@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/CHANGELOG.md +21 -0
- package/README.md +4 -2
- package/dist/core.d.ts +57 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +124 -0
- package/dist/email-functions.d.ts +20 -0
- package/dist/email-functions.d.ts.map +1 -0
- package/dist/email-functions.js +107 -0
- package/dist/functions.d.ts +10 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +75 -0
- package/dist/hash.d.ts +16 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +103 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/input.d.ts +104 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +231 -0
- package/dist/models.d.ts +57 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +122 -0
- package/dist/plugin.d.ts +75 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +172 -0
- package/dist/tokens.d.ts +34 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +52 -0
- package/package.json +49 -3
- package/src/core.ts +206 -0
- package/src/email-functions.ts +137 -0
- package/src/functions.ts +102 -0
- package/src/hash.ts +144 -0
- package/src/index.ts +13 -0
- package/src/input.ts +360 -0
- package/src/models.ts +190 -0
- package/src/plugin.ts +310 -0
- package/src/tokens.ts +98 -0
package/src/functions.ts
ADDED
|
@@ -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
|
+
}
|