@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.
- package/CHANGELOG.md +50 -0
- package/dist/classification/data-classification.d.ts +10 -0
- package/dist/classification/data-lock.d.ts +80 -0
- package/dist/classification/data-lock.js +146 -0
- package/dist/classification/index.d.ts +1 -0
- package/dist/classification/index.js +1 -0
- package/dist/classification/key-ids.d.ts +2 -0
- package/dist/classification/key-ids.js +2 -0
- package/dist/middleware/index.d.ts +2 -1
- package/dist/middleware/index.js +2 -1
- package/dist/middleware/require-origin.d.ts +23 -0
- package/dist/middleware/require-origin.js +57 -0
- package/dist/middleware/require-unlocked.d.ts +23 -0
- package/dist/middleware/require-unlocked.js +21 -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/data-lock/data-lock-wiring.d.ts +40 -0
- package/dist/wirings/data-lock/data-lock-wiring.js +77 -0
- package/dist/wirings/data-lock/index.d.ts +9 -0
- package/dist/wirings/data-lock/index.js +8 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +7 -1
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +25 -3
- package/package.json +1 -1
- package/src/classification/data-classification.ts +10 -0
- package/src/classification/index.ts +2 -0
- package/src/classification/key-ids.ts +2 -0
- package/src/middleware/index.ts +1 -1
- package/src/middleware/require-origin.test.ts +115 -0
- package/src/middleware/require-origin.ts +79 -0
- package/src/public-surface.json +5 -2
- 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-scaffold.test.ts +59 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +37 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
@@ -244,6 +244,65 @@ describe('startVirtualUserRun', () => {
|
|
|
244
244
|
assert.equal(started[0].disposition, 'accountable')
|
|
245
245
|
})
|
|
246
246
|
|
|
247
|
+
// The case NODE_ENV cannot answer: a staging environment that is a production
|
|
248
|
+
// mirror runs NODE_ENV=production too, and refusing every disposition there
|
|
249
|
+
// refuses them on the one environment they exist to be used on.
|
|
250
|
+
test('a non-production environment allows a probing disposition', async () => {
|
|
251
|
+
const { store, started } = runStore()
|
|
252
|
+
await startVirtualUserRun({
|
|
253
|
+
store,
|
|
254
|
+
personas,
|
|
255
|
+
config: { nodeEnv: 'production' },
|
|
256
|
+
environments: { staging: {}, production: { production: true } },
|
|
257
|
+
environment: 'staging',
|
|
258
|
+
persona: 'susan',
|
|
259
|
+
disposition: 'adversarial',
|
|
260
|
+
})
|
|
261
|
+
assert.equal(started[0].disposition, 'adversarial')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('the configured production environment still refuses one', async () => {
|
|
265
|
+
const { store, started } = runStore()
|
|
266
|
+
await assert.rejects(
|
|
267
|
+
startVirtualUserRun({
|
|
268
|
+
store,
|
|
269
|
+
personas,
|
|
270
|
+
config: { nodeEnv: 'development' },
|
|
271
|
+
environments: { staging: {}, production: { production: true } },
|
|
272
|
+
environment: 'production',
|
|
273
|
+
persona: 'susan',
|
|
274
|
+
disposition: 'adversarial',
|
|
275
|
+
}),
|
|
276
|
+
/Only the 'accountable' disposition may run against production/
|
|
277
|
+
)
|
|
278
|
+
assert.equal(started.length, 0)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// An environment nobody can name is one whose data nobody can vouch for.
|
|
282
|
+
// PIKKU_ENV is cleared rather than passed as undefined: it is the default the
|
|
283
|
+
// parameter falls back to, so leaving it set would test the wrong thing.
|
|
284
|
+
test('an unresolved environment is treated as production', async () => {
|
|
285
|
+
const { store, started } = runStore()
|
|
286
|
+
const pikkuEnv = process.env.PIKKU_ENV
|
|
287
|
+
delete process.env.PIKKU_ENV
|
|
288
|
+
try {
|
|
289
|
+
await assert.rejects(
|
|
290
|
+
startVirtualUserRun({
|
|
291
|
+
store,
|
|
292
|
+
personas,
|
|
293
|
+
config: { nodeEnv: 'development' },
|
|
294
|
+
environments: { staging: {}, production: { production: true } },
|
|
295
|
+
persona: 'susan',
|
|
296
|
+
disposition: 'adversarial',
|
|
297
|
+
}),
|
|
298
|
+
/Only the 'accountable' disposition may run against production/
|
|
299
|
+
)
|
|
300
|
+
} finally {
|
|
301
|
+
if (pikkuEnv !== undefined) process.env.PIKKU_ENV = pikkuEnv
|
|
302
|
+
}
|
|
303
|
+
assert.equal(started.length, 0)
|
|
304
|
+
})
|
|
305
|
+
|
|
247
306
|
test('refuses a persona that is declared as acted upon', async () => {
|
|
248
307
|
const { store, started } = runStore()
|
|
249
308
|
await assert.rejects(
|
|
@@ -11,6 +11,7 @@ import { prepareVirtualUserRun } from './prepare-virtual-user-run.js'
|
|
|
11
11
|
import { runVirtualUser as runVirtualUserEngine } from './run-virtual-user.js'
|
|
12
12
|
import { personaVirtualUserTarget } from './virtual-user-target.js'
|
|
13
13
|
import type { SchemaMap } from './virtual-user-derive.js'
|
|
14
|
+
import type { PersonaEnvironment } from '../persona/persona-environments.js'
|
|
14
15
|
import { PRODUCTION_DISPOSITION } from './virtual-user.types.js'
|
|
15
16
|
import type {
|
|
16
17
|
StepRecord,
|
|
@@ -160,8 +161,13 @@ export interface StartVirtualUserRunParams {
|
|
|
160
161
|
/**
|
|
161
162
|
* The app's config, read only for `nodeEnv` — structural because an
|
|
162
163
|
* application's Config is its own interface and need not declare it at all.
|
|
164
|
+
* The fallback signal, used only by a project that configures no environments.
|
|
163
165
|
*/
|
|
164
166
|
config: { nodeEnv?: string } | undefined
|
|
167
|
+
/** `environments` from pikku.config.json, as generated beside the personas. */
|
|
168
|
+
environments?: Readonly<Record<string, PersonaEnvironment>>
|
|
169
|
+
/** Which of them this process is. Defaults to `PIKKU_ENV`. */
|
|
170
|
+
environment?: string
|
|
165
171
|
persona: string
|
|
166
172
|
disposition?: string
|
|
167
173
|
seed?: number
|
|
@@ -181,6 +187,33 @@ export interface StartedVirtualUserRun {
|
|
|
181
187
|
memory: Record<string, string>
|
|
182
188
|
}
|
|
183
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Whether this process is running against production, for the disposition rule.
|
|
192
|
+
*
|
|
193
|
+
* The configured environment wins over `NODE_ENV` because they answer different
|
|
194
|
+
* questions. A deployment whose staging is a production *mirror* runs
|
|
195
|
+
* `NODE_ENV=production` there too — keying on it refuses every disposition on
|
|
196
|
+
* the one environment they exist to be used on. `PIKKU_ENV` names which of the
|
|
197
|
+
* configured environments this is, which is the question actually being asked,
|
|
198
|
+
* and it is the same signal `personaEnvironmentRefusal` already checks at
|
|
199
|
+
* sign-in.
|
|
200
|
+
*
|
|
201
|
+
* Unresolved is treated as production: an environment nobody can name is one
|
|
202
|
+
* whose data nobody can vouch for. `NODE_ENV` remains the answer only for a
|
|
203
|
+
* project that configures no environments at all, which has no production
|
|
204
|
+
* environment declared for this to be wrong about.
|
|
205
|
+
*/
|
|
206
|
+
const isProductionRun = (
|
|
207
|
+
config: { nodeEnv?: string } | undefined,
|
|
208
|
+
environments: Readonly<Record<string, PersonaEnvironment>> | undefined,
|
|
209
|
+
environment: string | undefined
|
|
210
|
+
): boolean => {
|
|
211
|
+
if (!environments || Object.keys(environments).length === 0) {
|
|
212
|
+
return config?.nodeEnv === 'production'
|
|
213
|
+
}
|
|
214
|
+
return environment ? Boolean(environments[environment]?.production) : true
|
|
215
|
+
}
|
|
216
|
+
|
|
184
217
|
/**
|
|
185
218
|
* Resolves a request against the declaration and records the run.
|
|
186
219
|
*
|
|
@@ -192,6 +225,8 @@ export const startVirtualUserRun = async ({
|
|
|
192
225
|
store,
|
|
193
226
|
personas,
|
|
194
227
|
config,
|
|
228
|
+
environments,
|
|
229
|
+
environment = process.env.PIKKU_ENV,
|
|
195
230
|
persona: personaId,
|
|
196
231
|
disposition: requested,
|
|
197
232
|
seed: requestedSeed,
|
|
@@ -210,8 +245,8 @@ export const startVirtualUserRun = async ({
|
|
|
210
245
|
// does wrong, which is not a thing to do to real customers' data. Checked
|
|
211
246
|
// against the effective disposition, so an override cannot smuggle one in.
|
|
212
247
|
if (
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
disposition !== PRODUCTION_DISPOSITION &&
|
|
249
|
+
isProductionRun(config, environments, environment)
|
|
215
250
|
) {
|
|
216
251
|
throw new Error(
|
|
217
252
|
`Only the '${PRODUCTION_DISPOSITION}' disposition may run against production; "${personaId}" is ${disposition}`
|