@bernouy/socle 0.0.1 → 0.0.2
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/README.md +448 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1 +1,448 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bernouy/socle
|
|
2
|
+
|
|
3
|
+
Minimal backend "socle" (foundation) for Bun apps. Ships the core platform
|
|
4
|
+
interfaces (HTTP runner, authentication, mail) plus ready-to-use default
|
|
5
|
+
implementations you can drop in or replace with your own.
|
|
6
|
+
|
|
7
|
+
> Runtime target: **Bun** (the runner uses `Bun.serve` and HTML pages are
|
|
8
|
+
> loaded via Bun's `with { type: "text" }` text-module attribute). It is not
|
|
9
|
+
> meant to run under plain Node.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @bernouy/socle
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
TypeScript is a peer dependency. Runtime dependencies (`bcryptjs`, `jose`,
|
|
20
|
+
`mongodb`, `nodemailer`, `sharp`, `file-type`) are installed automatically.
|
|
21
|
+
|
|
22
|
+
### Required environment variable
|
|
23
|
+
|
|
24
|
+
The authentication provider signs and verifies JWTs with HS256 and reads the
|
|
25
|
+
secret from `process.env.JWT_SECRET`. You **must** set it before starting the
|
|
26
|
+
runner:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
JWT_SECRET=some-long-random-string bun run src/index.ts
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## What's in the box
|
|
35
|
+
|
|
36
|
+
The package exposes two layers, both re-exported from the root:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import {
|
|
40
|
+
// Interfaces (pure contracts, no runtime code)
|
|
41
|
+
IBe5_Runner, RouteHandler, Middleware,
|
|
42
|
+
IBe5_Authentication, IBe5_Subject, IBe5_AccountSummary,
|
|
43
|
+
IBe5_Mailer, Be5_MailMessage,
|
|
44
|
+
|
|
45
|
+
// Default implementations
|
|
46
|
+
Be5_Runner,
|
|
47
|
+
Authentication, AuthConfig, signAuthJwt,
|
|
48
|
+
AuthRepositoryProvider,
|
|
49
|
+
AuthRepository, TSubject,
|
|
50
|
+
ConsoleMailerProvider,
|
|
51
|
+
SmtpMailerProvider, SmtpMailerConfig,
|
|
52
|
+
} from "@bernouy/socle";
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Concern | Interface | Default implementation |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| HTTP server | `IBe5_Runner` | `Be5_Runner` (Bun.serve) |
|
|
58
|
+
| Authentication | `IBe5_Authentication` | `Authentication` |
|
|
59
|
+
| Auth storage | `AuthRepository` | `AuthRepositoryProvider` (MongoDB) |
|
|
60
|
+
| Mail | `IBe5_Mailer` | `ConsoleMailerProvider`, `SmtpMailerProvider` |
|
|
61
|
+
|
|
62
|
+
You can replace any default by supplying your own implementation of the
|
|
63
|
+
matching interface — the `Authentication` class only depends on
|
|
64
|
+
`AuthRepository`, `IBe5_Runner`, and (optionally) `IBe5_Mailer`.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
Wires the runner, a MongoDB-backed repository, a console mailer, and the
|
|
71
|
+
authentication provider together.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import {
|
|
75
|
+
Be5_Runner,
|
|
76
|
+
Authentication,
|
|
77
|
+
AuthRepositoryProvider,
|
|
78
|
+
ConsoleMailerProvider,
|
|
79
|
+
} from "@bernouy/socle";
|
|
80
|
+
|
|
81
|
+
// 1. HTTP runner
|
|
82
|
+
const runner = new Be5_Runner();
|
|
83
|
+
|
|
84
|
+
// 2. Repository (MongoDB)
|
|
85
|
+
const repository = await AuthRepositoryProvider.create({
|
|
86
|
+
uri: process.env.MONGO_URI ?? "mongodb://localhost:27017",
|
|
87
|
+
databaseName: "my_app",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// 3. Mailer — console for dev, SMTP for prod
|
|
91
|
+
const mailer = new ConsoleMailerProvider("no-reply@my-app.local");
|
|
92
|
+
|
|
93
|
+
// 4. Authentication — the constructor mutates `runner` by registering
|
|
94
|
+
// its own routes under `basePath` (default "/auth").
|
|
95
|
+
const auth = new Authentication(repository, runner, {
|
|
96
|
+
basePath: "/auth",
|
|
97
|
+
baseUrl: "http://localhost:3000",
|
|
98
|
+
defaultRedirection: "/dashboard",
|
|
99
|
+
mailer, // optional — omit to disable password reset
|
|
100
|
+
mailFrom: "no-reply@my-app.local",
|
|
101
|
+
resetTokenTtlMinutes: 30,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 5. Your own routes
|
|
105
|
+
runner.get("/", () => new Response("Hello"));
|
|
106
|
+
|
|
107
|
+
runner.get("/dashboard", async (req) => {
|
|
108
|
+
const subject = await auth.getSubject(req);
|
|
109
|
+
return new Response(`Hello ${subject?.identifier} (${subject?.role})`);
|
|
110
|
+
}, [auth.requireAuthenticated]);
|
|
111
|
+
|
|
112
|
+
runner.get("/admin", async () => {
|
|
113
|
+
const accounts = await auth.listAccounts();
|
|
114
|
+
return Response.json(accounts);
|
|
115
|
+
}, [auth.requireAdmin]);
|
|
116
|
+
|
|
117
|
+
runner.start(); // default port 3000
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Run it with:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
JWT_SECRET=dev-secret MONGO_URI=mongodb://localhost:27017 bun run src/index.ts
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## The runner — `Be5_Runner`
|
|
129
|
+
|
|
130
|
+
Thin abstraction over `Bun.serve` with route registration, middleware, and
|
|
131
|
+
nested groups. Routes are matched by literal segment comparison with `:param`
|
|
132
|
+
wildcards. **There is no query-string parsing and no regex matching** — if you
|
|
133
|
+
need params, read them from `new URL(req.url)` yourself.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const runner = new Be5_Runner();
|
|
137
|
+
|
|
138
|
+
// Verb helpers
|
|
139
|
+
runner.get("/users", listUsers);
|
|
140
|
+
runner.post("/users", createUser);
|
|
141
|
+
runner.put("/users/:id", updateUser);
|
|
142
|
+
runner.patch("/users/:id", patchUser);
|
|
143
|
+
runner.delete("/users/:id", deleteUser);
|
|
144
|
+
|
|
145
|
+
// Or the generic form
|
|
146
|
+
runner.addEndpoint("GET", "/status", () => new Response("ok"));
|
|
147
|
+
|
|
148
|
+
// Global middleware (runs before every route)
|
|
149
|
+
runner.use(async (req, next) => {
|
|
150
|
+
console.log(req.method, req.url);
|
|
151
|
+
return next();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Per-route middleware (third argument on every helper)
|
|
155
|
+
runner.get("/me", meHandler, [auth.requireAuthenticated]);
|
|
156
|
+
|
|
157
|
+
// Groups — prefix + shared middleware
|
|
158
|
+
runner.group("/api/v1", (r) => {
|
|
159
|
+
r.get("/ping", () => new Response("pong"));
|
|
160
|
+
r.post("/things", createThing);
|
|
161
|
+
}, [auth.requireAuthenticated]);
|
|
162
|
+
|
|
163
|
+
runner.start(); // listens on 3000
|
|
164
|
+
runner.start(8080); // or a custom port
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Handler signature
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
type RouteHandler = (req: Request) => Response | Promise<Response>;
|
|
171
|
+
type Middleware = (req: Request, next: () => Promise<Response>) => Promise<Response>;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Handlers receive the native Web `Request` and must return a native `Response`.
|
|
175
|
+
Unhandled exceptions inside a handler or middleware bubble up to a generic
|
|
176
|
+
`500 Internal Server Error`; a missing route returns `404`.
|
|
177
|
+
|
|
178
|
+
### Gotchas
|
|
179
|
+
|
|
180
|
+
- **Nested `group()` currently drops outer middleware onto inner routes.**
|
|
181
|
+
Apply security-critical middleware (admin guards, etc.) directly on the
|
|
182
|
+
route instead of relying on a parent group to enforce it.
|
|
183
|
+
- **No query-string or body parsing** — use `new URL(req.url).searchParams`,
|
|
184
|
+
`req.json()`, `req.formData()` as usual on the Web `Request`.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Authentication — `Authentication`
|
|
189
|
+
|
|
190
|
+
Cookie-based auth using a JWT (HS256) stored in a cookie named
|
|
191
|
+
`Be5Credentials`. On construction it registers HTML pages and JSON endpoints
|
|
192
|
+
under `basePath` (default `/auth`):
|
|
193
|
+
|
|
194
|
+
| Route | Purpose |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `GET /auth/login` | Login page |
|
|
197
|
+
| `POST /auth/loginSubmit` | Login form handler |
|
|
198
|
+
| `GET /auth/register` | Register page (redirects to `/auth/setup` when the DB is empty, 404-like when `registerDisabled: true`) |
|
|
199
|
+
| `POST /auth/registerSubmit` | Register form handler |
|
|
200
|
+
| `GET /auth/logout` | Clears the cookie |
|
|
201
|
+
| `GET /auth/setup` | First-run setup page (only reachable while no account exists) |
|
|
202
|
+
| `POST /auth/setupSubmit` | Creates the first admin |
|
|
203
|
+
| `GET /auth/recover` | "Forgot password" page (**requires a mailer**, 503 otherwise) |
|
|
204
|
+
| `POST /auth/recoverSubmit` | Sends the reset email |
|
|
205
|
+
| `GET /auth/reset` | Reset-password page (via emailed token) |
|
|
206
|
+
| `POST /auth/resetSubmit` | Applies the new password |
|
|
207
|
+
| `POST /auth/changePasswordSubmit` | Authenticated password change |
|
|
208
|
+
| `GET /auth/admin/accounts` | Admin UI (admin-only) |
|
|
209
|
+
| `GET /auth/admin/api/accounts` | List accounts (admin-only) |
|
|
210
|
+
| `POST /auth/admin/api/accounts` | Create account (admin-only) |
|
|
211
|
+
| `DELETE /auth/admin/api/accounts` | Delete account (admin-only) |
|
|
212
|
+
| `PATCH /auth/admin/api/accounts` | Change role (admin-only) |
|
|
213
|
+
|
|
214
|
+
### Config
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
type AuthConfig = {
|
|
218
|
+
basePath?: string; // default "/auth"
|
|
219
|
+
registerDisabled?: boolean; // default false
|
|
220
|
+
defaultRedirection?: string; // default "/"
|
|
221
|
+
baseUrl?: string; // absolute URL, used to build reset links in emails
|
|
222
|
+
mailer?: IBe5_Mailer; // optional; absent disables password reset
|
|
223
|
+
resetTokenTtlMinutes?: number; // default 30
|
|
224
|
+
mailFrom?: string; // default From address for auth emails
|
|
225
|
+
};
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Route URLs as properties
|
|
229
|
+
|
|
230
|
+
`Authentication` exposes every page path it registers so you don't have to
|
|
231
|
+
hardcode them:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
auth.loginPage // "/auth/login"
|
|
235
|
+
auth.registerPage // "/auth/register"
|
|
236
|
+
auth.recoverPage // "/auth/recover"
|
|
237
|
+
auth.resetPage // "/auth/reset"
|
|
238
|
+
auth.logoutPage // "/auth/logout"
|
|
239
|
+
auth.setupPage // "/auth/setup"
|
|
240
|
+
auth.adminAccountsPage // "/auth/admin/accounts"
|
|
241
|
+
|
|
242
|
+
// Build a login URL that redirects back after success
|
|
243
|
+
auth.withRedirect(auth.loginPage, "/private");
|
|
244
|
+
// → "/auth/login?redirect=%2Fprivate"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Guarding routes
|
|
248
|
+
|
|
249
|
+
You have three tools to protect routes. Pick whichever fits the call site:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
// 1. Middleware form — plug into runner middleware arrays
|
|
253
|
+
runner.get("/dashboard", dashboard, [auth.requireAuthenticated]);
|
|
254
|
+
runner.get("/admin/foo", adminFoo, [auth.requireAdmin]);
|
|
255
|
+
|
|
256
|
+
// 2. Imperative guard — throws on failure
|
|
257
|
+
async function handler(req: Request) {
|
|
258
|
+
const subject = await auth.guardAuthenticated(req); // or guardAdmin
|
|
259
|
+
return new Response(`Hi ${subject.identifier}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 3. Soft check — returns null / boolean
|
|
263
|
+
const subject = await auth.getSubject(req); // IBe5_Subject | null
|
|
264
|
+
const loggedIn = await auth.isAuthenticated(req); // boolean
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`IBe5_Subject` is `{ identifier: string; role: "admin" | "user" }`.
|
|
268
|
+
|
|
269
|
+
### Programmatic account management
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
await auth.listAccounts(); // IBe5_AccountSummary[]
|
|
273
|
+
await auth.createAccount("a@b.c", "pw", "admin"); // IBe5_Subject
|
|
274
|
+
await auth.deleteAccount("a@b.c"); // refuses the last admin
|
|
275
|
+
await auth.setAccountRole("a@b.c", "user"); // refuses to demote the last admin
|
|
276
|
+
|
|
277
|
+
await auth.requestPasswordReset("a@b.c"); // throws if no mailer
|
|
278
|
+
await auth.resetPassword(token, "newPassword");
|
|
279
|
+
await auth.changePassword(req, "oldPw", "newPw");
|
|
280
|
+
|
|
281
|
+
auth.logout(); // returns a Response that clears the cookie
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### `mailEnabled`
|
|
285
|
+
|
|
286
|
+
`auth.mailEnabled` is `true` when a mailer was passed to the constructor.
|
|
287
|
+
Password recovery pages return `503` and `requestPasswordReset()` throws when
|
|
288
|
+
it is `false` — the login page is automatically stripped of the "forgot
|
|
289
|
+
password" link in that case.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Auth storage — `AuthRepository`
|
|
294
|
+
|
|
295
|
+
`Authentication` depends on a repository that implements this contract
|
|
296
|
+
(`src/default/AuthenticationProvider/interfaces/repository/AuthRepository.ts`):
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
type TSubject = {
|
|
300
|
+
id?: string;
|
|
301
|
+
email: string;
|
|
302
|
+
passwordHash: string;
|
|
303
|
+
role: "admin" | "user";
|
|
304
|
+
createdAt?: Date;
|
|
305
|
+
passwordResetTokenHash?: string | null;
|
|
306
|
+
passwordResetExpiresAt?: Date | null;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
interface AuthRepository {
|
|
310
|
+
findByEmail(email: string): Promise<TSubject | null>;
|
|
311
|
+
findByResetTokenHash(tokenHash: string): Promise<TSubject | null>;
|
|
312
|
+
register(subject: TSubject): Promise<TSubject>;
|
|
313
|
+
updatePassword(email: string, passwordHash: string): Promise<void>;
|
|
314
|
+
updateRole(email: string, role: "admin" | "user"): Promise<void>;
|
|
315
|
+
setResetToken(email: string, tokenHash: string, expiresAt: Date): Promise<void>;
|
|
316
|
+
clearResetToken(email: string): Promise<void>;
|
|
317
|
+
delete(email: string): Promise<void>;
|
|
318
|
+
list(): Promise<TSubject[]>;
|
|
319
|
+
count(): Promise<number>;
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Default implementation: `AuthRepositoryProvider` (MongoDB)
|
|
324
|
+
|
|
325
|
+
Writes to an `auth` collection in the database you specify. Use the async
|
|
326
|
+
factory — the constructor is for when you already hold a `MongoClient`.
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { AuthRepositoryProvider } from "@bernouy/socle";
|
|
330
|
+
|
|
331
|
+
const repository = await AuthRepositoryProvider.create({
|
|
332
|
+
uri: "mongodb://localhost:27017",
|
|
333
|
+
databaseName: "my_app",
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Writing your own repository
|
|
338
|
+
|
|
339
|
+
Just implement `AuthRepository` and hand the instance to
|
|
340
|
+
`new Authentication(myRepo, runner, config)`. No other wiring needed.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Mail — `IBe5_Mailer`
|
|
345
|
+
|
|
346
|
+
Contract:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
type Be5_MailMessage = {
|
|
350
|
+
to: string;
|
|
351
|
+
subject: string;
|
|
352
|
+
html: string;
|
|
353
|
+
text?: string;
|
|
354
|
+
from?: string; // overrides the provider default
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
interface IBe5_Mailer {
|
|
358
|
+
send(message: Be5_MailMessage): Promise<void>;
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### `ConsoleMailerProvider`
|
|
363
|
+
|
|
364
|
+
Dev/test mailer — prints messages to stdout instead of sending them. Handy
|
|
365
|
+
for inspecting password-reset links without configuring SMTP.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import { ConsoleMailerProvider } from "@bernouy/socle";
|
|
369
|
+
const mailer = new ConsoleMailerProvider("no-reply@my-app.local");
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### `SmtpMailerProvider`
|
|
373
|
+
|
|
374
|
+
SMTP-backed, built on `nodemailer` (imported dynamically so apps that only
|
|
375
|
+
use the console mailer don't pay the cost).
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
import { SmtpMailerProvider } from "@bernouy/socle";
|
|
379
|
+
|
|
380
|
+
const mailer = new SmtpMailerProvider({
|
|
381
|
+
host: "smtp.example.com",
|
|
382
|
+
port: 587,
|
|
383
|
+
secure: false,
|
|
384
|
+
auth: { user: "xxx", pass: "yyy" },
|
|
385
|
+
defaultFrom: "no-reply@example.com",
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## Recipes
|
|
392
|
+
|
|
393
|
+
### Redirect unauthenticated traffic to the login page
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
runner.get("/private", async (req) => {
|
|
397
|
+
if (!(await auth.isAuthenticated(req))) {
|
|
398
|
+
return Response.redirect(auth.withRedirect(auth.loginPage, "/private"), 302);
|
|
399
|
+
}
|
|
400
|
+
return new Response("Private area");
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Grouping your own API under a shared guard
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
runner.group("/api", (r) => {
|
|
408
|
+
// NOTE: apply the guard on each route rather than at the group level —
|
|
409
|
+
// nested-group middleware merging is currently lossy.
|
|
410
|
+
r.get("/me", meHandler, [auth.requireAuthenticated]);
|
|
411
|
+
r.post("/things", createThing, [auth.requireAuthenticated]);
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Replacing the runner with your own
|
|
416
|
+
|
|
417
|
+
Implement `IBe5_Runner` and pass it to `Authentication`:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
import type { IBe5_Runner } from "@bernouy/socle";
|
|
421
|
+
|
|
422
|
+
class MyRunner implements IBe5_Runner { /* ... */ }
|
|
423
|
+
|
|
424
|
+
const auth = new Authentication(repository, new MyRunner(), { /* ... */ });
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Issuing your own JWT (e.g. for tests)
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
import { signAuthJwt } from "@bernouy/socle";
|
|
431
|
+
|
|
432
|
+
const token = await signAuthJwt({
|
|
433
|
+
email: "a@b.c",
|
|
434
|
+
sub: "user-id",
|
|
435
|
+
role: "admin",
|
|
436
|
+
});
|
|
437
|
+
// Attach as a Be5Credentials cookie to authenticate requests.
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## TypeScript notes
|
|
443
|
+
|
|
444
|
+
- `strict`, `noUncheckedIndexedAccess`, and `verbatimModuleSyntax` are enabled
|
|
445
|
+
by the published types, so consumers must use `import type` for type-only
|
|
446
|
+
imports.
|
|
447
|
+
- Every interface is prefixed `IBe5_` and every default class `Be5_` /
|
|
448
|
+
`*Provider`, so autocomplete groups them together.
|