@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.
Files changed (66) hide show
  1. package/dist/proxmox/alertmanager-body.d.ts +54 -0
  2. package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
  3. package/dist/proxmox/credential-literals.d.ts +36 -0
  4. package/dist/proxmox/credential-literals.d.ts.map +1 -0
  5. package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
  6. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
  7. package/dist/proxmox/fake-pve.d.ts +5 -0
  8. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  9. package/dist/proxmox/generated/pbs.d.ts +278 -0
  10. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  11. package/dist/proxmox/generated/pve.d.ts +57 -0
  12. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  13. package/dist/proxmox/index.d.ts +8 -0
  14. package/dist/proxmox/index.d.ts.map +1 -1
  15. package/dist/proxmox/index.js +681 -183
  16. package/dist/proxmox/index.js.map +14 -4
  17. package/dist/proxmox/notification-matcher-form.d.ts +100 -0
  18. package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
  19. package/dist/proxmox/notification-matcher.d.ts +46 -0
  20. package/dist/proxmox/notification-matcher.d.ts.map +1 -0
  21. package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
  22. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
  23. package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
  24. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
  25. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
  26. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
  27. package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
  28. package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
  29. package/dist/proxmox/pbs-notification-target.d.ts +123 -0
  30. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
  31. package/dist/proxmox/resource.d.ts +4 -3
  32. package/dist/proxmox/resource.d.ts.map +1 -1
  33. package/dist/proxmox/write-only.d.ts +35 -0
  34. package/dist/proxmox/write-only.d.ts.map +1 -0
  35. package/dist/verify/fake-engine.d.ts +5 -0
  36. package/dist/verify/fake-engine.d.ts.map +1 -1
  37. package/docs/adopted-deploys.md +8 -0
  38. package/docs/pbs-alertmanager-body.md +73 -0
  39. package/docs/pbs-notifications.md +163 -0
  40. package/docs/proxmox.md +3 -0
  41. package/package.json +1 -1
  42. package/src/proxmox/alertmanager-body.ts +125 -0
  43. package/src/proxmox/credential-literals.test.ts +148 -0
  44. package/src/proxmox/credential-literals.ts +74 -0
  45. package/src/proxmox/fake-pbs-notify.ts +108 -0
  46. package/src/proxmox/fake-pve.ts +12 -2
  47. package/src/proxmox/generated/pbs.ts +329 -0
  48. package/src/proxmox/generated/pve.ts +62 -0
  49. package/src/proxmox/index.test.ts +3 -0
  50. package/src/proxmox/index.ts +18 -0
  51. package/src/proxmox/notification-matcher-form.ts +221 -0
  52. package/src/proxmox/notification-matcher.test.ts +234 -0
  53. package/src/proxmox/notification-matcher.ts +76 -0
  54. package/src/proxmox/pbs-notification-matcher.ts +81 -0
  55. package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
  56. package/src/proxmox/pbs-notification-target-form.ts +229 -0
  57. package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
  58. package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
  59. package/src/proxmox/pbs-notification-target-wire.ts +112 -0
  60. package/src/proxmox/pbs-notification-target.test.ts +226 -0
  61. package/src/proxmox/pbs-notification-target.ts +139 -0
  62. package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
  63. package/src/proxmox/resource.ts +6 -6
  64. package/src/proxmox/write-only.test.ts +75 -0
  65. package/src/proxmox/write-only.ts +106 -0
  66. package/src/verify/fake-engine.ts +8 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A credential written as a LITERAL in a plain prop — refused, because a plain prop is a value in
3
+ * the state store.
4
+ *
5
+ * ⛔ THE HOLE THIS CLOSES, MEASURED 2026-09-22 (adversarial review of PR 95). write-only.ts keeps
6
+ * every `{ fromEnv }` value out of state, and the doc WARNED that a plain header value is a prop.
7
+ * A warning is not a guard: `header: { Authorization: 'Bearer <token>' }` planned, deployed, and
8
+ * left the token in the dumped state store — and so did `url: 'https://…?token=<token>'`. Alchemy
9
+ * stores props unencrypted (write-only.ts has the StateEncoding reading), so the only safe
10
+ * literal is one that is not a credential.
11
+ *
12
+ * ★ WHAT COUNTS AS A CREDENTIAL IS A NAME, NOT A VALUE. A value cannot be recognised — a token is
13
+ * any string — but where it sits can: an `Authorization`/`Cookie` header, a header or query
14
+ * parameter whose name has a word like `token`, `key`, `secret`, `password` or `signature`, and a
15
+ * password in the URL's userinfo. A false positive costs one edit (declare it `{ fromEnv }`, or
16
+ * hold it in `secret` and write `{{ secrets.<name> }}`); a false negative costs a leaked token.
17
+ * ★ A TEMPLATE THAT READS A SECRET IS NOT A LITERAL. `Bearer {{ secrets.token }}`, and
18
+ * `{{ url-encode secrets.token }}` in a query, render on PBS from the root-only secret store —
19
+ * the prop holds the reference, not the value.
20
+ *
21
+ * ⚠️ WHAT THIS CANNOT SEE: a token in a URL PATH (a Slack-style `/services/T…/B…/<token>`), in the
22
+ * `body`, or in a `comment`. Those are plain props too; put the token in `secret` and reference
23
+ * it from the template. docs/pbs-notifications.md says the same to the reader.
24
+ * ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
25
+ */
26
+ import type { FromEnv } from './write-only.ts';
27
+
28
+ /** `{{ secrets.x }}`, `{{secrets.x}}`, `{{ url-encode secrets.x }}` — a reference, not a value. */
29
+ const READS_SECRET = /\{\{[^}]*\bsecrets\./;
30
+
31
+ /** A whole-name match, or one dash/underscore-separated word of the name. */
32
+ const CREDENTIAL_WORD =
33
+ /(^|[-_])(api[-_]?key|apikey|key|token|secret|password|passwd|pwd|pass|auth|signature|sig|code)($|[-_])/i;
34
+ const CREDENTIAL_HEADER = /^(proxy-authorization|authorization|cookie)$/i;
35
+
36
+ export const isCredentialName = (name: string): boolean =>
37
+ CREDENTIAL_HEADER.test(name) || CREDENTIAL_WORD.test(name);
38
+
39
+ const literal = (value: string): boolean => value !== '' && !READS_SECRET.test(value);
40
+
41
+ /** Plain header values that are credentials by their header's name. `{ fromEnv }` is never one. */
42
+ export const literalCredentialHeaders = (
43
+ header: Readonly<Record<string, FromEnv | string>> | undefined,
44
+ ): string[] =>
45
+ Object.entries(header ?? {})
46
+ .filter(([name, value]) => typeof value === 'string' && isCredentialName(name))
47
+ .filter(([, value]) => literal(value as string))
48
+ .map(([name]) => name)
49
+ .sort();
50
+
51
+ /**
52
+ * Where a URL carries a literal credential: `userinfo` (a password before the `@`), or the names
53
+ * of credential-shaped query (or fragment) parameters. Parsed by hand, not with `new URL`: a URL here is a
54
+ * TEMPLATE, and `{{ … }}` in the host or the userinfo is not a URL `new URL` will accept.
55
+ */
56
+ export const literalCredentialsInUrl = (url: string | undefined): string[] => {
57
+ if (url === undefined) return [];
58
+ const out: string[] = [];
59
+ const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(url)?.[1] ?? '';
60
+ const at = authority.lastIndexOf('@');
61
+ if (at >= 0) {
62
+ const userinfo = authority.slice(0, at);
63
+ const colon = userinfo.indexOf(':');
64
+ if (colon >= 0 && literal(userinfo.slice(colon + 1))) out.push('userinfo');
65
+ }
66
+ // ⚠️ THE FRAGMENT TOO: it is never sent, but it is still a prop — and so still in state.
67
+ const params = url.replace(/^[^?#]*/, '');
68
+ for (const pair of params.split(/[?&#]/)) {
69
+ const eq = pair.indexOf('=');
70
+ const name = eq < 0 ? pair : pair.slice(0, eq);
71
+ if (eq >= 0 && isCredentialName(name) && literal(pair.slice(eq + 1))) out.push(`?${name}`);
72
+ }
73
+ return out;
74
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * A PBS (or PVE) notification config that remembers writes — enough of proxmox-notify's CRUD for
3
+ * a family's REAL provider to create, drift, rotate and adopt through Alchemy's engine.
4
+ *
5
+ * ★ STATEFUL, UNLIKE `fakePve`'s bare answer function, because the questions here are about the
6
+ * SECOND deploy: whether a rotated secret is written, whether an unchanged one is not. That needs
7
+ * a server that holds what the first deploy wrote.
8
+ * ★ IT KEEPS proxmox-notify's TWO RULES THAT MATTER HERE (api/webhook.rs, read at HEAD 2026-09-22):
9
+ * 1. a read returns secret NAMES only; values live in a separate private store;
10
+ * 2. a PUT that carries `secret` REPLACES the list, a name without a value keeping the old
11
+ * one; a PUT without `secret` leaves the list alone. `delete` entries clear a field.
12
+ * Repeated form keys arrive as arrays, as the real urlencoded decoder builds them.
13
+ * ⛔ TEST-ONLY. No provider imports this file.
14
+ */
15
+ import { type FakePve, type PveCall, fakePve } from './fake-pve.ts';
16
+ import { keyAndValue } from './pbs-notification-target-wire.ts';
17
+
18
+ /** Keys the servers take as `type: array`. Everything else is a scalar. */
19
+ const LISTS = new Set([
20
+ 'header',
21
+ 'mailto',
22
+ 'mailto-user',
23
+ 'match-calendar',
24
+ 'match-field',
25
+ 'match-severity',
26
+ 'secret',
27
+ 'target',
28
+ ]);
29
+
30
+ export interface FakeNotify extends FakePve {
31
+ /** Public config by path, as a GET would see it apart from secrets. Mutable, for drift. */
32
+ readonly objects: Map<string, Record<string, unknown>>;
33
+ /** Secret items with values (`name=…,value=<base64>`), by path — the root-only file. */
34
+ readonly secrets: Map<string, string[]>;
35
+ }
36
+
37
+ const body = (call: PveCall) => {
38
+ const fields: Record<string, unknown> = {};
39
+ const deletes: string[] = [];
40
+ for (const [key, value] of call.pairs) {
41
+ if (key === 'delete') deletes.push(value);
42
+ else if (LISTS.has(key))
43
+ fields[key] = [...((fields[key] as string[] | undefined) ?? []), value];
44
+ // ⚠️ Scalars as the server's JSON reads them back: a flag as a boolean, a port as a number.
45
+ else if (key === 'disable' || key === 'invert-match') fields[key] = value === '1';
46
+ else if (key === 'port') fields[key] = Number(value);
47
+ else fields[key] = value;
48
+ }
49
+ return { deletes, fields };
50
+ };
51
+
52
+ const withSecretsBlanked = (
53
+ path: string,
54
+ object: Record<string, unknown>,
55
+ secrets: FakeNotify['secrets'],
56
+ ) => {
57
+ const held = secrets.get(path);
58
+ return held === undefined || held.length === 0
59
+ ? object
60
+ : { ...object, secret: held.map((item) => `name=${keyAndValue(item).name}`) };
61
+ };
62
+
63
+ export const fakeNotify = (
64
+ seed: Readonly<Record<string, Record<string, unknown>>> = {},
65
+ ): FakeNotify => {
66
+ const objects = new Map(Object.entries(seed).map(([path, object]) => [path, { ...object }]));
67
+ const secrets = new Map<string, string[]>();
68
+
69
+ const putSecrets = (path: string, items: readonly string[]) => {
70
+ const old = secrets.get(path) ?? [];
71
+ const next = items.map((item) => {
72
+ const { name, value } = keyAndValue(item);
73
+ if (value !== undefined) return item;
74
+ const kept = old.find((existing) => keyAndValue(existing).name === name);
75
+ if (kept === undefined) throw new Error(`secret '${name}' not known`);
76
+ return kept;
77
+ });
78
+ secrets.set(path, next);
79
+ };
80
+
81
+ const fake = fakePve((call) => {
82
+ const path = call.path.split('?')[0] ?? call.path;
83
+ const { deletes, fields } = body(call);
84
+ if (call.method === 'GET') {
85
+ const object = objects.get(path);
86
+ return object === undefined ? undefined : withSecretsBlanked(path, object, secrets);
87
+ }
88
+ if (call.method === 'DELETE') {
89
+ objects.delete(path);
90
+ secrets.delete(path);
91
+ return undefined;
92
+ }
93
+ const target = call.method === 'POST' ? `${path}/${String(fields['name'])}` : path;
94
+ const { secret, ...rest } = fields as { secret?: string[] } & Record<string, unknown>;
95
+ const next: Record<string, unknown> = {
96
+ ...(call.method === 'PUT' ? objects.get(target) : {}),
97
+ ...rest,
98
+ };
99
+ for (const key of deletes) {
100
+ if (key === 'secret') secrets.delete(target);
101
+ else delete next[key];
102
+ }
103
+ objects.set(target, next);
104
+ if (secret !== undefined) putSecrets(target, secret);
105
+ return undefined;
106
+ });
107
+ return { ...fake, objects, secrets };
108
+ };
@@ -28,6 +28,11 @@ export interface PveCall {
28
28
  /** Relative to `/api2/json/`, query included — the string a family passes to `pve()`. */
29
29
  readonly path: string;
30
30
  readonly form: Readonly<Record<string, string>>;
31
+ /**
32
+ * Every key/value pair in wire order. ⚠️ `form` keeps only the LAST value of a repeated key, and
33
+ * a PBS list IS a repeated key (client.ts `encode`) — assert a list on `pairs`, never `form`.
34
+ */
35
+ readonly pairs: readonly (readonly [string, string])[];
31
36
  }
32
37
 
33
38
  /** Answers one call with its `data`; `undefined` is PVE's `{"data": null}`. */
@@ -56,8 +61,13 @@ export const fakePve = (answer: PveAnswer): FakePve => {
56
61
  return Response.json({ data, lease_duration: 0 });
57
62
  }
58
63
  const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
59
- const form = Object.fromEntries(new URLSearchParams(await request.text()));
60
- const call = { form, method: request.method, path };
64
+ const params = new URLSearchParams(await request.text());
65
+ const call = {
66
+ form: Object.fromEntries(params),
67
+ method: request.method,
68
+ pairs: [...params],
69
+ path,
70
+ };
61
71
  calls.push(call);
62
72
  return Response.json({ data: answer(call) ?? null });
63
73
  };
@@ -141,6 +141,335 @@ export type ConfigDatastoreNameDeleteParams = {
141
141
  /** DELETE /config/datastore/{name} — `data` payload after client unwrap. */
142
142
  export type ConfigDatastoreNameDeleteReturn = string;
143
143
 
144
+ /** GET /config/notifications/endpoints — `data` payload after client unwrap. */
145
+ export type ConfigNotificationsEndpointsGetReturn = null;
146
+
147
+ /** GET /config/notifications/endpoints/gotify — `data` payload after client unwrap. */
148
+ export type ConfigNotificationsEndpointsGotifyGetReturn = readonly {
149
+ comment?: string;
150
+ disable?: boolean | 0 | 1;
151
+ filter?: string;
152
+ name: string;
153
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
154
+ server: string;
155
+ }[];
156
+
157
+ /** POST /config/notifications/endpoints/gotify — form/query parameters (path segments omitted). */
158
+ export type ConfigNotificationsEndpointsGotifyPostParams = {
159
+ comment?: string;
160
+ disable?: '0' | '1';
161
+ filter?: string;
162
+ name: string;
163
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
164
+ server: string;
165
+ token: string;
166
+ };
167
+ /** POST /config/notifications/endpoints/gotify — `data` payload after client unwrap. */
168
+ export type ConfigNotificationsEndpointsGotifyPostReturn = null;
169
+
170
+ /** GET /config/notifications/endpoints/gotify/{name} — `data` payload after client unwrap. */
171
+ export type ConfigNotificationsEndpointsGotifyNameGetReturn = {
172
+ comment?: string;
173
+ disable?: boolean | 0 | 1;
174
+ filter?: string;
175
+ name: string;
176
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
177
+ server: string;
178
+ };
179
+
180
+ /** PUT /config/notifications/endpoints/gotify/{name} — form/query parameters (path segments omitted). */
181
+ export type ConfigNotificationsEndpointsGotifyNamePutParams = {
182
+ comment?: string;
183
+ delete?: readonly ('comment' | 'disable')[];
184
+ digest?: string;
185
+ disable?: '0' | '1';
186
+ server?: string;
187
+ token?: string;
188
+ };
189
+ /** PUT /config/notifications/endpoints/gotify/{name} — `data` payload after client unwrap. */
190
+ export type ConfigNotificationsEndpointsGotifyNamePutReturn = null;
191
+
192
+ /** DELETE /config/notifications/endpoints/gotify/{name} — `data` payload after client unwrap. */
193
+ export type ConfigNotificationsEndpointsGotifyNameDeleteReturn = null;
194
+
195
+ /** GET /config/notifications/endpoints/sendmail — `data` payload after client unwrap. */
196
+ export type ConfigNotificationsEndpointsSendmailGetReturn = readonly {
197
+ author?: string;
198
+ comment?: string;
199
+ disable?: boolean | 0 | 1;
200
+ filter?: string;
201
+ 'from-address'?: string;
202
+ mailto?: readonly string[];
203
+ 'mailto-user'?: readonly string[];
204
+ name: string;
205
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
206
+ }[];
207
+
208
+ /** POST /config/notifications/endpoints/sendmail — form/query parameters (path segments omitted). */
209
+ export type ConfigNotificationsEndpointsSendmailPostParams = {
210
+ author?: string;
211
+ comment?: string;
212
+ disable?: '0' | '1';
213
+ filter?: string;
214
+ 'from-address'?: string;
215
+ mailto?: readonly string[];
216
+ 'mailto-user'?: readonly string[];
217
+ name: string;
218
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
219
+ };
220
+ /** POST /config/notifications/endpoints/sendmail — `data` payload after client unwrap. */
221
+ export type ConfigNotificationsEndpointsSendmailPostReturn = null;
222
+
223
+ /** GET /config/notifications/endpoints/sendmail/{name} — `data` payload after client unwrap. */
224
+ export type ConfigNotificationsEndpointsSendmailNameGetReturn = {
225
+ author?: string;
226
+ comment?: string;
227
+ disable?: boolean | 0 | 1;
228
+ filter?: string;
229
+ 'from-address'?: string;
230
+ mailto?: readonly string[];
231
+ 'mailto-user'?: readonly string[];
232
+ name: string;
233
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
234
+ };
235
+
236
+ /** PUT /config/notifications/endpoints/sendmail/{name} — form/query parameters (path segments omitted). */
237
+ export type ConfigNotificationsEndpointsSendmailNamePutParams = {
238
+ author?: string;
239
+ comment?: string;
240
+ delete?: readonly (
241
+ | 'author'
242
+ | 'comment'
243
+ | 'disable'
244
+ | 'from-address'
245
+ | 'mailto'
246
+ | 'mailto-user'
247
+ )[];
248
+ digest?: string;
249
+ disable?: '0' | '1';
250
+ 'from-address'?: string;
251
+ mailto?: readonly string[];
252
+ 'mailto-user'?: readonly string[];
253
+ };
254
+ /** PUT /config/notifications/endpoints/sendmail/{name} — `data` payload after client unwrap. */
255
+ export type ConfigNotificationsEndpointsSendmailNamePutReturn = null;
256
+
257
+ /** DELETE /config/notifications/endpoints/sendmail/{name} — `data` payload after client unwrap. */
258
+ export type ConfigNotificationsEndpointsSendmailNameDeleteReturn = null;
259
+
260
+ /** GET /config/notifications/endpoints/smtp — `data` payload after client unwrap. */
261
+ export type ConfigNotificationsEndpointsSmtpGetReturn = readonly {
262
+ author?: string;
263
+ comment?: string;
264
+ disable?: boolean | 0 | 1;
265
+ 'from-address': string;
266
+ mailto?: readonly string[];
267
+ 'mailto-user'?: readonly string[];
268
+ mode?: 'insecure' | 'starttls' | 'tls';
269
+ name: string;
270
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
271
+ port?: number;
272
+ server: string;
273
+ username?: string;
274
+ }[];
275
+
276
+ /** POST /config/notifications/endpoints/smtp — form/query parameters (path segments omitted). */
277
+ export type ConfigNotificationsEndpointsSmtpPostParams = {
278
+ author?: string;
279
+ comment?: string;
280
+ disable?: '0' | '1';
281
+ 'from-address': string;
282
+ mailto?: readonly string[];
283
+ 'mailto-user'?: readonly string[];
284
+ mode?: 'insecure' | 'starttls' | 'tls';
285
+ name: string;
286
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
287
+ password?: string;
288
+ port?: string;
289
+ server: string;
290
+ username?: string;
291
+ };
292
+ /** POST /config/notifications/endpoints/smtp — `data` payload after client unwrap. */
293
+ export type ConfigNotificationsEndpointsSmtpPostReturn = null;
294
+
295
+ /** GET /config/notifications/endpoints/smtp/{name} — `data` payload after client unwrap. */
296
+ export type ConfigNotificationsEndpointsSmtpNameGetReturn = {
297
+ author?: string;
298
+ comment?: string;
299
+ disable?: boolean | 0 | 1;
300
+ 'from-address': string;
301
+ mailto?: readonly string[];
302
+ 'mailto-user'?: readonly string[];
303
+ mode?: 'insecure' | 'starttls' | 'tls';
304
+ name: string;
305
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
306
+ port?: number;
307
+ server: string;
308
+ username?: string;
309
+ };
310
+
311
+ /** PUT /config/notifications/endpoints/smtp/{name} — form/query parameters (path segments omitted). */
312
+ export type ConfigNotificationsEndpointsSmtpNamePutParams = {
313
+ author?: string;
314
+ comment?: string;
315
+ delete?: readonly (
316
+ | 'author'
317
+ | 'comment'
318
+ | 'disable'
319
+ | 'mailto'
320
+ | 'mailto-user'
321
+ | 'password'
322
+ | 'port'
323
+ | 'username'
324
+ )[];
325
+ digest?: string;
326
+ disable?: '0' | '1';
327
+ 'from-address'?: string;
328
+ mailto?: readonly string[];
329
+ 'mailto-user'?: readonly string[];
330
+ mode?: 'insecure' | 'starttls' | 'tls';
331
+ password?: string;
332
+ port?: string;
333
+ server?: string;
334
+ username?: string;
335
+ };
336
+ /** PUT /config/notifications/endpoints/smtp/{name} — `data` payload after client unwrap. */
337
+ export type ConfigNotificationsEndpointsSmtpNamePutReturn = null;
338
+
339
+ /** DELETE /config/notifications/endpoints/smtp/{name} — `data` payload after client unwrap. */
340
+ export type ConfigNotificationsEndpointsSmtpNameDeleteReturn = null;
341
+
342
+ /** GET /config/notifications/endpoints/webhook — `data` payload after client unwrap. */
343
+ export type ConfigNotificationsEndpointsWebhookGetReturn = readonly {
344
+ body?: string;
345
+ comment?: string;
346
+ disable?: boolean | 0 | 1;
347
+ header?: readonly string[];
348
+ method: 'post' | 'put' | 'get';
349
+ name: string;
350
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
351
+ secret?: readonly string[];
352
+ url: string;
353
+ }[];
354
+
355
+ /** POST /config/notifications/endpoints/webhook — form/query parameters (path segments omitted). */
356
+ export type ConfigNotificationsEndpointsWebhookPostParams = {
357
+ body?: string;
358
+ comment?: string;
359
+ disable?: '0' | '1';
360
+ header?: readonly string[];
361
+ method: 'post' | 'put' | 'get';
362
+ name: string;
363
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
364
+ secret?: readonly string[];
365
+ url: string;
366
+ };
367
+ /** POST /config/notifications/endpoints/webhook — `data` payload after client unwrap. */
368
+ export type ConfigNotificationsEndpointsWebhookPostReturn = null;
369
+
370
+ /** GET /config/notifications/endpoints/webhook/{name} — `data` payload after client unwrap. */
371
+ export type ConfigNotificationsEndpointsWebhookNameGetReturn = {
372
+ body?: string;
373
+ comment?: string;
374
+ disable?: boolean | 0 | 1;
375
+ header?: readonly string[];
376
+ method: 'post' | 'put' | 'get';
377
+ name: string;
378
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
379
+ secret?: readonly string[];
380
+ url: string;
381
+ };
382
+
383
+ /** PUT /config/notifications/endpoints/webhook/{name} — form/query parameters (path segments omitted). */
384
+ export type ConfigNotificationsEndpointsWebhookNamePutParams = {
385
+ body?: string;
386
+ comment?: string;
387
+ delete?: readonly ('comment' | 'disable' | 'header' | 'body' | 'secret')[];
388
+ digest?: string;
389
+ disable?: '0' | '1';
390
+ header?: readonly string[];
391
+ method?: 'post' | 'put' | 'get';
392
+ secret?: readonly string[];
393
+ url?: string;
394
+ };
395
+ /** PUT /config/notifications/endpoints/webhook/{name} — `data` payload after client unwrap. */
396
+ export type ConfigNotificationsEndpointsWebhookNamePutReturn = null;
397
+
398
+ /** DELETE /config/notifications/endpoints/webhook/{name} — `data` payload after client unwrap. */
399
+ export type ConfigNotificationsEndpointsWebhookNameDeleteReturn = null;
400
+
401
+ /** GET /config/notifications/matchers — `data` payload after client unwrap. */
402
+ export type ConfigNotificationsMatchersGetReturn = readonly {
403
+ comment?: string;
404
+ disable?: boolean | 0 | 1;
405
+ 'invert-match'?: boolean | 0 | 1;
406
+ 'match-calendar'?: readonly string[];
407
+ 'match-field'?: readonly string[];
408
+ 'match-severity'?: readonly string[];
409
+ mode?: 'all' | 'any';
410
+ name: string;
411
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
412
+ target?: readonly string[];
413
+ }[];
414
+
415
+ /** POST /config/notifications/matchers — form/query parameters (path segments omitted). */
416
+ export type ConfigNotificationsMatchersPostParams = {
417
+ comment?: string;
418
+ disable?: '0' | '1';
419
+ 'invert-match'?: '0' | '1';
420
+ 'match-calendar'?: readonly string[];
421
+ 'match-field'?: readonly string[];
422
+ 'match-severity'?: readonly string[];
423
+ mode?: 'all' | 'any';
424
+ name: string;
425
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
426
+ target?: readonly string[];
427
+ };
428
+ /** POST /config/notifications/matchers — `data` payload after client unwrap. */
429
+ export type ConfigNotificationsMatchersPostReturn = null;
430
+
431
+ /** GET /config/notifications/matchers/{name} — `data` payload after client unwrap. */
432
+ export type ConfigNotificationsMatchersNameGetReturn = {
433
+ comment?: string;
434
+ disable?: boolean | 0 | 1;
435
+ 'invert-match'?: boolean | 0 | 1;
436
+ 'match-calendar'?: readonly string[];
437
+ 'match-field'?: readonly string[];
438
+ 'match-severity'?: readonly string[];
439
+ mode?: 'all' | 'any';
440
+ name: string;
441
+ origin?: 'user-created' | 'builtin' | 'modified-builtin';
442
+ target?: readonly string[];
443
+ };
444
+
445
+ /** PUT /config/notifications/matchers/{name} — form/query parameters (path segments omitted). */
446
+ export type ConfigNotificationsMatchersNamePutParams = {
447
+ comment?: string;
448
+ delete?: readonly (
449
+ | 'comment'
450
+ | 'disable'
451
+ | 'invert-match'
452
+ | 'match-calendar'
453
+ | 'match-field'
454
+ | 'match-severity'
455
+ | 'mode'
456
+ | 'target'
457
+ )[];
458
+ digest?: string;
459
+ disable?: '0' | '1';
460
+ 'invert-match'?: '0' | '1';
461
+ 'match-calendar'?: readonly string[];
462
+ 'match-field'?: readonly string[];
463
+ 'match-severity'?: readonly string[];
464
+ mode?: 'all' | 'any';
465
+ target?: readonly string[];
466
+ };
467
+ /** PUT /config/notifications/matchers/{name} — `data` payload after client unwrap. */
468
+ export type ConfigNotificationsMatchersNamePutReturn = null;
469
+
470
+ /** DELETE /config/notifications/matchers/{name} — `data` payload after client unwrap. */
471
+ export type ConfigNotificationsMatchersNameDeleteReturn = null;
472
+
144
473
  /** GET /config/prune — `data` payload after client unwrap. */
145
474
  export type ConfigPruneGetReturn = readonly {
146
475
  comment?: string;
@@ -375,6 +375,68 @@ export type ClusterNotificationsEndpointsWebhookNamePutReturn = null;
375
375
  /** DELETE /cluster/notifications/endpoints/webhook/{name} — `data` payload after client unwrap. */
376
376
  export type ClusterNotificationsEndpointsWebhookNameDeleteReturn = null;
377
377
 
378
+ /** GET /cluster/notifications/matchers — `data` payload after client unwrap. */
379
+ export type ClusterNotificationsMatchersGetReturn = readonly ({
380
+ comment?: string;
381
+ disable?: boolean | 0 | 1;
382
+ 'invert-match'?: boolean | 0 | 1;
383
+ 'match-calendar'?: readonly string[];
384
+ 'match-field'?: readonly string[];
385
+ 'match-severity'?: readonly string[];
386
+ mode?: 'all' | 'any';
387
+ name: string;
388
+ origin: 'user-created' | 'builtin' | 'modified-builtin';
389
+ target?: readonly string[];
390
+ } & Record<string, unknown>)[];
391
+
392
+ /** POST /cluster/notifications/matchers — form/query parameters (path segments omitted). */
393
+ export type ClusterNotificationsMatchersPostParams = {
394
+ comment?: string;
395
+ disable?: '0' | '1';
396
+ 'invert-match'?: '0' | '1';
397
+ 'match-calendar'?: readonly string[];
398
+ 'match-field'?: readonly string[];
399
+ 'match-severity'?: readonly string[];
400
+ mode?: 'all' | 'any';
401
+ name: string;
402
+ target?: readonly string[];
403
+ };
404
+ /** POST /cluster/notifications/matchers — `data` payload after client unwrap. */
405
+ export type ClusterNotificationsMatchersPostReturn = null;
406
+
407
+ /** GET /cluster/notifications/matchers/{name} — `data` payload after client unwrap. */
408
+ export type ClusterNotificationsMatchersNameGetReturn = {
409
+ comment?: string;
410
+ digest?: string;
411
+ disable?: boolean | 0 | 1;
412
+ 'invert-match'?: boolean | 0 | 1;
413
+ 'match-calendar'?: readonly string[];
414
+ 'match-field'?: readonly string[];
415
+ 'match-severity'?: readonly string[];
416
+ mode?: 'all' | 'any';
417
+ name: string;
418
+ target?: readonly string[];
419
+ } & Record<string, unknown>;
420
+
421
+ /** PUT /cluster/notifications/matchers/{name} — form/query parameters (path segments omitted). */
422
+ export type ClusterNotificationsMatchersNamePutParams = {
423
+ comment?: string;
424
+ delete?: readonly string[];
425
+ digest?: string;
426
+ disable?: '0' | '1';
427
+ 'invert-match'?: '0' | '1';
428
+ 'match-calendar'?: readonly string[];
429
+ 'match-field'?: readonly string[];
430
+ 'match-severity'?: readonly string[];
431
+ mode?: 'all' | 'any';
432
+ target?: readonly string[];
433
+ };
434
+ /** PUT /cluster/notifications/matchers/{name} — `data` payload after client unwrap. */
435
+ export type ClusterNotificationsMatchersNamePutReturn = null;
436
+
437
+ /** DELETE /cluster/notifications/matchers/{name} — `data` payload after client unwrap. */
438
+ export type ClusterNotificationsMatchersNameDeleteReturn = null;
439
+
378
440
  /** GET /cluster/firewall/aliases — `data` payload after client unwrap. */
379
441
  export type ClusterFirewallAliasesGetReturn = readonly ({
380
442
  cidr: string;
@@ -12,6 +12,8 @@ const src = await Bun.file(new URL('./index.ts', import.meta.url)).text();
12
12
 
13
13
  const resourceExports = [
14
14
  'PbsDatastore',
15
+ 'PbsNotificationMatcher',
16
+ 'PbsNotificationTarget',
15
17
  'PbsPruneJob',
16
18
  'PbsSyncJob',
17
19
  'PbsVerifyJob',
@@ -22,6 +24,7 @@ const resourceExports = [
22
24
  'ProxmoxHaRule',
23
25
  'ProxmoxLxc',
24
26
  'ProxmoxMetricServer',
27
+ 'ProxmoxNotificationMatcher',
25
28
  'ProxmoxNotificationTarget',
26
29
  'ProxmoxRole',
27
30
  'ProxmoxSdnApply',
@@ -15,10 +15,14 @@
15
15
  * ★ THE PROVISIONING BASELINE JOINED 2026-09-21: one privilege list (`PROVISION_PRIVILEGES`), the
16
16
  * helper that declares it (`declareProvisionBaseline`) and the pure generator of the one-time
17
17
  * root commands that bootstrap it (`provisionBootstrap`) — docs/provision-baseline.md.
18
+ * ★ THE NOTIFICATION FAMILIES JOINED 2026-09-22: PBS targets and matchers, the PVE matcher, the
19
+ * `FromEnv` shape their write-only values take, and the Alertmanager body template
20
+ * (docs/pbs-notifications.md; the template alone: docs/pbs-alertmanager-body.md).
18
21
  * ★ Anything else unlisted is still reachable by path if you genuinely need
19
22
  * it — that is a deliberate, visible act rather than an accident of barrelling.
20
23
  */
21
24
  export { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
25
+ export { type AlertmanagerBodyOptions, alertmanagerAlertBody } from './alertmanager-body.ts';
22
26
  export { ProxmoxApiTokenProvider } from './api-token.ts';
23
27
  export { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
24
28
  export { ProxmoxCephDaemonProvider } from './ceph-daemon.ts';
@@ -34,11 +38,24 @@ export { type LxcAttributes, type LxcProps, ProxmoxLxc, ProxmoxLxcProvider } fro
34
38
  export { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
35
39
  export { ProxmoxNetworkApplyProvider } from './network-apply.ts';
36
40
  export { ProxmoxNodeNetworkProvider } from './node-network.ts';
41
+ export {
42
+ ProxmoxNotificationMatcher,
43
+ ProxmoxNotificationMatcherProvider,
44
+ } from './notification-matcher.ts';
37
45
  export {
38
46
  ProxmoxNotificationTarget,
39
47
  ProxmoxNotificationTargetProvider,
40
48
  } from './notification-target.ts';
41
49
  export { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
50
+ export {
51
+ PbsNotificationMatcher,
52
+ PbsNotificationMatcherProvider,
53
+ } from './pbs-notification-matcher.ts';
54
+ export {
55
+ PbsNotificationTarget,
56
+ PbsNotificationTargetProvider,
57
+ type PbsNotificationTargetProps,
58
+ } from './pbs-notification-target.ts';
42
59
  export { PbsPruneJob, PbsPruneJobProvider } from './pbs-prune-job.ts';
43
60
  export { PbsSyncJob, PbsSyncJobProvider } from './pbs-sync-job.ts';
44
61
  export { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
@@ -63,4 +80,5 @@ export { ProxmoxSdnVnet, ProxmoxSdnVnetProvider } from './sdn-vnet.ts';
63
80
  export { ProxmoxSdnZone, ProxmoxSdnZoneProvider } from './sdn-zone.ts';
64
81
  export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
65
82
  export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
83
+ export { type FromEnv } from './write-only.ts';
66
84
  export { ProxmoxZfsPoolProvider } from './zfs-pool.ts';