@homeflare/alchemy 0.12.0 → 0.14.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.
Files changed (88) hide show
  1. package/README.md +7 -0
  2. package/dist/github/index.d.ts +18 -0
  3. package/dist/github/index.d.ts.map +1 -0
  4. package/dist/github/index.js +161 -0
  5. package/dist/github/index.js.map +12 -0
  6. package/dist/github/repo-policy-form.d.ts +86 -0
  7. package/dist/github/repo-policy-form.d.ts.map +1 -0
  8. package/dist/github/repo-policy-guards.d.ts +72 -0
  9. package/dist/github/repo-policy-guards.d.ts.map +1 -0
  10. package/dist/github/repo-policy.d.ts +21 -0
  11. package/dist/github/repo-policy.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/proxmox/alertmanager-body.d.ts +54 -0
  15. package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
  16. package/dist/proxmox/credential-literals.d.ts +36 -0
  17. package/dist/proxmox/credential-literals.d.ts.map +1 -0
  18. package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
  19. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
  20. package/dist/proxmox/fake-pve.d.ts +5 -0
  21. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  22. package/dist/proxmox/generated/pbs.d.ts +278 -0
  23. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  24. package/dist/proxmox/generated/pve.d.ts +57 -0
  25. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  26. package/dist/proxmox/index.d.ts +8 -0
  27. package/dist/proxmox/index.d.ts.map +1 -1
  28. package/dist/proxmox/index.js +681 -183
  29. package/dist/proxmox/index.js.map +14 -4
  30. package/dist/proxmox/notification-matcher-form.d.ts +100 -0
  31. package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
  32. package/dist/proxmox/notification-matcher.d.ts +46 -0
  33. package/dist/proxmox/notification-matcher.d.ts.map +1 -0
  34. package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
  35. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
  36. package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
  37. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
  38. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
  39. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
  40. package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
  41. package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
  42. package/dist/proxmox/pbs-notification-target.d.ts +123 -0
  43. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
  44. package/dist/proxmox/resource.d.ts +4 -3
  45. package/dist/proxmox/resource.d.ts.map +1 -1
  46. package/dist/proxmox/write-only.d.ts +35 -0
  47. package/dist/proxmox/write-only.d.ts.map +1 -0
  48. package/dist/verify/fake-engine.d.ts +5 -0
  49. package/dist/verify/fake-engine.d.ts.map +1 -1
  50. package/docs/adopted-deploys.md +8 -0
  51. package/docs/pbs-alertmanager-body.md +73 -0
  52. package/docs/pbs-notifications.md +163 -0
  53. package/docs/proxmox.md +3 -0
  54. package/docs/repo-policy-ruleset-hazards.md +65 -0
  55. package/docs/repo-policy.md +155 -0
  56. package/package.json +5 -1
  57. package/src/github/index.ts +23 -0
  58. package/src/github/repo-policy-form.test.ts +239 -0
  59. package/src/github/repo-policy-form.ts +173 -0
  60. package/src/github/repo-policy-guards.ts +179 -0
  61. package/src/github/repo-policy.test.ts +58 -0
  62. package/src/github/repo-policy.ts +65 -0
  63. package/src/index.ts +1 -0
  64. package/src/proxmox/alertmanager-body.ts +125 -0
  65. package/src/proxmox/credential-literals.test.ts +148 -0
  66. package/src/proxmox/credential-literals.ts +74 -0
  67. package/src/proxmox/fake-pbs-notify.ts +108 -0
  68. package/src/proxmox/fake-pve.ts +12 -2
  69. package/src/proxmox/generated/pbs.ts +329 -0
  70. package/src/proxmox/generated/pve.ts +62 -0
  71. package/src/proxmox/index.test.ts +3 -0
  72. package/src/proxmox/index.ts +18 -0
  73. package/src/proxmox/notification-matcher-form.ts +221 -0
  74. package/src/proxmox/notification-matcher.test.ts +234 -0
  75. package/src/proxmox/notification-matcher.ts +76 -0
  76. package/src/proxmox/pbs-notification-matcher.ts +81 -0
  77. package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
  78. package/src/proxmox/pbs-notification-target-form.ts +229 -0
  79. package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
  80. package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
  81. package/src/proxmox/pbs-notification-target-wire.ts +112 -0
  82. package/src/proxmox/pbs-notification-target.test.ts +226 -0
  83. package/src/proxmox/pbs-notification-target.ts +139 -0
  84. package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
  85. package/src/proxmox/resource.ts +6 -6
  86. package/src/proxmox/write-only.test.ts +75 -0
  87. package/src/proxmox/write-only.ts +106 -0
  88. 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 state = Alchemy.inMemoryState();
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* () {