@nxgt/janus 0.22.1 → 0.23.1

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 (54) hide show
  1. package/README.md +97 -16
  2. package/dist/auth/config/janus-config.d.ts +2 -0
  3. package/dist/auth/config/janus-config.d.ts.map +1 -1
  4. package/dist/auth/config/resolved-config.d.ts +1 -0
  5. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  6. package/dist/auth/config/warn-throttle-window.d.ts.map +1 -1
  7. package/dist/auth/email-flows/change-email-token.d.ts +42 -0
  8. package/dist/auth/email-flows/change-email-token.d.ts.map +1 -0
  9. package/dist/auth/email-flows/change-email.d.ts +11 -0
  10. package/dist/auth/email-flows/change-email.d.ts.map +1 -0
  11. package/dist/auth/email-flows/flows.d.ts +5 -5
  12. package/dist/auth/email-flows/flows.d.ts.map +1 -1
  13. package/dist/auth/events.d.ts +3 -2
  14. package/dist/auth/events.d.ts.map +1 -1
  15. package/dist/auth/index.d.ts +1 -1
  16. package/dist/auth/index.d.ts.map +1 -1
  17. package/dist/auth/mail-requests.d.ts +6 -3
  18. package/dist/auth/mail-requests.d.ts.map +1 -1
  19. package/dist/auth/one-time/issue.d.ts +7 -1
  20. package/dist/auth/one-time/issue.d.ts.map +1 -1
  21. package/dist/auth/port/types/tokens.d.ts +10 -4
  22. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  23. package/dist/auth/types/change-email.d.ts +51 -0
  24. package/dist/auth/types/change-email.d.ts.map +1 -0
  25. package/dist/auth/types/index.d.ts +1 -0
  26. package/dist/auth/types/index.d.ts.map +1 -1
  27. package/dist/auth/types/janus.d.ts +2 -1
  28. package/dist/auth/types/janus.d.ts.map +1 -1
  29. package/dist/auth/types/user-type.d.ts +3 -1
  30. package/dist/auth/types/user-type.d.ts.map +1 -1
  31. package/dist/auth/users/any-type-api.d.ts +2 -2
  32. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  33. package/dist/auth/users/update.d.ts +2 -1
  34. package/dist/auth/users/update.d.ts.map +1 -1
  35. package/dist/conformance/cases/tokens/change-email.d.ts +8 -0
  36. package/dist/conformance/cases/tokens/change-email.d.ts.map +1 -0
  37. package/dist/conformance/cases/tokens/index.d.ts.map +1 -1
  38. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -1
  39. package/dist/conformance/index.js +92 -57
  40. package/dist/conformance/index.js.map +6 -5
  41. package/dist/errors/janus-error/codes.d.ts +7 -3
  42. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  43. package/dist/index.js +134 -31
  44. package/dist/index.js.map +13 -11
  45. package/docs/README.md +1 -1
  46. package/docs/guide/adapters.md +13 -7
  47. package/docs/guide/email-flows.md +98 -12
  48. package/docs/guide/errors.md +3 -3
  49. package/docs/guide/events.md +5 -4
  50. package/docs/guide/users.md +10 -8
  51. package/docs/guide/vocabulary.md +3 -3
  52. package/docs/roadmap.md +13 -10
  53. package/docs/troubleshooting.md +32 -12
  54. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -425,7 +425,8 @@ function resolveTokenTtls(config, where) {
425
425
  resetPassword: parseDuration(config.tokens?.resetPassword ?? "1h", `${where}: tokens.resetPassword`),
426
426
  signInCode: parseDuration(config.tokens?.signInCode ?? "10m", `${where}: tokens.signInCode`),
427
427
  magicLink: parseDuration(config.tokens?.magicLink ?? "15m", `${where}: tokens.magicLink`),
428
- stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`)
428
+ stepUp: parseDuration(config.tokens?.stepUp ?? "10m", `${where}: tokens.stepUp`),
429
+ changeEmail: parseDuration(config.tokens?.changeEmail ?? "1h", `${where}: tokens.changeEmail`)
429
430
  };
430
431
  }
431
432
  // src/auth/config/warn-throttle-window.ts
@@ -434,7 +435,8 @@ var FLOWS = [
434
435
  "signInCode",
435
436
  "resetPassword",
436
437
  "verifyEmail",
437
- "stepUp"
438
+ "stepUp",
439
+ "changeEmail"
438
440
  ];
439
441
  var UNITS2 = [
440
442
  ["d", 86400000],
@@ -1151,13 +1153,13 @@ function codeMatches(token, challenge, code) {
1151
1153
  // src/auth/one-time/issue.ts
1152
1154
  import { randomInt } from "node:crypto";
1153
1155
  async function issueOneTime(context, token) {
1154
- return insertOneTime(context, token, null);
1156
+ return insertOneTime(context, token, null, token.boundHash ?? null);
1155
1157
  }
1156
1158
  async function issueCode(context, token) {
1157
1159
  const code = String(randomInt(1e6)).padStart(6, "0");
1158
- return { ...await insertOneTime(context, token, code), code };
1160
+ return { ...await insertOneTime(context, token, code, null), code };
1159
1161
  }
1160
- async function insertOneTime(context, token, code) {
1162
+ async function insertOneTime(context, token, code, boundHash) {
1161
1163
  const now = context.clock.now();
1162
1164
  const secret = token.secret ?? mintSecret();
1163
1165
  const expiresAt = new Date(now.getTime() + token.ttlMs);
@@ -1166,7 +1168,7 @@ async function insertOneTime(context, token, code) {
1166
1168
  kind: token.kind,
1167
1169
  userId: token.userId,
1168
1170
  address: token.address,
1169
- codeHash: code === null ? null : hashCode(secret, code),
1171
+ codeHash: code === null ? boundHash : hashCode(secret, code),
1170
1172
  attempts: 0,
1171
1173
  expiresAt,
1172
1174
  spentAt: null,
@@ -1243,8 +1245,11 @@ async function insertLink(links, tokenHash) {
1243
1245
  }
1244
1246
  // src/auth/mail-requests.ts
1245
1247
  var PURPOSE2 = "janus/mail/requests/v1";
1246
- async function countMailByAddress(context, type, flow, email, where) {
1247
- await countMail(context, type, flow, { address: normalizeEmail(email) }, where);
1248
+ async function countMailByAddress(context, type, flow, email, where, userId) {
1249
+ await countMail(context, type, flow, {
1250
+ address: normalizeEmail(email),
1251
+ ...userId === undefined ? {} : { userId }
1252
+ }, where);
1248
1253
  }
1249
1254
  async function countMailByUser(context, type, flow, userId, where) {
1250
1255
  await countMail(context, type, flow, { userId }, where);
@@ -1265,14 +1270,122 @@ async function countMail(context, type, flow, counted, where) {
1265
1270
  if (attempts <= throttle.attempts)
1266
1271
  return;
1267
1272
  const now = context.clock.now().getTime();
1268
- const byUser = "userId" in counted;
1269
- throw new MailThrottledError2(`${where}: too many e-mails asked for this ${byUser ? "user" : "address"} — the last one sent still works; use it, or wait for the next window`, {
1273
+ throw new MailThrottledError2(`${where}: too many e-mails asked for this ${"address" in counted ? "address" : "user"} — the last one sent still works; use it, or wait for the next window`, {
1270
1274
  userType: type.name,
1271
- ...byUser ? { userId: counted.userId } : {},
1275
+ ...counted.userId === undefined ? {} : { userId: counted.userId },
1272
1276
  retryAfter: Math.max(1, Math.ceil((endsAt - now) / 1000))
1273
1277
  });
1274
1278
  }
1275
1279
 
1280
+ // src/auth/users/fields-patch.ts
1281
+ var normalized = (email) => email === null ? null : normalizeEmail(email);
1282
+ function emailChanged(type, before, after) {
1283
+ return normalized(emailOf(type, before)) !== normalized(emailOf(type, after));
1284
+ }
1285
+ async function fieldsPatch(type, record, patch, where) {
1286
+ const fields = await validateFields(type, { ...record.fields, ...patch }, where);
1287
+ return {
1288
+ fields,
1289
+ logins: loginsOf(type, fields, where),
1290
+ schemaVersion: type.schemaVersion,
1291
+ ...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
1292
+ };
1293
+ }
1294
+
1295
+ // src/auth/email-flows/change-email-token.ts
1296
+ var formerHash = (secret, former) => hashSecret(`janus/changeEmail/former\x00${secret}\x00${normalizeEmail(former ?? "")}`);
1297
+ async function newAddressOf(type, user, email, where) {
1298
+ const fields = await validateFields(type, { ...user.fields, [type.email]: email }, where);
1299
+ const address = emailOf(type, fields);
1300
+ if (address === null) {
1301
+ throw new TypeError(`janus: email "${type.email}" did not name a string in validated ${type.name} fields — the schema must keep the e-mail field a required string`);
1302
+ }
1303
+ if (normalizeEmail(address) === normalizeEmail(emailOf(type, user.fields) ?? "")) {
1304
+ throw new UserInvalidError2(`${where}: the new e-mail is the user's current one — a change moves to another address; verifyEmail proves the current one`, {
1305
+ issues: [
1306
+ { path: [type.email], message: "is the current e-mail of the user" }
1307
+ ],
1308
+ userType: type.name
1309
+ });
1310
+ }
1311
+ return address;
1312
+ }
1313
+ async function issueChangeEmailToken(context, type, user, address) {
1314
+ const secret = mintSecret();
1315
+ const { expiresAt } = await issueOneTime(context, {
1316
+ kind: "changeEmail",
1317
+ userId: user.id,
1318
+ address,
1319
+ ttlMs: context.config.tokenTtlMs.changeEmail,
1320
+ secret,
1321
+ boundHash: formerHash(secret, emailOf(type, user.fields))
1322
+ });
1323
+ await context.store.tokens.spendUserTokens(user.id, "changeEmail", context.clock.now(), hashSecret(secret));
1324
+ return { token: secret, email: address, expiresAt };
1325
+ }
1326
+ function refuseMoved(type, user, token, secret, where) {
1327
+ if (token.codeHash !== formerHash(secret, emailOf(type, user.fields))) {
1328
+ throw new TokenError2("TOKEN_STALE", `${where}: the token was asked from an e-mail the user no longer has`, { operation: where, userId: user.id, userType: type.name });
1329
+ }
1330
+ }
1331
+ async function redeemChangeEmailToken(context, type, secret, where) {
1332
+ const token = await spendOneTime(context, secret, "changeEmail", where, "token");
1333
+ const user = await findRecord(context, token.userId, type.name);
1334
+ if (user === null)
1335
+ throw unknownOneTime(where, "token");
1336
+ refuseMoved(type, user, token, secret, where);
1337
+ return { token, user };
1338
+ }
1339
+
1340
+ // src/auth/email-flows/change-email.ts
1341
+ var inactive = (type, user, where) => new UserInactiveError2(`${where}: the user is inactive`, {
1342
+ userId: user.id,
1343
+ userType: type.name
1344
+ });
1345
+ function changeEmailFlows(context, type, at) {
1346
+ return {
1347
+ async send(user, email) {
1348
+ const where = at("changeEmail.send");
1349
+ const record = await getRecord(context, idOf(user), type.name, where);
1350
+ if (!record.active)
1351
+ throw inactive(type, record, where);
1352
+ const address = await newAddressOf(type, record, email, where);
1353
+ await countMailByUser(context, type, "changeEmail", record.id, where);
1354
+ await countMailByAddress(context, type, "changeEmail", address, where, record.id);
1355
+ return issueChangeEmailToken(context, type, record, address);
1356
+ },
1357
+ async confirm(secret) {
1358
+ return confirmChange(context, type, secret, at("changeEmail.confirm"));
1359
+ }
1360
+ };
1361
+ }
1362
+ async function confirmChange(context, type, secret, where) {
1363
+ const { token, user } = await redeemChangeEmailToken(context, type, secret, where);
1364
+ if (!user.active)
1365
+ throw inactive(type, user, where);
1366
+ const seen = { replaced: null };
1367
+ const written = await writeUser(context, user.id, type, undefined, where, async (record, now) => {
1368
+ if (!record.active)
1369
+ throw inactive(type, record, where);
1370
+ refuseMoved(type, record, token, secret, where);
1371
+ seen.replaced = record;
1372
+ const patch = await fieldsPatch(type, record, { [type.email]: token.address }, where);
1373
+ const proved = record.emailVerifiedAt === null || emailChanged(type, record.fields, patch.fields);
1374
+ return proved ? { ...patch, emailVerifiedAt: now } : patch;
1375
+ });
1376
+ const { replaced } = seen;
1377
+ const moved = replaced !== null && emailChanged(type, replaced.fields, written.fields);
1378
+ if (replaced !== null && moved) {
1379
+ await emit(context, "user.emailChanged", written, written.updatedAt, {
1380
+ formerEmail: emailOf(type, replaced.fields)
1381
+ });
1382
+ }
1383
+ if (moved || replaced?.emailVerifiedAt === null) {
1384
+ await emit(context, "user.emailVerified", written, written.updatedAt);
1385
+ }
1386
+ return toUser(written);
1387
+ }
1388
+
1276
1389
  // src/auth/password-written.ts
1277
1390
  async function endWhatThePasswordOpened(context, userId) {
1278
1391
  const now = context.clock.now();
@@ -1581,7 +1694,8 @@ function verifyEmailFlows(context, type, at) {
1581
1694
  function emailFlows(context, type, at) {
1582
1695
  return {
1583
1696
  verifyEmail: verifyEmailFlows(context, type, at),
1584
- resetPassword: resetPasswordFlows(context, type, at)
1697
+ resetPassword: resetPasswordFlows(context, type, at),
1698
+ changeEmail: changeEmailFlows(context, type, at)
1585
1699
  };
1586
1700
  }
1587
1701
  // src/auth/email-sign-in.ts
@@ -2471,21 +2585,6 @@ async function readBatch(context, ids) {
2471
2585
  return records;
2472
2586
  }
2473
2587
 
2474
- // src/auth/users/fields-patch.ts
2475
- var normalized = (email) => email === null ? null : normalizeEmail(email);
2476
- function emailChanged(type, before, after) {
2477
- return normalized(emailOf(type, before)) !== normalized(emailOf(type, after));
2478
- }
2479
- async function fieldsPatch(type, record, patch, where) {
2480
- const fields = await validateFields(type, { ...record.fields, ...patch }, where);
2481
- return {
2482
- fields,
2483
- logins: loginsOf(type, fields, where),
2484
- schemaVersion: type.schemaVersion,
2485
- ...emailChanged(type, record.fields, fields) ? { emailVerifiedAt: null } : {}
2486
- };
2487
- }
2488
-
2489
2588
  // src/auth/users/update.ts
2490
2589
  async function update(context, type, user, patch, options, where) {
2491
2590
  const seen = { replaced: null };
@@ -2495,9 +2594,13 @@ async function update(context, type, user, patch, options, where) {
2495
2594
  });
2496
2595
  const { replaced } = seen;
2497
2596
  if (replaced !== null && emailChanged(type, replaced.fields, written.fields)) {
2498
- await emit(context, "user.emailChanged", written, written.updatedAt, {
2499
- formerEmail: emailOf(type, replaced.fields)
2500
- });
2597
+ try {
2598
+ await context.store.tokens.spendUserTokens(written.id, "changeEmail", context.clock.now());
2599
+ } finally {
2600
+ await emit(context, "user.emailChanged", written, written.updatedAt, {
2601
+ formerEmail: emailOf(type, replaced.fields)
2602
+ });
2603
+ }
2501
2604
  }
2502
2605
  return written;
2503
2606
  }
@@ -2648,5 +2751,5 @@ export {
2648
2751
  systemClock
2649
2752
  };
2650
2753
 
2651
- //# debugId=D104C8FB67042A0864756E2164756E21
2754
+ //# debugId=33064E7A9A36409664756E2164756E21
2652
2755
  //# sourceMappingURL=index.js.map