@homeflare/alchemy 0.31.2 → 0.33.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 (184) hide show
  1. package/README.md +39 -3
  2. package/dist/grafana/dashboard-model.d.ts +77 -0
  3. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  4. package/dist/grafana/dashboard.d.ts +98 -0
  5. package/dist/grafana/dashboard.d.ts.map +1 -0
  6. package/dist/grafana/folder.d.ts +100 -0
  7. package/dist/grafana/folder.d.ts.map +1 -0
  8. package/dist/grafana/index.d.ts +9 -8
  9. package/dist/grafana/index.d.ts.map +1 -1
  10. package/dist/grafana/index.js +158 -6
  11. package/dist/grafana/index.js.map +8 -4
  12. package/dist/grafana/providers.d.ts +1 -1
  13. package/dist/grafana/providers.d.ts.map +1 -1
  14. package/dist/grafana/provisioned.d.ts +11 -0
  15. package/dist/grafana/provisioned.d.ts.map +1 -0
  16. package/dist/linux/container-form.d.ts +129 -0
  17. package/dist/linux/container-form.d.ts.map +1 -0
  18. package/dist/linux/container-generator.d.ts +74 -0
  19. package/dist/linux/container-generator.d.ts.map +1 -0
  20. package/dist/linux/container-handlers.d.ts +15 -0
  21. package/dist/linux/container-handlers.d.ts.map +1 -0
  22. package/dist/linux/container-lifecycle.d.ts +37 -0
  23. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  24. package/dist/linux/container-preflight.d.ts +34 -0
  25. package/dist/linux/container-preflight.d.ts.map +1 -0
  26. package/dist/linux/container-secrets.d.ts +37 -0
  27. package/dist/linux/container-secrets.d.ts.map +1 -0
  28. package/dist/linux/container-settle.d.ts +34 -0
  29. package/dist/linux/container-settle.d.ts.map +1 -0
  30. package/dist/linux/container.d.ts +30 -0
  31. package/dist/linux/container.d.ts.map +1 -0
  32. package/dist/linux/fake-quadlet.d.ts +25 -0
  33. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  34. package/dist/linux/index.d.ts +6 -3
  35. package/dist/linux/index.d.ts.map +1 -1
  36. package/dist/linux/index.js +641 -268
  37. package/dist/linux/index.js.map +15 -7
  38. package/dist/linux/providers.d.ts +1 -1
  39. package/dist/linux/providers.d.ts.map +1 -1
  40. package/dist/linux/systemctl.d.ts +7 -1
  41. package/dist/linux/systemctl.d.ts.map +1 -1
  42. package/dist/opnsense/alias-form.d.ts +29 -0
  43. package/dist/opnsense/alias-form.d.ts.map +1 -0
  44. package/dist/opnsense/alias.d.ts +81 -0
  45. package/dist/opnsense/alias.d.ts.map +1 -0
  46. package/dist/opnsense/category-form.d.ts +10 -0
  47. package/dist/opnsense/category-form.d.ts.map +1 -0
  48. package/dist/opnsense/category.d.ts +58 -0
  49. package/dist/opnsense/category.d.ts.map +1 -0
  50. package/dist/opnsense/credentials.d.ts +36 -0
  51. package/dist/opnsense/credentials.d.ts.map +1 -0
  52. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  53. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  54. package/dist/opnsense/group-form.d.ts +11 -0
  55. package/dist/opnsense/group-form.d.ts.map +1 -0
  56. package/dist/opnsense/group.d.ts +62 -0
  57. package/dist/opnsense/group.d.ts.map +1 -0
  58. package/dist/opnsense/index.d.ts +16 -0
  59. package/dist/opnsense/index.d.ts.map +1 -0
  60. package/dist/opnsense/index.js +211 -0
  61. package/dist/opnsense/index.js.map +20 -0
  62. package/dist/opnsense/policy.d.ts +20 -0
  63. package/dist/opnsense/policy.d.ts.map +1 -0
  64. package/dist/opnsense/providers.d.ts +13 -0
  65. package/dist/opnsense/providers.d.ts.map +1 -0
  66. package/dist/opnsense/resource.d.ts +74 -0
  67. package/dist/opnsense/resource.d.ts.map +1 -0
  68. package/dist/opnsense/wire.d.ts +39 -0
  69. package/dist/opnsense/wire.d.ts.map +1 -0
  70. package/dist/proxmox/index.js +170 -80
  71. package/dist/proxmox/index.js.map +9 -7
  72. package/dist/proxmox/storage-form.d.ts +54 -0
  73. package/dist/proxmox/storage-form.d.ts.map +1 -0
  74. package/dist/proxmox/storage-wire.d.ts +56 -0
  75. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  76. package/dist/proxmox/storage.d.ts +55 -60
  77. package/dist/proxmox/storage.d.ts.map +1 -1
  78. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  79. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  80. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  81. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  82. package/dist/proxmox/zfs-pool.d.ts +27 -52
  83. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  84. package/dist/unifi/fake-unifi.d.ts +48 -0
  85. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  86. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  87. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  88. package/dist/unifi/firewall-zone.d.ts +50 -0
  89. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  90. package/dist/unifi/index.d.ts +13 -0
  91. package/dist/unifi/index.d.ts.map +1 -0
  92. package/dist/unifi/index.js +190 -0
  93. package/dist/unifi/index.js.map +16 -0
  94. package/dist/unifi/network-form.d.ts +67 -0
  95. package/dist/unifi/network-form.d.ts.map +1 -0
  96. package/dist/unifi/network.d.ts +67 -0
  97. package/dist/unifi/network.d.ts.map +1 -0
  98. package/dist/unifi/policy.d.ts +20 -0
  99. package/dist/unifi/policy.d.ts.map +1 -0
  100. package/dist/unifi/providers.d.ts +13 -0
  101. package/dist/unifi/providers.d.ts.map +1 -0
  102. package/dist/unifi/resource.d.ts +64 -0
  103. package/dist/unifi/resource.d.ts.map +1 -0
  104. package/docs/grafana-folder-dashboard.md +145 -0
  105. package/docs/grafana.md +11 -10
  106. package/docs/opnsense.md +146 -0
  107. package/docs/provider-standard.md +1 -1
  108. package/docs/quadlet-container.md +132 -0
  109. package/docs/unifi.md +93 -0
  110. package/docs/upstream-conformance.md +9 -4
  111. package/package.json +12 -2
  112. package/src/grafana/dashboard-model.test.ts +111 -0
  113. package/src/grafana/dashboard-model.ts +111 -0
  114. package/src/grafana/dashboard.test.ts +232 -0
  115. package/src/grafana/dashboard.ts +161 -0
  116. package/src/grafana/folder.test.ts +212 -0
  117. package/src/grafana/folder.ts +161 -0
  118. package/src/grafana/index.ts +21 -8
  119. package/src/grafana/providers.ts +7 -1
  120. package/src/grafana/provisioned.ts +49 -0
  121. package/src/linux/container-fixture.test.ts +92 -0
  122. package/src/linux/container-form.ts +243 -0
  123. package/src/linux/container-generator.test.ts +195 -0
  124. package/src/linux/container-generator.ts +113 -0
  125. package/src/linux/container-handlers.ts +73 -0
  126. package/src/linux/container-lifecycle.test.ts +209 -0
  127. package/src/linux/container-lifecycle.ts +217 -0
  128. package/src/linux/container-preflight.ts +97 -0
  129. package/src/linux/container-secrets.test.ts +171 -0
  130. package/src/linux/container-secrets.ts +112 -0
  131. package/src/linux/container-settle.ts +97 -0
  132. package/src/linux/container.ts +60 -0
  133. package/src/linux/fake-quadlet.ts +127 -0
  134. package/src/linux/index.ts +11 -3
  135. package/src/linux/providers.ts +2 -0
  136. package/src/linux/systemctl.ts +9 -0
  137. package/src/opnsense/alias-form.test.ts +89 -0
  138. package/src/opnsense/alias-form.ts +53 -0
  139. package/src/opnsense/alias.test.ts +203 -0
  140. package/src/opnsense/alias.ts +117 -0
  141. package/src/opnsense/category-form.test.ts +54 -0
  142. package/src/opnsense/category-form.ts +20 -0
  143. package/src/opnsense/category.test.ts +138 -0
  144. package/src/opnsense/category.ts +82 -0
  145. package/src/opnsense/credentials.ts +41 -0
  146. package/src/opnsense/fake-opnsense.ts +79 -0
  147. package/src/opnsense/group-form.test.ts +75 -0
  148. package/src/opnsense/group-form.ts +25 -0
  149. package/src/opnsense/group.test.ts +141 -0
  150. package/src/opnsense/group.ts +78 -0
  151. package/src/opnsense/index.ts +45 -0
  152. package/src/opnsense/policy.ts +49 -0
  153. package/src/opnsense/providers.ts +21 -0
  154. package/src/opnsense/resource.ts +146 -0
  155. package/src/opnsense/wire.ts +56 -0
  156. package/src/opnsense/write-refusal.test.ts +148 -0
  157. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  158. package/src/proxmox/storage-form.ts +144 -0
  159. package/src/proxmox/storage-read-failure.test.ts +214 -0
  160. package/src/proxmox/storage-wire.ts +124 -0
  161. package/src/proxmox/storage.ts +165 -175
  162. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  163. package/src/proxmox/zfs-pool-form.ts +236 -0
  164. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  165. package/src/proxmox/zfs-pool-wire.ts +132 -0
  166. package/src/proxmox/zfs-pool.ts +106 -76
  167. package/src/unifi/fake-unifi.ts +60 -0
  168. package/src/unifi/firewall-zone-form.ts +65 -0
  169. package/src/unifi/firewall-zone.test.ts +88 -0
  170. package/src/unifi/firewall-zone.ts +66 -0
  171. package/src/unifi/index.ts +26 -0
  172. package/src/unifi/network-form.ts +207 -0
  173. package/src/unifi/network.test.ts +209 -0
  174. package/src/unifi/network.ts +83 -0
  175. package/src/unifi/policy.ts +37 -0
  176. package/src/unifi/providers.ts +15 -0
  177. package/src/unifi/resource.test.ts +133 -0
  178. package/src/unifi/resource.ts +131 -0
  179. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  180. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  181. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  182. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  183. package/src/proxmox/zfs-pool-spec.ts +0 -76
  184. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -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
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `Unifi.Network`'s `spec` against a fake UniFi Network API, proving the real distilled wire
3
+ * path (path assembly, JSON decode, `NotFound` folding), the declaration renderer, and — the
4
+ * task's own requirement — that no handler this family exposes ever sends a non-`GET` request.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import type * as networks from '@distilled.cloud/unifi-network/networks';
8
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
9
+ import * as Effect from 'effect/Effect';
10
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
11
+ import { attributesOf, matches } from './network-form.ts';
12
+ import { type NetworkProps, declareNetwork, spec } from './network.ts';
13
+ import { unifiOperations } from './resource.ts';
14
+
15
+ const NETWORK_PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
16
+
17
+ const liveNetwork = (
18
+ overrides: Partial<networks.NetworkDetails> = {},
19
+ ): networks.NetworkDetails => ({
20
+ default: false,
21
+ enabled: true,
22
+ id: 'net-1',
23
+ management: 'ADVANCED',
24
+ metadata: { origin: 'USER' },
25
+ name: 'Cameras',
26
+ vlanId: 40,
27
+ isolationEnabled: true,
28
+ zoneId: 'zone-1',
29
+ ...overrides,
30
+ });
31
+
32
+ const PROPS: NetworkProps = {
33
+ siteId: 'site-1',
34
+ networkId: 'net-1',
35
+ name: 'Cameras',
36
+ enabled: true,
37
+ management: 'ADVANCED',
38
+ vlanId: 40,
39
+ isolationEnabled: true,
40
+ zoneId: 'zone-1',
41
+ };
42
+
43
+ describe('Unifi.Network spec.fetchLive', () => {
44
+ test('a real GET response decodes into the typed live object', async () => {
45
+ const fake = fakeUnifi((method, url) =>
46
+ method === 'GET' && url.pathname === NETWORK_PATH
47
+ ? Response.json(liveNetwork())
48
+ : fakeFailure(400, 'unexpected request'),
49
+ );
50
+ const live = await Effect.runPromise(
51
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
52
+ );
53
+ expect(live?.id).toBe('net-1');
54
+ expect(fake.seen).toEqual([{ method: 'GET', path: NETWORK_PATH }]);
55
+ });
56
+
57
+ test('a genuine 404 folds to undefined -- never a false "unreadable" on anything else', async () => {
58
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
59
+ const live = await Effect.runPromise(
60
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
61
+ );
62
+ expect(live).toBeUndefined();
63
+ });
64
+
65
+ test('a 500 fails loudly -- only a genuine not-found may mean absent', async () => {
66
+ // ⚠️ `Retry.none`: the default policy retries a 500 (one of core's DEFAULT_ERROR_STATUSES)
67
+ // indefinitely with backoff -- this test asserts the FAILURE, not the retry schedule.
68
+ const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
69
+ const failure = await Effect.runPromise(
70
+ Effect.flip(
71
+ spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
72
+ ),
73
+ );
74
+ expect(failure._tag).toBe('InternalServerError');
75
+ });
76
+ });
77
+
78
+ describe('declareNetwork -- the declaration renderer', () => {
79
+ test('its output matches attributesOf(live) by construction, for any live object', () => {
80
+ const live = liveNetwork({ name: 'Guest', vlanId: 99, isolationEnabled: false });
81
+ const declared = declareNetwork(live, 'site-1');
82
+ const attrs = attributesOf(live, declared);
83
+ expect(matches(attrs, declared)).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe('matches -- unordered arrays (dhcpGuarding, ipv6Configuration)', () => {
88
+ // ⛔ `deepEqual` (alchemy/Diff) sorts object keys but not array elements: without normalizing
89
+ // these three fields (see network-form.ts's header), a console returning the same set in a
90
+ // different order would plan a spurious update.
91
+ test('the same trusted DHCP servers in a different order is a noop', () => {
92
+ const live = liveNetwork({
93
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.2', '10.0.0.1'] },
94
+ });
95
+ const props: NetworkProps = {
96
+ ...PROPS,
97
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
98
+ };
99
+ expect(matches(attributesOf(live, props), props)).toBe(true);
100
+ });
101
+
102
+ test('a different set of trusted DHCP servers is an update', () => {
103
+ const live = liveNetwork({
104
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
105
+ });
106
+ const props: NetworkProps = {
107
+ ...PROPS,
108
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.3'] },
109
+ };
110
+ expect(matches(attributesOf(live, props), props)).toBe(false);
111
+ });
112
+
113
+ test('the same IPv6 host subnets and DNS overrides in a different order is a noop', () => {
114
+ const live = liveNetwork({
115
+ ipv6Configuration: {
116
+ clientAddressAssignment: { slaacEnabled: true },
117
+ interfaceType: 'ETHERNET',
118
+ additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
119
+ dnsServerIpAddressesOverride: ['2001:4860:4860::8888', '2001:4860:4860::8844'],
120
+ },
121
+ });
122
+ const props: NetworkProps = {
123
+ ...PROPS,
124
+ ipv6Configuration: {
125
+ clientAddressAssignment: { slaacEnabled: true },
126
+ interfaceType: 'ETHERNET',
127
+ additionalHostIpSubnets: ['2001:db8:1::/64', '2001:db8::/64'],
128
+ dnsServerIpAddressesOverride: ['2001:4860:4860::8844', '2001:4860:4860::8888'],
129
+ },
130
+ };
131
+ expect(matches(attributesOf(live, props), props)).toBe(true);
132
+ });
133
+
134
+ test('a different set of IPv6 host subnets is an update', () => {
135
+ const live = liveNetwork({
136
+ ipv6Configuration: {
137
+ clientAddressAssignment: { slaacEnabled: true },
138
+ interfaceType: 'ETHERNET',
139
+ additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
140
+ },
141
+ });
142
+ const props: NetworkProps = {
143
+ ...PROPS,
144
+ ipv6Configuration: {
145
+ clientAddressAssignment: { slaacEnabled: true },
146
+ interfaceType: 'ETHERNET',
147
+ additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:2::/64'],
148
+ },
149
+ };
150
+ expect(matches(attributesOf(live, props), props)).toBe(false);
151
+ });
152
+
153
+ test('a live `null` (not `undefined`) for either field does not throw', () => {
154
+ // UniFi's JSON answers `null` for an unset optional field (see network-form.ts's header) —
155
+ // a reality the SDK's `T | undefined` types don't model. The double cast lands `null` at
156
+ // runtime the same way a real JSON decode would, past what the type system alone allows.
157
+ const live = {
158
+ ...liveNetwork(),
159
+ dhcpGuarding: null,
160
+ ipv6Configuration: null,
161
+ } as unknown as networks.NetworkDetails;
162
+ const declared = declareNetwork(live, 'site-1');
163
+ expect(() => attributesOf(live, declared)).not.toThrow();
164
+ expect(matches(attributesOf(live, declared), declared)).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe('Unifi.Network write paths never reach the vendor API', () => {
169
+ const ops = unifiOperations(spec);
170
+
171
+ test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
172
+ const fake = fakeUnifi((method, url) =>
173
+ method === 'GET' && url.pathname === NETWORK_PATH
174
+ ? Response.json(liveNetwork())
175
+ : fakeFailure(400, 'unexpected request'),
176
+ );
177
+ const exit = await Effect.runPromiseExit(
178
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
179
+ );
180
+ expect(exit._tag).toBe('Success');
181
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
182
+ });
183
+
184
+ test('reconcile on drift refuses instead of PUTting a merged body', async () => {
185
+ const fake = fakeUnifi((method, url) =>
186
+ method === 'GET' && url.pathname === NETWORK_PATH
187
+ ? Response.json(liveNetwork({ vlanId: 999 }))
188
+ : fakeFailure(400, 'unexpected request'),
189
+ );
190
+ const exit = await Effect.runPromiseExit(
191
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
192
+ );
193
+ expect(exit._tag).toBe('Failure');
194
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
195
+ });
196
+
197
+ test('delete always refuses -- no DELETE is ever sent', async () => {
198
+ const fake = fakeUnifi((method, url) =>
199
+ method === 'GET' && url.pathname === NETWORK_PATH
200
+ ? Response.json(liveNetwork())
201
+ : fakeFailure(400, 'unexpected request'),
202
+ );
203
+ const exit = await Effect.runPromiseExit(
204
+ ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
205
+ );
206
+ expect(exit._tag).toBe('Failure');
207
+ expect(fake.seen).toEqual([]);
208
+ });
209
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `Unifi.Network` — one network on a UniFi Network site, READ-ONLY (`policy.ts`).
3
+ *
4
+ * ★ WHY NETWORK IS IN THE FIRST IMPORT SET. `getNetworkDetails`/`getNetworksOverviewPage`
5
+ * (`../../distilled-unifi-network/src/services/networks.ts`) is a list+get pair over
6
+ * genuine configuration — VLAN, DHCP guarding, IPv4/IPv6 addressing, isolation — not runtime
7
+ * state like a connected client, a device statistic or a hotspot voucher. It is also the SDK
8
+ * package's own README recommendation for a first read/adopt-only resource, alongside `Site`
9
+ * (skipped here — no `getSite`, only the list operation, so it fails this task's own "both a
10
+ * list and a get" bar) and `FirewallZone`/`FirewallPolicy` (`firewall-zone.ts` ships
11
+ * `FirewallZone`; `FirewallPolicy`'s several discriminated filter variants and its
12
+ * list-replacing ordering endpoint are a bigger, separate PR).
13
+ *
14
+ * ⛔ THE WHOLE-OBJECT-PUT TRAP (`docs/unifi-api-notes.md`) NEVER FIRES HERE, BY CONSTRUCTION.
15
+ * `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration`
16
+ * outright when the caller omits them — the exact failure mode a read-only family cannot cause,
17
+ * because `resource.ts`'s `reconcile` never calls `updateNetwork` at all. Recorded here for
18
+ * whoever adds the write path this docs note anticipates: read-merge-write the WHOLE object,
19
+ * never a props-only body.
20
+ */
21
+ import { Resource } from 'alchemy';
22
+ import { adopt } from 'alchemy/AdoptPolicy';
23
+ import * as Provider from 'alchemy/Provider';
24
+ import * as networks from '@distilled.cloud/unifi-network/networks';
25
+ import * as Effect from 'effect/Effect';
26
+ import {
27
+ type NetworkAttributes,
28
+ type NetworkProps,
29
+ attributesOf,
30
+ matches,
31
+ } from './network-form.ts';
32
+ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
33
+ import { unifiHandlers } from './resource.ts';
34
+
35
+ export type { NetworkAttributes, NetworkProps } from './network-form.ts';
36
+ export { declareNetwork } from './network-form.ts';
37
+
38
+ export interface UnifiNetwork extends Resource<
39
+ 'Unifi.Network',
40
+ NetworkProps,
41
+ NetworkAttributes,
42
+ never,
43
+ UnifiRequirements
44
+ > {}
45
+
46
+ export const UnifiNetwork = Resource<UnifiNetwork>('Unifi.Network', {
47
+ defaultRemovalPolicy: 'retain',
48
+ });
49
+
50
+ /**
51
+ * `network('lan', props)` — `adopt(true)` piped on by default (H5). This family never writes, so
52
+ * the only thing a first deploy against an existing network could ever do is bind to it or refuse
53
+ * `OwnedBySomeoneElse`; piping `adopt(true)` here picks the former without a stack having to
54
+ * remember `--adopt`, mirroring `discord/application-command.ts`'s `applicationCommand`.
55
+ */
56
+ export const network = (id: string, props: NetworkProps) =>
57
+ UnifiNetwork(id, props).pipe(adopt(true));
58
+
59
+ /**
60
+ * ★ EXPORTED for direct testing against a fake `Credentials` layer (`fake-unifi.ts`), the same
61
+ * seam `netbox/prefix.ts`'s `spec` and `discord/application-command.ts`'s `spec` use — `handlers`
62
+ * bakes in `CredentialsFromEnv`, which resolves environment variables a test cannot repoint.
63
+ */
64
+ export const spec: UnifiSpec<
65
+ NetworkProps,
66
+ networks.NetworkDetails,
67
+ NetworkAttributes,
68
+ networks.GetNetworkDetailsError
69
+ > = {
70
+ type: 'Unifi.Network',
71
+ describe: (props) => `sites/${props.siteId}/networks/${props.networkId}`,
72
+ fetchLive: (props) =>
73
+ networks
74
+ .getNetworkDetails({ siteId: props.siteId, networkId: props.networkId })
75
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
76
+ attributes: attributesOf,
77
+ matches,
78
+ };
79
+
80
+ export const handlers = unifiHandlers(spec);
81
+
82
+ export const UnifiNetworkProvider = () =>
83
+ Provider.effect(UnifiNetwork, Effect.succeed(UnifiNetwork.Provider.of(handlers)));