@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.
@@ -0,0 +1,102 @@
1
+ // password's functions (D-113, D-114): `signUp`, `signIn` and `changePassword`, each
2
+ // taking one named input and returning `SignedIn`.
3
+ import type { ExposedModel, FunctionShape, OutputSpec } from 'ultimabase'
4
+ import { allow, defineFunction, defineType, PublicError } from 'ultimabase'
5
+ import type { State } from './core.ts'
6
+ import {
7
+ attemptOf,
8
+ checkCurrent,
9
+ createUser,
10
+ setPassword,
11
+ signedIn,
12
+ verified,
13
+ } from './core.ts'
14
+ import type { Mailer } from './email-functions.ts'
15
+ import { later } from './email-functions.ts'
16
+ import type { Property } from './input.ts'
17
+ import { requestLimited } from './models.ts'
18
+ import {
19
+ changePasswordInput,
20
+ outputSchema,
21
+ signInInput,
22
+ signUpInput,
23
+ } from './input.ts'
24
+
25
+ /** `User.nullable()`, which `ExposedModel.model` holds but does not type. */
26
+ function nullable(model: ExposedModel['model']): OutputSpec {
27
+ const make: unknown = Reflect.get(model, 'nullable')
28
+ if (typeof make !== 'function')
29
+ throw new TypeError(`${model.name} is not a model.`)
30
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a `defineModel` value's `nullable()` (D-109).
31
+ return Reflect.apply(make, model, []) as OutputSpec
32
+ }
33
+
34
+ /**
35
+ * The functions, `signUp` only when sign-up is open; each is public. With a
36
+ * `mailer`, `signUp` sends the verification email (D-117).
37
+ */
38
+ export function passwordFunctions(
39
+ stateOf: () => State,
40
+ users: ExposedModel,
41
+ fields: ReadonlyMap<string, Property> | false,
42
+ mailer: Mailer | undefined,
43
+ ): readonly FunctionShape[] {
44
+ const SignedIn = defineType({
45
+ name: 'SignedIn',
46
+ fields: {
47
+ token: outputSchema<string>({ type: 'string' }),
48
+ expiresAt: outputSchema<Date>({ type: 'string', format: 'date-time' }),
49
+ user: nullable(users.model),
50
+ },
51
+ })
52
+ const signUp = defineFunction({
53
+ name: 'signUp',
54
+ input: signUpInput(fields === false ? new Map() : fields),
55
+ output: SignedIn,
56
+ run: async ({ input: { input }, client }) => {
57
+ const state = stateOf()
58
+ if (client !== undefined) await requestLimited('signUp', client.address)
59
+ const { email, password, ...data } = input
60
+ const { user, stamp } = await createUser(state, email, password, data)
61
+ if (mailer !== undefined) {
62
+ later(state, async () => {
63
+ await mailer('verify', user.id, email)
64
+ })
65
+ }
66
+ return signedIn(state, { userId: user.id, stamp }, client)
67
+ },
68
+ }).with({ access: allow.public })
69
+ const signIn = defineFunction({
70
+ name: 'signIn',
71
+ input: signInInput,
72
+ output: SignedIn,
73
+ run: async ({ input: { input }, client }) => {
74
+ const state = stateOf()
75
+ const { email, password } = input
76
+ const attempt = attemptOf(email, client)
77
+ const checked = await verified(state, email, password, attempt)
78
+ return signedIn(state, checked, client)
79
+ },
80
+ }).with({ access: allow.public })
81
+ const changePassword = defineFunction({
82
+ name: 'changePassword',
83
+ input: changePasswordInput,
84
+ output: SignedIn,
85
+ run: async ({ input: { input }, caller, client }) => {
86
+ if (caller.kind !== 'user') {
87
+ throw new PublicError(
88
+ 'UNAUTHENTICATED',
89
+ 'changePassword needs a signed-in user.',
90
+ )
91
+ }
92
+ const state = stateOf()
93
+ const { id } = caller.user
94
+ await checkCurrent(id, input.currentPassword, attemptOf(id, client))
95
+ const stamp = await setPassword(state, id, input.newPassword)
96
+ return signedIn(state, { userId: id, stamp }, client)
97
+ },
98
+ }).with({ access: allow.public })
99
+ return fields === false
100
+ ? [signIn, changePassword]
101
+ : [signUp, signIn, changePassword]
102
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,144 @@
1
+ // Password hashes (D-113): Argon2id from `node:crypto`, stored as a PHC string,
2
+ // a few at a time (D-114).
3
+ import { argon2, randomBytes, timingSafeEqual } from 'node:crypto'
4
+ import { availableParallelism } from 'node:os'
5
+ import { PublicError } from 'ultimabase'
6
+
7
+ type Parameters = {
8
+ readonly memory: number
9
+ readonly passes: number
10
+ readonly parallelism: number
11
+ }
12
+
13
+ /** OWASP's first Argon2id setting: 19 MiB, two passes, one lane. */
14
+ const current: Parameters = { memory: 19456, passes: 2, parallelism: 1 }
15
+ const tagLength = 32
16
+ const phc =
17
+ /^\$argon2id\$v=19\$m=(\d+),t=(\d+),p=(\d+)\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)$/
18
+
19
+ const unpadded = (bytes: Buffer): string =>
20
+ bytes.toString('base64').replace(/=+$/, '')
21
+
22
+ /** The computations that run at once, and how many more may wait (D-114). */
23
+ export const lanes: number = availableParallelism()
24
+ export const waiting = 64
25
+ let running = 0
26
+ const queue: (() => void)[] = []
27
+
28
+ /** Runs `run` when a lane is free; refuses it when the queue is full. */
29
+ async function queued<T>(run: () => Promise<T>): Promise<T> {
30
+ if (running < lanes) running += 1
31
+ else if (queue.length < waiting) {
32
+ await new Promise<void>((resolve) => queue.push(resolve))
33
+ } else {
34
+ throw new PublicError(
35
+ 'TOO_MANY_ATTEMPTS',
36
+ 'The server is busy: try again.',
37
+ {
38
+ details: { retryAfter: 1 },
39
+ },
40
+ )
41
+ }
42
+ try {
43
+ return await run()
44
+ } finally {
45
+ // The lane goes to the next computation waiting, or is freed.
46
+ const next = queue.shift()
47
+ if (next === undefined) running -= 1
48
+ else next()
49
+ }
50
+ }
51
+
52
+ async function derive(
53
+ password: string,
54
+ nonce: Buffer,
55
+ parameters: Parameters,
56
+ length: number,
57
+ ): Promise<Buffer> {
58
+ return queued(async () => compute(password, nonce, parameters, length))
59
+ }
60
+
61
+ async function compute(
62
+ password: string,
63
+ nonce: Buffer,
64
+ parameters: Parameters,
65
+ length: number,
66
+ ): Promise<Buffer> {
67
+ return new Promise((resolve, reject) => {
68
+ argon2(
69
+ 'argon2id',
70
+ { message: password, nonce, tagLength: length, ...parameters },
71
+ (error, key) => {
72
+ if (error === null) resolve(key)
73
+ else reject(error)
74
+ },
75
+ )
76
+ })
77
+ }
78
+
79
+ /** The PHC string of `password`, with a new 16-byte salt. */
80
+ export async function hashPassword(password: string): Promise<string> {
81
+ const salt = randomBytes(16)
82
+ const key = await derive(password, salt, current, tagLength)
83
+ const { memory, passes, parallelism } = current
84
+ return `$argon2id$v=19$m=${memory},t=${passes},p=${parallelism}$${unpadded(salt)}$${unpadded(key)}`
85
+ }
86
+
87
+ /**
88
+ * Whether a stored hash is one this plugin could verify: up to 256 MiB, 16
89
+ * passes and 16 lanes, and a tag of 16 to 64 bytes, so a tampered row
90
+ * neither exhausts the server nor matches too easily.
91
+ */
92
+ const bounded = (
93
+ { memory, passes, parallelism }: Parameters,
94
+ length: number,
95
+ ): boolean =>
96
+ parallelism >= 1 &&
97
+ parallelism <= 16 &&
98
+ memory >= 8 * parallelism &&
99
+ memory <= 262_144 &&
100
+ passes >= 1 &&
101
+ passes <= 16 &&
102
+ length >= 16 &&
103
+ length <= 64
104
+
105
+ /**
106
+ * Whether `password` matches `stored`, and whether `stored` was made with
107
+ * other parameters, so a sign-in rehashes it.
108
+ */
109
+ export async function verifyPassword(
110
+ password: string,
111
+ stored: string,
112
+ ): Promise<{ readonly valid: boolean; readonly stale: boolean }> {
113
+ const parts = phc.exec(stored)
114
+ if (parts === null) throw new Error('A stored password hash is not Argon2id.')
115
+ const [, m, t, p, salt = '', hash = ''] = parts
116
+ const parameters = {
117
+ memory: Number(m),
118
+ passes: Number(t),
119
+ parallelism: Number(p),
120
+ }
121
+ const expected = Buffer.from(hash, 'base64')
122
+ if (!bounded(parameters, expected.length)) {
123
+ throw new Error('A stored password hash has out-of-bounds parameters.')
124
+ }
125
+ const key = await derive(
126
+ password,
127
+ Buffer.from(salt, 'base64'),
128
+ parameters,
129
+ expected.length,
130
+ )
131
+ const stale =
132
+ parameters.memory !== current.memory ||
133
+ parameters.passes !== current.passes ||
134
+ parameters.parallelism !== current.parallelism ||
135
+ expected.length !== tagLength
136
+ return { valid: timingSafeEqual(key, expected), stale }
137
+ }
138
+
139
+ let dummy: Promise<string> | undefined
140
+ /** A hash no password matches, checked for an unknown email (D-113). */
141
+ export async function dummyHash(): Promise<string> {
142
+ dummy ??= hashPassword(randomBytes(32).toString('base64'))
143
+ return dummy
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // `@ultimabase/password` (D-113): sign-up and sign-in with an email and a
2
+ // password, hashed with Argon2id, the attempts limited.
3
+ export type { SignedIn } from './core.ts'
4
+ export type { SendEmail } from './email-functions.ts'
5
+ export type { Credentials } from './input.ts'
6
+ export type {
7
+ Password,
8
+ PasswordCommands,
9
+ PasswordOptions,
10
+ UserField,
11
+ } from './plugin.ts'
12
+ export type { Email, EmailKind } from './tokens.ts'
13
+ export { password, passwordPlugin } from './plugin.ts'
package/src/input.ts ADDED
@@ -0,0 +1,360 @@
1
+ // The functions' inputs (D-109, D-113): Standard Schemas written here, each
2
+ // with the JSON Schema a transport types its arguments from (D-45).
3
+ import type { SchemaField } from 'ultimabase'
4
+ import { PublicError, Refused } from 'ultimabase'
5
+ import { decimalPattern, issueOf } from 'ultimabase/internal'
6
+
7
+ type Node = { readonly [key: string]: unknown }
8
+ type Read = { readonly value: unknown } | { readonly message: string }
9
+ type Issue = { readonly path: readonly string[]; readonly message: string }
10
+ type Scalar = Extract<SchemaField, { readonly kind: 'scalar' }>
11
+ type Result<O> = { readonly value: O } | { readonly issues: readonly Issue[] }
12
+
13
+ /** Standard Schema with its JSON Schema export (standardschema.dev), copied as its specification asks. */
14
+ interface StandardSchema<I, O> {
15
+ readonly '~standard': {
16
+ readonly version: 1
17
+ readonly vendor: string
18
+ readonly validate: (value: unknown) => Result<O>
19
+ readonly jsonSchema: {
20
+ readonly input: () => Node
21
+ readonly output: () => Node
22
+ }
23
+ readonly types?: { readonly input: I; readonly output: O }
24
+ }
25
+ }
26
+
27
+ /** One property of an input: how it reads a value, and its JSON Schema. */
28
+ export interface Property {
29
+ readonly read: (value: unknown) => Read
30
+ readonly json: Node
31
+ readonly required: boolean
32
+ /** The shared nodes its JSON Schema refers to, lifted to the root's. */
33
+ readonly defs?: Node
34
+ }
35
+
36
+ const isObject = (value: unknown): value is Node =>
37
+ typeof value === 'object' && value !== null && !Array.isArray(value)
38
+
39
+ const text = (value: unknown): Read =>
40
+ typeof value === 'string' ? { value } : { message: 'Expected a string.' }
41
+
42
+ /** The email trimmed, lowercased and in NFC, the account's subject (D-113, D-115). */
43
+ export function emailOf(value: unknown): Read {
44
+ if (typeof value !== 'string') return { message: 'Expected a string.' }
45
+ const email = value.trim().toLowerCase().normalize('NFC')
46
+ if (email.length > 254 || !/^[^\s@]+@[^\s@]+$/.test(email)) {
47
+ return { message: 'Expected an email address.' }
48
+ }
49
+ return { value: email }
50
+ }
51
+
52
+ /** How a password is typed does not change it: NFKC (D-115, NIST SP 800-63B). */
53
+ export const normalized = (password: string): string =>
54
+ password.normalize('NFKC')
55
+
56
+ /** A password to store, normalized: 8 to 256 characters, no other rule (D-113). */
57
+ export function passwordOf(value: unknown): Read {
58
+ if (typeof value !== 'string') return { message: 'Expected a string.' }
59
+ const password = normalized(value)
60
+ const length = Array.from(password).length
61
+ if (length < 8 || length > 256) {
62
+ return { message: 'A password has 8 to 256 characters.' }
63
+ }
64
+ return { value: password }
65
+ }
66
+
67
+ /** `read`'s value, or a `VALIDATION_FAILED` naming `path`. */
68
+ export function valid<T>(
69
+ read: (value: unknown) => Read,
70
+ value: T,
71
+ path: string,
72
+ ): T {
73
+ const result = read(value)
74
+ if ('message' in result) {
75
+ const issues = [{ path: [path], message: result.message }]
76
+ throw new PublicError('VALIDATION_FAILED', `${path}: ${result.message}`, {
77
+ details: { issues },
78
+ })
79
+ }
80
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- emailOf and passwordOf return the string they were given, normalized.
81
+ return result.value as T
82
+ }
83
+
84
+ /** A property of `read`, whose JSON Schema is `json`. */
85
+ export const property = (
86
+ read: Property['read'],
87
+ json: Node,
88
+ required = true,
89
+ ): Property => ({ read, json, required })
90
+
91
+ const [smallest, largest] = [-2_147_483_648, 2_147_483_647]
92
+
93
+ /**
94
+ * A field's check, the kernel's (D-127, D-132, D-139): a date may come as an
95
+ * ISO string, and an optional field takes `null`.
96
+ */
97
+ function readOf(field: Scalar): Property['read'] {
98
+ return (given) => {
99
+ if (given === null && field.optional && !field.list) return { value: given }
100
+ const value =
101
+ field.type === 'dateTime' && typeof given === 'string'
102
+ ? new Date(given)
103
+ : given
104
+ const message = issueOf(value, field)
105
+ return message === undefined ? { value } : { message }
106
+ }
107
+ }
108
+
109
+ function jsonOf(field: Scalar): Node {
110
+ const { type, values = [], optional } = field
111
+ if (field.list) {
112
+ const items = jsonOf({ ...field, list: false, optional: false })
113
+ return {
114
+ type: 'array',
115
+ items: type === 'string' ? { ...items, minLength: 1 } : items,
116
+ }
117
+ }
118
+ const integer = field.integer === true
119
+ const base =
120
+ type === 'dateTime' ||
121
+ type === 'date' ||
122
+ type === 'enum' ||
123
+ type === 'decimal'
124
+ ? 'string'
125
+ : integer
126
+ ? 'integer'
127
+ : type
128
+ return {
129
+ type: optional ? [base, 'null'] : base,
130
+ ...(integer ? { minimum: smallest, maximum: largest } : {}),
131
+ ...(type === 'decimal' ? { pattern: decimalPattern } : {}),
132
+ ...(type === 'dateTime' ? { format: 'date-time' } : {}),
133
+ ...(type === 'date' ? { format: 'date' } : {}),
134
+ ...(type === 'enum' ? { enum: optional ? [...values, null] : values } : {}),
135
+ }
136
+ }
137
+
138
+ /** A json field's input JSON Schema, `{}` without an export (D-125). */
139
+ function exported(schema: object | undefined): Node {
140
+ const standard: unknown = schema && Reflect.get(schema, '~standard')
141
+ const exporter = isObject(standard) ? standard['jsonSchema'] : undefined
142
+ const input = isObject(exporter) ? exporter['input'] : undefined
143
+ if (typeof input !== 'function') return {}
144
+ try {
145
+ const node: unknown = Reflect.apply(input, exporter, [
146
+ { target: 'draft-2020-12' },
147
+ ])
148
+ return isObject(node) ? node : {}
149
+ } catch {
150
+ // A schema it cannot export takes any JSON value, as a field without one.
151
+ return {}
152
+ }
153
+ }
154
+
155
+ /** A json field: any value read here, its schema run by the kernel (D-125). */
156
+ function jsonProperty(
157
+ field: Scalar,
158
+ required: boolean,
159
+ schema: object | undefined,
160
+ ): Property {
161
+ const { $schema: _, $defs: defs, ...json } = exported(schema)
162
+ const typed = Object.keys(json).length > 0
163
+ return {
164
+ read: (value) => ({ value }),
165
+ json: typed && field.optional ? { anyOf: [json, { type: 'null' }] } : json,
166
+ required,
167
+ ...(isObject(defs) ? { defs } : {}),
168
+ }
169
+ }
170
+
171
+ /**
172
+ * A user field a client may send at sign-up, a json one with its `validate`;
173
+ * a relation stops startup (D-113, D-125).
174
+ */
175
+ export function fieldProperty(
176
+ field: SchemaField,
177
+ required: boolean,
178
+ where: string,
179
+ schema?: object,
180
+ ): Property {
181
+ if (field.kind !== 'scalar') {
182
+ throw new Refused(
183
+ `${where}: ${field.name} is a relation, which sign-up does not take.`,
184
+ )
185
+ }
186
+ if (field.type === 'json') return jsonProperty(field, required, schema)
187
+ return property(readOf(field), jsonOf(field), required)
188
+ }
189
+
190
+ /** An object of `properties`, which refuses any other key. */
191
+ function objectRead(
192
+ properties: ReadonlyMap<string, Property>,
193
+ value: unknown,
194
+ path: readonly string[],
195
+ issues: Issue[],
196
+ ): Node {
197
+ if (!isObject(value)) {
198
+ issues.push({ path, message: 'Expected an object.' })
199
+ return {}
200
+ }
201
+ const output: { [key: string]: unknown } = {}
202
+ for (const key of Object.keys(value)) {
203
+ if (!properties.has(key)) {
204
+ issues.push({ path: [...path, key], message: 'Unknown field.' })
205
+ }
206
+ }
207
+ for (const [key, { read, required }] of properties) {
208
+ const given = value[key]
209
+ if (given === undefined) {
210
+ if (required) issues.push({ path: [...path, key], message: 'Required.' })
211
+ continue
212
+ }
213
+ const result = read(given)
214
+ if ('message' in result) {
215
+ issues.push({ path: [...path, key], message: result.message })
216
+ } else output[key] = result.value
217
+ }
218
+ return output
219
+ }
220
+
221
+ function objectJson(properties: ReadonlyMap<string, Property>): Node {
222
+ const required = [...properties].filter(([, each]) => each.required)
223
+ return {
224
+ type: 'object',
225
+ properties: Object.fromEntries(
226
+ [...properties].map(([key, each]) => [key, each.json]),
227
+ ),
228
+ required: required.map(([key]) => key),
229
+ additionalProperties: false,
230
+ }
231
+ }
232
+
233
+ /** What a named input's `run` reads, under `input` (D-109). */
234
+ export type Named<O> = { readonly input: O }
235
+
236
+ /**
237
+ * One `input` argument of the object `id`, typed `SignUpInput` over GraphQL
238
+ * for the id `SignUp`; the email is normalized, the dates read (D-113).
239
+ */
240
+ export function namedInput<O extends object>(
241
+ id: string,
242
+ properties: ReadonlyMap<string, Property>,
243
+ ): StandardSchema<Named<O>, Named<O>> {
244
+ const json = (): Node => ({
245
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
246
+ type: 'object',
247
+ properties: { input: { $ref: `#/$defs/${id}` } },
248
+ required: ['input'],
249
+ additionalProperties: false,
250
+ $defs: Object.assign(
251
+ {},
252
+ ...[...properties.values()].map((each) => each.defs),
253
+ { [id]: objectJson(properties) },
254
+ ),
255
+ })
256
+ const validate = (value: unknown) => {
257
+ const issues: Issue[] = []
258
+ const root = isObject(value) ? value : {}
259
+ const input = objectRead(properties, root['input'], ['input'], issues)
260
+ if (!isObject(value))
261
+ issues.push({ path: [], message: 'Expected an object.' })
262
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- each property of O was read by its check above.
263
+ return issues.length > 0 ? { issues } : { value: { input: input as O } }
264
+ }
265
+ return {
266
+ '~standard': Object.freeze({
267
+ version: 1,
268
+ vendor: 'ultimabase-password',
269
+ validate,
270
+ jsonSchema: { input: json, output: json },
271
+ }),
272
+ }
273
+ }
274
+
275
+ const string = { type: 'string' }
276
+ const email = property(emailOf, { type: 'string', format: 'email' })
277
+
278
+ /** What `signIn` and `User.signIn` take. */
279
+ export type Credentials = { readonly email: string; readonly password: string }
280
+
281
+ type ChangePassword = {
282
+ readonly currentPassword: string
283
+ readonly newPassword: string
284
+ }
285
+ type SignUp = Credentials & { readonly [field: string]: unknown }
286
+
287
+ export const signInInput: StandardSchema<
288
+ Named<Credentials>,
289
+ Named<Credentials>
290
+ > = namedInput(
291
+ 'SignIn',
292
+ new Map([
293
+ ['email', email],
294
+ ['password', property(text, string)],
295
+ ]),
296
+ )
297
+
298
+ export const changePasswordInput: StandardSchema<
299
+ Named<ChangePassword>,
300
+ Named<ChangePassword>
301
+ > = namedInput(
302
+ 'ChangePassword',
303
+ new Map([
304
+ ['currentPassword', property(text, string)],
305
+ ['newPassword', property(passwordOf, string)],
306
+ ]),
307
+ )
308
+
309
+ type ResetPassword = { readonly token: string; readonly password: string }
310
+
311
+ /** What `requestPasswordReset` takes (D-117). */
312
+ export const requestResetInput: StandardSchema<
313
+ Named<{ readonly email: string }>,
314
+ Named<{ readonly email: string }>
315
+ > = namedInput('RequestPasswordReset', new Map([['email', email]]))
316
+
317
+ /** What `resetPassword` takes: the email's token and the new password (D-117). */
318
+ export const resetPasswordInput: StandardSchema<
319
+ Named<ResetPassword>,
320
+ Named<ResetPassword>
321
+ > = namedInput(
322
+ 'ResetPassword',
323
+ new Map([
324
+ ['token', property(text, string)],
325
+ ['password', property(passwordOf, string)],
326
+ ]),
327
+ )
328
+
329
+ /** What `verifyEmail` takes: the email's token (D-117). */
330
+ export const verifyEmailInput: StandardSchema<
331
+ Named<{ readonly token: string }>,
332
+ Named<{ readonly token: string }>
333
+ > = namedInput('VerifyEmail', new Map([['token', property(text, string)]]))
334
+
335
+ /** The email, the password and the listed user fields, by their names. */
336
+ export const signUpInput = (
337
+ fields: ReadonlyMap<string, Property>,
338
+ ): StandardSchema<Named<SignUp>, Named<SignUp>> =>
339
+ namedInput(
340
+ 'SignUp',
341
+ new Map([
342
+ ['email', email],
343
+ ['password', property(passwordOf, string)],
344
+ ...fields,
345
+ ]),
346
+ )
347
+
348
+ /** An output's schema: typed and exported, never validated (D-45). */
349
+ export function outputSchema<T>(node: Node): StandardSchema<T, T> {
350
+ const json = (): Node => node
351
+ return {
352
+ '~standard': Object.freeze({
353
+ version: 1,
354
+ vendor: 'ultimabase-password',
355
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- an output is typed by its function, never validated (D-45).
356
+ validate: (value: unknown) => ({ value: value as T }),
357
+ jsonSchema: { input: json, output: json },
358
+ }),
359
+ }
360
+ }