sounding 0.0.1 → 0.0.3
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/README.md +16 -0
- package/index.js +27 -0
- package/lib/create-auth-helpers.js +134 -14
- package/lib/create-request-client.js +4 -1
- package/lib/default-config.js +18 -0
- package/lib/resolve-auth-config.js +93 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ The canonical Sails-native surface is:
|
|
|
14
14
|
- `sails.sounding`
|
|
15
15
|
- `sails.helpers.user.signupWithTeam(...)` inside trials
|
|
16
16
|
- `get('/api/issues')` or `sails.sounding.request.get('/api/issues')` inside endpoint-style trials
|
|
17
|
+
- `await auth.login.withPassword('creator@example.com', page, { password: 'secret123' })` inside browser trials
|
|
18
|
+
- `await auth.request.withPassword('creator@example.com', { password: 'secret123' })` inside request trials
|
|
17
19
|
- request helpers default to Sails virtual requests powered by `sails.request()`
|
|
18
20
|
- Inertia-style visits can use `visit('/pricing')` and partial reload options like `{ component, only }`
|
|
19
21
|
- a trial can opt into stricter parity with `test('...', { transport: 'http' }, ...)`
|
|
@@ -26,12 +28,26 @@ Sounding also owns its own built-in world engine, so the same package can:
|
|
|
26
28
|
- capture outgoing mail by wrapping `sails.helpers.mail.send` and storing normalized messages in `sails.sounding.mailbox`
|
|
27
29
|
|
|
28
30
|
The default configuration story is intentionally calm:
|
|
31
|
+
- Sounding only enables its hook in the environments listed under `sounding.environments`
|
|
32
|
+
- the default is `['test']`, so non-test boot paths stay dark unless you opt in explicitly
|
|
33
|
+
- if you intentionally need Sounding in another environment, add that environment name to the list
|
|
34
|
+
- auth conventions auto-detect `User`/`userId` and `Creator`/`creatorId`, with `sounding.auth` available for overrides
|
|
29
35
|
- Sounding manages a temporary `sails-sqlite` datastore by default
|
|
30
36
|
- managed SQLite artifacts live under `.tmp/db`
|
|
31
37
|
- the default datastore identity is `default`
|
|
32
38
|
- browser projects start with `desktop`
|
|
33
39
|
- `inherit` remains available when an app already has a serious test datastore story
|
|
34
40
|
|
|
41
|
+
For example:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
module.exports.sounding = {
|
|
45
|
+
environments: ['test'],
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you intentionally want Sounding during another boot path, widen the list explicitly, for example `['test', 'console']` or `['test', 'production']`.
|
|
50
|
+
|
|
35
51
|
This repository starts with docs-driven product research and the first hook/runtime scaffolding for that vision.
|
|
36
52
|
|
|
37
53
|
See `RESEARCH.md`.
|
package/index.js
CHANGED
|
@@ -14,15 +14,38 @@ const { createExpect } = require('./lib/create-expect')
|
|
|
14
14
|
const { createTestApi } = require('./lib/create-test-api')
|
|
15
15
|
const { getDefaultConfig } = require('./lib/default-config')
|
|
16
16
|
|
|
17
|
+
function getCurrentEnvironment(sails) {
|
|
18
|
+
return sails.config?.environment || process.env.NODE_ENV
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getEnabledEnvironments(sails) {
|
|
22
|
+
const configured = sails.config?.sounding?.environments
|
|
23
|
+
|
|
24
|
+
if (Array.isArray(configured)) {
|
|
25
|
+
return configured
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return getDefaultConfig().environments
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shouldEnableHook(sails) {
|
|
32
|
+
return getEnabledEnvironments(sails).includes(getCurrentEnvironment(sails))
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
function soundingHook(sails) {
|
|
18
36
|
const runtime = createRuntime(sails)
|
|
19
37
|
|
|
20
38
|
return {
|
|
21
39
|
defaults: {
|
|
40
|
+
// Hook defaults live under `sails.config.sounding`.
|
|
22
41
|
sounding: getDefaultConfig(),
|
|
23
42
|
},
|
|
24
43
|
|
|
25
44
|
configure() {
|
|
45
|
+
if (!shouldEnableHook(sails)) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
sails.hooks ||= {}
|
|
27
50
|
sails.sounding = runtime
|
|
28
51
|
sails.hooks.sounding = this
|
|
@@ -30,6 +53,10 @@ function soundingHook(sails) {
|
|
|
30
53
|
},
|
|
31
54
|
|
|
32
55
|
initialize(done) {
|
|
56
|
+
if (!shouldEnableHook(sails)) {
|
|
57
|
+
return done()
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
sails.sounding = runtime
|
|
34
61
|
Object.assign(this, runtime)
|
|
35
62
|
sails.hooks.sounding = this
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { resolveAuthConfig } = require('./resolve-auth-config')
|
|
2
|
+
|
|
1
3
|
function normalizeEmail(value) {
|
|
2
4
|
return String(value || '')
|
|
3
5
|
.trim()
|
|
@@ -9,12 +11,38 @@ function looksLikeEmail(value) {
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
function createAuthHelpers({ sails, world, mailbox, request }) {
|
|
12
|
-
function
|
|
13
|
-
return sails
|
|
14
|
+
function getAuthConfig() {
|
|
15
|
+
return resolveAuthConfig({ sails })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getAuthModel() {
|
|
19
|
+
return getAuthConfig().model
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveWorldActor(alias) {
|
|
23
|
+
if (!alias || typeof alias !== 'string') {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const auth = getAuthConfig()
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
world.current?.[auth.worldCollection]?.[alias] ||
|
|
31
|
+
world.current?.users?.[alias] ||
|
|
32
|
+
world.current?.creators?.[alias] ||
|
|
33
|
+
null
|
|
34
|
+
)
|
|
14
35
|
}
|
|
15
36
|
|
|
16
37
|
async function createUserFromEmail(email, fullName) {
|
|
17
|
-
const
|
|
38
|
+
const auth = getAuthConfig()
|
|
39
|
+
const User = getAuthModel()
|
|
40
|
+
|
|
41
|
+
if (auth.modelIdentity !== 'user') {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Sounding auth helpers could not auto-create a missing ${auth.modelIdentity} record.`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
18
46
|
|
|
19
47
|
if (!sails?.helpers?.user?.signupWithTeam) {
|
|
20
48
|
throw new Error(
|
|
@@ -42,17 +70,18 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
|
|
|
42
70
|
return signupResult.user
|
|
43
71
|
}
|
|
44
72
|
|
|
45
|
-
async function
|
|
46
|
-
const
|
|
73
|
+
async function resolveActor(actorOrEmail, options = {}) {
|
|
74
|
+
const auth = getAuthConfig()
|
|
75
|
+
const User = getAuthModel()
|
|
47
76
|
|
|
48
77
|
if (!actorOrEmail) {
|
|
49
|
-
throw new Error('Sounding auth helpers require an actor
|
|
78
|
+
throw new Error('Sounding auth helpers require an actor or email address.')
|
|
50
79
|
}
|
|
51
80
|
|
|
52
81
|
let candidate = actorOrEmail
|
|
53
82
|
|
|
54
|
-
if (typeof candidate === 'string' &&
|
|
55
|
-
candidate =
|
|
83
|
+
if (typeof candidate === 'string' && !looksLikeEmail(candidate)) {
|
|
84
|
+
candidate = resolveWorldActor(candidate) || candidate
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
if (candidate?.id && User?.findOne) {
|
|
@@ -76,7 +105,7 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
|
|
|
76
105
|
|
|
77
106
|
if (!user) {
|
|
78
107
|
throw new Error(
|
|
79
|
-
`Sounding auth helpers could not find a
|
|
108
|
+
`Sounding auth helpers could not find a ${auth.modelIdentity} for ${normalizedEmail}.`
|
|
80
109
|
)
|
|
81
110
|
}
|
|
82
111
|
|
|
@@ -84,13 +113,13 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
|
|
|
84
113
|
}
|
|
85
114
|
|
|
86
115
|
async function issueMagicLink(actorOrEmail, options = {}) {
|
|
87
|
-
const User =
|
|
116
|
+
const User = getAuthModel()
|
|
88
117
|
|
|
89
118
|
if (!User?.updateOne) {
|
|
90
|
-
throw new Error('Sounding auth helpers require
|
|
119
|
+
throw new Error('Sounding auth helpers require an auth model with updateOne().')
|
|
91
120
|
}
|
|
92
121
|
|
|
93
|
-
const user = await
|
|
122
|
+
const user = await resolveActor(actorOrEmail, {
|
|
94
123
|
createIfMissing: true,
|
|
95
124
|
...options,
|
|
96
125
|
})
|
|
@@ -115,7 +144,7 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
|
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
async function requestMagicLink(actorOrEmail, options = {}) {
|
|
118
|
-
const user = await
|
|
147
|
+
const user = await resolveActor(actorOrEmail, {
|
|
119
148
|
createIfMissing: true,
|
|
120
149
|
...options,
|
|
121
150
|
})
|
|
@@ -138,18 +167,109 @@ function createAuthHelpers({ sails, world, mailbox, request }) {
|
|
|
138
167
|
}
|
|
139
168
|
}
|
|
140
169
|
|
|
170
|
+
async function loginWithPassword(actorOrEmail, page, options = {}) {
|
|
171
|
+
if (!page || typeof page.goto !== 'function') {
|
|
172
|
+
throw new Error('Sounding password browser login requires a Playwright page.')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const auth = getAuthConfig()
|
|
176
|
+
const actor = await resolveActor(actorOrEmail, {
|
|
177
|
+
createIfMissing: false,
|
|
178
|
+
...options,
|
|
179
|
+
})
|
|
180
|
+
const email = normalizeEmail(actor?.email || actorOrEmail)
|
|
181
|
+
|
|
182
|
+
if (!options.password) {
|
|
183
|
+
throw new Error('Sounding password login requires a `password` option.')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const loginUrl = new URL(auth.password.pagePath, 'http://sounding.local')
|
|
187
|
+
|
|
188
|
+
for (const [key, value] of Object.entries(auth.password.pageQuery || {})) {
|
|
189
|
+
if (value != null) {
|
|
190
|
+
loginUrl.searchParams.set(key, String(value))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (options.returnUrl) {
|
|
195
|
+
loginUrl.searchParams.set(auth.password.form.returnUrl, options.returnUrl)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await page.goto(`${loginUrl.pathname}${loginUrl.search}`)
|
|
199
|
+
await page.fill(auth.password.selectors.email, email)
|
|
200
|
+
await page.fill(auth.password.selectors.password, options.password)
|
|
201
|
+
|
|
202
|
+
if (options.rememberMe && typeof page.check === 'function') {
|
|
203
|
+
await page.check(auth.password.selectors.rememberMe)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await page.click(auth.password.selectors.submit)
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
actor,
|
|
210
|
+
email,
|
|
211
|
+
path: `${loginUrl.pathname}${loginUrl.search}`,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function requestWithPassword(actorOrEmail, options = {}) {
|
|
216
|
+
const auth = getAuthConfig()
|
|
217
|
+
const actor = await resolveActor(actorOrEmail, {
|
|
218
|
+
createIfMissing: false,
|
|
219
|
+
...options,
|
|
220
|
+
})
|
|
221
|
+
const email = normalizeEmail(actor?.email || actorOrEmail)
|
|
222
|
+
|
|
223
|
+
if (!options.password) {
|
|
224
|
+
throw new Error('Sounding password request auth requires a `password` option.')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const payload = {
|
|
228
|
+
[auth.password.form.email]: email,
|
|
229
|
+
[auth.password.form.password]: options.password,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (options.rememberMe !== undefined) {
|
|
233
|
+
payload[auth.password.form.rememberMe] = options.rememberMe
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (options.returnUrl !== undefined) {
|
|
237
|
+
payload[auth.password.form.returnUrl] = options.returnUrl
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const targetRequest = options.request || request
|
|
241
|
+
const response = await targetRequest.post(
|
|
242
|
+
auth.password.loginPath,
|
|
243
|
+
payload,
|
|
244
|
+
options.requestOptions || {}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
actor,
|
|
249
|
+
email,
|
|
250
|
+
request: targetRequest,
|
|
251
|
+
response,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
141
255
|
const login = {
|
|
142
256
|
async as(actorOrEmail, page, options = {}) {
|
|
143
257
|
const magicLink = await issueMagicLink(actorOrEmail, options)
|
|
144
258
|
await page.goto(magicLink.url)
|
|
145
259
|
return magicLink
|
|
146
260
|
},
|
|
261
|
+
withPassword: loginWithPassword,
|
|
147
262
|
}
|
|
148
263
|
|
|
149
264
|
return {
|
|
150
|
-
|
|
265
|
+
conventions: getAuthConfig(),
|
|
266
|
+
resolveActor,
|
|
267
|
+
resolveUser: resolveActor,
|
|
151
268
|
issueMagicLink,
|
|
152
269
|
requestMagicLink,
|
|
270
|
+
request: {
|
|
271
|
+
withPassword: requestWithPassword,
|
|
272
|
+
},
|
|
153
273
|
login,
|
|
154
274
|
}
|
|
155
275
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { Transform } = require('node:stream')
|
|
2
2
|
const QS = require('node:querystring')
|
|
3
|
+
const { resolveAuthConfig } = require('./resolve-auth-config')
|
|
3
4
|
|
|
4
5
|
function isAbsoluteUrl(value) {
|
|
5
6
|
return /^https?:\/\//i.test(value)
|
|
@@ -526,11 +527,13 @@ function createRequestClient({
|
|
|
526
527
|
return this
|
|
527
528
|
}
|
|
528
529
|
|
|
530
|
+
const auth = resolveAuthConfig({ sails, getConfig })
|
|
529
531
|
const actorHeaders = actor.headers || actor.sounding?.headers || {}
|
|
530
532
|
const actorSession = actor.session ||
|
|
531
533
|
actor.sounding?.session || {
|
|
532
|
-
...(actor.id ? {
|
|
534
|
+
...(actor.id ? { [auth.sessionKey]: actor.id } : {}),
|
|
533
535
|
...(actor.team ? { teamId: actor.team } : {}),
|
|
536
|
+
...(actor.teamId ? { teamId: actor.teamId } : {}),
|
|
534
537
|
}
|
|
535
538
|
|
|
536
539
|
return this.withHeaders(actorHeaders).withSession(actorSession)
|
package/lib/default-config.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const DEFAULT_CONFIG = Object.freeze({
|
|
2
|
+
// Sails environments where the Sounding hook should boot.
|
|
3
|
+
// Keep this test-only by default so non-test processes stay dark.
|
|
4
|
+
environments: ['test'],
|
|
2
5
|
app: {
|
|
3
6
|
path: '.',
|
|
4
7
|
environment: 'test',
|
|
@@ -33,6 +36,21 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
33
36
|
},
|
|
34
37
|
auth: {
|
|
35
38
|
defaultActor: 'guest',
|
|
39
|
+
modelIdentity: null,
|
|
40
|
+
sessionKey: null,
|
|
41
|
+
worldCollection: null,
|
|
42
|
+
password: {
|
|
43
|
+
loginPath: '/login',
|
|
44
|
+
pagePath: '/login',
|
|
45
|
+
pageQuery: {},
|
|
46
|
+
form: {
|
|
47
|
+
email: 'email',
|
|
48
|
+
password: 'password',
|
|
49
|
+
rememberMe: 'rememberMe',
|
|
50
|
+
returnUrl: 'returnUrl',
|
|
51
|
+
},
|
|
52
|
+
selectors: {},
|
|
53
|
+
},
|
|
36
54
|
},
|
|
37
55
|
})
|
|
38
56
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function normalizeIdentity(value) {
|
|
6
|
+
if (typeof value !== 'string') {
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const normalized = value.trim().toLowerCase()
|
|
11
|
+
return normalized || null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function titleCase(value) {
|
|
15
|
+
if (!value) {
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return `${value[0].toUpperCase()}${value.slice(1)}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveSoundingConfig({ sails, getConfig } = {}) {
|
|
23
|
+
return (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getModelByIdentity(sails, identity) {
|
|
27
|
+
const normalizedIdentity = normalizeIdentity(identity)
|
|
28
|
+
|
|
29
|
+
if (!normalizedIdentity) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return sails?.models?.[normalizedIdentity] || sails?.models?.[titleCase(normalizedIdentity)] || null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function detectModelIdentity(sails) {
|
|
37
|
+
for (const candidate of ['user', 'creator']) {
|
|
38
|
+
if (getModelByIdentity(sails, candidate)) {
|
|
39
|
+
return candidate
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return 'user'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolvePasswordConfig(authConfig = {}) {
|
|
47
|
+
const passwordConfig = isPlainObject(authConfig.password) ? authConfig.password : {}
|
|
48
|
+
const formConfig = isPlainObject(passwordConfig.form) ? passwordConfig.form : {}
|
|
49
|
+
const selectorConfig = isPlainObject(passwordConfig.selectors) ? passwordConfig.selectors : {}
|
|
50
|
+
const form = {
|
|
51
|
+
email: formConfig.email || 'email',
|
|
52
|
+
password: formConfig.password || 'password',
|
|
53
|
+
rememberMe: formConfig.rememberMe || 'rememberMe',
|
|
54
|
+
returnUrl: formConfig.returnUrl || 'returnUrl',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
loginPath: passwordConfig.loginPath || authConfig.loginPath || '/login',
|
|
59
|
+
pagePath: passwordConfig.pagePath || passwordConfig.loginPath || authConfig.loginPath || '/login',
|
|
60
|
+
pageQuery: isPlainObject(passwordConfig.pageQuery) ? passwordConfig.pageQuery : {},
|
|
61
|
+
form,
|
|
62
|
+
selectors: {
|
|
63
|
+
email: selectorConfig.email || `input[name="${form.email}"], input[type="email"]`,
|
|
64
|
+
password:
|
|
65
|
+
selectorConfig.password || `input[name="${form.password}"], input[type="password"]`,
|
|
66
|
+
rememberMe:
|
|
67
|
+
selectorConfig.rememberMe ||
|
|
68
|
+
`input[name="${form.rememberMe}"], input[id="${form.rememberMe}"], input[type="checkbox"]`,
|
|
69
|
+
submit: selectorConfig.submit || 'button[type="submit"], input[type="submit"]',
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveAuthConfig({ sails, getConfig } = {}) {
|
|
75
|
+
const soundingConfig = resolveSoundingConfig({ sails, getConfig })
|
|
76
|
+
const authConfig = isPlainObject(soundingConfig.auth) ? soundingConfig.auth : {}
|
|
77
|
+
const modelIdentity = normalizeIdentity(authConfig.modelIdentity) || detectModelIdentity(sails)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
modelIdentity,
|
|
81
|
+
model: getModelByIdentity(sails, modelIdentity),
|
|
82
|
+
sessionKey: authConfig.sessionKey || `${modelIdentity}Id`,
|
|
83
|
+
worldCollection: authConfig.worldCollection || `${modelIdentity}s`,
|
|
84
|
+
password: resolvePasswordConfig(authConfig),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
detectModelIdentity,
|
|
90
|
+
getModelByIdentity,
|
|
91
|
+
resolveAuthConfig,
|
|
92
|
+
resolveSoundingConfig,
|
|
93
|
+
}
|