sounding 0.0.3 → 0.1.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.
@@ -1,15 +1,54 @@
1
1
  const { resolveAuthConfig } = require('./resolve-auth-config')
2
-
2
+ const { createSoundingError } = require('./create-error')
3
+
4
+ /** @typedef {import('./types').SoundingActor} SoundingActor */
5
+ /** @typedef {import('./types').SoundingAuthHelpers} SoundingAuthHelpers */
6
+ /** @typedef {import('./types').SoundingBrowserLoginResult} SoundingBrowserLoginResult */
7
+ /** @typedef {import('./types').SoundingMagicLink} SoundingMagicLink */
8
+ /** @typedef {import('./types').SoundingMailbox} SoundingMailbox */
9
+ /** @typedef {import('./types').SoundingPage} SoundingPage */
10
+ /** @typedef {import('./types').SoundingPasswordRequestResult} SoundingPasswordRequestResult */
11
+ /** @typedef {import('./types').SoundingRequestMagicLinkResult} SoundingRequestMagicLinkResult */
12
+ /** @typedef {import('./types').SoundingRequestOptions} SoundingRequestOptions */
13
+ /** @typedef {import('./types').SoundingRequestClient} SoundingRequestClient */
14
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
15
+ /** @typedef {import('./types').SoundingWorldEngine} SoundingWorldEngine */
16
+
17
+ /**
18
+ * @param {any} value
19
+ * @returns {string}
20
+ */
3
21
  function normalizeEmail(value) {
4
22
  return String(value || '')
5
23
  .trim()
6
24
  .toLowerCase()
7
25
  }
8
26
 
27
+ /**
28
+ * @param {any} value
29
+ * @returns {value is string}
30
+ */
9
31
  function looksLikeEmail(value) {
10
32
  return typeof value === 'string' && value.includes('@')
11
33
  }
12
34
 
35
+ /**
36
+ * @param {string[]} values
37
+ * @returns {string}
38
+ */
39
+ function formatAvailable(values) {
40
+ return values.length ? values.join(', ') : 'none'
41
+ }
42
+
43
+ /**
44
+ * @param {{
45
+ * sails?: SoundingSailsApp,
46
+ * world: SoundingWorldEngine,
47
+ * mailbox: SoundingMailbox,
48
+ * request: SoundingRequestClient,
49
+ * }} input
50
+ * @returns {SoundingAuthHelpers}
51
+ */
13
52
  function createAuthHelpers({ sails, world, mailbox, request }) {
14
53
  function getAuthConfig() {
15
54
  return resolveAuthConfig({ sails })
@@ -19,6 +58,10 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
19
58
  return getAuthConfig().model
20
59
  }
21
60
 
61
+ /**
62
+ * @param {string} alias
63
+ * @returns {SoundingActor | null}
64
+ */
22
65
  function resolveWorldActor(alias) {
23
66
  if (!alias || typeof alias !== 'string') {
24
67
  return null
@@ -34,20 +77,50 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
34
77
  )
35
78
  }
36
79
 
80
+ /**
81
+ * @returns {string[]}
82
+ */
83
+ function availableWorldActors() {
84
+ const auth = getAuthConfig()
85
+ const aliases = new Set()
86
+
87
+ for (const collection of [auth.worldCollection, 'users', 'creators']) {
88
+ const entries = world.current?.[collection]
89
+
90
+ if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
91
+ for (const alias of Object.keys(entries)) {
92
+ aliases.add(alias)
93
+ }
94
+ }
95
+ }
96
+
97
+ return Array.from(aliases).sort()
98
+ }
99
+
100
+ /**
101
+ * @param {string} email
102
+ * @param {string} [fullName]
103
+ * @returns {Promise<Record<string, any>>}
104
+ */
37
105
  async function createUserFromEmail(email, fullName) {
38
106
  const auth = getAuthConfig()
39
107
  const User = getAuthModel()
40
108
 
41
109
  if (auth.modelIdentity !== 'user') {
42
- throw new Error(
43
- `Sounding auth helpers could not auto-create a missing ${auth.modelIdentity} record.`
44
- )
110
+ throw createSoundingError({
111
+ code: 'E_SOUNDING_AUTH_CREATE_UNSUPPORTED',
112
+ message: `Sounding auth helpers could not auto-create a missing ${auth.modelIdentity} record.`,
113
+ details: {
114
+ modelIdentity: auth.modelIdentity,
115
+ },
116
+ })
45
117
  }
46
118
 
47
119
  if (!sails?.helpers?.user?.signupWithTeam) {
48
- throw new Error(
49
- 'Sounding auth helpers could not find `sails.helpers.user.signupWithTeam`.'
50
- )
120
+ throw createSoundingError({
121
+ code: 'E_SOUNDING_AUTH_SIGNUP_HELPER_MISSING',
122
+ message: 'Sounding auth helpers could not find `sails.helpers.user.signupWithTeam`.',
123
+ })
51
124
  }
52
125
 
53
126
  const signupResult = await sails.helpers.user.signupWithTeam.with({
@@ -70,53 +143,101 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
70
143
  return signupResult.user
71
144
  }
72
145
 
146
+ /**
147
+ * @param {SoundingActor | string} actorOrEmail
148
+ * @param {{ createIfMissing?: boolean, fullName?: string, [key: string]: any }} [options]
149
+ * @returns {Promise<Record<string, any>>}
150
+ */
73
151
  async function resolveActor(actorOrEmail, options = {}) {
74
152
  const auth = getAuthConfig()
75
153
  const User = getAuthModel()
76
154
 
77
155
  if (!actorOrEmail) {
78
- throw new Error('Sounding auth helpers require an actor or email address.')
156
+ throw createSoundingError({
157
+ code: 'E_SOUNDING_AUTH_ACTOR_REQUIRED',
158
+ message: 'Sounding auth helpers require an actor or email address.',
159
+ })
79
160
  }
80
161
 
81
162
  let candidate = actorOrEmail
163
+ let unresolvedAlias = null
82
164
 
83
165
  if (typeof candidate === 'string' && !looksLikeEmail(candidate)) {
166
+ unresolvedAlias = candidate
84
167
  candidate = resolveWorldActor(candidate) || candidate
85
168
  }
86
169
 
87
- if (candidate?.id && User?.findOne) {
170
+ if (typeof candidate !== 'string' && candidate?.id && User?.findOne) {
88
171
  return User.findOne({ id: candidate.id }) || candidate
89
172
  }
90
173
 
91
- const email = candidate?.email || (looksLikeEmail(candidate) ? candidate : null)
174
+ const email =
175
+ typeof candidate === 'string'
176
+ ? looksLikeEmail(candidate)
177
+ ? candidate
178
+ : null
179
+ : candidate?.email || null
92
180
 
93
181
  if (!email) {
94
- throw new Error(
95
- `Sounding auth helpers could not resolve an email address for actor \`${candidate}\`.`
96
- )
182
+ const details = {
183
+ actor: candidate,
184
+ }
185
+
186
+ if (unresolvedAlias && candidate === unresolvedAlias) {
187
+ const availableActors = availableWorldActors()
188
+ details.availableActors = availableActors
189
+
190
+ throw createSoundingError({
191
+ code: 'E_SOUNDING_AUTH_EMAIL_UNRESOLVED',
192
+ message: `Sounding auth helpers could not resolve an email address for actor \`${candidate}\`. Available actors: ${formatAvailable(availableActors)}.`,
193
+ details,
194
+ })
195
+ }
196
+
197
+ throw createSoundingError({
198
+ code: 'E_SOUNDING_AUTH_EMAIL_UNRESOLVED',
199
+ message: `Sounding auth helpers could not resolve an email address for actor \`${candidate}\`.`,
200
+ details,
201
+ })
97
202
  }
98
203
 
99
204
  const normalizedEmail = normalizeEmail(email)
100
205
  let user = User?.findOne ? await User.findOne({ email: normalizedEmail }) : null
101
206
 
102
207
  if (!user && options.createIfMissing !== false) {
103
- user = await createUserFromEmail(normalizedEmail, candidate?.fullName || options.fullName)
208
+ user = await createUserFromEmail(
209
+ normalizedEmail,
210
+ (typeof candidate !== 'string' && candidate?.fullName) || options.fullName
211
+ )
104
212
  }
105
213
 
106
214
  if (!user) {
107
- throw new Error(
108
- `Sounding auth helpers could not find a ${auth.modelIdentity} for ${normalizedEmail}.`
109
- )
215
+ throw createSoundingError({
216
+ code: 'E_SOUNDING_AUTH_MODEL_UNAVAILABLE',
217
+ message: `Sounding auth helpers could not find a ${auth.modelIdentity} for ${normalizedEmail}.`,
218
+ details: {
219
+ modelIdentity: auth.modelIdentity,
220
+ email: normalizedEmail,
221
+ },
222
+ })
110
223
  }
111
224
 
112
225
  return user
113
226
  }
114
227
 
228
+ /**
229
+ * @param {SoundingActor | string} actorOrEmail
230
+ * @param {{ createIfMissing?: boolean, fullName?: string, [key: string]: any }} [options]
231
+ * @returns {Promise<SoundingMagicLink>}
232
+ */
115
233
  async function issueMagicLink(actorOrEmail, options = {}) {
116
234
  const User = getAuthModel()
117
235
 
118
236
  if (!User?.updateOne) {
119
- throw new Error('Sounding auth helpers require an auth model with updateOne().')
237
+ throw createSoundingError({
238
+ code: 'E_SOUNDING_AUTH_MODEL_UNAVAILABLE',
239
+ message: 'Sounding auth helpers require an auth model with updateOne().',
240
+ })
120
241
  }
121
242
 
122
243
  const user = await resolveActor(actorOrEmail, {
@@ -143,6 +264,11 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
143
264
  }
144
265
  }
145
266
 
267
+ /**
268
+ * @param {SoundingActor | string} actorOrEmail
269
+ * @param {{ fullName?: string, redirectUrl?: string, requestOptions?: SoundingRequestOptions, [key: string]: any }} [options]
270
+ * @returns {Promise<SoundingRequestMagicLinkResult>}
271
+ */
146
272
  async function requestMagicLink(actorOrEmail, options = {}) {
147
273
  const user = await resolveActor(actorOrEmail, {
148
274
  createIfMissing: true,
@@ -167,9 +293,18 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
167
293
  }
168
294
  }
169
295
 
296
+ /**
297
+ * @param {SoundingActor | string} actorOrEmail
298
+ * @param {SoundingPage} page
299
+ * @param {{ password?: string, rememberMe?: boolean, returnUrl?: string, [key: string]: any }} [options]
300
+ * @returns {Promise<SoundingBrowserLoginResult>}
301
+ */
170
302
  async function loginWithPassword(actorOrEmail, page, options = {}) {
171
303
  if (!page || typeof page.goto !== 'function') {
172
- throw new Error('Sounding password browser login requires a Playwright page.')
304
+ throw createSoundingError({
305
+ code: 'E_SOUNDING_AUTH_BROWSER_PAGE_REQUIRED',
306
+ message: 'Sounding password browser login requires a Playwright page.',
307
+ })
173
308
  }
174
309
 
175
310
  const auth = getAuthConfig()
@@ -180,7 +315,10 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
180
315
  const email = normalizeEmail(actor?.email || actorOrEmail)
181
316
 
182
317
  if (!options.password) {
183
- throw new Error('Sounding password login requires a `password` option.')
318
+ throw createSoundingError({
319
+ code: 'E_SOUNDING_AUTH_PASSWORD_REQUIRED',
320
+ message: 'Sounding password login requires a `password` option.',
321
+ })
184
322
  }
185
323
 
186
324
  const loginUrl = new URL(auth.password.pagePath, 'http://sounding.local')
@@ -212,6 +350,11 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
212
350
  }
213
351
  }
214
352
 
353
+ /**
354
+ * @param {SoundingActor | string} actorOrEmail
355
+ * @param {{ password?: string, rememberMe?: boolean, returnUrl?: string, request?: SoundingRequestClient, requestOptions?: SoundingRequestOptions, [key: string]: any }} [options]
356
+ * @returns {Promise<SoundingPasswordRequestResult>}
357
+ */
215
358
  async function requestWithPassword(actorOrEmail, options = {}) {
216
359
  const auth = getAuthConfig()
217
360
  const actor = await resolveActor(actorOrEmail, {
@@ -221,9 +364,13 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
221
364
  const email = normalizeEmail(actor?.email || actorOrEmail)
222
365
 
223
366
  if (!options.password) {
224
- throw new Error('Sounding password request auth requires a `password` option.')
367
+ throw createSoundingError({
368
+ code: 'E_SOUNDING_AUTH_PASSWORD_REQUIRED',
369
+ message: 'Sounding password request auth requires a `password` option.',
370
+ })
225
371
  }
226
372
 
373
+ /** @type {Record<string, any>} */
227
374
  const payload = {
228
375
  [auth.password.form.email]: email,
229
376
  [auth.password.form.password]: options.password,