@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.
Files changed (2) hide show
  1. package/README.md +448 -1
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1 +1,448 @@
1
- # DASS
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bernouy/socle",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",