# apphaven client SDK and REST API

Every app serves a small SDK. The frontend talks to its own origin, so there's no URL to configure
and no CORS.

```html
<!-- static site -->
<script type="module">
  import { createClient, filter } from "/_apphaven/sdk.js";
  const app = createClient();
</script>
```

```js
// Vite project (pre-installed during the build; add "@apphaven/client": "*" to dependencies)
import { createClient, filter } from "@apphaven/client";
const app = createClient();
```

## Auth

```js
const user = await app.auth.signUp({ email, password, name });   // password: 8+ characters; no session yet (below)
const user = await app.auth.signIn({ email, password });
await app.auth.signOut();
const user = await app.auth.getUser();      // { id, email, name, role, image } or null
const stop = app.auth.onChange((user) => render(user));   // now + on every sign in/out
```

Every password sign-up confirms its address first: `signUp` creates the account and emails a
confirmation link, but returns **no session** (`getUser()` stays null), so show "check your
email, then sign in". `signIn` answers 403 until the link is opened (each attempt before that
re-sends the link). Opening it confirms the address and normally signs the person in. In preview,
opening it on a device without Apphaven access confirms the address and asks them to return to
the test version and sign in. A duplicate address gets the same answer as a new one, not an
error, so don't look for a 422.

Errors throw `ApphavenError` with `.status`, `.code` and a readable `.message`
(e.g. wrong password → 401, address not confirmed yet → 403). Show `.message` to the user.

Email flows (links are emailed by apphaven; nothing to configure):

```js
await app.auth.requestPasswordReset({ email });   // emails a link; always resolves
// The link opens /_apphaven/reset-password?token=… (a plain page every app has). To use your
// own page: requestPasswordReset({ email, redirectTo: "/reset" }) and on that page:
await app.auth.resetPassword({ newPassword });     // token read from ?token= (or pass { token })

await app.auth.sendMagicLink({ email, name });     // auth.magic_link: true; opening the link signs in
                                                   // and returns to the current page (or callbackURL)
await app.auth.sendVerificationEmail({ email });   // re-sends the confirmation link
```

With `magic_link`, a sign-in form can be just an email field; the link confirms the address by
being opened. Say "check your email" after these calls. In preview, only the owner's **Apphaven
account email address** is eligible for delivery. Other recipients' mail is `captured` for
inspection: use `read_emails`, then read the matching message by `id` to get its link. Check
the actual status even for the owner; `failed`, `limited` and `suppressed` need attention, and
`sent` does not prove the message reached an inbox.

### Testing sign-in in preview

The tools open the private preview automatically. That access is separate from being signed
in inside the app. `as: "alice"` or `as: "bob"` signs in an already-confirmed test user; use
those accounts for authenticated screens and access rules. They do **not** test ordinary
sign-up, the confirmation email or the app's sign-in form.

Test the ordinary flow separately after deploying, leaving `as` out throughout:

1. Use `view_page` with `steps` to submit the app's real sign-up form. Use a fresh synthetic
   address such as `verification-1@example.invalid` and a test-only password, not the owner's
   credentials. Choose a new address for each run: a duplicate sign-up gives a generic answer.
   Do not use `@apphaven.test` for this check; those addresses are automatically confirmed.
   Check that the page asks the person to confirm their address and does not treat them as
   signed in yet.
2. Check the unconfirmed account cannot sign in. For the exact API response, use
   `test_api_write` with `method: "POST"`, `path: "/api/auth/sign-in/email"` and
   `body: { "email": "verification-1@example.invalid", "password": "<your test password>" }`.
   Inspect the error code/message as well as the status. This attempt can send another
   confirmation email.
3. Call `read_emails` for preview, find the latest confirmation message for that exact address,
   then call it with that message's `id`. `captured` is expected for this non-owner address.
   Open the confirmation link using `view_page`, passing its app-local path **and complete
   query string** as `path`. Keep tokens out of app code and chat messages. An expired link
   needs a fresh confirmation email, not a change to the auth backend.
4. In a new `view_page` call, use `steps` to fill and submit the app's real sign-in form with
   that account, then exercise an authenticated action and check the resulting screen in
   the **same call**. Check the phone layout too. A screenshot taken with `as` would bypass
   the sign-in form and would not establish that this flow works.

Each `view_page` call starts a fresh browser context; ordinary sign-in cookies do not carry
over to the next call. `test_api` / `test_api_write` also do not retain ordinary sign-in
cookies between calls. Address confirmation persists on the account, so step 4 can sign in
again; a later signed-out `test_api` call is not a check of that browser session.
The `view_page` report's signed-out/test-user label describes the starting identity; inspect
the steps and resulting UI to check a sign-in performed through the form.

Test password reset using the same mail inspection process, then sign in with the new test
password through the real form. If magic links are enabled, open the captured sign-in link
and check the authenticated UI within that `view_page` call. Its session will not survive
into a later call. The owner walkthrough and private-preview link rules are in the overview.

### Troubleshooting sign-in

Use the endpoint and response body to distinguish these cases; a status alone is insufficient.
`test_api` / `test_api_write` show the response body, while `view_page` reports failed requests
and visible page text. Keep the UI's error message useful instead of replacing every failure
with "Sign-in failed".

| Evidence | Next step |
| --- | --- |
| `401 preview_locked` | The owner must sign in to **Apphaven** to open the private preview. Tool calls get preview access automatically; if a tool still gets this error, report it rather than changing the app's auth provider. |
| An auth response says the email or password is invalid | Check the app, environment and account. Apphaven accounts, preview app accounts and live app accounts have separate credentials; resetting preview removes copied live passwords. Use the app's password-reset flow when needed. |
| An auth response says the email is not verified/confirmed (403) | Find that address's confirmation email with `read_emails`, open the link, then retry sign-in. Check the message and link before resending. |
| `403 forbidden` from a model or operation | Check the app user's role and the relevant access rule. This is not evidence that their email is unconfirmed. |
| Preview mail is `captured` | Expected for non-owner addresses. Inspect the message and follow its link with the tools; retrying will not make it arrive in that recipient's inbox. |
| Mail is `failed`, `limited` or `suppressed` | Read the mail log's error or limit/suppression detail and explain the specific problem. Do not assume the app needs an external email or auth provider. |

App auth supports email/password and optional magic links. Google/Apple sign-in on an app is
not currently supported; Google sign-in to the Apphaven platform does not enable it for apps.
Explain the limitation and offer a supported flow. See the overview before proposing an
external provider or changing where the app's accounts and data live.

## Data

`app.db.<Model>` for every model:

```js
const { items, has_more } = await app.db.Recipe.list({
  filter: "published == true",      // same language as rules; fields of this model only
  sort: "-created_at,title",        // '-' = descending; default -created_at
  limit: 20, offset: 0,             // default 50, max 200
  expand: ["author", "category"],   // include linked records under item.expand
});
const r = await app.db.Recipe.get(id, { expand: ["author"] });
const r = await app.db.Recipe.create({ title: "Soup", servings: 4 });
const r = await app.db.Recipe.update(id, { title: "Better soup" });   // partial update
await app.db.Recipe.delete(id);
```

Counts and totals (computed by the database, over the records the user may list):

```js
// votes per book, most votes first, with the book record attached
const { items } = await app.db.Vote.aggregate({ group_by: ["book"], sort: "-count", expand: ["book"] });
// items: [{ book: "id…", count: 7, expand: { book: { title, … } } }, …]
const { items: [totals] } = await app.db.Run.aggregate({ sum: ["km"], avg: ["minutes"], filter: "date >= '2026-10-01'" });
// totals: { count: 42, sum_km: 318.5, avg_minutes: 37.2 }
```

Use this instead of loading every record to count them. Options: `group_by`, `count` (default
true), `sum`, `avg` (number fields), `min`, `max`, `filter`, `sort` (by an output name, e.g.
`-count`, `-sum_km`), `limit` (default 100, max 1000), `expand` (group fields).

- Records come back as `{ id, created_at, updated_at, ...fields }`.
- `expand` on a `user` field gives `{ id, name, image }` (never the email). On a `ref` field it gives
  the linked record if the reader may `get` it, else `null`.
- **Use `filter` (tagged template) whenever values come from user input**, so quotes are escaped:
  ``app.db.Recipe.list({ filter: filter`contains(title, ${search})` })``
- Filters can use `$user.id` and `$now`, e.g. `"owner == $user.id"`. Access rules always apply on
  top of filters; you don't need to repeat them.
- Signed-out visitors can submit at most 20 writes a minute (contact forms, orders); more get 429
  `rate_limited` — show the message.
- Errors: 400 `validation_failed` (`.details` maps field → problem), 401 `unauthorized` (sign in),
  403 `forbidden`, 404 `not_found` (also when a rule hides the record), 409 `conflict`
  (unique value taken).

## Operations

```js
await app.ops.publishRecipe({ id });                      // params at the top level
await app.ops.quickNote({ data: { text: "hi" } });        // create/update input under `data`
const { items } = await app.ops.myDrafts({ limit: 20 });
```

## Files

`File` values in `create`, `update` and an operation's `data` are uploaded and checked first, then
saved by id: `app.db.Profile.update(id, { photo: input.files[0] })`. For progress, upload first
with `app.files.upload(file, { model, field, onProgress })`. Reads return each file with `url` and
`thumb_url`. Signed out, files go through a form (a create operation). All of it, with examples:
get_docs "files".

## Realtime

Several people looking at the same thing: don't poll. `app.realtime.channel("Game:" + id)`
carries changes to that record and to records pointing at it, broadcasts between the people on
it, and who is there. Subscribe, then load; the record comes along when the rules allow, else
fetch it. All of it, with a chat room and a game table: get_docs "realtime".

## REST API (what the SDK calls)

| method | path | body / query |
| --- | --- | --- |
| GET | `/api` | describes models, fields and operations |
| GET | `/api/me` | `{ user }` |
| POST | `/api/auth/sign-up/email` | `{ email, password, name }` |
| POST | `/api/auth/sign-in/email` | `{ email, password }` |
| POST | `/api/auth/sign-out` | `{}` |
| GET | `/api/models/:Model` | `?filter=&sort=&limit=&offset=&expand=a,b` |
| GET | `/api/models/:Model/:id` | `?expand=` |
| POST | `/api/models/:Model` | record JSON |
| PATCH | `/api/models/:Model/:id` | partial record JSON |
| DELETE | `/api/models/:Model/:id` | |
| GET | `/api/models/:Model/_aggregate` | `?group_by=a,b&sum=x&avg=y&min=&max=&count=false&filter=&sort=-count&limit=&expand=` |
| POST | `/api/ops/:name` | `{ ...params, data: {...} }` (GET with query params also works for reads) |

Send `Content-Type: application/json`. Sessions are cookies, so use `credentials: "include"` if
you call `fetch` yourself. Errors are `{ "error": { "code", "message", "details"? } }`.
