# App accounts

How people sign up and sign in to a generated app, what a session is, and the auth endpoints behind it.

## Overview

Ripping Auth gives each app its own accounts: email and password, or Google. Accounts belong to the project (`app_users`), not to Ripping's own users. The app keeps a session token in `localStorage` under `auth.token` and sends it as `Authorization: Bearer <token>` to every route under `/api/apps/<projectId>/`.

`SITE.accounts: false` removes all of this: no sign-in screens, no `useAuth` state, no per-account data.

## useAuth

```ts
type User = { id: string; email: string; name: string | null; provider: "password" | "google"; role?: "admin" | "member" };

function useAuth(): {
  user: User | null; loading: boolean;
  signIn: typeof signIn; signUp: typeof signUp; signInWithGoogle: typeof signInWithGoogle; signOut: typeof signOut;
  requestPasswordReset: typeof requestPasswordReset; updateProfile: typeof updateProfile;
  changePassword: typeof changePassword; deleteAccount: typeof deleteAccount;
};
```

`loading` is `true` until the saved token has been checked. On first use the app calls `GET auth/me`; if the network is down it decodes the user from the token instead, so the app opens signed in while offline. `role` only arrives from `me`, so it is missing offline.

`role` is `"admin"` for people the owner marked as admins on the project's Users tab on Ripping. Admins see every inbox record and every upload, and may edit or delete any record.

## Functions

```ts
function signIn(email: string, password: string): Promise<void>;
function signUp(email: string, password: string, name?: string): Promise<void>;
function signInWithGoogle(): void;
function signOut(): void;
function requestPasswordReset(email: string): Promise<void>;   // resolves the same way whether or not the account exists
function updateProfile(p: { name: string }): Promise<void>;
function changePassword(currentPassword: string, password: string): Promise<void>;
function deleteAccount(password?: string): Promise<void>;      // password accounts must confirm with the password
function getToken(): string | null;
```

Each one throws an `Error` whose message is the server's `error` field, or `Something went wrong. Try again.` when there was none. A `401` on a signed-in call (`update`, `delete`) clears the token and sets `user` to `null`.

`signInWithGoogle` opens a 480×640 popup at `GET auth/google?return=<current page>`; when the popup is blocked it navigates the page instead. The callback posts `{ source: "ripping-auth", token, user }` to the opener, or sends the browser back to the return address with `#ripping_token=<token>`, which `auth.ts` reads and strips on start.

`signOut` only forgets the token. Sessions are stateless; there is nothing to sign out of on the server.

Saving or clearing a token fires a `window` event named `auth:change`. `store.ts`, `shared.ts` and `files.ts` listen for it and switch to the new account's data.

## Sessions

A session token is a JWT signed with HS256 using a key derived per project. Its payload carries `sub` (user id), `pid` (project id), `email`, `name`, `provider`, `ver` (the account's token version), `iat` and `exp`.

- A token lasts **30 days** from issue.
- It is bound to the project: a token from one app is refused by another.
- The server checks the account on every request that needs one: the account must exist, be `active`, and its `token_version` must equal the token's `ver`. Resetting a password bumps the version, so every older session stops working at once. Blocking an account on Ripping does the same.

## Sign-up modes

The owner chooses who may create an account. The mode is stored on the project (`app_signup`) and read on every sign-up.

| Mode | What happens on sign-up |
| --- | --- |
| `open` (default) | The account is created active and signed in. |
| `invite` | Only an email the owner invited may sign up; the invite is marked accepted. Anyone else gets `This app is invite-only. Ask its owner for an invite.` |
| `approval` | An invited email is active at once. Any other email is created `pending` and gets `Thanks for signing up. The owner of this app approves new accounts; you'll get an email when you can sign in.` A pending account cannot sign in until approved. |

A blocked account gets `This account can't sign in to this app.` on sign-in.

The owner's plan also caps users per app. When the cap is reached, sign-up fails with `This app isn't taking new accounts right now.`

## Password rules

`checkPassword` runs on the server for sign-up, reset and change. It refuses:

- fewer than 8 characters (`Use at least 8 characters.`)
- one of a short list of common passwords
- a password containing the local part of the person's email, when that part is 4+ characters (`Do not put your name or email in your password.`)
- one repeated character, or a keyboard run such as `qwertyui`
- anything scoring 1 out of 4: short and low-variety (`This would not take long to guess. Make it longer — a few ordinary words together works better than a short jumble.`)

Length counts more than variety: 16 characters of anything scores 4.

## Endpoints

All at `POST /api/apps/<projectId>/auth/<action>` with a JSON body, except `me` and `google` which are `GET`. CORS is open. Every error is `{ "error": "<message>" }`.

### Rate limits

Per IP per minute: `signup` 10, `forgot` 5, `reset` 10, `delete` 10, everything else 20. Over the limit: `429 Too many attempts. Try again in a minute.`

Per email address per hour, for `signin`, `forgot` and `reset`: 10. Over the limit: `429 Too many attempts for this account. Try again later.`

### me

```http
GET /api/apps/<projectId>/auth/me
Authorization: Bearer <token>
```

```json
{ "user": { "id": "…", "email": "ada@example.com", "name": "Ada", "provider": "password", "role": "member" } }
```

A deleted, blocked or pending account, an expired token or a bumped version returns `401 { "user": null }`.

### signup

```http
POST /api/apps/<projectId>/auth/signup
Content-Type: application/json

{ "email": "ada@example.com", "password": "correct horse battery", "name": "Ada" }
```

```json
{ "token": "<jwt>", "user": { "id": "…", "email": "ada@example.com", "name": "Ada", "provider": "password" } }
```

`name` is trimmed to 80 characters. Failures: `400 Enter a valid email.`, `400 <password problem>`, `409 That email already has an account. Sign in instead.`, `403 <sign-up mode message>`, `403 <pending message>`.

### signin

```http
POST /api/apps/<projectId>/auth/signin

{ "email": "ada@example.com", "password": "…" }
```

Same reply as `signup`. Failures: `400 Enter a valid email.`, `401 Wrong email or password.`, `403` when the account is pending or blocked.

### forgot

```http
POST /api/apps/<projectId>/auth/forgot

{ "email": "ada@example.com", "return": "https://myapp.example/#/signin" }
```

```json
{ "ok": true }
```

Always `ok`, so the form cannot reveal who has an account. When the account exists, an email carries a link to a page Ripping hosts (`/apps/<projectId>/reset?token=…`) that works for one hour. `return` is only kept when it points at the app's own address; the reset page then offers a way back.

### reset

```http
POST /api/apps/<projectId>/auth/reset

{ "token": "<from the email>", "password": "a new long password" }
```

Signs in with a fresh token and a bumped version. Failures: `400 This reset link is incomplete.`, `400 <password problem>`, `400 This reset link has expired or was already used. Ask for a new one.`, `403` when the account is pending or blocked.

### update

```http
POST /api/apps/<projectId>/auth/update
Authorization: Bearer <token>

{ "name": "Ada L." }
```

or `{ "currentPassword": "…", "password": "…" }`. Returns a new `{ token, user }`. Failures: `401 Sign in again to do that.`, `401 That account no longer exists.`, `400 Your current password is wrong.`, `400 <password problem>`, `400 Nothing to change.`, `500 Could not save. Try again.`

### delete

```http
POST /api/apps/<projectId>/auth/delete
Authorization: Bearer <token>

{ "password": "…" }
```

```json
{ "ok": true }
```

Deletes the account and its `app_data` rows (cascade). A password account must send the password: `400 That password is wrong.` Other failures: `401 Sign in again to do that.`, `500 Could not delete the account. Try again.`

### google

```http
GET /api/apps/<projectId>/auth/google?return=<absolute URL>
```

Redirects to Google, then back. `return` must be `https` and match one of: the project's published URL origin, a verified custom domain, its publish subdomain, or `localhost` (where `http` is allowed). Anything else gets a `400` page: `This sign-in can only return to the app's own address. Open the app from its published link and try again.` When no Google client is configured the page is `503 Google sign-in isn't configured on this platform yet.` The Google account's email must be verified. The owner may supply their own Google client on Ripping; otherwise Ripping's is used.
