@pikku/core 0.12.96 → 0.12.98

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/classification/data-classification.d.ts +10 -0
  3. package/dist/classification/data-lock.d.ts +80 -0
  4. package/dist/classification/data-lock.js +146 -0
  5. package/dist/classification/index.d.ts +1 -0
  6. package/dist/classification/index.js +1 -0
  7. package/dist/classification/key-ids.d.ts +2 -0
  8. package/dist/classification/key-ids.js +2 -0
  9. package/dist/middleware/index.d.ts +2 -1
  10. package/dist/middleware/index.js +2 -1
  11. package/dist/middleware/require-origin.d.ts +23 -0
  12. package/dist/middleware/require-origin.js +57 -0
  13. package/dist/middleware/require-unlocked.d.ts +23 -0
  14. package/dist/middleware/require-unlocked.js +21 -0
  15. package/dist/services/secret-service.d.ts +5 -0
  16. package/dist/services/typed-variables-service.d.ts +34 -0
  17. package/dist/services/typed-variables-service.js +69 -3
  18. package/dist/wirings/data-lock/data-lock-wiring.d.ts +40 -0
  19. package/dist/wirings/data-lock/data-lock-wiring.js +77 -0
  20. package/dist/wirings/data-lock/index.d.ts +9 -0
  21. package/dist/wirings/data-lock/index.js +8 -0
  22. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +7 -1
  23. package/dist/wirings/virtual-user/virtual-user-scaffold.js +25 -3
  24. package/package.json +1 -1
  25. package/src/classification/data-classification.ts +10 -0
  26. package/src/classification/index.ts +2 -0
  27. package/src/classification/key-ids.ts +2 -0
  28. package/src/middleware/index.ts +1 -1
  29. package/src/middleware/require-origin.test.ts +115 -0
  30. package/src/middleware/require-origin.ts +79 -0
  31. package/src/public-surface.json +5 -2
  32. package/src/services/secret-service.ts +5 -0
  33. package/src/services/typed-variables-service.test.ts +93 -0
  34. package/src/services/typed-variables-service.ts +94 -3
  35. package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +59 -0
  36. package/src/wirings/virtual-user/virtual-user-scaffold.ts +37 -2
  37. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,40 @@
1
+ import type { DataLock, LockState } from '../../classification/data-lock.js';
2
+ export type DataLockStatus = {
3
+ state: LockState;
4
+ /**
5
+ * Milliseconds before another guess will be looked at, or 0.
6
+ *
7
+ * The unlock screen shows this as a countdown; without it the only way to
8
+ * learn the wait is over is to guess again, and a guess made during a
9
+ * lockout is itself a failure that extends it.
10
+ */
11
+ retryAfterMs: number;
12
+ };
13
+ export type DataLockWiringOptions = {
14
+ /** Where the lock routes are mounted. Defaults to `/_pikku/data`. */
15
+ prefix?: string;
16
+ /**
17
+ * Which keys first-run initialization mints. Derive it with
18
+ * `keyIdsFromManifest`.
19
+ *
20
+ * It is fixed here rather than sent by the caller because the unlock screen
21
+ * posts a passphrase and nothing else — and because a key the schema names
22
+ * but nobody minted does not fail at startup, it fails at the first write to
23
+ * that one column.
24
+ */
25
+ keyIds?: string[];
26
+ };
27
+ /**
28
+ * Puts the passphrase gate on HTTP, so unlocking is a page in the app rather
29
+ * than a prompt in whatever happens to have launched the server.
30
+ *
31
+ * That is what lets one story cover both shapes pikku ships in: a desktop
32
+ * build whose window is pointed at the local server, and a headless
33
+ * `pikku serve` somewhere else, unlock the same way and share the unlock
34
+ * screen. A native prompt in the desktop shell would have left the headless
35
+ * case with nothing.
36
+ *
37
+ * The routes are registered here rather than generated because they belong to
38
+ * core: an app has no source file for them to be discovered in.
39
+ */
40
+ export declare const wireDataLock: (lock: DataLock, { prefix, keyIds }?: DataLockWiringOptions) => void;
@@ -0,0 +1,77 @@
1
+ import { pikkuState } from '../../pikku-state.js';
2
+ import { wireHTTP } from '../http/http-runner.js';
3
+ import { httpRouter } from '../http/routers/http-router.js';
4
+ const helperFunctionMeta = (funcId) => ({
5
+ pikkuFuncId: funcId,
6
+ sessionless: true,
7
+ functionType: 'helper',
8
+ inputSchemaName: null,
9
+ outputSchemaName: null,
10
+ });
11
+ const DEFAULT_PREFIX = '/_pikku/data';
12
+ /**
13
+ * Puts the passphrase gate on HTTP, so unlocking is a page in the app rather
14
+ * than a prompt in whatever happens to have launched the server.
15
+ *
16
+ * That is what lets one story cover both shapes pikku ships in: a desktop
17
+ * build whose window is pointed at the local server, and a headless
18
+ * `pikku serve` somewhere else, unlock the same way and share the unlock
19
+ * screen. A native prompt in the desktop shell would have left the headless
20
+ * case with nothing.
21
+ *
22
+ * The routes are registered here rather than generated because they belong to
23
+ * core: an app has no source file for them to be discovered in.
24
+ */
25
+ export const wireDataLock = (lock, { prefix = DEFAULT_PREFIX, keyIds } = {}) => {
26
+ const status = () => ({
27
+ state: lock.state,
28
+ retryAfterMs: lock.retryAfterMs,
29
+ });
30
+ register(prefix, 'get', '/status', 'pikkuDataLockStatus', async () => status());
31
+ register(prefix, 'post', '/initialize', 'pikkuDataLockInitialize', async (_services, { passphrase }) => {
32
+ await lock.initialize(passphrase, keyIds);
33
+ return status();
34
+ });
35
+ register(prefix, 'post', '/unlock', 'pikkuDataLockUnlock', async (_services, { passphrase }) => {
36
+ await lock.unlock(passphrase);
37
+ return status();
38
+ });
39
+ register(prefix, 'post', '/lock', 'pikkuDataLockLock', async (_services, { passphrase }) => {
40
+ // Locking proves ownership first. An open POST here would be a
41
+ // one-request denial of service: the store shuts and stays shut until
42
+ // someone is around to type the passphrase back in.
43
+ await lock.unlock(passphrase);
44
+ lock.lock();
45
+ return status();
46
+ });
47
+ // A router that has already compiled its table would otherwise answer 404
48
+ // for everything registered after it woke up.
49
+ httpRouter.reset();
50
+ };
51
+ const register = (prefix, method, path, funcId, func) => {
52
+ const route = `${prefix}${path}`;
53
+ const routes = pikkuState(null, 'http', 'routes');
54
+ if (routes.get(method)?.has(route)) {
55
+ return;
56
+ }
57
+ const httpMeta = pikkuState(null, 'http', 'meta');
58
+ httpMeta[method][route] = {
59
+ pikkuFuncId: funcId,
60
+ route,
61
+ method,
62
+ // Never a session. The gate cannot sit in front of its own key: a session
63
+ // may itself live in a column this lock is holding shut.
64
+ auth: false,
65
+ requiresSession: false,
66
+ };
67
+ const functionsMeta = pikkuState(null, 'function', 'meta');
68
+ if (!functionsMeta[funcId]) {
69
+ functionsMeta[funcId] = helperFunctionMeta(funcId);
70
+ }
71
+ wireHTTP({
72
+ method,
73
+ route,
74
+ func: { func },
75
+ auth: false,
76
+ });
77
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The HTTP face of {@link DataLock}: the routes an unlock screen talks to.
3
+ *
4
+ * Separate from `@pikku/core/classification` on purpose — that entry point is
5
+ * types and crypto, and a runtime that never serves HTTP should not have to
6
+ * load a router to use it.
7
+ */
8
+ export { wireDataLock } from './data-lock-wiring.js';
9
+ export type { DataLockStatus, DataLockWiringOptions, } from './data-lock-wiring.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The HTTP face of {@link DataLock}: the routes an unlock screen talks to.
3
+ *
4
+ * Separate from `@pikku/core/classification` on purpose — that entry point is
5
+ * types and crypto, and a runtime that never serves HTTP should not have to
6
+ * load a router to use it.
7
+ */
8
+ export { wireDataLock } from './data-lock-wiring.js';
@@ -4,6 +4,7 @@ import type { VariablesService } from '../../services/variables-service.js';
4
4
  import type { AgentRunnerService } from '../../services/agent-runner-service.js';
5
5
  import type { HttpPersonasConfig } from '../../services/http-personas.js';
6
6
  import type { ResolvedPersona, ScenarioPersonas } from '../../services/personas-service.js';
7
+ import type { PersonaEnvironment } from '../persona/persona-environments.js';
7
8
  import type { StepRecord, VirtualUserDisposition } from './virtual-user.types.js';
8
9
  import type { VirtualUserRunRecord, VirtualUserRunStore } from './virtual-user-run-store.js';
9
10
  import type { VirtualUserScheduleRecord, VirtualUserScheduleStore } from './virtual-user-schedule-store.js';
@@ -84,10 +85,15 @@ export interface StartVirtualUserRunParams {
84
85
  /**
85
86
  * The app's config, read only for `nodeEnv` — structural because an
86
87
  * application's Config is its own interface and need not declare it at all.
88
+ * The fallback signal, used only by a project that configures no environments.
87
89
  */
88
90
  config: {
89
91
  nodeEnv?: string;
90
92
  } | undefined;
93
+ /** `environments` from pikku.config.json, as generated beside the personas. */
94
+ environments?: Readonly<Record<string, PersonaEnvironment>>;
95
+ /** Which of them this process is. Defaults to `PIKKU_ENV`. */
96
+ environment?: string;
91
97
  persona: string;
92
98
  disposition?: string;
93
99
  seed?: number;
@@ -112,7 +118,7 @@ export interface StartedVirtualUserRun {
112
118
  * scheduled tick have in common. The dispatch that follows is typed off the
113
119
  * app's RPC map, so it stays in the generated wiring.
114
120
  */
115
- export declare const startVirtualUserRun: ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }: StartVirtualUserRunParams) => Promise<StartedVirtualUserRun>;
121
+ export declare const startVirtualUserRun: ({ store, personas, config, environments, environment, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }: StartVirtualUserRunParams) => Promise<StartedVirtualUserRun>;
116
122
  /**
117
123
  * One run on the wire.
118
124
  *
@@ -85,6 +85,28 @@ export const requireVirtualUserScheduleStore = (store) => {
85
85
  }
86
86
  return store;
87
87
  };
88
+ /**
89
+ * Whether this process is running against production, for the disposition rule.
90
+ *
91
+ * The configured environment wins over `NODE_ENV` because they answer different
92
+ * questions. A deployment whose staging is a production *mirror* runs
93
+ * `NODE_ENV=production` there too — keying on it refuses every disposition on
94
+ * the one environment they exist to be used on. `PIKKU_ENV` names which of the
95
+ * configured environments this is, which is the question actually being asked,
96
+ * and it is the same signal `personaEnvironmentRefusal` already checks at
97
+ * sign-in.
98
+ *
99
+ * Unresolved is treated as production: an environment nobody can name is one
100
+ * whose data nobody can vouch for. `NODE_ENV` remains the answer only for a
101
+ * project that configures no environments at all, which has no production
102
+ * environment declared for this to be wrong about.
103
+ */
104
+ const isProductionRun = (config, environments, environment) => {
105
+ if (!environments || Object.keys(environments).length === 0) {
106
+ return config?.nodeEnv === 'production';
107
+ }
108
+ return environment ? Boolean(environments[environment]?.production) : true;
109
+ };
88
110
  /**
89
111
  * Resolves a request against the declaration and records the run.
90
112
  *
@@ -92,7 +114,7 @@ export const requireVirtualUserScheduleStore = (store) => {
92
114
  * scheduled tick have in common. The dispatch that follows is typed off the
93
115
  * app's RPC map, so it stays in the generated wiring.
94
116
  */
95
- export const startVirtualUserRun = async ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }) => {
117
+ export const startVirtualUserRun = async ({ store, personas, config, environments, environment = process.env.PIKKU_ENV, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }) => {
96
118
  const runStore = requireVirtualUserRunStore(store);
97
119
  const persona = runnablePersona(personas, personaId);
98
120
  const disposition = (requested ??
@@ -101,8 +123,8 @@ export const startVirtualUserRun = async ({ store, personas, config, persona: pe
101
123
  // Every disposition other than this one exists to find out what the product
102
124
  // does wrong, which is not a thing to do to real customers' data. Checked
103
125
  // against the effective disposition, so an override cannot smuggle one in.
104
- if (config?.nodeEnv === 'production' &&
105
- disposition !== PRODUCTION_DISPOSITION) {
126
+ if (disposition !== PRODUCTION_DISPOSITION &&
127
+ isProductionRun(config, environments, environment)) {
106
128
  throw new Error(`Only the '${PRODUCTION_DISPOSITION}' disposition may run against production; "${personaId}" is ${disposition}`);
107
129
  }
108
130
  // Seeded here rather than inside the engine so the record carries the seed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.96",
3
+ "version": "0.12.98",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -54,6 +54,16 @@ export interface ColumnClassification {
54
54
  anonymize_strategy: AnonymizeStrategy
55
55
  /** At-rest representation. Absent means `plain`. */
56
56
  form?: ColumnForm
57
+ /**
58
+ * Which key protects this column, for a `wrapped` or `sealed` form. Absent
59
+ * means the deployment's default key.
60
+ *
61
+ * It is a purpose, not a tenant: naming one here says "these columns open
62
+ * together and separately from the rest", so the key that opens notes need
63
+ * not open credentials. The id is stored in the value, so a column that
64
+ * changes key is a rewrap rather than a migration.
65
+ */
66
+ keyId?: string
57
67
  description?: string
58
68
  }
59
69
 
@@ -36,3 +36,5 @@ export {
36
36
  isSecretValue,
37
37
  } from './secret-value.js'
38
38
  export type { Safe } from './secret-value.js'
39
+
40
+ export { DEFAULT_KEY_ID } from './key-ids.js'
@@ -0,0 +1,2 @@
1
+ /** The key a column protects itself with when it names none. */
2
+ export const DEFAULT_KEY_ID = 'default'
@@ -3,10 +3,10 @@ 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,
9
- addTagMiddleware as addMiddleware,
10
10
  addGlobalMiddleware,
11
11
  runMiddleware,
12
12
  } from '../middleware-runner.js'
@@ -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
+ )
@@ -4,21 +4,23 @@
4
4
  "./middleware": [
5
5
  "addGlobalMiddleware",
6
6
  "addGlobalPermission",
7
- "addMiddleware",
8
7
  "addTagMiddleware",
9
8
  "authAPIKey",
10
9
  "authBearer",
11
10
  "authCookie",
12
11
  "cors",
12
+ "isAllowedOrigin",
13
13
  "pikkuAgentMiddleware",
14
14
  "pikkuChannelMiddleware",
15
15
  "pikkuChannelMiddlewareFactory",
16
16
  "pikkuMiddleware",
17
17
  "pikkuMiddlewareFactory",
18
18
  "pikkuRemoteAuthMiddleware",
19
+ "requireOrigin",
19
20
  "runMiddleware",
20
21
  "telemetryInner",
21
- "telemetryOuter"
22
+ "telemetryOuter",
23
+ "toOrigin"
22
24
  ],
23
25
  "./function": [
24
26
  "AbandonedError",
@@ -451,6 +453,7 @@
451
453
  "setSingletonServices"
452
454
  ],
453
455
  "./classification": [
456
+ "DEFAULT_KEY_ID",
454
457
  "REDACTED",
455
458
  "SecretCoercionError",
456
459
  "SecretValue",
@@ -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
+ })