@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,53 @@
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
+
28
+ ## 0.12.97
29
+
30
+ ### Patch Changes
31
+
32
+ - 8154b1c: Restore the `SecretService.getSecret` JSDoc noting its failure mode, and state the `optional` carve-out `defineSecret` already documents: a key declared `optional` resolves `undefined` when absent rather than throwing. The line was on `main` and was removed by mistake in a comment cleanup on #1411 — the PR that changes what that throw says — leaving `getSecret` the only one of the interface's methods without its documented failure mode.
33
+ - 6d9c09c: Resolve a variable's declared default instead of dropping it.
34
+
35
+ `defineVariable` takes a schema, and a schema can carry a default — `z.enum(['https://api.github.com']).default('https://api.github.com')` is the shape most addons declare their base URL with. Nothing read it. `variables.get('GITHUB_BASE_URL')` returned `undefined` on a host that had not set it, and the `as string` at the call site hid that until a request went to `undefined/repos/...`.
36
+
37
+ The default now resolves in `TypedVariablesService`, which is the layer that knows what was declared — `VariablesService` only knows what a host put in it. A stored value always wins; a schema with no default still resolves to `undefined`.
38
+
39
+ `VariableStatus` gains `hasDefault`, and `getMissing()` no longer lists a variable that defaults: it has a value, just not one anybody has to supply. `isConfigured` still means what it said — that a host set it.
40
+
41
+ For this to work the generated `TYPED_VARIABLES_META` now carries the schema as a value rather than only `z.infer`-ing its type, so the schema module is retained in the emit instead of being elided.
42
+
43
+ - 239332b: Move first-party product analytics out of application code and into the framework.
44
+
45
+ `createAnalytics<Event>({ endpoint })` in `@pikku/react` is the buffered beacon client: it is typed against the app's own event union, flushes on an interval, on size and on `pagehide`/`visibilitychange` (via `sendBeacon`, so the abandon-point events survive unload), never surfaces a failure to the user and never retries. It also carries the delegated `data-analytics-click` listener, registered in the capture phase so a component calling `stopPropagation()` cannot silence instrumentation, and merging `data-analytics-meta` from ancestors with nearest-wins. Put the client on the Pikku instance and `usePikkuAnalytics<Event>()` reaches it from the provider, alongside `usePikkuFetch` and `usePikkuRPC`.
46
+
47
+ `requireOrigin()` in `@pikku/core/middleware` is a server-side origin lock for any unauthed route, and is re-exported from the generated `#pikku/middleware` leaf alongside `cors`. Unlike `cors()` — which only sets response headers a non-browser client ignores — it rejects with a 403 before the function body. Comparison is exact on the parsed origin, so `https://evil-myapp.com` cannot suffix-match `myapp.com`, and a missing `Origin` is rejected because a real browser always sets one on a cross-origin-capable POST. Allowed origins default to the request's own host and can be extended with a list or a resolver over services. `isAllowedOrigin` and `toOrigin` are exported for direct unit testing.
48
+
49
+ Together these let an app keep only its event registry and its wiring, instead of a few hundred lines of copied transport.
50
+
1
51
  ## 0.12.96
2
52
 
3
53
  ### 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';
@@ -3,8 +3,9 @@ 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
- export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
8
+ export { addTagMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
8
9
  export { addGlobalPermission } from '../permissions.js';
9
10
  export type { CorePikkuMiddleware, CorePikkuMiddlewareConfig, CorePikkuMiddlewareFactory, CorePikkuMiddlewareGroup, MiddlewareMetadata, MiddlewarePriority, } from './middleware.types.js';
10
11
  export { pikkuAgentMiddleware, pikkuChannelMiddleware, pikkuChannelMiddlewareFactory, pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
@@ -3,7 +3,8 @@ 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
- export { addTagMiddleware, addTagMiddleware as addMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
8
+ export { addTagMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
8
9
  export { addGlobalPermission } from '../permissions.js';
9
10
  export { pikkuAgentMiddleware, pikkuChannelMiddleware, pikkuChannelMiddlewareFactory, pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
@@ -0,0 +1,23 @@
1
+ import type { CoreSingletonServices } from '../types/core.types.js';
2
+ /** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
3
+ export declare const toOrigin: (value: string | null | undefined) => string | null;
4
+ /**
5
+ * Whether a request origin may post to an origin-locked route.
6
+ *
7
+ * The comparison is exact on the parsed origin, never a suffix match:
8
+ * `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
9
+ */
10
+ export declare const isAllowedOrigin: (requestOrigin: string | null, hostOrigin: string | null, configuredOrigins: string[]) => boolean;
11
+ /**
12
+ * Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
13
+ *
14
+ * This is not what `cors()` does. CORS sets response headers and is enforced by the
15
+ * browser, so a non-browser client ignores them and the request still runs; this rejects
16
+ * before the function body. It stops another site's page from posting to an unauthed
17
+ * route — it is not flood control, because `Origin` is trusted from nobody but a browser.
18
+ * A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
19
+ */
20
+ export declare const requireOrigin: import("./middleware.types.js").CorePikkuMiddlewareFactory<{
21
+ /** Extra allowed origins beyond the request's own host, or a resolver for them. */
22
+ origins?: string[] | ((services: CoreSingletonServices) => string[] | Promise<string[]>);
23
+ }>;
@@ -0,0 +1,57 @@
1
+ import { InvalidOriginError } from '../errors/errors.js';
2
+ import { pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
3
+ /** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
4
+ export const toOrigin = (value) => {
5
+ if (!value)
6
+ return null;
7
+ try {
8
+ const url = new URL(value);
9
+ return url.protocol && url.host ? url.origin : null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ };
15
+ /**
16
+ * Whether a request origin may post to an origin-locked route.
17
+ *
18
+ * The comparison is exact on the parsed origin, never a suffix match:
19
+ * `endsWith('myapp.com')` also accepts `https://evil-myapp.com`.
20
+ */
21
+ export const isAllowedOrigin = (requestOrigin, hostOrigin, configuredOrigins) => {
22
+ if (!requestOrigin)
23
+ return false;
24
+ if (hostOrigin && requestOrigin === hostOrigin)
25
+ return true;
26
+ return configuredOrigins.some((allowed) => toOrigin(allowed) === requestOrigin);
27
+ };
28
+ /**
29
+ * Rejects a request with a 403 unless its `Origin` is this app's own or explicitly allowed.
30
+ *
31
+ * This is not what `cors()` does. CORS sets response headers and is enforced by the
32
+ * browser, so a non-browser client ignores them and the request still runs; this rejects
33
+ * before the function body. It stops another site's page from posting to an unauthed
34
+ * route — it is not flood control, because `Origin` is trusted from nobody but a browser.
35
+ * A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
36
+ */
37
+ export const requireOrigin = pikkuMiddlewareFactory(({ origins = [] } = {}) => pikkuMiddleware({
38
+ name: 'requireOrigin',
39
+ description: 'Rejects requests that did not come from this app.',
40
+ func: async (services, { http }, next) => {
41
+ const request = http?.request;
42
+ if (!request)
43
+ return next();
44
+ const requestOrigin = toOrigin(request.header('origin')) ??
45
+ toOrigin(request.header('referer'));
46
+ const host = request.header('host');
47
+ const proto = request.header('x-forwarded-proto') ?? 'https';
48
+ const hostOrigin = host ? toOrigin(`${proto}://${host}`) : null;
49
+ const configured = typeof origins === 'function'
50
+ ? await origins(services)
51
+ : origins;
52
+ if (!isAllowedOrigin(requestOrigin, hostOrigin, configured)) {
53
+ throw new InvalidOriginError(`Rejected origin ${requestOrigin ?? '(none)'}`);
54
+ }
55
+ return next();
56
+ },
57
+ }));
@@ -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
+ });
@@ -4,6 +4,11 @@ export type SecretValues<T> = {
4
4
  [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>;
@@ -1,14 +1,34 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
1
2
  import type { VariablesService } from './variables-service.js';
2
3
  export interface VariableStatus {
3
4
  variableId: string;
4
5
  name: string;
5
6
  displayName: string;
6
7
  isConfigured: boolean;
8
+ /** Whether the declaration answers for itself when the host sets nothing. */
9
+ hasDefault: boolean;
7
10
  }
8
11
  export type VariableMeta = {
9
12
  name: string;
10
13
  displayName: string;
14
+ /**
15
+ * The shape the variable was declared with. It is the schema itself rather
16
+ * than a description of it, because a default is only knowable by running it:
17
+ * `undefined` goes in and, if the declaration carries one, the default comes
18
+ * back out.
19
+ *
20
+ * A thunk is accepted, and is what code generation emits. The generated file
21
+ * and the file declaring the schema import each other, so reading the schema
22
+ * while the modules are still initializing throws — deferring the read until
23
+ * a variable is actually asked for is what keeps the cycle harmless.
24
+ */
25
+ schema?: StandardSchemaV1 | (() => StandardSchemaV1);
11
26
  };
27
+ /**
28
+ * A declared default is the answer to a variable nobody set, so it is resolved
29
+ * here rather than in `VariablesService`: the store knows what a host has put
30
+ * in it, and only this layer knows what was declared.
31
+ */
12
32
  export declare class TypedVariablesService<TMap = Record<string, unknown>> implements VariablesService {
13
33
  private variables;
14
34
  private variablesMeta;
@@ -21,5 +41,19 @@ export declare class TypedVariablesService<TMap = Record<string, unknown>> imple
21
41
  has(name: string): Promise<boolean> | boolean;
22
42
  delete(name: string): Promise<void> | void;
23
43
  getAllStatus(): Promise<VariableStatus[]>;
44
+ /**
45
+ * What a deployment still has to be told. A variable that defaults is not on
46
+ * this list — it has a value, just not one anybody has to supply.
47
+ */
24
48
  getMissing(): Promise<VariableStatus[]>;
49
+ /**
50
+ * The value the declaration answers with when the host set nothing, or
51
+ * `undefined` when it does not answer for itself.
52
+ */
53
+ private resolveDefault;
54
+ /**
55
+ * Kept synchronous when the defaults resolve synchronously, so a caller that
56
+ * did not await `getVariables` before does not have to start.
57
+ */
58
+ private withDefaults;
25
59
  }
@@ -1,3 +1,9 @@
1
+ const isPromise = (value) => typeof value?.then === 'function';
2
+ /**
3
+ * A declared default is the answer to a variable nobody set, so it is resolved
4
+ * here rather than in `VariablesService`: the store knows what a host has put
5
+ * in it, and only this layer knows what was declared.
6
+ */
1
7
  export class TypedVariablesService {
2
8
  variables;
3
9
  variablesMeta;
@@ -6,10 +12,18 @@ export class TypedVariablesService {
6
12
  this.variablesMeta = variablesMeta;
7
13
  }
8
14
  get(name) {
9
- return this.variables.get(name);
15
+ const stored = this.variables.get(name);
16
+ if (isPromise(stored)) {
17
+ return stored.then((value) => value === undefined ? this.resolveDefault(name) : value);
18
+ }
19
+ return stored === undefined ? this.resolveDefault(name) : stored;
10
20
  }
11
21
  getVariables(names) {
12
- return this.variables.getVariables(names);
22
+ const stored = this.variables.getVariables(names);
23
+ if (isPromise(stored)) {
24
+ return stored.then((values) => this.withDefaults(names, values));
25
+ }
26
+ return this.withDefaults(names, stored);
13
27
  }
14
28
  getAll() {
15
29
  return this.variables.getAll();
@@ -32,12 +46,64 @@ export class TypedVariablesService {
32
46
  name: meta.name,
33
47
  displayName: meta.displayName,
34
48
  isConfigured: all[variableId] !== undefined,
49
+ hasDefault: (await this.resolveDefault(variableId)) !== undefined,
35
50
  });
36
51
  }
37
52
  return results;
38
53
  }
54
+ /**
55
+ * What a deployment still has to be told. A variable that defaults is not on
56
+ * this list — it has a value, just not one anybody has to supply.
57
+ */
39
58
  async getMissing() {
40
59
  const all = await this.getAllStatus();
41
- return all.filter((v) => !v.isConfigured);
60
+ return all.filter((v) => !v.isConfigured && !v.hasDefault);
61
+ }
62
+ /**
63
+ * The value the declaration answers with when the host set nothing, or
64
+ * `undefined` when it does not answer for itself.
65
+ */
66
+ resolveDefault(name) {
67
+ const declared = this.variablesMeta[name]?.schema;
68
+ if (!declared) {
69
+ return undefined;
70
+ }
71
+ const schema = typeof declared === 'function' ? declared() : declared;
72
+ const result = schema['~standard'].validate(undefined);
73
+ if (isPromise(result)) {
74
+ return result.then(unwrapDefault);
75
+ }
76
+ return unwrapDefault(result);
77
+ }
78
+ /**
79
+ * Kept synchronous when the defaults resolve synchronously, so a caller that
80
+ * did not await `getVariables` before does not have to start.
81
+ */
82
+ withDefaults(names, values) {
83
+ const out = { ...values };
84
+ const pending = [];
85
+ for (const name of names) {
86
+ if (out[name] !== undefined)
87
+ continue;
88
+ const fallback = this.resolveDefault(name);
89
+ if (isPromise(fallback)) {
90
+ pending.push(fallback.then((value) => {
91
+ if (value !== undefined)
92
+ out[name] = value;
93
+ }));
94
+ }
95
+ else if (fallback !== undefined) {
96
+ out[name] = fallback;
97
+ }
98
+ }
99
+ if (pending.length > 0) {
100
+ return Promise.all(pending).then(() => out);
101
+ }
102
+ return out;
42
103
  }
43
104
  }
105
+ /**
106
+ * A schema with no default rejects `undefined`, which is not a failure here —
107
+ * it is the answer that there is nothing to fall back to.
108
+ */
109
+ const unwrapDefault = (result) => result.issues ? undefined : result.value;