# Use Harakumo from a phone app

Harakumo does not build or publish phone apps. You build the app with Xcode, Android Studio, Expo or Flutter, and Apple and Google review it and ship it. Harakumo is the app's backend (sign-in for its users, an API you write as a function, a database, file storage, AI, mail and payments) and it hosts the small website the stores ask for.

The app holds one Harakumo value: the sign-in pool's client id. Everything else goes through your function, which keeps the keys.

## Who does what

| Who | What they do |
| --- | --- |
| You | Write the app (Swift, Kotlin, React Native, Expo or Flutter). Create a project with a sign-in pool and a function, put the client id in the app, and deploy a small companion website. |
| Apple and Google | Review, sign and distribute the app and its updates; run in-app purchases; deliver push notifications. |
| Harakumo | Sign-in for the app's users, the function that is the app's API, the database, file storage, AI, mail and payments behind it, and the companion website. |

## How it fits together

1. Sign-in: the app calls `https://harakumo.com/api/auth-pools/public/<clientId>/<action>` to sign up, log in, verify an email and reset a password, and gets back a token that lasts 7 days.
2. Everything else: the app calls your function (`https://hk-<project-slug>--<function-name>.harakumo.app`) and sends the token. The function checks it, then calls Harakumo's database, storage, AI, mail and payments APIs with keys it holds as environment variables.
3. Files: the function signs an upload or download link, and the app sends or fetches the bytes straight to storage.
4. Payments: the function creates a checkout link, the app opens it in the phone's browser, and the buyer comes back to the app through an https link.
5. The companion website: a static site on the same project serves the privacy, support and delete-account pages and the two app-link files.

> With an AI assistant connected (see AI connector), setup_app_backend creates what the app needs (a sign-in pool, a database, storage, a key limited to the project) and stores every value as a project environment variable.

## What may ship inside the app

Anything inside an app can be read by anyone who downloads it, however it is stored or hidden. Only the client id is made to be read.

| Value | In the app? | Why |
| --- | --- | --- |
| Client id (`ap_…`) | Yes | Publishable. It can only sign up (while public sign-up is on), log in, send and check emailed codes, reset a password and check a session, and only while app sign-in is on. |
| Client secret (`as_…`) | No, server only | It acts for the pool from your server, outside the limits the client-id endpoints have. |
| JWT secret (`js_…`) | No, server only | Whoever holds it can make a token for any user, and your function would accept it. |
| API keys (`hk_live_…`), read-only ones too | No, server only | A key reaches the project's data. Even a read-only key lists every user's email and signs download links for any file. |

## Turn on app sign-in

1. Create the pool: setup_app_backend from an AI assistant, Dashboard → the project → Auth, or `POST /api/projects/:id/auth`.
2. Turn on app sign-in: the switch "Allow sign-in from browsers and apps" on the project's Auth page, `PATCH /api/auth-pools/:id/settings { "publicEnabled": true }`, or the update_auth_settings tool (owner or admin). It is off by default; until it is on, every call to the client-id endpoints answers 403 `browser_access_off`.
3. If people create their accounts in the app, also turn on "Let anyone sign up" (`publicSignup: true`). It is a separate switch, off by default; until it is on, sign-up answers 403 `signup_closed` and log-in still works.
4. A native app sends no Origin header, so it needs no allowed origin. The allowed origins are for web pages, and for a website wrapped as an app (Capacitor, Ionic, Cordova, Tauri), which sends `capacitor://localhost`, `ionic://localhost`, `app://localhost` or `tauri://localhost`: add that one origin (`addOrigins` in update_auth_settings), or make the calls through native HTTP, which sends none.

**React Native or Expo**

```js
import * as SecureStore from 'expo-secure-store';

const AUTH = 'https://harakumo.com/api/auth-pools/public/ap_…';   // the client id: the only Harakumo value in the app

export async function logIn(email, password) {
  const r = await fetch(AUTH + '/login', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  const data = await r.json();
  if (!r.ok) throw new Error(data.error);   // data.code: invalid_credentials, locked, rate_limited …
  await SecureStore.setItemAsync('session', data.token);   // Keychain on iOS, Keystore on Android
  return data.user;
}

// On launch: still signed in?
export async function currentUser() {
  const token = await SecureStore.getItemAsync('session');
  if (!token) return null;
  const r = await fetch(AUTH + '/session', { headers: { authorization: 'Bearer ' + token } });
  if (r.status === 401) { await SecureStore.deleteItemAsync('session'); return null; }   // expired or revoked
  return (await r.json()).user;
}
```

**Swift**

```swift
let auth = URL(string: "https://harakumo.com/api/auth-pools/public/ap_…")!

struct Session: Decodable { let token: String }

func logIn(email: String, password: String) async throws -> String {
    var request = URLRequest(url: auth.appendingPathComponent("login"))
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(["email": email, "password": password])
    let (data, response) = try await URLSession.shared.data(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.userAuthenticationRequired) }
    return try JSONDecoder().decode(Session.self, from: data).token   // keep it in the Keychain
}
```

> Bodies are JSON with `content-type: application/json`. In Flutter, `jsonEncode` the body and set that header: a plain map is sent as a form and answers 400.

## Keep the token on the phone

- Store the token in the Keychain on iOS and in Keystore-backed storage on Android (expo-secure-store and flutter_secure_storage do this), never in AsyncStorage, SharedPreferences or a plain file.
- Send it to your function as `Authorization: Bearer <token>`. No cookies are involved.
- On launch, `GET …/session` with the token says who is signed in; 401 means sign in again.
- Tokens last 7 days and there are no refresh tokens yet, so people sign in again after a week.
- Signing out is deleting the token from the phone. To sign someone out on every device, your function calls `PATCH /api/auth-pools/:id/users/:userId { "action": "revoke-sessions" }`.

## The JWT secret stays on the server

Tokens are signed with the pool's JWT secret (HS256), so whoever holds it can make tokens. The app never checks a token itself; your function does, in one of two ways:

- Ask Harakumo: `POST /api/auth-pools/:id/verify { "token" }` with the client secret. It also refuses the tokens of deleted users and of users signed out everywhere.
- Check it in the function with the JWT secret from its environment: the HS256 signature, `iss` (the pool's issuer), `aud` (the client id) and `exp`. No call per request, but a revoked token is accepted until it expires.

## Your function is the app's API

One function can answer every screen of the app. It reads its settings as `env.NAME`, from the project's environment variables; the names below are the ones setup_app_backend stores. Functions deploy on every plan, including the free Hobby plan.

**Function**

```js
const API = 'https://harakumo.com/api';

function call(path, key, body) {
  return fetch(API + path, {
    method: 'POST',
    headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  }).then(r => r.json());
}

export default {
  async fetch(request, env) {
    // Who is calling: check the token the app sent.
    const token = (request.headers.get('authorization') || '').replace(/^Bearer /, '');
    const { valid, user } = token
      ? await call('/auth-pools/' + env.HARAKUMO_AUTH_POOL_ID + '/verify', env.HARAKUMO_AUTH_CLIENT_SECRET, { token })
      : { valid: false };
    if (!valid) return Response.json({ error: 'Sign in again' }, { status: 401 });

    const url = new URL(request.url);
    if (request.method === 'GET' && url.pathname === '/orders') {
      const { results } = await call('/databases/' + env.HARAKUMO_DATABASE_ID, env.HARAKUMO_API_KEY, {
        sql: 'SELECT id, total_cents, status FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20',
        params: [user.id],
      });
      return Response.json({ orders: results });
    }
    if (request.method === 'POST' && url.pathname === '/avatar') {
      const { size } = await request.json();   // the photo's length in bytes, from the app
      const signed = await call('/storage/' + env.HARAKUMO_BUCKET_ID, env.HARAKUMO_API_KEY, {
        key: 'users/' + user.id + '/avatar.jpg', method: 'put', contentType: 'image/jpeg',
        size, maxBytes: 5 * 1024 * 1024,
      });
      if (!signed.url) return Response.json({ error: signed.error }, { status: 400 });
      return Response.json({ upload: signed.url });   // the app PUTs exactly size bytes there, Content-Type: image/jpeg
    }
    return Response.json({ error: 'Not found' }, { status: 404 });
  },
};
```

> Every query names its user (`WHERE user_id = ?`): the function is the only thing between one user and another user's data. HARAKUMO_API_KEY is limited to the project, and it also sends mail and calls AI for the project; which key each service takes is in API keys & authentication.

## Files

1. The app asks your function for an upload link and says how big the file is. The function picks the key (for example `users/<id>/avatar.jpg`) and signs a PUT with `POST /api/storage/:id { "key", "method": "put", "contentType", "size" }`.
2. The app starts the PUT within 15 minutes, with the same Content-Type and exactly that many bytes. It is one plain request, so it works as a background upload on iOS and Android.
3. For downloads the function signs a GET link: 15 minutes by default, up to 7 days. Sign a long link once and reuse it for pictures many people see.

> With `size`, the link accepts exactly that many bytes, so nobody who gets hold of it can upload more; `maxBytes` refuses a bigger file before any link is made (413 `file_too_large`). `size` is optional, but a link without it takes any file of up to 5 GB from whoever holds it, so a function signing for an app always sends it. Upload links last at most 15 minutes. One upload is at most 5 GB (with `size`, 1 GB on Hobby) in a single request, with no resume yet: a dropped upload starts again with a new link. Sign links only for signed-in users, one key at a time.

## Push notifications

Harakumo has no push service yet. Send from a function through the store push services: keep each device's push token in a table of your database next to its user, and have a function send through Apple Push Notification service, Firebase Cloud Messaging or Expo's push service, with their keys stored as project environment variables.

Functions run only when called (no schedules yet), so a notification goes out when something calls the function, such as a payment webhook or another user's action, not on a timer.

## Payments in a store app

- Harakumo checkout takes one-off card payments in US dollars. In a store app it fits physical goods and real-world services: orders, food, bookings, tickets, deposits, fees and one-to-one sessions.
- Digital goods used inside the app (subscriptions, credits, premium features, game currency) must be sold through Apple's in-app purchase and Google Play Billing, with exceptions that depend on the country and on each store's programs, such as linking out to a web purchase in the US. Check each store's current rules before you link out. Harakumo does not check store purchases yet; record what each user bought from your function.
- Open the checkout link in the phone's browser or its in-app browser sheet (SFSafariViewController on iOS, Custom Tabs on Android; expo-web-browser and url_launcher do this), never in a web view of your own: wallets and bank checks can fail there.
- `successUrl` must be http or https. Point it at a page on your companion website that is also a universal link (iOS) or app link (Android), so the phone opens the app; the page itself says "Back to the app" for anyone without it.
- Reaching the success page is not proof of payment. Your function confirms it with the session lookup or the payment.succeeded webhook, then records the purchase.
- Refunds are made by the workspace owner in the dashboard (the project's Payments page), not by the app: API keys get 403 `payouts_require_owner`. An admin screen in the app can link there.

## The companion website

Both stores ask for web pages, and app links need two files on your domain. Put them in a static site on the same project; static sites deploy on every plan, including Hobby.

The site can deploy from the app's own repository: a site/ or docs/ folder with an index.html is served as the site, or an Expo app exports its web version with a build script (`"build": "expo export -p web"`). A Flutter web build is not run here, because the build machines have no Flutter toolchain: run `flutter build web` on your machine and upload the `build/web` folder.

A repository that holds only the phone app is not deployed: when its start script only runs a phone development server (`expo start`, `expo run:ios`, `react-native start`, `flutter run` and the like, also through `npm run`, `yarn` or `pnpm`) or there is none, the deploy is refused with code `phone_app` before anything builds. A start script that runs a real server (for example an Expo app's server output on Express) deploys as a server app, on Pro and up.

| Path | What it is for |
| --- | --- |
| `/privacy` | The privacy policy: linked in App Store Connect, in Play Console and inside the app. |
| `/support` | The support URL App Store Connect asks for: how people reach you. |
| `/delete-account` | How someone deletes their account and how long it takes. Google Play asks for a link that works after the app is uninstalled, so it can take the request itself: a form your function receives. |
| `/.well-known/apple-app-site-association` | Universal links on iOS: your Team ID and bundle id, as JSON, in a file with no extension. |
| `/.well-known/assetlinks.json` | App links on Android: your package name and the SHA-256 fingerprint of the Play App Signing key, not your upload key. |

> Each address works without `.html`: `/privacy` is served from `privacy.html` (what Next.js and Expo static exports write), and `/privacy/` redirects there, so a page needs no folder of its own (`privacy/index.html`). Give the stores the address without a trailing slash. `apple-app-site-association` is served as `application/json` though it has no extension. Files under `/.well-known/` are served; other dotfiles are not. A single-page app that wants every route answered with its index.html adds a `_redirects` file with the one rule `/* /index.html 200`; that fallback never answers under `/.well-known/` or for a path with a file extension, so the app-link files are the real files or a real 404.

## Let people delete their account

Both stores require that people who create an account in an app can delete it from the app, and Google Play also asks for the web link. No call lets a user delete their own account yet, so your function does it:

1. The app, or the form on `/delete-account`, calls your function. The function checks who is asking: the token from the app, or the email and password from the form with a login call (`POST /api/auth-pools/:id { "action": "login" }` with the client secret).
2. It deletes the user with `DELETE /api/auth-pools/:id/users/:userId` and the project's key; the verify call refuses their tokens from then on.
3. It deletes the user's rows and files, and the app or the page says it is done.

## Limits today

- Database: every query from your function is an HTTPS call through Harakumo, and a workspace may make 600 per 5 minutes, shared by database queries, vector calls, semantic memory and video status checks. All workspaces together also share one platform ceiling on these calls, and it is lower than one workspace's own allowance, so the platform as a whole can also refuse calls before your workspace reaches its number, and sooner while other workspaces are busy (429 `rate_limited`, with Retry-After). So do not plan on a steady rate per second. That suits a beta with a few testers, not yet an app many people use at once. Batch statements (a batch is one call) and cache what you can in the function; direct database connections for functions are planned.
- Sign-in tokens last 7 days, with no refresh tokens yet: people sign in again every week.
- No account self-deletion yet: build it in your function, as above.
- No Sign in with Apple or Google yet: email and password only. Apple asks for Sign in with Apple only when an app also offers another company's sign-in, so an email-only app is not affected.
- Emailed codes asked for from the app may use at most 20% of the workspace's 30-day email quota: 20 a month on Hobby.
- No push service, and functions have no logs and no schedules yet.
- Checkout is one-off payments in US dollars, with no subscriptions, and nothing checks App Store or Google Play purchases yet.

## Reference

**REST**

```http
# From the app: the client id only
POST   /api/auth-pools/public/:clientId/signup | login | send-verification | verify-email | forgot-password | reset-password
GET    /api/auth-pools/public/:clientId/session      Authorization: Bearer <token>

# From your function: secrets in its environment
POST   /api/auth-pools/:id/verify                     { "token" }                                      # Bearer client secret
POST   /api/auth-pools/:id                            { "action": "login", "email", "password" }       # Bearer client secret
POST   /api/databases/:id                            { "sql", "params"? }                             # Bearer API key
POST   /api/storage/:id                               { "key", "method": "put"|"get", "contentType"?, "size"?, "maxBytes"? }
POST   /api/payments/:accountId                       { "amountCents", "reference"?, "successUrl"? }
GET    /api/payments/:accountId/sessions/:sessionId   → { paid, payment, session }
DELETE /api/auth-pools/:id/users/:userId              # account deletion

# Once, by a developer or above (the connector tool, update_auth_settings: owner or admin)
PATCH  /api/auth-pools/:id/settings                   { "publicEnabled": true, "publicSignup"?: true, "allowedOrigins"?: [] }
```
