@binary1702/auth 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +324 -36
  5. package/README.md +279 -51
  6. package/dist/cli.js +232 -17
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +131 -10
  14. package/dist/core/db.js +388 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +11 -1
  32. package/dist/core/session.js +27 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +10 -3
  44. package/dist/next/index.js +444 -52
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +11 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. package/package.json +16 -3
package/README.md CHANGED
@@ -6,9 +6,9 @@ Email + password auth for Next.js. Plug and play.
6
6
  npx @binary1702/auth
7
7
  ```
8
8
 
9
- One command. It installs itself, writes two env vars, writes two files, creates the table, creates your first user. Then you move on.
9
+ One command. It installs itself, writes the secrets to `.env.local`, writes two files and a settings file, creates the tables, creates your first user. Then you move on.
10
10
 
11
- Letting an AI agent do the install? Paste [PROMPT.md](./PROMPT.md) into it. Same steps, written for an agent, with the checks to run and what to report back. [INSTALL.md](./INSTALL.md) is the short human version.
11
+ Letting an AI agent do the install? Paste [PROMPT.md](./PROMPT.md) into it. Same steps, written for an agent, with the checks to run and what to report back. [INSTALL.md](./INSTALL.md) is the short human version. An agent working *inside* an app that already has it reads [AGENTS.md](./AGENTS.md).
12
12
 
13
13
  ---
14
14
 
@@ -20,7 +20,7 @@ cd my-app
20
20
  npx @binary1702/auth
21
21
  ```
22
22
 
23
- You will be asked for one thing: your Postgres connection string (Neon). Everything else is automatic.
23
+ You will be asked for your Postgres connection string (Neon) and, optionally, how to send email (Enter to skip). Everything else is automatic.
24
24
 
25
25
  ```
26
26
  @binary1702/auth
@@ -28,9 +28,11 @@ You will be asked for one thing: your Postgres connection string (Neon). Everyth
28
28
  ✓ @binary1702/auth installed
29
29
  ✓ DATABASE_URL → .env.local
30
30
  ✓ AUTH_SECRET generated → .env.local
31
+ · email not configured: dev prints emails to the console
31
32
  ✓ app/auth/[...path]/route.ts
32
33
  ✓ proxy.ts
33
- ✓ auth_users table ready
34
+ ✓ b17_auth.yaml written: app name, sign-up policy, email, permissions
35
+ ✓ database schema ready (4 migrations)
34
36
 
35
37
  Create the first user:
36
38
  Email: ben@example.com
@@ -49,39 +51,142 @@ Run `npm run dev`, open the site, you are redirected to the sign-in page. Sign i
49
51
 
50
52
  ## Use it in your code
51
53
 
52
- Every page and API route is now behind sign-in. To know who the user is:
54
+ Every page and API route is now behind sign-in. To know who is asking:
53
55
 
54
56
  ```ts
55
- import { requireUser } from "@binary1702/auth/next"
57
+ import { getSession } from "@binary1702/auth/next"
56
58
 
57
59
  export async function GET() {
58
- const user = await requireUser() // { id, email }
60
+ const session = await getSession() // { user, organization, authorization } or null
59
61
  // ...
60
62
  }
61
63
  ```
62
64
 
63
- That is the only line you ever write by hand.
65
+ That is the only line you ever write by hand. `requireSession()` throws 401 instead of returning null, `requirePermission("invoices.create")` throws 403 when the role lacks it, and `getUser()` / `requireUser()` give just `{ id, email }`. All work in route handlers, server components, and server actions; `useAuth()` from `@binary1702/auth/react` gives the same object to client components. See [The session contract](#the-session-contract).
66
+
67
+ ---
68
+
69
+ ## What you get
70
+
71
+ Two kinds of URL under `/auth/`. **Pages** are HTML you open in a browser; they carry forms, and the forms POST back to the same path. **API** calls are JSON for code.
72
+
73
+ **Pages (open in a browser)**
74
+
75
+ | URL | What you see |
76
+ |---|---|
77
+ | `/auth/sign-in` | The sign-in form. `?verify=1` shows "check your email" with a resend button; `?verified=1` shows "email verified". |
78
+ | `/auth/sign-up` | The registration form. Closed unless `signup: open` (or `invite`) in `b17_auth.yaml`. See [Users](#users). |
79
+ | `/auth/sign-out` | Not a page: clears the cookie and redirects to sign-in. Link to it. |
80
+ | `/auth/verify?token=…` | The link in the verification email. Marks the email verified and redirects. |
81
+ | `/auth/forgot` | "Send me a reset link". Always answers the same, whether or not the address exists. |
82
+ | `/auth/admin` | Users, organizations, roles: what the CLI does, as a page. Only for emails under `admins:` in `b17_auth.yaml`. |
83
+ | `/auth/reset?token=…` | The link in the reset email. New password, then you are signed in; every older session of yours is ended. |
84
+
85
+ **API (JSON, for code)**
86
+
87
+ | Call | Does | Answers |
88
+ |---|---|---|
89
+ | `GET /auth/session` | the [session contract](#the-session-contract) | `200 { user, organization, authorization }` or `401` |
90
+ | `POST /auth/session` | switch the active organization `{ organization: "<id>" }` | the new contract, or `403` if not a member |
91
+ | `POST /auth/sign-in` | `{ email, password }` | `200 { user }` + cookie, `401`, or `403 { verify: true }` |
92
+ | `POST /auth/sign-up` | `{ email, password, invite? }` | `200 { user }` + cookie, or `400 / 403 / 404 / 409 { error }` |
93
+ | `POST /auth/sign-out` | clears the cookie | `200 { ok: true }` |
94
+ | `POST /auth/verify/resend` | `{ email }`: emails a fresh verification link | `200 { ok: true }`, always |
95
+ | `POST /auth/forgot` | `{ email }`: emails a reset link (1 h) if the address exists | `200 { ok: true }`, always |
96
+ | `POST /auth/reset` | `{ token, password }` | `200 { user }` + cookie, or `400 { error }` |
97
+
98
+ The same POST paths accept form posts too (that is what the pages do); a form post gets a redirect instead of JSON. Every POST refuses cross-site requests with `403`.
99
+
100
+ Guessing is rate limited per fixed window, in the database so it holds across instances: sign-in 20 per IP and 10 per email every 15 minutes, sign-up 10 per IP an hour, reset and verification emails 3 per address an hour. Over the limit answers `429` with a `Retry-After` header (and the message on the form). The IP is the first `x-forwarded-for` entry, else `x-real-ip`, else "unknown", so behind a proxy that sets neither, every visitor shares one bucket.
101
+
102
+ Everything else on the site requires a session. Unauthenticated page requests redirect to `/auth/sign-in?next=<where they were going>`. Unauthenticated `/api/*` requests get a `401` JSON response.
103
+
104
+ ---
105
+
106
+ ## The session contract
107
+
108
+ One endpoint, one shape, three keys. The same object comes from `GET /auth/session` in the browser, `getSession()` on the server, and `useAuth()` in React.
109
+
110
+ ```json
111
+ {
112
+ "user": { "id": "4f1c…", "email": "ben@binary1702.com" },
113
+ "organization": { "id": "9a2e…", "name": "Binary1702" },
114
+ "authorization": { "role": "admin", "permissions": ["invoices.view", "invoices.create"] }
115
+ }
116
+ ```
117
+
118
+ ```
119
+ user who: from the cookie, checked against the database on every call
120
+ organization where: the user's active organization, or null if they are in none
121
+ authorization what: the role of their membership, and the permissions that role maps to
122
+ ```
123
+
124
+ Signed out, revoked, or unverified while `AUTH_REQUIRE_VERIFIED` is on: the endpoint answers `401`, the helpers return `null`. The JSON is sent with `Cache-Control: no-store` and never contains the token.
125
+
126
+ **Server** (route handlers, server components, server actions):
64
127
 
65
128
  ```ts
66
- import { getUser } from "@binary1702/auth/next"
129
+ import { getSession, requireSession, requirePermission, unauthorized } from "@binary1702/auth/next"
130
+
131
+ const session = await getSession() // contract or null
132
+ const session = await requireSession() // contract or throws AuthError(401)
133
+ const session = await requirePermission("invoices.create") // contract or throws AuthError(401 | 403)
134
+
135
+ export async function POST() {
136
+ try {
137
+ const { user, organization } = await requirePermission("invoices.create")
138
+ // ...
139
+ } catch (e) {
140
+ return unauthorized(e) // 401 or 403 JSON for AuthError, rethrows anything else
141
+ }
142
+ }
143
+ ```
144
+
145
+ `getUser()` / `requireUser()` still exist and return just the `user` part. All of them run the same check, so there is one place where "is this session good" is decided.
146
+
147
+ **React** (rendering only; the server still checks every protected operation):
67
148
 
68
- const user = await getUser() // { id, email } or null
149
+ ```tsx
150
+ // app/layout.tsx (server): pass the session down, no extra request
151
+ import { getSession } from "@binary1702/auth/next"
152
+ import { AuthProvider } from "@binary1702/auth/react"
153
+
154
+ export default async function Layout({ children }) {
155
+ return <AuthProvider session={await getSession()}>{children}</AuthProvider>
156
+ }
157
+
158
+ // any client component
159
+ import { useAuth } from "@binary1702/auth/react"
160
+
161
+ const { user, organization, authorization } = useAuth()
69
162
  ```
70
163
 
71
- Works in route handlers, server components, and server actions.
164
+ Without a `session` prop, `<AuthProvider>` fetches `GET /auth/session` once on mount; `useAuth()` also gives `loading` and `refresh()`.
72
165
 
73
- ---
166
+ **Organizations and roles** live in two tables the package owns, `orgs` and `members (org_id, user_id, role)`. A user can be in several; the session carries the *active* one: the one they last switched to, else the earliest membership. Switching is `POST /auth/session { organization }`, and the server checks the membership, so a client can never pick an organization it is not in.
74
167
 
75
- ## What you get
168
+ ```bash
169
+ npx @binary1702/auth add-org "Binary1702"
170
+ npx @binary1702/auth add-member ben@binary1702.com Binary1702 admin # role defaults to "member"
171
+ npx @binary1702/auth remove-member ben@binary1702.com Binary1702
172
+ npx @binary1702/auth list-orgs
173
+ ```
76
174
 
77
- | URL | What it does |
78
- |---|---|
79
- | `/auth/sign-in` | The sign-in page. Served by the package, no React, no styling work. |
80
- | `/auth/sign-up` | Self registration. Closed unless `AUTH_SIGNUP=open`. See [Users](#users). |
81
- | `/auth/sign-out` | Clears the session and sends you back to sign-in. Link to it. |
82
- | `/auth/session` | `{ user }` or `{ user: null }` as JSON, for client code. |
175
+ **The admin page**, `/auth/admin`, does the same as those commands from the browser: add a user, set a password, sign someone out everywhere, mark an email verified, remove a user; create an organization, add a member with a role, change a role, remove a member or an organization. Beside each membership it shows the permissions that role resolves to, so the two layers are visible side by side: the membership row says *who is what, where*; `b17_auth.yaml` says *what a role means*. The page never edits the yaml. Who may open it:
83
176
 
84
- Everything else on the site requires a session. Unauthenticated page requests redirect to `/auth/sign-in?next=<where they were going>`. Unauthenticated `/api/*` requests get a `401` JSON response.
177
+ ```yaml
178
+ admins: ben@binary1702.com ops@binary1702.com
179
+ ```
180
+
181
+ An admin cannot remove or sign out the account they are using.
182
+
183
+ **Permissions** are the app's vocabulary, so they come from one env var: a JSON map from role to permissions. `"*"` means everything, `"invoices.*"` everything under that prefix.
184
+
185
+ ```
186
+ AUTH_PERMISSIONS={"owner":["*"],"admin":["invoices.*"],"member":["invoices.view"]}
187
+ ```
188
+
189
+ A role missing from the map has no permissions. An invalid value throws at the first session check, so a typo cannot silently lock everyone out.
85
190
 
86
191
  ---
87
192
 
@@ -100,16 +205,18 @@ npx @binary1702/auth sign-out-everywhere ben@example.com # end every sess
100
205
  npx @binary1702/auth list-users
101
206
  ```
102
207
 
103
- **Self sign-up at `/auth/sign-up`**, controlled by one env var:
208
+ **Self sign-up at `/auth/sign-up`**, controlled by one key in `b17_auth.yaml`:
104
209
 
105
- | `.env.local` | Who can sign up |
210
+ | `b17_auth.yaml` | Who can sign up |
106
211
  |---|---|
107
- | nothing set | Nobody. Except on a fresh install with zero users: `/auth/sign-in` sends you to create the first account. |
108
- | `AUTH_SIGNUP=open` | Anyone. The sign-in page gets a "Create one" link. |
109
- | `AUTH_SIGNUP=open` + `AUTH_INVITE_CODE=secret` | Anyone who knows the code. The form gets an invite field. |
110
- | `AUTH_EMAIL_DOMAIN=example.com` | Combine with any of the above: only `@example.com` emails can sign up. |
212
+ | `signup: closed` (the default) | Nobody. Except on a fresh install with zero users: `/auth/sign-in` sends you to create the first account. |
213
+ | `signup: open` | Anyone. The sign-in page gets a "Create one" link. |
214
+ | `signup: invite` + `inviteCode: ${AUTH_INVITE_CODE}` | Anyone who knows the code (kept in `.env.local`). The form gets an invite field. |
215
+ | `emailDomain: example.com` | Combine with either: only `@example.com` emails can sign up. |
216
+
217
+ (`AUTH_SIGNUP=open`, `AUTH_INVITE_CODE` and `AUTH_EMAIL_DOMAIN` as env vars still work and override the file.)
111
218
 
112
- Rules on the form: a valid email address, password at least 8. Accounts are created and signed in immediately. Nothing to approve.
219
+ Rules on the form: a valid email address, password 8 to 256 characters. Accounts are created and signed in immediately, and a verification link is emailed; with `requireVerified: true` the session waits for that link. Nothing to approve.
113
220
 
114
221
  Programmatic: `POST /auth/sign-up` as JSON `{ email, password, invite? }` returns `200 { user }` and the cookie, or `400 / 403 / 404 / 409` with `{ error }`.
115
222
 
@@ -133,24 +240,115 @@ import { proxy } from "@binary1702/auth/next/proxy"
133
240
  export default proxy
134
241
 
135
242
  export const config = {
136
- matcher: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
243
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
137
244
  }
138
245
  ```
139
246
 
140
- The matcher is inline because Next reads it statically at build time and ignores a re-exported one.
247
+ The matcher is inline because Next reads it statically at build time and ignores a re-exported one. It only skips Next's build output; the proxy itself lets through `/auth/*` and static files by extension (`.png`, `.css`, `.svg`, …), never anything under `/api/`.
248
+
249
+ **Upgrading from 0.2.5 or earlier:** replace the matcher in your `proxy.ts` with the one above. The old one skipped any path starting with `auth` (`/authors`) and any path with a dot (`/api/export.csv`), so those were not guarded. Re-running `npx @binary1702/auth` detects the old matcher and tells you.
250
+
251
+ Sign-in, sign-up and sign-out POSTs from another website are refused with `403` (checked via the browser's `Sec-Fetch-Site` / `Origin` headers), so no other site can sign your visitors in or out.
141
252
 
142
253
  If you already had a `proxy.ts`, the CLI leaves it alone and prints the snippet for you to merge.
143
254
 
144
255
  ---
145
256
 
257
+ ## b17_auth.yaml
258
+
259
+ One file in the project root for everything the app decides. The CLI writes it on install; you edit it. Secrets never go in it: they stay in `.env.local` and appear here as `${NAME}` references.
260
+
261
+ ```yaml
262
+ name: Binary1702 # page titles, email subjects
263
+ signup: open # open | closed | invite
264
+ protected: /app/* /api/* # only these need sign-in; everything else is open. * = anything below
265
+
266
+ database: ${DATABASE_URL}
267
+ secret: ${AUTH_SECRET}
268
+
269
+ email:
270
+ from: NoReply (Binary1702) <no-reply@binary1702.com>
271
+ replyTo: support@binary1702.com # optional
272
+ gmail: # or resend: { apiKey: ${AUTH_RESEND_API_KEY} }
273
+ user: ben@binary1702.com
274
+ clientEmail: ${AUTH_GMAIL_CLIENT_EMAIL}
275
+ privateKey: ${AUTH_GMAIL_PRIVATE_KEY}
276
+
277
+ permissions: # role: what it may do. all = everything, x.* = a prefix
278
+ owner: all
279
+ admin: all
280
+ member: invoices.* reports.read
281
+
282
+ organizations: # overrides by organization name
283
+ Acme:
284
+ member: invoices.read
285
+ ```
286
+
287
+ | Key | Default | |
288
+ |---|---|---|
289
+ | `name` | the request's host | shown in `<title>` and email subjects |
290
+ | `signup` | `closed` | `invite` also needs `inviteCode: ${AUTH_INVITE_CODE}` |
291
+ | `emailDomain` | any | only these addresses may sign up |
292
+ | `requireVerified` | `false` | no session until the verification link is clicked |
293
+ | `sessionDays` | `7` | cookie lifetime |
294
+ | `protected` | none: everything guarded | only these paths need sign-in; the rest is open. `/x/*` covers `/x` and everything below |
295
+ | `public` | none | the reverse: everything needs sign-in except these. Use one or the other. |
296
+ | `admins` | none | emails that may open `/auth/admin` |
297
+ | `email` | console in dev | see [Email](#email) |
298
+ | `permissions`, `organizations` | none | see [The session contract](#the-session-contract) |
299
+
300
+ The format is a small subset of YAML: `key: value`, sections by indentation, `#` comments. No arrays: a list is words separated by spaces. Mistakes are reported with the line number at startup.
301
+
302
+ **How it resolves.** Built-in defaults, then the file, then env vars on top. `${NAME}` is replaced from the environment when the file is read; a missing variable is an error that names the key. The keys `database`, `secret`, `inviteCode`, `privateKey`, `clientSecret`, `refreshToken` and `apiKey` must be references: the CLI refuses a literal there unless the file is git-ignored. Without a file, the env vars below work exactly as they always did, so an existing install changes nothing.
303
+
304
+ **`protected` and the proxy.** The matcher in `proxy.ts` is plumbing that says "run the guard on everything"; it never changes. The guard reads `protected:` (or `public:`) from this file and decides per request. With neither key, everything needs sign-in, as before. Next 16 runs the proxy in Node, so this just works. On Next 15, add `runtime: "nodejs"` to the `config` export in `middleware.ts` (and `experimental.nodeMiddleware: true` in `next.config`), since the edge runtime cannot read files.
305
+
306
+ ---
307
+
146
308
  ## Env vars
147
309
 
148
310
  | Var | What | Who sets it |
149
311
  |---|---|---|
150
312
  | `DATABASE_URL` | Postgres connection string | You, once, when the CLI asks |
151
313
  | `AUTH_SECRET` | Signs the session cookie | The CLI generates it |
314
+ | `AUTH_GMAIL_USER` + `AUTH_GMAIL_CLIENT_EMAIL` + `AUTH_GMAIL_PRIVATE_KEY` | Send email through the Gmail API as that mailbox. See [Email](#email). | You, when the CLI asks. Skippable. |
315
+ | `AUTH_RESEND_API_KEY` + `AUTH_EMAIL_FROM` | Or send through [Resend](https://resend.com) | Same prompt |
316
+ | `AUTH_EMAIL_FROM` | With Gmail: optional display name, `Binary1702 <verify@binary1702.com>` | You, optional |
317
+ | `AUTH_REQUIRE_VERIFIED` | `true`: no session until the email is verified. Default off. | You, optional |
318
+ | `AUTH_PERMISSIONS` | JSON map of role → permissions for the [session contract](#the-session-contract) | You, optional |
319
+ | `AUTH_SIGNUP`, `AUTH_INVITE_CODE`, `AUTH_EMAIL_DOMAIN` | Who may sign up. See [Users](#users). | You, optional |
320
+ | `AUTH_PROTECTED` / `AUTH_PUBLIC` | Which paths need sign-in, space-separated. Same as the yaml keys. | You, optional |
321
+ | `AUTH_ADMINS` | Emails that may open `/auth/admin` | You, optional |
322
+ | `AUTH_EMAIL_REPLY_TO` | Reply-To on outgoing email | You, optional |
323
+ | `AUTH_GMAIL_CLIENT_ID` + `AUTH_GMAIL_CLIENT_SECRET` + `AUTH_GMAIL_REFRESH_TOKEN` | Gmail through a personal account instead of a service account | You, optional |
324
+ | `AUTH_CONFIG` | Path to the settings file when it is not `b17_auth.yaml` in the working directory | You, rarely |
325
+
326
+ All live in `.env.local`. Copy them to your host (Vercel, etc.) when you deploy. With a `b17_auth.yaml`, only the secrets need to be env vars; the rest is in the file, and an env var set anyway overrides the file's value for that key.
327
+
328
+ ---
329
+
330
+ ## Email
331
+
332
+ Verification links and password resets go out as email. One function sends them, over `fetch`, with no mail library. Which way it goes is decided by what is set:
333
+
334
+ ```
335
+ AUTH_GMAIL_USER + AUTH_GMAIL_CLIENT_EMAIL + AUTH_GMAIL_PRIVATE_KEY → Gmail API, as that mailbox
336
+ AUTH_RESEND_API_KEY + AUTH_EMAIL_FROM → Resend
337
+ neither, in development → printed to the dev server's console (the links work)
338
+ neither, in production → the first send throws; sign-in and sign-up keep working
339
+ ```
340
+
341
+ **Gmail API with a Google Workspace mailbox** (for example `verify@binary1702.com`):
342
+
343
+ 1. In Google Cloud: create a project, enable the **Gmail API**, create a **service account**, and download its JSON key.
344
+ 2. In Google Admin → Security → API controls → **Domain-wide delegation**: add the service account's client id with the scope `https://www.googleapis.com/auth/gmail.send`.
345
+ 3. Run `npx @binary1702/auth`, answer `gmail`, give the mailbox and the path to the JSON key. The CLI writes `AUTH_GMAIL_USER`, `AUTH_GMAIL_CLIENT_EMAIL` and `AUTH_GMAIL_PRIVATE_KEY` (the PEM on one line, newlines as `\n`) to `.env.local`.
346
+
347
+ No password or app password is involved: the package signs a short-lived assertion with the service account's key, trades it for a one-hour token at Google's token endpoint, caches the token in memory, and POSTs each message to the Gmail send endpoint. Sent mail shows up in that mailbox's Sent folder.
348
+
349
+ **Gmail API with a personal Gmail account**: set `AUTH_GMAIL_USER`, `AUTH_GMAIL_CLIENT_ID`, `AUTH_GMAIL_CLIENT_SECRET` and `AUTH_GMAIL_REFRESH_TOKEN` (an OAuth client with the `gmail.send` scope, consented once). Same send path.
152
350
 
153
- Both live in `.env.local`. Copy both to your host (Vercel, etc.) when you deploy.
351
+ **Resend**: an API key and a From address on a domain you have verified in Resend.
154
352
 
155
353
  Rotating `AUTH_SECRET` signs everyone out. That is the revoke-all button.
156
354
  For one user: `sign-out-everywhere`, `set-password` or `remove-user`.
@@ -159,31 +357,45 @@ For one user: `sign-out-everywhere`, `set-password` or `remove-user`.
159
357
 
160
358
  ## How it works
161
359
 
162
- Five primitives around one cookie. Nothing else.
360
+ A few primitives around one cookie. Each is one file under `src/core/`, pure where it can be, with the Next handlers only wiring them.
163
361
 
164
362
  ```
165
- auth_users (id, email, password_hash, created_at,
166
- session_epoch) identity one table, owned by the package
167
- scrypt proof node:crypto, no dependency
168
- signed JWT in an httpOnly cookie, 7 days, carries the
169
- user's session_epoch session revoked when the epoch moves on
170
- proxy.ts guard verifies the cookie signature only
171
- getUser() / requireUser() gate signature + one lookup by id
172
- /auth/sign-in /auth/sign-out /auth/session lifecycle one route file
363
+ users (id, email, password_hash, session_epoch,
364
+ email_verified_at, active_org_id) identity owned by the package
365
+ orgs, members (org_id, user_id, role) where + role a user may be in several; one is active
366
+ tokens (user_id, kind, token_hash, expires_at,
367
+ used_at) links verify / reset; only the hash is stored
368
+ rate_limits (bucket, count, reset_at) brakes sign-in 20/IP and 10/email per 15 min, sign-up
369
+ 10/IP/h, reset and resend emails 3/h → 429
370
+ auth_migrations schema numbered steps, applied once on first start
371
+ scrypt proof node:crypto, no dependency
372
+ signed JWT in an httpOnly cookie, carries the user's
373
+ session_epoch session says WHO; revoked when the epoch moves on
374
+ proxy.ts guard signature only, plus protected: / public: paths
375
+ getSession() and friends gate signature + one query → the contract
376
+ b17_auth.yaml settings name, sign-up, public, email, permissions
377
+ sendEmail() email Gmail API or Resend, over fetch
378
+ /auth/* lifecycle one route file
173
379
  ```
174
380
 
175
381
  ```
176
382
  Request
177
- ├─ proxy.ts cookie valid? → continue else → redirect (page) or 401 (api)
383
+ ├─ proxy.ts open path, or cookie valid? → continue else → redirect (page) or 401 (api)
178
384
  ├─ /auth/sign-in POST email+password → verify hash → set cookie → redirect
179
- └─ your route requireUser() → reads cookie → user still exists, epoch matches? → { id, email }
385
+ ├─ /auth/sign-up create user → email a verify link → set cookie (or wait for the link)
386
+ ├─ /auth/forgot email a reset link → /auth/reset sets the password, bumps the epoch, signs in
387
+ └─ your route getSession() → cookie → user exists, epoch matches, verified if required?
388
+ → + active org + role → permissions from the yaml
389
+ → { user, organization, authorization }
180
390
  ```
181
391
 
182
- **Authentication only.** The package tells you `user.id`. What that user may see or edit is your app's job. Keeping that boundary is what keeps this package small enough to never break.
392
+ **Authentication, plus who-is-where.** The package tells you the user, their active organization and the role's permissions. What rows that role may touch is your app's query. Keeping that boundary is what keeps this package small enough to never break.
183
393
 
184
- **Two gates.** The proxy checks only the token's signature, so it costs nothing per request. `getUser()` / `requireUser()` also look the user up by id and compare the token's `session_epoch` with the database's. Deleting a user, changing their password or `sign-out-everywhere` moves the epoch on (or removes the row), and every older token stops working there. A revoked token still passes the proxy and can load a page, but it gets no user and no data: treat `null` / 401 as "go to `/auth/sign-out`", which clears the cookie and lands on sign-in.
394
+ **Two gates.** The proxy checks only the token's signature, so it costs nothing per request. `getSession()` also looks the user up and compares the token's `session_epoch` with the database's. Deleting a user, changing their password (including by reset link) or `sign-out-everywhere` moves the epoch on (or removes the row), and every older token stops working there. A revoked token still passes the proxy and can load a page, but it gets no session and no data: treat `null` / 401 as "go to `/auth/sign-out`", which clears the cookie and lands on sign-in.
185
395
 
186
- Tokens signed before 0.2.5 carry no epoch and count as 0, so upgrading signs nobody out. The `session_epoch` column is added automatically on first start.
396
+ **Hardening.** Every auth page is served with `Cache-Control: no-store`, `X-Frame-Options: DENY`, a `Content-Security-Policy` that allows no scripts (the admin page gets a per-request nonce for its one) and forms posting only to this origin. In production the cookie is `__Host-auth_session`: the browser refuses it unless Secure, path `/`, no domain, so a subdomain or a plain-http page cannot plant one. Passwords are scrypt with N=2^17 (about a third of a second per hash); rows hashed under older parameters are re-hashed on the next successful sign-in. The token carries `iss` and `aud`, so a token minted by another app that shares the secret is refused. The invite code is compared in constant time. Two sign-ups racing on one email: the database decides, the loser gets 409. Emails are unique case-insensitively.
397
+
398
+ **Upgrading from 0.2.x:** nothing to do by hand. The cookie name changes in production but the old one is still read and cleared, tokens from before the 0.3.0 cutoff are accepted without the new claims, and old password hashes upgrade themselves at sign-in. Migrations run on first start, the new columns default to "not verified" and "no organization", tokens signed before keep working, and without a `b17_auth.yaml` the env vars behave as before. Run `npx @binary1702/auth` once to get the settings file written.
187
399
 
188
400
  ---
189
401
 
@@ -194,10 +406,21 @@ Tokens signed before 0.2.5 carry no epoch and count as 0, so upgrading signs nob
194
406
  | Export | |
195
407
  |---|---|
196
408
  | `GET`, `POST` | Route handlers. Mount at `app/auth/[...path]/route.ts`. |
197
- | `requireUser()` | `Promise<{ id, email }>`. Throws `AuthError` (status 401) if signed out, deleted or revoked. |
198
- | `getUser()` | `Promise<{ id, email } \| null>`. Null if signed out, deleted or revoked. One database lookup; throws only if the database is unreachable. |
199
- | `unauthorized(err)` | Turns an `AuthError` into a 401 JSON response. Rethrows anything else. |
200
- | `AuthError` | The error class. Has `.status = 401`. |
409
+ | `getSession()` | `Promise<Session \| null>`. The [contract](#the-session-contract). Null if signed out, deleted, revoked or (when required) unverified. One database query. |
410
+ | `requireSession()` | Same, or throws `AuthError` (401). |
411
+ | `requirePermission(p)` | Same, or throws `AuthError` (401 signed out, 403 role lacks `p`). |
412
+ | `getUser()` / `requireUser()` | Just the `user` part: `{ id, email }`. |
413
+ | `unauthorized(err)` | Turns an `AuthError` into a 401/403 JSON response. Rethrows anything else. |
414
+ | `AuthError` | The error class. Has `.status` (401 or 403). |
415
+ | `Session`, `Organization`, `Authorization`, `User` | The types. |
416
+
417
+ ### `@binary1702/auth/react`
418
+
419
+ | Export | |
420
+ |---|---|
421
+ | `AuthProvider` | `<AuthProvider session={…}>` with the server's `getSession()` result, or without a prop to fetch once on mount. |
422
+ | `useAuth()` | `{ user, organization, authorization, loading, refresh }`. Nulls and an empty permission list when signed out. |
423
+ | `fetchSession()` | `GET /auth/session` from the browser: `Session \| null`. |
201
424
 
202
425
  ### `@binary1702/auth/next/proxy`
203
426
 
@@ -214,7 +437,11 @@ For building your own handlers or using the primitives outside Next.
214
437
  |---|---|
215
438
  | `hashPassword(pw)`, `verifyPassword(pw, hash)` | scrypt |
216
439
  | `signSession(user, secret)`, `verifySession(token, secret)` | JWT HS256 |
217
- | `ensureSchema()`, `findUser()`, `createUser()`, `countUsers()`, `listUsers()`, `setPassword()` | Database |
440
+ | `ensureSchema()`, `findUser()`, `createUser()`, `countUsers()`, `listUsers()`, `setPassword()`, `sessionRow()` | Users. Every function takes an optional `Db` as its last argument, so handler logic can run against a fake. |
441
+ | `createOrg()`, `findOrg()`, `listOrgs()`, `addMember()`, `removeMember()`, `setActiveOrg()` | Organizations |
442
+ | `buildSession()`, `hasPermission()`, `permissionsFor()` | The session contract, pure |
443
+ | `issueToken()`, `consumeToken()` | One-time tokens (`verify`, `reset`): only the SHA-256 is stored |
444
+ | `sendEmail()` | Resend over `fetch`, console in development |
218
445
  | `signInPage({ error?, next?, title? })` | The HTML form as a string |
219
446
  | `COOKIE_NAME`, `SESSION_DAYS`, `SIGN_IN_PATH`, `SIGN_OUT_PATH` | Constants |
220
447
 
@@ -225,8 +452,9 @@ For building your own handlers or using the primitives outside Next.
225
452
  No client library. Plain HTTP.
226
453
 
227
454
  ```ts
228
- // who am I
229
- const { user } = await fetch("/auth/session").then((r) => r.json())
455
+ // who am I, where am I, what may I do (401 when signed out)
456
+ const res = await fetch("/auth/session")
457
+ const { user, organization, authorization } = res.ok ? await res.json() : {}
230
458
 
231
459
  // sign in from a custom form
232
460
  await fetch("/auth/sign-in", {