@orieljs/framework 0.1.0-alpha.12 → 0.1.0-alpha.13

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.
@@ -2,9 +2,9 @@ import {
2
2
  Fragment,
3
3
  jsx,
4
4
  jsxs
5
- } from "./chunk-24673JQF.js";
6
- import "./chunk-MURTCTRH.js";
7
- import "./chunk-HTJKT2R2.js";
5
+ } from "./chunk-ZRTWDJKD.js";
6
+ import "./chunk-6GMXKQ6O.js";
7
+ import "./chunk-CKH4KCYF.js";
8
8
  export {
9
9
  Fragment,
10
10
  jsx,
@@ -1357,6 +1357,11 @@ declare class LiveComponentManager implements LiveManager {
1357
1357
 
1358
1358
  /** The URI the client runtime posts updates to. */
1359
1359
  declare const UPDATE_URI = "/oriel/live/update";
1360
+ /**
1361
+ * Where the client gets a session before its first update when the page came without one (a page cached at a CDN):
1362
+ * through the `web` middleware, so the answer sets the session and XSRF-TOKEN cookies (Sanctum's csrf-cookie route).
1363
+ */
1364
+ declare const SESSION_URI = "/oriel/live/session";
1360
1365
  /**
1361
1366
  * Binds the LiveManager and the PaginationState that WithPagination components answer, merges the `live` config,
1362
1367
  * registers POST /oriel/live/update (named `live.update`, in the `web` group: sessions and CSRF apply), the upload and
@@ -1583,4 +1588,4 @@ interface PaginationProps {
1583
1588
  */
1584
1589
  declare function Pagination({ paginator, class: className }: PaginationProps): Htmlable;
1585
1590
 
1586
- export { type DatabaseTestOptions as $, UploadValidationError as A, BusFake as B, Component as C, WithPagination as D, EventFake as E, FileNotPreviewableError as F, componentName as G, live as H, InvalidLiveRequestError as I, persistentMiddleware as J, AnonymousNotifiable as K, Live as L, MailFake as M, NotificationFake as N, On as O, PERSISTENT_MIDDLEWARE as P, QueueFake as Q, AssertableInertia as R, StorageFake as S, TEMPORARY_UPLOAD_DEFAULTS as T, UPDATE_URI as U, AssertableJson as V, WithFileUploads as W, type BatchRecord as X, type BootOptions as Y, type CountOrFilter as Z, DatabaseMigrations as _, type ComponentResult as a, DatabaseTransactions as a0, type DispatchedEvent as a1, type ExpectedErrors as a2, type ExpectedListener as a3, FakeDisk as a4, FakeFile as a5, HttpClient as a6, InteractsWithApplication as a7, InteractsWithConsole as a8, InteractsWithDatabase as a9, cookiePrefix as aA, decryptCookie as aB, encryptCookie as aC, flatten as aD, freezeSecond as aE, freezeTime as aF, guessMimeType as aG, inertiaPageOf as aH, install as aI, isExpired as aJ, matches as aK, nameOf as aL, recordingExceptionHandler as aM, recordingViewFactory as aN, responseCookies as aO, runCommand as aP, travel as aQ, travelBack as aR, travelTo as aS, usingInMemoryDatabase as aT, InteractsWithTime as aa, type JsonStructure as ab, MailRecord as ac, MakesHttpRequests as ad, PendingCommand as ae, type PushedJob as af, RefreshDatabase as ag, RefreshDatabaseState as ah, type RequestData as ai, type RequestOptions as aj, type ResponseContext as ak, type ResponseCookie as al, type SentNotification as am, TESTING_ENVIRONMENT as an, type TableOrModel as ao, type Target as ap, TestCase as aq, type TestHooks as ar, TestResponse as as, type TestTrait as at, type TravelDate as au, Wormhole as av, actingAsAuthFactory as aw, addresses as ax, applyEnvironment as ay, cookieHeader as az, Computed as b, CorruptComponentPayloadError as c, ErrorBag as d, InvalidUploadSignatureError as e, LiveComponentManager as f, LiveComponentNotFoundError as g, LiveError as h, type LiveProps as i, LiveServiceProvider as j, Locked as k, MethodNotCallableError as l, PREVIEW_URI as m, type Paginated as n, Pagination as o, type PaginationProps as p, type PaginationSource as q, PendingEvent as r, type PersistentMiddleware as s, PropertyNotUpdatableError as t, type TemporaryFileData as u, TemporaryFileNotFoundError as v, TemporaryUploadedFile as w, TestRequestScope as x, Testable as y, UPLOAD_URI as z };
1591
+ export { DatabaseMigrations as $, UPLOAD_URI as A, BusFake as B, Component as C, UploadValidationError as D, EventFake as E, FileNotPreviewableError as F, WithPagination as G, componentName as H, InvalidLiveRequestError as I, live as J, persistentMiddleware as K, Live as L, MailFake as M, NotificationFake as N, On as O, PERSISTENT_MIDDLEWARE as P, QueueFake as Q, AnonymousNotifiable as R, StorageFake as S, TEMPORARY_UPLOAD_DEFAULTS as T, UPDATE_URI as U, AssertableInertia as V, WithFileUploads as W, AssertableJson as X, type BatchRecord as Y, type BootOptions as Z, type CountOrFilter as _, type ComponentResult as a, type DatabaseTestOptions as a0, DatabaseTransactions as a1, type DispatchedEvent as a2, type ExpectedErrors as a3, type ExpectedListener as a4, FakeDisk as a5, FakeFile as a6, HttpClient as a7, InteractsWithApplication as a8, InteractsWithConsole as a9, cookieHeader as aA, cookiePrefix as aB, decryptCookie as aC, encryptCookie as aD, flatten as aE, freezeSecond as aF, freezeTime as aG, guessMimeType as aH, inertiaPageOf as aI, install as aJ, isExpired as aK, matches as aL, nameOf as aM, recordingExceptionHandler as aN, recordingViewFactory as aO, responseCookies as aP, runCommand as aQ, travel as aR, travelBack as aS, travelTo as aT, usingInMemoryDatabase as aU, InteractsWithDatabase as aa, InteractsWithTime as ab, type JsonStructure as ac, MailRecord as ad, MakesHttpRequests as ae, PendingCommand as af, type PushedJob as ag, RefreshDatabase as ah, RefreshDatabaseState as ai, type RequestData as aj, type RequestOptions as ak, type ResponseContext as al, type ResponseCookie as am, type SentNotification as an, TESTING_ENVIRONMENT as ao, type TableOrModel as ap, type Target as aq, TestCase as ar, type TestHooks as as, TestResponse as at, type TestTrait as au, type TravelDate as av, Wormhole as aw, actingAsAuthFactory as ax, addresses as ay, applyEnvironment as az, Computed as b, CorruptComponentPayloadError as c, ErrorBag as d, InvalidUploadSignatureError as e, LiveComponentManager as f, LiveComponentNotFoundError as g, LiveError as h, type LiveProps as i, LiveServiceProvider as j, Locked as k, MethodNotCallableError as l, PREVIEW_URI as m, type Paginated as n, Pagination as o, type PaginationProps as p, type PaginationSource as q, PendingEvent as r, type PersistentMiddleware as s, PropertyNotUpdatableError as t, SESSION_URI as u, type TemporaryFileData as v, TemporaryFileNotFoundError as w, TemporaryUploadedFile as x, TestRequestScope as y, Testable as z };
package/dist/testing.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { K as AnonymousNotifiable, R as AssertableInertia, V as AssertableJson, X as BatchRecord, Y as BootOptions, B as BusFake, Z as CountOrFilter, _ as DatabaseMigrations, $ as DatabaseTestOptions, a0 as DatabaseTransactions, a1 as DispatchedEvent, E as EventFake, a2 as ExpectedErrors, a3 as ExpectedListener, a4 as FakeDisk, a5 as FakeFile, a6 as HttpClient, a7 as InteractsWithApplication, a8 as InteractsWithConsole, a9 as InteractsWithDatabase, aa as InteractsWithTime, ab as JsonStructure, L as Live, M as MailFake, ac as MailRecord, ad as MakesHttpRequests, N as NotificationFake, ae as PendingCommand, af as PushedJob, Q as QueueFake, ag as RefreshDatabase, ah as RefreshDatabaseState, ai as RequestData, aj as RequestOptions, ak as ResponseContext, al as ResponseCookie, am as SentNotification, S as StorageFake, an as TESTING_ENVIRONMENT, ao as TableOrModel, ap as Target, aq as TestCase, ar as TestHooks, as as TestResponse, at as TestTrait, y as Testable, au as TravelDate, av as Wormhole, aw as actingAsAuthFactory, ax as addresses, ay as applyEnvironment, az as cookieHeader, aA as cookiePrefix, aB as decryptCookie, aC as encryptCookie, aD as flatten, aE as freezeSecond, aF as freezeTime, aG as guessMimeType, aH as inertiaPageOf, aI as install, aJ as isExpired, aK as matches, aL as nameOf, aM as recordingExceptionHandler, aN as recordingViewFactory, aO as responseCookies, aP as runCommand, aQ as travel, aR as travelBack, aS as travelTo, aT as usingInMemoryDatabase } from './testing-CKwfWJGn.js';
1
+ export { R as AnonymousNotifiable, V as AssertableInertia, X as AssertableJson, Y as BatchRecord, Z as BootOptions, B as BusFake, _ as CountOrFilter, $ as DatabaseMigrations, a0 as DatabaseTestOptions, a1 as DatabaseTransactions, a2 as DispatchedEvent, E as EventFake, a3 as ExpectedErrors, a4 as ExpectedListener, a5 as FakeDisk, a6 as FakeFile, a7 as HttpClient, a8 as InteractsWithApplication, a9 as InteractsWithConsole, aa as InteractsWithDatabase, ab as InteractsWithTime, ac as JsonStructure, L as Live, M as MailFake, ad as MailRecord, ae as MakesHttpRequests, N as NotificationFake, af as PendingCommand, ag as PushedJob, Q as QueueFake, ah as RefreshDatabase, ai as RefreshDatabaseState, aj as RequestData, ak as RequestOptions, al as ResponseContext, am as ResponseCookie, an as SentNotification, S as StorageFake, ao as TESTING_ENVIRONMENT, ap as TableOrModel, aq as Target, ar as TestCase, as as TestHooks, at as TestResponse, au as TestTrait, z as Testable, av as TravelDate, aw as Wormhole, ax as actingAsAuthFactory, ay as addresses, az as applyEnvironment, aA as cookieHeader, aB as cookiePrefix, aC as decryptCookie, aD as encryptCookie, aE as flatten, aF as freezeSecond, aG as freezeTime, aH as guessMimeType, aI as inertiaPageOf, aJ as install, aK as isExpired, aL as matches, aM as nameOf, aN as recordingExceptionHandler, aO as recordingViewFactory, aP as responseCookies, aQ as runCommand, aR as travel, aS as travelBack, aT as travelTo, aU as usingInMemoryDatabase } from './testing-B8NOiQC9.js';
2
2
  import './contracts.js';
3
3
  import './index.d-WQpfdIbG.js';
package/dist/testing.js CHANGED
@@ -52,9 +52,9 @@ import {
52
52
  travelBack,
53
53
  travelTo,
54
54
  usingInMemoryDatabase
55
- } from "./chunk-7NA4HALO.js";
56
- import "./chunk-MURTCTRH.js";
57
- import "./chunk-HTJKT2R2.js";
55
+ } from "./chunk-OTCTQVDQ.js";
56
+ import "./chunk-6GMXKQ6O.js";
57
+ import "./chunk-CKH4KCYF.js";
58
58
  export {
59
59
  AnonymousNotifiable,
60
60
  AssertableInertia,
@@ -187,6 +187,8 @@ page, or to the path you give.
187
187
  `attempt()` changes the session's id as it signs the user in, which protects against session fixation; the kits also
188
188
  call `regenerate()`, as above.
189
189
 
190
+ To ask for a code after the password, by email or from an authenticator app, see [Two-factor login](two-factor.md).
191
+
190
192
  #### Specifying additional conditions
191
193
 
192
194
  Add any condition to the credentials. A list matches any of its values, and a function receives the query:
package/docs/console.md CHANGED
@@ -116,7 +116,7 @@ These are the commands every app has. A new app adds one of its own, `inspire`,
116
116
  | `schedule:run` | Runs the scheduled tasks that are due |
117
117
  | `schedule:work` | Runs the tasks that are due, every minute, until you stop it |
118
118
  | `storage:link` | Links `public/storage` to `storage/app/public` |
119
- | `vercel:build` | Writes your app's build for Vercel into `.vercel/output` ([Deploying to Vercel](deployment.md#deploying-to-vercel)) |
119
+ | `vercel:build` | Writes your app's build for Vercel into `.vercel/output` ([Deploying to Vercel](deployment.md#deploying-to-vercel)); `--cron=` sets when Vercel calls the scheduler ([Scheduled tasks](deployment.md#scheduled-tasks)) |
120
120
 
121
121
  `node oriel help <command>` lists a command's options. A few that are worth knowing up front:
122
122
 
@@ -308,10 +308,11 @@ and each instance has its own, so:
308
308
  headers itself, so the function trusts them when `trusted_proxies`, in `config/http.ts`, is empty: your URLs keep
309
309
  the browser's `https`, and `request().ip()` is the visitor's (see
310
310
  [Configuring trusted proxies](requests.md#configuring-trusted-proxies)).
311
- - **Files your app stores** go on an `s3` disk (see [File storage](storage.md)): the `local` and `public` disks write
312
- into `storage`, which the function can't.
313
- - **No queue worker or scheduler runs.** With `QUEUE_CONNECTION=sync`, a job runs during the request that dispatches
314
- it.
311
+ - **Files your app stores** go on the `blob` disk, a Vercel Blob store, or an `s3` disk (see
312
+ [The Vercel Blob driver](storage.md#the-vercel-blob-driver)): the `local` and `public` disks write into `storage`,
313
+ which the function can't.
314
+ - **No queue worker runs.** With `QUEUE_CONNECTION=sync`, a job runs during the request that dispatches it. Your
315
+ scheduled tasks run through Vercel Cron (see [Scheduled tasks](#scheduled-tasks)).
315
316
  - **Inertia pages render in the browser**, as they do when no SSR server answers: the SSR server doesn't run on
316
317
  Vercel. `INERTIA_SSR_ENABLED=false` skips asking it.
317
318
 
@@ -331,7 +332,26 @@ Route.get('/pricing', () => view('pricing'))
331
332
 
332
333
  `cache.headers` leaves a response that sets a cookie alone, so that the CDN never hands one visitor's cookies to the
333
334
  next. The `web` group's `StartSession` and `VerifyCsrfToken` set cookies on every page: take them off the pages you
334
- cache, as above. Those pages have no session, so they can't show who is signed in.
335
+ cache, as above. Those pages have no session, so they can't show who is signed in. Their Live components still work:
336
+ they get a session with their first update (see [Pages cached without a session](live.md#pages-cached-without-a-session)).
337
+
338
+ ### Scheduled tasks
339
+
340
+ No `schedule:work` runs on Vercel, so Vercel Cron calls your app's scheduler instead. When your app schedules any task,
341
+ `vercel:build` adds a cron that calls `/_oriel/schedule`, daily at 03:00 UTC. Add a `CRON_SECRET` to the project's
342
+ environment variables (a long random string): Vercel sends it with every call, and without it the route answers 404,
343
+ so nobody else can run your tasks.
344
+
345
+ Each call runs every task that fell due since the previous call, once, however often it fell due in between: a task
346
+ scheduled every five minutes runs once a day, and a task at 04:00 runs at the next morning's call. `vercel:build` names
347
+ the tasks due more often than the cron. Vercel's Hobby plan runs a cron at most once a day, sometime within the hour
348
+ you chose; a paid plan can call more often:
349
+
350
+ ```sh
351
+ node oriel vercel:build --cron="*/10 * * * *"
352
+ ```
353
+
354
+ `--cron=false` writes no cron.
335
355
 
336
356
  ## Debug mode
337
357
 
package/docs/live.md CHANGED
@@ -689,6 +689,26 @@ export default class PostCount extends Component {
689
689
  `lazy={false}` turns `static lazy` off. Without `placeholder()`, the placeholder is the `live.lazy_placeholder` view,
690
690
  or an empty `<div>`. Give the placeholder the same root element as `render()`.
691
691
 
692
+ ## Pages cached without a session
693
+
694
+ A page that is the same for every visitor can be cached by a CDN (see
695
+ [Caching pages on the CDN](deployment.md#caching-pages-on-the-cdn)), and its Live components still work. Such a page
696
+ leaves `StartSession` and `VerifyCsrfToken` out, so it comes with no session and no `XSRF-TOKEN` cookie. Before its
697
+ first update, the client asks `/oriel/live/session` for both, once for the whole page, and then sends the update with
698
+ the token:
699
+
700
+ ```ts title="routes/web.ts"
701
+ import { Route, StartSession, VerifyCsrfToken, view } from '@orieljs/framework'
702
+
703
+ Route.get('/docs/{page}', (page: string) => view('docs', { page }))
704
+ .middleware('cache.headers:public;s_maxage=86400;stale_while_revalidate=60')
705
+ .withoutMiddleware(StartSession, VerifyCsrfToken)
706
+ ```
707
+
708
+ The cached HTML is the same for everyone, so render nothing there that belongs to one visitor: no name, no flashed
709
+ message, no CSRF field. A lazy component suits such a page: its placeholder is cached, and what it shows for this
710
+ visitor comes with its own load. `Live.start({ sessionUri })` moves the session route if you moved the update route.
711
+
692
712
  ## Pagination
693
713
 
694
714
  `WithPagination` gives a component a page, which `paginate()` uses:
@@ -151,6 +151,10 @@
151
151
  "slug": "authentication",
152
152
  "title": "Authentication"
153
153
  },
154
+ {
155
+ "slug": "two-factor",
156
+ "title": "Two-factor login"
157
+ },
154
158
  {
155
159
  "slug": "authorization",
156
160
  "title": "Authorization"
package/docs/routing.md CHANGED
@@ -431,7 +431,7 @@ yourself. Until the first run, any name is accepted.
431
431
  Your [Inertia](inertia.md#links-and-the-route-helper) pages build URLs with the same `route()`, from your adapter. It
432
432
  takes the same names and parameters, and returns a path. The browser's route table leaves out the routes that
433
433
  `types.except_routes` in `config/inertia.ts` names, with `*` as a wildcard. Unless you set it, it leaves out `live.*`,
434
- Live's own routes.
434
+ Live's own routes, and `vercel.*`, the route Vercel Cron calls.
435
435
 
436
436
  ```tsx tab="React" title="resources/js/components/PostLink.tsx"
437
437
  import { Link, route } from '@orieljs/inertia-react'
@@ -219,6 +219,9 @@ Add one cron entry to your server, which runs `schedule:run` every minute:
219
219
  `schedule:run` runs each due task once, prints its name, and goes on to the next one when a task fails, after
220
220
  printing the error. With nothing due, it prints "No scheduled commands are ready to run.".
221
221
 
222
+ On Vercel, which runs no cron of yours, Vercel Cron calls the scheduler instead (see
223
+ [Scheduled tasks on Vercel](deployment.md#scheduled-tasks)).
224
+
222
225
  ### Sub-minute tasks
223
226
 
224
227
  A task that runs every few seconds, such as `everyTenSeconds()`, is due every minute. Its `schedule:run` runs it
package/docs/storage.md CHANGED
@@ -5,9 +5,9 @@ service), through one API.
5
5
 
6
6
  ## Configuration
7
7
 
8
- Your disks are in `config/filesystems.ts`. Each disk names its driver, `local` or `s3`, and its settings; `default`
9
- names the disk `Storage` uses when you don't name one (`FILESYSTEM_DISK`, else `local`). A new app has three disks:
10
- `local`, `public` and `s3`.
8
+ Your disks are in `config/filesystems.ts`. Each disk names its driver, `local`, `s3` or `vercel-blob`, and its
9
+ settings; `default` names the disk `Storage` uses when you don't name one (`FILESYSTEM_DISK`, else `local`). A new app
10
+ has four disks: `local`, `public`, `s3` and `blob`.
11
11
 
12
12
  ### The local driver
13
13
 
@@ -40,6 +40,27 @@ The `s3` disk reads `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_R
40
40
  `AWS_ENDPOINT` and `AWS_USE_PATH_STYLE_ENDPOINT` from your `.env`. For an S3-compatible service, such as MinIO or
41
41
  Cloudflare R2, set `endpoint`, and `usePathStyleEndpoint` if it wants the bucket in the path.
42
42
 
43
+ ### The Vercel Blob driver
44
+
45
+ On Vercel, files your app stores need a disk that outlives the function: the `blob` disk keeps them in a
46
+ [Vercel Blob](https://vercel.com/docs/vercel-blob) store. Create a store in your Vercel project's Storage tab and
47
+ connect it to the project: Vercel then sets `BLOB_READ_WRITE_TOKEN`, which the disk reads.
48
+
49
+ ```ts
50
+ import { Storage } from '@orieljs/framework'
51
+ declare const cover: Uint8Array
52
+ // ---cut---
53
+ await Storage.disk('blob').put('covers/launch.png', cover)
54
+
55
+ const address = Storage.disk('blob').url('covers/launch.png')
56
+ ```
57
+
58
+ `url()` gives the file's public address in the store, worked out from the token; set `BLOB_URL` to use another one
59
+ (a custom domain). `root` (`BLOB_ROOT`) keeps every path under a folder, so preview deployments can write under
60
+ `preview` and never touch production's files. Blob stores here are public: a `private` disk or file is refused, and
61
+ `temporaryUrl()` returns the public address. A Vercel function accepts request bodies up to 4.5 MB, which limits what
62
+ a form can upload through it.
63
+
43
64
  ## Obtaining disk instances
44
65
 
45
66
  `Storage` works on the default disk; `Storage.disk(name)` on another:
@@ -0,0 +1,183 @@
1
+ # Two-factor login
2
+
3
+ Ask for a second factor after the password: a 6-digit code sent by email, or one from an authenticator app, with
4
+ one-time recovery codes for when the phone or the mailbox is gone.
5
+
6
+ ## Introduction
7
+
8
+ With two-factor login on, a correct password no longer signs the user in. It starts a pending login, kept in the
9
+ session, and your app shows a page that asks for the code. Only the right code (or a recovery code) completes the
10
+ login. This is Laravel Fortify's two-factor login, with an emailed code as well.
11
+
12
+ Each user has a method:
13
+
14
+ - **Email**, the default: a code is mailed to the user when their password is accepted. It needs no setup, so every
15
+ user has it from the start.
16
+ - **Authenticator app**: the user scans a QR code with an app such as Google Authenticator or 1Password and confirms
17
+ it with one code; from then on, the app's code is asked for.
18
+
19
+ The starter kits don't have two-factor pages yet: you add the routes and pages your app needs, as below.
20
+
21
+ ## Turning it on
22
+
23
+ Add the four columns two-factor login keeps on `users`, then turn it on in `config/auth.ts`:
24
+
25
+ ```sh
26
+ node oriel two-factor:install
27
+ node oriel migrate
28
+ ```
29
+
30
+ ```ts title="config/auth.ts"
31
+ import { User } from '../app/Models/User'
32
+ // ---cut---
33
+ export default {
34
+ defaults: { guard: 'web', passwords: 'users' },
35
+ guards: { web: { driver: 'session', provider: 'users' } },
36
+ providers: { users: { driver: 'eloquent', model: User } },
37
+ two_factor: { enabled: true },
38
+ }
39
+ ```
40
+
41
+ `two_factor` takes these settings, each with its default:
42
+
43
+ | Setting | Default | |
44
+ | --- | --- | --- |
45
+ | `enabled` | `false` | Ask for a second factor after the password |
46
+ | `methods` | `['email', 'app']` | The methods users may use (see below) |
47
+ | `default` | `'email'` | The method a user has until they set up another |
48
+ | `code_ttl` | `600` | Seconds an emailed code works |
49
+ | `pending_ttl` | `600` | Seconds a pending login waits for its code |
50
+ | `max_attempts` | `5` | Wrong codes a pending login allows; then the user signs in again |
51
+ | `resend_after` | `30` | Seconds before another email code may be sent |
52
+ | `window` | `1` | Authenticator steps (30 seconds each) of clock drift accepted either way |
53
+ | `recovery_codes` | `8` | How many recovery codes a user gets |
54
+ | `issuer` | `app.name` | The name authenticator apps show |
55
+
56
+ A user is asked only by a method open to them: email, or an app they have set up. With `methods: ['app']`, a user who
57
+ hasn't set up an app has no method open, so their password alone signs them in, as in Fortify, where two-factor is each
58
+ user's choice. Keep `'email'` in `methods` to ask every user for a second factor.
59
+
60
+ The secret and the recovery codes are encrypted with your app key; emailed codes are kept hashed, in the cache, so the
61
+ cache must be shared by your servers (`database` or `redis`, as on Vercel).
62
+
63
+ ## Signing in
64
+
65
+ Your login controller calls `TwoFactor.attempt()` instead of `Auth.attempt()`. It has three outcomes: `'authenticated'`
66
+ (the user is signed in: two-factor is off, or not asked of this user), `'challenge'` (the password was right and the
67
+ code is asked for), and `false` (wrong credentials):
68
+
69
+ ```ts title="app/Http/Controllers/LoginController.ts"
70
+ import { back, redirect, request, TwoFactor } from '@orieljs/framework'
71
+ import { Controller } from './Controller'
72
+
73
+ export class LoginController extends Controller {
74
+ async authenticate() {
75
+ const credentials = await request().validate({ email: 'required|email', password: 'required' })
76
+
77
+ const result = await TwoFactor.attempt(credentials, request().boolean('remember'))
78
+ if (result === 'challenge') return redirect('/two-factor-challenge')
79
+ if (result === 'authenticated') {
80
+ await request().session().regenerate()
81
+ return redirect().intended('dashboard')
82
+ }
83
+
84
+ return back().withErrors({ email: 'The provided credentials do not match our records.' }).withInput(request().only('email'))
85
+ }
86
+ }
87
+ ```
88
+
89
+ ## The challenge page
90
+
91
+ The challenge page's routes take the `two-factor.challenge` middleware: only a login waiting for its code reaches them,
92
+ and anyone else is sent to log in.
93
+
94
+ ```ts title="routes/web.ts"
95
+ import { back, redirect, request, Route, TwoFactor, view } from '@orieljs/framework'
96
+
97
+ Route.middleware('two-factor.challenge').group(() => {
98
+ Route.get('/two-factor-challenge', async () => view('auth.two-factor', { challenge: await TwoFactor.challenge() }))
99
+
100
+ Route.post('/two-factor-challenge', async () => {
101
+ if (await TwoFactor.verify(String(request().input('code', '')))) {
102
+ await request().session().regenerate()
103
+ return redirect().intended('dashboard')
104
+ }
105
+ return back().withErrors({ code: 'That code is not right.' })
106
+ })
107
+
108
+ Route.post('/two-factor-challenge/email', async () => {
109
+ await TwoFactor.useMethod('email')
110
+ return back()
111
+ })
112
+
113
+ Route.post('/two-factor-challenge/resend', async () => {
114
+ return back().with('status', (await TwoFactor.resend()) ? 'We sent you a new code.' : 'Wait a moment before asking again.')
115
+ })
116
+ })
117
+ ```
118
+
119
+ `TwoFactor.challenge()` gives the page what to say: the method asked for, the other methods the user has (`methods`),
120
+ and where an emailed code went, masked (`a***@example.com`). `verify()` takes the 6-digit code for that method, or a
121
+ recovery code, read leniently: spaces, dashes and capitals don't matter. A code works once; five wrong codes end the
122
+ pending login, and the user signs in again.
123
+
124
+ The emailed code is the `TwoFactorCode` notification, sent with your mailer, so on Vercel your app needs a mail service
125
+ over SMTP. `TwoFactorCode.toMailUsing()` lets you write the mail yourself:
126
+
127
+ ```ts title="app/Providers/AppServiceProvider.ts"
128
+ import { MailMessage, TwoFactorCode } from '@orieljs/framework'
129
+ // ---cut---
130
+ TwoFactorCode.toMailUsing((_user, code, minutes) =>
131
+ new MailMessage().subject('Your sign-in code').line(`Your code is ${code}.`).line(`It works for ${minutes} minutes.`),
132
+ )
133
+ ```
134
+
135
+ ## Setting up an authenticator app
136
+
137
+ On an account page, a signed-in user sets up their app in two steps: show the QR code, then confirm with a code from
138
+ the app. Until it is confirmed, the app doesn't count, and the user is still asked by email:
139
+
140
+ ```ts title="routes/web.ts"
141
+ import { back, request, Route, TwoFactor, view } from '@orieljs/framework'
142
+ import type { User } from '../app/Models/User'
143
+ // ---cut---
144
+ Route.middleware('auth').group(() => {
145
+ Route.post('/account/two-factor/app', async () => {
146
+ const { svg, secret } = await TwoFactor.enableApp((await request().user<User>())!)
147
+ return view('account.two-factor-app', { svg, secret })
148
+ })
149
+
150
+ Route.post('/account/two-factor/app/confirm', async () => {
151
+ const confirmed = await TwoFactor.confirmApp((await request().user<User>())!, String(request().input('code', '')))
152
+ return confirmed ? back().with('status', 'Your app is set up.') : back().withErrors({ code: 'That code is not right.' })
153
+ })
154
+ })
155
+ ```
156
+
157
+ `enableApp()` returns the secret (for typing in by hand), its `otpauth://` URL and the QR code as an SVG image.
158
+ `confirmApp()` makes the app the user's method and gives them recovery codes. `disableApp()` removes it, and
159
+ `setMethod()` lets a user who has both choose which is asked for.
160
+
161
+ ## Recovery codes
162
+
163
+ `TwoFactor.recoveryCodes(user)` returns the user's codes, making them on first ask; show them once and ask the user to
164
+ keep them somewhere safe. Each works once, in place of a code. `regenerateRecoveryCodes(user)` makes new ones, and the
165
+ old ones stop working.
166
+
167
+ ## Choosing who is asked
168
+
169
+ By default every user is asked for a second factor once two-factor is on. `requireWhen()` narrows it, for instance to
170
+ admins:
171
+
172
+ ```ts title="app/Providers/AppServiceProvider.ts"
173
+ import { TwoFactor } from '@orieljs/framework'
174
+ import type { User } from '../Models/User'
175
+ // ---cut---
176
+ TwoFactor.requireWhen((user) => (user as User).getAttribute('is_admin') === true)
177
+ ```
178
+
179
+ ## Events
180
+
181
+ `TwoFactorChallenged` (the code was asked for), `TwoFactorFailed` (a wrong code), `TwoFactorAuthenticated` (the user is
182
+ signed in), `RecoveryCodeUsed`, `TwoFactorAppEnabled` and `TwoFactorAppDisabled`, each with the user. The password step
183
+ fires `Attempting` and `Failed`, as `Auth.attempt()` does, and the login fires `Login`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orieljs/framework",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.13",
4
4
  "description": "Oriel: a Laravel-style, full-stack TypeScript framework for Node.js and Bun.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -47,6 +47,7 @@
47
47
  "@aws-sdk/client-s3": "3.1139.0",
48
48
  "@aws-sdk/s3-request-presigner": "3.1139.0",
49
49
  "@node-rs/argon2": "2.2.1",
50
+ "@vercel/blob": "2.8.2",
50
51
  "bcryptjs": "3.0.3",
51
52
  "better-sqlite3": "13.0.3",
52
53
  "cron-parser": "5.10.1",
@@ -55,40 +56,41 @@
55
56
  "kysely": "0.29.6",
56
57
  "nodemailer": "10.0.10",
57
58
  "pino": "10.3.1",
58
- "pluralize": "8.0.0"
59
+ "pluralize": "8.0.0",
60
+ "uqr": "0.1.3"
59
61
  },
60
62
  "devDependencies": {
61
- "@orieljs/config": "0.1.0-alpha.12",
62
- "@orieljs/auth": "0.1.0-alpha.12",
63
- "@orieljs/collections": "0.1.0-alpha.12",
64
- "@orieljs/bus": "0.1.0-alpha.12",
65
- "@orieljs/contracts": "0.1.0-alpha.12",
66
- "@orieljs/database": "0.1.0-alpha.12",
67
- "@orieljs/cookie": "0.1.0-alpha.12",
68
- "@orieljs/encryption": "0.1.0-alpha.12",
69
- "@orieljs/container": "0.1.0-alpha.12",
70
- "@orieljs/events": "0.1.0-alpha.12",
71
- "@orieljs/foundation": "0.1.0-alpha.12",
72
- "@orieljs/hashing": "0.1.0-alpha.12",
73
- "@orieljs/filesystem": "0.1.0-alpha.12",
74
- "@orieljs/http": "0.1.0-alpha.12",
75
- "@orieljs/inertia": "0.1.0-alpha.12",
76
- "@orieljs/live": "0.1.0-alpha.12",
77
- "@orieljs/log": "0.1.0-alpha.12",
78
- "@orieljs/mail": "0.1.0-alpha.12",
79
- "@orieljs/obfuscate": "0.1.0-alpha.12",
80
- "@orieljs/pipeline": "0.1.0-alpha.12",
81
- "@orieljs/queue": "0.1.0-alpha.12",
82
- "@orieljs/routing": "0.1.0-alpha.12",
83
- "@orieljs/cache": "0.1.0-alpha.12",
84
- "@orieljs/console": "0.1.0-alpha.12",
85
- "@orieljs/notifications": "0.1.0-alpha.12",
86
- "@orieljs/support": "0.1.0-alpha.12",
87
- "@orieljs/testing": "0.1.0-alpha.12",
88
- "@orieljs/validation": "0.1.0-alpha.12",
89
- "@orieljs/vercel": "0.1.0-alpha.12",
90
- "@orieljs/view": "0.1.0-alpha.12",
91
- "@orieljs/session": "0.1.0-alpha.12"
63
+ "@orieljs/auth": "0.1.0-alpha.13",
64
+ "@orieljs/bus": "0.1.0-alpha.13",
65
+ "@orieljs/cache": "0.1.0-alpha.13",
66
+ "@orieljs/config": "0.1.0-alpha.13",
67
+ "@orieljs/collections": "0.1.0-alpha.13",
68
+ "@orieljs/console": "0.1.0-alpha.13",
69
+ "@orieljs/container": "0.1.0-alpha.13",
70
+ "@orieljs/database": "0.1.0-alpha.13",
71
+ "@orieljs/cookie": "0.1.0-alpha.13",
72
+ "@orieljs/encryption": "0.1.0-alpha.13",
73
+ "@orieljs/events": "0.1.0-alpha.13",
74
+ "@orieljs/filesystem": "0.1.0-alpha.13",
75
+ "@orieljs/hashing": "0.1.0-alpha.13",
76
+ "@orieljs/http": "0.1.0-alpha.13",
77
+ "@orieljs/inertia": "0.1.0-alpha.13",
78
+ "@orieljs/live": "0.1.0-alpha.13",
79
+ "@orieljs/log": "0.1.0-alpha.13",
80
+ "@orieljs/mail": "0.1.0-alpha.13",
81
+ "@orieljs/obfuscate": "0.1.0-alpha.13",
82
+ "@orieljs/notifications": "0.1.0-alpha.13",
83
+ "@orieljs/pipeline": "0.1.0-alpha.13",
84
+ "@orieljs/queue": "0.1.0-alpha.13",
85
+ "@orieljs/routing": "0.1.0-alpha.13",
86
+ "@orieljs/session": "0.1.0-alpha.13",
87
+ "@orieljs/support": "0.1.0-alpha.13",
88
+ "@orieljs/testing": "0.1.0-alpha.13",
89
+ "@orieljs/validation": "0.1.0-alpha.13",
90
+ "@orieljs/vercel": "0.1.0-alpha.13",
91
+ "@orieljs/view": "0.1.0-alpha.13",
92
+ "@orieljs/contracts": "0.1.0-alpha.13",
93
+ "@orieljs/foundation": "0.1.0-alpha.13"
92
94
  },
93
95
  "peerDependencies": {
94
96
  "pg": "^8.23.0",