@binary1702/auth 0.3.0 → 0.4.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.
package/AGENTS.md CHANGED
@@ -9,7 +9,9 @@ cookie auth_session: a signed token saying WHO (user id). Nothing els
9
9
  proxy.ts runs on every request: open path or valid cookie → continue, else → /auth/sign-in (401 for /api/*)
10
10
  getSession() the real check, one query: user still exists, session not revoked, email verified if required
11
11
  → { user, organization, authorization } or null
12
- b17_auth.yaml the app's choices: name, sign-up policy, which paths need sign-in, email, roles → permissions
12
+ b17_auth.yaml the app's choices: name, sign-up policy, which paths need sign-in, email, the permissions the app
13
+ gates (permissions:) and the starting roles (roles:, written into the database once)
14
+ roles table what each role may do, as set on /auth/admin (Roles, or inside an organization). The database decides.
13
15
  .env.local secrets only (DATABASE_URL, AUTH_SECRET, the email key); the yaml references them as ${NAME}
14
16
  ```
15
17
 
@@ -66,8 +68,8 @@ export async function POST(req: Request) {
66
68
  2. **Scope data by `organization.id`** (and `user.id` where it is personal). The package authenticates and tells you the role; what rows that role may touch is the app's query.
67
69
  3. **Never read or parse the `auth_session` cookie.** Never query the package's tables (`users`, `orgs`, `members`, `tokens`, `auth_migrations`) from app code. Users and memberships are managed with the CLI (below). Your own tables should use other names.
68
70
  4. **Never put a secret in `b17_auth.yaml`.** Secrets go in `.env.local`; the yaml references them as `${NAME}`. The CLI refuses a literal secret in the file.
69
- 5. **Permission names are the app's vocabulary.** Pick `area.verb` names (`invoices.create`). `all` grants everything, `invoices.*` everything under that prefix. Define them in `b17_auth.yaml` under `permissions:`; `owner` has everything unless the file says otherwise.
70
- 6. **Do not build sign-in, sign-up, forgot-password or verification pages.** They exist under `/auth/`. Link to them.
71
+ 5. **Permission names are the app's vocabulary.** Pick `area.verb` names (`invoices.create`) and declare each with a label in `b17_auth.yaml` under `permissions:`; those are the rows on `/auth/admin › Roles`. Who holds which is decided there and saved in the database, never in code or in the yaml (`roles:` only seeds the database once). `owner` and the emails under `admins:` always hold everything. In the UI use `useAuth().can("invoices.create")` to show or hide; on the server `requirePermission()` is the check.
72
+ 6. **Do not build forgot-password or verification pages.** They exist under `/auth/`. Link to them. Sign-in and sign-up exist there too; build your own only when the app wants its own design, and then through `pages:` in `b17_auth.yaml` plus `signIn()` / `signUp()` / `fetchPolicy()` from `@binary1702/auth/react` (see "Your own sign-in page" below). Never a page that reads the cookie or checks a password itself.
71
73
  7. **Do not edit `proxy.ts`'s matcher.** Which paths need sign-in is `protected:` in `b17_auth.yaml` (`/x/*` covers `/x` and everything below); everything not listed is open. Without the key, everything is guarded, and `public:` lists exceptions instead. Never both.
72
74
  8. **Do not edit anything in `node_modules/@binary1702/auth`.**
73
75
 
@@ -77,6 +79,7 @@ export async function POST(req: Request) {
77
79
  name: Acme Portal # page titles, email subjects
78
80
  signup: closed # open | closed | invite (needs inviteCode: ${AUTH_INVITE_CODE})
79
81
  protected: /app/* /api/* # only these need sign-in; everything else is open
82
+ home: /app # the signed-in home: sign-in lands here, a signed-in visitor to / is sent here
80
83
 
81
84
  database: ${DATABASE_URL}
82
85
  secret: ${AUTH_SECRET}
@@ -88,25 +91,37 @@ email:
88
91
  clientEmail: ${AUTH_GMAIL_CLIENT_EMAIL}
89
92
  privateKey: ${AUTH_GMAIL_PRIVATE_KEY}
90
93
 
91
- permissions: # role: what it may do
92
- owner: all
94
+ permissions: # what the app gates: id: label (the rows on /auth/admin › Roles)
95
+ invoices.read: See invoices
96
+ invoices.create: Create invoices
97
+ reports.read: See reports
98
+
99
+ roles: # the starting point, written into the database once; /auth/admin decides after
100
+ owner: all # owner always holds everything
93
101
  admin: invoices.* reports.*
94
102
  member: invoices.read
95
-
96
- organizations: # per-organization overrides, by name
97
- Acme:
98
- member: invoices.read reports.read
99
103
  ```
100
104
 
101
105
  No arrays: a list is words separated by spaces. Changes are picked up on the next request in development; restart in production.
102
106
 
107
+ ## Your own sign-in page
108
+
109
+ ```yaml
110
+ pages:
111
+ signIn: /sign-in # the app's own page (either, or both)
112
+ signUp: /sign-up
113
+ ```
114
+
115
+ With that, every redirect the package makes (the proxy, sign-out, a verified email, "create the first account") lands on the app's page, `GET /auth/sign-in` redirects there with the same query, and the page is always open. The protocol stays with the package: the page calls `signIn({ email, password, next })` / `signUp({ email, password, invite?, next })` from `@binary1702/auth/react` (JSON POSTs to `/auth/sign-in` and `/auth/sign-up`) and navigates to `r.next` on `ok`, or shows `r.error`. `fetchPolicy()` (`GET /auth/policy`) says whether sign-up is allowed, whether an invite code is needed, and whether this is the first account (`signup.reason === "first"`: the sign-in page should send the visitor to sign-up). The query the page may receive: `next`, `error` (`1` = wrong email or password, otherwise the message), `verify=1`, `verified=1`.
116
+
103
117
  ## URLs the package serves
104
118
 
105
119
  | Pages (HTML, for a person) | API (JSON, for code) |
106
120
  |---|---|
107
- | `GET /auth/sign-in`, `/auth/sign-up`, `/auth/forgot`, `/auth/reset?token=`, `/auth/verify?token=`, `/auth/admin` | `GET /auth/session` → the shape or 401 |
108
- | `GET /auth/sign-out` clears the cookie | `POST /auth/session { organization }` switches the active organization (membership checked) |
109
- | | `POST /auth/sign-in`, `/auth/sign-up`, `/auth/sign-out`, `/auth/forgot`, `/auth/reset`, `/auth/verify/resend` |
121
+ | `GET /auth/sign-in`, `/auth/sign-up`, `/auth/forgot`, `/auth/reset?token=`, `/auth/invite?token=`, `/auth/verify?token=`, `/auth/admin` | `GET /auth/session` → the shape or 401 |
122
+ | `GET /auth/sign-out` clears the cookie, lands on `?next=` (same origin) or sign-in | `POST /auth/session { organization }` switches the active organization (membership checked) |
123
+ | | `GET /auth/policy` → `{ signup, pages, name }` for an app's own sign-in / sign-up page |
124
+ | | `POST /auth/sign-in`, `/auth/sign-up` (answer `{ user, next }` + cookie), `/auth/sign-out`, `/auth/forgot`, `/auth/reset`, `/auth/verify/resend` |
110
125
 
111
126
  Every POST refuses cross-site requests with 403.
112
127
 
@@ -131,7 +146,8 @@ npx @binary1702/auth list-orgs
131
146
  | Symptom | Cause | Fix |
132
147
  |---|---|---|
133
148
  | `getSession()` is null but the user just signed in | password changed, `sign-out-everywhere`, or `requireVerified` is on and the email is not verified | sign out and in; or verify the email |
134
- | 403 from `requirePermission` for an owner | a `b17_auth.yaml` `permissions.owner` line that narrows it | remove the line; owner has everything by default |
149
+ | 403 from `requirePermission` for a role that should have it | the role's row in the database (Roles on /auth/admin, or the organization's own rule) does not tick that permission; a permission added to the yaml later starts off | tick it on /auth/admin; owner and admins always have everything |
150
+ | `permissions: now lists what the app gates…` at startup | the yaml still has the pre-0.4.0 role map under `permissions:` | rename that section to `roles:`; declare the catalogue under `permissions:` |
135
151
  | `AUTH_SECRET is missing` / `DATABASE_URL is missing` | server started before `.env.local`, or the yaml references a name that is not set | check `.env.local`, restart |
136
152
  | `b17_auth.yaml line N: …` at startup | a syntax mistake in the file | the message says which line |
137
153
  | a page redirects to sign-in that should not | it matches `protected:` (or is not in `public:`); or Next 15 edge middleware cannot read the file | adjust the list; on Next 15 set `runtime: "nodejs"` in the middleware config |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased (0.4.0)
4
+
5
+ ### Added
6
+ - `pages:` in `b17_auth.yaml` (`signIn: /sign-in`, `signUp: /sign-up`; env `AUTH_SIGN_IN_PAGE` / `AUTH_SIGN_UP_PAGE`): the app serves its own sign-in / sign-up page in its own design. Every redirect the package makes lands there, `GET /auth/sign-in` and `/auth/sign-up` redirect there with the same query, and the page is always open to the proxy. The POST protocol does not move.
7
+ - `@binary1702/auth/react`: `signIn()`, `signUp()`, `signOut()`, `fetchPolicy()`, the protocol as functions for such a page, with one result shape (`AuthResult`).
8
+ - `GET /auth/policy`: `{ signup: { allowed, inviteRequired, reason }, pages, name }`, what a sign-in / sign-up page needs before it renders.
9
+ - `POST /auth/sign-in` and `/auth/sign-up` JSON answers carry `next`, the server-checked same-origin destination.
10
+ - `home:` in `b17_auth.yaml` (env `AUTH_HOME`): the signed-in home. Sign-in lands there when nothing says otherwise, the admin page's "app" link points there, and the proxy sends a signed-in visitor who opens `/` there, so `/` can stay the public site. `GET /auth/policy` returns it as `home`.
11
+ - `/auth/sign-out?next=<path>`: where to land after signing out (same origin), e.g. `?next=/` for the public front.
12
+ - **Permissions are decided in the database, on /auth/admin.** A `roles` table (migration 8) holds what each role may do: for every organization (scope `""`) or for one organization (its id); the most specific rule wins, and nothing saved means nothing allowed. The session query joins it, so a change is live on the next request. `/auth/admin › Roles` is a grid: one row per permission the app declares, one checkbox per role, Save per role; each organization's page has the same grid for its own rule ("follows Roles" until saved). The `owner` role is fixed at everything and cannot be edited. An organization's owner may open their organization on /auth/admin and manage its roles and members; everything else stays with `admins:`.
13
+ - `b17_auth.yaml`: `permissions:` now declares what the app gates (`id: label`, the grid's rows); `roles:` is the starting point, written into the database once (at install, or on first use) and restorable per role with "defaults". `organizations:` is gone (set an organization's own rule on its page). A file still holding the old role map under `permissions:` fails at startup with the fix spelled out. Env: `AUTH_ROLES` (JSON) replaces `AUTH_PERMISSIONS`, which still works.
14
+ - `@binary1702/auth/react`: `useAuth().can(permission)` for showing and hiding UI.
15
+ - Two ways to add a user on `/auth/admin`: **Register** (email + password: ready and verified) and **Invite** (email only: the account gets a random password and one link, `/auth/invite?token=…`, good once for seven days; the person chooses a password, which verifies the address, and is signed in). "invite" on an unverified user sends the link again. `invite` is a token kind; `POST /auth/invite` is the reset flow for it.
16
+ - The admin page is honest about email: a banner on every tab when no transport is configured, and "email" / "invite" say the link was printed to the dev terminal rather than claiming a send. Either way the one-time link just issued is shown on the page with a copy button, so an admin can hand it over directly (only this site's own /auth/ links are ever shown; the query is not trusted).
17
+
18
+ ### Changed
19
+ - `permissionsFor(role, stored)` and `buildSession(row, { admins })`: the yaml no longer takes part in the decision. An email under `admins:` holds every permission.
20
+
3
21
  ## 0.3.0 (2026-10-22)
4
22
 
5
23
  Nothing to change by hand when upgrading from 0.2.x: migrations run on first start, the old cookie name is still read, tokens from before the cutoff are accepted, and password hashes upgrade themselves at sign-in. Run `npx @binary1702/auth` once to get `b17_auth.yaml` written.
package/PROMPT.md CHANGED
@@ -207,7 +207,7 @@ export async function GET() {
207
207
  }
208
208
  ```
209
209
 
210
- Permissions are names the app chooses (`invoices.create`), mapped from roles in `b17_auth.yaml` under `permissions:`. `owner` has everything by default. Memberships come from the CLI (`add-org`, `add-member`). Do not invent a permission system in the app; use `requirePermission()`.
210
+ Permissions are names the app chooses (`invoices.create`), declared with a label in `b17_auth.yaml` under `permissions:`. Which role holds which is set on `/auth/admin › Roles` and saved in the database (`roles:` in the yaml only seeds it once); `owner` always has everything. Memberships come from the CLI (`add-org`, `add-member`) or the admin page. Do not invent a permission system in the app; use `requirePermission()` on the server and `useAuth().can()` in the UI.
211
211
 
212
212
  **Server components (pages, layouts):**
213
213
 
@@ -256,7 +256,7 @@ Sign-out: a link to `/auth/sign-out`, or `fetch("/auth/sign-out", { method: "POS
256
256
 
257
257
  After the install, `node_modules/@binary1702/auth/AGENTS.md` is the reference for working in the app day to day.
258
258
 
259
- **Custom sign-in form (only if I ask for one):** a page under `/auth/` with a form that POSTs `email`, `password` and optionally `next` to `/auth/sign-in`. It must be served from the same site; cross-site POSTs get `403`.
259
+ **Custom sign-in / sign-up page (only if I ask for one):** name it in `b17_auth.yaml` under `pages:` (`signIn: /sign-in`, `signUp: /sign-up`), then build an ordinary page at that path with `signIn()` / `signUp()` / `fetchPolicy()` from `@binary1702/auth/react`. Never under `/auth/`. The package then sends every signed-out visitor there and redirects `GET /auth/sign-in` to it. Details: README "Your own sign-in page" and `AGENTS.md`.
260
260
 
261
261
  ## Step 6: Settings
262
262
 
@@ -271,7 +271,8 @@ Only if I ask for them. All go in `b17_auth.yaml` (the CLI wrote it with comment
271
271
  | `requireVerified: true` | no session until the emailed link is clicked (needs email configured) |
272
272
  | `protected: /app/* /api/*` | only these need sign-in; everything else is open (default without it: everything needs sign-in, `public:` lists exceptions) |
273
273
  | `email:` | Gmail API or Resend; see the README's "Email" section. Keys stay in `.env.local`. |
274
- | `permissions:` / `organizations:` | role → permissions, and per-organization overrides |
274
+ | `permissions:` | what the app gates, `id: label`: the rows of the Roles grid on `/auth/admin` |
275
+ | `roles:` | role → permissions, the starting point written into the database once; the admin page decides after |
275
276
 
276
277
  Without `signup`, sign-up is closed, except while the user table is empty: then `/auth/sign-in` sends visitors to create the first account.
277
278
 
package/README.md CHANGED
@@ -74,13 +74,14 @@ Two kinds of URL under `/auth/`. **Pages** are HTML you open in a browser; they
74
74
 
75
75
  | URL | What you see |
76
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. |
77
+ | `/auth/sign-in` | The sign-in form. `?verify=1` shows "check your email" with a resend button; `?verified=1` shows "email verified". With `pages.signIn` in `b17_auth.yaml`, redirects to [your own page](#your-own-sign-in-page). |
78
+ | `/auth/sign-up` | The registration form. Closed unless `signup: open` (or `invite`) in `b17_auth.yaml`. See [Users](#users). Same `pages.signUp` override. |
79
+ | `/auth/sign-out` | Not a page: clears the cookie and redirects to sign-in, or to `?next=<same-origin path>` (`/auth/sign-out?next=/` lands on the public front). Link to it. |
80
80
  | `/auth/verify?token=…` | The link in the verification email. Marks the email verified and redirects. |
81
81
  | `/auth/forgot` | "Send me a reset link". Always answers the same, whether or not the address exists. |
82
82
  | `/auth/admin` | Users, organizations, roles: what the CLI does, as a page. Only for emails under `admins:` in `b17_auth.yaml`. |
83
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
+ | `/auth/invite?token=…` | The link an added user gets (admin page, "Add user" with no password): choose a password, which verifies the address, and you are signed in. Works once, seven days. |
84
85
 
85
86
  **API (JSON, for code)**
86
87
 
@@ -88,8 +89,9 @@ Two kinds of URL under `/auth/`. **Pages** are HTML you open in a browser; they
88
89
  |---|---|---|
89
90
  | `GET /auth/session` | the [session contract](#the-session-contract) | `200 { user, organization, authorization }` or `401` |
90
91
  | `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 }` |
92
+ | `GET /auth/policy` | what a sign-in / sign-up page needs before it renders | `200 { signup: { allowed, inviteRequired, reason }, pages, name }` |
93
+ | `POST /auth/sign-in` | `{ email, password, next? }` | `200 { user, next }` + cookie, `401`, or `403 { verify: true }` |
94
+ | `POST /auth/sign-up` | `{ email, password, invite?, next? }` | `200 { user, next }` + cookie, or `400 / 403 / 404 / 409 { error }` |
93
95
  | `POST /auth/sign-out` | clears the cookie | `200 { ok: true }` |
94
96
  | `POST /auth/verify/resend` | `{ email }`: emails a fresh verification link | `200 { ok: true }`, always |
95
97
  | `POST /auth/forgot` | `{ email }`: emails a reset link (1 h) if the address exists | `200 { ok: true }`, always |
@@ -172,21 +174,24 @@ npx @binary1702/auth remove-member ben@binary1702.com Binary1702
172
174
  npx @binary1702/auth list-orgs
173
175
  ```
174
176
 
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:
177
+ **The admin page**, `/auth/admin`, does the same as those commands from the browser: add a user two ways, **Register** (email + password, ready and verified) or **Invite** (email only: a seven-day link to choose a password, which verifies the address and signs them in), set a password, sign someone out everywhere, mark an email verified or send the Invite link again, remove a user; create an organization, add a member with a role, change a role, remove a member or an organization. And it decides *what a role means*: the **Roles** tab is a grid, one row per permission the app declares, one checkbox per role, a Save button per role. Each organization's page carries the same grid for its own rule. Who may open it:
176
178
 
177
179
  ```yaml
178
180
  admins: ben@binary1702.com ops@binary1702.com
179
181
  ```
180
182
 
181
- An admin cannot remove or sign out the account they are using.
183
+ An admin cannot remove or sign out the account they are using. An organization's **owner** may open their own organization on the page (`/auth/admin?org=<id>`) and manage its roles and members, nothing else.
182
184
 
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.
185
+ **Permissions** have three parts, each in one place:
184
186
 
185
187
  ```
186
- AUTH_PERMISSIONS={"owner":["*"],"admin":["invoices.*"],"member":["invoices.view"]}
188
+ b17_auth.yaml permissions: what exists id: label, the rows of the grid. The app's vocabulary.
189
+ roles: the start role → permissions, written into the database once
190
+ roles table /auth/admin what's allowed per role, for every organization or for one; most specific wins
191
+ session JSON authorization the result { role, permissions }, read by the app
187
192
  ```
188
193
 
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.
194
+ Nothing saved for a role means it may do nothing. The `owner` role always holds everything and cannot be edited; so does any email under `admins:`. A saved role grants exactly its ticked boxes: a permission the app declares later starts off until someone ticks it. `"*"` means everything and `"invoices.*"` everything under that prefix, in the yaml's starting lists and in `hasPermission()`.
190
195
 
191
196
  ---
192
197
 
@@ -274,14 +279,15 @@ email:
274
279
  clientEmail: ${AUTH_GMAIL_CLIENT_EMAIL}
275
280
  privateKey: ${AUTH_GMAIL_PRIVATE_KEY}
276
281
 
277
- permissions: # role: what it may do. all = everything, x.* = a prefix
278
- owner: all
282
+ permissions: # what the app gates: id: label (the rows on /auth/admin › Roles)
283
+ invoices.read: See invoices
284
+ invoices.create: Create invoices
285
+ reports.read: See reports
286
+
287
+ roles: # the starting point, written into the database once. all = everything, x.* = a prefix
288
+ owner: all # (then /auth/admin › Roles decides; owner always has everything)
279
289
  admin: all
280
290
  member: invoices.* reports.read
281
-
282
- organizations: # overrides by organization name
283
- Acme:
284
- member: invoices.read
285
291
  ```
286
292
 
287
293
  | Key | Default | |
@@ -294,8 +300,11 @@ organizations: # overrides by organization n
294
300
  | `protected` | none: everything guarded | only these paths need sign-in; the rest is open. `/x/*` covers `/x` and everything below |
295
301
  | `public` | none | the reverse: everything needs sign-in except these. Use one or the other. |
296
302
  | `admins` | none | emails that may open `/auth/admin` |
303
+ | `pages` | the built-in pages | `signIn: /sign-in` and/or `signUp: /sign-up`: your own pages, see [Your own sign-in page](#your-own-sign-in-page) |
304
+ | `home` | `/` | the signed-in home, e.g. `/app`: where sign-in lands when nothing says otherwise, the admin page's "app" link, and where a signed-in visitor opening `/` is sent (so `/` can be the public site) |
297
305
  | `email` | console in dev | see [Email](#email) |
298
- | `permissions`, `organizations` | none | see [The session contract](#the-session-contract) |
306
+ | `permissions` | none | what the app gates, `id: label`; see [The session contract](#the-session-contract) |
307
+ | `roles` | `owner: all` | role → permissions, the starting point written into the database once; after that `/auth/admin › Roles` decides |
299
308
 
300
309
  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
310
 
@@ -315,7 +324,7 @@ The format is a small subset of YAML: `key: value`, sections by indentation, `#`
315
324
  | `AUTH_RESEND_API_KEY` + `AUTH_EMAIL_FROM` | Or send through [Resend](https://resend.com) | Same prompt |
316
325
  | `AUTH_EMAIL_FROM` | With Gmail: optional display name, `Binary1702 <verify@binary1702.com>` | You, optional |
317
326
  | `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 |
327
+ | `AUTH_ROLES` (or the older `AUTH_PERMISSIONS`) | JSON map of role → permissions, the starting point written into the database once (same as `roles:`) | You, optional |
319
328
  | `AUTH_SIGNUP`, `AUTH_INVITE_CODE`, `AUTH_EMAIL_DOMAIN` | Who may sign up. See [Users](#users). | You, optional |
320
329
  | `AUTH_PROTECTED` / `AUTH_PUBLIC` | Which paths need sign-in, space-separated. Same as the yaml keys. | You, optional |
321
330
  | `AUTH_ADMINS` | Emails that may open `/auth/admin` | You, optional |
@@ -385,7 +394,7 @@ Request
385
394
  ├─ /auth/sign-up create user → email a verify link → set cookie (or wait for the link)
386
395
  ├─ /auth/forgot email a reset link → /auth/reset sets the password, bumps the epoch, signs in
387
396
  └─ your route getSession() → cookie → user exists, epoch matches, verified if required?
388
- → + active org + role → permissions from the yaml
397
+ → + active org + role → permissions from the roles table (set on /auth/admin)
389
398
  → { user, organization, authorization }
390
399
  ```
391
400
 
@@ -421,6 +430,10 @@ Request
421
430
  | `AuthProvider` | `<AuthProvider session={…}>` with the server's `getSession()` result, or without a prop to fetch once on mount. |
422
431
  | `useAuth()` | `{ user, organization, authorization, loading, refresh }`. Nulls and an empty permission list when signed out. |
423
432
  | `fetchSession()` | `GET /auth/session` from the browser: `Session \| null`. |
433
+ | `signIn({ email, password, next? })` | `POST /auth/sign-in` as JSON. `{ ok: true, user, next }` or `{ ok: false, status, error, verify, retryAfter? }`. For [your own sign-in page](#your-own-sign-in-page). |
434
+ | `signUp({ email, password, invite?, next? })` | `POST /auth/sign-up`. Same result shape; `verify: true` with `ok` means "check your email". |
435
+ | `signOut()` | `POST /auth/sign-out`. |
436
+ | `fetchPolicy()` | `GET /auth/policy`: `{ signup: { allowed, inviteRequired, reason }, pages, name }`. |
424
437
 
425
438
  ### `@binary1702/auth/next/proxy`
426
439
 
@@ -440,6 +453,7 @@ For building your own handlers or using the primitives outside Next.
440
453
  | `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
454
  | `createOrg()`, `findOrg()`, `listOrgs()`, `addMember()`, `removeMember()`, `setActiveOrg()` | Organizations |
442
455
  | `buildSession()`, `hasPermission()`, `permissionsFor()` | The session contract, pure |
456
+ | `storedRoles()`, `setRolePermissions()`, `resetRole()`, `seedRoles()`, `memberRole()` | The roles table: what each role may do, per scope |
443
457
  | `issueToken()`, `consumeToken()` | One-time tokens (`verify`, `reset`): only the SHA-256 is stored |
444
458
  | `sendEmail()` | Resend over `fetch`, console in development |
445
459
  | `signInPage({ error?, next?, title? })` | The HTML form as a string |
@@ -471,9 +485,52 @@ Or just link to `/auth/sign-out`.
471
485
 
472
486
  ---
473
487
 
474
- ## Custom sign-in page
488
+ ## Your own sign-in page
489
+
490
+ The built-in pages are plain HTML with light and dark mode. To draw them in your app's design, name your pages in `b17_auth.yaml`:
491
+
492
+ ```yaml
493
+ pages:
494
+ signIn: /sign-in
495
+ signUp: /sign-up # either, or both
496
+ ```
497
+
498
+ That moves the *page*, not the protocol. The package keeps deciding what is valid, who may sign up, where to go next and when to refuse; your page only renders.
499
+
500
+ ```
501
+ pages.signIn set → the proxy, sign-out, a verified email, "no account yet" all land on /sign-in
502
+ GET /auth/sign-in?next=… redirects to /sign-in?next=… (old links keep working)
503
+ /sign-in is always open, in both protected: and public: modes
504
+ POST /auth/sign-in unchanged: that is what your page calls
505
+ ```
506
+
507
+ Your page is an ordinary route in the app (`app/sign-in/page.tsx`), built with the functions from `@binary1702/auth/react`:
508
+
509
+ ```tsx
510
+ "use client"
511
+ import { fetchPolicy, signIn, signUp } from "@binary1702/auth/react"
512
+
513
+ const r = await signIn({ email, password, next }) // POST /auth/sign-in as JSON
514
+ if (r.ok) location.assign(r.next) // next: checked by the server, same origin only
515
+ else setError(r.error) // 401 wrong password, 429 too many tries, 403 verify your email
516
+
517
+ const { signup, pages } = await fetchPolicy() // GET /auth/policy
518
+ // signup.allowed may anyone sign up right now? (sign-up page: show "closed" otherwise)
519
+ // signup.inviteRequired ask for the invite code
520
+ // signup.reason "first": no users yet, this is the first account (sign-in page: send to pages.signUp)
521
+ ```
522
+
523
+ What arrives on your page in the query, because the package redirected there:
524
+
525
+ | Query | Means | Where from |
526
+ |---|---|---|
527
+ | `?next=/app/x` | where to go after sign-in; pass it to `signIn()` as is | the proxy |
528
+ | `?error=1` | wrong email or password | a form POST to `/auth/sign-in` that failed (JSON callers get `401` instead) |
529
+ | `?error=<text>` | the message | a form POST to `/auth/sign-up` that failed |
530
+ | `?verify=1` | account created, email not verified yet | sign-up with `requireVerified` |
531
+ | `?verified=1` | the verification link worked; sign in now | `/auth/verify` |
475
532
 
476
- The built-in page is plain HTML with light and dark mode. If you want your own, make any page with a form that POSTs `email` and `password` to `/auth/sign-in`, and put it anywhere under `/auth/` so the proxy lets it through. Optional hidden field `next` for where to go after.
533
+ A form that POSTs `email`, `password` (and `next`, `invite`) to `/auth/sign-in` or `/auth/sign-up` works too; it gets redirects instead of JSON. Either way the request must come from your own site: cross-site POSTs get `403`. Forgot-password and reset stay the package's pages under `/auth/`; link to `/auth/forgot`.
477
534
 
478
535
  ---
479
536
 
package/dist/cli.js CHANGED
@@ -309,6 +309,10 @@ signup: closed # open | closed | invite (
309
309
  # protected: /app/* /api/* # only these need sign-in; everything else is open. * = anything below
310
310
  # # (or the reverse: public: / /pricing = everything needs sign-in except these)
311
311
  # admins: you@example.com # who may open /auth/admin (users, organizations, roles)
312
+ # home: /app # the signed-in home: after sign-in, and where a signed-in visitor to / goes
313
+ # pages: # your own sign-in / sign-up pages instead of the built-in ones;
314
+ # signIn: /sign-in # they call signIn() / signUp() from @binary1702/auth/react
315
+ # signUp: /sign-up
312
316
 
313
317
  database: \${DATABASE_URL}
314
318
  secret: \${AUTH_SECRET}
@@ -331,14 +335,14 @@ ${gmail
331
335
  # resend:
332
336
  # apiKey: \${AUTH_RESEND_API_KEY}`}
333
337
 
334
- permissions: # role: what it may do. all = everything, x.* = a prefix
335
- owner: all
338
+ # permissions: # what the app gates: id: label. The rows on /auth/admin › Roles
339
+ # invoices.read: See invoices
340
+ # invoices.create: Create invoices
341
+
342
+ roles: # role: what it may do to start with. all = everything, x.* = a prefix
343
+ owner: all # (/auth/admin › Roles saves changes in the database)
336
344
  admin: all
337
345
  member:
338
-
339
- # organizations: # overrides by organization name
340
- # Acme:
341
- # member: invoices.read
342
346
  `;
343
347
  }
344
348
  async function stepConfig() {
@@ -365,6 +369,17 @@ async function stepConfig() {
365
369
  writeFileSync(abs, configTemplate(readEnv()));
366
370
  done(`${CONFIG_FILE} written: app name, sign-up policy, email, permissions`);
367
371
  }
372
+ /** The roles from b17_auth.yaml, written into the database once. A role the admin page already saved is left alone. */
373
+ async function stepRoles() {
374
+ const { seedRoles } = await db();
375
+ const { options, resetOptions } = await import("./core/options.js");
376
+ resetOptions(); // the file may have been written a step ago
377
+ const written = await seedRoles(options().roles);
378
+ if (written.length)
379
+ done(`roles written to the database: ${written.join(", ")} ${c.dim("(the starting point; /auth/admin › Roles decides from here)")}`);
380
+ else
381
+ skip("roles already in the database");
382
+ }
368
383
  async function stepSchema() {
369
384
  const { ensureSchema, MIGRATIONS } = await db();
370
385
  await ensureSchema().catch((e) => fail(`Could not reach the database: ${e.message}`));
@@ -513,6 +528,7 @@ async function main() {
513
528
  stepFiles(appDir, src);
514
529
  await stepConfig();
515
530
  await stepSchema();
531
+ await stepRoles();
516
532
  await stepFirstUser();
517
533
  finish();
518
534
  }
@@ -1,7 +1,7 @@
1
- import type { AdminOrg, AdminUser } from "./db.ts";
1
+ import type { AdminOrg, AdminUser, StoredRole } from "./db.ts";
2
2
  import type { Options } from "./options.ts";
3
3
  export type AdminView = {
4
- tab: "orgs" | "users" | "settings";
4
+ tab: "orgs" | "users" | "roles" | "settings";
5
5
  } | {
6
6
  tab: "org";
7
7
  orgId: string;
@@ -12,14 +12,20 @@ export type AdminPageData = {
12
12
  users: AdminUser[];
13
13
  orgs: AdminOrg[];
14
14
  roles: string[];
15
+ /** what the admin page saved for roles, all scopes */
16
+ stored: StoredRole[];
15
17
  options: Options;
16
18
  transport: string;
17
19
  tokens: {
18
20
  total: number;
19
21
  live: number;
20
22
  };
23
+ /** an organization's owner, not a system admin: only their organization is shown */
24
+ limited?: boolean;
21
25
  notice?: string;
22
26
  error?: string;
27
+ /** a one-time link (invite or verify) just issued, shown so the admin can hand it over */
28
+ link?: string;
23
29
  /** per-render, for the one script the page carries; must match the CSP header */
24
30
  nonce: string;
25
31
  };