@homeflare/alchemy 0.12.0 → 0.13.0
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/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +278 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +57 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +8 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +681 -183
- package/dist/proxmox/index.js.map +14 -4
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +4 -3
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopted-deploys.md +8 -0
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/proxmox.md +3 -0
- package/package.json +1 -1
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/generated/pbs.ts +329 -0
- package/src/proxmox/generated/pve.ts +62 -0
- package/src/proxmox/index.test.ts +3 -0
- package/src/proxmox/index.ts +18 -0
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/resource.ts +6 -6
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* write-only.ts — resolving `{ fromEnv }` and the seal a plan compares instead of a value.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE SEAL IS THE WHOLE CHANGE-DETECTION STORY FOR A SECRET THE SERVER WILL NOT RETURN, so each
|
|
5
|
+
* property it relies on is pinned: the same values verify, a different value does not, key order
|
|
6
|
+
* does not matter, and nothing malformed ever reads as a match.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { resolveAll, seal, sealMatches } from './write-only.ts';
|
|
10
|
+
|
|
11
|
+
describe('resolveAll', () => {
|
|
12
|
+
test('resolves by variable name and reports every missing or empty one, by name', () => {
|
|
13
|
+
const env = { HOOK: 's3cret', EMPTY: '' };
|
|
14
|
+
const got = resolveAll(
|
|
15
|
+
{ a: { fromEnv: 'HOOK' }, b: { fromEnv: 'EMPTY' }, c: { fromEnv: 'UNSET' } },
|
|
16
|
+
env,
|
|
17
|
+
);
|
|
18
|
+
expect(got.values).toEqual({ a: 's3cret' });
|
|
19
|
+
expect(got.missing).toEqual(['EMPTY', 'UNSET']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('does not trim: the value sent is the value in the environment', () => {
|
|
23
|
+
expect(resolveAll({ a: { fromEnv: 'X' } }, { X: ' v\n' }).values).toEqual({ a: ' v\n' });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('seal', () => {
|
|
28
|
+
const values = { 'secret:token': 'correct horse', password: 'battery staple' };
|
|
29
|
+
|
|
30
|
+
test('verifies the values it was made from, and no others', () => {
|
|
31
|
+
const sealed = seal(values);
|
|
32
|
+
expect(sealed).toStartWith('scrypt:');
|
|
33
|
+
expect(sealMatches(sealed, values)).toBe(true);
|
|
34
|
+
expect(sealMatches(sealed, { ...values, password: 'battery staplf' })).toBe(false);
|
|
35
|
+
expect(sealMatches(sealed, { 'secret:token': 'correct horse' })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('never contains a value, raw or base64', () => {
|
|
39
|
+
const sealed = seal(values);
|
|
40
|
+
for (const value of Object.values(values)) {
|
|
41
|
+
expect(sealed).not.toContain(value);
|
|
42
|
+
expect(sealed).not.toContain(Buffer.from(value).toString('base64'));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('key order is not a change', () => {
|
|
47
|
+
const sealed = seal({ a: '1', b: '2' });
|
|
48
|
+
expect(sealMatches(sealed, { b: '2', a: '1' })).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a random salt by default: sealing twice gives two seals that both verify', () => {
|
|
52
|
+
const one = seal(values);
|
|
53
|
+
const two = seal(values);
|
|
54
|
+
expect(one).not.toBe(two);
|
|
55
|
+
expect(sealMatches(two, values)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a fixed salt is deterministic — what a header digest needs to not churn state', () => {
|
|
59
|
+
expect(seal(values, 'fixed')).toBe(seal(values, 'fixed'));
|
|
60
|
+
expect(seal(values, 'fixed')).not.toBe(seal(values, 'other'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('nothing to seal is the empty string, and only nothing matches it', () => {
|
|
64
|
+
expect(seal({})).toBe('');
|
|
65
|
+
expect(sealMatches('', {})).toBe(true);
|
|
66
|
+
expect(sealMatches('', values)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a malformed seal is never a match', () => {
|
|
70
|
+
const [, salt, digest] = seal(values).split(':');
|
|
71
|
+
for (const bad of [`md5:${salt}:${digest}`, `scrypt:${salt}`, `scrypt:${salt}:${digest}:x`]) {
|
|
72
|
+
expect(sealMatches(bad, values)).toBe(false);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Values a declaration may SEND and must never STORE — a webhook secret, a header carrying a
|
|
3
|
+
* credential, an smtp password — and the one form in which a plan may still remember them.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ A SECRET PROP IS A SECRET IN THE STATE STORE, WHATEVER TYPE IT HAS. Alchemy persists props as
|
|
6
|
+
* well as attributes — `delete` is handed `olds`, which can only have come from the store — and
|
|
7
|
+
* it does not encrypt either: `StateEncoding.ts` TAGS a `Redacted` value and writes the inner
|
|
8
|
+
* value beside the tag. So `Redacted` hides a secret from a log line and from nothing else.
|
|
9
|
+
* notification-target.ts (PVE) answered that by making no secret declarable at all, and paid
|
|
10
|
+
* for it: an authenticated smtp target or a signed webhook could not be created from code.
|
|
11
|
+
* ★ SO THE PROP HOLDS A NAME, NOT A VALUE. `{ fromEnv: 'PBS_HOOK_TOKEN' }` names the variable the
|
|
12
|
+
* DEPLOYING process reads at call time — the shape forgejo/org-actions-secrets.ts already uses.
|
|
13
|
+
* The name is what lands in state; the value exists only in that process's memory and on the
|
|
14
|
+
* wire to the server.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ A PLAN STILL NEEDS TO KNOW WHETHER A VALUE CHANGED, AND IT CANNOT ASK THE SERVER. PBS returns a
|
|
17
|
+
* webhook secret's NAME and never its value, and never returns an smtp password at all. So what
|
|
18
|
+
* was last written is remembered as a SEAL: a salted scrypt digest, which lets the next plan
|
|
19
|
+
* check "is the value in my environment the one I wrote" without the store holding anything a
|
|
20
|
+
* reader could send to PBS.
|
|
21
|
+
* ⚠️ A DIGEST OF A GUESSABLE SECRET IS STILL GUESSABLE. The salt defeats precomputed tables and
|
|
22
|
+
* scrypt makes each guess cost memory and time; neither makes a dictionary word safe. The
|
|
23
|
+
* remedy is a secret worth the name — a random token — not a faster hash.
|
|
24
|
+
*/
|
|
25
|
+
import { Buffer } from 'node:buffer';
|
|
26
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
/** A write-only value, by the NAME of the environment variable holding it. Never the value. */
|
|
29
|
+
export interface FromEnv {
|
|
30
|
+
readonly fromEnv: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where a value is looked up. Defaults to `process.env`, read at call time, never at load. */
|
|
34
|
+
export type Environment = Readonly<Record<string, string | undefined>>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Each `name → FromEnv` resolved, or the variable names that could not be.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ AN EMPTY VARIABLE IS A MISSING ONE. A secret renderer that failed writes `NAME=` rather than
|
|
40
|
+
* omitting the line, and sending `''` would store an empty secret on the server — a webhook that
|
|
41
|
+
* authenticates with nothing, reported as a successful deploy.
|
|
42
|
+
* ⚠️ THE VALUE IS NOT TRIMMED. A token with a trailing newline is a broken header either way, and
|
|
43
|
+
* trimming here would make the seal disagree with a value written by any other tool.
|
|
44
|
+
*/
|
|
45
|
+
export const resolveAll = (
|
|
46
|
+
refs: Readonly<Record<string, FromEnv>>,
|
|
47
|
+
env: Environment = process.env,
|
|
48
|
+
): { readonly values: Readonly<Record<string, string>>; readonly missing: readonly string[] } => {
|
|
49
|
+
const values: Record<string, string> = {};
|
|
50
|
+
const missing: string[] = [];
|
|
51
|
+
for (const [key, ref] of Object.entries(refs)) {
|
|
52
|
+
const value = env[ref.fromEnv];
|
|
53
|
+
if (value === undefined || value === '') missing.push(ref.fromEnv);
|
|
54
|
+
else values[key] = value;
|
|
55
|
+
}
|
|
56
|
+
return { missing: [...new Set(missing)].sort(), values };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ★ PARAMETERS ARE NODE'S OWN SCRYPT DEFAULTS (N 2^14, r 8, p 1) — 16 MiB and tens of
|
|
61
|
+
* milliseconds per digest. Plan runs it a handful of times per resource, which is noise, and a
|
|
62
|
+
* guesser runs it once per guess, which is the point.
|
|
63
|
+
*/
|
|
64
|
+
const COST = { N: 16_384, maxmem: 64 * 1024 * 1024, p: 1, r: 8 } as const;
|
|
65
|
+
const PREFIX = 'scrypt';
|
|
66
|
+
|
|
67
|
+
/** Key order must not change a digest, so entries are sorted and serialised as JSON pairs. */
|
|
68
|
+
const canonical = (values: Readonly<Record<string, string>>): string =>
|
|
69
|
+
JSON.stringify(
|
|
70
|
+
Object.keys(values)
|
|
71
|
+
.sort()
|
|
72
|
+
.map((key) => [key, values[key]]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const digest = (salt: Buffer, values: Readonly<Record<string, string>>): Buffer =>
|
|
76
|
+
scryptSync(canonical(values), salt, 32, COST);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `scrypt:<salt>:<digest>`, or `''` when there is nothing to seal.
|
|
80
|
+
*
|
|
81
|
+
* ★ A RANDOM SALT BY DEFAULT, FOR WHAT THIS PROVIDER WROTE. Pass a fixed one only for a value the
|
|
82
|
+
* server returns anyway (a header), where a fresh salt per read would churn the state row on
|
|
83
|
+
* every plan without protecting anything the server does not already show its auditors.
|
|
84
|
+
*/
|
|
85
|
+
export const seal = (values: Readonly<Record<string, string>>, salt?: string): string => {
|
|
86
|
+
if (Object.keys(values).length === 0) return '';
|
|
87
|
+
const bytes = salt === undefined ? randomBytes(16) : Buffer.from(salt, 'utf8');
|
|
88
|
+
return `${PREFIX}:${bytes.toString('base64url')}:${digest(bytes, values).toString('base64url')}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether `values` are the ones `sealed` was made from.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ A MALFORMED OR EMPTY SEAL ANSWERS FALSE, never throws: callers decide what "no evidence"
|
|
95
|
+
* means, and every caller here treats it as "cannot tell" rather than as drift.
|
|
96
|
+
*/
|
|
97
|
+
export const sealMatches = (sealed: string, values: Readonly<Record<string, string>>): boolean => {
|
|
98
|
+
if (sealed === '') return Object.keys(values).length === 0;
|
|
99
|
+
const [prefix, salt, stored, extra] = sealed.split(':');
|
|
100
|
+
if (prefix !== PREFIX || salt === undefined || stored === undefined || extra !== undefined) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const expected = Buffer.from(stored, 'base64url');
|
|
104
|
+
const actual = digest(Buffer.from(salt, 'base64url'), values);
|
|
105
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
106
|
+
};
|
|
@@ -130,6 +130,11 @@ export interface FakeEngine {
|
|
|
130
130
|
readonly deploy: (body: Body) => Promise<Record<string, string>>;
|
|
131
131
|
/** Verify `body` against what the earlier deploys recorded. */
|
|
132
132
|
readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
|
|
133
|
+
/**
|
|
134
|
+
* Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
|
|
135
|
+
* that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
|
|
136
|
+
*/
|
|
137
|
+
readonly stored: () => string;
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
/**
|
|
@@ -138,7 +143,8 @@ export interface FakeEngine {
|
|
|
138
143
|
* ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY — openbao/fake-stack.ts has the reasoning.
|
|
139
144
|
*/
|
|
140
145
|
export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): FakeEngine => {
|
|
141
|
-
const
|
|
146
|
+
const rows: NonNullable<Parameters<typeof Alchemy.inMemoryState>[0]> = {};
|
|
147
|
+
const state = Alchemy.inMemoryState(rows);
|
|
142
148
|
const stack = Alchemy.Stack as unknown as (
|
|
143
149
|
name: string,
|
|
144
150
|
options: { providers: typeof layer; state: typeof state },
|
|
@@ -161,6 +167,7 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
|
|
|
161
167
|
);
|
|
162
168
|
|
|
163
169
|
return {
|
|
170
|
+
stored: () => JSON.stringify(rows),
|
|
164
171
|
deploy: (body) =>
|
|
165
172
|
run(body, (compiled) =>
|
|
166
173
|
Effect.gen(function* () {
|