@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
@@ -33,12 +33,12 @@
33
33
  * user, so a role wide enough to edit the account can still be refused for one group.
34
34
  * · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
35
35
  *
36
- * ⚠️ A MISSING USER IS A 500, NOT A 404 — "no such user ('x@pve')". distilled turns that into an
37
- * `UnknownProxmoxError` (protocol.ts), and `readOrUnreadable`'s own `orElseSucceed` folds it
38
- * into "absent" same as the old hand-rolled client did. A 403 therefore also reads as absent
39
- * UNLESS it is the one refusal `readOrUnreadable` catches (unreadable-read.ts): a REFUSED
40
- * `provision` mint, the cries-wolf case. Any other refusal still reads as absent, and the
41
- * honest error arrives one step later, from the create.
36
+ * ⚠️ A MISSING USER IS A 500, NOT A 404 — MEASURED against the live cluster, "no such user
37
+ * ('x@pve')". distilled turns that into an `InternalServerError`, with no distinct "not found"
38
+ * tag to catch. `user-wire.ts`'s `readUser` (used by `read`/`reconcile`, where the create
39
+ * workflow needs "absent" from exactly this failure) still folds it; `readUserOrFail` (used by
40
+ * `diff` below) does not — see that function's own header for why folding it there was the
41
+ * cries-wolf bug all over again, just for a genuine PVE quirk instead of a permission denial.
42
42
  *
43
43
  * ⚠️ THE WIRE URL FOR THIS FAMILY CHANGED, THOUGH NOTHING A CALLER SEES DID. MEASURED
44
44
  * (user.test.ts): distilled's `{userid}` label substitution percent-encodes it —
@@ -60,18 +60,15 @@ import * as Effect from 'effect/Effect';
60
60
  import { guardWrite } from './distilled-guard.ts';
61
61
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
62
62
  import { runPve } from './distilled-pve.ts';
63
- import {
64
- UNREADABLE,
65
- type Unreadable,
66
- readOrUnreadable,
67
- unreadableWarning,
68
- } from './unreadable-read.ts';
63
+ import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
69
64
  import {
70
65
  USER_CREATE,
71
66
  USER_UPDATE,
72
- attributesOf,
73
67
  createForm,
68
+ dropUnreadable,
74
69
  matches,
70
+ readUser,
71
+ readUserOrFail,
75
72
  updateForm,
76
73
  } from './user-wire.ts';
77
74
 
@@ -126,17 +123,6 @@ export interface ProxmoxUser extends Resource<
126
123
 
127
124
  export const ProxmoxUser = Resource<ProxmoxUser>('Proxmox.User');
128
125
 
129
- /** ★ Default `read` role for this family — only the write side needs `provision`. */
130
- const readUser = (props: UserProps) =>
131
- readOrUnreadable(runPve(props.target, 'read', false, access.getAccessUser(props))).pipe(
132
- Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
133
- Effect.orElseSucceed(() => undefined),
134
- );
135
-
136
- /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
137
- const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
138
- live === UNREADABLE ? undefined : live;
139
-
140
126
  export const ProxmoxUserProvider = () =>
141
127
  Provider.effect(
142
128
  ProxmoxUser,
@@ -148,15 +134,31 @@ export const ProxmoxUserProvider = () =>
148
134
  * identity someone made years ago. Adoption is an explicit act.
149
135
  */
150
136
  list: () => Effect.succeed([]),
151
- read: Effect.fn(function* ({ olds }) {
152
- return dropUnreadable(yield* readUser(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
+ // `readUser`'s fold is still needed there or a brand-new account 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. `readUserOrFail` on that branch propagates it instead.
148
+ read: Effect.fn(function* ({ olds, output }) {
149
+ return dropUnreadable(
150
+ yield* output === undefined ? readUser(olds) : readUserOrFail(olds),
151
+ );
153
152
  }),
154
153
  diff: Effect.fn(function* ({ news, output }) {
155
154
  if (!isResolved(news)) return undefined;
156
155
  yield* guardWrite(USER_CREATE, createForm(news), output === undefined);
157
156
  yield* guardWrite(USER_UPDATE, updateForm(news), false);
158
157
  if (output === undefined) return undefined;
159
- const live = yield* readUser(news);
158
+ // ⚠️ `readUserOrFail`, NOT `readUser` — see that function's own header (user-wire.ts).
159
+ // A genuine 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* readUserOrFail(news);
160
162
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
161
163
  // `update` on an account that was plainly there — see unreadable-read.ts.
162
164
  if (live === UNREADABLE) {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A minimal in-memory UniFi Network API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring `../netbox/fake-netbox.ts`
4
+ * (itself mirroring `../forgejo/fake-forgejo.ts`). Tests exercise distilled's REAL path assembly,
5
+ * JSON encode/decode and status→typed-error matching — nothing about that matching is
6
+ * re-implemented here, only the wire responses a scenario needs.
7
+ *
8
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
9
+ * `unifi.example.com` and the key is a placeholder — never a real console address: a cloud
10
+ * connector's base URL embeds the account's Console ID, which must never appear in this repo
11
+ * (`docs/unifi.md`).
12
+ */
13
+ import { type Credentials, credentials } from '@distilled.cloud/unifi-network/Credentials';
14
+ import * as Layer from 'effect/Layer';
15
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
16
+
17
+ export const FAKE_BASE = 'https://unifi.example.com/proxy/network/integration';
18
+ export const FAKE_KEY = 'placeholder-unifi-key';
19
+
20
+ export type Seen = { readonly method: string; readonly path: string };
21
+
22
+ /** No documented failure envelope (see `protocol.ts`) — plain text is all a failure needs. */
23
+ export const fakeFailure = (status: number, detail: string) => new Response(detail, { status });
24
+
25
+ /**
26
+ * `route` decides every response; this wrapper only records what was sent and adapts to
27
+ * `globalThis.fetch`'s signature.
28
+ *
29
+ * ★ THE WRITE-REFUSAL PROOF USES `seen` DIRECTLY, NOT A THROWING ROUTE. `resource.test.ts`'s
30
+ * "no SDK write operation is reachable" test asserts `seen.every(s => s.method === 'GET')`
31
+ * after driving every handler — a request this family's engine was never supposed to send
32
+ * shows up in `seen` as a fact, rather than depending on an exception surviving Effect's own
33
+ * error handling on its way back out of a fake `fetch`.
34
+ */
35
+ export const fakeUnifi = (route: (method: string, url: URL) => Response) => {
36
+ const seen: Seen[] = [];
37
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
38
+ const request =
39
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
40
+ const url = new URL(request.url);
41
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
42
+ return route(request.method, url);
43
+ }) as typeof globalThis.fetch;
44
+ return { fetch, seen };
45
+ };
46
+
47
+ /**
48
+ * distilled Credentials + the real FetchHttpClient over the fake — `UnifiNetworkOpContext`, what
49
+ * every resource file's exported `spec.fetchLive` needs.
50
+ *
51
+ * ⚠️ NOT PAIRED WITH `handlers`. `handlers` (each resource file's `unifiHandlers(spec)` export)
52
+ * bakes in `CredentialsFromEnv`, which resolves `UNIFI_NETWORK_API_KEY`/`_API_BASE_URL` through
53
+ * Effect's `Config` — whose default provider snapshots `process.env` once and never re-reads it
54
+ * (the same seam `netbox/fake-netbox.ts` documents). Tests call `spec.fetchLive`/`spec.attributes`
55
+ * directly instead, with this explicit layer.
56
+ */
57
+ export const fakeUnifiLayer = (
58
+ fetchFn: typeof globalThis.fetch,
59
+ creds: Layer.Layer<Credentials> = credentials({ apiBaseUrl: FAKE_BASE, apiKey: FAKE_KEY }),
60
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `Unifi.FirewallZone`'s wire shape — the declarable subset of `FirewallZone`, and how a live
3
+ * read becomes both plan attributes and a rendered declaration.
4
+ *
5
+ * ⚠️ ONLY `name` AND `networkIds` ARE DECLARABLE. `CreateFirewallZoneRequest`
6
+ * (`firewall.ts`) accepts exactly those two beyond `siteId`; `metadata` (origin/source) and
7
+ * `id` are server-derived, so — same split as `network-form.ts`'s `default`/`metadata` —
8
+ * they are attributes-only, reported but never compared or declared.
9
+ *
10
+ * ⛔ `networkIds` ORDER IS NOT PART OF IDENTITY. The vendor document gives no indication the
11
+ * list is ordered (it is a set of attached networks, not a sequence like the ordering
12
+ * endpoints' rule lists), so `matches` compares it as a set — sorted before comparing —
13
+ * the same reasoning `proxmox/user-wire.ts`'s `groupSet` gives for PVE's `groups`.
14
+ */
15
+ import type * as firewall from '@distilled.cloud/unifi-network/firewall';
16
+
17
+ export interface FirewallZoneProps {
18
+ siteId: string;
19
+ firewallZoneId: string;
20
+ /** Name of a firewall zone. */
21
+ name: string;
22
+ /** Network IDs attached to this zone — compared as a set, see the header. */
23
+ networkIds: string[];
24
+ }
25
+
26
+ export interface FirewallZoneAttributes {
27
+ siteId: string;
28
+ firewallZoneId: string;
29
+ name: string;
30
+ networkIds: string[];
31
+ /** `origin`/`source` (e.g. `SDWAN`) — server-derived, never declared. */
32
+ metadataOrigin: string;
33
+ }
34
+
35
+ const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();
36
+
37
+ export const attributesOf = (
38
+ live: firewall.FirewallZone,
39
+ props: FirewallZoneProps,
40
+ ): FirewallZoneAttributes => ({
41
+ siteId: props.siteId,
42
+ firewallZoneId: live.id,
43
+ name: live.name,
44
+ networkIds: sortedSet(live.networkIds),
45
+ metadataOrigin: live.metadata.origin,
46
+ });
47
+
48
+ export const matches = (attributes: FirewallZoneAttributes, props: FirewallZoneProps): boolean =>
49
+ attributes.name === props.name &&
50
+ attributes.networkIds.join(',') === sortedSet(props.networkIds).join(',');
51
+
52
+ /**
53
+ * The declaration renderer (task spec: "given one live object, return the props a declaration
54
+ * needs so its plan is noop"). `matches(attributesOf(live, props), declareFirewallZone(live,
55
+ * siteId))` is `true` by construction (`firewall-zone.test.ts` proves it).
56
+ */
57
+ export const declareFirewallZone = (
58
+ live: firewall.FirewallZone,
59
+ siteId: string,
60
+ ): FirewallZoneProps => ({
61
+ siteId,
62
+ firewallZoneId: live.id,
63
+ name: live.name,
64
+ networkIds: sortedSet(live.networkIds),
65
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `Unifi.FirewallZone`'s `spec` against a fake UniFi Network API — mirrors `network.test.ts`.
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import type * as firewall from '@distilled.cloud/unifi-network/firewall';
6
+ import * as Effect from 'effect/Effect';
7
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
8
+ import { attributesOf, matches } from './firewall-zone-form.ts';
9
+ import { type FirewallZoneProps, declareFirewallZone, spec } from './firewall-zone.ts';
10
+ import { unifiOperations } from './resource.ts';
11
+
12
+ const ZONE_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/zones/zone-1';
13
+
14
+ const liveZone = (overrides: Partial<firewall.FirewallZone> = {}): firewall.FirewallZone => ({
15
+ id: 'zone-1',
16
+ metadata: { origin: 'USER' },
17
+ name: 'Cameras',
18
+ networkIds: ['net-2', 'net-1'],
19
+ ...overrides,
20
+ });
21
+
22
+ const PROPS: FirewallZoneProps = {
23
+ siteId: 'site-1',
24
+ firewallZoneId: 'zone-1',
25
+ name: 'Cameras',
26
+ networkIds: ['net-1', 'net-2'],
27
+ };
28
+
29
+ describe('Unifi.FirewallZone spec.fetchLive', () => {
30
+ test('a real GET response decodes, and networkIds order does not affect matches', async () => {
31
+ const fake = fakeUnifi((method, url) =>
32
+ method === 'GET' && url.pathname === ZONE_PATH
33
+ ? Response.json(liveZone())
34
+ : fakeFailure(400, 'unexpected request'),
35
+ );
36
+ const live = await Effect.runPromise(
37
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
38
+ );
39
+ expect(live?.id).toBe('zone-1');
40
+ const attrs = attributesOf(live as firewall.FirewallZone, PROPS);
41
+ expect(matches(attrs, PROPS)).toBe(true);
42
+ });
43
+
44
+ test('a genuine 404 folds to undefined', async () => {
45
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
46
+ const live = await Effect.runPromise(
47
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
48
+ );
49
+ expect(live).toBeUndefined();
50
+ });
51
+ });
52
+
53
+ describe('declareFirewallZone -- the declaration renderer', () => {
54
+ test('its output matches attributesOf(live) by construction, for any live object', () => {
55
+ const live = liveZone({ networkIds: ['net-9', 'net-3', 'net-3'] });
56
+ const declared = declareFirewallZone(live, 'site-1');
57
+ const attrs = attributesOf(live, declared);
58
+ expect(matches(attrs, declared)).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe('Unifi.FirewallZone write paths never reach the vendor API', () => {
63
+ test('reconcile on an exact-match adoption (H6) sends only the one read', async () => {
64
+ const fake = fakeUnifi((method, url) =>
65
+ method === 'GET' && url.pathname === ZONE_PATH
66
+ ? Response.json(liveZone())
67
+ : fakeFailure(400, 'unexpected request'),
68
+ );
69
+ const exit = await Effect.runPromiseExit(
70
+ unifiOperations(spec)
71
+ .reconcile(PROPS)
72
+ .pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
73
+ );
74
+ expect(exit._tag).toBe('Success');
75
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
76
+ });
77
+
78
+ test('delete always refuses -- no DELETE is ever sent', async () => {
79
+ const fake = fakeUnifi(() => fakeFailure(400, 'unexpected request'));
80
+ const exit = await Effect.runPromiseExit(
81
+ unifiOperations(spec)
82
+ .destroy(PROPS)
83
+ .pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
84
+ );
85
+ expect(exit._tag).toBe('Failure');
86
+ expect(fake.seen).toEqual([]);
87
+ });
88
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `Unifi.FirewallZone` — one firewall zone on a UniFi Network site, READ-ONLY (`policy.ts`).
3
+ *
4
+ * ★ WHY FIREWALL ZONE IS IN THE FIRST IMPORT SET AND `Unifi.FirewallPolicy` IS NOT.
5
+ * `getFirewallZone`/`getFirewallZones` (`firewall.ts`) is a list+get pair over genuine,
6
+ * simply-shaped configuration: `{id, metadata, name, networkIds}`, four fields, no nested
7
+ * discriminated filter. `FirewallPolicy` (same file) is also list+get and also configuration,
8
+ * but its `source`/`destination`/`ipProtocolScope` fields carry several of `docs/unifi-api-notes.md`'s
9
+ * "converter-flattened discriminator variants" each, and `firewall/policies/ordering` replaces
10
+ * the whole ordered list — modelling those correctly is a bigger, separate PR (`network.ts`'s
11
+ * header note names it explicitly) rather than something to rush alongside this one.
12
+ */
13
+ import { Resource } from 'alchemy';
14
+ import { adopt } from 'alchemy/AdoptPolicy';
15
+ import * as Provider from 'alchemy/Provider';
16
+ import * as firewall from '@distilled.cloud/unifi-network/firewall';
17
+ import * as Effect from 'effect/Effect';
18
+ import {
19
+ type FirewallZoneAttributes,
20
+ type FirewallZoneProps,
21
+ attributesOf,
22
+ matches,
23
+ } from './firewall-zone-form.ts';
24
+ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
25
+ import { unifiHandlers } from './resource.ts';
26
+
27
+ export type { FirewallZoneAttributes, FirewallZoneProps } from './firewall-zone-form.ts';
28
+ export { declareFirewallZone } from './firewall-zone-form.ts';
29
+
30
+ export interface UnifiFirewallZone extends Resource<
31
+ 'Unifi.FirewallZone',
32
+ FirewallZoneProps,
33
+ FirewallZoneAttributes,
34
+ never,
35
+ UnifiRequirements
36
+ > {}
37
+
38
+ export const UnifiFirewallZone = Resource<UnifiFirewallZone>('Unifi.FirewallZone', {
39
+ defaultRemovalPolicy: 'retain',
40
+ });
41
+
42
+ /** `firewallZone('lan', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
43
+ export const firewallZone = (id: string, props: FirewallZoneProps) =>
44
+ UnifiFirewallZone(id, props).pipe(adopt(true));
45
+
46
+ /** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
47
+ export const spec: UnifiSpec<
48
+ FirewallZoneProps,
49
+ firewall.FirewallZone,
50
+ FirewallZoneAttributes,
51
+ firewall.GetFirewallZoneError
52
+ > = {
53
+ type: 'Unifi.FirewallZone',
54
+ describe: (props) => `sites/${props.siteId}/firewall/zones/${props.firewallZoneId}`,
55
+ fetchLive: (props) =>
56
+ firewall
57
+ .getFirewallZone({ siteId: props.siteId, firewallZoneId: props.firewallZoneId })
58
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
59
+ attributes: attributesOf,
60
+ matches,
61
+ };
62
+
63
+ export const handlers = unifiHandlers(spec);
64
+
65
+ export const UnifiFirewallZoneProvider = () =>
66
+ Provider.effect(UnifiFirewallZone, Effect.succeed(UnifiFirewallZone.Provider.of(handlers)));
@@ -0,0 +1,26 @@
1
+ /**
2
+ * UniFi Network providers for Alchemy — READ-ONLY, by Tim's rule (2026-09-24; `policy.ts`).
3
+ *
4
+ * ⛔ THIS BARREL IS THE PUBLIC API, SMALLER THAN THE DIRECTORY. `resource.ts`'s generic engine
5
+ * and each `*-form.ts`'s wire helpers are internals a provider needs but a consumer should
6
+ * not depend on — mirrors `../netbox/index.ts`.
7
+ */
8
+ export { UNIFI_READ_ONLY_POLICY, UnifiWriteRefused, type UnifiWriteAction } from './policy.ts';
9
+ export type { UnifiRequirements, UnifiSpec } from './resource.ts';
10
+ export {
11
+ UnifiNetwork,
12
+ UnifiNetworkProvider,
13
+ declareNetwork,
14
+ network,
15
+ type NetworkAttributes,
16
+ type NetworkProps,
17
+ } from './network.ts';
18
+ export {
19
+ UnifiFirewallZone,
20
+ UnifiFirewallZoneProvider,
21
+ declareFirewallZone,
22
+ firewallZone,
23
+ type FirewallZoneAttributes,
24
+ type FirewallZoneProps,
25
+ } from './firewall-zone.ts';
26
+ export { providers } from './providers.ts';
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `Unifi.Network`'s wire shape: the declarable subset of `NetworkDetails`, and how a live read
3
+ * becomes both plan attributes and a rendered declaration.
4
+ *
5
+ * ⚠️ PROPS MIRROR `UpdateNetworkRequest`, NOT `NetworkDetails`. `default` and `metadata` are
6
+ * server-derived — no writable endpoint in `networks.ts` accepts either — so they are
7
+ * attributes-only, reported but never compared or declared. Everything else in `NetworkProps`
8
+ * is exactly what `updateNetwork`/`createNetwork` would accept, even though this family never
9
+ * calls either: the shape is what a future write path would need, and what today's read-only
10
+ * `matches` compares against.
11
+ *
12
+ * ⛔ `ipv4Configuration` STAYS `unknown`, ON THE SDK'S OWN AUTHORITY. `networks.ts`'s own comment
13
+ * ("Shape varies by variant — widened by scripts/convert.ts; see README") says the OpenAPI
14
+ * discriminator this field varies over was flattened at generation time, not decoded per
15
+ * variant. Comparing it as an opaque JSON value (below) is honest about that; guessing a
16
+ * narrower shape here would silently stop matching the day a new variant's key order differs.
17
+ * ⚠️ It may ALSO hide an unordered array the way `dhcpGuarding` and `ipv6Configuration` do
18
+ * below — this family just can't know, because the shape is never decoded. If a future variant
19
+ * plans spurious `update`s, suspect this field first and widen it into a typed, order-normalized
20
+ * shape rather than adding another opaque comparison.
21
+ *
22
+ * ⛔ THREE FIELDS ARE SETS, NOT SEQUENCES: `dhcpGuarding.trustedDhcpServerIpAddresses`,
23
+ * `ipv6Configuration.additionalHostIpSubnets`, and `ipv6Configuration.dnsServerIpAddressesOverride`.
24
+ * The vendor document gives no indication any of the three is ordered — same reasoning
25
+ * `firewall-zone-form.ts` gives for `networkIds`, and `proxmox/user-wire.ts`'s `groupSet` gives
26
+ * for PVE's `groups` — but `matches` compares via alchemy's `deepEqual`, which sorts object KEYS
27
+ * but NOT ARRAY ELEMENTS (`node_modules/alchemy/lib/Diff.js`). Left alone, a console that
28
+ * returns the same set in a different order between calls plans a spurious `update`, and the
29
+ * read-only reconcile then refuses every deploy. `sortedSet` (dedupe + sort) neutralizes that
30
+ * before any of the three is attributed, declared, or compared. Every OTHER array field in this
31
+ * family (there are none today besides these three) would stay declaration-order as written.
32
+ */
33
+ import { deepEqual } from 'alchemy/Diff';
34
+ import type * as networks from '@distilled.cloud/unifi-network/networks';
35
+
36
+ const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();
37
+
38
+ // ⚠️ `value == null` (NOT `=== undefined`) ON PURPOSE, IN BOTH NORMALIZERS BELOW. The SDK types
39
+ // `dhcpGuarding`/`ipv6Configuration` `T | undefined`, but `matches`'s own header says UniFi's
40
+ // JSON answers `null` for an unset optional field — a reality `deepEqual`'s `stripNullish`
41
+ // already treats as equivalent to `undefined` (`value == null` in alchemy's `Diff.js`), which
42
+ // this family's props/attributes types don't model at the TS level. Destructuring a `null` with
43
+ // `=== undefined`'s false branch (`{ ...value, field: value.field }`) would throw
44
+ // `TypeError: Cannot read properties of null` the first time a console omits either field —
45
+ // `== null` catches both and returns the value through untouched, matching what the rest of
46
+ // this file already does for every other optional field.
47
+ const normalizeDhcpGuarding = (
48
+ value: networks.NetworkDHCPGuarding | undefined,
49
+ ): networks.NetworkDHCPGuarding | undefined =>
50
+ value == null
51
+ ? value
52
+ : { ...value, trustedDhcpServerIpAddresses: sortedSet(value.trustedDhcpServerIpAddresses) };
53
+
54
+ // ⚠️ `exactOptionalPropertyTypes` (see the props header) means these two subfields must be
55
+ // OMITTED, not set to an explicit `undefined`, when the live/declared value has no override —
56
+ // `NetworkIPv6Configuration` types them `field?: T`, not `field?: T | undefined`. Conditional
57
+ // spread (rather than `field: value.field === undefined ? undefined : sortedSet(...)`) is what
58
+ // keeps an absent key absent.
59
+ const normalizeIpv6Configuration = (
60
+ value: networks.NetworkIPv6Configuration | undefined,
61
+ ): networks.NetworkIPv6Configuration | undefined =>
62
+ value == null
63
+ ? value
64
+ : {
65
+ ...value,
66
+ ...(value.additionalHostIpSubnets !== undefined
67
+ ? { additionalHostIpSubnets: sortedSet(value.additionalHostIpSubnets) }
68
+ : {}),
69
+ ...(value.dnsServerIpAddressesOverride !== undefined
70
+ ? { dnsServerIpAddressesOverride: sortedSet(value.dnsServerIpAddressesOverride) }
71
+ : {}),
72
+ };
73
+
74
+ /**
75
+ * ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` ON PURPOSE. `tsconfig.json` sets
76
+ * `exactOptionalPropertyTypes`, which treats `field?: T` (absent-or-T) and
77
+ * `field?: T | undefined` (absent-or-explicitly-undefined) as different types. `declareNetwork`
78
+ * below assigns `live.dhcpGuarding` (typed `T | undefined`) straight through — the whole point
79
+ * of a renderer that echoes a live optional field structurally rather than defaulting it — so
80
+ * the target has to admit an explicit `undefined`, not just omission.
81
+ */
82
+ export interface NetworkProps {
83
+ siteId: string;
84
+ networkId: string;
85
+ name: string;
86
+ enabled: boolean;
87
+ management: string;
88
+ /** VLAN ID. Must be 1 for the default network and >= 2 for additional networks. */
89
+ vlanId: number;
90
+ dhcpGuarding?: networks.NetworkDHCPGuarding | undefined;
91
+ cellularBackupEnabled?: boolean | undefined;
92
+ internetAccessEnabled?: boolean | undefined;
93
+ /** Opaque — see the header. */
94
+ ipv4Configuration?: unknown;
95
+ ipv6Configuration?: networks.NetworkIPv6Configuration | undefined;
96
+ isolationEnabled?: boolean | undefined;
97
+ mdnsForwardingEnabled?: boolean | undefined;
98
+ zoneId?: string | undefined;
99
+ deviceId?: string | undefined;
100
+ }
101
+
102
+ export interface NetworkAttributes {
103
+ siteId: string;
104
+ networkId: string;
105
+ /** Whether this is the site's undeletable default network — server-derived, never declared. */
106
+ default: boolean;
107
+ name: string;
108
+ enabled: boolean;
109
+ management: string;
110
+ vlanId: number;
111
+ dhcpGuarding: networks.NetworkDHCPGuarding | undefined;
112
+ cellularBackupEnabled: boolean | undefined;
113
+ internetAccessEnabled: boolean | undefined;
114
+ ipv4Configuration: unknown;
115
+ ipv6Configuration: networks.NetworkIPv6Configuration | undefined;
116
+ isolationEnabled: boolean | undefined;
117
+ mdnsForwardingEnabled: boolean | undefined;
118
+ zoneId: string | undefined;
119
+ deviceId: string | undefined;
120
+ }
121
+
122
+ export const attributesOf = (
123
+ live: networks.NetworkDetails,
124
+ props: NetworkProps,
125
+ ): NetworkAttributes => ({
126
+ siteId: props.siteId,
127
+ networkId: live.id,
128
+ default: live.default,
129
+ name: live.name,
130
+ enabled: live.enabled,
131
+ management: live.management,
132
+ vlanId: live.vlanId,
133
+ dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),
134
+ cellularBackupEnabled: live.cellularBackupEnabled,
135
+ internetAccessEnabled: live.internetAccessEnabled,
136
+ ipv4Configuration: live.ipv4Configuration,
137
+ ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),
138
+ isolationEnabled: live.isolationEnabled,
139
+ mdnsForwardingEnabled: live.mdnsForwardingEnabled,
140
+ zoneId: live.zoneId,
141
+ deviceId: live.deviceId,
142
+ });
143
+
144
+ /**
145
+ * ⚠️ `stripNullish: true` ON EVERY COMPARISON. UniFi's JSON answers `null` for an unset optional
146
+ * field; a declaration that omits the same prop carries `undefined`. Without this option every
147
+ * plan against an object with one unset optional field would report `update` forever — the
148
+ * exact "read and write are different shapes" trap `netbox/values.ts` and `proxmox/user-wire.ts`
149
+ * both work around, here solved once with the engine's own `deepEqual` instead of a per-field
150
+ * coercion, because nothing here needs PVE's or NetBox's extra wire-format translation.
151
+ */
152
+ export const matches = (attributes: NetworkAttributes, props: NetworkProps): boolean =>
153
+ attributes.name === props.name &&
154
+ attributes.enabled === props.enabled &&
155
+ attributes.management === props.management &&
156
+ attributes.vlanId === props.vlanId &&
157
+ deepEqual(
158
+ normalizeDhcpGuarding(attributes.dhcpGuarding),
159
+ normalizeDhcpGuarding(props.dhcpGuarding),
160
+ {
161
+ stripNullish: true,
162
+ },
163
+ ) &&
164
+ deepEqual(attributes.cellularBackupEnabled, props.cellularBackupEnabled, {
165
+ stripNullish: true,
166
+ }) &&
167
+ deepEqual(attributes.internetAccessEnabled, props.internetAccessEnabled, {
168
+ stripNullish: true,
169
+ }) &&
170
+ deepEqual(attributes.ipv4Configuration, props.ipv4Configuration, { stripNullish: true }) &&
171
+ deepEqual(
172
+ normalizeIpv6Configuration(attributes.ipv6Configuration),
173
+ normalizeIpv6Configuration(props.ipv6Configuration),
174
+ { stripNullish: true },
175
+ ) &&
176
+ deepEqual(attributes.isolationEnabled, props.isolationEnabled, { stripNullish: true }) &&
177
+ deepEqual(attributes.mdnsForwardingEnabled, props.mdnsForwardingEnabled, {
178
+ stripNullish: true,
179
+ }) &&
180
+ deepEqual(attributes.zoneId, props.zoneId, { stripNullish: true }) &&
181
+ deepEqual(attributes.deviceId, props.deviceId, { stripNullish: true });
182
+
183
+ /**
184
+ * The declaration renderer (task spec: "given one live object, return the props a declaration
185
+ * needs so its plan is noop"). A later import script feeds `getNetworkDetails`'s own response
186
+ * straight in and writes the result into `alchemy.run.ts` as `network('<id>', declareNetwork(live,
187
+ * siteId))` — no field is invented or defaulted here beyond what `attributesOf` already reports,
188
+ * so `matches(attributesOf(live, props), declareNetwork(live, siteId))` is `true` by construction
189
+ * (`network.test.ts` proves it) for any live object this SDK can decode.
190
+ */
191
+ export const declareNetwork = (live: networks.NetworkDetails, siteId: string): NetworkProps => ({
192
+ siteId,
193
+ networkId: live.id,
194
+ name: live.name,
195
+ enabled: live.enabled,
196
+ management: live.management,
197
+ vlanId: live.vlanId,
198
+ dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),
199
+ cellularBackupEnabled: live.cellularBackupEnabled,
200
+ internetAccessEnabled: live.internetAccessEnabled,
201
+ ipv4Configuration: live.ipv4Configuration,
202
+ ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),
203
+ isolationEnabled: live.isolationEnabled,
204
+ mdnsForwardingEnabled: live.mdnsForwardingEnabled,
205
+ zoneId: live.zoneId,
206
+ deviceId: live.deviceId,
207
+ });