@ultimabase/password 0.2.0 → 0.3.0-canary.20261031104207

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @ultimabase/password
2
2
 
3
+ ## 0.3.0-canary.20261031104207
4
+
5
+ ### Minor Changes
6
+
7
+ - 876b414: `password()` adds `passwordSetAt` to the user model: when the password was last set by `signUp`, `changePassword`, `resetPassword` or `User.setPassword`, read by the user and Master, `null` once the account closes (D-169). With `sendEmail`, each change but the sign-up's sends a `'passwordChanged'` email to the account once it commits. Run a migration after the upgrade.
8
+
9
+ ### Patch Changes
10
+
11
+ - 0f13ad3: Requests at once keep one pending token of each kind per user, as a new one replaces the older. A unique index on `PasswordToken` (`userId`, `kind`) enforces it: run a migration after the upgrade.
12
+ - Updated dependencies [41cfff6]
13
+ - Updated dependencies [6e8ebf6]
14
+ - Updated dependencies [9cd2dd4]
15
+ - Updated dependencies [353c2bf]
16
+ - ultimabase@0.3.0-canary.20261031104207
17
+ - @ultimabase/auth@0.3.0-canary.20261031104207
18
+
3
19
  ## 0.2.0
4
20
 
5
21
  ### Minor Changes
package/dist/core.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface State {
15
15
  readonly sending: Set<Promise<void>>;
16
16
  readonly sendEmail: SendEmail | undefined;
17
17
  }
18
+ /** Whether `setPassword` wrote `at` for `userId`, told once. */
19
+ export declare const passwordSet: (userId: string, at: Date) => boolean;
18
20
  /** What a sign-in returns: its session, and the user read as itself. */
19
21
  export interface SignedIn<U = Row> {
20
22
  /**
@@ -31,7 +33,7 @@ export declare const attemptOf: (subject: string, client: Client | undefined) =>
31
33
  /**
32
34
  * Creates the user, its account and its hash in one transaction, as Master:
33
35
  * the caller's fields were checked against the list (D-113). The email's
34
- * hook links the account (D-162).
36
+ * hook links the account (D-162); `passwordSetAt` is the sign-up's (D-169).
35
37
  */
36
38
  export declare function createUser(state: State, email: string, password: string, data: object): Promise<{
37
39
  readonly user: Row;
@@ -48,8 +50,8 @@ export declare function verified(state: State, email: string, password: string,
48
50
  export declare function checkCurrent(userId: string, password: string, attempt: Attempt): Promise<void>;
49
51
  /**
50
52
  * Replaces `userId`'s hash, or gives one to an email without (D-162), ends
51
- * every session of the user and deletes its pending reset tokens (D-117);
52
- * the new stamp.
53
+ * every session of the user, deletes its pending reset tokens (D-117) and
54
+ * sets `passwordSetAt` (D-169); the new stamp.
53
55
  */
54
56
  export declare function setPassword(state: State, userId: string, password: string): Promise<string>;
55
57
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,KAAK,EACX,MAAM,EAEN,YAAY,EACZ,MAAM,EAEN,GAAG,EAEH,MAAM,YAAY,CAAA;AAGnB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAErD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAK1C,sEAAsE;AACtE,eAAO,MAAM,MAAM,aAAa,CAAA;AAEhC,qEAAqE;AACrE,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,KAAK,CAAA;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;IACpC,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,CAAA;CACzC;AAED,wEAAwE;AACxE,MAAM,WAAW,QAAQ,CAAC,CAAC,GAAG,GAAG;IAChC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,CAAA;CACvB;AASD,4EAA4E;AAC5E,eAAO,MAAM,SAAS,YACZ,MAAM,UACP,MAAM,GAAG,SAAS,KACxB,OACqE,CAAA;AAExE;;;;GAIG;AACH,wBAAsB,UAAU,CAC/B,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAWzD;AAyCD,uEAAuE;AACvE,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACtB;AAED,wEAAwE;AACxE,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,QAAQ,CAAC,CAOnB;AAED,gEAAgE;AAChE,wBAAsB,YAAY,CACjC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,IAAI,CAAC,CAIf;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CAyBjB;AAaD;;;;GAIG;AACH,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,QAAQ,EAC3B,MAAM,EAAE,MAAM,GAAG,SAAS,GACxB,OAAO,CAAC,QAAQ,CAAC,CAQnB"}
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,KAAK,EACX,MAAM,EAEN,YAAY,EACZ,MAAM,EAEN,GAAG,EAEH,MAAM,YAAY,CAAA;AAGnB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAErD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAK1C,sEAAsE;AACtE,eAAO,MAAM,MAAM,aAAa,CAAA;AAEhC,qEAAqE;AACrE,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;IAC5B,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,KAAK,CAAA;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;IACpC,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,CAAA;CACzC;AAKD,gEAAgE;AAChE,eAAO,MAAM,WAAW,WAAY,MAAM,MAAM,IAAI,KAAG,OACX,CAAA;AAE5C,wEAAwE;AACxE,MAAM,WAAW,QAAQ,CAAC,CAAC,GAAG,GAAG;IAChC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,CAAA;CACvB;AASD,4EAA4E;AAC5E,eAAO,MAAM,SAAS,YACZ,MAAM,UACP,MAAM,GAAG,SAAS,KACxB,OACqE,CAAA;AAExE;;;;GAIG;AACH,wBAAsB,UAAU,CAC/B,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAWzD;AAyCD,uEAAuE;AACvE,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACtB;AAED,wEAAwE;AACxE,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,QAAQ,CAAC,CAOnB;AAED,gEAAgE;AAChE,wBAAsB,YAAY,CACjC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,GACd,OAAO,CAAC,IAAI,CAAC,CAIf;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CAmCjB;AAaD;;;;GAIG;AACH,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,QAAQ,EAC3B,MAAM,EAAE,MAAM,GAAG,SAAS,GACxB,OAAO,CAAC,QAAQ,CAAC,CAQnB"}
package/dist/core.js CHANGED
@@ -9,6 +9,10 @@ import { PasswordToken } from './tokens.js';
9
9
  const M = { as: master };
10
10
  /** The method `AuthAccount` links a password's email with (D-113). */
11
11
  export const method = 'password';
12
+ /** The `passwordSetAt` each `setPassword` wrote, which its commit tells (D-169). */
13
+ const setting = new Set();
14
+ /** Whether `setPassword` wrote `at` for `userId`, told once. */
15
+ export const passwordSet = (userId, at) => setting.delete(`${userId} ${at.getTime()}`);
12
16
  const invalid = () => new PublicError('INVALID_CREDENTIALS', 'Wrong email or password.');
13
17
  /** The current password is known to be the user's: no email to name (D-161). */
14
18
  const wrongPassword = () => new PublicError('INVALID_CREDENTIALS', 'Wrong password.');
@@ -17,13 +21,13 @@ export const attemptOf = (subject, client) => client === undefined ? undefined :
17
21
  /**
18
22
  * Creates the user, its account and its hash in one transaction, as Master:
19
23
  * the caller's fields were checked against the list (D-113). The email's
20
- * hook links the account (D-162).
24
+ * hook links the account (D-162); `passwordSetAt` is the sign-up's (D-169).
21
25
  */
22
26
  export async function createUser(state, email, password, data) {
23
27
  const hash = await hashPassword(password);
24
28
  const stamp = randomUUID();
25
29
  return transaction(async () => {
26
- const user = await state.users.queries.create({ data: { ...data, email } }, M);
30
+ const user = await state.users.queries.create({ data: { ...data, email, passwordSetAt: new Date() } }, M);
27
31
  await PasswordHash.create({ data: { userId: user.id, hash, stamp } }, M);
28
32
  return { user, stamp };
29
33
  });
@@ -80,8 +84,8 @@ export async function checkCurrent(userId, password, attempt) {
80
84
  }
81
85
  /**
82
86
  * Replaces `userId`'s hash, or gives one to an email without (D-162), ends
83
- * every session of the user and deletes its pending reset tokens (D-117);
84
- * the new stamp.
87
+ * every session of the user, deletes its pending reset tokens (D-117) and
88
+ * sets `passwordSetAt` (D-169); the new stamp.
85
89
  */
86
90
  export async function setPassword(state, userId, password) {
87
91
  const stored = await PasswordHash.findUnique({ where: { userId } }, M);
@@ -99,6 +103,13 @@ export async function setPassword(state, userId, password) {
99
103
  : PasswordHash.update({ where: { userId }, data }, M));
100
104
  await PasswordToken.deleteMany({ where: { userId, kind: 'reset' } }, M);
101
105
  await state.auth.revokeSessions(userId);
106
+ // Through the user's queries, so the application's hooks see it, and
107
+ // password's own tells the account once it commits (D-169).
108
+ const passwordSetAt = new Date();
109
+ if (state.sendEmail !== undefined) {
110
+ setting.add(`${userId} ${passwordSetAt.getTime()}`);
111
+ }
112
+ await state.users.queries.update({ where: { id: userId }, data: { passwordSetAt } }, M);
102
113
  });
103
114
  // Again once the stamp is visible: a sign-in that read the old one opened
104
115
  // its session before, so this ends it (see `signedIn`).
@@ -29,7 +29,12 @@ export declare function updating(state: State, record: Emailed, data: Draft): Pr
29
29
  * and deletes the links the old one received.
30
30
  */
31
31
  export declare function updated(state: State, record: Emailed, previous: Emailed): Promise<void>;
32
- /** Tells the old address, once the change commits, when `sendEmail` exists. */
33
- export declare function committed(state: State, record: Emailed, previous: Emailed): void;
32
+ /**
33
+ * Once the write commits, when `sendEmail` exists, tells the old address its
34
+ * new email (D-162) and the account a password `setPassword` set (D-169).
35
+ */
36
+ export declare function committed(state: State, record: Emailed & {
37
+ readonly passwordSetAt: Date | null;
38
+ }, previous: Emailed): void;
34
39
  export {};
35
40
  //# sourceMappingURL=email-field.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"email-field.d.ts","sourceRoot":"","sources":["../src/email-field.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAExC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,WAAW,CAAA;AAQtC,KAAK,OAAO,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAA;AACrE,KAAK,KAAK,GAAG;IACZ,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;IACjC,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CACnC,CAAA;AAED,sDAAsD;AACtD,eAAO,MAAM,UAAU,UAAW;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;CACxC,KAAG,OAKH,CAAA;AAED,wDAAwD;AACxD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,CAI1C;AAED,8DAA8D;AAC9D,wBAAsB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1E;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,IAAI,EAAE,KAAK,GACT,OAAO,CAAC,IAAI,CAAC,CAYf;AAED;;;GAGG;AACH,wBAAsB,OAAO,CAC5B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,QAAQ,EAAE,OAAO,GACf,OAAO,CAAC,IAAI,CAAC,CAUf;AAED,+EAA+E;AAC/E,wBAAgB,SAAS,CACxB,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,QAAQ,EAAE,OAAO,GACf,IAAI,CAcN"}
1
+ {"version":3,"file":"email-field.d.ts","sourceRoot":"","sources":["../src/email-field.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAExC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,WAAW,CAAA;AAQtC,KAAK,OAAO,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAA;AACrE,KAAK,KAAK,GAAG;IACZ,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;IACjC,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CACnC,CAAA;AAED,sDAAsD;AACtD,eAAO,MAAM,UAAU,UAAW;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;CACxC,KAAG,OAKH,CAAA;AAED,wDAAwD;AACxD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,CAI1C;AAED,8DAA8D;AAC9D,wBAAsB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1E;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAC7B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,IAAI,EAAE,KAAK,GACT,OAAO,CAAC,IAAI,CAAC,CAYf;AAED;;;GAGG;AACH,wBAAsB,OAAO,CAC5B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,QAAQ,EAAE,OAAO,GACf,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACxB,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,GAAG;IAAE,QAAQ,CAAC,aAAa,EAAE,IAAI,GAAG,IAAI,CAAA;CAAE,EACzD,QAAQ,EAAE,OAAO,GACf,IAAI,CAiBN"}
@@ -1,5 +1,5 @@
1
1
  import { isMaster, master, PublicError } from 'ultimabase';
2
- import { method } from './core.js';
2
+ import { method, passwordSet } from './core.js';
3
3
  import { later } from './email-functions.js';
4
4
  import { emailOf, valid } from './input.js';
5
5
  import { PasswordToken } from './tokens.js';
@@ -59,19 +59,28 @@ export async function updated(state, record, previous) {
59
59
  const where = { userId: record.id, kind: { in: ['reset', 'verify'] } };
60
60
  await PasswordToken.deleteMany({ where }, M);
61
61
  }
62
- /** Tells the old address, once the change commits, when `sendEmail` exists. */
62
+ /**
63
+ * Once the write commits, when `sendEmail` exists, tells the old address its
64
+ * new email (D-162) and the account a password `setPassword` set (D-169).
65
+ */
63
66
  export function committed(state, record, previous) {
64
- const { sendEmail } = state;
65
- const [from, to] = [previous.email, record.email];
66
- if (sendEmail === undefined || from === null || to === null || from === to) {
67
+ const { sendEmail, auth } = state;
68
+ if (sendEmail === undefined)
67
69
  return;
70
+ const { id: userId, passwordSetAt } = record;
71
+ if (passwordSetAt !== null && passwordSet(userId, passwordSetAt)) {
72
+ later(state, async () => {
73
+ // A user stored before D-162 reads no email: its account has it.
74
+ const to = await auth.subjectOf(userId, method);
75
+ if (to === undefined)
76
+ return;
77
+ await sendEmail({ kind: 'passwordChanged', to, userId });
78
+ });
68
79
  }
80
+ const [from, to] = [previous.email, record.email];
81
+ if (from === null || to === null || from === to)
82
+ return;
69
83
  later(state, async () => {
70
- await sendEmail({
71
- kind: 'emailChanged',
72
- to: from,
73
- newEmail: to,
74
- userId: record.id,
75
- });
84
+ await sendEmail({ kind: 'emailChanged', to: from, newEmail: to, userId });
76
85
  });
77
86
  }
package/dist/plugin.d.ts CHANGED
@@ -69,6 +69,10 @@ export interface Password extends PluginTypes {
69
69
  readonly optional: true;
70
70
  readonly unique: true;
71
71
  }>;
72
+ /** When the password was last set (D-169). */
73
+ readonly passwordSetAt: Field<Date | null, {
74
+ readonly optional: true;
75
+ }>;
72
76
  };
73
77
  readonly commands: PasswordCommands<this['fields']>;
74
78
  }
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
package/dist/plugin.js CHANGED
@@ -46,6 +46,7 @@ const reserved = new Set([
46
46
  'email',
47
47
  'password',
48
48
  'emailVerified',
49
+ 'passwordSetAt',
49
50
  ]);
50
51
  /** The listed fields' properties; an unknown, a reserved or a missing one stops startup. */
51
52
  function signUpFields(users, listed) {
@@ -113,6 +114,10 @@ const definition = definePlugin({
113
114
  email: f.string({ optional: true, unique: true }).with({
114
115
  access: { read: readsEmail, create: () => false, update: () => false },
115
116
  }),
117
+ // Only Master writes it; password sets it with the password (D-169).
118
+ passwordSetAt: f.dateTime({ optional: true }).with({
119
+ access: { read: readsEmail, create: () => false, update: () => false },
120
+ }),
116
121
  }),
117
122
  hooks: {
118
123
  // The email and the password account stay one (D-162).
@@ -179,16 +184,18 @@ const definition = definePlugin({
179
184
  const sending = new Set();
180
185
  states.set(auth, { auth, users, signUp, logger, sending, sendEmail });
181
186
  // A closed account keeps its row, without a password, an email or a
182
- // verified one (D-160, D-162).
187
+ // verified one (D-160, D-162, D-169).
183
188
  auth.onCloseAccount(async (userId) => {
184
189
  await forget(userId);
185
190
  const where = { id: userId };
186
191
  const user = await users.queries.findUnique({ where }, M);
187
192
  if (user === null)
188
193
  return;
189
- if (user.email === null && user.emailVerified !== true)
194
+ const { email, emailVerified, passwordSetAt } = user;
195
+ if (email === null && emailVerified !== true && passwordSetAt === null) {
190
196
  return;
191
- const data = { email: null, emailVerified: false };
197
+ }
198
+ const data = { email: null, emailVerified: false, passwordSetAt: null };
192
199
  await users.queries.update({ where, data }, M);
193
200
  });
194
201
  },
package/dist/tokens.d.ts CHANGED
@@ -4,7 +4,8 @@ import { PublicError } from 'ultimabase';
4
4
  export type EmailKind = 'reset' | 'verify' | 'emailChange';
5
5
  /**
6
6
  * What `sendEmail` receives: the application writes the text and the link.
7
- * `'emailChanged'` tells the old address its email changed (D-162).
7
+ * `'emailChanged'` tells the old address its email changed (D-162),
8
+ * `'passwordChanged'` the account its password changed (D-169).
8
9
  */
9
10
  export type Email = {
10
11
  readonly kind: EmailKind;
@@ -19,6 +20,11 @@ export type Email = {
19
20
  readonly to: string;
20
21
  readonly newEmail: string;
21
22
  readonly userId: string;
23
+ } | {
24
+ readonly kind: 'passwordChanged';
25
+ /** The account's email. */
26
+ readonly to: string;
27
+ readonly userId: string;
22
28
  };
23
29
  type TokenFields = {
24
30
  readonly userId: Field<string, ScalarOptions<string>>;
@@ -34,7 +40,7 @@ type TokenFields = {
34
40
  }>;
35
41
  };
36
42
  export declare const PasswordToken: ModelOf<'PasswordToken', TokenFields, []>;
37
- /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
43
+ /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one, even one issued at once. */
38
44
  export declare function issueToken(userId: string, kind: EmailKind, email: string): Promise<string>;
39
45
  /**
40
46
  * The user a token of `kind` was issued to, and the address it was sent to;
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAuB,WAAW,EAAE,MAAM,YAAY,CAAA;AAO7D,gDAAgD;AAChD,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,aAAa,CAAA;AAE1D;;;GAGG;AACH,MAAM,MAAM,KAAK,GACd;IACA,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACtB,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;IAC7B,uBAAuB;IACvB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACtB,CAAA;AAQJ,KAAK,WAAW,GAAG;IAClB,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACnD,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IACvD,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;IACpD,6CAA6C;IAC7C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,GAAG,IAAI,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;CACjE,CAAA;AAED,eAAO,MAAM,aAAa,EAAE,OAAO,CAAC,eAAe,EAAE,WAAW,EAAE,EAAE,CAWjE,CAAA;AAKH,sFAAsF;AACtF,wBAAsB,UAAU,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CAcjB;AAKD;;;GAGG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,SAAS,GACb,OAAO,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAUrE;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,WAChB,MAAM,KACZ,OAAO,CAAC,WAAW,GAAG,SAAS,CAAoC,CAAA"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAuB,WAAW,EAAE,MAAM,YAAY,CAAA;AAO7D,gDAAgD;AAChD,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,aAAa,CAAA;AAE1D;;;;GAIG;AACH,MAAM,MAAM,KAAK,GACd;IACA,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACtB,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;IAC7B,uBAAuB;IACvB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACtB,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAA;IAChC,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACtB,CAAA;AAQJ,KAAK,WAAW,GAAG;IAClB,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACnD,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IACvD,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;IACpD,6CAA6C;IAC7C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,GAAG,IAAI,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;CACjE,CAAA;AAED,eAAO,MAAM,aAAa,EAAE,OAAO,CAAC,eAAe,EAAE,WAAW,EAAE,EAAE,CAejE,CAAA;AAKH,+GAA+G;AAC/G,wBAAsB,UAAU,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CAyBjB;AAKD;;;GAGG;AACH,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,SAAS,GACb,OAAO,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAUrE;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,WAChB,MAAM,KACZ,OAAO,CAAC,WAAW,GAAG,SAAS,CAAoC,CAAA"}
package/dist/tokens.js CHANGED
@@ -20,18 +20,32 @@ export const PasswordToken = defineModel({
20
20
  expiresAt: f.dateTime(),
21
21
  email: f.string({ optional: true }),
22
22
  }),
23
- indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
23
+ // One pending token of each kind per user: requests at once cannot keep two.
24
+ indexes: [
25
+ { fields: ['userId', 'kind'], unique: true },
26
+ { fields: ['expiresAt'] },
27
+ ],
24
28
  });
25
29
  const hashOf = (token) => createHash('sha256').update(token).digest('base64url');
26
- /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
30
+ /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one, even one issued at once. */
27
31
  export async function issueToken(userId, kind, email) {
28
32
  const now = Date.now();
29
33
  await PasswordToken.deleteMany({ where: { expiresAt: { lte: new Date(now) } } }, M);
30
- await PasswordToken.deleteMany({ where: { userId, kind } }, M);
31
- const token = randomBytes(32).toString('base64url');
32
34
  const expiresAt = new Date(now + lifetimes[kind]);
33
- await PasswordToken.create({ data: { userId, kind, hash: hashOf(token), expiresAt, email } }, M);
34
- return token;
35
+ for (;;) {
36
+ await PasswordToken.deleteMany({ where: { userId, kind } }, M);
37
+ const token = randomBytes(32).toString('base64url');
38
+ try {
39
+ await PasswordToken.create({ data: { userId, kind, hash: hashOf(token), expiresAt, email } }, M);
40
+ return token;
41
+ }
42
+ catch (error) {
43
+ // Another request issued one meanwhile: this newer one replaces it.
44
+ if (!(error instanceof PublicError && error.code === 'UNIQUE_VIOLATION')) {
45
+ throw error;
46
+ }
47
+ }
48
+ }
35
49
  }
36
50
  const invalidLink = () => new PublicError('INVALID_CREDENTIALS', 'This link is invalid or has expired.');
37
51
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimabase/password",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-canary.20261031104207",
4
4
  "description": "Passwords for UltimaBase: sign-up, sign-in and password changes, hashed with Argon2id.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -34,18 +34,18 @@
34
34
  "ipaddr.js": "2.5.0"
35
35
  },
36
36
  "peerDependencies": {
37
- "@ultimabase/auth": "^0.2.0",
38
- "ultimabase": "^0.2.0"
37
+ "@ultimabase/auth": "0.3.0-canary.20261031104207",
38
+ "ultimabase": "0.3.0-canary.20261031104207"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/pg": "8.23.1",
42
- "@ultimabase/auth": "0.2.0",
43
- "@ultimabase/graphql": "0.2.0",
44
- "@ultimabase/http": "0.2.0",
45
- "@ultimabase/postgres": "0.2.0",
42
+ "@ultimabase/auth": "0.3.0-canary.20261031104207",
43
+ "@ultimabase/graphql": "0.3.0-canary.20261031104207",
44
+ "@ultimabase/http": "0.3.0-canary.20261031104207",
45
+ "@ultimabase/postgres": "0.3.0-canary.20261031104207",
46
46
  "graphql": "17.0.2",
47
47
  "pg": "8.23.1",
48
- "ultimabase": "0.2.0",
48
+ "ultimabase": "0.3.0-canary.20261031104207",
49
49
  "vite": "8.3.4",
50
50
  "vitest": "5.0.5"
51
51
  }
package/src/core.ts CHANGED
@@ -35,6 +35,13 @@ export interface State {
35
35
  readonly sendEmail: SendEmail | undefined
36
36
  }
37
37
 
38
+ /** The `passwordSetAt` each `setPassword` wrote, which its commit tells (D-169). */
39
+ const setting = new Set<string>()
40
+
41
+ /** Whether `setPassword` wrote `at` for `userId`, told once. */
42
+ export const passwordSet = (userId: string, at: Date): boolean =>
43
+ setting.delete(`${userId} ${at.getTime()}`)
44
+
38
45
  /** What a sign-in returns: its session, and the user read as itself. */
39
46
  export interface SignedIn<U = Row> {
40
47
  /**
@@ -64,7 +71,7 @@ export const attemptOf = (
64
71
  /**
65
72
  * Creates the user, its account and its hash in one transaction, as Master:
66
73
  * the caller's fields were checked against the list (D-113). The email's
67
- * hook links the account (D-162).
74
+ * hook links the account (D-162); `passwordSetAt` is the sign-up's (D-169).
68
75
  */
69
76
  export async function createUser(
70
77
  state: State,
@@ -76,7 +83,7 @@ export async function createUser(
76
83
  const stamp = randomUUID()
77
84
  return transaction(async () => {
78
85
  const user = await state.users.queries.create(
79
- { data: { ...data, email } },
86
+ { data: { ...data, email, passwordSetAt: new Date() } },
80
87
  M,
81
88
  )
82
89
  await PasswordHash.create({ data: { userId: user.id, hash, stamp } }, M)
@@ -157,8 +164,8 @@ export async function checkCurrent(
157
164
 
158
165
  /**
159
166
  * Replaces `userId`'s hash, or gives one to an email without (D-162), ends
160
- * every session of the user and deletes its pending reset tokens (D-117);
161
- * the new stamp.
167
+ * every session of the user, deletes its pending reset tokens (D-117) and
168
+ * sets `passwordSetAt` (D-169); the new stamp.
162
169
  */
163
170
  export async function setPassword(
164
171
  state: State,
@@ -184,6 +191,16 @@ export async function setPassword(
184
191
  : PasswordHash.update({ where: { userId }, data }, M))
185
192
  await PasswordToken.deleteMany({ where: { userId, kind: 'reset' } }, M)
186
193
  await state.auth.revokeSessions(userId)
194
+ // Through the user's queries, so the application's hooks see it, and
195
+ // password's own tells the account once it commits (D-169).
196
+ const passwordSetAt = new Date()
197
+ if (state.sendEmail !== undefined) {
198
+ setting.add(`${userId} ${passwordSetAt.getTime()}`)
199
+ }
200
+ await state.users.queries.update(
201
+ { where: { id: userId }, data: { passwordSetAt } },
202
+ M,
203
+ )
187
204
  })
188
205
  // Again once the stamp is visible: a sign-in that read the old one opened
189
206
  // its session before, so this ends it (see `signedIn`).
@@ -3,7 +3,7 @@
3
3
  import type { Caller } from 'ultimabase'
4
4
  import { isMaster, master, PublicError } from 'ultimabase'
5
5
  import type { State } from './core.ts'
6
- import { method } from './core.ts'
6
+ import { method, passwordSet } from './core.ts'
7
7
  import { later } from './email-functions.ts'
8
8
  import { emailOf, valid } from './input.ts'
9
9
  import { PasswordToken } from './tokens.ts'
@@ -82,23 +82,29 @@ export async function updated(
82
82
  await PasswordToken.deleteMany({ where }, M)
83
83
  }
84
84
 
85
- /** Tells the old address, once the change commits, when `sendEmail` exists. */
85
+ /**
86
+ * Once the write commits, when `sendEmail` exists, tells the old address its
87
+ * new email (D-162) and the account a password `setPassword` set (D-169).
88
+ */
86
89
  export function committed(
87
90
  state: State,
88
- record: Emailed,
91
+ record: Emailed & { readonly passwordSetAt: Date | null },
89
92
  previous: Emailed,
90
93
  ): void {
91
- const { sendEmail } = state
92
- const [from, to] = [previous.email, record.email]
93
- if (sendEmail === undefined || from === null || to === null || from === to) {
94
- return
94
+ const { sendEmail, auth } = state
95
+ if (sendEmail === undefined) return
96
+ const { id: userId, passwordSetAt } = record
97
+ if (passwordSetAt !== null && passwordSet(userId, passwordSetAt)) {
98
+ later(state, async () => {
99
+ // A user stored before D-162 reads no email: its account has it.
100
+ const to = await auth.subjectOf(userId, method)
101
+ if (to === undefined) return
102
+ await sendEmail({ kind: 'passwordChanged', to, userId })
103
+ })
95
104
  }
105
+ const [from, to] = [previous.email, record.email]
106
+ if (from === null || to === null || from === to) return
96
107
  later(state, async () => {
97
- await sendEmail({
98
- kind: 'emailChanged',
99
- to: from,
100
- newEmail: to,
101
- userId: record.id,
102
- })
108
+ await sendEmail({ kind: 'emailChanged', to: from, newEmail: to, userId })
103
109
  })
104
110
  }
package/src/plugin.ts CHANGED
@@ -119,6 +119,8 @@ export interface Password extends PluginTypes {
119
119
  string | null,
120
120
  { readonly optional: true; readonly unique: true }
121
121
  >
122
+ /** When the password was last set (D-169). */
123
+ readonly passwordSetAt: Field<Date | null, { readonly optional: true }>
122
124
  }
123
125
  readonly commands: PasswordCommands<this['fields']>
124
126
  }
@@ -168,6 +170,7 @@ const reserved = new Set([
168
170
  'email',
169
171
  'password',
170
172
  'emailVerified',
173
+ 'passwordSetAt',
171
174
  ])
172
175
 
173
176
  /** The listed fields' properties; an unknown, a reserved or a missing one stops startup. */
@@ -273,6 +276,10 @@ const definition = definePlugin<Password>({
273
276
  email: f.string({ optional: true, unique: true }).with({
274
277
  access: { read: readsEmail, create: () => false, update: () => false },
275
278
  }),
279
+ // Only Master writes it; password sets it with the password (D-169).
280
+ passwordSetAt: f.dateTime({ optional: true }).with({
281
+ access: { read: readsEmail, create: () => false, update: () => false },
282
+ }),
276
283
  }),
277
284
  hooks: {
278
285
  // The email and the password account stay one (D-162).
@@ -348,14 +355,17 @@ const definition = definePlugin<Password>({
348
355
  const sending = new Set<Promise<void>>()
349
356
  states.set(auth, { auth, users, signUp, logger, sending, sendEmail })
350
357
  // A closed account keeps its row, without a password, an email or a
351
- // verified one (D-160, D-162).
358
+ // verified one (D-160, D-162, D-169).
352
359
  auth.onCloseAccount(async (userId) => {
353
360
  await forget(userId)
354
361
  const where = { id: userId }
355
362
  const user = await users.queries.findUnique({ where }, M)
356
363
  if (user === null) return
357
- if (user.email === null && user.emailVerified !== true) return
358
- const data = { email: null, emailVerified: false }
364
+ const { email, emailVerified, passwordSetAt } = user
365
+ if (email === null && emailVerified !== true && passwordSetAt === null) {
366
+ return
367
+ }
368
+ const data = { email: null, emailVerified: false, passwordSetAt: null }
359
369
  await users.queries.update({ where, data }, M)
360
370
  })
361
371
  },
package/src/tokens.ts CHANGED
@@ -14,7 +14,8 @@ export type EmailKind = 'reset' | 'verify' | 'emailChange'
14
14
 
15
15
  /**
16
16
  * What `sendEmail` receives: the application writes the text and the link.
17
- * `'emailChanged'` tells the old address its email changed (D-162).
17
+ * `'emailChanged'` tells the old address its email changed (D-162),
18
+ * `'passwordChanged'` the account its password changed (D-169).
18
19
  */
19
20
  export type Email =
20
21
  | {
@@ -32,6 +33,12 @@ export type Email =
32
33
  readonly newEmail: string
33
34
  readonly userId: string
34
35
  }
36
+ | {
37
+ readonly kind: 'passwordChanged'
38
+ /** The account's email. */
39
+ readonly to: string
40
+ readonly userId: string
41
+ }
35
42
 
36
43
  const lifetimes: Readonly<Record<EmailKind, number>> = {
37
44
  reset: hour,
@@ -59,13 +66,17 @@ export const PasswordToken: ModelOf<'PasswordToken', TokenFields, []> =
59
66
  expiresAt: f.dateTime(),
60
67
  email: f.string({ optional: true }),
61
68
  }),
62
- indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
69
+ // One pending token of each kind per user: requests at once cannot keep two.
70
+ indexes: [
71
+ { fields: ['userId', 'kind'], unique: true },
72
+ { fields: ['expiresAt'] },
73
+ ],
63
74
  })
64
75
 
65
76
  const hashOf = (token: string): string =>
66
77
  createHash('sha256').update(token).digest('base64url')
67
78
 
68
- /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
79
+ /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one, even one issued at once. */
69
80
  export async function issueToken(
70
81
  userId: string,
71
82
  kind: EmailKind,
@@ -76,14 +87,25 @@ export async function issueToken(
76
87
  { where: { expiresAt: { lte: new Date(now) } } },
77
88
  M,
78
89
  )
79
- await PasswordToken.deleteMany({ where: { userId, kind } }, M)
80
- const token = randomBytes(32).toString('base64url')
81
90
  const expiresAt = new Date(now + lifetimes[kind])
82
- await PasswordToken.create(
83
- { data: { userId, kind, hash: hashOf(token), expiresAt, email } },
84
- M,
85
- )
86
- return token
91
+ for (;;) {
92
+ await PasswordToken.deleteMany({ where: { userId, kind } }, M)
93
+ const token = randomBytes(32).toString('base64url')
94
+ try {
95
+ await PasswordToken.create(
96
+ { data: { userId, kind, hash: hashOf(token), expiresAt, email } },
97
+ M,
98
+ )
99
+ return token
100
+ } catch (error) {
101
+ // Another request issued one meanwhile: this newer one replaces it.
102
+ if (!(
103
+ error instanceof PublicError && error.code === 'UNIQUE_VIOLATION'
104
+ )) {
105
+ throw error
106
+ }
107
+ }
108
+ }
87
109
  }
88
110
 
89
111
  const invalidLink = () =>