@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 +16 -0
- package/dist/core.d.ts +5 -3
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +15 -4
- package/dist/email-field.d.ts +7 -2
- package/dist/email-field.d.ts.map +1 -1
- package/dist/email-field.js +20 -11
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +10 -3
- package/dist/tokens.d.ts +8 -2
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +20 -6
- package/package.json +8 -8
- package/src/core.ts +21 -4
- package/src/email-field.ts +19 -13
- package/src/plugin.ts +13 -3
- package/src/tokens.ts +32 -10
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
|
|
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
|
/**
|
package/dist/core.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
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`).
|
package/dist/email-field.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
33
|
-
|
|
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
|
|
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"}
|
package/dist/email-field.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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
|
-
|
|
194
|
+
const { email, emailVerified, passwordSetAt } = user;
|
|
195
|
+
if (email === null && emailVerified !== true && passwordSetAt === null) {
|
|
190
196
|
return;
|
|
191
|
-
|
|
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;
|
package/dist/tokens.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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.
|
|
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": "
|
|
38
|
-
"ultimabase": "
|
|
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.
|
|
43
|
-
"@ultimabase/graphql": "0.
|
|
44
|
-
"@ultimabase/http": "0.
|
|
45
|
-
"@ultimabase/postgres": "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.
|
|
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
|
|
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`).
|
package/src/email-field.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 = () =>
|