@pikku/core 0.12.97 → 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 CHANGED
@@ -1,3 +1,30 @@
1
+ ## 0.12.98
2
+
3
+ ### Patch Changes
4
+
5
+ - 80eb5c0: Remove the `addMiddleware` alias of `addTagMiddleware`.
6
+
7
+ The CLI inspector decides what registers tag middleware by matching the call's
8
+ identifier text, so `addMiddleware(...)` compiled, exported and registered
9
+ nothing — no error, no warning, and the middleware simply never ran. The name
10
+ was also the one the concept-mapping skill taught.
11
+
12
+ `addTagMiddleware` is the newer name and the scope-matched sibling of
13
+ `addGlobalMiddleware`; the alias was reintroduced after the rename that
14
+ established that pair.
15
+
16
+ - 2252016: Decide whether a virtual-user run is against production from the configured
17
+ environment rather than `NODE_ENV`.
18
+
19
+ A deployment whose staging is a production mirror runs `NODE_ENV=production`
20
+ there too, so the old check refused every disposition on the one environment
21
+ they exist to be used on. `startVirtualUserRun` now takes the `environments`
22
+ generated beside the personas and the environment this process is (`PIKKU_ENV`
23
+ by default), which is the same signal `personaEnvironmentRefusal` already
24
+ checks at sign-in; the generated scaffold passes them. An environment that
25
+ cannot be resolved is treated as production. `NODE_ENV` remains the answer for
26
+ a project that configures no environments at all.
27
+
1
28
  ## 0.12.97
2
29
 
3
30
  ### Patch Changes
@@ -55,6 +55,16 @@ export interface ColumnClassification {
55
55
  anonymize_strategy: AnonymizeStrategy;
56
56
  /** At-rest representation. Absent means `plain`. */
57
57
  form?: ColumnForm;
58
+ /**
59
+ * Which key protects this column, for a `wrapped` or `sealed` form. Absent
60
+ * means the deployment's default key.
61
+ *
62
+ * It is a purpose, not a tenant: naming one here says "these columns open
63
+ * together and separately from the rest", so the key that opens notes need
64
+ * not open credentials. The id is stored in the value, so a column that
65
+ * changes key is a rewrap rather than a migration.
66
+ */
67
+ keyId?: string;
58
68
  description?: string;
59
69
  }
60
70
  export type ClassificationManifest = {
@@ -0,0 +1,80 @@
1
+ import type { WrappedValue } from './data-classification.js';
2
+ export type LockState = 'uninitialized' | 'locked' | 'unlocked';
3
+ /**
4
+ * One KEK's stored material: everything a passphrase has to reproduce, and
5
+ * nothing a passphrase could be recovered from.
6
+ */
7
+ export type LockRecord = {
8
+ keyId: string;
9
+ keyVersion: number;
10
+ salt: string;
11
+ /**
12
+ * A DEK sealed under this KEK. Unwrapping it is the passphrase check — AES-GCM
13
+ * fails its authentication tag under the wrong key, so a bad passphrase is
14
+ * caught before it can produce a single garbled row.
15
+ */
16
+ verifier: WrappedValue;
17
+ };
18
+ /**
19
+ * Where lock records live.
20
+ *
21
+ * Necessarily readable while locked — a store that needed its own key to find
22
+ * out how to unlock itself could never be opened. It holds no plaintext key
23
+ * material, so this costs nothing.
24
+ */
25
+ export interface LockVault {
26
+ read(): Promise<LockRecord[]>;
27
+ write(records: LockRecord[]): Promise<void>;
28
+ }
29
+ /**
30
+ * The gate in front of every classified column.
31
+ *
32
+ * A key is never held at construction: the server boots locked and serves the
33
+ * unlock screen, so the passphrase arrives over HTTP long after services are
34
+ * built. `getKEK` is what a Kysely classification resolver calls per operation,
35
+ * and it throws rather than returning a falsy key — silently writing plaintext
36
+ * into a column the manifest calls `wrapped` would look like a working app
37
+ * while the data sat exposed.
38
+ */
39
+ export declare class DataLock {
40
+ private readonly vault;
41
+ private keks;
42
+ private records;
43
+ private initialized;
44
+ private failures;
45
+ private lockedOutUntil;
46
+ private readonly now;
47
+ constructor(vault: LockVault, options?: {
48
+ now?: () => number;
49
+ });
50
+ get state(): LockState;
51
+ /**
52
+ * How long before another guess will be looked at, or 0.
53
+ *
54
+ * Exposed so an unlock screen can show the wait instead of discovering it by
55
+ * guessing again — a guess made during a lockout is itself a failure, and
56
+ * extends the window it was trying to wait out.
57
+ */
58
+ get retryAfterMs(): number;
59
+ /** Read what the store already has, so `state` can answer. */
60
+ init(): Promise<LockState>;
61
+ /**
62
+ * First run: mint a salt and verifier per key and leave the store open, since
63
+ * whoever chose the passphrase a moment ago does not need to retype it.
64
+ */
65
+ initialize(passphrase: string, keyIds?: string[]): Promise<void>;
66
+ unlock(passphrase: string): Promise<void>;
67
+ lock(): void;
68
+ getKEK(keyId: string): Promise<CryptoKey>;
69
+ /**
70
+ * The version to stamp into a value written under `keyId`.
71
+ *
72
+ * Separate from `getKEK` because only a write has to ask: a stored value
73
+ * carries the version it was sealed under, so a read already knows. Readable
74
+ * while locked, since a version number is not key material.
75
+ */
76
+ getKeyVersion(keyId: string): number;
77
+ private assertKnownKeyId;
78
+ private recordFailure;
79
+ private assertInitialized;
80
+ }
@@ -0,0 +1,146 @@
1
+ import { deriveKEK, generateDEK, generateKEKSalt, unwrapDEK, wrapDEK, } from '../crypto-utils.js';
2
+ import { DataLockedError, InvalidPassphraseError, TooManyAttemptsError, } from '../errors/errors.js';
3
+ import { DEFAULT_KEY_ID } from './key-ids.js';
4
+ /** Wrong guesses tolerated before a lockout window opens. */
5
+ const MAX_ATTEMPTS = 5;
6
+ const LOCKOUT_MS = 30_000;
7
+ const MAX_LOCKOUT_MS = 15 * 60_000;
8
+ /**
9
+ * The gate in front of every classified column.
10
+ *
11
+ * A key is never held at construction: the server boots locked and serves the
12
+ * unlock screen, so the passphrase arrives over HTTP long after services are
13
+ * built. `getKEK` is what a Kysely classification resolver calls per operation,
14
+ * and it throws rather than returning a falsy key — silently writing plaintext
15
+ * into a column the manifest calls `wrapped` would look like a working app
16
+ * while the data sat exposed.
17
+ */
18
+ export class DataLock {
19
+ vault;
20
+ keks = new Map();
21
+ records = [];
22
+ initialized = false;
23
+ failures = 0;
24
+ lockedOutUntil = 0;
25
+ now;
26
+ constructor(vault, options = {}) {
27
+ this.vault = vault;
28
+ this.now = options.now ?? Date.now;
29
+ }
30
+ get state() {
31
+ if (!this.records.length) {
32
+ return 'uninitialized';
33
+ }
34
+ return this.keks.size ? 'unlocked' : 'locked';
35
+ }
36
+ /**
37
+ * How long before another guess will be looked at, or 0.
38
+ *
39
+ * Exposed so an unlock screen can show the wait instead of discovering it by
40
+ * guessing again — a guess made during a lockout is itself a failure, and
41
+ * extends the window it was trying to wait out.
42
+ */
43
+ get retryAfterMs() {
44
+ return Math.max(0, this.lockedOutUntil - this.now());
45
+ }
46
+ /** Read what the store already has, so `state` can answer. */
47
+ async init() {
48
+ this.records = await this.vault.read();
49
+ this.initialized = true;
50
+ return this.state;
51
+ }
52
+ /**
53
+ * First run: mint a salt and verifier per key and leave the store open, since
54
+ * whoever chose the passphrase a moment ago does not need to retype it.
55
+ */
56
+ async initialize(passphrase, keyIds = [DEFAULT_KEY_ID]) {
57
+ this.assertInitialized();
58
+ if (this.records.length) {
59
+ throw new Error('This store is already initialized. Re-initializing would seal it under a new key while every existing row stayed sealed under the old one.');
60
+ }
61
+ const records = [];
62
+ for (const keyId of keyIds) {
63
+ const salt = generateKEKSalt();
64
+ const kek = await deriveKEK(passphrase, salt);
65
+ records.push({
66
+ keyId,
67
+ keyVersion: 1,
68
+ salt,
69
+ verifier: await wrapDEK(kek, await generateDEK()),
70
+ });
71
+ this.keks.set(keyId, kek);
72
+ }
73
+ await this.vault.write(records);
74
+ this.records = records;
75
+ }
76
+ async unlock(passphrase) {
77
+ this.assertInitialized();
78
+ if (this.now() < this.lockedOutUntil) {
79
+ // A correct passphrase waits too. Exempting it would hand an attacker the
80
+ // oracle the throttle exists to deny: a guess that behaves differently is
81
+ // a guess that has been confirmed.
82
+ throw new TooManyAttemptsError();
83
+ }
84
+ const opened = new Map();
85
+ for (const record of this.records) {
86
+ const kek = await deriveKEK(passphrase, record.salt);
87
+ try {
88
+ await unwrapDEK(kek, record.verifier);
89
+ }
90
+ catch {
91
+ this.recordFailure();
92
+ // Which record failed says which key the passphrase was not for, so the
93
+ // whole attempt fails as one rather than naming it.
94
+ throw new InvalidPassphraseError();
95
+ }
96
+ opened.set(record.keyId, kek);
97
+ }
98
+ this.failures = 0;
99
+ this.lockedOutUntil = 0;
100
+ this.keks = opened;
101
+ }
102
+ lock() {
103
+ this.keks.clear();
104
+ }
105
+ async getKEK(keyId) {
106
+ // A keyId nobody initialized is a configuration error, and saying "locked"
107
+ // about it sends whoever reads that log hunting for a passphrase to a
108
+ // store that is already open. `DataLockedError` means only the lock state.
109
+ this.assertKnownKeyId(keyId);
110
+ const kek = this.keks.get(keyId);
111
+ if (!kek) {
112
+ throw new DataLockedError();
113
+ }
114
+ return kek;
115
+ }
116
+ /**
117
+ * The version to stamp into a value written under `keyId`.
118
+ *
119
+ * Separate from `getKEK` because only a write has to ask: a stored value
120
+ * carries the version it was sealed under, so a read already knows. Readable
121
+ * while locked, since a version number is not key material.
122
+ */
123
+ getKeyVersion(keyId) {
124
+ this.assertKnownKeyId(keyId);
125
+ return this.records.find((record) => record.keyId === keyId).keyVersion;
126
+ }
127
+ assertKnownKeyId(keyId) {
128
+ if (!this.records.some((record) => record.keyId === keyId)) {
129
+ throw new Error(`No lock record for key "${keyId}" — every keyId a column names has to be passed to initialize(). Derive the list from the classification manifest with keyIdsFromManifest() so the two cannot drift.`);
130
+ }
131
+ }
132
+ recordFailure() {
133
+ this.failures += 1;
134
+ if (this.failures < MAX_ATTEMPTS) {
135
+ return;
136
+ }
137
+ const escalation = this.failures - MAX_ATTEMPTS;
138
+ this.lockedOutUntil =
139
+ this.now() + Math.min(LOCKOUT_MS * 2 ** escalation, MAX_LOCKOUT_MS);
140
+ }
141
+ assertInitialized() {
142
+ if (!this.initialized) {
143
+ throw new Error('DataLock.init() must run before the store is used');
144
+ }
145
+ }
146
+ }
@@ -11,3 +11,4 @@ export type { Private, Pii, Secret, Classification, AnonymizeStrategy, ColumnFor
11
11
  export { hashToken, unsafeAsWrapped, unsafeAsSealed, unsafeAsHashed, } from './column-form.js';
12
12
  export { REDACTED, SecretCoercionError, SecretValue, createSecretValue, isSecretValue, } from './secret-value.js';
13
13
  export type { Safe } from './secret-value.js';
14
+ export { DEFAULT_KEY_ID } from './key-ids.js';
@@ -1,2 +1,3 @@
1
1
  export { hashToken, unsafeAsWrapped, unsafeAsSealed, unsafeAsHashed, } from './column-form.js';
2
2
  export { REDACTED, SecretCoercionError, SecretValue, createSecretValue, isSecretValue, } from './secret-value.js';
3
+ 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 declare const DEFAULT_KEY_ID = "default";
@@ -0,0 +1,2 @@
1
+ /** The key a column protects itself with when it names none. */
2
+ export const DEFAULT_KEY_ID = 'default';
@@ -5,7 +5,7 @@ export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
5
5
  export { cors } from './cors.js';
6
6
  export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
7
7
  export { telemetryOuter, telemetryInner } from './telemetry.js';
8
- export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
8
+ export { addTagMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
9
9
  export { addGlobalPermission } from '../permissions.js';
10
10
  export type { CorePikkuMiddleware, CorePikkuMiddlewareConfig, CorePikkuMiddlewareFactory, CorePikkuMiddlewareGroup, MiddlewareMetadata, MiddlewarePriority, } from './middleware.types.js';
11
11
  export { pikkuAgentMiddleware, pikkuChannelMiddleware, pikkuChannelMiddlewareFactory, pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
@@ -5,6 +5,6 @@ export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
5
5
  export { cors } from './cors.js';
6
6
  export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
7
7
  export { telemetryOuter, telemetryInner } from './telemetry.js';
8
- export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
8
+ export { addTagMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
9
9
  export { addGlobalPermission } from '../permissions.js';
10
10
  export { pikkuAgentMiddleware, pikkuChannelMiddleware, pikkuChannelMiddlewareFactory, pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
@@ -0,0 +1,23 @@
1
+ import type { DataLock } from '../classification/data-lock.js';
2
+ /**
3
+ * Refuses a request while the encrypted store is locked.
4
+ *
5
+ * Applied by tag or route rather than globally, because the unlock endpoint and
6
+ * the static frontend have to stay reachable — a store that gated its own
7
+ * unlock screen could never be opened. Static mounts serve a file hit before
8
+ * dispatch, so the app shell is already outside this gate; the unlock function
9
+ * is the one wiring that must not carry it.
10
+ *
11
+ * The gate is deliberately in front of the function rather than inside the
12
+ * query layer. Both refuse, but only this one refuses before the handler has
13
+ * touched the database.
14
+ */
15
+ export declare const requireUnlocked: (lock: DataLock) => import("./middleware.types.js").CorePikkuMiddleware<import("../types/core.types.js").CoreSingletonServices<{
16
+ logLevel?: import("../services/logger.js").LogLevel;
17
+ secrets?: {
18
+ requireAllowedHosts?: boolean;
19
+ };
20
+ workflow?: import("../wirings/workflow/workflow.types.js").WorkflowServiceConfig;
21
+ webhook?: import("../services/webhook-service.js").WebhookServiceConfig;
22
+ postgres?: import("../types/core.types.js").PostgresConfig;
23
+ }>, import("../types/core.types.js").CoreUserSession>;
@@ -0,0 +1,21 @@
1
+ import { DataLockedError } from '../errors/errors.js';
2
+ import { pikkuMiddleware } from './middleware-factories.js';
3
+ /**
4
+ * Refuses a request while the encrypted store is locked.
5
+ *
6
+ * Applied by tag or route rather than globally, because the unlock endpoint and
7
+ * the static frontend have to stay reachable — a store that gated its own
8
+ * unlock screen could never be opened. Static mounts serve a file hit before
9
+ * dispatch, so the app shell is already outside this gate; the unlock function
10
+ * is the one wiring that must not carry it.
11
+ *
12
+ * The gate is deliberately in front of the function rather than inside the
13
+ * query layer. Both refuse, but only this one refuses before the handler has
14
+ * touched the database.
15
+ */
16
+ export const requireUnlocked = (lock) => pikkuMiddleware(async (_services, _wires, next) => {
17
+ if (lock.state !== 'unlocked') {
18
+ throw new DataLockedError();
19
+ }
20
+ return next();
21
+ });
@@ -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.97",
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'
@@ -7,7 +7,6 @@ export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js'
7
7
  export { telemetryOuter, telemetryInner } from './telemetry.js'
8
8
  export {
9
9
  addTagMiddleware,
10
- addTagMiddleware as addMiddleware,
11
10
  addGlobalMiddleware,
12
11
  runMiddleware,
13
12
  } from '../middleware-runner.js'
@@ -4,7 +4,6 @@
4
4
  "./middleware": [
5
5
  "addGlobalMiddleware",
6
6
  "addGlobalPermission",
7
- "addMiddleware",
8
7
  "addTagMiddleware",
9
8
  "authAPIKey",
10
9
  "authBearer",
@@ -454,6 +453,7 @@
454
453
  "setSingletonServices"
455
454
  ],
456
455
  "./classification": [
456
+ "DEFAULT_KEY_ID",
457
457
  "REDACTED",
458
458
  "SecretCoercionError",
459
459
  "SecretValue",