@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.
@@ -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 { createModuleRunner } from './module-runner.js'
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 mod = await runner.run(file)
60
- assert.ok(mod)
61
- const createTodo = mod!.createTodo as {
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 mod = await runner.run(userFile)
87
- assert.ok(mod)
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(await (first!.value as any).func(), 'v1')
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(await (second!.value as any).func(), 'v2')
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('returns null on a bad edit so the caller keeps old code', async () => {
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 mod = await runner.run(file)
125
- assert.equal(mod, null)
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 mod = await runner.run(file)
157
- assert.ok(mod)
199
+ const result = await runner.run(file)
200
+ assert.equal(result.ok, true)
158
201
  }
159
202
  gc()
160
203
  const growth = heapUsedMb() - baseline
@@ -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. Returns `null` on failure so the caller can keep the
24
- * previously-loaded code. */
25
- run: (absPath: string) => Promise<Record<string, unknown> | null>
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
- return null
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
 
@@ -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
+ )
@@ -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
- return this.variables.get(name)
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
- return this.variables.getVariables<T>(names)
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