@ultimabase/password 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { authPlugin } from '@ultimabase/auth';
2
2
  import { definePlugin, isMaster, master, PublicError, Refused, } from 'ultimabase';
3
3
  import manifest from '../package.json' with { type: 'json' };
4
- import { attemptOf, createUser, setPassword, signedIn, verified, } from './core.js';
4
+ import { attemptOf, checkCurrent, createUser, setPassword, signedIn, verified, } from './core.js';
5
+ import { created, committed, creating, readsEmail, updated, updating, } from './email-field.js';
5
6
  import { emailFunctions, mailerOf } from './email-functions.js';
6
7
  import { passwordFunctions } from './functions.js';
7
8
  import { emailOf, fieldProperty, passwordOf, valid } from './input.js';
@@ -17,6 +18,14 @@ function stateOf(auth) {
17
18
  throw new Error('password has not started.');
18
19
  return state;
19
20
  }
21
+ /** Deletes the user's hash, links and email requests (D-113, D-160). */
22
+ async function forget(userId) {
23
+ const where = { userId };
24
+ await PasswordHash.deleteMany({ where }, M);
25
+ await PasswordToken.deleteMany({ where }, M);
26
+ const emails = { kind: 'email', subject: userId };
27
+ await PasswordRequest.deleteMany({ where: emails }, M);
28
+ }
20
29
  /** The one model whose `use` lists `password()`. */
21
30
  function usersOf(models) {
22
31
  // oxlint-disable-next-line typescript/unbound-method -- compared by identity, never called.
@@ -82,6 +91,14 @@ function configOf(options, models) {
82
91
  return { users, fields };
83
92
  }
84
93
  const callerOf = (options) => options?.as ?? { kind: 'anonymous' };
94
+ /** Refuses `command` on user `id` but as Master or as that user. */
95
+ function selfOrMaster(options, id, command) {
96
+ const caller = callerOf(options);
97
+ const self = caller.kind === 'user' && caller.user.id === id;
98
+ if (!self && !isMaster(caller)) {
99
+ throw new PublicError('ACCESS_DENIED', `User.${command} runs as Master or as the user.`);
100
+ }
101
+ }
85
102
  const definition = definePlugin({
86
103
  name: 'password',
87
104
  manifest,
@@ -92,15 +109,31 @@ const definition = definePlugin({
92
109
  emailVerified: f.boolean({ default: false }).with({
93
110
  access: { create: () => false, update: () => false },
94
111
  }),
112
+ // Only Master writes it; the hooks keep the account in step (D-162).
113
+ email: f.string({ optional: true, unique: true }).with({
114
+ access: { read: readsEmail, create: () => false, update: () => false },
115
+ }),
95
116
  }),
96
117
  hooks: {
118
+ // The email and the password account stay one (D-162).
119
+ beforeCreate: ({ data }) => {
120
+ creating(data);
121
+ },
122
+ afterCreate: async ({ record, plugins }) => {
123
+ await created(stateOf(plugins.auth), record);
124
+ },
125
+ beforeUpdate: async ({ record, data, plugins }) => {
126
+ await updating(stateOf(plugins.auth), record, data);
127
+ },
128
+ afterUpdate: async ({ record, previous, plugins }) => {
129
+ await updated(stateOf(plugins.auth), record, previous);
130
+ },
131
+ afterUpdateCommit: ({ record, previous, plugins }) => {
132
+ committed(stateOf(plugins.auth), record, previous);
133
+ },
97
134
  // The user's hash and tokens go with them (D-113, D-117).
98
135
  afterDelete: async ({ record }) => {
99
- const where = { userId: record.id };
100
- await PasswordHash.deleteMany({ where }, M);
101
- await PasswordToken.deleteMany({ where }, M);
102
- const emails = { kind: 'email', subject: record.id };
103
- await PasswordRequest.deleteMany({ where: emails }, M);
136
+ await forget(record.id);
104
137
  },
105
138
  },
106
139
  commands: ({ queries, plugins }) => {
@@ -124,14 +157,16 @@ const definition = definePlugin({
124
157
  return opened;
125
158
  },
126
159
  setPassword: async ({ id, password }, options) => {
127
- const caller = callerOf(options);
128
- const self = caller.kind === 'user' && caller.user.id === id;
129
- if (!self && !isMaster(caller)) {
130
- throw new PublicError('ACCESS_DENIED', 'User.setPassword runs as Master or as the user.');
131
- }
160
+ selfOrMaster(options, id, 'setPassword');
132
161
  const state = stateOf(plugins.auth);
133
162
  await setPassword(state, id, valid(passwordOf, password, 'password'));
134
163
  },
164
+ checkPassword: async ({ id, password }, options) => {
165
+ selfOrMaster(options, id, 'checkPassword');
166
+ const client = options?.client;
167
+ // Counted without a client too: the check guards an action (D-161).
168
+ await checkCurrent(id, password, attemptOf(id, client) ?? { subject: id });
169
+ },
135
170
  };
136
171
  },
137
172
  // The functions are built before the APIs (D-89): they read the state
@@ -140,7 +175,22 @@ const definition = definePlugin({
140
175
  const { users, fields } = configOf(options, models);
141
176
  const signUp = fields === false ? false : new Set(fields.keys());
142
177
  const { auth } = plugins;
143
- states.set(auth, { auth, users, signUp, logger, sending: new Set() });
178
+ const { sendEmail } = options;
179
+ const sending = new Set();
180
+ states.set(auth, { auth, users, signUp, logger, sending, sendEmail });
181
+ // A closed account keeps its row, without a password, an email or a
182
+ // verified one (D-160, D-162).
183
+ auth.onCloseAccount(async (userId) => {
184
+ await forget(userId);
185
+ const where = { id: userId };
186
+ const user = await users.queries.findUnique({ where }, M);
187
+ if (user === null)
188
+ return;
189
+ if (user.email === null && user.emailVerified !== true)
190
+ return;
191
+ const data = { email: null, emailVerified: false };
192
+ await users.queries.update({ where, data }, M);
193
+ });
144
194
  },
145
195
  // The emails under way still query: they end before the database closes.
146
196
  stop: async ({ plugins }) => {
package/dist/tokens.d.ts CHANGED
@@ -1,16 +1,25 @@
1
1
  import type { Field, ModelOf, ScalarOptions } from 'ultimabase';
2
2
  import { PublicError } from 'ultimabase';
3
3
  /** What an email's token lets its holder do. */
4
- export type EmailKind = 'reset' | 'verify';
5
- /** What `sendEmail` receives: the application writes the text and the link. */
6
- export interface Email {
4
+ export type EmailKind = 'reset' | 'verify' | 'emailChange';
5
+ /**
6
+ * What `sendEmail` receives: the application writes the text and the link.
7
+ * `'emailChanged'` tells the old address its email changed (D-162).
8
+ */
9
+ export type Email = {
7
10
  readonly kind: EmailKind;
8
- /** The account's email, normalized. */
11
+ /** The address the link goes to, normalized. */
9
12
  readonly to: string;
10
- /** What the link carries, for `resetPassword` or `verifyEmail`. */
13
+ /** What the link carries, for the function its kind names. */
11
14
  readonly token: string;
12
15
  readonly userId: string;
13
- }
16
+ } | {
17
+ readonly kind: 'emailChanged';
18
+ /** The old address. */
19
+ readonly to: string;
20
+ readonly newEmail: string;
21
+ readonly userId: string;
22
+ };
14
23
  type TokenFields = {
15
24
  readonly userId: Field<string, ScalarOptions<string>>;
16
25
  readonly kind: Field<string, ScalarOptions<string>>;
@@ -19,12 +28,22 @@ type TokenFields = {
19
28
  readonly unique: true;
20
29
  }>;
21
30
  readonly expiresAt: Field<Date, ScalarOptions<Date>>;
31
+ /** The address the email went to (D-162). */
32
+ readonly email: Field<string | null, {
33
+ readonly optional: true;
34
+ }>;
22
35
  };
23
36
  export declare const PasswordToken: ModelOf<'PasswordToken', TokenFields, []>;
24
- /** A new token of `kind` for `userId`, which replaces the older one. */
25
- export declare function issueToken(userId: string, kind: EmailKind): Promise<string>;
26
- /** The user a token of `kind` was issued to; a wrong, used or expired one fails. */
27
- export declare function redeemToken(token: string, kind: EmailKind): Promise<string>;
37
+ /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
38
+ export declare function issueToken(userId: string, kind: EmailKind, email: string): Promise<string>;
39
+ /**
40
+ * The user a token of `kind` was issued to, and the address it was sent to;
41
+ * a wrong, used or expired one fails.
42
+ */
43
+ export declare function redeemToken(token: string, kind: EmailKind): Promise<{
44
+ readonly userId: string;
45
+ readonly email: string | null;
46
+ }>;
28
47
  /**
29
48
  * Counts an email to `userId`; past three within one hour, the refusal
30
49
  * instead, and the email is not counted.
@@ -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,CAAA;AAE1C,+EAA+E;AAC/E,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACvB;AAOD,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;CACpD,CAAA;AAED,eAAO,MAAM,aAAa,EAAE,OAAO,CAAC,eAAe,EAAE,WAAW,EAAE,EAAE,CAUjE,CAAA;AAKH,wEAAwE;AACxE,wBAAsB,UAAU,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,GACb,OAAO,CAAC,MAAM,CAAC,CAcjB;AAKD,oFAAoF;AACpF,wBAAsB,WAAW,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,SAAS,GACb,OAAO,CAAC,MAAM,CAAC,CAUjB;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;;;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"}
package/dist/tokens.js CHANGED
@@ -9,6 +9,7 @@ const emails = 3;
9
9
  const lifetimes = {
10
10
  reset: hour,
11
11
  verify: 24 * hour,
12
+ emailChange: hour,
12
13
  };
13
14
  export const PasswordToken = defineModel({
14
15
  name: 'PasswordToken',
@@ -17,22 +18,26 @@ export const PasswordToken = defineModel({
17
18
  kind: f.string(),
18
19
  hash: f.string({ unique: true }),
19
20
  expiresAt: f.dateTime(),
21
+ email: f.string({ optional: true }),
20
22
  }),
21
23
  indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
22
24
  });
23
25
  const hashOf = (token) => createHash('sha256').update(token).digest('base64url');
24
- /** A new token of `kind` for `userId`, which replaces the older one. */
25
- export async function issueToken(userId, kind) {
26
+ /** A new token of `kind` for `userId`, sent to `email`; it replaces the older one. */
27
+ export async function issueToken(userId, kind, email) {
26
28
  const now = Date.now();
27
29
  await PasswordToken.deleteMany({ where: { expiresAt: { lte: new Date(now) } } }, M);
28
30
  await PasswordToken.deleteMany({ where: { userId, kind } }, M);
29
31
  const token = randomBytes(32).toString('base64url');
30
32
  const expiresAt = new Date(now + lifetimes[kind]);
31
- await PasswordToken.create({ data: { userId, kind, hash: hashOf(token), expiresAt } }, M);
33
+ await PasswordToken.create({ data: { userId, kind, hash: hashOf(token), expiresAt, email } }, M);
32
34
  return token;
33
35
  }
34
36
  const invalidLink = () => new PublicError('INVALID_CREDENTIALS', 'This link is invalid or has expired.');
35
- /** The user a token of `kind` was issued to; a wrong, used or expired one fails. */
37
+ /**
38
+ * The user a token of `kind` was issued to, and the address it was sent to;
39
+ * a wrong, used or expired one fails.
40
+ */
36
41
  export async function redeemToken(token, kind) {
37
42
  const hash = hashOf(token);
38
43
  const stored = await PasswordToken.findUnique({ where: { hash } }, M);
@@ -43,7 +48,7 @@ export async function redeemToken(token, kind) {
43
48
  if (count === 0 || stored.expiresAt.getTime() <= Date.now()) {
44
49
  throw invalidLink();
45
50
  }
46
- return stored.userId;
51
+ return { userId: stored.userId, email: stored.email };
47
52
  }
48
53
  /**
49
54
  * Counts an email to `userId`; past three within one hour, the refusal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimabase/password",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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.1.2",
38
- "ultimabase": "^0.1.2"
37
+ "@ultimabase/auth": "^0.2.0",
38
+ "ultimabase": "^0.2.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/pg": "8.23.1",
42
- "@ultimabase/auth": "0.1.2",
43
- "@ultimabase/graphql": "0.1.2",
44
- "@ultimabase/http": "0.1.2",
45
- "@ultimabase/postgres": "0.1.2",
42
+ "@ultimabase/auth": "0.2.0",
43
+ "@ultimabase/graphql": "0.2.0",
44
+ "@ultimabase/http": "0.2.0",
45
+ "@ultimabase/postgres": "0.2.0",
46
46
  "graphql": "17.0.2",
47
47
  "pg": "8.23.1",
48
- "ultimabase": "0.1.2",
48
+ "ultimabase": "0.2.0",
49
49
  "vite": "8.3.4",
50
50
  "vitest": "5.0.5"
51
51
  }
package/src/core.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  } from 'ultimabase'
14
14
  import { master, PublicError, transaction } from 'ultimabase'
15
15
  import { dummyHash, hashPassword, verifyPassword } from './hash.ts'
16
+ import type { SendEmail } from './email-functions.ts'
16
17
  import { normalized } from './input.ts'
17
18
  import type { Attempt } from './models.ts'
18
19
  import { limited, PasswordHash } from './models.ts'
@@ -31,6 +32,7 @@ export interface State {
31
32
  readonly logger: Logger
32
33
  /** The sends under way, which stopping waits for (D-117). */
33
34
  readonly sending: Set<Promise<void>>
35
+ readonly sendEmail: SendEmail | undefined
34
36
  }
35
37
 
36
38
  /** What a sign-in returns: its session, and the user read as itself. */
@@ -48,6 +50,10 @@ export interface SignedIn<U = Row> {
48
50
  const invalid = () =>
49
51
  new PublicError('INVALID_CREDENTIALS', 'Wrong email or password.')
50
52
 
53
+ /** The current password is known to be the user's: no email to name (D-161). */
54
+ const wrongPassword = () =>
55
+ new PublicError('INVALID_CREDENTIALS', 'Wrong password.')
56
+
51
57
  /** The attempt a client makes for `subject`; server code is not counted. */
52
58
  export const attemptOf = (
53
59
  subject: string,
@@ -57,7 +63,8 @@ export const attemptOf = (
57
63
 
58
64
  /**
59
65
  * Creates the user, its account and its hash in one transaction, as Master:
60
- * the caller's fields were checked against the list (D-113).
66
+ * the caller's fields were checked against the list (D-113). The email's
67
+ * hook links the account (D-162).
61
68
  */
62
69
  export async function createUser(
63
70
  state: State,
@@ -68,8 +75,10 @@ export async function createUser(
68
75
  const hash = await hashPassword(password)
69
76
  const stamp = randomUUID()
70
77
  return transaction(async () => {
71
- const user = await state.users.queries.create({ data }, M)
72
- await state.auth.linkAccount(user.id, method, email)
78
+ const user = await state.users.queries.create(
79
+ { data: { ...data, email } },
80
+ M,
81
+ )
73
82
  await PasswordHash.create({ data: { userId: user.id, hash, stamp } }, M)
74
83
  return { user, stamp }
75
84
  })
@@ -142,13 +151,14 @@ export async function checkCurrent(
142
151
  attempt: Attempt,
143
152
  ): Promise<void> {
144
153
  await limited(attempt, async () => {
145
- if ((await matches(userId, password)) === undefined) throw invalid()
154
+ if ((await matches(userId, password)) === undefined) throw wrongPassword()
146
155
  })
147
156
  }
148
157
 
149
158
  /**
150
- * Replaces `userId`'s hash, ends every session of the user and deletes its
151
- * pending reset tokens (D-117); the new stamp.
159
+ * 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.
152
162
  */
153
163
  export async function setPassword(
154
164
  state: State,
@@ -156,7 +166,10 @@ export async function setPassword(
156
166
  password: string,
157
167
  ): Promise<string> {
158
168
  const stored = await PasswordHash.findUnique({ where: { userId } }, M)
159
- if (stored === null) {
169
+ const linked =
170
+ stored !== null ||
171
+ (await state.auth.subjectOf(userId, method)) !== undefined
172
+ if (!linked) {
160
173
  throw new PublicError(
161
174
  'NOT_FOUND',
162
175
  `User ${userId} has no password: sign it up with signUp.`,
@@ -165,7 +178,10 @@ export async function setPassword(
165
178
  const hash = await hashPassword(password)
166
179
  const stamp = randomUUID()
167
180
  await transaction(async () => {
168
- await PasswordHash.update({ where: { userId }, data: { hash, stamp } }, M)
181
+ const data = { hash, stamp }
182
+ await (stored === null
183
+ ? PasswordHash.create({ data: { userId, ...data } }, M)
184
+ : PasswordHash.update({ where: { userId }, data }, M))
169
185
  await PasswordToken.deleteMany({ where: { userId, kind: 'reset' } }, M)
170
186
  await state.auth.revokeSessions(userId)
171
187
  })
@@ -0,0 +1,104 @@
1
+ // The user's `email` (D-162): the password account's subject, which
2
+ // password's hooks keep in step with the field, whichever path writes it.
3
+ import type { Caller } from 'ultimabase'
4
+ import { isMaster, master, PublicError } from 'ultimabase'
5
+ import type { State } from './core.ts'
6
+ import { method } from './core.ts'
7
+ import { later } from './email-functions.ts'
8
+ import { emailOf, valid } from './input.ts'
9
+ import { PasswordToken } from './tokens.ts'
10
+
11
+ const M = { as: master }
12
+
13
+ type Emailed = { readonly id: string; readonly email: string | null }
14
+ type Draft = {
15
+ email?: string | null | undefined
16
+ emailVerified?: boolean | undefined
17
+ }
18
+
19
+ /** Only the user itself and Master read the email. */
20
+ export const readsEmail = (facts: {
21
+ readonly caller: Caller
22
+ readonly record: { readonly id: string }
23
+ }): boolean => {
24
+ const { caller, record } = facts
25
+ return (
26
+ isMaster(caller) || (caller.kind === 'user' && caller.user.id === record.id)
27
+ )
28
+ }
29
+
30
+ /** Normalizes a created user's email, as at sign-up. */
31
+ export function creating(data: Draft): void {
32
+ if (typeof data.email === 'string') {
33
+ data.email = valid(emailOf, data.email, 'email')
34
+ }
35
+ }
36
+
37
+ /** Links the created user's email as its password account. */
38
+ export async function created(state: State, record: Emailed): Promise<void> {
39
+ if (record.email === null) return
40
+ await state.auth.linkAccount(record.id, method, record.email)
41
+ }
42
+
43
+ /**
44
+ * Normalizes an updated email, refuses `null` while the account is linked,
45
+ * and unverifies another email unless the write verifies it.
46
+ */
47
+ export async function updating(
48
+ state: State,
49
+ record: Emailed,
50
+ data: Draft,
51
+ ): Promise<void> {
52
+ if (data.email === undefined) return
53
+ const subject = await state.auth.subjectOf(record.id, method)
54
+ if (data.email === null) {
55
+ if (subject === undefined) return
56
+ const message = 'closeAccount removes the email.'
57
+ throw new PublicError('VALIDATION_FAILED', `email: ${message}`, {
58
+ details: { issues: [{ path: ['email'], message }] },
59
+ })
60
+ }
61
+ data.email = valid(emailOf, data.email, 'email')
62
+ if (data.email !== subject) data.emailVerified ??= false
63
+ }
64
+
65
+ /**
66
+ * Moves the password account to the new email, in the write's transaction,
67
+ * and deletes the links the old one received.
68
+ */
69
+ export async function updated(
70
+ state: State,
71
+ record: Emailed,
72
+ previous: Emailed,
73
+ ): Promise<void> {
74
+ if (record.email === null || record.email === previous.email) return
75
+ const { auth } = state
76
+ const subject = await auth.subjectOf(record.id, method)
77
+ // Filling the field from the account changes nothing else.
78
+ if (subject === record.email) return
79
+ if (subject !== undefined) await auth.unlinkAccount(record.id, method)
80
+ await auth.linkAccount(record.id, method, record.email)
81
+ const where = { userId: record.id, kind: { in: ['reset', 'verify'] } }
82
+ await PasswordToken.deleteMany({ where }, M)
83
+ }
84
+
85
+ /** Tells the old address, once the change commits, when `sendEmail` exists. */
86
+ export function committed(
87
+ state: State,
88
+ record: Emailed,
89
+ previous: Emailed,
90
+ ): 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
95
+ }
96
+ later(state, async () => {
97
+ await sendEmail({
98
+ kind: 'emailChanged',
99
+ to: from,
100
+ newEmail: to,
101
+ userId: record.id,
102
+ })
103
+ })
104
+ }
@@ -1,12 +1,21 @@
1
- // password's email functions (D-117): `requestPasswordReset`,
2
- // `resetPassword`, `verifyEmail` and `sendVerificationEmail`, there when the
3
- // application gives `sendEmail`.
1
+ // password's email functions (D-117, D-162): `requestPasswordReset`,
2
+ // `resetPassword`, `verifyEmail`, `sendVerificationEmail`,
3
+ // `requestEmailChange` and `confirmEmailChange`, there when the application
4
+ // gives `sendEmail`.
4
5
  import type { FunctionShape } from 'ultimabase'
5
- import { allow, defineFunction, master, PublicError } from 'ultimabase'
6
+ import {
7
+ allow,
8
+ defineFunction,
9
+ master,
10
+ PublicError,
11
+ transaction,
12
+ } from 'ultimabase'
6
13
  import type { State } from './core.ts'
7
- import { method, setPassword } from './core.ts'
14
+ import { attemptOf, checkCurrent, method, setPassword } from './core.ts'
8
15
  import {
16
+ confirmEmailChangeInput,
9
17
  outputSchema,
18
+ requestEmailChangeInput,
10
19
  requestResetInput,
11
20
  resetPasswordInput,
12
21
  verifyEmailInput,
@@ -49,7 +58,7 @@ export const mailerOf =
49
58
  async (kind, userId, to) => {
50
59
  const refused = await emailLimited(userId)
51
60
  if (refused !== undefined) return refused
52
- const token = await issueToken(userId, kind)
61
+ const token = await issueToken(userId, kind, to)
53
62
  await sendEmail({ kind, to, token, userId })
54
63
  return undefined
55
64
  }
@@ -91,7 +100,7 @@ export function emailFunctions(
91
100
  output: done,
92
101
  run: async ({ input: { input } }) => {
93
102
  const state = stateOf()
94
- const userId = await redeemToken(input.token, 'reset')
103
+ const { userId } = await redeemToken(input.token, 'reset')
95
104
  await setPassword(state, userId, input.password)
96
105
  await verify(state, userId)
97
106
  return true
@@ -102,7 +111,8 @@ export function emailFunctions(
102
111
  input: verifyEmailInput,
103
112
  output: done,
104
113
  run: async ({ input: { input } }) => {
105
- await verify(stateOf(), await redeemToken(input.token, 'verify'))
114
+ const { userId } = await redeemToken(input.token, 'verify')
115
+ await verify(stateOf(), userId)
106
116
  return true
107
117
  },
108
118
  }).with({ access: allow.public })
@@ -133,5 +143,52 @@ export function emailFunctions(
133
143
  resetPassword,
134
144
  verifyEmail,
135
145
  sendVerificationEmail,
146
+ ...emailChangeFunctions(stateOf, mail),
136
147
  ]
137
148
  }
149
+
150
+ /** `requestEmailChange` and `confirmEmailChange` (D-162). */
151
+ function emailChangeFunctions(
152
+ stateOf: () => State,
153
+ mail: Mailer,
154
+ ): readonly FunctionShape[] {
155
+ const requestEmailChange = defineFunction({
156
+ name: 'requestEmailChange',
157
+ input: requestEmailChangeInput,
158
+ output: done,
159
+ run: async ({ input: { input }, caller, client }) => {
160
+ if (caller.kind !== 'user') {
161
+ throw new PublicError(
162
+ 'UNAUTHENTICATED',
163
+ 'requestEmailChange needs a signed-in user.',
164
+ )
165
+ }
166
+ const { id } = caller.user
167
+ await checkCurrent(id, input.currentPassword, attemptOf(id, client))
168
+ const { newEmail } = input
169
+ // The caller proved the password: telling it is no enumeration.
170
+ if ((await stateOf().auth.accountOf(method, newEmail)) !== undefined) {
171
+ throw new PublicError('UNIQUE_VIOLATION', 'This email has an account.')
172
+ }
173
+ const refused = await mail('emailChange', id, newEmail)
174
+ if (refused !== undefined) throw refused
175
+ return true
176
+ },
177
+ }).with({ access: allow.public })
178
+ const confirmEmailChange = defineFunction({
179
+ name: 'confirmEmailChange',
180
+ input: confirmEmailChangeInput,
181
+ output: done,
182
+ run: async ({ input: { input } }) => {
183
+ const { users } = stateOf()
184
+ // A taken email undoes the redeem too: the link works until it expires.
185
+ await transaction(async () => {
186
+ const { userId, email } = await redeemToken(input.token, 'emailChange')
187
+ const data = { email, emailVerified: true }
188
+ await users.queries.update({ where: { id: userId }, data }, M)
189
+ })
190
+ return true
191
+ },
192
+ }).with({ access: allow.public })
193
+ return [requestEmailChange, confirmEmailChange]
194
+ }
package/src/input.ts CHANGED
@@ -332,6 +332,32 @@ export const verifyEmailInput: StandardSchema<
332
332
  Named<{ readonly token: string }>
333
333
  > = namedInput('VerifyEmail', new Map([['token', property(text, string)]]))
334
334
 
335
+ type RequestEmailChange = {
336
+ readonly newEmail: string
337
+ readonly currentPassword: string
338
+ }
339
+
340
+ /** What `requestEmailChange` takes: the new email and the password (D-162). */
341
+ export const requestEmailChangeInput: StandardSchema<
342
+ Named<RequestEmailChange>,
343
+ Named<RequestEmailChange>
344
+ > = namedInput(
345
+ 'RequestEmailChange',
346
+ new Map([
347
+ ['newEmail', email],
348
+ ['currentPassword', property(text, string)],
349
+ ]),
350
+ )
351
+
352
+ /** What `confirmEmailChange` takes: the email's token (D-162). */
353
+ export const confirmEmailChangeInput: StandardSchema<
354
+ Named<{ readonly token: string }>,
355
+ Named<{ readonly token: string }>
356
+ > = namedInput(
357
+ 'ConfirmEmailChange',
358
+ new Map([['token', property(text, string)]]),
359
+ )
360
+
335
361
  /** The email, the password and the listed user fields, by their names. */
336
362
  export const signUpInput = (
337
363
  fields: ReadonlyMap<string, Property>,