@drhomet/mithaq 0.0.0-stage → 0.2.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 (152) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/README.md +160 -2
  6. package/dist/asas/actor.d.ts +96 -0
  7. package/dist/asas/actor.js +114 -0
  8. package/dist/asas/build.d.ts +239 -0
  9. package/dist/asas/build.js +834 -0
  10. package/dist/asas/cli/mithaq.d.ts +2 -0
  11. package/dist/asas/cli/mithaq.js +177 -0
  12. package/dist/asas/cli/upgrade.d.ts +33 -0
  13. package/dist/asas/cli/upgrade.js +256 -0
  14. package/dist/asas/http/fetch.d.ts +41 -0
  15. package/dist/asas/http/fetch.js +178 -0
  16. package/dist/asas/http/orpc.d.ts +45 -0
  17. package/dist/asas/http/orpc.js +153 -0
  18. package/dist/asas/runner/bulk.d.ts +20 -0
  19. package/dist/asas/runner/bulk.js +196 -0
  20. package/dist/asas/runner/errors.d.ts +63 -0
  21. package/dist/asas/runner/errors.js +75 -0
  22. package/dist/asas/runner/read.d.ts +72 -0
  23. package/dist/asas/runner/read.js +601 -0
  24. package/dist/asas/runner/runtime.d.ts +21 -0
  25. package/dist/asas/runner/runtime.js +8 -0
  26. package/dist/asas/runner/steps.d.ts +418 -0
  27. package/dist/asas/runner/steps.js +277 -0
  28. package/dist/asas/runner/store.d.ts +4 -0
  29. package/dist/asas/runner/store.js +21 -0
  30. package/dist/asas/runner/tenant.d.ts +54 -0
  31. package/dist/asas/runner/tenant.js +275 -0
  32. package/dist/asas/runner/write.d.ts +606 -0
  33. package/dist/asas/runner/write.js +1019 -0
  34. package/dist/auth/better-auth/access.d.ts +29 -0
  35. package/dist/auth/better-auth/access.js +103 -0
  36. package/dist/auth/better-auth/generate.d.ts +31 -0
  37. package/dist/auth/better-auth/generate.js +328 -0
  38. package/dist/auth/better-auth/index.d.ts +94 -0
  39. package/dist/auth/better-auth/index.js +81 -0
  40. package/dist/auth/better-auth/tenant.d.ts +26 -0
  41. package/dist/auth/better-auth/tenant.js +34 -0
  42. package/dist/auth/local/index.d.ts +285 -0
  43. package/dist/auth/local/index.js +738 -0
  44. package/dist/base/decimal.d.ts +9 -0
  45. package/dist/base/decimal.js +16 -0
  46. package/dist/base/fields.d.ts +124 -0
  47. package/dist/base/fields.js +52 -0
  48. package/dist/base/index.d.ts +47 -0
  49. package/dist/base/index.js +5 -0
  50. package/dist/base/json-schema.d.ts +7 -0
  51. package/dist/base/json-schema.js +35 -0
  52. package/dist/base/kinds.d.ts +75 -0
  53. package/dist/base/kinds.js +123 -0
  54. package/dist/base/rules.d.ts +73 -0
  55. package/dist/base/rules.js +81 -0
  56. package/dist/drizzle/index.d.ts +308 -0
  57. package/dist/drizzle/index.js +951 -0
  58. package/dist/drizzle/kinds.d.ts +28 -0
  59. package/dist/drizzle/kinds.js +43 -0
  60. package/dist/drizzle/migrate.d.ts +159 -0
  61. package/dist/drizzle/migrate.js +1328 -0
  62. package/dist/drizzle/proxy/index.d.ts +1 -0
  63. package/dist/drizzle/proxy/index.js +1 -0
  64. package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
  65. package/dist/drizzle/proxy/node-postgres.js +1 -0
  66. package/dist/drizzle/proxy/pg-core.d.ts +1 -0
  67. package/dist/drizzle/proxy/pg-core.js +1 -0
  68. package/dist/drizzle/rules.d.ts +336 -0
  69. package/dist/drizzle/rules.js +44 -0
  70. package/dist/index.d.ts +35 -0
  71. package/dist/index.js +26 -0
  72. package/dist/mithaq/define.d.ts +335 -0
  73. package/dist/mithaq/define.js +57 -0
  74. package/dist/mithaq/generate-messages.d.ts +38 -0
  75. package/dist/mithaq/generate-messages.js +235 -0
  76. package/dist/mithaq/language.d.ts +374 -0
  77. package/dist/mithaq/language.js +76 -0
  78. package/dist/mithaq/lock.d.ts +71 -0
  79. package/dist/mithaq/lock.js +329 -0
  80. package/dist/mithaq/message-file.d.ts +13 -0
  81. package/dist/mithaq/message-file.js +29 -0
  82. package/dist/mithaq/messages.d.ts +55 -0
  83. package/dist/mithaq/messages.js +111 -0
  84. package/dist/mithaq/refusal.d.ts +6 -0
  85. package/dist/mithaq/refusal.js +6 -0
  86. package/dist/mithaq/resolve.d.ts +10 -0
  87. package/dist/mithaq/resolve.js +1014 -0
  88. package/dist/mithaq/rows.d.ts +87 -0
  89. package/dist/mithaq/rows.js +174 -0
  90. package/dist/mithaq/rukn.d.ts +72 -0
  91. package/dist/mithaq/rukn.js +15 -0
  92. package/dist/mithaq/standard-schema.d.ts +38 -0
  93. package/dist/mithaq/standard-schema.js +2 -0
  94. package/dist/mithaq/types.d.ts +386 -0
  95. package/dist/mithaq/types.js +15 -0
  96. package/dist/mithaq/user.d.ts +19 -0
  97. package/dist/mithaq/user.js +16 -0
  98. package/dist/mithaq/where.d.ts +32 -0
  99. package/dist/mithaq/where.js +87 -0
  100. package/dist/rbac/index.d.ts +105 -0
  101. package/dist/rbac/index.js +182 -0
  102. package/dist/rbac/steps.d.ts +22 -0
  103. package/dist/rbac/steps.js +161 -0
  104. package/dist/rich-text/index.d.ts +28 -0
  105. package/dist/rich-text/index.js +59 -0
  106. package/dist/rich-text/rich-text.d.ts +106 -0
  107. package/dist/rich-text/rich-text.js +249 -0
  108. package/dist/rich-text/value.d.ts +55 -0
  109. package/dist/rich-text/value.js +126 -0
  110. package/dist/screen/client.d.ts +156 -0
  111. package/dist/screen/client.js +71 -0
  112. package/dist/screen/generate.d.ts +14 -0
  113. package/dist/screen/generate.js +268 -0
  114. package/dist/screen/index.d.ts +496 -0
  115. package/dist/screen/index.js +663 -0
  116. package/dist/screen/react.d.ts +97 -0
  117. package/dist/screen/react.js +58 -0
  118. package/dist/screen/studio.d.ts +24 -0
  119. package/dist/screen/studio.js +68 -0
  120. package/dist/service/index.d.ts +63 -0
  121. package/dist/service/index.js +15 -0
  122. package/dist/storage/allowed-type.d.ts +5 -0
  123. package/dist/storage/allowed-type.js +7 -0
  124. package/dist/storage/index.d.ts +42 -0
  125. package/dist/storage/index.js +43 -0
  126. package/dist/storage/storage.d.ts +386 -0
  127. package/dist/storage/storage.js +412 -0
  128. package/dist/targets.d.ts +14 -0
  129. package/dist/targets.js +16 -0
  130. package/dist/versions/index.d.ts +37 -0
  131. package/dist/versions/index.js +8 -0
  132. package/dist/versions/versions.d.ts +104 -0
  133. package/dist/versions/versions.js +160 -0
  134. package/dist/vite.d.ts +7 -0
  135. package/dist/vite.js +81 -0
  136. package/dist/zod/index.d.ts +193 -0
  137. package/dist/zod/index.js +122 -0
  138. package/dist/zod/kinds.d.ts +36 -0
  139. package/dist/zod/kinds.js +58 -0
  140. package/dist/zod/rules.d.ts +117 -0
  141. package/dist/zod/rules.js +67 -0
  142. package/docs/packages/mithaq/README.md +962 -0
  143. package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
  144. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  145. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  146. package/docs/packages/mithaq-admin/README.md +312 -0
  147. package/docs/templates/asas/README.md +1056 -0
  148. package/package.json +92 -4
  149. package/skills/add-field/SKILL.md +27 -0
  150. package/skills/add-kayan/SKILL.md +26 -0
  151. package/skills/eject-admin-piece/SKILL.md +28 -0
  152. package/skills/upgrade-mithaq/SKILL.md +35 -0
@@ -0,0 +1,738 @@
1
+ import crypto, { timingSafeEqual, randomBytes, randomUUID } from 'node:crypto';
2
+ import { and, eq, isNull, sql } from 'drizzle-orm';
3
+ import { SignJWT, jwtVerify } from 'jose';
4
+ import { declaredFieldAccess } from '../../mithaq/rows.js';
5
+ import { providerOf } from '../../base/rules.js';
6
+ import { accessReport, isAdminActor, markAuthActor, resolveAssigned } from '../../asas/actor.js';
7
+ import { AsasError, codes, retryConflict, denied as refused, Notice } from '../../asas/runner/errors.js';
8
+ import { firstRegisters, running } from '../../asas/runner/runtime.js';
9
+ import { drain } from '../../asas/runner/write.js';
10
+ import { insert, neverRead, sealOf, update } from '../../asas/runner/steps.js';
11
+ // Random and made once per process, so a development token dies with the process
12
+ // and nobody who reads this file can forge one.
13
+ const developmentSecret = randomBytes(32).toString('base64url');
14
+ // Read off the module at each call, so a test can count the calls a login makes.
15
+ const scrypt = (password, salt, options) => new Promise((resolve, reject) => crypto.scrypt(password, salt, 64, options, (error, key) => (error ? reject(error) : resolve(key))));
16
+ /** How long a token and its session live, in seconds. */
17
+ export const defaultTokenExpiration = 7200;
18
+ /** Failed logins before the account locks. */
19
+ export const defaultMaxLoginAttempts = 5;
20
+ /** How long the lock holds, in milliseconds. */
21
+ export const defaultLockTime = 600000;
22
+ // A declaration with neither member is a store that lied about what it is.
23
+ export const isLocalAuthTarget = (target) => target.sql === 'drizzle' && 'db' in target && 'tables' in target;
24
+ // Compared by identity: a callback could answer `false` at build and `true` later.
25
+ const denied = neverRead;
26
+ // Only the administrator sets `_verified`; a write of `false` is admitted from anyone,
27
+ // because it only ever closes an account.
28
+ const verifiedWrite = ({ user, siblingData }) => isAdminActor(user) || siblingData?._verified === false;
29
+ // A field callback here would drop the value the hash step or the mint wrote
30
+ // when the runner asks it again at the store step.
31
+ const application = { create: 'application', update: 'application' };
32
+ const closed = { read: denied };
33
+ /**
34
+ * The fields a kayan carrying `local()` spreads beside `defaultFields`. Every one but `email`
35
+ * and `_verified` is closed on read and supplied by the provider alone.
36
+ */
37
+ export const localAuthFields = {
38
+ email: { kind: 'email', rules: { required: true, unique: true, lowercase: true, trim: true } },
39
+ hash: { kind: 'text', access: closed, rules: { required: true, providedBy: application } },
40
+ salt: { kind: 'text', access: closed, rules: { required: true, providedBy: application } },
41
+ sessions: { kind: 'text', access: closed, rules: { providedBy: application } },
42
+ loginAttempts: { kind: 'number', access: closed, rules: { defaultValue: 0, providedBy: application } },
43
+ lockUntil: { kind: 'datetime', access: closed, rules: { providedBy: application } },
44
+ resetPasswordToken: { kind: 'text', access: closed, rules: { providedBy: application } },
45
+ resetPasswordAttempts: { kind: 'number', access: closed, rules: { defaultValue: 0, providedBy: application } },
46
+ resetPasswordExpiration: { kind: 'datetime', access: closed, rules: { providedBy: application } },
47
+ _verified: { kind: 'checkbox', access: { create: verifiedWrite, update: verifiedWrite }, rules: { defaultValue: false } },
48
+ _verificationToken: { kind: 'text', access: closed, rules: { providedBy: application } },
49
+ };
50
+ const requiredFields = Object.keys(localAuthFields);
51
+ // Read once at load, so a project that reassigns a key of the exported value
52
+ // cannot move what the build compares against.
53
+ const shippedAccess = requiredFields.flatMap((name) => Object.entries(localAuthFields[name].access ?? {}).map(([operation, callback]) => ({ name, operation, callback })));
54
+ const protectedFields = shippedAccess.filter(({ operation }) => operation === 'read').map(({ name }) => name);
55
+ /** The columns the provider supplies, read once at load for the reason `shippedAccess` is. */
56
+ const suppliedFields = requiredFields.filter((name) => providerOf(localAuthFields[name], 'create') === 'application');
57
+ const json = (body, init) => Response.json(body, { ...init, headers: { 'content-type': 'application/json', ...init?.headers } });
58
+ // The `{ code, message }` body every other refusal of the app has.
59
+ const refusal = (status, message, headers) => json({ code: codes[status], message }, { status, ...(headers && { headers }) });
60
+ const parseCookies = (headers) => {
61
+ const map = new Map();
62
+ for (const pair of (headers.get('cookie') ?? '').split(/; */)) {
63
+ const splitAt = pair.indexOf('=');
64
+ try {
65
+ if (splitAt > 0)
66
+ map.set(pair.slice(0, splitAt), decodeURIComponent(pair.slice(splitAt + 1)));
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ }
72
+ return map;
73
+ };
74
+ const cookie = (name, value, maxAge, secure) => `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
75
+ // Never an empty key, which anyone could sign with. WebCrypto on Node already
76
+ // refuses one; this says so on every runtime.
77
+ const keyOf = (secret) => {
78
+ if (!secret)
79
+ throw new Error('[mithaq local auth] No secret to sign or verify with.');
80
+ return new TextEncoder().encode(secret);
81
+ };
82
+ const sign = (secret, token, maxAge) => new SignJWT({ collection: token.collection, id: token.id, sid: token.sid })
83
+ .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
84
+ .setIssuedAt()
85
+ .setExpirationTime(`${maxAge}s`)
86
+ .sign(keyOf(secret));
87
+ const verify = async (secret, signed) => {
88
+ try {
89
+ const { payload } = await jwtVerify(signed, keyOf(secret), { algorithms: ['HS256'] });
90
+ return payload.id !== undefined && payload.id !== null && typeof payload.sid === 'string' && typeof payload.collection === 'string'
91
+ ? { collection: payload.collection, id: payload.id, sid: payload.sid }
92
+ : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ };
98
+ // OWASP's minimum for scrypt. It needs 128 MiB, past Node's 32 MiB default
99
+ // `maxmem`, so the ceiling is twice that and a stored hash asking for more fails.
100
+ const cost = { N: 2 ** 17, r: 8, p: 1 };
101
+ const maxmem = 256 * 1024 * 1024;
102
+ /** A hash stored before the parameters were: Node's defaults. */
103
+ const legacyCost = { N: 16384, r: 8, p: 1 };
104
+ // Only two costs are read back: `p` costs time and almost no memory, so a stored
105
+ // `scrypt$524288$2$524288$` would hold a thread for a day. Any other set never matches.
106
+ const costOf = (stored) => {
107
+ const [tag, N, r, p, key] = stored.split('$');
108
+ if (tag !== 'scrypt' || key === undefined)
109
+ return { cost: legacyCost, key: stored };
110
+ const named = [cost, legacyCost].find((known) => `${N}$${r}$${p}` === `${known.N}$${known.r}$${known.p}`);
111
+ return named ? { cost: named, key } : { cost, key: '' };
112
+ };
113
+ const saltAndHash = async (password) => {
114
+ const salt = randomBytes(16).toString('base64url');
115
+ const key = await scrypt(password, salt, { ...cost, maxmem });
116
+ return { salt, hash: `scrypt$${cost.N}$${cost.r}$${cost.p}$${key.toString('base64url')}` };
117
+ };
118
+ // The rehash runs whether or not the password matches, so a wrong password for an
119
+ // older hash takes as long as one for a current hash.
120
+ const checkPassword = async (password, salt, hash) => {
121
+ const stored = costOf(hash);
122
+ const current = stored.cost === cost;
123
+ const [checked, upgrade] = await Promise.all([
124
+ scrypt(password, salt, { ...stored.cost, maxmem }),
125
+ current ? null : saltAndHash(password),
126
+ ]);
127
+ const key = Buffer.from(stored.key, 'base64url');
128
+ return { matched: key.length === checked.length && timingSafeEqual(key, checked), upgrade };
129
+ };
130
+ // Scrypt runs for an unknown address too, so the time an answer takes does not
131
+ // separate a known address from an unknown one.
132
+ const absentSalt = 'mithaq-local-auth-absent-account';
133
+ const absentHash = `scrypt$${cost.N}$${cost.r}$${cost.p}$mithaq-local-auth-absent-account`;
134
+ // Reset messages one address gets per token hour. The lockout columns are left alone:
135
+ // writing them from `forgot-password` would let anyone lock an owner out by asking for resets.
136
+ const maxResets = 3;
137
+ // Counted before the body is read or a row looked up, so an unknown address and a
138
+ // wrong password spend the same count, and the 429 comes before scrypt runs.
139
+ const rateLimit = { max: 10, window: 60_000, entries: 10_000 };
140
+ const limitedPaths = new Set(['login', 'forgot-password', 'reset-password']);
141
+ const freshToken = () => randomBytes(20).toString('hex');
142
+ const storedSessions = (row) => {
143
+ if (typeof row.sessions !== 'string')
144
+ return [];
145
+ try {
146
+ const sessions = JSON.parse(row.sessions);
147
+ return Array.isArray(sessions) ? sessions : [];
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ };
153
+ const sessionsOf = (row) => storedSessions(row).filter((session) => new Date(session.expiresAt).getTime() > Date.now());
154
+ const clean = (row) => Object.fromEntries(Object.entries(row).filter(([name]) => !protectedFields.includes(name)));
155
+ const asDate = (value) => (value instanceof Date ? value : typeof value === 'string' ? new Date(value) : null);
156
+ const locked = (until) => {
157
+ const date = asDate(until);
158
+ return !!date && Number.isFinite(date.getTime()) && date.getTime() > Date.now();
159
+ };
160
+ // `clean(row)`, so `send` never sees the hash, the salt or the other token. Awaited
161
+ // inside the transaction that wrote the token, so a `send` that throws rolls it back.
162
+ const hand = async (options, kind, email, token, user) => {
163
+ if (options.email)
164
+ return options.email.send({ to: email, kind, token, user: clean(user) });
165
+ // Development only, so no token reaches a production log.
166
+ if (!options.development)
167
+ throw new Error(`[mithaq local auth] A ${kind} token has nowhere to go: no local({ email }), and not development.`);
168
+ console.log(`[mithaq local auth] ${kind} token for "${email}": ${token}`);
169
+ console.warn('[mithaq local auth] Email attempted without being configured.'
170
+ + ` Pass local({ email }).send to be handed the ${kind} token instead of printing it.`);
171
+ };
172
+ // Outside development with no `email`, no verification or reset token is minted at all.
173
+ const mails = (options) => !!options.email || !!options.development;
174
+ const optionsOf = (kayan) => kayan.auth.options;
175
+ /** Whether local auth signs nothing: no secret, and not development (`withoutSecret`). */
176
+ const unsignedOf = (options) => options.secret === undefined && !options.development;
177
+ // A ceiling, so no caller hands scrypt a body of any size.
178
+ const passwordLength = { min: 8, max: 1024 };
179
+ const passwordRefusal = `A password of ${passwordLength.min} to ${passwordLength.max} characters is required.`;
180
+ const isPassword = (value) => typeof value === 'string' && value.length >= passwordLength.min && value.length <= passwordLength.max;
181
+ // The `password` key is taken off the value and checked here; the rest goes to the
182
+ // derived shape, which stays strict.
183
+ const withPassword = (shape, required) => ({
184
+ '~standard': {
185
+ version: 1,
186
+ vendor: shape['~standard'].vendor,
187
+ validate: async (value) => {
188
+ if (!value || typeof value !== 'object' || Array.isArray(value))
189
+ return shape['~standard'].validate(value);
190
+ const { password, ...rest } = value;
191
+ const own = password === undefined ? !required : isPassword(password);
192
+ const result = await shape['~standard'].validate(rest);
193
+ const issues = [...(own ? [] : [{ message: passwordRefusal, path: ['password'] }]), ...(result.issues ?? [])];
194
+ if (issues.length)
195
+ return { issues };
196
+ return { value: { ...result.value, ...(password === undefined ? {} : { password }) } };
197
+ },
198
+ },
199
+ });
200
+ // The key is deleted, so no later step, `send` or audit entry is handed the plaintext.
201
+ // It runs after the parse and the runner's field access.
202
+ const hashPassword = async (ctx) => {
203
+ // Without a secret no account is made, whoever asks, and the refusal comes before scrypt runs.
204
+ // An update still runs, so a super admin can verify a waiting account.
205
+ const options = optionsOf(ctx.kayan);
206
+ if (!ctx.dataBefore && unsignedOf(options)) {
207
+ throw refused({ message: String(options.withoutSecret), key: 'error.forbidden.local_auth_off' });
208
+ }
209
+ const { password, ...data } = ctx.data;
210
+ return { ...ctx, data: typeof password === 'string' ? { ...data, ...(await saltAndHash(password)) } : data };
211
+ };
212
+ // A step is handed the runner's wrapped store, so every open entry is asked; at
213
+ // depth 1 inside first-register the only one is that entry.
214
+ const registeringFirst = (ctx) => ctx.context.depth === 1 && [...(running.getStore()?.transactions?.values() ?? [])].some((open) => firstRegisters.has(open));
215
+ // A create is forced unverified unless the administrator sent it verified; an update
216
+ // that moves the address unverifies it. Minted after the parse, so no caller picks a token.
217
+ const mintVerification = (ctx) => {
218
+ const before = ctx.dataBefore;
219
+ const moved = !!before && 'email' in ctx.data && ctx.data.email !== before.email;
220
+ if (before && !moved) {
221
+ return ctx.data._verified === true && before._verified !== true ? { ...ctx, data: { ...ctx.data, _verificationToken: null } } : ctx;
222
+ }
223
+ // First-register's own create is verified once written, so it is minted nothing.
224
+ if (!before && (ctx.data._verified === true || registeringFirst(ctx)))
225
+ return ctx;
226
+ // With no way to hand a token over, none is minted and an administrator verifies the account.
227
+ const fresh = { ...ctx.data, _verified: false, _verificationToken: mails(optionsOf(ctx.kayan)) ? freshToken() : null };
228
+ // A moved address takes the old one's reset link and sessions with it, or the
229
+ // link would still reset the password and the cookies come back once verified.
230
+ return { ...ctx, data: moved ? { ...fresh, resetPasswordToken: null, resetPasswordExpiration: null, sessions: '[]' } : fresh };
231
+ };
232
+ // Inside the operation's transaction, so a `send` that throws rolls the write back.
233
+ const sendVerification = async (ctx) => {
234
+ const token = ctx.data._verificationToken;
235
+ if (typeof token === 'string' && token !== ctx.dataBefore?._verificationToken) {
236
+ await hand(optionsOf(ctx.kayan), 'verification', String(ctx.data.email), token, ctx.data);
237
+ }
238
+ return ctx;
239
+ };
240
+ // `buildAsas` refuses a kayan holding these steps without `local()`, rather than
241
+ // letting the handover print every token it is given.
242
+ export const holdsVerificationSteps = (kayan) => Object.values(kayan.operations).some((operation) => [operation.steps, operation.beforeTransaction, operation.afterSteps, operation.afterCommit]
243
+ .some((list) => list.some((step) => step === mintVerification || step === sendVerification)));
244
+ /**
245
+ * The `create` and `update` a kayan carrying `local()` spreads over `defaultOperations`.
246
+ * A kayan adding a step writes the list around them: `[...localAuthOperations.create.steps, notify]`.
247
+ */
248
+ export const localAuthOperations = {
249
+ create: {
250
+ input: ({ create }) => withPassword(create, true),
251
+ steps: [hashPassword, mintVerification, insert, sendVerification],
252
+ },
253
+ update: {
254
+ input: ({ update: shape }) => withPassword(shape, false),
255
+ steps: [hashPassword, mintVerification, update, sendVerification],
256
+ },
257
+ };
258
+ /** The local email-and-password strategy: sessions on the user row, and a signed cookie naming one. */
259
+ export const localAuthStrategy = ({ options, kayan: resolved, target, writes, projectUser, permissions = { kayan: [], can: async () => ({ allowed: false, operations: {} }), canAccessAdmin: async () => false, screens: async () => [] }, assigned, onError, }) => {
260
+ if (typeof projectUser !== 'function') {
261
+ throw new Error('localAuthStrategy({ projectUser }) is required. Pass the projection buildAsas passes, the kayan\'s own field read access, so a response carries only what its reader may read.');
262
+ }
263
+ const refuse = (what) => {
264
+ throw new Error(`kayan "${resolved.name}" carries auth: local(...) and ${what}`);
265
+ };
266
+ for (const field of requiredFields) {
267
+ if (!resolved.fields[field]) {
268
+ refuse(`needs field "${field}".`
269
+ + (field === 'email' && options.email
270
+ ? ' local({ email }).send is handed that field as the address, so a kayan without it has nowhere to send a token.'
271
+ : '')
272
+ + ' Spread ...localAuthFields into its fields.');
273
+ }
274
+ }
275
+ // A field that forgot a shipped callback, or wrote one that may answer true, would
276
+ // hand a secret to a response or let a caller verify an address it does not own.
277
+ for (const { name, operation, callback } of shippedAccess) {
278
+ const access = resolved.fields[name].access;
279
+ if (declaredFieldAccess(access?.[operation]) !== callback) {
280
+ refuse(`its field "${name}" is ${operation === 'read' ? 'readable' : `writable on ${operation}`}. Write ${name}: localAuthFields.${name},`
281
+ + ' or spread ...localAuthFields into its fields, so its access is the shipped one.');
282
+ }
283
+ }
284
+ // Or a caller would choose its hash, its salt or its token.
285
+ for (const name of suppliedFields) {
286
+ for (const on of ['create', 'update']) {
287
+ if (providerOf(resolved.fields[name], on) !== 'application') {
288
+ refuse(`its field "${name}" is supplied by a caller on ${on}. Write ${name}: localAuthFields.${name},`
289
+ + ' or spread ...localAuthFields into its fields, so the provider alone writes it.');
290
+ }
291
+ }
292
+ }
293
+ // Read by the store step each operation holds, not by its name, so a custom
294
+ // operation that inserts or updates meets the rule too.
295
+ const holdsShipped = (steps, seal) => {
296
+ const store = steps.findIndex((step) => sealOf(step) === seal);
297
+ const hashed = steps.indexOf(hashPassword);
298
+ const mint = steps.indexOf(mintVerification);
299
+ return hashed >= 0 && mint >= 0 && store > Math.max(hashed, mint) && steps.indexOf(sendVerification) > store;
300
+ };
301
+ const lacks = (operation, list) => refuse(`its ${operation} lacks the shipped steps. Spread ...localAuthOperations into its operations,`
302
+ + ` or write its ${operation} around ...localAuthOperations.${list}.steps, so the password is hashed and the token minted before the store step and handed over after it.`);
303
+ if (!holdsShipped(resolved.operations.create?.steps ?? [], 'insert'))
304
+ lacks('create', 'create');
305
+ for (const [operation, declaration] of Object.entries(resolved.operations)) {
306
+ const seal = declaration.steps.map((step) => sealOf(step)).find((one) => one === 'insert' || one === 'update' || one === 'updateMany');
307
+ if (!seal)
308
+ continue;
309
+ const steps = seal === 'updateMany' ? resolved.operations.update?.steps ?? [] : declaration.steps;
310
+ if (!holdsShipped(steps, seal === 'insert' ? 'insert' : 'update'))
311
+ lacks(operation, seal === 'insert' ? 'create' : 'update');
312
+ }
313
+ // No environment variable stands in for the secret. Only `development` signs
314
+ // without one, under a secret no other process verifies.
315
+ const { secret, withoutSecret } = options;
316
+ const unsigned = unsignedOf(options);
317
+ if (unsigned && (typeof withoutSecret !== 'string' || !withoutSecret)) {
318
+ throw new Error('local({ secret }) is required unless local({ development: true }) is set, or local({ withoutSecret }) names what local auth answers without one.');
319
+ }
320
+ if (secret !== undefined && (typeof secret !== 'string' || secret.length < 32)) {
321
+ throw new Error('local({ secret }) signs every token and is a string of 32 characters at least.');
322
+ }
323
+ // Empty only when unsigned, and `keyOf` refuses an empty key.
324
+ const signingSecret = secret ?? (options.development ? developmentSecret : '');
325
+ if (unsigned) {
326
+ onError(new Notice(`[mithaq local auth] kayan "${resolved.name}" has no local({ secret }), so every POST to /api/auth/local answers 503: ${withoutSecret}`));
327
+ }
328
+ // `typeof` admits NaN, which `Number(process.env.X)` answers for an unset variable:
329
+ // a NaN `maxLoginAttempts` switches the lockout off.
330
+ for (const [key, fallback] of [['tokenExpiration', 'defaultTokenExpiration'], ['maxLoginAttempts', 'defaultMaxLoginAttempts'], ['lockTime', 'defaultLockTime']]) {
331
+ const value = options[key];
332
+ if (!Number.isFinite(value) || value < 0) {
333
+ const what = value === undefined ? 'is required' : `is ${String(value)}, which is not a finite number of 0 or more`;
334
+ throw new Error(`local({ ${key} }) ${what}. Pass ${fallback}, Payload's value, or a finite number of 0 or more of your own.`);
335
+ }
336
+ }
337
+ if (typeof options.ip !== 'function') {
338
+ throw new Error('local({ ip }) is required. Pass the address your server saw for each request, which login, forgot-password and reset-password are rate limited by,'
339
+ + ' or an ip that answers null for no rate limit.');
340
+ }
341
+ if (options.email !== undefined && typeof options.email?.send !== 'function') {
342
+ throw new Error('local({ email }) hands every token to its send, which must be a function.');
343
+ }
344
+ // Without `email`, no token is printed outside development and none is minted, so
345
+ // the project is told once here what it switched off.
346
+ if (!mails(options)) {
347
+ onError(new Notice(`[mithaq local auth] kayan "${resolved.name}" has no local({ email }), so no verification or reset message is sent:`
348
+ + ' a sign-up makes an account an administrator verifies, and forgot-password mints nothing. Pass local({ email }) to send them.'));
349
+ }
350
+ const { tokenExpiration: maxAge, maxLoginAttempts, lockTime, ip } = options;
351
+ const secure = options.secureCookies ?? !options.development;
352
+ const name = 'local';
353
+ const cookieName = `${name}-token`;
354
+ const windows = new Map();
355
+ let uncounted = false;
356
+ /** Counts this request against its address, and answers the seconds to wait once it is past the limit. */
357
+ const overLimit = async (request) => {
358
+ const address = await ip(request);
359
+ if (address === null || address === undefined) {
360
+ // Once, so an `ip` that always answers nothing does not switch the limit off without a word.
361
+ if (!uncounted) {
362
+ uncounted = true;
363
+ onError(new Notice(`[mithaq local auth] kayan "${resolved.name}": local({ ip }) answered no address for a request, so it was not rate limited.`
364
+ + ' Requests it answers null or undefined for are never counted.'));
365
+ }
366
+ return null;
367
+ }
368
+ const now = Date.now();
369
+ let held = windows.get(address);
370
+ if (!held || held.ends <= now) {
371
+ // Deleted and set again, so the map's first key is the oldest window.
372
+ windows.delete(address);
373
+ if (windows.size >= rateLimit.entries) {
374
+ for (const [key, one] of windows)
375
+ if (one.ends <= now)
376
+ windows.delete(key);
377
+ if (windows.size >= rateLimit.entries)
378
+ windows.delete(windows.keys().next().value);
379
+ }
380
+ held = { count: 0, ends: now + rateLimit.window };
381
+ windows.set(address, held);
382
+ }
383
+ return ++held.count > rateLimit.max ? Math.ceil((held.ends - now) / 1000) : null;
384
+ };
385
+ const table = target.tables[resolved.name];
386
+ // This provider reads through the store's client, so it writes the soft-delete
387
+ // filter every managed read carries itself; every read of the row carries it.
388
+ const alive = resolved.softDelete ? isNull(table[resolved.softDelete]) : undefined;
389
+ const firstRow = (from, match) => {
390
+ const rows = from.select().from(table);
391
+ const where = match && alive ? and(match, alive) : match ?? alive;
392
+ return where ? rows.where(where).limit(1) : rows.limit(1);
393
+ };
394
+ const findBy = async (field, value) => (await firstRow(target.db, eq(table[field], value)))[0] ?? null;
395
+ const anyRow = async () => (await firstRow(target.db)).length > 0;
396
+ // Read back under a write lock and changed in one transaction, so two logins, a
397
+ // logout and a lockout never overwrite one another's columns.
398
+ const mutate = async (id, change, inside) => target.db.transaction(async (tx) => {
399
+ const where = alive ? and(eq(table.id, id), alive) : eq(table.id, id);
400
+ const [held] = await tx
401
+ .update(table)
402
+ .set({ sessions: table.sessions })
403
+ .where(where)
404
+ .returning();
405
+ if (!held)
406
+ return null;
407
+ const [updated] = await tx
408
+ .update(table)
409
+ .set(change(held))
410
+ .where(where)
411
+ .returning();
412
+ if (updated && inside)
413
+ await inside(updated);
414
+ return updated ?? null;
415
+ });
416
+ const mutateSessions = (id, change) => mutate(id, (row) => ({ sessions: JSON.stringify(change(sessionsOf(row))) }));
417
+ const actorOf = async (row) => {
418
+ const user = clean(row);
419
+ const actor = { ...user, collection: resolved.name, strategy: name, kayan: resolved.name, user };
420
+ // A local account is no organization's member, so it is asked under the tenant its row's
421
+ // bound field names, and under none where its kayan is shared. `permissions` and `roles` are
422
+ // deleted, not set to `undefined`: present decides.
423
+ delete actor.permissions;
424
+ delete actor.roles;
425
+ const held = await resolveAssigned({ resolver: assigned, onError, tenant: resolved.tenant ? row[resolved.tenant] : undefined, member: row.id, actor });
426
+ if (held !== undefined) {
427
+ ;
428
+ actor.permissions = held.permissions;
429
+ actor.roles = held.roles;
430
+ }
431
+ return markAuthActor(actor);
432
+ };
433
+ const publicUser = async (row) => clean(await projectUser(row, await actorOf(row)));
434
+ const startSession = async (row) => {
435
+ // Every cookie is issued here, so an unverified account gets none by any path.
436
+ if (row._verified !== true)
437
+ throw new Error(`The "${resolved.name}" row ${String(row.id)} is not verified, so no session starts for it.`);
438
+ const session = {
439
+ id: randomUUID(),
440
+ createdAt: new Date().toISOString(),
441
+ expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
442
+ };
443
+ const user = await mutateSessions(row.id, (sessions) => [...sessions, session]);
444
+ if (!user)
445
+ throw new Error(`No "${resolved.name}" row with id ${String(row.id)} to update.`);
446
+ return {
447
+ header: cookie(cookieName, await sign(signingSecret, { collection: resolved.name, id: row.id, sid: session.id }, maxAge), maxAge, secure),
448
+ user,
449
+ };
450
+ };
451
+ const extractToken = (headers) => {
452
+ const authorization = headers.get('authorization');
453
+ if (authorization?.startsWith('Bearer '))
454
+ return authorization.slice('Bearer '.length);
455
+ if (authorization?.startsWith('JWT '))
456
+ return authorization.slice('JWT '.length);
457
+ return parseCookies(headers).get(cookieName) ?? '';
458
+ };
459
+ // A read, never a write: a write transaction would lock the user row and serialise
460
+ // that user's concurrent requests.
461
+ const authenticate = async ({ headers }) => {
462
+ const token = await verify(signingSecret, extractToken(headers));
463
+ if (!token || token.collection !== resolved.name)
464
+ return { user: null };
465
+ const row = await findBy('id', token.id);
466
+ // An unverified row authenticates no one, whatever cookie names it.
467
+ if (!row || row._verified !== true)
468
+ return { user: null };
469
+ if (!sessionsOf(row).some((session) => session.id === token.sid))
470
+ return { user: null };
471
+ return { user: await actorOf(row) };
472
+ };
473
+ const bodyOf = async (request) => {
474
+ try {
475
+ return await request.json();
476
+ }
477
+ catch {
478
+ return null;
479
+ }
480
+ };
481
+ // Two strings and nothing else of the body, so first-register never writes a key the caller chose.
482
+ const credentials = (body) => body && typeof body.email === 'string' && isPassword(body.password)
483
+ ? { email: body.email.toLowerCase().trim(), password: body.password }
484
+ : null;
485
+ // An empty table holds no row to lock, so the table is locked: otherwise two concurrent
486
+ // requests both read nothing and both make an account. The first account is verified here.
487
+ const registerFirst = async (body, extra) => {
488
+ const given = credentials(body);
489
+ if (!given)
490
+ return { error: refusal(400, `An email and a password of ${passwordLength.min} to ${passwordLength.max} characters are required.`) };
491
+ // Held outside the transaction so it is drained only once that transaction commits.
492
+ let open;
493
+ const result = await target.db.transaction(async (tx) => {
494
+ await tx.execute(sql `lock table ${table} in exclusive mode`);
495
+ if ((await firstRow(tx)).length > 0) {
496
+ return {
497
+ error: refusal(403, `The "${resolved.name}" kayan already has a row, so first-register is closed. Log in with an account it holds instead.`),
498
+ };
499
+ }
500
+ // Marked only here, past the empty read under the lock, so only this create skips the kayan's create access.
501
+ open = { operation: `${resolved.name}.first-register`, tx };
502
+ firstRegisters.add(open);
503
+ const made = await running.run({
504
+ context: Object.freeze({ ...extra, chain: [], depth: 0, source: 'local' }),
505
+ transactions: new Map([[target, open]]),
506
+ user: null,
507
+ }, () => writes[resolved.name].operations.create({ email: given.email, password: given.password }));
508
+ const [row] = await tx.update(table).set({ _verified: true }).where(eq(table.id, made.id)).returning();
509
+ return { row: row };
510
+ });
511
+ if (open)
512
+ await drain(open.afterCommit ?? []);
513
+ return result;
514
+ };
515
+ return {
516
+ name,
517
+ authenticate,
518
+ async handler(request) {
519
+ const path = new URL(request.url).pathname.replace(/^\/api\/auth\/local\/?/, '');
520
+ const post = request.method === 'POST';
521
+ const get = request.method === 'GET';
522
+ // Without a secret every path that would log in, register, reset, verify, unlock or
523
+ // log out is refused before it reads a body or a row.
524
+ if (post && unsigned)
525
+ return refusal(503, String(withoutSecret));
526
+ if (post && limitedPaths.has(path)) {
527
+ const wait = await overLimit(request);
528
+ if (wait !== null) {
529
+ return refusal(429, 'Too many requests. Try again later.', { 'retry-after': String(wait) });
530
+ }
531
+ }
532
+ try {
533
+ if (path === 'access' && get) {
534
+ return json(await accessReport(permissions, (await authenticate({ headers: request.headers })).user));
535
+ }
536
+ if (path === 'init' && get) {
537
+ return json({ initialized: await anyRow() });
538
+ }
539
+ if (path === 'first-register' && post) {
540
+ const made = await registerFirst(await bodyOf(request), await options.context?.(request));
541
+ if (made.error)
542
+ return made.error;
543
+ // After the commit: it writes the same row, and the table lock would hold it off.
544
+ const { header, user } = await startSession(made.row);
545
+ return json({ user: await publicUser(user) }, { status: 201, headers: { 'set-cookie': header } });
546
+ }
547
+ if (path === 'login' && post) {
548
+ const body = await bodyOf(request);
549
+ if (!body)
550
+ return refusal(400, 'Request body must be valid JSON.');
551
+ // Bounded before scrypt, and answered the same whether or not the address is held.
552
+ if (!isPassword(body.password))
553
+ return refusal(400, passwordRefusal);
554
+ const email = typeof body.email === 'string' ? body.email.toLowerCase().trim() : null;
555
+ const row = email ? await findBy('email', email) : null;
556
+ // The answer for a locked account names the lock, so login does say this address has an account.
557
+ if (row && locked(row.lockUntil)) {
558
+ return refusal(401, 'This account is locked. Try again later, or ask an administrator to unlock it.');
559
+ }
560
+ const { matched, upgrade } = await checkPassword(body.password, String(row?.salt ?? absentSalt), String(row?.hash ?? absentHash));
561
+ const correct = !!row && matched;
562
+ if (!correct) {
563
+ // A lock that has expired restarts the count at one. A limit of 0 never locks.
564
+ if (row && maxLoginAttempts > 0) {
565
+ await mutate(row.id, (current) => {
566
+ const expired = current.lockUntil !== null && current.lockUntil !== undefined && !locked(current.lockUntil);
567
+ if (expired)
568
+ return { loginAttempts: 1, lockUntil: null };
569
+ const attempts = Number(current.loginAttempts ?? 0) + 1;
570
+ return attempts >= maxLoginAttempts
571
+ ? { loginAttempts: attempts, lockUntil: new Date(Date.now() + lockTime) }
572
+ : { loginAttempts: attempts, lockUntil: null };
573
+ });
574
+ }
575
+ return refusal(401, 'Invalid email or password.');
576
+ }
577
+ // Asked after the password, so nobody without it learns whether the account is verified.
578
+ if (row._verified !== true) {
579
+ const next = mails(options)
580
+ ? 'This account is not verified. Follow the link in its verification message first.'
581
+ : 'This account is not verified, and this app sends no verification message. Ask an administrator to verify it.';
582
+ return refusal(403, next);
583
+ }
584
+ // An older hash is replaced, unless the password changed while this login ran.
585
+ await mutate(row.id, (held) => ({ loginAttempts: 0, lockUntil: null, ...(upgrade && held.hash === row.hash ? upgrade : {}) }));
586
+ const { header, user } = await startSession(row);
587
+ return json({ user: await publicUser(user) }, { headers: { 'set-cookie': header } });
588
+ }
589
+ // Answered 200 either way, `{ user: null }` for nobody.
590
+ if (path === 'me' && get) {
591
+ const actor = (await authenticate({ headers: request.headers })).user;
592
+ return json({ user: actor ? await publicUser(actor.user) : null });
593
+ }
594
+ if (path === 'refresh-token' && post) {
595
+ // One verify and one read: the session the cookie names must be one the row still holds.
596
+ const token = await verify(signingSecret, extractToken(request.headers));
597
+ if (!token || token.collection !== resolved.name)
598
+ return json({ user: null }, { status: 401 });
599
+ const row = await findBy('id', token.id);
600
+ if (!row || row._verified !== true || !sessionsOf(row).some((session) => session.id === token.sid)) {
601
+ return json({ user: null }, { status: 401 });
602
+ }
603
+ // The same session id, so a refresh never adds a session.
604
+ const expiresAt = new Date(Date.now() + maxAge * 1000).toISOString();
605
+ const user = await mutateSessions(token.id, (sessions) => sessions.map((session) => (session.id === token.sid ? { ...session, expiresAt } : session)));
606
+ if (!user)
607
+ return json({ user: null }, { status: 401 });
608
+ const header = cookie(cookieName, await sign(signingSecret, { collection: resolved.name, id: token.id, sid: token.sid }, maxAge), maxAge, secure);
609
+ return json({ user: await publicUser(user) }, { headers: { 'set-cookie': header } });
610
+ }
611
+ if (path === 'logout' && post) {
612
+ const token = await verify(signingSecret, extractToken(request.headers));
613
+ // A marked row answers null here and keeps this session entry, which is unreachable
614
+ // while it stays marked; the cookie is cleared and the answer is 200 either way.
615
+ const row = token?.collection === resolved.name ? await findBy('id', token.id) : null;
616
+ if (row)
617
+ await mutateSessions(row.id, (sessions) => sessions.filter((session) => session.id !== token.sid));
618
+ return json({ ok: true }, { headers: { 'set-cookie': cookie(cookieName, '', 0, secure) } });
619
+ }
620
+ // The same answer whether or not the address is held, so this endpoint never says who has an account.
621
+ if (path === 'forgot-password' && post) {
622
+ const body = await bodyOf(request);
623
+ const email = body && typeof body.email === 'string' ? body.email.toLowerCase().trim() : null;
624
+ if (!email)
625
+ return refusal(400, 'An email is required.');
626
+ const row = await findBy('email', email);
627
+ // With no way to hand a token over, a held address is answered exactly as an unknown one.
628
+ if (row && mails(options)) {
629
+ // Read, counted and written under the row's write lock, so a burst of requests
630
+ // cannot each see the same count.
631
+ let token = null;
632
+ try {
633
+ await mutate(row.id, (held) => {
634
+ // A live token is handed over again rather than replaced, so a second request
635
+ // does not strand the first mail.
636
+ const live = asDate(held.resetPasswordExpiration);
637
+ const reused = typeof held.resetPasswordToken === 'string' && live && live.getTime() > Date.now()
638
+ ? held.resetPasswordToken
639
+ : null;
640
+ // The window ends with the token it counted.
641
+ const attempts = (reused ? Number(held.resetPasswordAttempts ?? 0) : 0) + 1;
642
+ // Past the limit only the count is written, and the answer is the one every call gets.
643
+ if (attempts > maxResets)
644
+ return { resetPasswordAttempts: attempts };
645
+ token = reused ?? freshToken();
646
+ return {
647
+ resetPasswordToken: token,
648
+ resetPasswordAttempts: attempts,
649
+ // Only a minted token gets an hour: sliding a reused token's hour forward on every
650
+ // request would let one caller hold one secret open for as long as it keeps asking.
651
+ ...(reused ? {} : { resetPasswordExpiration: new Date(Date.now() + 3600000) }),
652
+ };
653
+ }, async (updated) => {
654
+ if (token)
655
+ await hand(options, 'reset', email, token, updated);
656
+ });
657
+ }
658
+ catch (failure) {
659
+ // Swallowed: a 500 here and a 200 for an unknown address would be an account-existence
660
+ // oracle. The project's `onError` is told.
661
+ onError(new Error(`[mithaq local auth] forgot-password could not hand the reset token for row "${row.id}" over, so nothing was written.`, { cause: failure }));
662
+ }
663
+ }
664
+ // Timing still tells a held address from an unknown one; closing that needs a
665
+ // constant-time path or a queue.
666
+ return json({
667
+ message: mails(options)
668
+ ? 'If that email has an account, a reset token has been issued for it.'
669
+ : 'This app sends no reset message, so no reset token was issued. Ask an administrator to set a new password.',
670
+ });
671
+ }
672
+ if (path === 'reset-password' && post) {
673
+ const body = await bodyOf(request);
674
+ if (!body || typeof body.token !== 'string' || !isPassword(body.password)) {
675
+ return refusal(400, `A token and a password of ${passwordLength.min} to ${passwordLength.max} characters are required.`);
676
+ }
677
+ const row = await findBy('resetPasswordToken', body.token);
678
+ const expires = asDate(row?.resetPasswordExpiration);
679
+ if (!row || !expires || expires.getTime() <= Date.now()) {
680
+ return refusal(400, 'That reset token is unknown or expired.');
681
+ }
682
+ const { hash, salt } = await saltAndHash(body.password);
683
+ const updated = await mutate(row.id, () => ({
684
+ hash,
685
+ salt,
686
+ resetPasswordToken: null,
687
+ resetPasswordExpiration: null,
688
+ loginAttempts: 0,
689
+ lockUntil: null,
690
+ }));
691
+ if (!updated)
692
+ return refusal(400, 'That reset token is unknown or expired.');
693
+ // A reset keeps `_verified` as it was, so an unverified account still signs in nowhere.
694
+ if (updated._verified !== true)
695
+ return json({ user: null });
696
+ const { header, user } = await startSession(updated);
697
+ return json({ user: await publicUser(user) }, { headers: { 'set-cookie': header } });
698
+ }
699
+ if (path === 'unlock' && post) {
700
+ const actor = (await authenticate({ headers: request.headers })).user;
701
+ if (!(await permissions.canAccessAdmin(actor))) {
702
+ return refusal(403, 'Forbidden: unlock is for an administrator.');
703
+ }
704
+ const body = await bodyOf(request);
705
+ const email = body && typeof body.email === 'string' ? body.email.toLowerCase().trim() : null;
706
+ if (!email)
707
+ return refusal(400, 'An email is required.');
708
+ const row = await findBy('email', email);
709
+ if (!row)
710
+ return refusal(404, 'No account with that email.');
711
+ await mutate(row.id, () => ({ loginAttempts: 0, lockUntil: null }));
712
+ return json({ ok: true });
713
+ }
714
+ if (path.startsWith('verify/') && post) {
715
+ const token = decodeURIComponent(path.slice('verify/'.length));
716
+ const row = token ? await findBy('_verificationToken', token) : null;
717
+ if (!row)
718
+ return refusal(400, 'That verification token is unknown.');
719
+ await mutate(row.id, () => ({ _verified: true, _verificationToken: null }));
720
+ return json({ ok: true });
721
+ }
722
+ }
723
+ catch (error) {
724
+ error = retryConflict(error);
725
+ if (error instanceof AsasError && error.key === 'error.conflict.retry')
726
+ return json({ code: codes[409], message: error.message, data: { key: error.key, params: error.params } }, { status: 409 });
727
+ // The driver detail may name another account, so other conflicts keep the existing refusal.
728
+ if (error instanceof AsasError && error.status === 409) {
729
+ return refusal(409, 'Email already exists.');
730
+ }
731
+ throw error;
732
+ }
733
+ return refusal(404, 'Not found.');
734
+ },
735
+ };
736
+ };
737
+ /** The local email-and-password provider as a kayan declares it: `auth: local({ ... })`. */
738
+ export const local = (options) => ({ provider: 'local', options });