@madauth/server 0.3.0-beta.1 → 0.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -23,14 +23,14 @@ import {
23
23
  parseClaims,
24
24
  parseRoles,
25
25
  passwordAccountKey
26
- } from "./chunk-ODITZLMM.js";
26
+ } from "./chunk-TI35AZ2U.js";
27
27
  import {
28
28
  generateSigningKey
29
29
  } from "./chunk-JDQLSL36.js";
30
30
  import {
31
31
  DEV_WEBHOOK_RECEIVER_URL,
32
32
  generateWebhookSecret
33
- } from "./chunk-NPNM26J3.js";
33
+ } from "./chunk-3QRKWLR7.js";
34
34
 
35
35
  // src/cli.ts
36
36
  import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -293,7 +293,7 @@ ${name}=${value}
293
293
  password && variable("WEBHOOK_SECRET", generateWebhookSecret(), "Signs every webhook call. Your receiver needs the same one."),
294
294
  password && variable(
295
295
  "WEBHOOK_EVENTS",
296
- "email.verify,email.reset,email.already_registered",
296
+ "email.verify,email.reset,email.already_registered,email.no_password",
297
297
  "The types your webhook receiver handles; only these are sent. Add e.g. signup.before or user.created."
298
298
  ),
299
299
  variable("PORT", String(PORT), "Port the server listens on.")
@@ -4,6 +4,7 @@ var WEBHOOK_TYPES = [
4
4
  "email.verify",
5
5
  "email.reset",
6
6
  "email.already_registered",
7
+ "email.no_password",
7
8
  "signup.before",
8
9
  "user.created",
9
10
  "email.verified",
@@ -34,7 +34,7 @@ import {
34
34
  toMadauthUser,
35
35
  verifyAgainstDummy,
36
36
  verifyPassword
37
- } from "./chunk-ODITZLMM.js";
37
+ } from "./chunk-TI35AZ2U.js";
38
38
  import {
39
39
  importSigningKeys
40
40
  } from "./chunk-JDQLSL36.js";
@@ -42,7 +42,7 @@ import {
42
42
  WEBHOOK_TIMEOUT_MS,
43
43
  createWebhookClient,
44
44
  localeOf
45
- } from "./chunk-NPNM26J3.js";
45
+ } from "./chunk-3QRKWLR7.js";
46
46
 
47
47
  // src/routes/google.ts
48
48
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
@@ -324,6 +324,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
324
324
  locale
325
325
  });
326
326
  };
327
+ const sendAccountEmail = async (user, type, target, locale) => {
328
+ if (!webhook.wants(type)) return true;
329
+ const methods = await users.signInMethods(user.id);
330
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
331
+ };
327
332
  const consume = async (data, purpose) => {
328
333
  if (typeof data.token === "string" && data.token) {
329
334
  return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
@@ -391,9 +396,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
391
396
  const passwordHash = await hashPassword(data.password);
392
397
  const existing = await users.findByEmail(normalizeEmail(email));
393
398
  if (existing?.emailVerified) {
394
- if (!webhook.wants("email.already_registered")) return accepted(c);
395
- const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
396
- return sent ? accepted(c) : unavailable(c);
399
+ return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
397
400
  }
398
401
  if (existing) {
399
402
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
@@ -442,8 +445,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
442
445
  if (target instanceof Response) return target;
443
446
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
444
447
  const user = await users.findByEmail(normalizeEmail(data.email));
445
- if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
446
- if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
448
+ const locale = localeOf(data.locale);
449
+ if (user && await users.passwordAccount(user.id)) {
450
+ if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
451
+ } else if (user) {
452
+ if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
447
453
  }
448
454
  return accepted(c);
449
455
  });
@@ -453,11 +459,10 @@ function passwordRoutes(app, ctx, password, users, webhook) {
453
459
  if (policy) return error(c, 400, "weak_password", policy);
454
460
  const consumed = await consume(data, "reset");
455
461
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
456
- if (!user) return invalidVerification(c, consumed);
462
+ const account = user ? await users.passwordAccount(user.id) : null;
463
+ if (!user || !account) return invalidVerification(c, consumed);
457
464
  const secret = await hashPassword(data.password);
458
- const account = await users.passwordAccount(user.id);
459
- if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
460
- else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
465
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c, consumed);
461
466
  const sessionVersion = user.sessionVersion + 1;
462
467
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
463
468
  await users.clearVerifications(user.id);
@@ -8,7 +8,7 @@ import {
8
8
  WEBHOOK_TYPES,
9
9
  checkWebhookSecret,
10
10
  generateWebhookSecret
11
- } from "./chunk-NPNM26J3.js";
11
+ } from "./chunk-3QRKWLR7.js";
12
12
 
13
13
  // src/claims.ts
14
14
  var ADMIN_ROLE = "admin";
@@ -216,7 +216,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
216
216
  const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
217
217
  if (!clientId && !password) {
218
218
  throw new ConfigError(
219
- `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
219
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered,email.no_password. See docs/server.md.`
220
220
  );
221
221
  }
222
222
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
@@ -333,7 +333,7 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
333
333
  throw new ConfigError(
334
334
  `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
335
335
 
336
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
336
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
337
337
 
338
338
  For one that only wants to know who signed in:
339
339
 
@@ -466,6 +466,12 @@ var Users = class {
466
466
  async passwordAccount(userId) {
467
467
  return this.findAccountByKey(passwordAccountKey(userId));
468
468
  }
469
+ /** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
470
+ async signInMethods(userId) {
471
+ const accounts = await this.store.findMany("account", { userId });
472
+ const methods = accounts.map((account) => String(account.key).split(":")[0]);
473
+ return SIGN_IN_METHODS.filter((method) => methods.includes(method));
474
+ }
469
475
  async findAccountByKey(key) {
470
476
  return await this.store.findOne("account", { key });
471
477
  }
@@ -540,8 +546,9 @@ var Users = class {
540
546
  async updateUser(id, patch) {
541
547
  await this.store.update("user", { id }, patch);
542
548
  }
549
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
543
550
  async updateAccount(id, patch) {
544
- await this.store.update("account", { id }, patch);
551
+ return await this.store.update("account", { id }, patch) === 1;
545
552
  }
546
553
  /**
547
554
  * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
@@ -635,15 +642,15 @@ export {
635
642
  normalizeEmail,
636
643
  isValidEmail,
637
644
  deriveCodeKey,
638
- passwordAccountKey,
639
- googleAccountKey,
640
- toMadauthUser,
641
- Users,
642
645
  SIGN_IN_METHODS,
643
646
  parseMethodSettings,
644
647
  Settings,
645
648
  isOn,
646
649
  anyOn,
650
+ passwordAccountKey,
651
+ googleAccountKey,
652
+ toMadauthUser,
653
+ Users,
647
654
  envVars,
648
655
  resolveEnv,
649
656
  ConfigError,
@@ -1,6 +1,6 @@
1
1
  import { JWK, JWTVerifyGetKey } from 'jose';
2
2
  import { S as StoreAdapter } from './schema-3yhCc2jH.js';
3
- import { W as WebhookSettings } from './webhook-DCDP6SaE.js';
3
+ import { W as WebhookSettings } from './webhook-j5u9Kv9l.js';
4
4
 
5
5
  interface MadauthConfig {
6
6
  /** Public base URL of the server (MADAUTH_ISSUER). */
@@ -1,7 +1,7 @@
1
- import { E as EntryOptions } from '../config-DEBYi8hb.js';
1
+ import { E as EntryOptions } from '../config-Dg2fZuWR.js';
2
2
  import 'jose';
3
3
  import '../schema-3yhCc2jH.js';
4
- import '../webhook-DCDP6SaE.js';
4
+ import '../webhook-j5u9Kv9l.js';
5
5
 
6
6
  /**
7
7
  * Registers madAuth as an Azure Functions v4 HTTP function that handles every route. Pass `store` to use your
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-ZSBKNV5W.js";
3
+ } from "../chunk-DVVBIFIV.js";
4
4
  import "../chunk-DCDTO5LG.js";
5
5
  import {
6
6
  loadConfig,
7
7
  resolveEnv
8
- } from "../chunk-ODITZLMM.js";
8
+ } from "../chunk-TI35AZ2U.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-NPNM26J3.js";
10
+ import "../chunk-3QRKWLR7.js";
11
11
 
12
12
  // src/entry/azure.ts
13
13
  import { app } from "@azure/functions";
@@ -1,8 +1,8 @@
1
1
  import { LambdaEvent, LambdaContext, handle } from 'hono/aws-lambda';
2
- import { E as EntryOptions } from '../config-DEBYi8hb.js';
2
+ import { E as EntryOptions } from '../config-Dg2fZuWR.js';
3
3
  import 'jose';
4
4
  import '../schema-3yhCc2jH.js';
5
- import '../webhook-DCDP6SaE.js';
5
+ import '../webhook-j5u9Kv9l.js';
6
6
 
7
7
  type LambdaHandler = (event: LambdaEvent, context: LambdaContext) => ReturnType<ReturnType<typeof handle>>;
8
8
  /**
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-ZSBKNV5W.js";
3
+ } from "../chunk-DVVBIFIV.js";
4
4
  import "../chunk-DCDTO5LG.js";
5
5
  import {
6
6
  loadConfig,
7
7
  resolveEnv
8
- } from "../chunk-ODITZLMM.js";
8
+ } from "../chunk-TI35AZ2U.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-NPNM26J3.js";
10
+ import "../chunk-3QRKWLR7.js";
11
11
 
12
12
  // src/entry/lambda.ts
13
13
  import { handle } from "hono/aws-lambda";
@@ -1,7 +1,7 @@
1
- import { E as EntryOptions } from '../config-DEBYi8hb.js';
1
+ import { E as EntryOptions } from '../config-Dg2fZuWR.js';
2
2
  import 'jose';
3
3
  import '../schema-3yhCc2jH.js';
4
- import '../webhook-DCDP6SaE.js';
4
+ import '../webhook-j5u9Kv9l.js';
5
5
 
6
6
  type StartOptions = EntryOptions;
7
7
  /**
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-ZSBKNV5W.js";
3
+ } from "../chunk-DVVBIFIV.js";
4
4
  import "../chunk-DCDTO5LG.js";
5
5
  import {
6
6
  loadConfig,
7
7
  resolveEnv
8
- } from "../chunk-ODITZLMM.js";
8
+ } from "../chunk-TI35AZ2U.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-NPNM26J3.js";
10
+ import "../chunk-3QRKWLR7.js";
11
11
 
12
12
  // src/entry/node.ts
13
13
  import { serve } from "@hono/node-server";
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { Hono } from 'hono';
2
- import { M as MadauthConfig } from './config-DEBYi8hb.js';
3
- export { C as ConfigError, a as ConfigOverrides, E as EntryOptions, e as envVars, l as loadConfig } from './config-DEBYi8hb.js';
2
+ import { M as MadauthConfig } from './config-Dg2fZuWR.js';
3
+ export { C as ConfigError, a as ConfigOverrides, E as EntryOptions, e as envVars, l as loadConfig } from './config-Dg2fZuWR.js';
4
4
  import { JWK } from 'jose';
5
5
  import { a as Schema } from './schema-3yhCc2jH.js';
6
6
  export { F as FieldDef, b as FieldType, R as Row, S as StoreAdapter, V as Value, W as Where, m as madauthSchema } from './schema-3yhCc2jH.js';
7
7
  export { M as MadauthUser } from './user-BMWEWREH.js';
8
- import './webhook-DCDP6SaE.js';
8
+ import './webhook-j5u9Kv9l.js';
9
9
 
10
10
  /** Creates a new private ES256 JWK for MADAUTH_SIGNING_KEY. */
11
11
  declare function generateSigningKey(): Promise<JWK>;
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  import {
11
11
  REDIRECT_ERROR_PARAM,
12
12
  createApp
13
- } from "./chunk-ZSBKNV5W.js";
13
+ } from "./chunk-DVVBIFIV.js";
14
14
  import "./chunk-DCDTO5LG.js";
15
15
  import {
16
16
  ADMIN_ROLE,
@@ -20,11 +20,11 @@ import {
20
20
  loadConfig,
21
21
  parseClaims,
22
22
  parseRoles
23
- } from "./chunk-ODITZLMM.js";
23
+ } from "./chunk-TI35AZ2U.js";
24
24
  import {
25
25
  generateSigningKey
26
26
  } from "./chunk-JDQLSL36.js";
27
- import "./chunk-NPNM26J3.js";
27
+ import "./chunk-3QRKWLR7.js";
28
28
  export {
29
29
  ADMIN_ROLE,
30
30
  ConfigError,
@@ -7910,6 +7910,76 @@ function userFromClaims(claims) {
7910
7910
 
7911
7911
  // src/users.ts
7912
7912
  import { randomBytes as randomBytes2 } from "crypto";
7913
+
7914
+ // src/settings.ts
7915
+ var SIGN_IN_METHODS = ["google", "password"];
7916
+ var METHODS_SETTING = "methods";
7917
+ function parseMethodSettings(input) {
7918
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
7919
+ const settings = {};
7920
+ for (const [key, value] of Object.entries(input)) {
7921
+ if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
7922
+ settings[key] = value;
7923
+ }
7924
+ return settings;
7925
+ }
7926
+ var MAX_TRIES = 3;
7927
+ var Settings = class {
7928
+ constructor(store) {
7929
+ this.store = store;
7930
+ }
7931
+ /** The setting's value, or undefined if it was never set. */
7932
+ async get(id) {
7933
+ return (await this.read(id)).value;
7934
+ }
7935
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
7936
+ async read(id) {
7937
+ const record = await this.store.findOne("setting", { id });
7938
+ if (typeof record?.value !== "string") return {};
7939
+ try {
7940
+ return { json: record.value, value: JSON.parse(record.value) };
7941
+ } catch {
7942
+ return { json: record.value };
7943
+ }
7944
+ }
7945
+ /**
7946
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
7947
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
7948
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
7949
+ * `updatedBy` is the address of the admin, or null for the command line.
7950
+ */
7951
+ async change(id, apply, updatedBy) {
7952
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
7953
+ const { json, value } = await this.read(id);
7954
+ const next = apply(value);
7955
+ if (next === null) return null;
7956
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
7957
+ const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
7958
+ if (written) return next;
7959
+ }
7960
+ throw new Error(`Setting "${id}" kept changing; try again.`);
7961
+ }
7962
+ /** Replaces the setting. */
7963
+ async set(id, value, updatedBy) {
7964
+ await this.change(id, () => value, updatedBy);
7965
+ }
7966
+ /** Which sign-in methods are on; a method that was never switched off is on. */
7967
+ async methods() {
7968
+ return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
7969
+ }
7970
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
7971
+ async changeMethods(apply, updatedBy) {
7972
+ return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
7973
+ }
7974
+ };
7975
+ function isOn(method, available, methods) {
7976
+ return available(method) && methods[method] !== false;
7977
+ }
7978
+ function anyOn(available, methods) {
7979
+ return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
7980
+ }
7981
+
7982
+ // src/users.ts
7913
7983
  var MAX_CODE_ATTEMPTS = 5;
7914
7984
  var MAX_WRONG_CODES = 10;
7915
7985
  var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
@@ -7947,6 +8017,12 @@ var Users = class {
7947
8017
  async passwordAccount(userId) {
7948
8018
  return this.findAccountByKey(passwordAccountKey(userId));
7949
8019
  }
8020
+ /** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
8021
+ async signInMethods(userId) {
8022
+ const accounts = await this.store.findMany("account", { userId });
8023
+ const methods = accounts.map((account) => String(account.key).split(":")[0]);
8024
+ return SIGN_IN_METHODS.filter((method) => methods.includes(method));
8025
+ }
7950
8026
  async findAccountByKey(key) {
7951
8027
  return await this.store.findOne("account", { key });
7952
8028
  }
@@ -8021,8 +8097,9 @@ var Users = class {
8021
8097
  async updateUser(id, patch) {
8022
8098
  await this.store.update("user", { id }, patch);
8023
8099
  }
8100
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
8024
8101
  async updateAccount(id, patch) {
8025
- await this.store.update("account", { id }, patch);
8102
+ return await this.store.update("account", { id }, patch) === 1;
8026
8103
  }
8027
8104
  /**
8028
8105
  * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
@@ -8107,6 +8184,7 @@ var WEBHOOK_TYPES = [
8107
8184
  "email.verify",
8108
8185
  "email.reset",
8109
8186
  "email.already_registered",
8187
+ "email.no_password",
8110
8188
  "signup.before",
8111
8189
  "user.created",
8112
8190
  "email.verified",
@@ -8416,6 +8494,11 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8416
8494
  locale
8417
8495
  });
8418
8496
  };
8497
+ const sendAccountEmail = async (user, type, target, locale) => {
8498
+ if (!webhook.wants(type)) return true;
8499
+ const methods = await users.signInMethods(user.id);
8500
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
8501
+ };
8419
8502
  const consume = async (data, purpose) => {
8420
8503
  if (typeof data.token === "string" && data.token) {
8421
8504
  return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
@@ -8483,9 +8566,7 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8483
8566
  const passwordHash = await hashPassword(data.password);
8484
8567
  const existing = await users.findByEmail(normalizeEmail(email));
8485
8568
  if (existing?.emailVerified) {
8486
- if (!webhook.wants("email.already_registered")) return accepted(c);
8487
- const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
8488
- return sent ? accepted(c) : unavailable(c);
8569
+ return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
8489
8570
  }
8490
8571
  if (existing) {
8491
8572
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
@@ -8534,8 +8615,11 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8534
8615
  if (target instanceof Response) return target;
8535
8616
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8536
8617
  const user = await users.findByEmail(normalizeEmail(data.email));
8537
- if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
8538
- if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
8618
+ const locale = localeOf(data.locale);
8619
+ if (user && await users.passwordAccount(user.id)) {
8620
+ if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
8621
+ } else if (user) {
8622
+ if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
8539
8623
  }
8540
8624
  return accepted(c);
8541
8625
  });
@@ -8545,11 +8629,10 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8545
8629
  if (policy) return error(c, 400, "weak_password", policy);
8546
8630
  const consumed = await consume(data, "reset");
8547
8631
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
8548
- if (!user) return invalidVerification(c, consumed);
8632
+ const account = user ? await users.passwordAccount(user.id) : null;
8633
+ if (!user || !account) return invalidVerification(c, consumed);
8549
8634
  const secret = await hashPassword(data.password);
8550
- const account = await users.passwordAccount(user.id);
8551
- if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
8552
- else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
8635
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c, consumed);
8553
8636
  const sessionVersion = user.sessionVersion + 1;
8554
8637
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
8555
8638
  await users.clearVerifications(user.id);
@@ -8560,74 +8643,6 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8560
8643
  });
8561
8644
  }
8562
8645
 
8563
- // src/settings.ts
8564
- var SIGN_IN_METHODS = ["google", "password"];
8565
- var METHODS_SETTING = "methods";
8566
- function parseMethodSettings(input) {
8567
- if (!input || typeof input !== "object" || Array.isArray(input)) return null;
8568
- const settings = {};
8569
- for (const [key, value] of Object.entries(input)) {
8570
- if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
8571
- settings[key] = value;
8572
- }
8573
- return settings;
8574
- }
8575
- var MAX_TRIES = 3;
8576
- var Settings = class {
8577
- constructor(store) {
8578
- this.store = store;
8579
- }
8580
- /** The setting's value, or undefined if it was never set. */
8581
- async get(id) {
8582
- return (await this.read(id)).value;
8583
- }
8584
- /** The stored JSON and what it holds; both undefined if the setting was never set. */
8585
- async read(id) {
8586
- const record = await this.store.findOne("setting", { id });
8587
- if (typeof record?.value !== "string") return {};
8588
- try {
8589
- return { json: record.value, value: JSON.parse(record.value) };
8590
- } catch {
8591
- return { json: record.value };
8592
- }
8593
- }
8594
- /**
8595
- * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
8596
- * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
8597
- * again with the newer value. Resolves to the new value, or null if `apply` refused.
8598
- * `updatedBy` is the address of the admin, or null for the command line.
8599
- */
8600
- async change(id, apply, updatedBy) {
8601
- for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
8602
- const { json, value } = await this.read(id);
8603
- const next = apply(value);
8604
- if (next === null) return null;
8605
- const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
8606
- const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
8607
- if (written) return next;
8608
- }
8609
- throw new Error(`Setting "${id}" kept changing; try again.`);
8610
- }
8611
- /** Replaces the setting. */
8612
- async set(id, value, updatedBy) {
8613
- await this.change(id, () => value, updatedBy);
8614
- }
8615
- /** Which sign-in methods are on; a method that was never switched off is on. */
8616
- async methods() {
8617
- return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
8618
- }
8619
- /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
8620
- async changeMethods(apply, updatedBy) {
8621
- return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
8622
- }
8623
- };
8624
- function isOn(method, available, methods) {
8625
- return available(method) && methods[method] !== false;
8626
- }
8627
- function anyOn(available, methods) {
8628
- return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
8629
- }
8630
-
8631
8646
  // src/app.ts
8632
8647
  var EVENT_TIMEOUT_MS = 5e3;
8633
8648
  function createApp(config) {
@@ -8933,7 +8948,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
8933
8948
  const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
8934
8949
  if (!clientId && !password) {
8935
8950
  throw new ConfigError(
8936
- `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
8951
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered,email.no_password. See docs/server.md.`
8937
8952
  );
8938
8953
  }
8939
8954
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
@@ -9042,7 +9057,7 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
9042
9057
  throw new ConfigError(
9043
9058
  `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
9044
9059
 
9045
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
9060
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
9046
9061
 
9047
9062
  For one that only wants to know who signed in:
9048
9063
 
@@ -4062,6 +4062,76 @@ function userFromClaims(claims) {
4062
4062
 
4063
4063
  // src/users.ts
4064
4064
  import { randomBytes as randomBytes2 } from "crypto";
4065
+
4066
+ // src/settings.ts
4067
+ var SIGN_IN_METHODS = ["google", "password"];
4068
+ var METHODS_SETTING = "methods";
4069
+ function parseMethodSettings(input) {
4070
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
4071
+ const settings = {};
4072
+ for (const [key, value] of Object.entries(input)) {
4073
+ if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
4074
+ settings[key] = value;
4075
+ }
4076
+ return settings;
4077
+ }
4078
+ var MAX_TRIES = 3;
4079
+ var Settings = class {
4080
+ constructor(store) {
4081
+ this.store = store;
4082
+ }
4083
+ /** The setting's value, or undefined if it was never set. */
4084
+ async get(id) {
4085
+ return (await this.read(id)).value;
4086
+ }
4087
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
4088
+ async read(id) {
4089
+ const record = await this.store.findOne("setting", { id });
4090
+ if (typeof record?.value !== "string") return {};
4091
+ try {
4092
+ return { json: record.value, value: JSON.parse(record.value) };
4093
+ } catch {
4094
+ return { json: record.value };
4095
+ }
4096
+ }
4097
+ /**
4098
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
4099
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
4100
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
4101
+ * `updatedBy` is the address of the admin, or null for the command line.
4102
+ */
4103
+ async change(id, apply, updatedBy) {
4104
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
4105
+ const { json, value } = await this.read(id);
4106
+ const next = apply(value);
4107
+ if (next === null) return null;
4108
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
4109
+ const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
4110
+ if (written) return next;
4111
+ }
4112
+ throw new Error(`Setting "${id}" kept changing; try again.`);
4113
+ }
4114
+ /** Replaces the setting. */
4115
+ async set(id, value, updatedBy) {
4116
+ await this.change(id, () => value, updatedBy);
4117
+ }
4118
+ /** Which sign-in methods are on; a method that was never switched off is on. */
4119
+ async methods() {
4120
+ return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
4121
+ }
4122
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
4123
+ async changeMethods(apply, updatedBy) {
4124
+ return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
4125
+ }
4126
+ };
4127
+ function isOn(method, available, methods) {
4128
+ return available(method) && methods[method] !== false;
4129
+ }
4130
+ function anyOn(available, methods) {
4131
+ return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
4132
+ }
4133
+
4134
+ // src/users.ts
4065
4135
  var MAX_CODE_ATTEMPTS = 5;
4066
4136
  var MAX_WRONG_CODES = 10;
4067
4137
  var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
@@ -4099,6 +4169,12 @@ var Users = class {
4099
4169
  async passwordAccount(userId) {
4100
4170
  return this.findAccountByKey(passwordAccountKey(userId));
4101
4171
  }
4172
+ /** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
4173
+ async signInMethods(userId) {
4174
+ const accounts = await this.store.findMany("account", { userId });
4175
+ const methods = accounts.map((account) => String(account.key).split(":")[0]);
4176
+ return SIGN_IN_METHODS.filter((method) => methods.includes(method));
4177
+ }
4102
4178
  async findAccountByKey(key) {
4103
4179
  return await this.store.findOne("account", { key });
4104
4180
  }
@@ -4173,8 +4249,9 @@ var Users = class {
4173
4249
  async updateUser(id, patch) {
4174
4250
  await this.store.update("user", { id }, patch);
4175
4251
  }
4252
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
4176
4253
  async updateAccount(id, patch) {
4177
- await this.store.update("account", { id }, patch);
4254
+ return await this.store.update("account", { id }, patch) === 1;
4178
4255
  }
4179
4256
  /**
4180
4257
  * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
@@ -4259,6 +4336,7 @@ var WEBHOOK_TYPES = [
4259
4336
  "email.verify",
4260
4337
  "email.reset",
4261
4338
  "email.already_registered",
4339
+ "email.no_password",
4262
4340
  "signup.before",
4263
4341
  "user.created",
4264
4342
  "email.verified",
@@ -4568,6 +4646,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4568
4646
  locale
4569
4647
  });
4570
4648
  };
4649
+ const sendAccountEmail = async (user, type, target, locale) => {
4650
+ if (!webhook.wants(type)) return true;
4651
+ const methods = await users.signInMethods(user.id);
4652
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
4653
+ };
4571
4654
  const consume = async (data, purpose) => {
4572
4655
  if (typeof data.token === "string" && data.token) {
4573
4656
  return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
@@ -4635,9 +4718,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4635
4718
  const passwordHash = await hashPassword(data.password);
4636
4719
  const existing = await users.findByEmail(normalizeEmail(email));
4637
4720
  if (existing?.emailVerified) {
4638
- if (!webhook.wants("email.already_registered")) return accepted(c);
4639
- const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
4640
- return sent ? accepted(c) : unavailable(c);
4721
+ return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
4641
4722
  }
4642
4723
  if (existing) {
4643
4724
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
@@ -4686,8 +4767,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4686
4767
  if (target instanceof Response) return target;
4687
4768
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
4688
4769
  const user = await users.findByEmail(normalizeEmail(data.email));
4689
- if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
4690
- if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
4770
+ const locale = localeOf(data.locale);
4771
+ if (user && await users.passwordAccount(user.id)) {
4772
+ if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
4773
+ } else if (user) {
4774
+ if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
4691
4775
  }
4692
4776
  return accepted(c);
4693
4777
  });
@@ -4697,11 +4781,10 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4697
4781
  if (policy) return error(c, 400, "weak_password", policy);
4698
4782
  const consumed = await consume(data, "reset");
4699
4783
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
4700
- if (!user) return invalidVerification(c, consumed);
4784
+ const account = user ? await users.passwordAccount(user.id) : null;
4785
+ if (!user || !account) return invalidVerification(c, consumed);
4701
4786
  const secret = await hashPassword(data.password);
4702
- const account = await users.passwordAccount(user.id);
4703
- if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
4704
- else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
4787
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c, consumed);
4705
4788
  const sessionVersion = user.sessionVersion + 1;
4706
4789
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
4707
4790
  await users.clearVerifications(user.id);
@@ -4712,74 +4795,6 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4712
4795
  });
4713
4796
  }
4714
4797
 
4715
- // src/settings.ts
4716
- var SIGN_IN_METHODS = ["google", "password"];
4717
- var METHODS_SETTING = "methods";
4718
- function parseMethodSettings(input) {
4719
- if (!input || typeof input !== "object" || Array.isArray(input)) return null;
4720
- const settings = {};
4721
- for (const [key, value] of Object.entries(input)) {
4722
- if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
4723
- settings[key] = value;
4724
- }
4725
- return settings;
4726
- }
4727
- var MAX_TRIES = 3;
4728
- var Settings = class {
4729
- constructor(store) {
4730
- this.store = store;
4731
- }
4732
- /** The setting's value, or undefined if it was never set. */
4733
- async get(id) {
4734
- return (await this.read(id)).value;
4735
- }
4736
- /** The stored JSON and what it holds; both undefined if the setting was never set. */
4737
- async read(id) {
4738
- const record = await this.store.findOne("setting", { id });
4739
- if (typeof record?.value !== "string") return {};
4740
- try {
4741
- return { json: record.value, value: JSON.parse(record.value) };
4742
- } catch {
4743
- return { json: record.value };
4744
- }
4745
- }
4746
- /**
4747
- * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
4748
- * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
4749
- * again with the newer value. Resolves to the new value, or null if `apply` refused.
4750
- * `updatedBy` is the address of the admin, or null for the command line.
4751
- */
4752
- async change(id, apply, updatedBy) {
4753
- for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
4754
- const { json, value } = await this.read(id);
4755
- const next = apply(value);
4756
- if (next === null) return null;
4757
- const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
4758
- const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
4759
- if (written) return next;
4760
- }
4761
- throw new Error(`Setting "${id}" kept changing; try again.`);
4762
- }
4763
- /** Replaces the setting. */
4764
- async set(id, value, updatedBy) {
4765
- await this.change(id, () => value, updatedBy);
4766
- }
4767
- /** Which sign-in methods are on; a method that was never switched off is on. */
4768
- async methods() {
4769
- return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
4770
- }
4771
- /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
4772
- async changeMethods(apply, updatedBy) {
4773
- return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
4774
- }
4775
- };
4776
- function isOn(method, available, methods) {
4777
- return available(method) && methods[method] !== false;
4778
- }
4779
- function anyOn(available, methods) {
4780
- return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
4781
- }
4782
-
4783
4798
  // src/app.ts
4784
4799
  var EVENT_TIMEOUT_MS = 5e3;
4785
4800
  function createApp(config) {
@@ -5085,7 +5100,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
5085
5100
  const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
5086
5101
  if (!clientId && !password) {
5087
5102
  throw new ConfigError(
5088
- `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
5103
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered,email.no_password. See docs/server.md.`
5089
5104
  );
5090
5105
  }
5091
5106
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
@@ -5194,7 +5209,7 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
5194
5209
  throw new ConfigError(
5195
5210
  `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
5196
5211
 
5197
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
5212
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
5198
5213
 
5199
5214
  For one that only wants to know who signed in:
5200
5215
 
@@ -30020,6 +30020,7 @@ var WEBHOOK_TYPES = [
30020
30020
  "email.verify",
30021
30021
  "email.reset",
30022
30022
  "email.already_registered",
30023
+ "email.no_password",
30023
30024
  "signup.before",
30024
30025
  "user.created",
30025
30026
  "email.verified",
@@ -30158,7 +30159,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
30158
30159
  const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
30159
30160
  if (!clientId && !password) {
30160
30161
  throw new ConfigError(
30161
- `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
30162
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered,email.no_password. See docs/server.md.`
30162
30163
  );
30163
30164
  }
30164
30165
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
@@ -30267,7 +30268,7 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
30267
30268
  throw new ConfigError(
30268
30269
  `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
30269
30270
 
30270
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
30271
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
30271
30272
 
30272
30273
  For one that only wants to know who signed in:
30273
30274
 
@@ -33803,6 +33804,76 @@ function userFromClaims(claims) {
33803
33804
 
33804
33805
  // src/users.ts
33805
33806
  import { randomBytes as randomBytes3 } from "crypto";
33807
+
33808
+ // src/settings.ts
33809
+ var SIGN_IN_METHODS = ["google", "password"];
33810
+ var METHODS_SETTING = "methods";
33811
+ function parseMethodSettings(input) {
33812
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
33813
+ const settings = {};
33814
+ for (const [key, value] of Object.entries(input)) {
33815
+ if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
33816
+ settings[key] = value;
33817
+ }
33818
+ return settings;
33819
+ }
33820
+ var MAX_TRIES = 3;
33821
+ var Settings = class {
33822
+ constructor(store) {
33823
+ this.store = store;
33824
+ }
33825
+ /** The setting's value, or undefined if it was never set. */
33826
+ async get(id) {
33827
+ return (await this.read(id)).value;
33828
+ }
33829
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
33830
+ async read(id) {
33831
+ const record = await this.store.findOne("setting", { id });
33832
+ if (typeof record?.value !== "string") return {};
33833
+ try {
33834
+ return { json: record.value, value: JSON.parse(record.value) };
33835
+ } catch {
33836
+ return { json: record.value };
33837
+ }
33838
+ }
33839
+ /**
33840
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
33841
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
33842
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
33843
+ * `updatedBy` is the address of the admin, or null for the command line.
33844
+ */
33845
+ async change(id, apply, updatedBy) {
33846
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
33847
+ const { json: json2, value } = await this.read(id);
33848
+ const next = apply(value);
33849
+ if (next === null) return null;
33850
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
33851
+ const written = json2 === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json2 }, patch) === 1;
33852
+ if (written) return next;
33853
+ }
33854
+ throw new Error(`Setting "${id}" kept changing; try again.`);
33855
+ }
33856
+ /** Replaces the setting. */
33857
+ async set(id, value, updatedBy) {
33858
+ await this.change(id, () => value, updatedBy);
33859
+ }
33860
+ /** Which sign-in methods are on; a method that was never switched off is on. */
33861
+ async methods() {
33862
+ return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
33863
+ }
33864
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
33865
+ async changeMethods(apply, updatedBy) {
33866
+ return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
33867
+ }
33868
+ };
33869
+ function isOn(method, available, methods) {
33870
+ return available(method) && methods[method] !== false;
33871
+ }
33872
+ function anyOn(available, methods) {
33873
+ return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
33874
+ }
33875
+
33876
+ // src/users.ts
33806
33877
  var MAX_CODE_ATTEMPTS = 5;
33807
33878
  var MAX_WRONG_CODES = 10;
33808
33879
  var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
@@ -33840,6 +33911,12 @@ var Users = class {
33840
33911
  async passwordAccount(userId) {
33841
33912
  return this.findAccountByKey(passwordAccountKey(userId));
33842
33913
  }
33914
+ /** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
33915
+ async signInMethods(userId) {
33916
+ const accounts = await this.store.findMany("account", { userId });
33917
+ const methods = accounts.map((account) => String(account.key).split(":")[0]);
33918
+ return SIGN_IN_METHODS.filter((method) => methods.includes(method));
33919
+ }
33843
33920
  async findAccountByKey(key) {
33844
33921
  return await this.store.findOne("account", { key });
33845
33922
  }
@@ -33914,8 +33991,9 @@ var Users = class {
33914
33991
  async updateUser(id, patch) {
33915
33992
  await this.store.update("user", { id }, patch);
33916
33993
  }
33994
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
33917
33995
  async updateAccount(id, patch) {
33918
- await this.store.update("account", { id }, patch);
33996
+ return await this.store.update("account", { id }, patch) === 1;
33919
33997
  }
33920
33998
  /**
33921
33999
  * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
@@ -34231,6 +34309,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34231
34309
  locale
34232
34310
  });
34233
34311
  };
34312
+ const sendAccountEmail = async (user, type, target, locale) => {
34313
+ if (!webhook.wants(type)) return true;
34314
+ const methods = await users.signInMethods(user.id);
34315
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
34316
+ };
34234
34317
  const consume = async (data, purpose) => {
34235
34318
  if (typeof data.token === "string" && data.token) {
34236
34319
  return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
@@ -34298,9 +34381,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34298
34381
  const passwordHash = await hashPassword(data.password);
34299
34382
  const existing = await users.findByEmail(normalizeEmail(email));
34300
34383
  if (existing?.emailVerified) {
34301
- if (!webhook.wants("email.already_registered")) return accepted(c6);
34302
- const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
34303
- return sent ? accepted(c6) : unavailable(c6);
34384
+ return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c6) : unavailable(c6);
34304
34385
  }
34305
34386
  if (existing) {
34306
34387
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c6);
@@ -34349,8 +34430,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34349
34430
  if (target instanceof Response) return target;
34350
34431
  if (!isValidEmail(data.email)) return error(c6, 400, "invalid_email", "This is not a valid e-mail address.");
34351
34432
  const user = await users.findByEmail(normalizeEmail(data.email));
34352
- if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
34353
- if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c6);
34433
+ const locale = localeOf(data.locale);
34434
+ if (user && await users.passwordAccount(user.id)) {
34435
+ if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c6);
34436
+ } else if (user) {
34437
+ if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c6);
34354
34438
  }
34355
34439
  return accepted(c6);
34356
34440
  });
@@ -34360,11 +34444,10 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34360
34444
  if (policy) return error(c6, 400, "weak_password", policy);
34361
34445
  const consumed = await consume(data, "reset");
34362
34446
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
34363
- if (!user) return invalidVerification(c6, consumed);
34447
+ const account = user ? await users.passwordAccount(user.id) : null;
34448
+ if (!user || !account) return invalidVerification(c6, consumed);
34364
34449
  const secret = await hashPassword(data.password);
34365
- const account = await users.passwordAccount(user.id);
34366
- if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
34367
- else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
34450
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c6, consumed);
34368
34451
  const sessionVersion = user.sessionVersion + 1;
34369
34452
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
34370
34453
  await users.clearVerifications(user.id);
@@ -34375,74 +34458,6 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34375
34458
  });
34376
34459
  }
34377
34460
 
34378
- // src/settings.ts
34379
- var SIGN_IN_METHODS = ["google", "password"];
34380
- var METHODS_SETTING = "methods";
34381
- function parseMethodSettings(input) {
34382
- if (!input || typeof input !== "object" || Array.isArray(input)) return null;
34383
- const settings = {};
34384
- for (const [key, value] of Object.entries(input)) {
34385
- if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
34386
- settings[key] = value;
34387
- }
34388
- return settings;
34389
- }
34390
- var MAX_TRIES = 3;
34391
- var Settings = class {
34392
- constructor(store) {
34393
- this.store = store;
34394
- }
34395
- /** The setting's value, or undefined if it was never set. */
34396
- async get(id) {
34397
- return (await this.read(id)).value;
34398
- }
34399
- /** The stored JSON and what it holds; both undefined if the setting was never set. */
34400
- async read(id) {
34401
- const record = await this.store.findOne("setting", { id });
34402
- if (typeof record?.value !== "string") return {};
34403
- try {
34404
- return { json: record.value, value: JSON.parse(record.value) };
34405
- } catch {
34406
- return { json: record.value };
34407
- }
34408
- }
34409
- /**
34410
- * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
34411
- * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
34412
- * again with the newer value. Resolves to the new value, or null if `apply` refused.
34413
- * `updatedBy` is the address of the admin, or null for the command line.
34414
- */
34415
- async change(id, apply, updatedBy) {
34416
- for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
34417
- const { json: json2, value } = await this.read(id);
34418
- const next = apply(value);
34419
- if (next === null) return null;
34420
- const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
34421
- const written = json2 === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json2 }, patch) === 1;
34422
- if (written) return next;
34423
- }
34424
- throw new Error(`Setting "${id}" kept changing; try again.`);
34425
- }
34426
- /** Replaces the setting. */
34427
- async set(id, value, updatedBy) {
34428
- await this.change(id, () => value, updatedBy);
34429
- }
34430
- /** Which sign-in methods are on; a method that was never switched off is on. */
34431
- async methods() {
34432
- return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
34433
- }
34434
- /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
34435
- async changeMethods(apply, updatedBy) {
34436
- return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
34437
- }
34438
- };
34439
- function isOn(method, available, methods) {
34440
- return available(method) && methods[method] !== false;
34441
- }
34442
- function anyOn(available, methods) {
34443
- return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
34444
- }
34445
-
34446
34461
  // src/app.ts
34447
34462
  var EVENT_TIMEOUT_MS = 5e3;
34448
34463
  function createApp(config) {
@@ -1,5 +1,5 @@
1
1
  /** Every `type` madAuth sends. */
2
- declare const WEBHOOK_TYPES: readonly ["email.verify", "email.reset", "email.already_registered", "signup.before", "user.created", "email.verified", "email.password_reset", "user.signed_in", "user.deleted", "user.claims_changed"];
2
+ declare const WEBHOOK_TYPES: readonly ["email.verify", "email.reset", "email.already_registered", "email.no_password", "signup.before", "user.created", "email.verified", "email.password_reset", "user.signed_in", "user.deleted", "user.claims_changed"];
3
3
  type WebhookType = (typeof WEBHOOK_TYPES)[number];
4
4
  /** How old a call may be before receivers reject it, so a captured call can't be replayed. */
5
5
  declare const WEBHOOK_TOLERANCE_SECONDS: number;
package/dist/webhook.d.ts CHANGED
@@ -1 +1 @@
1
- export { a as WEBHOOK_TOLERANCE_SECONDS, b as WEBHOOK_TYPES, c as WebhookType, g as generateWebhookSecret, s as signWebhook, v as verifyWebhook } from './webhook-DCDP6SaE.js';
1
+ export { a as WEBHOOK_TOLERANCE_SECONDS, b as WEBHOOK_TYPES, c as WebhookType, g as generateWebhookSecret, s as signWebhook, v as verifyWebhook } from './webhook-j5u9Kv9l.js';
package/dist/webhook.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  generateWebhookSecret,
5
5
  signWebhook,
6
6
  verifyWebhook
7
- } from "./chunk-NPNM26J3.js";
7
+ } from "./chunk-3QRKWLR7.js";
8
8
  export {
9
9
  WEBHOOK_TOLERANCE_SECONDS,
10
10
  WEBHOOK_TYPES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madauth/server",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.2",
4
4
  "description": "madAuth server: Google and e-mail & password sign-in with madAuth sessions, for Docker, AWS Lambda and Azure Functions.",
5
5
  "license": "MIT",
6
6
  "author": "David Neuy",