@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 +29 -13
- package/CHANGELOG.md +18 -0
- package/PROMPT.md +4 -3
- package/README.md +78 -21
- package/dist/cli.js +22 -6
- package/dist/core/admin-html.d.ts +8 -2
- package/dist/core/admin-html.js +133 -35
- package/dist/core/admin.d.ts +35 -5
- package/dist/core/admin.js +49 -7
- package/dist/core/config.d.ts +2 -2
- package/dist/core/config.js +2 -2
- package/dist/core/db.d.ts +25 -1
- package/dist/core/db.js +62 -1
- package/dist/core/guard.d.ts +4 -1
- package/dist/core/guard.js +4 -1
- package/dist/core/html.d.ts +8 -1
- package/dist/core/html.js +26 -13
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +24 -5
- package/dist/core/options.js +89 -22
- package/dist/core/org.d.ts +14 -5
- package/dist/core/org.js +27 -17
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/signup.d.ts +4 -0
- package/dist/core/signup.js +7 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/next/index.js +250 -56
- package/dist/next/proxy.js +14 -6
- package/dist/react/index.d.ts +55 -1
- package/dist/react/index.js +54 -2
- package/package.json +3 -2
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,
|
|
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`)
|
|
70
|
-
6. **Do not build
|
|
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: #
|
|
92
|
-
|
|
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
|
-
| | `
|
|
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
|
|
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`),
|
|
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
|
|
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:`
|
|
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
|
-
| `
|
|
92
|
-
| `POST /auth/sign-
|
|
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.
|
|
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**
|
|
185
|
+
**Permissions** have three parts, each in one place:
|
|
184
186
|
|
|
185
187
|
```
|
|
186
|
-
|
|
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
|
|
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: #
|
|
278
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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:
|
|
335
|
-
|
|
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
|
};
|