@homeflare/alchemy 0.14.0 → 0.15.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 (90) hide show
  1. package/dist/openbao/index.d.ts +10 -3
  2. package/dist/openbao/index.d.ts.map +1 -1
  3. package/dist/proxmox/acl.d.ts.map +1 -1
  4. package/dist/proxmox/backup-job.d.ts.map +1 -1
  5. package/dist/proxmox/constraint-guard.d.ts +28 -0
  6. package/dist/proxmox/constraint-guard.d.ts.map +1 -0
  7. package/dist/proxmox/constraints.d.ts +71 -0
  8. package/dist/proxmox/constraints.d.ts.map +1 -0
  9. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  10. package/dist/proxmox/generated/constraints/index.d.ts +19 -0
  11. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -0
  12. package/dist/proxmox/generated/constraints/pbs-config.d.ts +18 -0
  13. package/dist/proxmox/generated/constraints/pbs-config.d.ts.map +1 -0
  14. package/dist/proxmox/generated/constraints/pve-access.d.ts +18 -0
  15. package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -0
  16. package/dist/proxmox/generated/constraints/pve-cluster.d.ts +18 -0
  17. package/dist/proxmox/generated/constraints/pve-cluster.d.ts.map +1 -0
  18. package/dist/proxmox/generated/constraints/pve-pools.d.ts +18 -0
  19. package/dist/proxmox/generated/constraints/pve-pools.d.ts.map +1 -0
  20. package/dist/proxmox/generated/constraints/pve-storage.d.ts +18 -0
  21. package/dist/proxmox/generated/constraints/pve-storage.d.ts.map +1 -0
  22. package/dist/proxmox/group.d.ts.map +1 -1
  23. package/dist/proxmox/ha-resource.d.ts.map +1 -1
  24. package/dist/proxmox/ha-rule.d.ts.map +1 -1
  25. package/dist/proxmox/index.d.ts +6 -1
  26. package/dist/proxmox/index.d.ts.map +1 -1
  27. package/dist/proxmox/index.js +816 -257
  28. package/dist/proxmox/index.js.map +33 -24
  29. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  30. package/dist/proxmox/pbs-datastore-endpoint.d.ts +22 -0
  31. package/dist/proxmox/pbs-datastore-endpoint.d.ts.map +1 -0
  32. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  33. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  34. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  35. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  36. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  37. package/dist/proxmox/pool.d.ts.map +1 -1
  38. package/dist/proxmox/provision-baseline.d.ts +30 -5
  39. package/dist/proxmox/provision-baseline.d.ts.map +1 -1
  40. package/dist/proxmox/replication-job.d.ts.map +1 -1
  41. package/dist/proxmox/resource-spec.d.ts +120 -0
  42. package/dist/proxmox/resource-spec.d.ts.map +1 -0
  43. package/dist/proxmox/resource.d.ts +10 -91
  44. package/dist/proxmox/resource.d.ts.map +1 -1
  45. package/dist/proxmox/role.d.ts.map +1 -1
  46. package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
  47. package/dist/proxmox/sdn-zone.d.ts.map +1 -1
  48. package/dist/proxmox/storage.d.ts.map +1 -1
  49. package/dist/proxmox/user.d.ts.map +1 -1
  50. package/docs/provision-baseline.md +34 -7
  51. package/docs/proxmox-lxc.md +40 -0
  52. package/package.json +1 -1
  53. package/src/openbao/index-types.test.ts +87 -0
  54. package/src/openbao/index.ts +15 -3
  55. package/src/proxmox/acl.ts +2 -0
  56. package/src/proxmox/backup-job.ts +2 -0
  57. package/src/proxmox/constraint-guard.ts +53 -0
  58. package/src/proxmox/constraints.test.ts +260 -0
  59. package/src/proxmox/constraints.ts +135 -0
  60. package/src/proxmox/firewall-alias.ts +5 -0
  61. package/src/proxmox/generated/constraints/index.ts +31 -0
  62. package/src/proxmox/generated/constraints/pbs-config.ts +141 -0
  63. package/src/proxmox/generated/constraints/pve-access.ts +57 -0
  64. package/src/proxmox/generated/constraints/pve-cluster.ts +174 -0
  65. package/src/proxmox/generated/constraints/pve-pools.ts +26 -0
  66. package/src/proxmox/generated/constraints/pve-storage.ts +75 -0
  67. package/src/proxmox/group.ts +2 -0
  68. package/src/proxmox/ha-resource.ts +5 -0
  69. package/src/proxmox/ha-rule.ts +2 -0
  70. package/src/proxmox/index.ts +12 -0
  71. package/src/proxmox/lxc-interior.test.ts +61 -0
  72. package/src/proxmox/notification-matcher.ts +5 -0
  73. package/src/proxmox/pbs-datastore-endpoint.ts +29 -0
  74. package/src/proxmox/pbs-datastore.ts +8 -1
  75. package/src/proxmox/pbs-notification-matcher.ts +5 -0
  76. package/src/proxmox/pbs-prune-job.ts +2 -0
  77. package/src/proxmox/pbs-sync-job-form.ts +2 -0
  78. package/src/proxmox/pbs-verify-job.ts +2 -0
  79. package/src/proxmox/pool.ts +2 -0
  80. package/src/proxmox/provision-baseline.test.ts +38 -0
  81. package/src/proxmox/provision-baseline.ts +73 -14
  82. package/src/proxmox/provision-bootstrap.test.ts +51 -0
  83. package/src/proxmox/replication-job.ts +5 -0
  84. package/src/proxmox/resource-spec.ts +117 -0
  85. package/src/proxmox/resource.ts +50 -91
  86. package/src/proxmox/role.ts +2 -0
  87. package/src/proxmox/sdn-vnet.ts +2 -0
  88. package/src/proxmox/sdn-zone.ts +2 -0
  89. package/src/proxmox/storage.ts +2 -0
  90. package/src/proxmox/user.ts +2 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The barrel's TYPE surface, pinned.
3
+ *
4
+ * ⛔ THIS IS THE TEST FOR A BUG THAT ONLY A CONSUMER COULD SEE. Every `Bao.*` family already
5
+ * re-exported its `…Props` and `…Attributes` from its own module, but `index.ts` forwarded only
6
+ * the VALUES for seven of them. A consumer that may not deep-import — which is every consumer,
7
+ * by the estate's adoption rules — therefore could not name the props of the very Resource the
8
+ * barrel handed it, and had to write the shape out and hope it stayed in step.
9
+ * MEASURED 2026-09-22 in homeflare-openbao, which did exactly that for `BaoProxmoxRole`.
10
+ *
11
+ * ★ A TYPE-ONLY TEST, ON PURPOSE. Nothing here runs; `tsc --noEmit` is the assertion. If a family's
12
+ * props stop being reachable from the package entry, this file stops compiling, which is the
13
+ * only moment anyone would otherwise notice — at the next consumer, in another repo.
14
+ */
15
+ import type {
16
+ BaoAuthMethodAttributes,
17
+ BaoAuthMethodProps,
18
+ BaoAuthRoleAttributes,
19
+ BaoAuthRoleProps,
20
+ BaoCloudflareRoleAttributes,
21
+ BaoCloudflareRoleProps,
22
+ BaoJwtRoleAttributes,
23
+ BaoMountAttributes,
24
+ BaoMountProps,
25
+ BaoPkiRoleAttributes,
26
+ BaoPkiRoleProps,
27
+ BaoPluginAttributes,
28
+ BaoPluginProps,
29
+ BaoPolicyAttributes,
30
+ BaoPolicyProps,
31
+ BaoProxmoxRoleAttributes,
32
+ BaoProxmoxRoleProps,
33
+ BaoSshRoleAttributes,
34
+ BaoSshRoleProps,
35
+ } from './index.ts';
36
+ import { expect, test } from 'bun:test';
37
+
38
+ /**
39
+ * ⛔ The one a consumer was already missing. `mount` + `name` + `mintUser` + `ttl` + `maxTtl` IS the
40
+ * whole role, so a consumer that writes this shape out by hand is writing the entire
41
+ * server-side state of the family — including `mintUser`, which is its security boundary.
42
+ */
43
+ const proxmoxRole: BaoProxmoxRoleProps = {
44
+ maxTtl: '30m',
45
+ mintUser: 'hf-provision@pve',
46
+ mount: 'proxmox-ops',
47
+ name: 'provision',
48
+ ttl: '5m',
49
+ };
50
+
51
+ const mount: BaoMountProps = { path: 'proxmox-ops', type: 'openbao-plugin-secrets-proxmox' };
52
+
53
+ /** ⛔ The plugin catalog key — a consumer needs this to declare a registration at all. */
54
+ const plugin: BaoPluginProps = {
55
+ command: 'openbao-plugin-secrets-proxmox',
56
+ name: 'openbao-plugin-secrets-proxmox',
57
+ sha256: 'a'.repeat(64),
58
+ type: 'secret',
59
+ version: 'v0.0.0-dev',
60
+ };
61
+
62
+ /** ★ Named so the attribute types are reachable too: a consumer reads outputs as well as writing props. */
63
+ type Reachable =
64
+ | BaoAuthMethodAttributes
65
+ | BaoAuthMethodProps
66
+ | BaoAuthRoleAttributes
67
+ | BaoAuthRoleProps
68
+ | BaoCloudflareRoleAttributes
69
+ | BaoCloudflareRoleProps
70
+ | BaoJwtRoleAttributes
71
+ | BaoMountAttributes
72
+ | BaoPkiRoleAttributes
73
+ | BaoPkiRoleProps
74
+ | BaoProxmoxRoleAttributes
75
+ | BaoPluginAttributes
76
+ | BaoPolicyAttributes
77
+ | BaoPolicyProps
78
+ | BaoSshRoleAttributes
79
+ | BaoSshRoleProps;
80
+
81
+ test("every openbao family's props are reachable from the package entry", () => {
82
+ const reachable: Reachable[] = [];
83
+ expect(reachable).toEqual([]);
84
+ expect(proxmoxRole.mintUser).toBe('hf-provision@pve');
85
+ expect(mount.type).toBe('openbao-plugin-secrets-proxmox');
86
+ expect(plugin.version).toBe('v0.0.0-dev');
87
+ });
@@ -23,11 +23,13 @@ export {
23
23
  revokeSelf,
24
24
  revokeSelfEffect,
25
25
  } from './approle-login.ts';
26
+ export type { BaoAuthMethodAttributes, BaoAuthMethodProps } from './auth-method.ts';
26
27
  export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
27
- export type { BaoAuthRoleProps } from './auth-role.ts';
28
+ export type { BaoAuthRoleAttributes, BaoAuthRoleProps } from './auth-role.ts';
28
29
  export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
29
30
  export type { BaoIdentity, BaoIdentityExpected, BaoIdentityFailure } from './bao-identity.ts';
30
31
  export { BaoIdentityError, assertBaoIdentity, assertBaoIdentityEffect } from './bao-identity.ts';
32
+ export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps } from './cloudflare-role.ts';
31
33
  export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
32
34
  export {
33
35
  CloudflarePermissionGroups,
@@ -42,7 +44,12 @@ export type { HostAppRolesInput, HostRoleClass, HostRoleHost } from './host-appr
42
44
  export { HOST_ROLE_SEPARATOR, hostAppRoles, hostRoleName } from './host-approles.ts';
43
45
  export type { BaoJwtAuthConfigProps } from './jwt-config.ts';
44
46
  export { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
45
- export type { BaoJwtRoleProps, BaoJwtRoleType } from './jwt-role.ts';
47
+ export type {
48
+ BaoJwtCallbackMode,
49
+ BaoJwtRoleAttributes,
50
+ BaoJwtRoleProps,
51
+ BaoJwtRoleType,
52
+ } from './jwt-role.ts';
46
53
  export { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
47
54
  export type { BaoKubernetesAliasSource, BaoKubernetesRoleProps } from './kubernetes-role.ts';
48
55
  export { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
@@ -50,10 +57,15 @@ export type { BaoMfaLoginEnforcementProps } from './mfa-enforcement.ts';
50
57
  export { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
51
58
  export type { BaoMfaTotpMethodProps, BaoTotpAlgorithm } from './mfa-totp.ts';
52
59
  export { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
60
+ export type { BaoMountAttributes, BaoMountProps } from './mount.ts';
53
61
  export { BaoMount, BaoMountProvider } from './mount.ts';
62
+ export type { BaoPkiRoleAttributes, BaoPkiRoleProps } from './pki-role.ts';
54
63
  export { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
55
- export type { BaoPluginProps, BaoPluginType } from './plugin.ts';
64
+ export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin.ts';
56
65
  export { BaoPlugin, BaoPluginProvider } from './plugin.ts';
66
+ export type { BaoPolicyAttributes, BaoPolicyProps } from './policy.ts';
57
67
  export { BaoPolicy, BaoPolicyProvider } from './policy.ts';
68
+ export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps } from './proxmox-role.ts';
58
69
  export { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
70
+ export type { BaoSshRoleAttributes, BaoSshRoleProps } from './ssh-role.ts';
59
71
  export { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
@@ -189,6 +189,8 @@ const ops = pveOperations<AclProps, AclAttributes>({
189
189
  * the props they came from is true by construction. An identity change is a DIFFERENT grant,
190
190
  * which `diff` in the provider handles.
191
191
  */
192
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
193
+ endpoint: { create: 'pve:PUT /access/acl', update: 'pve:PUT /access/acl' },
192
194
  matches: (attributes, props) =>
193
195
  attributes.bound && attributes.propagate === (props.propagate !== false),
194
196
  path: () => 'access/acl',
@@ -173,6 +173,8 @@ const handlers = pveHandlers<BackupJobProps, BackupJobAttributes>({
173
173
  /** ⛔ `id` IS SENT AND IS NOT OPTIONAL. Everything above depends on PVE not inventing one. */
174
174
  createForm: (props) => ({ ...shape(props), id: props.id }),
175
175
  /** Each line reads "not declared, or equal"; the five defaulted fields have no undeclared case. */
176
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
177
+ endpoint: { create: 'pve:POST /cluster/backup', update: 'pve:PUT /cluster/backup/{id}' },
176
178
  matches: (attributes, props) =>
177
179
  attributes.schedule === props.schedule &&
178
180
  attributes.enabled === (props.enabled !== false) &&
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The generated tables, looked up by endpoint, and the refusal that stops a plan.
3
+ *
4
+ * ★ ONE FILE BETWEEN THE PROVIDERS AND THE GENERATED DATA, so `resource.ts` gains three lines
5
+ * rather than an import of two generated modules and a merge, and so a family that writes its
6
+ * own handlers (CephPool, PbsDatastore) reaches the same check by the same name.
7
+ *
8
+ * ⛔ AN UNKNOWN KEY IS A DEFECT, NOT A REASON TO SKIP THE CHECK. Silently passing would put us back
9
+ * where this started: a declaration nothing checked, and a plan that says so in no way at all.
10
+ * The generator resolves every key in its source scan against the vendor schema, so a key that
11
+ * is absent here means the tables were not regenerated — which the message says.
12
+ */
13
+ import * as Effect from 'effect/Effect';
14
+ import type { PveForm } from './client.ts';
15
+ import { type EndpointConstraints, type EndpointKey, refusal, violations } from './constraints.ts';
16
+ import { PROXMOX_CONSTRAINTS } from './generated/constraints/index.ts';
17
+
18
+ export { PROXMOX_CONSTRAINTS };
19
+
20
+ export const constraintsFor = (key: EndpointKey): EndpointConstraints => {
21
+ const table = PROXMOX_CONSTRAINTS[key];
22
+ if (table === undefined) {
23
+ throw new Error(
24
+ `no vendor constraint table for '${key}'. It is named in this package but not generated: ` +
25
+ 'run `bun codegen/constraints.ts` (see codegen/README.md).',
26
+ );
27
+ }
28
+ return table;
29
+ };
30
+
31
+ /** Every vendor rule this form breaks. Pure — a test calls it with a literal form. */
32
+ export const formViolations = (
33
+ key: EndpointKey,
34
+ form: PveForm,
35
+ presence: boolean,
36
+ ): readonly string[] => violations(constraintsFor(key), form, { presence });
37
+
38
+ /**
39
+ * ⛔ `Effect.die`, NOT `Effect.fail`. A declaration the vendor would reject is a DEFECT in the
40
+ * stack file, not a recoverable condition: there is no retry and no fallback that makes a
41
+ * 129-character comment legal. The same call that refuses a write-back with no read-back
42
+ * (resource.ts) dies for the same reason.
43
+ */
44
+ export const guardForm = (
45
+ key: EndpointKey | undefined,
46
+ form: PveForm,
47
+ presence: boolean,
48
+ ): Effect.Effect<void> =>
49
+ Effect.suspend(() => {
50
+ if (key === undefined) return Effect.void;
51
+ const found = formViolations(key, form, presence);
52
+ return found.length === 0 ? Effect.void : Effect.die(new Error(refusal(key, found)));
53
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The vendor's own limits, enforced at plan time — built from the failure that made them necessary.
3
+ *
4
+ * 🔴 2026-09-22, `homeflare-proxmox` `deploy:pbs`: ten objects adopted, then
5
+ * `POST config/verify -> 400: parameter verification failed - comment: value may only be 128
6
+ * characters long`. The job was `v-r2-offsite`, the monthly read-back of the R2 copy. Plan,
7
+ * `bun run check`, the family's own tests and `hf-adopt-verify` were all green, because the only
8
+ * place the number 128 existed was PBS's published schema.
9
+ *
10
+ * ★ SO THE FIRST TEST IS THAT JOB, WITH A 129-CHARACTER COMMENT, THROUGH ALCHEMY'S OWN ENGINE, and
11
+ * the assertion that matters is not only the message: it is that `fake.writes()` is EMPTY. The
12
+ * incident was half a deploy landing before the refusal.
13
+ */
14
+ import { describe, expect, test } from 'bun:test';
15
+ import * as Layer from 'effect/Layer';
16
+ import { engineOver } from '../verify/fake-engine.ts';
17
+ import { constraintsFor, formViolations } from './constraint-guard.ts';
18
+ import { type EndpointConstraints, refusal, violations } from './constraints.ts';
19
+ import type { PbsTarget } from './credentials.ts';
20
+ import { fakePve, withoutBao } from './fake-pve.ts';
21
+ import { createForm as datastoreCreateForm } from './pbs-datastore-form.ts';
22
+ import { createBody as pruneCreateBody } from './pbs-prune-job-form.ts';
23
+ import { createBody as syncCreateBody } from './pbs-sync-job-form.ts';
24
+ import { PROXMOX_CONSTRAINTS, PROXMOX_CONSTRAINTS_DIGEST } from './generated/constraints/index.ts';
25
+ import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
26
+
27
+ const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
28
+ const VERIFY = 'pbs:POST /config/verify';
29
+ const BELL = String.fromCodePoint(7);
30
+ const ROCKET = String.fromCodePoint(0x1f680);
31
+
32
+ /** The real declaration, comment length made an argument. */
33
+ const job = (comment: string) =>
34
+ PbsVerifyJob('v-r2-offsite', {
35
+ comment,
36
+ id: 'v-r2-offsite',
37
+ 'ignore-verified': true,
38
+ 'outdated-after': 30,
39
+ schedule: 'Sun *-1..7 11:00',
40
+ store: 'r2-offsite',
41
+ target: PBS,
42
+ });
43
+
44
+ describe('the v-r2-offsite failure, refused at plan instead of by the server', () => {
45
+ const deployWith = async (comment: string) => {
46
+ /**
47
+ * A PBS that does not have this job yet, so the plan is a CREATE — the branch `diff` used to
48
+ * skip entirely (`output === undefined` returned before any check). It remembers the POST so
49
+ * the read-back in `reconcile` can succeed, which is what the 128-character case needs.
50
+ */
51
+ const live = new Map<string, Record<string, unknown>>();
52
+ const fake = fakePve((call) => {
53
+ if (call.method === 'GET') return live.get(call.path);
54
+ if (call.method === 'POST') live.set(`config/verify/${String(call.form['id'])}`, call.form);
55
+ return undefined;
56
+ });
57
+ const engine = engineOver(PbsVerifyJobProvider().pipe(Layer.provideMerge(fake.layer)));
58
+ const outcome = await withoutBao(() =>
59
+ engine.deploy(job(comment)).then(
60
+ () => ({ error: undefined as unknown }),
61
+ (error: unknown) => ({ error }),
62
+ ),
63
+ );
64
+ return { ...outcome, calls: fake.calls, writes: fake.writes() };
65
+ };
66
+
67
+ test('129 characters is refused, and NOTHING is written', async () => {
68
+ const { calls, error, writes } = await deployWith('c'.repeat(129));
69
+ expect(String(error)).toContain('comment: at most 128 characters');
70
+ expect(String(error)).toContain(VERIFY);
71
+ // ⛔ THE ASSERTION THE INCIDENT IS ABOUT. A refusal after the POST is the server's own 400.
72
+ expect(writes).toEqual([]);
73
+ /**
74
+ * ⚠️ ONE CALL IS MADE, AND IT IS A GET. Measured 2026-09-22: Alchemy runs the provider's
75
+ * `read` handler before `diff`, so the plan has already asked PBS whether the job exists by
76
+ * the time the constraint is checked. Every call is a read; nothing is created or changed.
77
+ */
78
+ expect(calls.map((call) => call.method)).toEqual(['GET']);
79
+ });
80
+
81
+ test('128 characters is accepted and the create goes through', async () => {
82
+ const { error, writes } = await deployWith('c'.repeat(128));
83
+ expect(error).toBeUndefined();
84
+ expect(writes).toEqual(['POST config/verify']);
85
+ });
86
+ });
87
+
88
+ describe('the table is the vendor own rule set, read from the generated file', () => {
89
+ test('POST /config/verify carries the 128 and the safe-id rules PBS publishes', () => {
90
+ const table = constraintsFor(VERIFY);
91
+ expect(table['comment']).toMatchObject({ maxLength: 128, type: 'string' });
92
+ expect(table['id']).toMatchObject({ maxLength: 32, minLength: 3, required: true });
93
+ expect(table['max-depth']).toMatchObject({ maximum: 7, minimum: 0 });
94
+ });
95
+
96
+ test('an unknown endpoint is a defect that names the command, not a silent pass', () => {
97
+ expect(() => constraintsFor('pbs:POST /config/nope')).toThrow('bun codegen/constraints.ts');
98
+ });
99
+
100
+ /**
101
+ * ⚠️ PBS's `comment` pattern is a POSIX class inside Rust regex delimiters, neither of which
102
+ * JavaScript reads the same way. `new RegExp` accepts BOTH silently and means something else.
103
+ * This asserts the translation, because a wrong one refuses every legal comment.
104
+ */
105
+ test('the PBS comment pattern is translated, not copied', () => {
106
+ const rule = constraintsFor(VERIFY)['comment'];
107
+ expect(rule?.patternSource).toContain('cntrl');
108
+ expect(formViolations(VERIFY, { comment: 'nightly read-back of the R2 copy' }, false)).toEqual(
109
+ [],
110
+ );
111
+ expect(formViolations(VERIFY, { comment: `bell${BELL}here` }, false)).toEqual([
112
+ `comment: must match ${rule?.patternSource ?? ''}`,
113
+ ]);
114
+ });
115
+
116
+ /**
117
+ * ⚠️ THE SORT IS PART OF THE DIGEST, and it is spelled out here rather than imported from
118
+ * `codegen/emit.ts`: `src/` is published, and a test that reached out of the package would
119
+ * ship a broken import in the tarball. The generator sorts for the same reason it must be
120
+ * sorted here — it merges per area, this module spreads per import.
121
+ */
122
+ test('the committed tables match their own digest, so no value was hand-edited', () => {
123
+ const sorted = Object.fromEntries(
124
+ Object.keys(PROXMOX_CONSTRAINTS)
125
+ .sort()
126
+ .map((key) => [key, PROXMOX_CONSTRAINTS[key]]),
127
+ );
128
+ const recomputed = new Bun.CryptoHasher('sha256')
129
+ .update(JSON.stringify(sorted))
130
+ .digest('hex')
131
+ .slice(0, 16);
132
+ expect(recomputed).toBe(PROXMOX_CONSTRAINTS_DIGEST);
133
+ });
134
+ });
135
+
136
+ /**
137
+ * ★ ONE MUTANT PER BRANCH. Each case below fails if that single comparison is deleted, inverted or
138
+ * its boundary moved by one — which is the only way to know the validator checks what it claims
139
+ * rather than passing everything.
140
+ */
141
+ describe('every rule kind, at and past its boundary', () => {
142
+ const table: EndpointConstraints = {
143
+ depth: { maximum: 7, minimum: 0, type: 'integer' },
144
+ mode: { enum: ['all', 'any'], type: 'string' },
145
+ name: { maxLength: 4, minLength: 2, pattern: '^[a-z]+$', patternSource: '/^[a-z]+$/' },
146
+ store: { required: true, type: 'string' },
147
+ tags: { maxLength: 3, type: 'string' },
148
+ };
149
+ const at = (form: Record<string, string | readonly string[]>, presence = false) =>
150
+ violations(table, form, { presence });
151
+
152
+ test('maxLength: 4 passes, 5 does not', () => {
153
+ expect(at({ name: 'abcd' })).toEqual([]);
154
+ expect(at({ name: 'abcde' })).toEqual(['name: at most 4 characters']);
155
+ });
156
+
157
+ test('minLength: 2 passes, 1 does not', () => {
158
+ expect(at({ name: 'ab' })).toEqual([]);
159
+ expect(at({ name: 'a' })).toEqual(['name: at least 2 characters']);
160
+ });
161
+
162
+ test('minimum and maximum are inclusive on both sides', () => {
163
+ expect(at({ depth: '0' })).toEqual([]);
164
+ expect(at({ depth: '7' })).toEqual([]);
165
+ expect(at({ depth: '-1' })).toEqual(['depth: at least 0']);
166
+ expect(at({ depth: '8' })).toEqual(['depth: at most 7']);
167
+ });
168
+
169
+ test('enum quotes the vendor own members', () => {
170
+ expect(at({ mode: 'any' })).toEqual([]);
171
+ expect(at({ mode: 'ALL' })).toEqual(['mode: must be one of all, any']);
172
+ });
173
+
174
+ test('pattern reports the VENDOR spelling, not the translated one', () => {
175
+ expect(at({ name: 'abc' })).toEqual([]);
176
+ expect(at({ name: 'ab1' })).toEqual(['name: must match /^[a-z]+$/']);
177
+ });
178
+
179
+ test('required is checked only when presence is asked for — an update form is partial', () => {
180
+ expect(at({})).toEqual([]);
181
+ expect(at({}, true)).toEqual(['store: required']);
182
+ expect(at({ store: 'r2' }, true)).toEqual([]);
183
+ });
184
+
185
+ /** ⚠️ Characters, not UTF-16 code units: an astral character is ONE character to Proxmox. */
186
+ test('length counts characters, so an astral character is one', () => {
187
+ expect(at({ tags: `${ROCKET}ab` })).toEqual([]);
188
+ expect(at({ tags: `${ROCKET}abc` })).toEqual(['tags: at most 3 characters']);
189
+ });
190
+
191
+ /** ⚠️ A list is repeated keys on the wire (client.ts), so every element faces the same rule. */
192
+ test('every element of an array value is checked', () => {
193
+ expect(at({ tags: ['ab', 'abcd'] })).toEqual(['tags: at most 3 characters']);
194
+ });
195
+
196
+ test('a key the table does not mention is not an error', () => {
197
+ expect(at({ unlisted: 'anything at all' })).toEqual([]);
198
+ });
199
+
200
+ test('the refusal names the endpoint and points at the generated table', () => {
201
+ expect(refusal(VERIFY, ['comment: at most 128 characters'])).toContain(VERIFY);
202
+ expect(refusal(VERIFY, ['comment: at most 128 characters'])).toContain('generated/constraints');
203
+ });
204
+ });
205
+
206
+ /**
207
+ * ★ THE OTHER THREE PBS FAMILIES, WITH THE ESTATE'S OWN DECLARATIONS, AGAINST THEIR CREATE TABLE.
208
+ * The risk this feature carries is not the one it fixes: a table that refuses a declaration the
209
+ * vendor would have ACCEPTED blocks a deploy that was always legal, and the operator cannot tell
210
+ * that from a genuine violation. These are the jobs `homeflare-proxmox` actually declares
211
+ * (`prune-cluster-all`, `sync-all-to-r2`, the `r2-offsite` datastore), so a false positive in the
212
+ * presence check or a mistranslated pattern fails here rather than on a Sunday morning.
213
+ */
214
+ describe('the live PBS declarations pass their own create tables', () => {
215
+ test('prune, sync and datastore create forms have no violations', () => {
216
+ expect(
217
+ formViolations(
218
+ 'pbs:POST /config/prune',
219
+ pruneCreateBody({
220
+ comment: 'cluster retention',
221
+ id: 'prune-cluster-all',
222
+ 'keep-daily': 7,
223
+ 'keep-last': 3,
224
+ 'keep-monthly': 6,
225
+ 'keep-weekly': 4,
226
+ schedule: 'sat 03:00',
227
+ store: 'cluster',
228
+ target: PBS,
229
+ }),
230
+ true,
231
+ ),
232
+ ).toEqual([]);
233
+ expect(
234
+ formViolations(
235
+ 'pbs:POST /config/sync',
236
+ syncCreateBody({
237
+ comment: 'offsite copy to R2',
238
+ id: 'sync-all-to-r2',
239
+ 'remote-store': 'r2-offsite',
240
+ schedule: 'mon 08:30',
241
+ store: 'cluster',
242
+ target: PBS,
243
+ }),
244
+ true,
245
+ ),
246
+ ).toEqual([]);
247
+ expect(
248
+ formViolations(
249
+ 'pbs:POST /config/datastore',
250
+ datastoreCreateForm({
251
+ comment: 'offsite',
252
+ name: 'r2-offsite',
253
+ path: '/mnt/datastore/r2-offsite',
254
+ target: PBS,
255
+ }),
256
+ true,
257
+ ),
258
+ ).toEqual([]);
259
+ });
260
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The vendor's own rules, checked here, before the request is built.
3
+ *
4
+ * 🔴 THE INCIDENT THIS EXISTS FOR, 2026-09-22. `homeflare-proxmox`'s first `deploy:pbs` adopted ten
5
+ * objects and then failed its one create: `PVE POST config/verify -> 400: parameter verification
6
+ * failed - comment: value may only be 128 characters long`. Half a deploy in, on the server, from
7
+ * a limit PBS publishes in its own schema and the generated `comment?: string` does not carry.
8
+ * Plan was green. `bun run check` was green. `hf-adopt-verify` was green — its offline fake serves
9
+ * a fixture and refuses anything but GET, so a newly declared object's VALUES are never exercised.
10
+ *
11
+ * ★ SO THE TABLE IS GENERATED AND THIS FILE IS THE READER. `generated/constraints/*` is emitted
12
+ * from the cluster's own `apidoc.js` by `bun codegen/constraints.ts`; nothing here is hand-typed,
13
+ * because a hand-typed limit is the tribal knowledge that was already in a doc comment and
14
+ * already did not run.
15
+ *
16
+ * ⛔ IT CHECKS THE FORM, NOT THE PROPS, AND THAT IS THE WHOLE POINT. The form is what goes on the
17
+ * wire: `String(props['max-depth'])`, `'0' | '1'` for a boolean, an omitted key for an undeclared
18
+ * field. A props-level check would pass values the form then mangles, and would have to be
19
+ * written once per family — which is how the copy that forgets a field gets written.
20
+ *
21
+ * ⚠️ WHAT IT DELIBERATELY DOES NOT ENFORCE, so nobody reads a green plan as more than it is:
22
+ * - a `format` NAME (PVE's `pve-calendar-event`, `pve-node`, 216 of them): the name is a
23
+ * validator PVE implements server-side and publishes nothing about. Recorded, never checked.
24
+ * - a pattern the generator could not translate faithfully (codegen/pattern.ts). Recorded
25
+ * verbatim as `patternSource`, never checked.
26
+ * - PBS `schedule`, which carries `typetext: <calendar-event>` and NO pattern and NO maxLength.
27
+ * The schema cannot help there; a malformed schedule still reaches PBS.
28
+ * Each of those is a fact in the table rather than a guess, which is the rule this follows:
29
+ * if the vendor did not state it, it is not enforced and the table says so.
30
+ */
31
+ import type { PveForm } from './client.ts';
32
+
33
+ export interface ParamConstraint {
34
+ readonly type?: string;
35
+ /** From the vendor's `optional`. ⚠️ Checked on CREATE only — see `violations`. */
36
+ readonly required?: boolean;
37
+ readonly maxLength?: number;
38
+ readonly minLength?: number;
39
+ readonly minimum?: number;
40
+ readonly maximum?: number;
41
+ readonly enum?: readonly string[];
42
+ /** A JavaScript-safe translation. Absent when the vendor's dialect could not be carried over. */
43
+ readonly pattern?: string;
44
+ /** The vendor's own spelling, always — what a violation message quotes, and the audit trail. */
45
+ readonly patternSource?: string;
46
+ /** PVE's server-side validator name. Recorded so a reader knows what is NOT checked here. */
47
+ readonly format?: string;
48
+ readonly default?: string;
49
+ }
50
+
51
+ export type EndpointConstraints = Readonly<Record<string, ParamConstraint>>;
52
+
53
+ /** `POST /config/verify` — exactly how the generated tables key an endpoint. */
54
+ export type EndpointKey = string;
55
+
56
+ export interface ViolationOptions {
57
+ /**
58
+ * Check that every vendor-required parameter is present.
59
+ *
60
+ * ⛔ CREATE ONLY. An update form is deliberately PARTIAL — `formToSend` sends the fields that
61
+ * changed and nothing else — so requiring presence there would refuse every ordinary edit.
62
+ */
63
+ readonly presence?: boolean;
64
+ }
65
+
66
+ const lengthOf = (value: string): number => [...value].length;
67
+
68
+ /**
69
+ * ⚠️ `[...value].length`, NOT `value.length`. Proxmox counts characters; JavaScript counts UTF-16
70
+ * code units, so an emoji in a comment counts twice and a 128-character limit would refuse a
71
+ * comment PBS accepts. Rejecting a legal value at plan time is the one failure mode worse than
72
+ * the server 400 this replaces.
73
+ */
74
+ const checkValue = (name: string, value: string, rule: ParamConstraint): string[] => {
75
+ const out: string[] = [];
76
+ if (rule.maxLength !== undefined && lengthOf(value) > rule.maxLength) {
77
+ out.push(`${name}: at most ${rule.maxLength} characters`);
78
+ }
79
+ if (rule.minLength !== undefined && lengthOf(value) < rule.minLength) {
80
+ out.push(`${name}: at least ${rule.minLength} characters`);
81
+ }
82
+ if (rule.enum !== undefined && !rule.enum.includes(value)) {
83
+ out.push(`${name}: must be one of ${rule.enum.join(', ')}`);
84
+ }
85
+ if (rule.pattern !== undefined && !new RegExp(rule.pattern).test(value)) {
86
+ out.push(`${name}: must match ${rule.patternSource ?? rule.pattern}`);
87
+ }
88
+ // ⚠️ NUMBER-NESS IS NOT CHECKED, ONLY THE BOUND. The form is strings by construction and a
89
+ // non-numeric string is a type error upstream; `Number('')` is 0, so a blank would read as in
90
+ // range. Guarding on Number.isFinite keeps a blank from silently passing a minimum of 0.
91
+ const numeric = Number(value);
92
+ const bounded = rule.minimum !== undefined || rule.maximum !== undefined;
93
+ if (bounded && value !== '' && Number.isFinite(numeric)) {
94
+ if (rule.minimum !== undefined && numeric < rule.minimum) {
95
+ out.push(`${name}: at least ${rule.minimum}`);
96
+ }
97
+ if (rule.maximum !== undefined && numeric > rule.maximum) {
98
+ out.push(`${name}: at most ${rule.maximum}`);
99
+ }
100
+ }
101
+ return out;
102
+ };
103
+
104
+ /**
105
+ * Every vendor rule this form breaks, in the order the parameters are declared. Pure: no network,
106
+ * no clock, no environment — so `diff` can call it and a test can call it with a literal.
107
+ *
108
+ * ⚠️ A KEY THE TABLE DOES NOT MENTION IS NOT AN ERROR. The table carries the constrained
109
+ * parameters, not every parameter, and a form may legitimately send an unconstrained one.
110
+ */
111
+ export const violations = (
112
+ table: EndpointConstraints,
113
+ form: PveForm,
114
+ options: ViolationOptions = {},
115
+ ): readonly string[] => {
116
+ const out: string[] = [];
117
+ for (const [name, rule] of Object.entries(table)) {
118
+ const value = form[name];
119
+ if (value === undefined) {
120
+ if (options.presence === true && rule.required === true) out.push(`${name}: required`);
121
+ continue;
122
+ }
123
+ // ⚠️ AN ARRAY IS REPEATED KEYS ON THE WIRE (client.ts), so every element faces the same rule.
124
+ for (const item of typeof value === 'string' ? [value] : value) {
125
+ out.push(...checkValue(name, item, rule));
126
+ }
127
+ }
128
+ return out;
129
+ };
130
+
131
+ /** The message a plan dies with. ⛔ Names the endpoint, because the rule belongs to the vendor. */
132
+ export const refusal = (endpoint: EndpointKey, found: readonly string[]): string =>
133
+ `${endpoint} would be rejected by the vendor:\n - ${found.join('\n - ')}\n` +
134
+ "These are the vendor's own limits, read from its published schema " +
135
+ '(packages/alchemy/src/proxmox/generated/constraints). Fix the declaration.';
@@ -171,6 +171,11 @@ const handlers = pveHandlers<FirewallAliasProps, FirewallAliasAttributes>({
171
171
  * is identity and case-only drift (see the header); `ipversion` is derived and unwritable.
172
172
  * Comparing either would report an update that no update can settle.
173
173
  */
174
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
175
+ endpoint: {
176
+ create: 'pve:POST /cluster/firewall/aliases',
177
+ update: 'pve:PUT /cluster/firewall/aliases/{name}',
178
+ },
174
179
  matches: (attributes, props) =>
175
180
  attributes.cidr === cidr(props.cidr) && attributes.comment === comment(props.comment),
176
181
  /**
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every generated Proxmox constraint table, merged — DO NOT EDIT BY HAND.
3
+ *
4
+ * Run: bun codegen/constraints.ts
5
+ *
6
+ * ⚠️ MERGING IS SAFE ONLY BECAUSE EVERY KEY IS PREFIXED `pve:`/`pbs:`. PVE and PBS both publish
7
+ * `PUT /access/acl` and `PUT /access/password` with DIFFERENT rules; an unprefixed merge would
8
+ * silently enforce one product's limits on the other's objects.
9
+ */
10
+ import type { EndpointConstraints } from '../../constraints.ts';
11
+ import { PVE_ACCESS_CONSTRAINTS } from './pve-access.ts';
12
+ import { PVE_CLUSTER_CONSTRAINTS } from './pve-cluster.ts';
13
+ import { PVE_POOLS_CONSTRAINTS } from './pve-pools.ts';
14
+ import { PVE_STORAGE_CONSTRAINTS } from './pve-storage.ts';
15
+ import { PBS_CONFIG_CONSTRAINTS } from './pbs-config.ts';
16
+
17
+ export const PROXMOX_CONSTRAINTS: Readonly<Record<string, EndpointConstraints>> = {
18
+ ...PVE_ACCESS_CONSTRAINTS,
19
+ ...PVE_CLUSTER_CONSTRAINTS,
20
+ ...PVE_POOLS_CONSTRAINTS,
21
+ ...PVE_STORAGE_CONSTRAINTS,
22
+ ...PBS_CONFIG_CONSTRAINTS,
23
+ };
24
+
25
+ /**
26
+ * sha256 of the merged table, truncated.
27
+ *
28
+ * ★ A DIGEST OF THE DATA, NOT OF THE FILE TEXT, so reformatting is not a stale generation while
29
+ * changing a 128 to a 129 by hand is. `tests/schema-manifest.test.ts` recomputes it.
30
+ */
31
+ export const PROXMOX_CONSTRAINTS_DIGEST = '03f403e0c6b3cf7d';