@pikku/core 0.12.97 → 0.12.99
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 +78 -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 +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/require-unlocked.d.ts +23 -0
- package/dist/middleware/require-unlocked.js +21 -0
- package/dist/services/http-personas.d.ts +10 -4
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/persona-actor-secret.d.ts +38 -0
- package/dist/services/persona-actor-secret.js +39 -0
- package/dist/services/persona-sign-in.d.ts +11 -1
- package/dist/services/persona-sign-in.js +10 -1
- package/dist/services/typed-secret-service.js +4 -1
- package/dist/wirings/agent-scorer/agent-scorer.d.ts +14 -0
- 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/gateway/gateway.types.d.ts +13 -0
- package/dist/wirings/persona/index.d.ts +2 -1
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/secret/secret.types.d.ts +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/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +1 -1
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-but-it-needs-a-trigger.md +65 -0
- package/knowledge/decisions/internals/index.md +1 -1
- package/knowledge/decisions/security/actor-sign-in-only-works-for-actor-flagged-users.md +19 -15
- package/knowledge/decisions/security/an-actor-credential-is-derived-per-persona.md +41 -0
- package/knowledge/decisions/security/index.md +2 -1
- package/package.json +4 -4
- 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 +0 -1
- package/src/public-surface.json +13 -1
- package/src/services/http-personas-converse.test.ts +3 -3
- package/src/services/http-personas.test.ts +13 -5
- package/src/services/http-personas.ts +10 -3
- package/src/services/index.ts +8 -0
- package/src/services/persona-actor-secret.test.ts +68 -0
- package/src/services/persona-actor-secret.ts +70 -0
- package/src/services/persona-sign-in.ts +20 -2
- package/src/services/typed-secret-service.test.ts +26 -1
- package/src/services/typed-secret-service.ts +4 -1
- package/src/wirings/agent-scorer/agent-scorer.ts +14 -0
- package/src/wirings/gateway/gateway.types.ts +20 -1
- package/src/wirings/persona/index.ts +9 -0
- package/src/wirings/secret/secret.types.ts +8 -0
- 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
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +0 -53
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
+
## 0.12.99
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- ee9da9e: Reading an optional secret that is not set no longer makes `hasSecret` report it as set. `TypedSecretService` caches `undefined` to remember the absence, and the cache probe read that as a value.
|
|
6
|
+
- 7a15c9c: An actor credential is one persona's, not everyone's
|
|
7
|
+
|
|
8
|
+
`SCENARIO_ACTOR_SECRET` was a skeleton key. Anyone holding it could post any
|
|
9
|
+
`actor: true` address to `/auth/sign-in/actor` and get that persona's session —
|
|
10
|
+
including the `admin` persona, which provisioning grants real admin. The browser
|
|
11
|
+
switcher held it too, baked into the dev bundle as `VITE_SCENARIO_ACTOR_SECRET`,
|
|
12
|
+
so "the reviewer can sign in as each kind of user" and "the reviewer's bundle is
|
|
13
|
+
entitled to every persona" were the same fact.
|
|
14
|
+
|
|
15
|
+
It is now a root that credentials derive from, never one that is presented:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
deriveActorSecret(root, email) // HKDF-expanded HMAC-SHA256 over the address
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The endpoint re-derives the expected value for whichever address is signing in
|
|
22
|
+
and compares, so nothing is stored or looked up, a credential minted for one
|
|
23
|
+
persona is refused for every other, and rotating the root invalidates all of
|
|
24
|
+
them at once. The root itself is no longer a valid credential, and a root under
|
|
25
|
+
32 characters refuses the endpoint rather than deriving weak credentials from
|
|
26
|
+
it — the server log says why, the client is not told.
|
|
27
|
+
|
|
28
|
+
What that buys, in the places that used to need the whole key:
|
|
29
|
+
|
|
30
|
+
- **`pikku dev`** mints one credential per declared persona into
|
|
31
|
+
`VITE_DEV_ACTOR_SECRETS` and no longer writes `VITE_SCENARIO_ACTOR_SECRET` at
|
|
32
|
+
all. The root stays on the server.
|
|
33
|
+
- **`pikku persona secret <id>`** mints them for anything else, and a run given
|
|
34
|
+
`PIKKU_PERSONA_SECRETS=id=secret,…` can sign in as those personas and no
|
|
35
|
+
others — asking for one outside the list throws naming the persona instead of
|
|
36
|
+
falling back to the root.
|
|
37
|
+
|
|
38
|
+
`useDevActors()` and `<DevActorSwitcher />` take `secrets` (one per address)
|
|
39
|
+
where they took `secret`, and an actor with no credential is no longer offered
|
|
40
|
+
rather than rendering a row that 401s. `HttpPersonasConfig.secret` and the
|
|
41
|
+
Playwright provider's `secret` additionally accept a resolver, which is how a
|
|
42
|
+
partially-credentialled run is expressed.
|
|
43
|
+
|
|
44
|
+
- ee9da9e: the surface gate measures the surface it actually ships
|
|
45
|
+
|
|
46
|
+
The doc-quality gate went in with ceilings of 112, 823 and 10 beside a surface
|
|
47
|
+
that measured 160, 1210 and 15, so it never passed on any build. Re-baselined to
|
|
48
|
+
the real measurements, and the key-documentation floor earned its way from 76%
|
|
49
|
+
to 79% by documenting what a caller has to put in `defineSecret`, the gateway
|
|
50
|
+
message shapes, and the scorer and judge configs.
|
|
51
|
+
|
|
52
|
+
## 0.12.98
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- 80eb5c0: Remove the `addMiddleware` alias of `addTagMiddleware`.
|
|
57
|
+
|
|
58
|
+
The CLI inspector decides what registers tag middleware by matching the call's
|
|
59
|
+
identifier text, so `addMiddleware(...)` compiled, exported and registered
|
|
60
|
+
nothing — no error, no warning, and the middleware simply never ran. The name
|
|
61
|
+
was also the one the concept-mapping skill taught.
|
|
62
|
+
|
|
63
|
+
`addTagMiddleware` is the newer name and the scope-matched sibling of
|
|
64
|
+
`addGlobalMiddleware`; the alias was reintroduced after the rename that
|
|
65
|
+
established that pair.
|
|
66
|
+
|
|
67
|
+
- 2252016: Decide whether a virtual-user run is against production from the configured
|
|
68
|
+
environment rather than `NODE_ENV`.
|
|
69
|
+
|
|
70
|
+
A deployment whose staging is a production mirror runs `NODE_ENV=production`
|
|
71
|
+
there too, so the old check refused every disposition on the one environment
|
|
72
|
+
they exist to be used on. `startVirtualUserRun` now takes the `environments`
|
|
73
|
+
generated beside the personas and the environment this process is (`PIKKU_ENV`
|
|
74
|
+
by default), which is the same signal `personaEnvironmentRefusal` already
|
|
75
|
+
checks at sign-in; the generated scaffold passes them. An environment that
|
|
76
|
+
cannot be resolved is treated as production. `NODE_ENV` remains the answer for
|
|
77
|
+
a project that configures no environments at all.
|
|
78
|
+
|
|
1
79
|
## 0.12.97
|
|
2
80
|
|
|
3
81
|
### 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';
|
|
@@ -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,
|
|
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';
|
package/dist/middleware/index.js
CHANGED
|
@@ -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,
|
|
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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ScenarioPersona, ResolvedPersona, ScenarioPersonas, ScenarioInvokeOptions, ScenarioHttpResponse } from './personas-service.js';
|
|
2
2
|
import type { ConverseOptions, ActorFlowVerdict } from '../wirings/actor-flow/actor-flow.types.js';
|
|
3
|
-
import { type OperatorSignInOptions } from './persona-sign-in.js';
|
|
3
|
+
import { type ActorSecretResolver, type OperatorSignInOptions } from './persona-sign-in.js';
|
|
4
4
|
export interface HttpPersonasConfig {
|
|
5
5
|
/**
|
|
6
6
|
* Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
|
|
@@ -10,13 +10,19 @@ export interface HttpPersonasConfig {
|
|
|
10
10
|
*/
|
|
11
11
|
apiUrl: string;
|
|
12
12
|
/**
|
|
13
|
-
* The
|
|
14
|
-
*
|
|
13
|
+
* The ROOT actor secret, from which each persona's own credential is derived
|
|
14
|
+
* and bound to their address. Sign-in only ever works for user rows flagged
|
|
15
|
+
* `actor: true`, and a derived credential only ever works for the one address
|
|
16
|
+
* it was derived for.
|
|
17
|
+
*
|
|
18
|
+
* Pass an {@link ActorSecretResolver} instead to drive personas whose
|
|
19
|
+
* credentials were minted elsewhere — a caller entitled to one persona then
|
|
20
|
+
* never holds the root.
|
|
15
21
|
*
|
|
16
22
|
* The local-development credential. A deployed stage has none, and passes
|
|
17
23
|
* {@link HttpPersonasConfig.operator} instead.
|
|
18
24
|
*/
|
|
19
|
-
secret?: string;
|
|
25
|
+
secret?: string | ActorSecretResolver;
|
|
20
26
|
/**
|
|
21
27
|
* Fabric operator credentials, for signing personas into a DEPLOYED stage.
|
|
22
28
|
*
|
package/dist/services/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type { JWTService } from './jwt-service.js';
|
|
|
23
23
|
export type { EmailService, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
|
|
24
24
|
export { renderEmail, type EmailAssets, type EmailTemplateAssets, type EmailTemplateHashes, type RenderEmailRequest, type RenderedEmailResult, } from './email-template.js';
|
|
25
25
|
export { DEFAULT_WEBHOOK_RETRIES, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, type SendWebhookInput, type SendWebhookResult, type WebhookAttemptResult, type WebhookDeliveryRecord, type WebhookDeliveryWithAttempts, type WebhookJobData, type WebhookServiceConfig, } from './webhook-service.js';
|
|
26
|
+
export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from './persona-actor-secret.js';
|
|
26
27
|
export type { Logger } from './logger.js';
|
|
27
28
|
export type { SecretService, SecretValues } from './secret-service.js';
|
|
28
29
|
export type { VariablesService } from './variables-service.js';
|
package/dist/services/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export { LocalGatewayService } from './local-gateway-service.js';
|
|
|
19
19
|
export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary, } from './file-scenario-run-store.js';
|
|
20
20
|
export { renderEmail, } from './email-template.js';
|
|
21
21
|
export { DEFAULT_WEBHOOK_RETRIES, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, } from './webhook-service.js';
|
|
22
|
+
export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from './persona-actor-secret.js';
|
|
22
23
|
export { SchedulerService } from './scheduler-service.js';
|
|
23
24
|
export { TypedCredentialService } from './typed-credential-service.js';
|
|
24
25
|
export { NoopAuditService, createInvocationAudit } from './audit-service.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The name the root secret is held under, used only in error messages. */
|
|
2
|
+
export declare const ACTOR_SECRET_NAME = "SCENARIO_ACTOR_SECRET";
|
|
3
|
+
/**
|
|
4
|
+
* Namespaces the derivation so the same root secret used for anything else
|
|
5
|
+
* produces different values. See knowledge/crypto.md.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ACTOR_SECRET_INFO = "pikku:actor-sign-in";
|
|
8
|
+
/** The root must be strong: every persona's credential is derived from it. */
|
|
9
|
+
export declare const ACTOR_ROOT_SECRET_MIN_LENGTH = 32;
|
|
10
|
+
/**
|
|
11
|
+
* What the derivation is bound to. Lowercased because the sign-in endpoint
|
|
12
|
+
* looks the user up by lowercased address, and a credential that verified
|
|
13
|
+
* against a different string than the row it opens is a credential for nothing.
|
|
14
|
+
*/
|
|
15
|
+
export declare const actorSecretSubject: (email: string) => string;
|
|
16
|
+
/**
|
|
17
|
+
* One persona's actor credential: `HMAC-SHA256(root, email)`, base64url.
|
|
18
|
+
*
|
|
19
|
+
* The root secret is not itself a valid credential and never travels: what a
|
|
20
|
+
* scenario run, a CI job or a virtual user is handed is the derived value for
|
|
21
|
+
* the one address it is entitled to. Presenting it for any other address fails,
|
|
22
|
+
* so a leaked credential is worth exactly one synthetic account rather than the
|
|
23
|
+
* whole actor population.
|
|
24
|
+
*
|
|
25
|
+
* Deterministic, so nothing is stored and nothing is provisioned — the target
|
|
26
|
+
* re-derives the expected value from the address being signed in as. Rotating
|
|
27
|
+
* the root invalidates every derived credential at once, which is the property
|
|
28
|
+
* a per-persona secret table would have to implement by hand.
|
|
29
|
+
*/
|
|
30
|
+
export declare const deriveActorSecret: (rootSecret: string, email: string) => Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* Whether `presented` is the credential for `email` under `rootSecret`.
|
|
33
|
+
*
|
|
34
|
+
* False — never throws — for a malformed, truncated or mismatched value, and
|
|
35
|
+
* the comparison is WebCrypto's own HMAC verify, so it does not exit early on
|
|
36
|
+
* the first differing byte.
|
|
37
|
+
*/
|
|
38
|
+
export declare const verifyActorSecret: (rootSecret: string, email: string, presented: string) => Promise<boolean>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { MIN_KEY_MATERIAL_LENGTH, signWithKeyMaterial, verifyWithKeyMaterial, } from '../crypto-utils.js';
|
|
2
|
+
/** The name the root secret is held under, used only in error messages. */
|
|
3
|
+
export const ACTOR_SECRET_NAME = 'SCENARIO_ACTOR_SECRET';
|
|
4
|
+
/**
|
|
5
|
+
* Namespaces the derivation so the same root secret used for anything else
|
|
6
|
+
* produces different values. See knowledge/crypto.md.
|
|
7
|
+
*/
|
|
8
|
+
export const ACTOR_SECRET_INFO = 'pikku:actor-sign-in';
|
|
9
|
+
/** The root must be strong: every persona's credential is derived from it. */
|
|
10
|
+
export const ACTOR_ROOT_SECRET_MIN_LENGTH = MIN_KEY_MATERIAL_LENGTH;
|
|
11
|
+
/**
|
|
12
|
+
* What the derivation is bound to. Lowercased because the sign-in endpoint
|
|
13
|
+
* looks the user up by lowercased address, and a credential that verified
|
|
14
|
+
* against a different string than the row it opens is a credential for nothing.
|
|
15
|
+
*/
|
|
16
|
+
export const actorSecretSubject = (email) => email.trim().toLowerCase();
|
|
17
|
+
/**
|
|
18
|
+
* One persona's actor credential: `HMAC-SHA256(root, email)`, base64url.
|
|
19
|
+
*
|
|
20
|
+
* The root secret is not itself a valid credential and never travels: what a
|
|
21
|
+
* scenario run, a CI job or a virtual user is handed is the derived value for
|
|
22
|
+
* the one address it is entitled to. Presenting it for any other address fails,
|
|
23
|
+
* so a leaked credential is worth exactly one synthetic account rather than the
|
|
24
|
+
* whole actor population.
|
|
25
|
+
*
|
|
26
|
+
* Deterministic, so nothing is stored and nothing is provisioned — the target
|
|
27
|
+
* re-derives the expected value from the address being signed in as. Rotating
|
|
28
|
+
* the root invalidates every derived credential at once, which is the property
|
|
29
|
+
* a per-persona secret table would have to implement by hand.
|
|
30
|
+
*/
|
|
31
|
+
export const deriveActorSecret = async (rootSecret, email) => signWithKeyMaterial(ACTOR_SECRET_NAME, rootSecret, ACTOR_SECRET_INFO, actorSecretSubject(email));
|
|
32
|
+
/**
|
|
33
|
+
* Whether `presented` is the credential for `email` under `rootSecret`.
|
|
34
|
+
*
|
|
35
|
+
* False — never throws — for a malformed, truncated or mismatched value, and
|
|
36
|
+
* the comparison is WebCrypto's own HMAC verify, so it does not exit early on
|
|
37
|
+
* the first differing byte.
|
|
38
|
+
*/
|
|
39
|
+
export const verifyActorSecret = async (rootSecret, email, presented) => verifyWithKeyMaterial(ACTOR_SECRET_NAME, rootSecret, ACTOR_SECRET_INFO, actorSecretSubject(email), presented);
|
|
@@ -26,6 +26,11 @@ export interface PersonaSignIn {
|
|
|
26
26
|
/** Headers every request after `login` must carry. */
|
|
27
27
|
headers(): Record<string, string>;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Yields the credential for one persona, for a caller that holds that persona's
|
|
31
|
+
* derived secret and not the root it came from.
|
|
32
|
+
*/
|
|
33
|
+
export type ActorSecretResolver = (persona: ResolvedPersona) => string | Promise<string>;
|
|
29
34
|
/**
|
|
30
35
|
* Sign a persona in through the Better Auth actor plugin — the local-development
|
|
31
36
|
* path.
|
|
@@ -34,12 +39,17 @@ export interface PersonaSignIn {
|
|
|
34
39
|
* for it. Passwordless by design and refused for any row not carrying that flag,
|
|
35
40
|
* so the secret can never reach a real user's account; the plugin still declines
|
|
36
41
|
* to serve the endpoint at all outside `pikku dev`.
|
|
42
|
+
*
|
|
43
|
+
* What is presented is the persona's own credential, derived from the root and
|
|
44
|
+
* bound to their address. A run driving many personas holds the root and
|
|
45
|
+
* derives as it goes; a run entitled to one persona is handed that one value
|
|
46
|
+
* through a resolver and can sign in as nobody else.
|
|
37
47
|
*/
|
|
38
48
|
export declare class ActorSignIn implements PersonaSignIn {
|
|
39
49
|
private readonly apiUrl;
|
|
40
50
|
private readonly secret;
|
|
41
51
|
private readonly signInPath;
|
|
42
|
-
constructor(apiUrl: string, secret: string, signInPath: string);
|
|
52
|
+
constructor(apiUrl: string, secret: string | ActorSecretResolver, signInPath: string);
|
|
43
53
|
login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>;
|
|
44
54
|
headers(): Record<string, string>;
|
|
45
55
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deriveActorSecret } from './persona-actor-secret.js';
|
|
1
2
|
/**
|
|
2
3
|
* The header `resolveImpersonatedSession` reads the target user id from.
|
|
3
4
|
*
|
|
@@ -18,6 +19,11 @@ const failed = async (what, personaId, res) => {
|
|
|
18
19
|
* for it. Passwordless by design and refused for any row not carrying that flag,
|
|
19
20
|
* so the secret can never reach a real user's account; the plugin still declines
|
|
20
21
|
* to serve the endpoint at all outside `pikku dev`.
|
|
22
|
+
*
|
|
23
|
+
* What is presented is the persona's own credential, derived from the root and
|
|
24
|
+
* bound to their address. A run driving many personas holds the root and
|
|
25
|
+
* derives as it goes; a run entitled to one persona is handed that one value
|
|
26
|
+
* through a resolver and can sign in as nobody else.
|
|
21
27
|
*/
|
|
22
28
|
export class ActorSignIn {
|
|
23
29
|
apiUrl;
|
|
@@ -29,13 +35,16 @@ export class ActorSignIn {
|
|
|
29
35
|
this.signInPath = signInPath;
|
|
30
36
|
}
|
|
31
37
|
async login(jar, persona) {
|
|
38
|
+
const secret = typeof this.secret === 'function'
|
|
39
|
+
? await this.secret(persona)
|
|
40
|
+
: await deriveActorSecret(this.secret, persona.email);
|
|
32
41
|
const res = await jar.fetch(`${this.apiUrl}${this.signInPath}`, {
|
|
33
42
|
method: 'POST',
|
|
34
43
|
headers: { 'content-type': 'application/json' },
|
|
35
44
|
body: JSON.stringify({
|
|
36
45
|
email: persona.email,
|
|
37
46
|
name: persona.name,
|
|
38
|
-
secret
|
|
47
|
+
secret,
|
|
39
48
|
}),
|
|
40
49
|
});
|
|
41
50
|
if (!res.ok) {
|
|
@@ -21,8 +21,11 @@ export class TypedSecretService {
|
|
|
21
21
|
return value;
|
|
22
22
|
}
|
|
23
23
|
async hasSecret(key) {
|
|
24
|
+
// `undefined` is cached for an optional secret that resolved absent, so a
|
|
25
|
+
// cache hit means "already looked", not "there is a value". Reporting true
|
|
26
|
+
// for it would let a read of an optional secret assert its own presence.
|
|
24
27
|
if (this.cache.has(key)) {
|
|
25
|
-
return
|
|
28
|
+
return this.cache.get(key) !== undefined;
|
|
26
29
|
}
|
|
27
30
|
return this.secrets.hasSecret(key);
|
|
28
31
|
}
|