odoro 1.0.9 → 2.1.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.
Files changed (88) hide show
  1. package/README.md +94 -0
  2. package/client.d.ts +90 -16
  3. package/dist/build-XJIP4GNT.js +5 -0
  4. package/dist/{chunk-34RGOZFA.js → chunk-4D56Z7G3.js} +49 -50
  5. package/dist/{chunk-JMEHF3KN.js → chunk-7ZB5MAM6.js} +4 -4
  6. package/dist/chunk-DF67LADH.js +735 -0
  7. package/dist/{chunk-LHZGX5ML.js → chunk-HS7NGZLM.js} +307 -196
  8. package/dist/chunk-LIKVHWB4.js +231 -0
  9. package/dist/chunk-QOVBLN7A.js +241 -0
  10. package/dist/chunk-RTWJ5GFB.js +729 -0
  11. package/dist/chunk-TONYIKCN.js +57 -0
  12. package/dist/cli.d.ts +12 -13
  13. package/dist/cli.js +86 -63
  14. package/dist/{commands-AHMXBWQQ.js → commands-GYA7NTCX.js} +124 -124
  15. package/dist/commands-UOWN3IK4.js +181 -0
  16. package/dist/{create-CYVSVDAD.js → create-B2TLV6XI.js} +198 -160
  17. package/dist/index.d.ts +603 -151
  18. package/dist/index.js +6 -6
  19. package/dist/{package-EIDLKAA6.js → package-3ZJOEXM4.js} +6 -5
  20. package/dist/preview-3IRG7MHV.js +4 -0
  21. package/dist/registry/index.d.ts +75 -76
  22. package/dist/registry/index.js +1 -1
  23. package/dist/{server-4UGN3SFS.js → server-ZB3P3EGS.js} +3 -3
  24. package/package.json +6 -5
  25. package/templates/react-ts/README.md +71 -35
  26. package/templates/react-ts/_env.example +9 -0
  27. package/templates/react-ts/_variants/with-engine/src/background.tsx +118 -0
  28. package/templates/react-ts/_variants/without-libs/src/App.tsx +380 -0
  29. package/templates/react-ts/_variants/without-libs/src/background.tsx +24 -0
  30. package/templates/react-ts/_variants/without-libs/src/entry-server.tsx +39 -0
  31. package/templates/react-ts/_variants/without-libs/src/main.tsx +26 -0
  32. package/templates/{react-ts-server/_variantes/sans-libs/client → react-ts/_variants/without-libs}/src/styles.css +137 -137
  33. package/templates/react-ts/{_variantes/sans-routeur → _variants/without-router}/src/App.tsx +141 -141
  34. package/templates/react-ts/_variants/without-router/src/entry-server.tsx +39 -0
  35. package/templates/react-ts/index.html +4 -4
  36. package/templates/react-ts/odoro.config.ts +5 -0
  37. package/templates/react-ts/src/App.tsx +219 -209
  38. package/templates/react-ts/src/background.tsx +66 -0
  39. package/templates/react-ts/src/entry-server.tsx +85 -0
  40. package/templates/react-ts/src/main.tsx +13 -4
  41. package/templates/react-ts/src/router.tsx +65 -45
  42. package/templates/react-ts/src/styles.css +5 -5
  43. package/templates/react-ts-server/Dockerfile +9 -9
  44. package/templates/react-ts-server/README.md +97 -45
  45. package/templates/react-ts-server/_env.example +31 -33
  46. package/templates/react-ts-server/_variants/with-engine/client/src/background.tsx +118 -0
  47. package/templates/react-ts-server/_variants/without-libs/client/src/App.tsx +380 -0
  48. package/templates/react-ts-server/_variants/without-libs/client/src/background.tsx +24 -0
  49. package/templates/react-ts-server/_variants/without-libs/client/src/entry-server.tsx +39 -0
  50. package/templates/react-ts-server/_variants/without-libs/client/src/main.tsx +26 -0
  51. package/templates/{react-ts/_variantes/sans-libs → react-ts-server/_variants/without-libs/client}/src/styles.css +137 -137
  52. package/templates/react-ts-server/{_variantes/sans-routeur → _variants/without-router}/client/src/App.tsx +141 -141
  53. package/templates/react-ts-server/_variants/without-router/client/src/entry-server.tsx +39 -0
  54. package/templates/react-ts-server/client/index.html +4 -4
  55. package/templates/react-ts-server/client/src/App.tsx +228 -210
  56. package/templates/react-ts-server/client/src/account.tsx +267 -0
  57. package/templates/react-ts-server/client/src/auth.tsx +139 -0
  58. package/templates/react-ts-server/client/src/background.tsx +66 -0
  59. package/templates/react-ts-server/client/src/entry-server.tsx +92 -0
  60. package/templates/react-ts-server/client/src/main.tsx +17 -5
  61. package/templates/react-ts-server/client/src/router.tsx +77 -45
  62. package/templates/react-ts-server/client/src/styles.css +5 -5
  63. package/templates/react-ts-server/odoro.config.ts +7 -4
  64. package/templates/react-ts-server/package.json +2 -0
  65. package/templates/react-ts-server/scripts/dev.mjs +14 -14
  66. package/templates/react-ts-server/server/src/main.ts +119 -45
  67. package/templates/react-ts-server/server/src/modules/auth/index.ts +238 -0
  68. package/templates/react-ts-server/server/src/modules/auth/password.ts +122 -0
  69. package/templates/react-ts-server/server/src/modules/auth/store.ts +193 -0
  70. package/templates/react-ts-server/server/src/modules/health/index.ts +45 -46
  71. package/dist/build-JFQHODAT.js +0 -5
  72. package/dist/chunk-22KJTV2R.js +0 -380
  73. package/dist/chunk-3SZIN6VG.js +0 -64
  74. package/dist/chunk-DL3NPC4H.js +0 -113
  75. package/dist/chunk-TQUJ3MFS.js +0 -268
  76. package/dist/chunk-ZVL7EXJO.js +0 -66
  77. package/dist/commands-4JRBD55Z.js +0 -245
  78. package/dist/preview-7DKEAQOQ.js +0 -4
  79. package/templates/react-ts/_variantes/avec-moteur/src/fond.tsx +0 -118
  80. package/templates/react-ts/_variantes/sans-libs/src/App.tsx +0 -382
  81. package/templates/react-ts/_variantes/sans-libs/src/fond.tsx +0 -23
  82. package/templates/react-ts/_variantes/sans-libs/src/main.tsx +0 -17
  83. package/templates/react-ts/src/fond.tsx +0 -66
  84. package/templates/react-ts-server/_variantes/avec-moteur/client/src/fond.tsx +0 -118
  85. package/templates/react-ts-server/_variantes/sans-libs/client/src/App.tsx +0 -382
  86. package/templates/react-ts-server/_variantes/sans-libs/client/src/fond.tsx +0 -23
  87. package/templates/react-ts-server/_variantes/sans-libs/client/src/main.tsx +0 -17
  88. package/templates/react-ts-server/client/src/fond.tsx +0 -66
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Authentication module — register, sign in, sign out, read your profile.
3
+ *
4
+ * ## What this demonstration is, and what it is not
5
+ *
6
+ * It is a working account system: a password hashed with `scrypt`, a session
7
+ * in a table, an `httpOnly` cookie. It is the shape to start from, and it is
8
+ * deliberately small enough to be read in full before being trusted.
9
+ *
10
+ * It is not a complete product. There is no email confirmation, no password
11
+ * reset, no rate limit on login, no second factor. Each one is a deliberate
12
+ * addition, and naming them here is more honest than implying they are handled.
13
+ *
14
+ * ## Why the session is a cookie, and not a token in the page
15
+ *
16
+ * A token kept in `localStorage` is readable by any script that manages to run
17
+ * on the page. An `httpOnly` cookie is not: the browser sends it and never
18
+ * hands it over. The cost is a `sameSite` policy to think about, which the
19
+ * kernel sets to `lax` — enough to block the ordinary cross-site form post,
20
+ * while an ordinary link still arrives signed in.
21
+ *
22
+ * ## Why signing in says so little
23
+ *
24
+ * A wrong email and a wrong password give the **same** answer. Distinguishing
25
+ * them turns the login form into a way to ask whether an address has an
26
+ * account here — which is exactly what someone assembling a list wants.
27
+ *
28
+ * @module
29
+ */
30
+
31
+ import {
32
+ ConflictError,
33
+ UnauthorizedError,
34
+ defineModule,
35
+ route,
36
+ } from '@odoro-cli/server'
37
+ import type { Request } from 'express'
38
+ import { z } from 'zod'
39
+
40
+ import { hashPassword, verifyPassword } from './password.js'
41
+ import { SESSION_TTL, createAuthStore, type Account, type AuthStore } from './store.js'
42
+
43
+ /** Name of the session cookie. */
44
+ export const SESSION_COOKIE = 'odoro_session'
45
+
46
+ /** What a profile shows. */
47
+ const profile = z.object({
48
+ id: z.string(),
49
+ email: z.string(),
50
+ name: z.string(),
51
+ createdAt: z.string(),
52
+ })
53
+
54
+ /** What registering asks for. */
55
+ const registration = z.object({
56
+ email: z.string().email().max(320),
57
+ name: z.string().min(1).max(80),
58
+ // Length is the only rule that holds: a composition rule pushes people
59
+ // towards "Password1!" and nothing else.
60
+ password: z.string().min(12).max(200),
61
+ })
62
+
63
+ /** What signing in asks for. */
64
+ const credentials = z.object({
65
+ email: z.string().email().max(320),
66
+ password: z.string().min(1).max(200),
67
+ })
68
+
69
+ /** Renders an account as the interface reads it. */
70
+ function render(account: Account): z.infer<typeof profile> {
71
+ return {
72
+ id: account.id,
73
+ email: account.email,
74
+ name: account.name,
75
+ createdAt: account.createdAt.toISOString(),
76
+ }
77
+ }
78
+
79
+ /** Reads the session cookie of a request, without a parser dependency. */
80
+ export function readSessionCookie(request: Request): string | undefined {
81
+ const header = request.headers.cookie
82
+ if (header === undefined) return undefined
83
+
84
+ for (const part of header.split(';')) {
85
+ const cut = part.indexOf('=')
86
+ if (cut === -1) continue
87
+ if (part.slice(0, cut).trim() !== SESSION_COOKIE) continue
88
+ return decodeURIComponent(part.slice(cut + 1).trim())
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ /**
94
+ * Builds the guard the kernel calls on every request.
95
+ *
96
+ * @example
97
+ * createApp({ authenticate: createAuthenticator(store), … })
98
+ */
99
+ export function createAuthenticator(store: AuthStore) {
100
+ return async (request: Request) => {
101
+ const sessionId = readSessionCookie(request)
102
+ if (sessionId === undefined) return undefined
103
+
104
+ const session = await store.readSession(sessionId)
105
+ if (session === undefined) return undefined
106
+
107
+ return { id: session.userId, sessionId, organizationId: undefined }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The module, and the store it owns.
113
+ *
114
+ * The store comes back out because the guard needs it too, and because whoever
115
+ * closes the server has to close its connections.
116
+ *
117
+ * ## Why the store can be handed in
118
+ *
119
+ * By default the module opens its own, on `DATABASE_URL`. A test hands in a
120
+ * different one and drives the four routes without a database — which is the
121
+ * only way to check the cookie, the guard and the answers without provisioning
122
+ * a server for it.
123
+ *
124
+ * @param store The store to use. Absent, one is opened on `DATABASE_URL`.
125
+ *
126
+ * @example
127
+ * const auth = createAuthModule(config)
128
+ * createApp({ modules: [auth.module], authenticate: auth.authenticate, … })
129
+ */
130
+ export function createAuthModule(
131
+ config: { DATABASE_URL: string },
132
+ store: AuthStore = createAuthStore(config.DATABASE_URL),
133
+ ) {
134
+
135
+ const module = defineModule({
136
+ name: 'auth',
137
+ routes: [
138
+ route({
139
+ name: 'auth.register',
140
+ method: 'POST',
141
+ path: '/api/auth/register',
142
+ auth: 'public',
143
+ summary: 'Creates an account and signs it in.',
144
+ input: registration,
145
+ output: profile,
146
+ handler: async ({ input, cookies }) => {
147
+ const email = input.email.trim().toLowerCase()
148
+
149
+ if ((await store.byEmail(email)) !== undefined) {
150
+ throw new ConflictError('This address already has an account.')
151
+ }
152
+
153
+ const account = await store.create(
154
+ email,
155
+ input.name.trim(),
156
+ await hashPassword(input.password),
157
+ )
158
+
159
+ cookies.set(SESSION_COOKIE, await store.openSession(account.id), {
160
+ maxAge: SESSION_TTL,
161
+ })
162
+
163
+ return render(account)
164
+ },
165
+ }),
166
+
167
+ route({
168
+ name: 'auth.login',
169
+ method: 'POST',
170
+ path: '/api/auth/login',
171
+ auth: 'public',
172
+ summary: 'Signs in.',
173
+ input: credentials,
174
+ output: profile,
175
+ handler: async ({ input, cookies }) => {
176
+ const account = await store.byEmail(input.email.trim().toLowerCase())
177
+
178
+ // The same answer in both cases, and the hash is computed even when
179
+ // the account is absent: answering faster for an unknown address
180
+ // would tell, by the delay alone, which addresses exist.
181
+ const stored =
182
+ account?.password ??
183
+ 'scrypt$65536$8$1$00000000000000000000000000000000$00'
184
+ const matches = await verifyPassword(input.password, stored)
185
+
186
+ if (account === undefined || !matches) {
187
+ throw new UnauthorizedError('Wrong address or password.')
188
+ }
189
+
190
+ cookies.set(SESSION_COOKIE, await store.openSession(account.id), {
191
+ maxAge: SESSION_TTL,
192
+ })
193
+
194
+ return render(account)
195
+ },
196
+ }),
197
+
198
+ route({
199
+ name: 'auth.logout',
200
+ method: 'POST',
201
+ path: '/api/auth/logout',
202
+ auth: 'optional',
203
+ summary: 'Signs out.',
204
+ handler: async ({ user, cookies }) => {
205
+ // The cookie goes in every case: a session already unknown to the
206
+ // table must not leave a cookie behind that the browser keeps
207
+ // sending for a month.
208
+ if (user !== undefined) await store.closeSession(user.sessionId)
209
+ cookies.clear(SESSION_COOKIE)
210
+ },
211
+ }),
212
+
213
+ route({
214
+ name: 'auth.me',
215
+ method: 'GET',
216
+ path: '/api/auth/me',
217
+ auth: 'required',
218
+ summary: 'The profile of the signed-in account.',
219
+ output: profile,
220
+ handler: async ({ user, cookies }) => {
221
+ const account = await store.byId(user.id)
222
+
223
+ // The session names an account that is gone: the cookie is stale,
224
+ // and keeping it would make every request fail the same way.
225
+ if (account === undefined) {
226
+ await store.closeSession(user.sessionId)
227
+ cookies.clear(SESSION_COOKIE)
228
+ throw new UnauthorizedError('This account no longer exists.')
229
+ }
230
+
231
+ return render(account)
232
+ },
233
+ }),
234
+ ] as never,
235
+ })
236
+
237
+ return { module, store, authenticate: createAuthenticator(store) }
238
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Hashing and verifying a password.
3
+ *
4
+ * ## Why there is no dependency here
5
+ *
6
+ * `scrypt` is in the Node standard library, and it is a password hash designed
7
+ * for the job: slow on purpose, and costly in memory, which is what makes a
8
+ * rented GPU farm a bad deal for whoever steals the table.
9
+ *
10
+ * Argon2 and bcrypt are fine choices too. They are native modules: a compiler
11
+ * on every machine, a rebuild on every Node upgrade, and a binary in the image.
12
+ * For the same guarantee, that is a price worth not paying.
13
+ *
14
+ * ## What is stored
15
+ *
16
+ * `scrypt$N$r$p$salt$hash`, everything in hexadecimal. The parameters travel
17
+ * with the hash: the day they are raised, the passwords already stored keep
18
+ * verifying with their own, and each one migrates at its owner's next login.
19
+ * A hash that does not carry its parameters cannot be migrated without asking
20
+ * everyone to reset.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'
26
+ import { promisify } from 'node:util'
27
+
28
+ /**
29
+ * `scrypt`, promised.
30
+ *
31
+ * The signature is written out: `promisify` keeps only the first overload of
32
+ * `scrypt`, the one without options — and the options are exactly what carries
33
+ * the cost here.
34
+ */
35
+ const derive = promisify(scrypt) as (
36
+ password: string,
37
+ salt: Buffer,
38
+ keyLength: number,
39
+ options: ScryptOptions,
40
+ ) => Promise<Buffer>
41
+
42
+ /**
43
+ * Cost of the derivation.
44
+ *
45
+ * `N = 2^16` takes about a hundred milliseconds on an ordinary server, and
46
+ * asks for 64 MB of memory. That is the knob to turn as machines get faster —
47
+ * the stored hashes will follow on their own.
48
+ */
49
+ const COST = { N: 65_536, r: 8, p: 1, keyLength: 32 } as const
50
+
51
+ /** Length of the salt, in bytes. */
52
+ const SALT = 16
53
+
54
+ /**
55
+ * Hashes a password.
56
+ *
57
+ * @example
58
+ * const stored = await hashPassword('correct horse battery staple')
59
+ */
60
+ export async function hashPassword(password: string): Promise<string> {
61
+ const salt = randomBytes(SALT)
62
+ const key = await derive(password, salt, COST.keyLength, {
63
+ N: COST.N,
64
+ r: COST.r,
65
+ p: COST.p,
66
+ // `scrypt` refuses beyond a default ceiling well below what N = 2^16 asks
67
+ // for. Raising it is not a weakening: it is the memory the cost demands.
68
+ maxmem: 256 * COST.N * COST.r,
69
+ })
70
+
71
+ return ['scrypt', COST.N, COST.r, COST.p, salt.toString('hex'), key.toString('hex')].join(
72
+ '$',
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Verifies a password against a stored hash.
78
+ *
79
+ * ## Why it never throws
80
+ *
81
+ * A malformed hash — a truncated row, a migration half done — is a failed
82
+ * verification, not a crash. Throwing here would turn a corrupt row into a
83
+ * 500 on a login page, which tells an attacker that this account is special.
84
+ *
85
+ * @returns `true` if the password matches.
86
+ *
87
+ * @example
88
+ * await verifyPassword('correct horse battery staple', stored) // true
89
+ */
90
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
91
+ const parts = stored.split('$')
92
+ if (parts.length !== 6 || parts[0] !== 'scrypt') return false
93
+
94
+ const [, rawN, rawR, rawP, rawSalt, rawKey] = parts
95
+ const N = Number(rawN)
96
+ const r = Number(rawR)
97
+ const p = Number(rawP)
98
+ if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return false
99
+
100
+ let expected: Buffer
101
+ try {
102
+ expected = Buffer.from(rawKey ?? '', 'hex')
103
+ if (expected.length === 0) return false
104
+ } catch {
105
+ return false
106
+ }
107
+
108
+ try {
109
+ const key = await derive(password, Buffer.from(rawSalt ?? '', 'hex'), expected.length, {
110
+ N,
111
+ r,
112
+ p,
113
+ maxmem: 256 * N * r,
114
+ })
115
+
116
+ // Constant time: a comparison that stops at the first differing byte tells,
117
+ // by how long it took, how many bytes were right.
118
+ return timingSafeEqual(key, expected)
119
+ } catch {
120
+ return false
121
+ }
122
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Where the accounts and the sessions live.
3
+ *
4
+ * ## PostgreSQL, and nothing else
5
+ *
6
+ * There is no local database in this stack: `DATABASE_URL` points at a hosted
7
+ * one. So this module refuses clearly when it is missing, rather than falling
8
+ * back to memory — a demo that works without a database teaches a shape that
9
+ * stops working the day it is deployed, and does so silently.
10
+ *
11
+ * ## The schema creates itself, once
12
+ *
13
+ * Two tables, created on first use. That is enough for a demo and for a first
14
+ * deployment; it is not a migration tool. The day the schema changes shape,
15
+ * the project needs a real one — and that day, this file is the one to delete.
16
+ *
17
+ * ## What is stored of a session
18
+ *
19
+ * Its **fingerprint**, not the session itself. Whoever reads the table — a
20
+ * backup, a support query, a leak — holds hashes, and a hash does not open a
21
+ * session. The identifier itself only ever exists in the cookie.
22
+ *
23
+ * @module
24
+ */
25
+
26
+ import { createHash, randomBytes } from 'node:crypto'
27
+
28
+ import { ServiceUnavailableError } from '@odoro-cli/server'
29
+ import pg from 'pg'
30
+
31
+ /** An account, as the rest of the module reads it. */
32
+ export interface Account {
33
+ readonly id: string
34
+ readonly email: string
35
+ readonly name: string
36
+ readonly createdAt: Date
37
+ }
38
+
39
+ /** An account and the hash that guards it. */
40
+ interface AccountRow extends Account {
41
+ readonly password: string
42
+ }
43
+
44
+ /** How long a session lives, in seconds. */
45
+ export const SESSION_TTL = 60 * 60 * 24 * 30
46
+
47
+ /** The schema, created on first use. */
48
+ const SCHEMA = `
49
+ CREATE TABLE IF NOT EXISTS app_user (
50
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
51
+ email text NOT NULL UNIQUE,
52
+ name text NOT NULL,
53
+ password text NOT NULL,
54
+ created_at timestamptz NOT NULL DEFAULT now()
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS app_session (
58
+ id text PRIMARY KEY,
59
+ user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
60
+ expires_at timestamptz NOT NULL,
61
+ created_at timestamptz NOT NULL DEFAULT now()
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS app_session_user ON app_session(user_id);
65
+ `
66
+
67
+ /** The fingerprint under which a session identifier is stored. */
68
+ function fingerprint(id: string): string {
69
+ return createHash('sha256').update(id).digest('hex')
70
+ }
71
+
72
+ /** What the rest of the module calls. */
73
+ export interface AuthStore {
74
+ create(email: string, name: string, password: string): Promise<Account>
75
+ byEmail(email: string): Promise<AccountRow | undefined>
76
+ byId(id: string): Promise<Account | undefined>
77
+ openSession(userId: string): Promise<string>
78
+ readSession(sessionId: string): Promise<{ userId: string } | undefined>
79
+ closeSession(sessionId: string): Promise<void>
80
+ dispose(): Promise<void>
81
+ }
82
+
83
+ /**
84
+ * Opens the store.
85
+ *
86
+ * @param url The `DATABASE_URL`. Empty, every call refuses with a 503 that
87
+ * names what is missing — the interface still starts, and says so.
88
+ *
89
+ * @example
90
+ * const store = createAuthStore(config.DATABASE_URL)
91
+ * await store.create('a@b.c', 'Ada', 'correct horse battery staple')
92
+ */
93
+ export function createAuthStore(url: string): AuthStore {
94
+ const configured = url.trim().length > 0
95
+
96
+ const absent = (): never => {
97
+ throw new ServiceUnavailableError(
98
+ 'DATABASE_URL missing: the accounts have nowhere to live. ' +
99
+ 'See .env.example, or run `odoro db:create`.',
100
+ )
101
+ }
102
+
103
+ const pool = configured
104
+ ? new pg.Pool({
105
+ connectionString: url,
106
+ // A demo does not need twenty connections, and a hosted database
107
+ // counts them.
108
+ max: 5,
109
+ })
110
+ : undefined
111
+
112
+ let ready: Promise<void> | undefined
113
+
114
+ /** Creates the schema, once per process. */
115
+ const ensure = async (): Promise<pg.Pool> => {
116
+ if (pool === undefined) return absent()
117
+ ready ??= pool.query(SCHEMA).then(() => undefined)
118
+ await ready
119
+ return pool
120
+ }
121
+
122
+ return {
123
+ async create(email, name, password) {
124
+ const db = await ensure()
125
+ const { rows } = await db.query<Account>(
126
+ `INSERT INTO app_user (email, name, password)
127
+ VALUES ($1, $2, $3)
128
+ RETURNING id, email, name, created_at AS "createdAt"`,
129
+ [email, name, password],
130
+ )
131
+ // The row is there: `INSERT ... RETURNING` either returns it or throws.
132
+ return rows[0] as Account
133
+ },
134
+
135
+ async byEmail(email) {
136
+ const db = await ensure()
137
+ const { rows } = await db.query<AccountRow>(
138
+ `SELECT id, email, name, password, created_at AS "createdAt"
139
+ FROM app_user WHERE email = $1`,
140
+ [email],
141
+ )
142
+ return rows[0]
143
+ },
144
+
145
+ async byId(id) {
146
+ const db = await ensure()
147
+ const { rows } = await db.query<Account>(
148
+ `SELECT id, email, name, created_at AS "createdAt"
149
+ FROM app_user WHERE id = $1`,
150
+ [id],
151
+ )
152
+ return rows[0]
153
+ },
154
+
155
+ async openSession(userId) {
156
+ const db = await ensure()
157
+
158
+ // Expired sessions go now, on a query that has nothing else to do. A
159
+ // scheduled job would be better; this is enough not to keep them
160
+ // forever, and it costs one index scan per login.
161
+ await db.query(`DELETE FROM app_session WHERE expires_at <= now()`)
162
+
163
+ // 256 bits drawn from the system source. This value never lands in the
164
+ // table: only its fingerprint does.
165
+ const id = randomBytes(32).toString('base64url')
166
+ await db.query(
167
+ `INSERT INTO app_session (id, user_id, expires_at)
168
+ VALUES ($1, $2, now() + make_interval(secs => $3))`,
169
+ [fingerprint(id), userId, SESSION_TTL],
170
+ )
171
+ return id
172
+ },
173
+
174
+ async readSession(sessionId) {
175
+ const db = await ensure()
176
+ const { rows } = await db.query<{ userId: string }>(
177
+ `SELECT user_id AS "userId" FROM app_session
178
+ WHERE id = $1 AND expires_at > now()`,
179
+ [fingerprint(sessionId)],
180
+ )
181
+ return rows[0]
182
+ },
183
+
184
+ async closeSession(sessionId) {
185
+ const db = await ensure()
186
+ await db.query(`DELETE FROM app_session WHERE id = $1`, [fingerprint(sessionId)])
187
+ },
188
+
189
+ async dispose() {
190
+ await pool?.end()
191
+ },
192
+ }
193
+ }