@homeflare/alchemy 0.31.1 → 0.32.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 (166) hide show
  1. package/README.md +44 -7
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/linux/container-form.d.ts +129 -0
  5. package/dist/linux/container-form.d.ts.map +1 -0
  6. package/dist/linux/container-generator.d.ts +74 -0
  7. package/dist/linux/container-generator.d.ts.map +1 -0
  8. package/dist/linux/container-handlers.d.ts +15 -0
  9. package/dist/linux/container-handlers.d.ts.map +1 -0
  10. package/dist/linux/container-lifecycle.d.ts +37 -0
  11. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  12. package/dist/linux/container-preflight.d.ts +34 -0
  13. package/dist/linux/container-preflight.d.ts.map +1 -0
  14. package/dist/linux/container-secrets.d.ts +37 -0
  15. package/dist/linux/container-secrets.d.ts.map +1 -0
  16. package/dist/linux/container-settle.d.ts +34 -0
  17. package/dist/linux/container-settle.d.ts.map +1 -0
  18. package/dist/linux/container.d.ts +30 -0
  19. package/dist/linux/container.d.ts.map +1 -0
  20. package/dist/linux/fake-quadlet.d.ts +25 -0
  21. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  22. package/dist/linux/index.d.ts +6 -3
  23. package/dist/linux/index.d.ts.map +1 -1
  24. package/dist/linux/index.js +641 -268
  25. package/dist/linux/index.js.map +15 -7
  26. package/dist/linux/providers.d.ts +1 -1
  27. package/dist/linux/providers.d.ts.map +1 -1
  28. package/dist/linux/systemctl.d.ts +7 -1
  29. package/dist/linux/systemctl.d.ts.map +1 -1
  30. package/dist/opnsense/alias-form.d.ts +29 -0
  31. package/dist/opnsense/alias-form.d.ts.map +1 -0
  32. package/dist/opnsense/alias.d.ts +81 -0
  33. package/dist/opnsense/alias.d.ts.map +1 -0
  34. package/dist/opnsense/category-form.d.ts +10 -0
  35. package/dist/opnsense/category-form.d.ts.map +1 -0
  36. package/dist/opnsense/category.d.ts +58 -0
  37. package/dist/opnsense/category.d.ts.map +1 -0
  38. package/dist/opnsense/credentials.d.ts +36 -0
  39. package/dist/opnsense/credentials.d.ts.map +1 -0
  40. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  41. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  42. package/dist/opnsense/group-form.d.ts +11 -0
  43. package/dist/opnsense/group-form.d.ts.map +1 -0
  44. package/dist/opnsense/group.d.ts +62 -0
  45. package/dist/opnsense/group.d.ts.map +1 -0
  46. package/dist/opnsense/index.d.ts +16 -0
  47. package/dist/opnsense/index.d.ts.map +1 -0
  48. package/dist/opnsense/index.js +211 -0
  49. package/dist/opnsense/index.js.map +20 -0
  50. package/dist/opnsense/policy.d.ts +20 -0
  51. package/dist/opnsense/policy.d.ts.map +1 -0
  52. package/dist/opnsense/providers.d.ts +13 -0
  53. package/dist/opnsense/providers.d.ts.map +1 -0
  54. package/dist/opnsense/resource.d.ts +74 -0
  55. package/dist/opnsense/resource.d.ts.map +1 -0
  56. package/dist/opnsense/wire.d.ts +39 -0
  57. package/dist/opnsense/wire.d.ts.map +1 -0
  58. package/dist/proxmox/acl.d.ts.map +1 -1
  59. package/dist/proxmox/api-token.d.ts.map +1 -1
  60. package/dist/proxmox/group-wire.d.ts +44 -5
  61. package/dist/proxmox/group-wire.d.ts.map +1 -1
  62. package/dist/proxmox/group.d.ts.map +1 -1
  63. package/dist/proxmox/index.js +326 -264
  64. package/dist/proxmox/index.js.map +13 -11
  65. package/dist/proxmox/role.d.ts.map +1 -1
  66. package/dist/proxmox/storage-form.d.ts +54 -0
  67. package/dist/proxmox/storage-form.d.ts.map +1 -0
  68. package/dist/proxmox/storage-wire.d.ts +56 -0
  69. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  70. package/dist/proxmox/storage.d.ts +55 -60
  71. package/dist/proxmox/storage.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +34 -5
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/dist/proxmox/user.d.ts +6 -6
  75. package/dist/proxmox/user.d.ts.map +1 -1
  76. package/dist/unifi/fake-unifi.d.ts +48 -0
  77. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  78. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  79. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  80. package/dist/unifi/firewall-zone.d.ts +50 -0
  81. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  82. package/dist/unifi/index.d.ts +13 -0
  83. package/dist/unifi/index.d.ts.map +1 -0
  84. package/dist/unifi/index.js +190 -0
  85. package/dist/unifi/index.js.map +16 -0
  86. package/dist/unifi/network-form.d.ts +67 -0
  87. package/dist/unifi/network-form.d.ts.map +1 -0
  88. package/dist/unifi/network.d.ts +67 -0
  89. package/dist/unifi/network.d.ts.map +1 -0
  90. package/dist/unifi/policy.d.ts +20 -0
  91. package/dist/unifi/policy.d.ts.map +1 -0
  92. package/dist/unifi/providers.d.ts +13 -0
  93. package/dist/unifi/providers.d.ts.map +1 -0
  94. package/dist/unifi/resource.d.ts +64 -0
  95. package/dist/unifi/resource.d.ts.map +1 -0
  96. package/dist/verify/fake-engine.d.ts +14 -0
  97. package/dist/verify/fake-engine.d.ts.map +1 -1
  98. package/docs/grafana.md +47 -17
  99. package/docs/opnsense.md +146 -0
  100. package/docs/provider-standard.md +1 -1
  101. package/docs/quadlet-container.md +132 -0
  102. package/docs/unifi.md +93 -0
  103. package/docs/upstream-conformance.md +14 -4
  104. package/package.json +13 -3
  105. package/src/grafana/index.ts +6 -5
  106. package/src/linux/container-fixture.test.ts +92 -0
  107. package/src/linux/container-form.ts +243 -0
  108. package/src/linux/container-generator.test.ts +195 -0
  109. package/src/linux/container-generator.ts +113 -0
  110. package/src/linux/container-handlers.ts +73 -0
  111. package/src/linux/container-lifecycle.test.ts +209 -0
  112. package/src/linux/container-lifecycle.ts +217 -0
  113. package/src/linux/container-preflight.ts +97 -0
  114. package/src/linux/container-secrets.test.ts +171 -0
  115. package/src/linux/container-secrets.ts +112 -0
  116. package/src/linux/container-settle.ts +97 -0
  117. package/src/linux/container.ts +60 -0
  118. package/src/linux/fake-quadlet.ts +127 -0
  119. package/src/linux/index.ts +11 -3
  120. package/src/linux/providers.ts +2 -0
  121. package/src/linux/systemctl.ts +9 -0
  122. package/src/opnsense/alias-form.test.ts +89 -0
  123. package/src/opnsense/alias-form.ts +53 -0
  124. package/src/opnsense/alias.test.ts +203 -0
  125. package/src/opnsense/alias.ts +117 -0
  126. package/src/opnsense/category-form.test.ts +54 -0
  127. package/src/opnsense/category-form.ts +20 -0
  128. package/src/opnsense/category.test.ts +138 -0
  129. package/src/opnsense/category.ts +82 -0
  130. package/src/opnsense/credentials.ts +41 -0
  131. package/src/opnsense/fake-opnsense.ts +79 -0
  132. package/src/opnsense/group-form.test.ts +75 -0
  133. package/src/opnsense/group-form.ts +25 -0
  134. package/src/opnsense/group.test.ts +141 -0
  135. package/src/opnsense/group.ts +78 -0
  136. package/src/opnsense/index.ts +45 -0
  137. package/src/opnsense/policy.ts +49 -0
  138. package/src/opnsense/providers.ts +21 -0
  139. package/src/opnsense/resource.ts +146 -0
  140. package/src/opnsense/wire.ts +56 -0
  141. package/src/opnsense/write-refusal.test.ts +148 -0
  142. package/src/proxmox/acl.ts +10 -4
  143. package/src/proxmox/api-token.ts +20 -12
  144. package/src/proxmox/group-wire.ts +62 -8
  145. package/src/proxmox/group.ts +23 -35
  146. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  147. package/src/proxmox/role.ts +9 -2
  148. package/src/proxmox/storage-form.ts +144 -0
  149. package/src/proxmox/storage-read-failure.test.ts +214 -0
  150. package/src/proxmox/storage-wire.ts +124 -0
  151. package/src/proxmox/storage.ts +165 -175
  152. package/src/proxmox/user-wire.ts +44 -8
  153. package/src/proxmox/user.ts +29 -27
  154. package/src/unifi/fake-unifi.ts +60 -0
  155. package/src/unifi/firewall-zone-form.ts +65 -0
  156. package/src/unifi/firewall-zone.test.ts +88 -0
  157. package/src/unifi/firewall-zone.ts +66 -0
  158. package/src/unifi/index.ts +26 -0
  159. package/src/unifi/network-form.ts +207 -0
  160. package/src/unifi/network.test.ts +209 -0
  161. package/src/unifi/network.ts +83 -0
  162. package/src/unifi/policy.ts +37 -0
  163. package/src/unifi/providers.ts +15 -0
  164. package/src/unifi/resource.test.ts +133 -0
  165. package/src/unifi/resource.ts +131 -0
  166. package/src/verify/fake-engine.ts +23 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * One shape for every OPNsense object this family declares — `read`/`diff` mirroring
3
+ * `../discord/resource.ts`'s marker-less-API posture exactly (S7–S9, H1), and `reconcile`/
4
+ * `delete` replaced end to end with the read-only refusal `policy.ts` states.
5
+ *
6
+ * ★ WHY ONE ENGINE FOR THREE RESOURCES. `alias.ts`, `category.ts` and `group.ts` differ only in
7
+ * which `firewall_*` module they call and how a live item maps to attributes — the read/diff/
8
+ * reconcile/delete flow is identical, so it lives here once, the way `netbox/resource.ts` and
9
+ * `discord/resource.ts` already do for their own families.
10
+ *
11
+ * ★ `Unowned(attrs)` ON A COLD READ, PLAIN ATTRIBUTES ON A WARM ONE (S7, S8). None of Alias,
12
+ * Category or Group carries an ownership marker OPNsense itself understands — no tag, no
13
+ * metadata field a stack could stamp — so this is the same "marker-less API" S8 describes, and
14
+ * the same posture `../discord/resource.ts` documents against `Snippet.ts`'s reference read.
15
+ * `adopt(true)` (H5), piped on by each resource file's convenience constructor, is what turns
16
+ * an `Unowned` first read into a silent one-time takeover instead of an `OwnedBySomeoneElse`
17
+ * refusal — exactly what an import script generating `alchemy.run.ts` rows from live reads
18
+ * needs: declare what is live, adopt it, change nothing.
19
+ *
20
+ * ⛔ `reconcile` AND `delete` NEVER CALL AN SDK OPERATION. `reconcile` still OBSERVES first (one
21
+ * GET, via `read`) so the refusal it raises can say whether it would have created or updated —
22
+ * S9's "observe before you write" doctrine, kept for the error message even though there is no
23
+ * write to guard. `delete` observes nothing: it refuses unconditionally, because under this
24
+ * policy a delete never succeeds regardless of whether the object is still live. See policy.ts.
25
+ */
26
+ import { Unowned } from 'alchemy/AdoptPolicy';
27
+ import { isResolved } from 'alchemy/Diff';
28
+ import type { Input } from 'alchemy/Input';
29
+ import type { OpnsenseOpContext } from '@distilled.cloud/opnsense/Protocol';
30
+ import * as Effect from 'effect/Effect';
31
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
32
+ import { CredentialsFromEnv } from './credentials.ts';
33
+ import { refuseWrite } from './policy.ts';
34
+
35
+ /** What every stack must still provide after `opnsenseHandlers` bakes in `CredentialsFromEnv`. */
36
+ export type OpnsenseRequirements = HttpClient.HttpClient;
37
+
38
+ /** Every resource in this family identifies its live object by OPNsense's own assigned uuid. */
39
+ export interface OpnsenseIdentity {
40
+ readonly uuid: string;
41
+ }
42
+
43
+ /**
44
+ * One OPNsense object's read path. `Live` is whatever the generated `get()` response decodes to
45
+ * for one item (an `AliasItem`, `CategoryItem`, `GroupItem`); `E` is left to each resource file
46
+ * to declare — in practice always `OpnsenseOpError`, since every generated operation shares one
47
+ * error channel (protocol.ts).
48
+ */
49
+ export type OpnsenseSpec<Props extends OpnsenseIdentity, Live, Attributes extends object, E> = {
50
+ readonly resourceType: string;
51
+ /** Absent when `props.uuid` is not a key in the live model's map — see each resource file. */
52
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, OpnsenseOpContext>;
53
+ /** Also the declaration renderer (S.T. propsFromLive re-export) — see each `*-form.ts`. */
54
+ readonly attributes: (uuid: string, live: Live) => Attributes;
55
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
56
+ };
57
+
58
+ export const opnsenseOperations = <
59
+ Props extends OpnsenseIdentity,
60
+ Live,
61
+ Attributes extends object,
62
+ E,
63
+ >(
64
+ spec: OpnsenseSpec<Props, Live, Attributes, E>,
65
+ ) => {
66
+ const read = (props: Props) =>
67
+ spec
68
+ .fetchLive(props)
69
+ .pipe(
70
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(props.uuid, live))),
71
+ );
72
+
73
+ return {
74
+ read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
75
+ Effect.gen(function* () {
76
+ const attrs = yield* read(olds);
77
+ if (attrs === undefined) return undefined;
78
+ // Warm read: a prior apply already adopted this exact uuid — ours.
79
+ if (output !== undefined) return attrs;
80
+ // Cold read (fresh store, or state lost): no ownership marker exists on this vendor.
81
+ return Unowned(attrs);
82
+ }),
83
+
84
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
85
+ Effect.gen(function* () {
86
+ if (output === undefined || !isResolved(news)) return undefined;
87
+ const live = yield* read(news);
88
+ if (live === undefined) return { action: 'update' } as const;
89
+ return spec.matches(live, news)
90
+ ? ({ action: 'noop' } as const)
91
+ : ({ action: 'update' } as const);
92
+ }),
93
+
94
+ reconcile: (news: Props) =>
95
+ Effect.gen(function* () {
96
+ const live = yield* read(news);
97
+ return yield* refuseWrite(
98
+ spec.resourceType,
99
+ news.uuid,
100
+ live === undefined ? 'create' : 'update',
101
+ );
102
+ }),
103
+
104
+ destroy: (olds: Props) => refuseWrite(spec.resourceType, olds.uuid, 'delete'),
105
+ };
106
+ };
107
+
108
+ /**
109
+ * The provider handlers for a spec'd OPNsense object, wired once. `list` answers empty — every
110
+ * `get()` in this family returns the WHOLE model tree with no per-account/per-scope narrowing,
111
+ * so a `list` that enumerated it would sweep the estate's entire alias/category/group set into
112
+ * one plan; adoption stays explicit, one uuid at a time, the same reasoning netbox's and
113
+ * discord's own `list` give for a shared or marker-less object space (S12).
114
+ *
115
+ * `nuke: { skip: true }` — `delete` refuses unconditionally (policy.ts), so `alchemy unsafe
116
+ * nuke` could never do anything here but fail loudly on every object it found; skipping keeps
117
+ * that refusal out of a nuke census entirely, matching `postgres/database.ts` and
118
+ * `github/repository-ruleset.ts`'s own `nuke: { skip: true }` for the same reason.
119
+ */
120
+ export const opnsenseHandlers = <
121
+ Props extends OpnsenseIdentity,
122
+ Live,
123
+ Attributes extends object,
124
+ E,
125
+ >(
126
+ spec: OpnsenseSpec<Props, Live, Attributes, E>,
127
+ ) => {
128
+ const ops = opnsenseOperations(spec);
129
+ // ⚠️ GENERIC OVER ITS OWN ERROR AND REQUIREMENT CHANNELS, NOT PINNED TO `E`/`OpnsenseOpContext`.
130
+ // `read`/`diff` fail with `E` and need `OpnsenseOpContext`; `reconcile` fails with
131
+ // `E | OpnsenseWriteRefused` (it observes, then refuses) and still needs `OpnsenseOpContext`
132
+ // for that observe; `delete` fails with `OpnsenseWriteRefused` alone and needs NOTHING (it
133
+ // never observes) — three different signatures sharing one credentials-providing wrapper.
134
+ const withCredentials = <A, Err, R>(effect: Effect.Effect<A, Err, R>) =>
135
+ Effect.provide(effect, CredentialsFromEnv);
136
+ return {
137
+ list: () => Effect.succeed([]),
138
+ nuke: { skip: true },
139
+ read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
140
+ withCredentials(ops.read({ olds, output })),
141
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
142
+ withCredentials(ops.diff(news, output)),
143
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
144
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
145
+ };
146
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * How OPNsense's MVC model spells a value on the wire, and how to get it back to something
3
+ * comparable — the `../proxmox/values.ts` idea, scoped to this vendor's own spellings.
4
+ *
5
+ * ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS. Every `BooleanField` is the string
6
+ * `"1"` or `"0"` (never a JSON boolean — `AliasItem.enabled: string`, `GroupItem.nogroup?:
7
+ * string`, see `firewall_alias.ts`/`firewall_group.ts`). Every multi-select `OptionField`/
8
+ * `ModelRelationField` is a SINGLE comma-joined string, not a JSON array (`AliasItem.categories`,
9
+ * `GroupItem.members` — both carry the doc comment "wire value is a single comma-joined string,
10
+ * not a JSON array" in the generated SDK). Nothing here is specific to one resource.
11
+ *
12
+ * ⚠️ COMMA-LIST ORDER ROUND-TRIP IS NOT ASSUMED STABLE. OPNsense builds these from a PHP
13
+ * associative array (`ModelRelationField`/`OptionField::getNodeData()`), and nothing in the
14
+ * model XML promises the join order survives a save-then-read unchanged. Sorting and
15
+ * deduplicating both sides before comparing is what makes `matches` (alias-form.ts,
16
+ * group-form.ts) actually settle instead of reporting drift on a re-ordered list that changed
17
+ * nothing — the same reasoning `../proxmox/user-wire.ts`'s `groupSet` documents for PVE's
18
+ * `groups` field.
19
+ */
20
+
21
+ /** OPNsense's own boolean spelling. Absent takes the caller's stated default — OPNsense's own
22
+ * default for a `BooleanField` is form-specific, so there is no house-wide fallback here. */
23
+ export const bool01 = (value: string | undefined, fallback: boolean): boolean =>
24
+ value === undefined ? fallback : value === '1';
25
+
26
+ /** The write direction, for symmetry — unused today (this family never writes), kept so a form
27
+ * helper never has to guess the spelling if lifting the read-only policy adds one later. */
28
+ export const toBool01 = (value: boolean): string => (value ? '1' : '0');
29
+
30
+ /**
31
+ * A comma-joined multi-select column, normalised to a sorted, deduplicated set — see the module
32
+ * header's ⚠️ for why normalising (not just splitting) is what makes a diff settle.
33
+ */
34
+ export const csvSet = (value: string | undefined): readonly string[] => {
35
+ if (value === undefined || value.trim() === '') return [];
36
+ const items = value
37
+ .split(',')
38
+ .map((item) => item.trim())
39
+ .filter((item) => item !== '');
40
+ return [...new Set(items)].sort();
41
+ };
42
+
43
+ /** A declared array, normalised the SAME way `csvSet` normalises the wire, so `matches` compares
44
+ * two sets built by one function rather than trusting the caller's array to already be sorted. */
45
+ export const csvSetOf = (value: readonly string[] | undefined): readonly string[] =>
46
+ csvSet(value === undefined ? undefined : value.join(','));
47
+
48
+ /**
49
+ * OPNsense's numeric-as-string fields (`"60"`, `"0"`), tolerant of an already-numeric input the
50
+ * same way `../proxmox/user-wire.ts`'s `int` is tolerant of PVE's mixed number/string releases.
51
+ */
52
+ export const numField = (value: string | undefined, fallback: number): number => {
53
+ if (value === undefined) return fallback;
54
+ const parsed = Number(value);
55
+ return Number.isFinite(parsed) ? parsed : fallback;
56
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * THE PROOF: no SDK write operation — `addItem`, `setItem`, `delItem`, `toggleItem`, `set`, or
3
+ * OPNsense's own POST-shaped search reads — is reachable from ANY handler of ANY resource in this
4
+ * family, for ANY input. `getOnlyOrFail` (fake-opnsense.ts) fails the test itself the instant a
5
+ * non-GET request is made, so this does not rely on inspecting what each handler happens to call
6
+ * — it fails loudly the moment anything but a `get()` reaches the wire.
7
+ *
8
+ * `alias.test.ts`, `category.test.ts` and `group.test.ts` already run every scenario through this
9
+ * same fake and would catch a write there too; this file exists so ONE place in this family
10
+ * states the guarantee plainly, exercising the public `handlers` object each resource file wires
11
+ * into its `Provider` (not just the lower-level `opnsenseOperations`), across every lifecycle
12
+ * method `Provider.of` calls and against both an absent and a drifted live object.
13
+ *
14
+ * ⚠️ `handlers.*` BAKES IN `CredentialsFromEnv` (resource.ts's `withCredentials`), unlike
15
+ * `opnsenseOperations(spec)` — see fake-opnsense.ts's warning on `fakeOpnsenseLayer`. Providing
16
+ * `fakeOpnsenseLayer`'s own `Credentials` layer from here does nothing: the inner `Effect.provide`
17
+ * already closed over that tag, so it wins. Left unaddressed, `CredentialsFromEnv` reads real
18
+ * `OPNSENSE_*` env vars via `EffectConfig`, finds none in CI, and `Effect.orDie`s before any
19
+ * request is built — `fake.seen` stays empty and `.every(...)` on an empty array is vacuously
20
+ * `true`. `fakeCredentialsConfigProvider` below fixes this WITHOUT touching real `process.env`
21
+ * (which a parallel test file could race on): it overrides the `effect/ConfigProvider` the
22
+ * `EffectConfig.String("OPNSENSE_*")` reads inside `CredentialsFromEnv` resolve against, which is
23
+ * a different tag CredentialsFromEnv never shadows — proved against this exact fake/real pattern
24
+ * before landing here. Every "only GETs" test below also asserts `fake.seen` has exactly the
25
+ * one GET `fetchLive` issues, so a run that silently makes zero requests (or a future
26
+ * double-GET regression) fails loudly instead of passing on an under-checked `.every()`.
27
+ *
28
+ * ⚠️ THIS FIX HAS ONE INVARIANT: `CredentialsFromEnv` (distilled-opnsense's `credentials.ts`) must
29
+ * keep resolving `OPNSENSE_*` through the AMBIENT `ConfigProvider` rather than one it pins
30
+ * itself. If it ever starts providing its own `ConfigProvider` — the way it already closes over
31
+ * the `Credentials` tag via `withCredentials` — this override would be shadowed the same way
32
+ * `fakeOpnsenseLayer`'s `Credentials` layer is today, and the vacuous-pass bug this file exists
33
+ * to prevent would return silently.
34
+ */
35
+ import { describe, expect, test } from 'bun:test';
36
+ import * as Effect from 'effect/Effect';
37
+ import * as Layer from 'effect/Layer';
38
+ import * as ConfigProvider from 'effect/ConfigProvider';
39
+ import { handlers as aliasHandlers, spec as aliasSpec } from './alias.ts';
40
+ import { handlers as categoryHandlers, spec as categorySpec } from './category.ts';
41
+ import {
42
+ FAKE_BASE,
43
+ FAKE_KEY,
44
+ FAKE_SECRET,
45
+ fakeOpnsenseLayer,
46
+ getOnlyOrFail,
47
+ } from './fake-opnsense.ts';
48
+ import { handlers as groupHandlers, spec as groupSpec } from './group.ts';
49
+
50
+ /**
51
+ * Overrides the `ConfigProvider` `CredentialsFromEnv`'s `EffectConfig.String("OPNSENSE_*")`
52
+ * resolves against, so `handlers.*` (which bakes in real env-var credentials resolution) gets
53
+ * the same placeholder values `fakeOpnsenseLayer`'s default `Credentials` layer uses — without
54
+ * ever reading or writing real `process.env`.
55
+ */
56
+ const fakeCredentialsConfigProvider = ConfigProvider.layer(
57
+ ConfigProvider.fromUnknown({
58
+ OPNSENSE_API_KEY: FAKE_KEY,
59
+ OPNSENSE_API_SECRET: FAKE_SECRET,
60
+ OPNSENSE_URL: FAKE_BASE,
61
+ }),
62
+ );
63
+
64
+ /** `fakeOpnsenseLayer` plus the env override `handlers.*` needs — see the file header. */
65
+ const fakeHandlersLayer = (fetchFn: typeof globalThis.fetch) =>
66
+ Layer.mergeAll(fakeOpnsenseLayer(fetchFn), fakeCredentialsConfigProvider);
67
+
68
+ const families = [
69
+ {
70
+ empty: Response.json({ alias: {} }),
71
+ handlers: aliasHandlers,
72
+ live: Response.json({
73
+ alias: { aliases: { alias: { u: { content: 'x', enabled: '1', name: 'n', type: 'host' } } } },
74
+ }),
75
+ props: { content: 'x', name: 'n', type: 'host', uuid: 'u' },
76
+ resourceType: aliasSpec.resourceType,
77
+ },
78
+ {
79
+ empty: Response.json({ category: {} }),
80
+ handlers: categoryHandlers,
81
+ live: Response.json({ category: { categories: { category: { u: { name: 'n' } } } } }),
82
+ props: { name: 'n', uuid: 'u' },
83
+ resourceType: categorySpec.resourceType,
84
+ },
85
+ {
86
+ empty: Response.json({ group: {} }),
87
+ handlers: groupHandlers,
88
+ live: Response.json({
89
+ group: { ifgroupentry: { u: { ifname: 'n', members: '', sequence: '0' } } },
90
+ }),
91
+ props: { ifname: 'n', members: [], uuid: 'u' },
92
+ resourceType: groupSpec.resourceType,
93
+ },
94
+ ];
95
+
96
+ describe.each(families)(
97
+ '$resourceType — write-refusal proof',
98
+ ({ empty, handlers, live, props }) => {
99
+ test('list() is empty and issues no request', async () => {
100
+ const fake = getOnlyOrFail(() => {
101
+ throw new Error('list() must never call the wire at all');
102
+ });
103
+ const result = await Effect.runPromise(
104
+ Effect.provide(handlers.list(), fakeOpnsenseLayer(fake.fetch)),
105
+ );
106
+ expect(result).toEqual([]);
107
+ expect(fake.seen).toHaveLength(0);
108
+ });
109
+
110
+ test('reconcile against a LIVE object still only GETs, then refuses', async () => {
111
+ const fake = getOnlyOrFail(() => live);
112
+ const outcome = await Effect.runPromiseExit(
113
+ Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
114
+ );
115
+ expect(outcome._tag).toBe('Failure');
116
+ // ⚠️ Not sufficient alone — `.every()` on an empty array is vacuously true. See the file
117
+ // header: without `fakeHandlersLayer`'s ConfigProvider override this assertion never ran
118
+ // a single request, because `CredentialsFromEnv` died first. Exactly 1, not just > 0:
119
+ // `fetchLive` (alias.ts/category.ts/group.ts) issues one `get()` — matches
120
+ // `alias.test.ts`'s own `toHaveLength(1)` and also catches a future double-GET regression.
121
+ expect(fake.seen).toHaveLength(1);
122
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
123
+ });
124
+
125
+ test('reconcile against an ABSENT object still only GETs, then refuses', async () => {
126
+ const fake = getOnlyOrFail(() => empty);
127
+ const outcome = await Effect.runPromiseExit(
128
+ Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
129
+ );
130
+ expect(outcome._tag).toBe('Failure');
131
+ expect(fake.seen).toHaveLength(1);
132
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
133
+ });
134
+
135
+ test('delete makes ZERO requests and refuses', async () => {
136
+ const fake = getOnlyOrFail(() => live);
137
+ const outcome = await Effect.runPromiseExit(
138
+ Effect.provide(handlers.delete({ olds: props as never }), fakeOpnsenseLayer(fake.fetch)),
139
+ );
140
+ expect(outcome._tag).toBe('Failure');
141
+ expect(fake.seen).toHaveLength(0);
142
+ });
143
+
144
+ test('nuke is skipped, so account-wide teardown never even lists this type', () => {
145
+ expect(handlers.nuke).toEqual({ skip: true });
146
+ });
147
+ },
148
+ );
@@ -99,15 +99,21 @@ export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
99
99
 
100
100
  /**
101
101
  * ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
102
- * ⛔ on filtering. `orElseSucceed` still folds every OTHER failure into `undefined`.
102
+ * ⛔ on filtering.
103
103
  * ★ `readOrUnreadable` (unreadable-read.ts) runs first: a REFUSED mint of `provision` — the
104
- * "cries wolf" bug — comes back `UNREADABLE` instead of the same `undefined` a genuine
105
- * absence produces, so `diff` below can tell them apart.
104
+ * "cries wolf" bug — comes back `UNREADABLE` instead of the `undefined` a genuine, SUCCESSFUL
105
+ * read (the row simply not being in the list) produces, so `diff` below can tell them apart.
106
+ * ⛔ NO `orElseSucceed` HERE ANY MORE — found on 2026-09-24, the SAME bug class as the credential
107
+ * denial: it used to fold EVERY OTHER failure (a transport error, `PveClusterExhausted`, a
108
+ * decode failure) into `undefined` too, which then read exactly like a genuine absence and
109
+ * forced the same false `update`. Only a read that actually SUCCEEDS (and the grant is not in
110
+ * the list it returns) may mean absent now; every other failure propagates and fails the whole
111
+ * plan loudly — the engine's own contract for a failed `read`/`diff` (verify.ts's `readWithState`
112
+ * already expects and reports it as `'failed'`).
106
113
  */
107
114
  const readAttributes = (props: AclProps) =>
108
115
  readOrUnreadable(runPve(props.target, 'provision', false, access.listAccessAcl({}))).pipe(
109
116
  Effect.map((rows) => (rows === UNREADABLE ? UNREADABLE : attributesOf(rows, props))),
110
- Effect.orElseSucceed(() => undefined),
111
117
  );
112
118
 
113
119
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
@@ -141,12 +141,22 @@ export const ProxmoxApiToken = Resource<ProxmoxApiToken>('Proxmox.ApiToken', {
141
141
  defaultRemovalPolicy: 'retain',
142
142
  });
143
143
 
144
- /** ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges. */
145
- const readToken = (props: ApiTokenProps) =>
146
- readOrUnreadable(runPve(props.target, 'provision', false, access.getAccessUserToken(props))).pipe(
147
- Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
148
- Effect.orElseSucceed(() => undefined),
149
- );
144
+ /**
145
+ * ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges.
146
+ * ⛔ `{ userid, tokenid }`, NEVER THE WHOLE `props` — group.ts's readGroup has the full measured
147
+ * mechanism (kit 0.31.1's regression): an extra key beyond the schema's own path labels gets
148
+ * encoded onto this GET as a body, which a stricter fetch client refuses outright.
149
+ * ⛔ NO `orElseSucceed` — a MISSING token's absence signal is a SUCCESSFUL read with `expire`/
150
+ * `privsep` both absent (`attributesOf`/api-token-form.ts, MEASURED pre-migration), unlike
151
+ * user.ts/group.ts's missing-object 500 — so this family never needed their folding split. A
152
+ * THROWN failure here is genuinely unexpected and must propagate and fail the plan loudly.
153
+ */
154
+ const readToken = (props: ApiTokenProps) => {
155
+ const label = { tokenid: props.tokenid, userid: props.userid };
156
+ return readOrUnreadable(
157
+ runPve(props.target, 'provision', false, access.getAccessUserToken(label)),
158
+ ).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
159
+ };
150
160
 
151
161
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
152
162
  const dropUnreadable = (live: ApiTokenAttributes | Unreadable | undefined) =>
@@ -193,12 +203,10 @@ export const ProxmoxApiTokenProvider = () =>
193
203
  }),
194
204
  reconcile: Effect.fn(function* ({ news }) {
195
205
  // ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
196
- // read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race —
197
- // but UNLIKE group/role/user, the message below assumes genuine absence and would
198
- // misname a transient credential refusal as "this resource does not create tokens".
199
- // Rare (the SAME role must mint twice, once for diff/once for reconcile, and only the
200
- // second is denied) and not silently wrong — the operator still sees a die() and goes
201
- // looking — but named here rather than left for the next reader to assume away.
206
+ // read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race, not
207
+ // the routine case `diff` handles. Any OTHER read failure now propagates on its own
208
+ // (readToken no longer folds it), so the die() message below is reached only on a
209
+ // genuinely absent token, never on a transient failure wearing that message.
202
210
  const before = dropUnreadable(yield* readToken(news));
203
211
  if (before === undefined) {
204
212
  return yield* Effect.die(
@@ -1,10 +1,13 @@
1
1
  /**
2
- * `Proxmox.Group`'s wire shape: PVE's create/update forms and how a live item becomes
3
- * attributes. Split out of group.ts (2026-09-24) to keep that file under the 250-line cap —
4
- * the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
2
+ * `Proxmox.Group`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
3
+ * and the read itself. Split out of group.ts (2026-09-24) to keep that file under the 250-line
4
+ * cap — the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
5
5
  */
6
- import type * as access from '@distilled.cloud/proxmox/access';
6
+ import * as access from '@distilled.cloud/proxmox/access';
7
+ import * as Effect from 'effect/Effect';
8
+ import { runPve } from './distilled-pve.ts';
7
9
  import type { GroupAttributes, GroupProps } from './group.ts';
10
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
8
11
  import { csv, text } from './values.ts';
9
12
 
10
13
  export const GROUP_CREATE = 'pve:POST /access/groups';
@@ -41,10 +44,7 @@ export const updateForm = (props: GroupProps): access.PutAccessGroupRequest => (
41
44
  groupid: props.groupid,
42
45
  });
43
46
 
44
- export const attributesOf = (
45
- live: access.GetAccessGroupResponse,
46
- props: GroupProps,
47
- ): GroupAttributes => ({
47
+ const attributesOf = (live: access.GetAccessGroupResponse, props: GroupProps): GroupAttributes => ({
48
48
  /**
49
49
  * ⚠️ ABSENT IS EMPTY, NOT MISSING — `read_group` sets `comment` only `if defined(…)`. distilled
50
50
  * types it `comment?: string`, so `text()`'s `''` fallback meets the same normalisation.
@@ -61,3 +61,57 @@ export const attributesOf = (
61
61
  */
62
62
  export const matches = (attributes: GroupAttributes, props: GroupProps) =>
63
63
  attributes.comment === storedComment(props.comment);
64
+
65
+ /**
66
+ * ★ Default `read` role — nothing widened for this family, see group.ts's header privileges note.
67
+ * ⛔ `{ groupid: props.groupid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression
68
+ * in kit 0.31.1. `GetAccessGroupRequest`'s schema declares only `groupid` (a path label); any
69
+ * OTHER key on the object passed to `access.getAccessGroup` — `target`, `comment`, every field
70
+ * this family's own props carry — is treated by distilled's `buildRequest` as an "unknown key"
71
+ * and JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
72
+ * fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
73
+ * (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
74
+ * which `runPveWith` retries across every member and exhausts identically —
75
+ * `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
76
+ * false `update` on every group. `role.ts`'s list call and acl.ts's `listAccessAcl({})` were
77
+ * never at risk: both are always called with a literal `{}`.
78
+ * ⛔ `live.members === undefined` IS DEFENSIVE, NOT THE ABSENCE SIGNAL — CORRECTED 2026-09-24.
79
+ * This file previously claimed a missing group answers 200 with `{"data":null}`, unwrapped to
80
+ * `{}`. MEASURED against the live cluster: `GET /access/groups/{missing}` answers a genuine 500
81
+ * ("group '…' does not exist"), the SAME shape as user.ts's own measured 500 — there is no
82
+ * success-path absence signal here at all. The check stays as insurance against a future PVE
83
+ * release that does answer `{}`, but this function is what actually carries the "not found"
84
+ * case, as a thrown error — `readGroup` below is the one that folds it.
85
+ */
86
+ export const readGroupOrFail = (props: GroupProps) =>
87
+ readOrUnreadable(
88
+ runPve(props.target, 'read', false, access.getAccessGroup({ groupid: props.groupid })),
89
+ ).pipe(
90
+ Effect.map((live) =>
91
+ live === UNREADABLE
92
+ ? UNREADABLE
93
+ : live.members === undefined
94
+ ? undefined
95
+ : attributesOf(live, props),
96
+ ),
97
+ );
98
+
99
+ /**
100
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT.
101
+ * PVE's OWN missing-group answer is a thrown 500 (the ⛔ above), so `reconcile`'s create branch
102
+ * NEEDS "absent" from a failure to ever run at all — without this fold, a brand-new group could
103
+ * never be created, because its very-first read would always fail loudly. Safe here because a
104
+ * wrongful fold costs at most a redundant `createAccessGroup` POST, which PVE refuses loudly
105
+ * ("group already exists") rather than silently corrupting anything.
106
+ * ⛔ `group.ts`'s `diff` does **NOT** use this — found 2026-09-24, the SAME bug class as the
107
+ * credential denial fix: folding a TRANSIENT failure (a `PveClusterExhausted`, the encoding bug
108
+ * above) into "absent" at PLAN TIME is exactly what forced a false `update` with nothing
109
+ * compared. `diff` calls `readGroupOrFail` directly instead, so a genuine failure there
110
+ * propagates and fails the whole plan loudly — never silently misreports one row.
111
+ */
112
+ export const readGroup = (props: GroupProps) =>
113
+ readGroupOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
114
+
115
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
116
+ export const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
117
+ live === UNREADABLE ? undefined : live;
@@ -76,19 +76,16 @@ import { guardWrite } from './distilled-guard.ts';
76
76
  import {
77
77
  GROUP_CREATE,
78
78
  GROUP_UPDATE,
79
- attributesOf,
80
79
  createForm,
80
+ dropUnreadable,
81
81
  matches,
82
+ readGroup,
83
+ readGroupOrFail,
82
84
  updateForm,
83
85
  } from './group-wire.ts';
84
86
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
85
87
  import { runPve } from './distilled-pve.ts';
86
- import {
87
- UNREADABLE,
88
- type Unreadable,
89
- readOrUnreadable,
90
- unreadableWarning,
91
- } from './unreadable-read.ts';
88
+ import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
92
89
 
93
90
  export interface GroupProps extends WithTarget {
94
91
  /**
@@ -127,31 +124,6 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
127
124
  defaultRemovalPolicy: 'retain',
128
125
  });
129
126
 
130
- /**
131
- * ★ Default `read` role — nothing widened for this family, see the header's privileges note.
132
- * ⛔ `live.members === undefined` MEANS ABSENT, NOT AN EMPTY GROUP. `@distilled.cloud/core`'s
133
- * REST protocol unwraps PVE's `{"data": null}` (a genuinely missing object — MEASURED in
134
- * `protocol.ts`: "a Unit-output endpoint... `data` is null/absent") to `{}` BEFORE the
135
- * schema-driven decode, not to a decode failure. `members` is the one REQUIRED field on this
136
- * response, so its absence is the only signal that the group was never there — the same trap
137
- * api-token-form.ts's `attributes` guards with its own required-field check.
138
- */
139
- const readGroup = (props: GroupProps) =>
140
- readOrUnreadable(runPve(props.target, 'read', false, access.getAccessGroup(props))).pipe(
141
- Effect.map((live) =>
142
- live === UNREADABLE
143
- ? UNREADABLE
144
- : live.members === undefined
145
- ? undefined
146
- : attributesOf(live, props),
147
- ),
148
- Effect.orElseSucceed(() => undefined),
149
- );
150
-
151
- /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
152
- const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
153
- live === UNREADABLE ? undefined : live;
154
-
155
127
  export const ProxmoxGroupProvider = () =>
156
128
  Provider.effect(
157
129
  ProxmoxGroup,
@@ -162,15 +134,31 @@ export const ProxmoxGroupProvider = () =>
162
134
  * cluster, `admins` (grants `Administrator` on `/`) included. Adoption stays explicit.
163
135
  */
164
136
  list: () => Effect.succeed([]),
165
- read: Effect.fn(function* ({ olds }) {
166
- return dropUnreadable(yield* readGroup(olds));
137
+ // ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — MEASURED 2026-09-24, an adversarial review
138
+ // of this very fix caught the gap. The engine calls this ONE hook from FOUR places
139
+ // (alchemy/src/{Plan,Apply,Drift}.ts), and only `output` tells them apart: Plan.ts's
140
+ // cold-start adoption probe and interrupted-create recovery, and Apply.ts's
141
+ // delete-recovery, all pass `output: undefined` — nothing is confirmed to exist yet, so
142
+ // `readGroup`'s fold is still needed there or a brand-new group could never be adopted
143
+ // or created. `Drift.ts` (`alchemy drift`/`sync`/`deploy --detect-drift`) is different:
144
+ // it calls this on an ALREADY-CONFIRMED row (`output: old.attr`, defined), exactly
145
+ // `diff`'s own situation below — a transient failure folded to `undefined` there is
146
+ // reported as `{action: 'missing'}` with NO error at all, the cries-wolf bug again, one
147
+ // command over. `readGroupOrFail` on that branch propagates it instead.
148
+ read: Effect.fn(function* ({ olds, output }) {
149
+ return dropUnreadable(
150
+ yield* output === undefined ? readGroup(olds) : readGroupOrFail(olds),
151
+ );
167
152
  }),
168
153
  diff: Effect.fn(function* ({ news, output }) {
169
154
  if (!isResolved(news)) return undefined;
170
155
  yield* guardWrite(GROUP_CREATE, createForm(news), output === undefined);
171
156
  yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
172
157
  if (output === undefined) return undefined;
173
- const live = yield* readGroup(news);
158
+ // ⚠️ `readGroupOrFail`, NOT `readGroup` — see that function's own ⛔. A genuine
159
+ // TRANSIENT failure here propagates and fails the whole plan loudly instead of
160
+ // folding to "absent" and forcing a false update, the cries-wolf class of bug.
161
+ const live = yield* readGroupOrFail(news);
174
162
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
175
163
  // `update` on a group that was plainly there — see unreadable-read.ts.
176
164
  if (live === UNREADABLE) {