@ultimabase/password 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/models.ts ADDED
@@ -0,0 +1,190 @@
1
+ // password's own models (D-110, D-113, D-114, D-117): each user's hash, and
2
+ // what the sign-in limit and the hourly limits count.
3
+ import ipaddr from 'ipaddr.js'
4
+ import type { Field, ModelOf, ScalarOptions } from 'ultimabase'
5
+ import { defineModel, master, PublicError } from 'ultimabase'
6
+
7
+ const M = { as: master }
8
+ const limit = 10
9
+ const ceiling = 100
10
+ const window = 15 * 60 * 1000
11
+ const requests = 10
12
+ export const hour: number = 60 * 60 * 1000
13
+
14
+ type HashFields = {
15
+ readonly userId: Field<string, { readonly unique: true }>
16
+ readonly hash: Field<string, ScalarOptions<string>>
17
+ /** New each time the password is set, kept when it is rehashed. */
18
+ readonly stamp: Field<string, { readonly unique: true }>
19
+ }
20
+
21
+ export const PasswordHash: ModelOf<'PasswordHash', HashFields, []> =
22
+ defineModel({
23
+ name: 'PasswordHash',
24
+ fields: (f) => ({
25
+ userId: f.string({ unique: true }),
26
+ hash: f.string(),
27
+ stamp: f.string({ unique: true }),
28
+ }),
29
+ })
30
+
31
+ type AttemptFields = {
32
+ readonly subject: Field<string, ScalarOptions<string>>
33
+ readonly address: Field<string, ScalarOptions<string>>
34
+ readonly at: Field<Date, ScalarOptions<Date>>
35
+ }
36
+
37
+ export const PasswordAttempt: ModelOf<'PasswordAttempt', AttemptFields, []> =
38
+ defineModel({
39
+ name: 'PasswordAttempt',
40
+ fields: (f) => ({
41
+ subject: f.string(),
42
+ address: f.string(),
43
+ at: f.dateTime(),
44
+ }),
45
+ indexes: [{ fields: ['subject', 'address'] }, { fields: ['at'] }],
46
+ })
47
+
48
+ type RequestFields = {
49
+ readonly kind: Field<string, ScalarOptions<string>>
50
+ /** An address's network, or a user for the emails it receives. */
51
+ readonly subject: Field<string, ScalarOptions<string>>
52
+ readonly at: Field<Date, ScalarOptions<Date>>
53
+ }
54
+
55
+ export const PasswordRequest: ModelOf<'PasswordRequest', RequestFields, []> =
56
+ defineModel({
57
+ name: 'PasswordRequest',
58
+ fields: (f) => ({
59
+ kind: f.string(),
60
+ subject: f.string(),
61
+ at: f.dateTime(),
62
+ }),
63
+ indexes: [{ fields: ['kind', 'subject'] }, { fields: ['at'] }],
64
+ })
65
+
66
+ /**
67
+ * What the limits count for `address`: an IPv6 address's /64 network, the
68
+ * IPv4 address an IPv4-mapped one holds, in any notation, any other as it is
69
+ * (D-114).
70
+ */
71
+ export function networkOf(address: string): string {
72
+ if (!ipaddr.IPv6.isValid(address)) return address
73
+ const ip = ipaddr.IPv6.parse(address)
74
+ if (ip.isIPv4MappedAddress()) return ip.toIPv4Address().toString()
75
+ const prefix = ip.parts.slice(0, 4).map((group) => group.toString(16))
76
+ return `${prefix.join(':')}::/64`
77
+ }
78
+
79
+ const tooMany = (oldest: Date, span: number, now: number): PublicError =>
80
+ new PublicError('TOO_MANY_ATTEMPTS', 'Too many attempts: try again later.', {
81
+ details: {
82
+ retryAfter: Math.max(
83
+ Math.ceil((oldest.getTime() + span - now) / 1000),
84
+ 1,
85
+ ),
86
+ },
87
+ })
88
+
89
+ /** Where an attempt comes from; `undefined` for server code, not counted. */
90
+ export type Attempt =
91
+ { readonly subject: string; readonly address: string } | undefined
92
+
93
+ /** The `n`th most recent failure but `id` matching `where`, if there is one. */
94
+ async function nth(
95
+ where: { readonly subject?: string; readonly address?: string },
96
+ id: string,
97
+ n: number,
98
+ ): Promise<Date | undefined> {
99
+ const failures = await PasswordAttempt.findMany(
100
+ { where: { ...where, id: { not: id } }, orderBy: { at: 'desc' }, take: n },
101
+ M,
102
+ )
103
+ return failures[n - 1]?.at
104
+ }
105
+
106
+ /**
107
+ * Counts an attempt as failed until `run` succeeds, so concurrent attempts
108
+ * cannot pass the limit (D-113, D-114). Within 15 minutes, the 11th from one
109
+ * address on a subject is refused, as the 101st on it or from the address;
110
+ * a success clears its address's count, a busy refusal is not counted.
111
+ */
112
+ export async function limited<T>(
113
+ attempt: Attempt,
114
+ run: () => Promise<T>,
115
+ ): Promise<T> {
116
+ if (attempt === undefined) return run()
117
+ const { subject } = attempt
118
+ const address = networkOf(attempt.address)
119
+ const now = Date.now()
120
+ const since = new Date(now - window)
121
+ await PasswordAttempt.deleteMany({ where: { at: { lte: since } } }, M)
122
+ const { id } = await PasswordAttempt.create(
123
+ { data: { subject, address, at: new Date(now) } },
124
+ M,
125
+ )
126
+ // Several may be reached: the latest says when to retry.
127
+ const [oldest] = [
128
+ await nth({ subject, address }, id, limit),
129
+ await nth({ subject }, id, ceiling),
130
+ await nth({ address }, id, ceiling),
131
+ ]
132
+ .filter((at) => at !== undefined)
133
+ .toSorted((a, b) => b.getTime() - a.getTime())
134
+ if (oldest !== undefined) {
135
+ await PasswordAttempt.deleteMany({ where: { id } }, M)
136
+ throw tooMany(oldest, window, now)
137
+ }
138
+ try {
139
+ const result = await run()
140
+ await PasswordAttempt.deleteMany({ where: { subject, address } }, M)
141
+ return result
142
+ } catch (error) {
143
+ if (error instanceof PublicError && error.code === 'TOO_MANY_ATTEMPTS') {
144
+ await PasswordAttempt.deleteMany({ where: { id } }, M)
145
+ }
146
+ throw error
147
+ }
148
+ }
149
+
150
+ /** What the hourly limits count (D-114, D-117). */
151
+ export type RequestKind = 'signUp' | 'reset' | 'verify' | 'email'
152
+
153
+ /**
154
+ * Counts a request of `kind` from `subject`; past `most` within one hour,
155
+ * the refusal instead, and the request is not counted. It is counted before
156
+ * the others are, so concurrent requests cannot pass the limit (D-114).
157
+ */
158
+ export async function hourly(
159
+ kind: RequestKind,
160
+ subject: string,
161
+ most: number,
162
+ ): Promise<PublicError | undefined> {
163
+ const now = Date.now()
164
+ await PasswordRequest.deleteMany(
165
+ { where: { at: { lte: new Date(now - hour) } } },
166
+ M,
167
+ )
168
+ const { id } = await PasswordRequest.create(
169
+ { data: { kind, subject, at: new Date(now) } },
170
+ M,
171
+ )
172
+ const where = { kind, subject, id: { not: id } }
173
+ const recent = await PasswordRequest.findMany(
174
+ { where, orderBy: { at: 'desc' }, take: most },
175
+ M,
176
+ )
177
+ const oldest = recent[most - 1]
178
+ if (oldest === undefined) return undefined
179
+ await PasswordRequest.deleteMany({ where: { id } }, M)
180
+ return tooMany(oldest.at, hour, now)
181
+ }
182
+
183
+ /** Refuses the eleventh request of `kind` from `address` within one hour (D-114, D-117). */
184
+ export async function requestLimited(
185
+ kind: RequestKind,
186
+ address: string,
187
+ ): Promise<void> {
188
+ const refused = await hourly(kind, networkOf(address), requests)
189
+ if (refused !== undefined) throw refused
190
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,310 @@
1
+ // `passwordPlugin()` and `password()` (D-113): a sign-in method, an ordinary
2
+ // plugin with its functions, its models, its commands and its options.
3
+ import type { Auth, AuthApi } from '@ultimabase/auth'
4
+ import { authPlugin } from '@ultimabase/auth'
5
+ import type {
6
+ Client,
7
+ ExposedModel,
8
+ Field,
9
+ Identity,
10
+ Payload,
11
+ PluginOf,
12
+ PluginTypes,
13
+ Queries,
14
+ Register,
15
+ UseOf,
16
+ UserOf,
17
+ } from 'ultimabase'
18
+ import {
19
+ definePlugin,
20
+ isMaster,
21
+ master,
22
+ PublicError,
23
+ Refused,
24
+ } from 'ultimabase'
25
+ import manifest from '../package.json' with { type: 'json' }
26
+ import type { SignedIn, State } from './core.ts'
27
+ import {
28
+ attemptOf,
29
+ createUser,
30
+ setPassword,
31
+ signedIn,
32
+ verified,
33
+ } from './core.ts'
34
+ import type { SendEmail } from './email-functions.ts'
35
+ import { emailFunctions, mailerOf } from './email-functions.ts'
36
+ import { passwordFunctions } from './functions.ts'
37
+ import type { Credentials, Property } from './input.ts'
38
+ import { emailOf, fieldProperty, passwordOf, valid } from './input.ts'
39
+ import { PasswordAttempt, PasswordHash, PasswordRequest } from './models.ts'
40
+ import { PasswordToken } from './tokens.ts'
41
+
42
+ /** The user fields a sign-up may take; any name before the user is registered. */
43
+ export type UserField = Register extends { readonly user: unknown }
44
+ ? Extract<
45
+ Exclude<keyof UserOf<Register>, 'id' | 'createdAt' | 'updatedAt'>,
46
+ string
47
+ >
48
+ : string
49
+
50
+ export interface PasswordOptions {
51
+ /** The user fields `signUp` takes besides the email and the password; `false` closes sign-up. */
52
+ readonly signUp?: readonly UserField[] | false
53
+ /**
54
+ * Sends an email that carries a token: a reset or a verification link
55
+ * the application writes. Without it, no function sends one (D-117).
56
+ */
57
+ readonly sendEmail?: SendEmail
58
+ }
59
+
60
+ type Options = { readonly as?: Identity }
61
+ /** A transport's client, which counts the attempt and gets the cookie (D-115). */
62
+ type SignInOptions = Options & { readonly client?: Client }
63
+ type CreateData<F> = Parameters<Queries<F>['create']>[0]['data']
64
+ type UserRow<F> = Payload<F, {}>
65
+
66
+ /** password's commands on the user model (D-113). */
67
+ export interface PasswordCommands<F> {
68
+ /** Creates the user, its account and its hash; opens no session. */
69
+ signUp(
70
+ args: Credentials & { readonly data: CreateData<F> },
71
+ options?: Options,
72
+ ): Promise<UserRow<F>>
73
+ /**
74
+ * Opens a session; with a `client`, counted and with its cookie as the
75
+ * signIn function, otherwise never limited.
76
+ */
77
+ signIn(
78
+ args: Credentials,
79
+ options?: SignInOptions,
80
+ ): Promise<SignedIn<UserRow<F>>>
81
+ /**
82
+ * Replaces the password and ends every session, as Master or as the user.
83
+ * It takes no current password: check the user's intent first, as
84
+ * changePassword does.
85
+ */
86
+ setPassword(
87
+ args: { readonly id: string; readonly password: string },
88
+ options?: Options,
89
+ ): Promise<void>
90
+ }
91
+
92
+ /** password's types (D-57). */
93
+ export interface Password extends PluginTypes {
94
+ readonly name: 'password'
95
+ readonly requires: Auth
96
+ readonly appOptions: PasswordOptions
97
+ readonly addedFields: {
98
+ readonly emailVerified: Field<boolean, { readonly default: false }>
99
+ }
100
+ readonly commands: PasswordCommands<this['fields']>
101
+ }
102
+
103
+ const M = { as: master }
104
+ const states = new WeakMap<AuthApi, State>()
105
+ /** The `create` of each model whose `use` lists `password()`. */
106
+ const using = new WeakSet<object>()
107
+
108
+ function stateOf(auth: AuthApi): State {
109
+ const state = states.get(auth)
110
+ if (state === undefined) throw new Error('password has not started.')
111
+ return state
112
+ }
113
+
114
+ /** The one model whose `use` lists `password()`. */
115
+ function usersOf(models: readonly ExposedModel[]): ExposedModel {
116
+ // oxlint-disable-next-line typescript/unbound-method -- compared by identity, never called.
117
+ const users = models.filter(({ queries }) => using.has(queries.create))
118
+ const [first, second] = users
119
+ if (first === undefined) {
120
+ throw new Refused(
121
+ 'passwordPlugin() needs the user model to list it: add `use: [password()]` to defineUser.',
122
+ )
123
+ }
124
+ if (second !== undefined) {
125
+ throw new Refused(
126
+ `${first.schema.name} and ${second.schema.name} both use password(): only the user model does.`,
127
+ )
128
+ }
129
+ return first
130
+ }
131
+
132
+ const reserved = new Set([
133
+ 'id',
134
+ 'createdAt',
135
+ 'updatedAt',
136
+ 'email',
137
+ 'password',
138
+ 'emailVerified',
139
+ ])
140
+
141
+ /** The listed fields' properties; an unknown, a reserved or a missing one stops startup. */
142
+ function signUpFields(
143
+ users: ExposedModel,
144
+ listed: readonly string[],
145
+ ): ReadonlyMap<string, Property> {
146
+ const { name, fields } = users.schema
147
+ const where = `passwordPlugin({ signUp })`
148
+ const properties = new Map<string, Property>()
149
+ for (const field of listed) {
150
+ const found = fields.find((each) => each.name === field)
151
+ if (found === undefined || reserved.has(field)) {
152
+ throw new Refused(
153
+ `${where}: ${name}.${field} is not a field sign-up may take.`,
154
+ )
155
+ }
156
+ properties.set(
157
+ field,
158
+ fieldProperty(
159
+ found,
160
+ users.required.includes(field),
161
+ where,
162
+ users.validators[field],
163
+ ),
164
+ )
165
+ }
166
+ const missing = users.required.filter((field) => !properties.has(field))
167
+ if (missing.length > 0) {
168
+ throw new Refused(
169
+ `${where}: ${name} needs ${missing.join(', ')} to be created; list them.`,
170
+ )
171
+ }
172
+ return properties
173
+ }
174
+
175
+ /** A sign-up's data, checked against the list unless the caller is Master. */
176
+ function signUpData(state: State, data: object, caller: Identity): object {
177
+ if (isMaster(caller)) return data
178
+ if (state.signUp === false) {
179
+ throw new PublicError('ACCESS_DENIED', 'Sign-up is closed.')
180
+ }
181
+ const { signUp } = state
182
+ const issues = Object.keys(data)
183
+ .filter((key) => !signUp.has(key))
184
+ .map((key) => ({ path: ['data', key], message: 'Unknown field.' }))
185
+ if (issues.length > 0) {
186
+ throw new PublicError(
187
+ 'VALIDATION_FAILED',
188
+ 'Sign-up takes the listed fields only.',
189
+ {
190
+ details: { issues },
191
+ },
192
+ )
193
+ }
194
+ return data
195
+ }
196
+
197
+ /** The user model and the sign-up fields, `false` when sign-up is closed. */
198
+ function configOf(
199
+ options: PasswordOptions,
200
+ models: readonly ExposedModel[],
201
+ ): {
202
+ readonly users: ExposedModel
203
+ readonly fields: ReadonlyMap<string, Property> | false
204
+ } {
205
+ const users = usersOf(models)
206
+ const listed = options.signUp ?? []
207
+ const fields = listed === false ? false : signUpFields(users, listed)
208
+ return { users, fields }
209
+ }
210
+
211
+ const callerOf = (options: Options | undefined): Identity =>
212
+ options?.as ?? { kind: 'anonymous' }
213
+
214
+ const definition = definePlugin<Password>({
215
+ name: 'password',
216
+ manifest,
217
+ requires: { auth: authPlugin },
218
+ models: [PasswordHash, PasswordAttempt, PasswordRequest, PasswordToken],
219
+ addedFields: (f) => ({
220
+ // Only Master confirms an email: rules read it (D-117).
221
+ emailVerified: f.boolean({ default: false }).with({
222
+ access: { create: () => false, update: () => false },
223
+ }),
224
+ }),
225
+ hooks: {
226
+ // The user's hash and tokens go with them (D-113, D-117).
227
+ afterDelete: async ({ record }) => {
228
+ const where = { userId: record.id }
229
+ await PasswordHash.deleteMany({ where }, M)
230
+ await PasswordToken.deleteMany({ where }, M)
231
+ const emails = { kind: 'email', subject: record.id }
232
+ await PasswordRequest.deleteMany({ where: emails }, M)
233
+ },
234
+ },
235
+ commands: ({ queries, plugins }) => {
236
+ using.add(queries.create)
237
+ return {
238
+ signUp: async ({ email, password, data }, options) => {
239
+ const state = stateOf(plugins.auth)
240
+ const checked = signUpData(state, data, callerOf(options))
241
+ const { user } = await createUser(
242
+ state,
243
+ valid(emailOf, email, 'email'),
244
+ valid(passwordOf, password, 'password'),
245
+ checked,
246
+ )
247
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model's row, created by its own queries.
248
+ return user as UserRow<Password['addedFields']>
249
+ },
250
+ signIn: async ({ email, password }, options) => {
251
+ const state = stateOf(plugins.auth)
252
+ const subject = valid(emailOf, email, 'email')
253
+ const client = options?.client
254
+ const attempt = attemptOf(subject, client)
255
+ const checked = await verified(state, subject, password, attempt)
256
+ const opened = await signedIn(state, checked, client)
257
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model's row, read by its own queries.
258
+ return opened as SignedIn<UserRow<Password['addedFields']>>
259
+ },
260
+ setPassword: async ({ id, password }, options) => {
261
+ const caller = callerOf(options)
262
+ const self = caller.kind === 'user' && caller.user.id === id
263
+ if (!self && !isMaster(caller)) {
264
+ throw new PublicError(
265
+ 'ACCESS_DENIED',
266
+ 'User.setPassword runs as Master or as the user.',
267
+ )
268
+ }
269
+ const state = stateOf(plugins.auth)
270
+ await setPassword(state, id, valid(passwordOf, password, 'password'))
271
+ },
272
+ }
273
+ },
274
+ // The functions are built before the APIs (D-89): they read the state
275
+ // when they run, and `api` stores it once auth's API exists.
276
+ api: ({ options, plugins, models, logger }) => {
277
+ const { users, fields } = configOf(options, models)
278
+ const signUp = fields === false ? false : new Set(fields.keys())
279
+ const { auth } = plugins
280
+ states.set(auth, { auth, users, signUp, logger, sending: new Set() })
281
+ },
282
+ // The emails under way still query: they end before the database closes.
283
+ stop: async ({ plugins }) => {
284
+ await Promise.all(stateOf(plugins.auth).sending)
285
+ },
286
+ functions: ({ options, plugins, models }) => {
287
+ const { users, fields } = configOf(options, models)
288
+ const state = () => stateOf(plugins.auth)
289
+ const { sendEmail } = options
290
+ const mailer = sendEmail === undefined ? undefined : mailerOf(sendEmail)
291
+ return [
292
+ ...passwordFunctions(state, users, fields, mailer),
293
+ ...(mailer === undefined ? [] : emailFunctions(state, mailer)),
294
+ ]
295
+ },
296
+ })
297
+
298
+ /**
299
+ * The password sign-in method; it requires `authPlugin()`.
300
+ *
301
+ * Known and kept (D-115): `signUp` refuses a taken email with
302
+ * `UNIQUE_VIOLATION`, which tells that the email has an account until email
303
+ * verification can hide it; a hundred failures on one account within 15
304
+ * minutes, from any addresses, lock it for everyone, and a hundred from one
305
+ * address lock that address out of every account (D-114); `SignedIn`
306
+ * holds the token even when the cookie is set.
307
+ */
308
+ export const passwordPlugin: PluginOf<Password> = definition.plugin
309
+ /** The user model's entry: its commands, and its hash deleted with it. */
310
+ export const password: UseOf<Password> = definition.use
package/src/tokens.ts ADDED
@@ -0,0 +1,98 @@
1
+ // password's emails (D-117): the tokens their links carry, used once, and
2
+ // the emails each account receives within one hour.
3
+ import { createHash, randomBytes } from 'node:crypto'
4
+ import type { Field, ModelOf, ScalarOptions } from 'ultimabase'
5
+ import { defineModel, master, PublicError } from 'ultimabase'
6
+ import { hour, hourly } from './models.ts'
7
+
8
+ const M = { as: master }
9
+ /** The emails an account receives within one hour, of either kind. */
10
+ const emails = 3
11
+
12
+ /** What an email's token lets its holder do. */
13
+ export type EmailKind = 'reset' | 'verify'
14
+
15
+ /** What `sendEmail` receives: the application writes the text and the link. */
16
+ export interface Email {
17
+ readonly kind: EmailKind
18
+ /** The account's email, normalized. */
19
+ readonly to: string
20
+ /** What the link carries, for `resetPassword` or `verifyEmail`. */
21
+ readonly token: string
22
+ readonly userId: string
23
+ }
24
+
25
+ const lifetimes: Readonly<Record<EmailKind, number>> = {
26
+ reset: hour,
27
+ verify: 24 * hour,
28
+ }
29
+
30
+ type TokenFields = {
31
+ readonly userId: Field<string, ScalarOptions<string>>
32
+ readonly kind: Field<string, ScalarOptions<string>>
33
+ /** The token's SHA-256: a leaked database gives no working link. */
34
+ readonly hash: Field<string, { readonly unique: true }>
35
+ readonly expiresAt: Field<Date, ScalarOptions<Date>>
36
+ }
37
+
38
+ export const PasswordToken: ModelOf<'PasswordToken', TokenFields, []> =
39
+ defineModel({
40
+ name: 'PasswordToken',
41
+ fields: (f) => ({
42
+ userId: f.string(),
43
+ kind: f.string(),
44
+ hash: f.string({ unique: true }),
45
+ expiresAt: f.dateTime(),
46
+ }),
47
+ indexes: [{ fields: ['userId', 'kind'] }, { fields: ['expiresAt'] }],
48
+ })
49
+
50
+ const hashOf = (token: string): string =>
51
+ createHash('sha256').update(token).digest('base64url')
52
+
53
+ /** A new token of `kind` for `userId`, which replaces the older one. */
54
+ export async function issueToken(
55
+ userId: string,
56
+ kind: EmailKind,
57
+ ): Promise<string> {
58
+ const now = Date.now()
59
+ await PasswordToken.deleteMany(
60
+ { where: { expiresAt: { lte: new Date(now) } } },
61
+ M,
62
+ )
63
+ await PasswordToken.deleteMany({ where: { userId, kind } }, M)
64
+ const token = randomBytes(32).toString('base64url')
65
+ const expiresAt = new Date(now + lifetimes[kind])
66
+ await PasswordToken.create(
67
+ { data: { userId, kind, hash: hashOf(token), expiresAt } },
68
+ M,
69
+ )
70
+ return token
71
+ }
72
+
73
+ const invalidLink = () =>
74
+ new PublicError('INVALID_CREDENTIALS', 'This link is invalid or has expired.')
75
+
76
+ /** The user a token of `kind` was issued to; a wrong, used or expired one fails. */
77
+ export async function redeemToken(
78
+ token: string,
79
+ kind: EmailKind,
80
+ ): Promise<string> {
81
+ const hash = hashOf(token)
82
+ const stored = await PasswordToken.findUnique({ where: { hash } }, M)
83
+ if (stored?.kind !== kind) throw invalidLink()
84
+ // One delete wins, so a token used twice at once works once.
85
+ const { count } = await PasswordToken.deleteMany({ where: { hash } }, M)
86
+ if (count === 0 || stored.expiresAt.getTime() <= Date.now()) {
87
+ throw invalidLink()
88
+ }
89
+ return stored.userId
90
+ }
91
+
92
+ /**
93
+ * Counts an email to `userId`; past three within one hour, the refusal
94
+ * instead, and the email is not counted.
95
+ */
96
+ export const emailLimited = async (
97
+ userId: string,
98
+ ): Promise<PublicError | undefined> => hourly('email', userId, emails)