@pikku/core 0.12.95 → 0.12.97
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 +66 -0
- package/dist/dev/hot-reload.js +24 -4
- package/dist/dev/module-runner.d.ts +20 -3
- package/dist/dev/module-runner.js +17 -4
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/require-origin.d.ts +23 -0
- package/dist/middleware/require-origin.js +57 -0
- package/dist/services/secret-service.d.ts +5 -0
- package/dist/services/typed-variables-service.d.ts +34 -0
- package/dist/services/typed-variables-service.js +69 -3
- package/dist/wirings/workflow/scenario-prose.d.ts +23 -1
- package/dist/wirings/workflow/scenario-prose.js +12 -3
- package/dist/wirings/workflow/scenario-run.types.d.ts +7 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.test.ts +42 -0
- package/src/dev/hot-reload.ts +30 -4
- package/src/dev/module-runner.test.ts +56 -13
- package/src/dev/module-runner.ts +32 -10
- package/src/middleware/index.ts +1 -0
- package/src/middleware/require-origin.test.ts +115 -0
- package/src/middleware/require-origin.ts +79 -0
- package/src/public-surface.json +4 -1
- package/src/services/secret-service.ts +5 -0
- package/src/services/typed-variables-service.test.ts +93 -0
- package/src/services/typed-variables-service.ts +94 -3
- package/src/wirings/virtual-user/virtual-user-derive.test.ts +5 -5
- package/src/wirings/workflow/scenario-prose.test.ts +134 -9
- package/src/wirings/workflow/scenario-prose.ts +37 -2
- package/src/wirings/workflow/scenario-run.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -8,7 +8,10 @@ import { join } from 'node:path'
|
|
|
8
8
|
import { pathToFileURL } from 'node:url'
|
|
9
9
|
import { tmpdir } from 'node:os'
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createModuleRunner,
|
|
13
|
+
isTopLevelAwaitLimitation,
|
|
14
|
+
} from './module-runner.js'
|
|
12
15
|
|
|
13
16
|
// A forced-GC hook without launching the process with a flag: on Bun use the
|
|
14
17
|
// native collector; on Node flip --expose-gc on just long enough to grab `gc`.
|
|
@@ -56,9 +59,10 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
56
59
|
export const createTodo = { func: async (_s: any, d: Todo) => ({ id: d.id }) }`
|
|
57
60
|
)
|
|
58
61
|
|
|
59
|
-
const
|
|
60
|
-
assert.ok
|
|
61
|
-
const createTodo =
|
|
62
|
+
const result = await runner.run(file)
|
|
63
|
+
assert.equal(result.ok, true)
|
|
64
|
+
const createTodo = (result as { exports: Record<string, unknown> }).exports
|
|
65
|
+
.createTodo as {
|
|
62
66
|
func: (...a: any[]) => Promise<any>
|
|
63
67
|
}
|
|
64
68
|
assert.equal(typeof createTodo.func, 'function')
|
|
@@ -83,8 +87,9 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
83
87
|
wire('createTodo', createTodo)`
|
|
84
88
|
)
|
|
85
89
|
|
|
86
|
-
const
|
|
87
|
-
assert.ok
|
|
90
|
+
const result = await runner.run(userFile)
|
|
91
|
+
assert.equal(result.ok, true)
|
|
92
|
+
const mod = (result as { exports: Record<string, unknown> }).exports
|
|
88
93
|
|
|
89
94
|
// Read the dependency through the same resolver the runner uses, so we
|
|
90
95
|
// observe the exact instance the user module's `import` bound to (using a
|
|
@@ -103,26 +108,64 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
103
108
|
|
|
104
109
|
await writeFile(file, `export const value = { func: async () => 'v1' }`)
|
|
105
110
|
const first = await runner.run(file)
|
|
106
|
-
assert.equal(
|
|
111
|
+
assert.equal(first.ok, true)
|
|
112
|
+
assert.equal(await ((first as any).exports.value as any).func(), 'v1')
|
|
107
113
|
|
|
108
114
|
await writeFile(file, `export const value = { func: async () => 'v2' }`)
|
|
109
115
|
const second = await runner.run(file)
|
|
110
|
-
assert.equal(
|
|
116
|
+
assert.equal(second.ok, true)
|
|
117
|
+
assert.equal(await ((second as any).exports.value as any).func(), 'v2')
|
|
111
118
|
|
|
112
119
|
// Stable key: many reloads of one path never grow the registry.
|
|
113
120
|
for (let i = 0; i < 20; i++) await runner.run(file)
|
|
114
121
|
assert.equal(runner.size, 1)
|
|
115
122
|
})
|
|
116
123
|
|
|
117
|
-
test('
|
|
124
|
+
test('reports a bad edit with its reason so the caller can say why', async () => {
|
|
118
125
|
const runner = createModuleRunner()
|
|
119
126
|
const file = join(tmpDir, 'broken.ts')
|
|
120
127
|
await writeFile(
|
|
121
128
|
file,
|
|
122
129
|
`export const oops = { func: async () => ( } ] syntax`
|
|
123
130
|
)
|
|
124
|
-
const
|
|
125
|
-
assert.equal(
|
|
131
|
+
const result = await runner.run(file)
|
|
132
|
+
assert.equal(result.ok, false)
|
|
133
|
+
// The caller keeps serving the old code, so this error is the only thing
|
|
134
|
+
// standing between the developer and an unexplained stale response.
|
|
135
|
+
const { error } = result as { error: Error }
|
|
136
|
+
assert.ok(error instanceof Error)
|
|
137
|
+
assert.match(error.message, /broken\.ts/)
|
|
138
|
+
assert.equal(isTopLevelAwaitLimitation(error), false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('names the top-level await limitation as such', async () => {
|
|
142
|
+
const runner = createModuleRunner()
|
|
143
|
+
const file = join(tmpDir, 'tla.ts')
|
|
144
|
+
await writeFile(
|
|
145
|
+
file,
|
|
146
|
+
`const config = await Promise.resolve({ ok: true })
|
|
147
|
+
export const load = { func: async () => config }`
|
|
148
|
+
)
|
|
149
|
+
const result = await runner.run(file)
|
|
150
|
+
assert.equal(result.ok, false)
|
|
151
|
+
// Nothing is wrong with this file — the `cjs` emit is what cannot take it,
|
|
152
|
+
// and the caller has to be able to tell the developer that.
|
|
153
|
+
assert.equal(
|
|
154
|
+
isTopLevelAwaitLimitation((result as { error: Error }).error),
|
|
155
|
+
true
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a thrown non-Error still arrives as an Error carrying its value', async () => {
|
|
160
|
+
const runner = createModuleRunner()
|
|
161
|
+
const file = join(tmpDir, 'throws-a-string.ts')
|
|
162
|
+
await writeFile(file, `throw 'boom'`)
|
|
163
|
+
const result = await runner.run(file)
|
|
164
|
+
assert.equal(result.ok, false)
|
|
165
|
+
const { error } = result as { error: Error }
|
|
166
|
+
assert.ok(error instanceof Error)
|
|
167
|
+
assert.equal(error.message, 'boom')
|
|
168
|
+
assert.equal((error as { cause?: unknown }).cause, 'boom')
|
|
126
169
|
})
|
|
127
170
|
|
|
128
171
|
test('editing and reimporting a module 200x does not leak memory', async () => {
|
|
@@ -153,8 +196,8 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
153
196
|
|
|
154
197
|
for (let i = 1; i <= 200; i++) {
|
|
155
198
|
await write(i)
|
|
156
|
-
const
|
|
157
|
-
assert.ok
|
|
199
|
+
const result = await runner.run(file)
|
|
200
|
+
assert.equal(result.ok, true)
|
|
158
201
|
}
|
|
159
202
|
gc()
|
|
160
203
|
const growth = heapUsedMb() - baseline
|
package/src/dev/module-runner.ts
CHANGED
|
@@ -18,22 +18,35 @@ const loadTransform = async (): Promise<EsbuildTransform> => {
|
|
|
18
18
|
return transformSync
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** The outcome of one run. A failure carries its error rather than collapsing
|
|
22
|
+
* to `null`: the caller keeps serving the previously-loaded code, so unless the
|
|
23
|
+
* reason travels with the failure the running process silently disagrees with
|
|
24
|
+
* the file on disk and nothing anywhere says why. */
|
|
25
|
+
export type PikkuModuleRunResult =
|
|
26
|
+
{ ok: true; exports: Record<string, unknown> } | { ok: false; error: Error }
|
|
27
|
+
|
|
21
28
|
export interface PikkuModuleRunner {
|
|
22
29
|
/** Run a user module by absolute path. Repeated runs of one path overwrite a
|
|
23
|
-
* single registry slot.
|
|
24
|
-
* previously-loaded code
|
|
25
|
-
|
|
30
|
+
* single registry slot. Failure is returned, not thrown, so the caller can
|
|
31
|
+
* keep the previously-loaded code — and the discriminant makes that case
|
|
32
|
+
* impossible to read past by accident. */
|
|
33
|
+
run: (absPath: string) => Promise<PikkuModuleRunResult>
|
|
26
34
|
evict: (absPath: string) => void
|
|
27
35
|
clear: () => void
|
|
28
36
|
readonly size: number
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/** esbuild states pikku's one documented reload limitation only in the text of
|
|
40
|
+
* its transform error. Matching it is worth the fragility: the developer's file
|
|
41
|
+
* is correct, and no amount of re-reading it will reveal that the reloader —
|
|
42
|
+
* not the file — is what cannot cope. */
|
|
43
|
+
export const isTopLevelAwaitLimitation = (error: Error): boolean =>
|
|
44
|
+
/top-level await/i.test(error.message)
|
|
45
|
+
|
|
31
46
|
export const createModuleRunner = (): PikkuModuleRunner => {
|
|
32
47
|
const registry = new Map<string, Record<string, unknown>>()
|
|
33
48
|
|
|
34
|
-
const run = async (
|
|
35
|
-
filePath: string
|
|
36
|
-
): Promise<Record<string, unknown> | null> => {
|
|
49
|
+
const run = async (filePath: string): Promise<PikkuModuleRunResult> => {
|
|
37
50
|
const absPath = resolve(filePath)
|
|
38
51
|
try {
|
|
39
52
|
const transform = await loadTransform()
|
|
@@ -55,11 +68,20 @@ export const createModuleRunner = (): PikkuModuleRunner => {
|
|
|
55
68
|
fn(require, moduleObj.exports, moduleObj, absPath, dirname(absPath))
|
|
56
69
|
|
|
57
70
|
registry.set(absPath, moduleObj.exports)
|
|
58
|
-
return moduleObj.exports
|
|
59
|
-
} catch {
|
|
71
|
+
return { ok: true, exports: moduleObj.exports }
|
|
72
|
+
} catch (thrown) {
|
|
60
73
|
// A bad edit, or the one known limitation: a file using top-level
|
|
61
|
-
// `await`, which cannot be emitted in `cjs` form.
|
|
62
|
-
|
|
74
|
+
// `await`, which cannot be emitted in `cjs` form. Normalised to an
|
|
75
|
+
// `Error` so the caller always has a message and a stack to print
|
|
76
|
+
// without re-deriving them; a non-`Error` throw keeps its original value
|
|
77
|
+
// as the `cause`.
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error:
|
|
81
|
+
thrown instanceof Error
|
|
82
|
+
? thrown
|
|
83
|
+
: new Error(String(thrown), { cause: thrown }),
|
|
84
|
+
}
|
|
63
85
|
}
|
|
64
86
|
}
|
|
65
87
|
|
package/src/middleware/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { authCookie } from './auth-cookie.js'
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js'
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js'
|
|
5
5
|
export { cors } from './cors.js'
|
|
6
|
+
export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
|
|
6
7
|
export { telemetryOuter, telemetryInner } from './telemetry.js'
|
|
7
8
|
export {
|
|
8
9
|
addTagMiddleware,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
|
|
4
|
+
import { InvalidOriginError } from '../errors/errors.js'
|
|
5
|
+
import { resetPikkuState } from '../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
resetPikkuState()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const headers = (values: Record<string, string | undefined>) => ({
|
|
12
|
+
method: () => 'post',
|
|
13
|
+
header: (name: string) => values[name],
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const run = async (
|
|
17
|
+
config: Parameters<typeof requireOrigin>[0],
|
|
18
|
+
values: Record<string, string | undefined>
|
|
19
|
+
) => {
|
|
20
|
+
let reached = false
|
|
21
|
+
const middleware = requireOrigin(config)
|
|
22
|
+
await middleware({} as any, { http: { request: headers(values) } } as any, async () => {
|
|
23
|
+
reached = true
|
|
24
|
+
})
|
|
25
|
+
return reached
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('toOrigin', () => {
|
|
29
|
+
test('keeps scheme, host and port and drops the rest', () => {
|
|
30
|
+
assert.equal(toOrigin('https://app.com:8443/a/b?c=1'), 'https://app.com:8443')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('rejects the sandboxed-iframe "null" origin and unparseable values', () => {
|
|
34
|
+
assert.equal(toOrigin('null'), null)
|
|
35
|
+
assert.equal(toOrigin(''), null)
|
|
36
|
+
assert.equal(toOrigin(undefined), null)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('isAllowedOrigin', () => {
|
|
41
|
+
test('matches the request host exactly', () => {
|
|
42
|
+
assert.equal(isAllowedOrigin('https://app.com', 'https://app.com', []), true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('does not suffix-match a lookalike domain', () => {
|
|
46
|
+
assert.equal(isAllowedOrigin('https://evil-app.com', null, ['https://app.com']), false)
|
|
47
|
+
assert.equal(isAllowedOrigin('https://app.com.evil.net', null, ['https://app.com']), false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('normalises a configured origin before comparing', () => {
|
|
51
|
+
assert.equal(isAllowedOrigin('https://app.com', null, ['https://app.com/path']), true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('rejects a missing origin', () => {
|
|
55
|
+
assert.equal(isAllowedOrigin(null, 'https://app.com', ['https://app.com']), false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('requireOrigin', () => {
|
|
60
|
+
test('allows a beacon from the request own host', async () => {
|
|
61
|
+
assert.equal(
|
|
62
|
+
await run({}, { origin: 'https://app.com', host: 'app.com' }),
|
|
63
|
+
true
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('falls back to referer when only it is sent', async () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
await run({}, { referer: 'https://app.com/pricing', host: 'app.com' }),
|
|
70
|
+
true
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('honours x-forwarded-proto when deriving the host origin', async () => {
|
|
75
|
+
assert.equal(
|
|
76
|
+
await run(
|
|
77
|
+
{},
|
|
78
|
+
{ origin: 'http://app.com', host: 'app.com', 'x-forwarded-proto': 'http' }
|
|
79
|
+
),
|
|
80
|
+
true
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('rejects another site with a 403', async () => {
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
() => run({}, { origin: 'https://evil.com', host: 'app.com' }),
|
|
87
|
+
InvalidOriginError
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('rejects a non-browser caller that sends no origin', async () => {
|
|
92
|
+
await assert.rejects(
|
|
93
|
+
() => run({}, { host: 'app.com' }),
|
|
94
|
+
InvalidOriginError
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('resolves configured origins from services when given a function', async () => {
|
|
99
|
+
assert.equal(
|
|
100
|
+
await run(
|
|
101
|
+
{ origins: async () => ['https://other.com'] },
|
|
102
|
+
{ origin: 'https://other.com', host: 'app.com' }
|
|
103
|
+
),
|
|
104
|
+
true
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('passes through when there is no http wire at all', async () => {
|
|
109
|
+
let reached = false
|
|
110
|
+
await requireOrigin({})({} as any, {} as any, async () => {
|
|
111
|
+
reached = true
|
|
112
|
+
})
|
|
113
|
+
assert.equal(reached, true)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CoreSingletonServices } from '../types/core.types.js'
|
|
2
|
+
import { InvalidOriginError } from '../errors/errors.js'
|
|
3
|
+
import {
|
|
4
|
+
pikkuMiddleware,
|
|
5
|
+
pikkuMiddlewareFactory,
|
|
6
|
+
} from './middleware-factories.js'
|
|
7
|
+
|
|
8
|
+
/** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
|
|
9
|
+
export const toOrigin = (value: string | null | undefined): string | null => {
|
|
10
|
+
if (!value) return null
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value)
|
|
13
|
+
return url.protocol && url.host ? url.origin : null
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a request origin may post to an origin-locked route.
|
|
21
|
+
*
|
|
22
|
+
* The comparison is exact on the parsed origin, never a suffix match:
|
|
23
|
+
* `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
|
|
24
|
+
*/
|
|
25
|
+
export const isAllowedOrigin = (
|
|
26
|
+
requestOrigin: string | null,
|
|
27
|
+
hostOrigin: string | null,
|
|
28
|
+
configuredOrigins: string[]
|
|
29
|
+
): boolean => {
|
|
30
|
+
if (!requestOrigin) return false
|
|
31
|
+
if (hostOrigin && requestOrigin === hostOrigin) return true
|
|
32
|
+
return configuredOrigins.some((allowed) => toOrigin(allowed) === requestOrigin)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
|
|
37
|
+
*
|
|
38
|
+
* This is not what `cors()` does. CORS sets response headers and is enforced by the
|
|
39
|
+
* browser, so a non-browser client ignores them and the request still runs; this rejects
|
|
40
|
+
* before the function body. It stops another site's page from posting to an unauthed
|
|
41
|
+
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
42
|
+
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
43
|
+
*/
|
|
44
|
+
export const requireOrigin = pikkuMiddlewareFactory<{
|
|
45
|
+
/** Extra allowed origins beyond the request's own host, or a resolver for them. */
|
|
46
|
+
origins?:
|
|
47
|
+
| string[]
|
|
48
|
+
| ((services: CoreSingletonServices) => string[] | Promise<string[]>)
|
|
49
|
+
}>(({ origins = [] } = {}) =>
|
|
50
|
+
pikkuMiddleware({
|
|
51
|
+
name: 'requireOrigin',
|
|
52
|
+
description: 'Rejects requests that did not come from this app.',
|
|
53
|
+
func: async (services, { http }, next) => {
|
|
54
|
+
const request = http?.request
|
|
55
|
+
if (!request) return next()
|
|
56
|
+
|
|
57
|
+
const requestOrigin =
|
|
58
|
+
toOrigin(request.header('origin')) ??
|
|
59
|
+
toOrigin(request.header('referer'))
|
|
60
|
+
|
|
61
|
+
const host = request.header('host')
|
|
62
|
+
const proto = request.header('x-forwarded-proto') ?? 'https'
|
|
63
|
+
const hostOrigin = host ? toOrigin(`${proto}://${host}`) : null
|
|
64
|
+
|
|
65
|
+
const configured =
|
|
66
|
+
typeof origins === 'function'
|
|
67
|
+
? await origins(services as CoreSingletonServices)
|
|
68
|
+
: origins
|
|
69
|
+
|
|
70
|
+
if (!isAllowedOrigin(requestOrigin, hostOrigin, configured)) {
|
|
71
|
+
throw new InvalidOriginError(
|
|
72
|
+
`Rejected origin ${requestOrigin ?? '(none)'}`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return next()
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
)
|
package/src/public-surface.json
CHANGED
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
"authBearer",
|
|
11
11
|
"authCookie",
|
|
12
12
|
"cors",
|
|
13
|
+
"isAllowedOrigin",
|
|
13
14
|
"pikkuAgentMiddleware",
|
|
14
15
|
"pikkuChannelMiddleware",
|
|
15
16
|
"pikkuChannelMiddlewareFactory",
|
|
16
17
|
"pikkuMiddleware",
|
|
17
18
|
"pikkuMiddlewareFactory",
|
|
18
19
|
"pikkuRemoteAuthMiddleware",
|
|
20
|
+
"requireOrigin",
|
|
19
21
|
"runMiddleware",
|
|
20
22
|
"telemetryInner",
|
|
21
|
-
"telemetryOuter"
|
|
23
|
+
"telemetryOuter",
|
|
24
|
+
"toOrigin"
|
|
22
25
|
],
|
|
23
26
|
"./function": [
|
|
24
27
|
"AbandonedError",
|
|
@@ -4,6 +4,11 @@ import type { SecretValue } from '../classification/secret-value.js'
|
|
|
4
4
|
export type SecretValues<T> = { [K in keyof T]: SecretValue<T[K]> }
|
|
5
5
|
|
|
6
6
|
export interface SecretService {
|
|
7
|
+
/**
|
|
8
|
+
* Throws if the secret is not found, unless `defineSecret` declared it
|
|
9
|
+
* `optional` — then absence resolves `undefined`. Unwrap the result with
|
|
10
|
+
* `.reveal()`.
|
|
11
|
+
*/
|
|
7
12
|
getSecret<T = string>(key: string): Promise<SecretValue<T>>
|
|
8
13
|
/** Answers for any key, including a disallowed one — it must not throw. */
|
|
9
14
|
hasSecret(key: string): Promise<boolean>
|
|
@@ -2,6 +2,7 @@ import { describe, test } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import { TypedVariablesService } from './typed-variables-service.js'
|
|
4
4
|
import { LocalVariablesService } from './local-variables.js'
|
|
5
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
5
6
|
|
|
6
7
|
describe('TypedVariablesService', () => {
|
|
7
8
|
const createService = (vars: Record<string, string | undefined> = {}) => {
|
|
@@ -71,3 +72,95 @@ describe('TypedVariablesService', () => {
|
|
|
71
72
|
assert.strictEqual(missing.length, 0)
|
|
72
73
|
})
|
|
73
74
|
})
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stands in for `z.enum([...]).default(...)`: a schema that answers `undefined`
|
|
78
|
+
* with a value rather than an issue. Core declares no schema library of its
|
|
79
|
+
* own, so the contract under test is Standard Schema's, not Zod's.
|
|
80
|
+
*/
|
|
81
|
+
const withDefault = <T>(value: T): StandardSchemaV1<unknown, T> => ({
|
|
82
|
+
'~standard': {
|
|
83
|
+
version: 1,
|
|
84
|
+
vendor: 'test',
|
|
85
|
+
validate: (input: unknown) =>
|
|
86
|
+
input === undefined ? { value } : { value: input as T },
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const noDefault: StandardSchemaV1<unknown, string> = {
|
|
91
|
+
'~standard': {
|
|
92
|
+
version: 1,
|
|
93
|
+
vendor: 'test',
|
|
94
|
+
validate: (input: unknown) =>
|
|
95
|
+
typeof input === 'string'
|
|
96
|
+
? { value: input }
|
|
97
|
+
: { issues: [{ message: 'expected a string' }] },
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('TypedVariablesService schema defaults', () => {
|
|
102
|
+
const createService = (vars: Record<string, string | undefined> = {}) =>
|
|
103
|
+
new TypedVariablesService(new LocalVariablesService(vars), {
|
|
104
|
+
GITHUB_BASE_URL: {
|
|
105
|
+
name: 'GITHUB_BASE_URL',
|
|
106
|
+
displayName: 'GitHub Base URL',
|
|
107
|
+
schema: withDefault('https://api.github.com'),
|
|
108
|
+
},
|
|
109
|
+
API_KEY: {
|
|
110
|
+
name: 'API_KEY',
|
|
111
|
+
displayName: 'API Key',
|
|
112
|
+
schema: noDefault,
|
|
113
|
+
},
|
|
114
|
+
// The form code generation emits, deferred past the import cycle.
|
|
115
|
+
REGION: {
|
|
116
|
+
name: 'REGION',
|
|
117
|
+
displayName: 'Region',
|
|
118
|
+
schema: () => withDefault('eu-west-1'),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('resolves a declared default when the host sets nothing', async () => {
|
|
123
|
+
const service = createService()
|
|
124
|
+
assert.strictEqual(
|
|
125
|
+
await service.get('GITHUB_BASE_URL'),
|
|
126
|
+
'https://api.github.com'
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('prefers the host value over the default', async () => {
|
|
131
|
+
const service = createService({ GITHUB_BASE_URL: 'https://ghe.internal' })
|
|
132
|
+
assert.strictEqual(
|
|
133
|
+
await service.get('GITHUB_BASE_URL'),
|
|
134
|
+
'https://ghe.internal'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('stays undefined when the schema carries no default', async () => {
|
|
139
|
+
const service = createService()
|
|
140
|
+
assert.strictEqual(await service.get('API_KEY'), undefined)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('resolves a default behind a thunk', async () => {
|
|
144
|
+
const service = createService()
|
|
145
|
+
assert.strictEqual(await service.get('REGION'), 'eu-west-1')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('a defaulted variable is not missing', async () => {
|
|
149
|
+
const service = createService()
|
|
150
|
+
const missing = await service.getMissing()
|
|
151
|
+
assert.deepStrictEqual(
|
|
152
|
+
missing.map((v) => v.variableId),
|
|
153
|
+
['API_KEY']
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('status separates having a default from being configured', async () => {
|
|
158
|
+
const service = createService()
|
|
159
|
+
const status = await service.getAllStatus()
|
|
160
|
+
const github = status.find((s) => s.variableId === 'GITHUB_BASE_URL')!
|
|
161
|
+
assert.strictEqual(github.isConfigured, false)
|
|
162
|
+
assert.strictEqual(github.hasDefault, true)
|
|
163
|
+
const apiKey = status.find((s) => s.variableId === 'API_KEY')!
|
|
164
|
+
assert.strictEqual(apiKey.hasDefault, false)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
1
2
|
import type { VariablesService } from './variables-service.js'
|
|
2
3
|
|
|
3
4
|
export interface VariableStatus {
|
|
@@ -5,13 +6,35 @@ export interface VariableStatus {
|
|
|
5
6
|
name: string
|
|
6
7
|
displayName: string
|
|
7
8
|
isConfigured: boolean
|
|
9
|
+
/** Whether the declaration answers for itself when the host sets nothing. */
|
|
10
|
+
hasDefault: boolean
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export type VariableMeta = {
|
|
11
14
|
name: string
|
|
12
15
|
displayName: string
|
|
16
|
+
/**
|
|
17
|
+
* The shape the variable was declared with. It is the schema itself rather
|
|
18
|
+
* than a description of it, because a default is only knowable by running it:
|
|
19
|
+
* `undefined` goes in and, if the declaration carries one, the default comes
|
|
20
|
+
* back out.
|
|
21
|
+
*
|
|
22
|
+
* A thunk is accepted, and is what code generation emits. The generated file
|
|
23
|
+
* and the file declaring the schema import each other, so reading the schema
|
|
24
|
+
* while the modules are still initializing throws — deferring the read until
|
|
25
|
+
* a variable is actually asked for is what keeps the cycle harmless.
|
|
26
|
+
*/
|
|
27
|
+
schema?: StandardSchemaV1 | (() => StandardSchemaV1)
|
|
13
28
|
}
|
|
14
29
|
|
|
30
|
+
const isPromise = (value: unknown): value is Promise<unknown> =>
|
|
31
|
+
typeof (value as Promise<unknown> | undefined)?.then === 'function'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A declared default is the answer to a variable nobody set, so it is resolved
|
|
35
|
+
* here rather than in `VariablesService`: the store knows what a host has put
|
|
36
|
+
* in it, and only this layer knows what was declared.
|
|
37
|
+
*/
|
|
15
38
|
export class TypedVariablesService<
|
|
16
39
|
TMap = Record<string, unknown>,
|
|
17
40
|
> implements VariablesService {
|
|
@@ -25,13 +48,23 @@ export class TypedVariablesService<
|
|
|
25
48
|
): Promise<TMap[K] | undefined> | TMap[K] | undefined
|
|
26
49
|
get<T = string>(name: string): Promise<T | undefined> | T | undefined
|
|
27
50
|
get(name: string): Promise<unknown> | unknown {
|
|
28
|
-
|
|
51
|
+
const stored = this.variables.get(name)
|
|
52
|
+
if (isPromise(stored)) {
|
|
53
|
+
return stored.then((value) =>
|
|
54
|
+
value === undefined ? this.resolveDefault(name) : value
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
return stored === undefined ? this.resolveDefault(name) : stored
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
getVariables<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
32
61
|
names: (keyof T & string)[]
|
|
33
62
|
): Promise<Partial<T>> | Partial<T> {
|
|
34
|
-
|
|
63
|
+
const stored = this.variables.getVariables<T>(names)
|
|
64
|
+
if (isPromise(stored)) {
|
|
65
|
+
return stored.then((values) => this.withDefaults(names, values))
|
|
66
|
+
}
|
|
67
|
+
return this.withDefaults(names, stored)
|
|
35
68
|
}
|
|
36
69
|
|
|
37
70
|
getAll():
|
|
@@ -62,14 +95,72 @@ export class TypedVariablesService<
|
|
|
62
95
|
name: meta.name,
|
|
63
96
|
displayName: meta.displayName,
|
|
64
97
|
isConfigured: all[variableId] !== undefined,
|
|
98
|
+
hasDefault: (await this.resolveDefault(variableId)) !== undefined,
|
|
65
99
|
})
|
|
66
100
|
}
|
|
67
101
|
|
|
68
102
|
return results
|
|
69
103
|
}
|
|
70
104
|
|
|
105
|
+
/**
|
|
106
|
+
* What a deployment still has to be told. A variable that defaults is not on
|
|
107
|
+
* this list — it has a value, just not one anybody has to supply.
|
|
108
|
+
*/
|
|
71
109
|
async getMissing(): Promise<VariableStatus[]> {
|
|
72
110
|
const all = await this.getAllStatus()
|
|
73
|
-
return all.filter((v) => !v.isConfigured)
|
|
111
|
+
return all.filter((v) => !v.isConfigured && !v.hasDefault)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The value the declaration answers with when the host set nothing, or
|
|
116
|
+
* `undefined` when it does not answer for itself.
|
|
117
|
+
*/
|
|
118
|
+
private resolveDefault(name: string): Promise<unknown> | unknown {
|
|
119
|
+
const declared = this.variablesMeta[name]?.schema
|
|
120
|
+
if (!declared) {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
const schema = typeof declared === 'function' ? declared() : declared
|
|
124
|
+
const result = schema['~standard'].validate(undefined)
|
|
125
|
+
if (isPromise(result)) {
|
|
126
|
+
return result.then(unwrapDefault)
|
|
127
|
+
}
|
|
128
|
+
return unwrapDefault(result)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Kept synchronous when the defaults resolve synchronously, so a caller that
|
|
133
|
+
* did not await `getVariables` before does not have to start.
|
|
134
|
+
*/
|
|
135
|
+
private withDefaults<T extends Record<string, unknown>>(
|
|
136
|
+
names: (keyof T & string)[],
|
|
137
|
+
values: Partial<T>
|
|
138
|
+
): Promise<Partial<T>> | Partial<T> {
|
|
139
|
+
const out: Record<string, unknown> = { ...values }
|
|
140
|
+
const pending: Promise<void>[] = []
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
if (out[name] !== undefined) continue
|
|
143
|
+
const fallback = this.resolveDefault(name)
|
|
144
|
+
if (isPromise(fallback)) {
|
|
145
|
+
pending.push(
|
|
146
|
+
fallback.then((value) => {
|
|
147
|
+
if (value !== undefined) out[name] = value
|
|
148
|
+
})
|
|
149
|
+
)
|
|
150
|
+
} else if (fallback !== undefined) {
|
|
151
|
+
out[name] = fallback
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (pending.length > 0) {
|
|
155
|
+
return Promise.all(pending).then(() => out as Partial<T>)
|
|
156
|
+
}
|
|
157
|
+
return out as Partial<T>
|
|
74
158
|
}
|
|
75
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A schema with no default rejects `undefined`, which is not a failure here —
|
|
163
|
+
* it is the answer that there is nothing to fall back to.
|
|
164
|
+
*/
|
|
165
|
+
const unwrapDefault = (result: StandardSchemaV1.Result<unknown>) =>
|
|
166
|
+
result.issues ? undefined : result.value
|