@homeflare/alchemy 0.38.0 → 0.39.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.
- package/README.md +22 -0
- package/dist/forgejo/index.js +4 -2
- package/dist/forgejo/index.js.map +3 -3
- package/dist/forgejo/team-member.d.ts +25 -0
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +14 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +565 -133
- package/dist/proxmox/index.js.map +33 -23
- package/dist/proxmox/qemu-form.d.ts +27 -18
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts +30 -0
- package/dist/proxmox/qemu-judge.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-net.d.ts +9 -0
- package/dist/proxmox/qemu-net.d.ts.map +1 -0
- package/dist/proxmox/qemu-props.d.ts +112 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -0
- package/dist/proxmox/qemu-volume.d.ts +17 -0
- package/dist/proxmox/qemu-volume.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +14 -27
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/storage-download-errors.d.ts +5 -0
- package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
- package/dist/proxmox/storage-download-identity.d.ts +27 -0
- package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/storage-download-props.d.ts +36 -0
- package/dist/proxmox/storage-download-props.d.ts.map +1 -0
- package/dist/proxmox/storage-download-task.d.ts +17 -0
- package/dist/proxmox/storage-download-task.d.ts.map +1 -0
- package/dist/proxmox/storage-download.d.ts +44 -0
- package/dist/proxmox/storage-download.d.ts.map +1 -0
- package/dist/telemetry/fake-collector.d.ts +36 -0
- package/dist/telemetry/fake-collector.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +223 -0
- package/dist/telemetry/index.js.map +12 -0
- package/dist/telemetry/layer.d.ts +86 -0
- package/dist/telemetry/layer.d.ts.map +1 -0
- package/dist/telemetry/redact-serialization.d.ts +21 -0
- package/dist/telemetry/redact-serialization.d.ts.map +1 -0
- package/dist/telemetry/redact.d.ts +46 -0
- package/dist/telemetry/redact.d.ts.map +1 -0
- package/dist/unifi/drift.d.ts +27 -0
- package/dist/unifi/drift.d.ts.map +1 -0
- package/dist/unifi/fake-unifi.d.ts +5 -1
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-zone-form.d.ts +6 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +4 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +138 -58
- package/dist/unifi/index.js.map +10 -8
- package/dist/unifi/network-drift.d.ts +26 -0
- package/dist/unifi/network-drift.d.ts.map +1 -0
- package/dist/unifi/network-form.d.ts +40 -7
- package/dist/unifi/network-form.d.ts.map +1 -1
- package/dist/unifi/network.d.ts +1 -0
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/paginate.d.ts +36 -0
- package/dist/unifi/paginate.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +36 -1
- package/dist/unifi/resource.d.ts.map +1 -1
- package/docs/telemetry-spike.md +94 -0
- package/docs/telemetry.md +109 -0
- package/docs/unifi.md +56 -0
- package/package.json +6 -2
- package/src/forgejo/team-member.test.ts +14 -0
- package/src/forgejo/team-member.ts +28 -1
- package/src/proxmox/constraints-required.test.ts +1 -0
- package/src/proxmox/generated/constraints/index.ts +3 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.test.ts +11 -6
- package/src/proxmox/index.ts +19 -3
- package/src/proxmox/qemu-form.ts +49 -13
- package/src/proxmox/qemu-identity.test.ts +30 -0
- package/src/proxmox/qemu-judge.test.ts +65 -0
- package/src/proxmox/qemu-judge.ts +84 -0
- package/src/proxmox/qemu-lifecycle.test.ts +78 -19
- package/src/proxmox/qemu-lifecycle.ts +131 -32
- package/src/proxmox/qemu-net.test.ts +48 -0
- package/src/proxmox/qemu-net.ts +78 -0
- package/src/proxmox/qemu-ownership.test.ts +44 -0
- package/src/proxmox/qemu-props.test.ts +87 -0
- package/src/proxmox/qemu-props.ts +157 -0
- package/src/proxmox/qemu-volume.test.ts +80 -0
- package/src/proxmox/qemu-volume.ts +121 -0
- package/src/proxmox/qemu.ts +15 -29
- package/src/proxmox/storage-download-errors.ts +7 -0
- package/src/proxmox/storage-download-identity.test.ts +54 -0
- package/src/proxmox/storage-download-identity.ts +50 -0
- package/src/proxmox/storage-download-lifecycle.ts +226 -0
- package/src/proxmox/storage-download-ownership.test.ts +50 -0
- package/src/proxmox/storage-download-props.ts +38 -0
- package/src/proxmox/storage-download-task.ts +72 -0
- package/src/proxmox/storage-download.test.ts +216 -0
- package/src/proxmox/storage-download.ts +58 -0
- package/src/telemetry/fake-collector.ts +59 -0
- package/src/telemetry/http-client-sealing.test.ts +82 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/layer.test.ts +204 -0
- package/src/telemetry/layer.ts +152 -0
- package/src/telemetry/redact-serialization.ts +178 -0
- package/src/telemetry/redact.test.ts +124 -0
- package/src/telemetry/redact.ts +147 -0
- package/src/unifi/drift.test.ts +78 -0
- package/src/unifi/drift.ts +56 -0
- package/src/unifi/errors-and-secrets.test.ts +127 -0
- package/src/unifi/fake-unifi.ts +5 -1
- package/src/unifi/firewall-zone-form.ts +31 -2
- package/src/unifi/firewall-zone.test.ts +21 -1
- package/src/unifi/firewall-zone.ts +1 -1
- package/src/unifi/get-only-guard.test.ts +165 -0
- package/src/unifi/index.ts +15 -1
- package/src/unifi/network-drift.test.ts +207 -0
- package/src/unifi/network-drift.ts +80 -0
- package/src/unifi/network-form.ts +11 -39
- package/src/unifi/network.test.ts +7 -82
- package/src/unifi/network.ts +6 -6
- package/src/unifi/paginate.test.ts +228 -0
- package/src/unifi/paginate.ts +129 -0
- package/src/unifi/resource.ts +73 -3
- package/src/unifi/write-op-reference.test.ts +236 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"firewall-zone.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-zone.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AACpE,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,EAGvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAGlE,YAAY,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"firewall-zone.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-zone.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AACpE,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,EAGvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAGlE,YAAY,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAEvE,MAAM,WAAW,iBAAkB,SAAQ,QAAQ,CACjD,oBAAoB,EACpB,iBAAiB,EACjB,sBAAsB,EACtB,KAAK,EACL,iBAAiB,CAClB;CAAG;AAEJ,eAAO,MAAM,iBAAiB,oDAE5B,CAAC;AAEH,+FAA+F;AAC/F,eAAO,MAAM,YAAY,OAAQ,MAAM,SAAS,iBAAiB,kGACjB,CAAC;AAEjD,iGAAiG;AACjG,eAAO,MAAM,IAAI,EAAE,SAAS,CAC1B,iBAAiB,EACjB,QAAQ,CAAC,YAAY,EACrB,sBAAsB,EACtB,QAAQ,CAAC,oBAAoB,CAU9B,CAAC;AAEF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;CAAsB,CAAC;AAE5C,eAAO,MAAM,yBAAyB,uIACuD,CAAC"}
|
package/dist/unifi/index.d.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* not depend on — mirrors `../netbox/index.ts`.
|
|
7
7
|
*/
|
|
8
8
|
export { UNIFI_READ_ONLY_POLICY, UnifiWriteRefused, type UnifiWriteAction } from './policy.ts';
|
|
9
|
-
export type
|
|
10
|
-
export
|
|
11
|
-
export {
|
|
9
|
+
export { GetOnlyHttpClient, UnifiNonGetRequest, type UnifiRequirements, type UnifiSpec, } from './resource.ts';
|
|
10
|
+
export type { FieldDrift } from './drift.ts';
|
|
11
|
+
export { UnifiNetwork, UnifiNetworkProvider, declareNetwork, network, type NetworkAttributes, type NetworkProps, driftOf as networkDriftOf, } from './network.ts';
|
|
12
|
+
export { UnifiFirewallZone, UnifiFirewallZoneProvider, declareFirewallZone, firewallZone, type FirewallZoneAttributes, type FirewallZoneProps, driftOf as firewallZoneDriftOf, } from './firewall-zone.ts';
|
|
12
13
|
export { providers } from './providers.ts';
|
|
13
14
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/unifi/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/F,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/unifi/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/F,OAAO,EAIL,iBAAiB,EACjB,kBAAkB,EAClB,KAAK,iBAAiB,EACtB,KAAK,SAAS,GACf,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,cAAc,EACd,OAAO,EACP,KAAK,iBAAiB,EACtB,KAAK,YAAY,EAIjB,OAAO,IAAI,cAAc,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,mBAAmB,EACnB,YAAY,EACZ,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,EACtB,OAAO,IAAI,mBAAmB,GAC/B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/unifi/index.js
CHANGED
|
@@ -9,6 +9,73 @@ class UnifiWriteRefused extends Data.TaggedError("UnifiWriteRefused") {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
var refuseWrite = (type, identity, action) => Effect.fail(new UnifiWriteRefused({ type, identity, action }));
|
|
12
|
+
// src/unifi/resource.ts
|
|
13
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
14
|
+
import { isResolved } from "alchemy/Diff";
|
|
15
|
+
import { CredentialsFromEnv } from "@distilled.cloud/unifi-network/Credentials";
|
|
16
|
+
import * as Effect2 from "effect/Effect";
|
|
17
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
18
|
+
var unifiOperations = (spec) => {
|
|
19
|
+
const read = (props) => spec.fetchLive(props).pipe(Effect2.map((live) => live === undefined ? undefined : spec.attributes(live, props)));
|
|
20
|
+
return {
|
|
21
|
+
read,
|
|
22
|
+
readHandler: ({ olds, output }) => Effect2.gen(function* () {
|
|
23
|
+
const attrs = yield* read(olds);
|
|
24
|
+
if (attrs === undefined)
|
|
25
|
+
return;
|
|
26
|
+
return output !== undefined ? attrs : Unowned(attrs);
|
|
27
|
+
}),
|
|
28
|
+
diff: (news, output) => Effect2.gen(function* () {
|
|
29
|
+
if (output === undefined || !isResolved(news))
|
|
30
|
+
return;
|
|
31
|
+
const live = yield* read(news);
|
|
32
|
+
if (live === undefined)
|
|
33
|
+
return { action: "update" };
|
|
34
|
+
return spec.matches(live, news) ? { action: "noop" } : { action: "update" };
|
|
35
|
+
}),
|
|
36
|
+
reconcile: (news) => Effect2.gen(function* () {
|
|
37
|
+
const live = yield* read(news);
|
|
38
|
+
if (live === undefined)
|
|
39
|
+
return yield* refuseWrite(spec.type, spec.describe(news), "create");
|
|
40
|
+
if (!spec.matches(live, news)) {
|
|
41
|
+
return yield* refuseWrite(spec.type, spec.describe(news), "update");
|
|
42
|
+
}
|
|
43
|
+
return live;
|
|
44
|
+
}),
|
|
45
|
+
destroy: (olds) => refuseWrite(spec.type, spec.describe(olds), "delete")
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
class UnifiNonGetRequest extends Error {
|
|
50
|
+
method;
|
|
51
|
+
path;
|
|
52
|
+
constructor(method, path) {
|
|
53
|
+
super(`UniFi HttpClient guard: refused ${method} ${path} -- ${UNIFI_READ_ONLY_POLICY}. This is a ` + "defect, not a recoverable condition: something under src/unifi tried to send a non-GET " + "request, which policy.ts's per-operation refusal should already have made impossible. " + "Fix the kit code that produced this request.");
|
|
54
|
+
this.method = method;
|
|
55
|
+
this.path = path;
|
|
56
|
+
this.name = "UnifiNonGetRequest";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
var requestPath = (url) => {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(url).pathname;
|
|
62
|
+
} catch {
|
|
63
|
+
return "<unparseable request url>";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var guardGetOnly = (client) => client.pipe(HttpClient.mapRequestEffect((request) => request.method === "GET" ? Effect2.succeed(request) : Effect2.die(new UnifiNonGetRequest(request.method, requestPath(request.url)))));
|
|
67
|
+
var GetOnlyHttpClient = HttpClient.layerMergedContext(Effect2.map(HttpClient.HttpClient, guardGetOnly));
|
|
68
|
+
var unifiHandlers = (spec) => {
|
|
69
|
+
const ops = unifiOperations(spec);
|
|
70
|
+
const withCredentials = (effect) => effect.pipe(Effect2.provide(GetOnlyHttpClient), Effect2.provide(CredentialsFromEnv));
|
|
71
|
+
return {
|
|
72
|
+
list: () => Effect2.succeed([]),
|
|
73
|
+
read: (args) => withCredentials(ops.readHandler(args)),
|
|
74
|
+
diff: (args) => withCredentials(ops.diff(args.news, args.output)),
|
|
75
|
+
reconcile: (args) => withCredentials(ops.reconcile(args.news)),
|
|
76
|
+
delete: (args) => withCredentials(ops.destroy(args.olds))
|
|
77
|
+
};
|
|
78
|
+
};
|
|
12
79
|
// src/unifi/network.ts
|
|
13
80
|
import { Resource } from "alchemy";
|
|
14
81
|
import { adopt } from "alchemy/AdoptPolicy";
|
|
@@ -17,7 +84,6 @@ import * as networks from "@distilled.cloud/unifi-network/networks";
|
|
|
17
84
|
import * as Effect3 from "effect/Effect";
|
|
18
85
|
|
|
19
86
|
// src/unifi/network-form.ts
|
|
20
|
-
import { deepEqual } from "alchemy/Diff";
|
|
21
87
|
var sortedSet = (values) => [...new Set(values)].sort();
|
|
22
88
|
var normalizeDhcpGuarding = (value) => value == null ? value : { ...value, trustedDhcpServerIpAddresses: sortedSet(value.trustedDhcpServerIpAddresses) };
|
|
23
89
|
var normalizeIpv6Configuration = (value) => value == null ? value : {
|
|
@@ -43,15 +109,6 @@ var attributesOf = (live, props) => ({
|
|
|
43
109
|
zoneId: live.zoneId,
|
|
44
110
|
deviceId: live.deviceId
|
|
45
111
|
});
|
|
46
|
-
var matches = (attributes, props) => attributes.name === props.name && attributes.enabled === props.enabled && attributes.management === props.management && attributes.vlanId === props.vlanId && deepEqual(normalizeDhcpGuarding(attributes.dhcpGuarding), normalizeDhcpGuarding(props.dhcpGuarding), {
|
|
47
|
-
stripNullish: true
|
|
48
|
-
}) && deepEqual(attributes.cellularBackupEnabled, props.cellularBackupEnabled, {
|
|
49
|
-
stripNullish: true
|
|
50
|
-
}) && deepEqual(attributes.internetAccessEnabled, props.internetAccessEnabled, {
|
|
51
|
-
stripNullish: true
|
|
52
|
-
}) && deepEqual(attributes.ipv4Configuration, props.ipv4Configuration, { stripNullish: true }) && deepEqual(normalizeIpv6Configuration(attributes.ipv6Configuration), normalizeIpv6Configuration(props.ipv6Configuration), { stripNullish: true }) && deepEqual(attributes.isolationEnabled, props.isolationEnabled, { stripNullish: true }) && deepEqual(attributes.mdnsForwardingEnabled, props.mdnsForwardingEnabled, {
|
|
53
|
-
stripNullish: true
|
|
54
|
-
}) && deepEqual(attributes.zoneId, props.zoneId, { stripNullish: true }) && deepEqual(attributes.deviceId, props.deviceId, { stripNullish: true });
|
|
55
112
|
var declareNetwork = (live, siteId) => ({
|
|
56
113
|
siteId,
|
|
57
114
|
networkId: live.id,
|
|
@@ -70,52 +127,61 @@ var declareNetwork = (live, siteId) => ({
|
|
|
70
127
|
deviceId: live.deviceId
|
|
71
128
|
});
|
|
72
129
|
|
|
73
|
-
// src/unifi/
|
|
74
|
-
import {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
130
|
+
// src/unifi/drift.ts
|
|
131
|
+
import { deepEqual } from "alchemy/Diff";
|
|
132
|
+
var defaultEqual = (live, declared) => deepEqual(live, declared, { stripNullish: true });
|
|
133
|
+
var makeDriftOf = (fields) => (attributes, props) => fields.flatMap(({ field, live, declared, equal = defaultEqual }) => {
|
|
134
|
+
const liveValue = live(attributes);
|
|
135
|
+
const declaredValue = declared(props);
|
|
136
|
+
return equal(liveValue, declaredValue) ? [] : [{ field, live: liveValue, declared: declaredValue }];
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// src/unifi/network-drift.ts
|
|
140
|
+
var fieldDrift = makeDriftOf([
|
|
141
|
+
{ field: "name", live: (a) => a.name, declared: (p) => p.name },
|
|
142
|
+
{ field: "enabled", live: (a) => a.enabled, declared: (p) => p.enabled },
|
|
143
|
+
{ field: "management", live: (a) => a.management, declared: (p) => p.management },
|
|
144
|
+
{ field: "vlanId", live: (a) => a.vlanId, declared: (p) => p.vlanId },
|
|
145
|
+
{
|
|
146
|
+
field: "dhcpGuarding",
|
|
147
|
+
live: (a) => normalizeDhcpGuarding(a.dhcpGuarding),
|
|
148
|
+
declared: (p) => normalizeDhcpGuarding(p.dhcpGuarding)
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
field: "cellularBackupEnabled",
|
|
152
|
+
live: (a) => a.cellularBackupEnabled,
|
|
153
|
+
declared: (p) => p.cellularBackupEnabled
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
field: "internetAccessEnabled",
|
|
157
|
+
live: (a) => a.internetAccessEnabled,
|
|
158
|
+
declared: (p) => p.internetAccessEnabled
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
field: "ipv4Configuration",
|
|
162
|
+
live: (a) => a.ipv4Configuration,
|
|
163
|
+
declared: (p) => p.ipv4Configuration
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
field: "ipv6Configuration",
|
|
167
|
+
live: (a) => normalizeIpv6Configuration(a.ipv6Configuration),
|
|
168
|
+
declared: (p) => normalizeIpv6Configuration(p.ipv6Configuration)
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
field: "isolationEnabled",
|
|
172
|
+
live: (a) => a.isolationEnabled,
|
|
173
|
+
declared: (p) => p.isolationEnabled
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
field: "mdnsForwardingEnabled",
|
|
177
|
+
live: (a) => a.mdnsForwardingEnabled,
|
|
178
|
+
declared: (p) => p.mdnsForwardingEnabled
|
|
179
|
+
},
|
|
180
|
+
{ field: "zoneId", live: (a) => a.zoneId, declared: (p) => p.zoneId },
|
|
181
|
+
{ field: "deviceId", live: (a) => a.deviceId, declared: (p) => p.deviceId }
|
|
182
|
+
]);
|
|
183
|
+
var matches = (attributes, props) => fieldDrift(attributes, props).length === 0;
|
|
184
|
+
var driftOf = (live, props) => fieldDrift(attributesOf(live, props), props);
|
|
119
185
|
|
|
120
186
|
// src/unifi/network.ts
|
|
121
187
|
var UnifiNetwork = Resource("Unifi.Network", {
|
|
@@ -147,7 +213,17 @@ var attributesOf2 = (live, props) => ({
|
|
|
147
213
|
networkIds: sortedSet2(live.networkIds),
|
|
148
214
|
metadataOrigin: live.metadata.origin
|
|
149
215
|
});
|
|
150
|
-
var
|
|
216
|
+
var fieldDrift2 = makeDriftOf([
|
|
217
|
+
{ field: "name", live: (a) => a.name, declared: (p) => p.name },
|
|
218
|
+
{
|
|
219
|
+
field: "networkIds",
|
|
220
|
+
live: (a) => a.networkIds,
|
|
221
|
+
declared: (p) => sortedSet2(p.networkIds),
|
|
222
|
+
equal: (live, declared) => live.join(",") === declared.join(",")
|
|
223
|
+
}
|
|
224
|
+
]);
|
|
225
|
+
var matches2 = (attributes, props) => fieldDrift2(attributes, props).length === 0;
|
|
226
|
+
var driftOf2 = (live, props) => fieldDrift2(attributesOf2(live, props), props);
|
|
151
227
|
var declareFirewallZone = (live, siteId) => ({
|
|
152
228
|
siteId,
|
|
153
229
|
firewallZoneId: live.id,
|
|
@@ -173,18 +249,22 @@ var UnifiFirewallZoneProvider = () => Provider2.effect(UnifiFirewallZone, Effect
|
|
|
173
249
|
import * as Layer from "effect/Layer";
|
|
174
250
|
var providers = () => Layer.mergeAll(UnifiNetworkProvider(), UnifiFirewallZoneProvider());
|
|
175
251
|
export {
|
|
252
|
+
GetOnlyHttpClient,
|
|
176
253
|
UNIFI_READ_ONLY_POLICY,
|
|
177
254
|
UnifiFirewallZone,
|
|
178
255
|
UnifiFirewallZoneProvider,
|
|
179
256
|
UnifiNetwork,
|
|
180
257
|
UnifiNetworkProvider,
|
|
258
|
+
UnifiNonGetRequest,
|
|
181
259
|
UnifiWriteRefused,
|
|
182
260
|
declareFirewallZone,
|
|
183
261
|
declareNetwork,
|
|
184
262
|
firewallZone,
|
|
263
|
+
driftOf2 as firewallZoneDriftOf,
|
|
185
264
|
network,
|
|
265
|
+
driftOf as networkDriftOf,
|
|
186
266
|
providers
|
|
187
267
|
};
|
|
188
268
|
|
|
189
|
-
//# debugId=
|
|
269
|
+
//# debugId=557618804604F27464756E2164756E21
|
|
190
270
|
//# sourceMappingURL=index.js.map
|
package/dist/unifi/index.js.map
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/unifi/policy.ts", "../src/unifi/network.ts", "../src/unifi/network-form.ts", "../src/unifi/
|
|
3
|
+
"sources": ["../src/unifi/policy.ts", "../src/unifi/resource.ts", "../src/unifi/network.ts", "../src/unifi/network-form.ts", "../src/unifi/drift.ts", "../src/unifi/network-drift.ts", "../src/unifi/firewall-zone.ts", "../src/unifi/firewall-zone-form.ts", "../src/unifi/providers.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"/**\n * The one refusal every `Unifi.*` write handler returns — never an SDK write call.\n *\n * ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY FOR NOW. This family has no\n * `create`, no `update` body and no delete call anywhere in its source — `reconcile` and\n * `delete` both fail with this typed error instead, and `resource.test.ts` proves the point\n * with a fake `HttpClient` that fails the test on any request that is not a `GET`. Lifting the\n * policy is a kit change (a new PR that adds a write path, reviewed as one), not a flag a stack\n * can pass.\n *\n * ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a\n * caller (a future import script, a test) branch on the refusal without parsing a message.\n */\nimport * as Data from 'effect/Data';\nimport * as Effect from 'effect/Effect';\n\nexport const UNIFI_READ_ONLY_POLICY =\n \"read-only by Tim's rule, 2026-09-24; lifting it is a kit change\";\n\n/** What kind of write was asked for and refused — for the message only, never acted on. */\nexport type UnifiWriteAction = 'create' | 'update' | 'delete';\n\nexport class UnifiWriteRefused extends Data.TaggedError('UnifiWriteRefused')<{\n /** The vendor type string, e.g. `Unifi.Network`. */\n readonly type: string;\n /** `spec.describe(props)` — the object the write would have touched. */\n readonly identity: string;\n readonly action: UnifiWriteAction;\n}> {\n override get message(): string {\n return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_READ_ONLY_POLICY}.`;\n }\n}\n\n/** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */\nexport const refuseWrite = (type: string, identity: string, action: UnifiWriteAction) =>\n Effect.fail(new UnifiWriteRefused({ type, identity, action }));\n",
|
|
6
|
-
"/**\n * `Unifi.Network` — one network on a UniFi Network site, READ-ONLY (`policy.ts`).\n *\n * ★ WHY NETWORK IS IN THE FIRST IMPORT SET. `getNetworkDetails`/`getNetworksOverviewPage`\n * (`../../distilled-unifi-network/src/services/networks.ts`) is a list+get pair over\n * genuine configuration — VLAN, DHCP guarding, IPv4/IPv6 addressing, isolation — not runtime\n * state like a connected client, a device statistic or a hotspot voucher. It is also the SDK\n * package's own README recommendation for a first read/adopt-only resource, alongside `Site`\n * (skipped here — no `getSite`, only the list operation, so it fails this task's own \"both a\n * list and a get\" bar) and `FirewallZone`/`FirewallPolicy` (`firewall-zone.ts` ships\n * `FirewallZone`; `FirewallPolicy`'s several discriminated filter variants and its\n * list-replacing ordering endpoint are a bigger, separate PR).\n *\n * ⛔ THE WHOLE-OBJECT-PUT TRAP (`docs/unifi-api-notes.md`) NEVER FIRES HERE, BY CONSTRUCTION.\n * `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration`\n * outright when the caller omits them — the exact failure mode a read-only family cannot cause,\n * because `resource.ts`'s `reconcile` never calls `updateNetwork` at all. Recorded here for\n * whoever adds the write path this docs note anticipates: read-merge-write the WHOLE object,\n * never a props-only body.\n */\nimport { Resource } from 'alchemy';\nimport { adopt } from 'alchemy/AdoptPolicy';\nimport * as Provider from 'alchemy/Provider';\nimport * as networks from '@distilled.cloud/unifi-network/networks';\nimport * as Effect from 'effect/Effect';\nimport {\n type NetworkAttributes,\n type NetworkProps,\n attributesOf,\n matches,\n} from './network-form.ts';\nimport type { UnifiRequirements, UnifiSpec } from './resource.ts';\nimport { unifiHandlers } from './resource.ts';\n\nexport type { NetworkAttributes, NetworkProps } from './network-form.ts';\nexport { declareNetwork } from './network-form.ts';\n\nexport interface UnifiNetwork extends Resource<\n 'Unifi.Network',\n NetworkProps,\n NetworkAttributes,\n never,\n UnifiRequirements\n> {}\n\nexport const UnifiNetwork = Resource<UnifiNetwork>('Unifi.Network', {\n defaultRemovalPolicy: 'retain',\n});\n\n/**\n * `network('lan', props)` — `adopt(true)` piped on by default (H5). This family never writes, so\n * the only thing a first deploy against an existing network could ever do is bind to it or refuse\n * `OwnedBySomeoneElse`; piping `adopt(true)` here picks the former without a stack having to\n * remember `--adopt`, mirroring `discord/application-command.ts`'s `applicationCommand`.\n */\nexport const network = (id: string, props: NetworkProps) =>\n UnifiNetwork(id, props).pipe(adopt(true));\n\n/**\n * ★ EXPORTED for direct testing against a fake `Credentials` layer (`fake-unifi.ts`), the same\n * seam `netbox/prefix.ts`'s `spec` and `discord/application-command.ts`'s `spec` use — `handlers`\n * bakes in `CredentialsFromEnv`, which resolves environment variables a test cannot repoint.\n */\nexport const spec: UnifiSpec<\n NetworkProps,\n networks.NetworkDetails,\n NetworkAttributes,\n networks.GetNetworkDetailsError\n> = {\n type: 'Unifi.Network',\n describe: (props) => `sites/${props.siteId}/networks/${props.networkId}`,\n fetchLive: (props) =>\n networks\n .getNetworkDetails({ siteId: props.siteId, networkId: props.networkId })\n .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),\n attributes: attributesOf,\n matches,\n};\n\nexport const handlers = unifiHandlers(spec);\n\nexport const UnifiNetworkProvider = () =>\n Provider.effect(UnifiNetwork, Effect.succeed(UnifiNetwork.Provider.of(handlers)));\n",
|
|
7
|
-
"/**\n * `Unifi.Network`'s wire shape: the declarable subset of `NetworkDetails`, and how a live read\n * becomes both plan attributes and a rendered declaration.\n *\n * ⚠️ PROPS MIRROR `UpdateNetworkRequest`, NOT `NetworkDetails`. `default` and `metadata` are\n * server-derived — no writable endpoint in `networks.ts` accepts either — so they are\n * attributes-only, reported but never compared or declared. Everything else in `NetworkProps`\n * is exactly what `updateNetwork`/`createNetwork` would accept, even though this family never\n * calls either: the shape is what a future write path would need, and what today's read-only\n * `matches` compares against.\n *\n * ⛔ `ipv4Configuration` STAYS `unknown`, ON THE SDK'S OWN AUTHORITY. `networks.ts`'s own comment\n * (\"Shape varies by variant — widened by scripts/convert.ts; see README\") says the OpenAPI\n * discriminator this field varies over was flattened at generation time, not decoded per\n * variant. Comparing it as an opaque JSON value (below) is honest about that; guessing a\n * narrower shape here would silently stop matching the day a new variant's key order differs.\n * ⚠️ It may ALSO hide an unordered array the way `dhcpGuarding` and `ipv6Configuration` do\n * below — this family just can't know, because the shape is never decoded. If a future variant\n * plans spurious `update`s, suspect this field first and widen it into a typed, order-normalized\n * shape rather than adding another opaque comparison.\n *\n * ⛔ THREE FIELDS ARE SETS, NOT SEQUENCES: `dhcpGuarding.trustedDhcpServerIpAddresses`,\n * `ipv6Configuration.additionalHostIpSubnets`, and `ipv6Configuration.dnsServerIpAddressesOverride`.\n * The vendor document gives no indication any of the three is ordered — same reasoning\n * `firewall-zone-form.ts` gives for `networkIds`, and `proxmox/user-wire.ts`'s `groupSet` gives\n * for PVE's `groups` — but `matches` compares via alchemy's `deepEqual`, which sorts object KEYS\n * but NOT ARRAY ELEMENTS (`node_modules/alchemy/lib/Diff.js`). Left alone, a console that\n * returns the same set in a different order between calls plans a spurious `update`, and the\n * read-only reconcile then refuses every deploy. `sortedSet` (dedupe + sort) neutralizes that\n * before any of the three is attributed, declared, or compared. Every OTHER array field in this\n * family (there are none today besides these three) would stay declaration-order as written.\n */\nimport { deepEqual } from 'alchemy/Diff';\nimport type * as networks from '@distilled.cloud/unifi-network/networks';\n\nconst sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();\n\n// ⚠️ `value == null` (NOT `=== undefined`) ON PURPOSE, IN BOTH NORMALIZERS BELOW. The SDK types\n// `dhcpGuarding`/`ipv6Configuration` `T | undefined`, but `matches`'s own header says UniFi's\n// JSON answers `null` for an unset optional field — a reality `deepEqual`'s `stripNullish`\n// already treats as equivalent to `undefined` (`value == null` in alchemy's `Diff.js`), which\n// this family's props/attributes types don't model at the TS level. Destructuring a `null` with\n// `=== undefined`'s false branch (`{ ...value, field: value.field }`) would throw\n// `TypeError: Cannot read properties of null` the first time a console omits either field —\n// `== null` catches both and returns the value through untouched, matching what the rest of\n// this file already does for every other optional field.\nconst normalizeDhcpGuarding = (\n value: networks.NetworkDHCPGuarding | undefined,\n): networks.NetworkDHCPGuarding | undefined =>\n value == null\n ? value\n : { ...value, trustedDhcpServerIpAddresses: sortedSet(value.trustedDhcpServerIpAddresses) };\n\n// ⚠️ `exactOptionalPropertyTypes` (see the props header) means these two subfields must be\n// OMITTED, not set to an explicit `undefined`, when the live/declared value has no override —\n// `NetworkIPv6Configuration` types them `field?: T`, not `field?: T | undefined`. Conditional\n// spread (rather than `field: value.field === undefined ? undefined : sortedSet(...)`) is what\n// keeps an absent key absent.\nconst normalizeIpv6Configuration = (\n value: networks.NetworkIPv6Configuration | undefined,\n): networks.NetworkIPv6Configuration | undefined =>\n value == null\n ? value\n : {\n ...value,\n ...(value.additionalHostIpSubnets !== undefined\n ? { additionalHostIpSubnets: sortedSet(value.additionalHostIpSubnets) }\n : {}),\n ...(value.dnsServerIpAddressesOverride !== undefined\n ? { dnsServerIpAddressesOverride: sortedSet(value.dnsServerIpAddressesOverride) }\n : {}),\n };\n\n/**\n * ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` ON PURPOSE. `tsconfig.json` sets\n * `exactOptionalPropertyTypes`, which treats `field?: T` (absent-or-T) and\n * `field?: T | undefined` (absent-or-explicitly-undefined) as different types. `declareNetwork`\n * below assigns `live.dhcpGuarding` (typed `T | undefined`) straight through — the whole point\n * of a renderer that echoes a live optional field structurally rather than defaulting it — so\n * the target has to admit an explicit `undefined`, not just omission.\n */\nexport interface NetworkProps {\n siteId: string;\n networkId: string;\n name: string;\n enabled: boolean;\n management: string;\n /** VLAN ID. Must be 1 for the default network and >= 2 for additional networks. */\n vlanId: number;\n dhcpGuarding?: networks.NetworkDHCPGuarding | undefined;\n cellularBackupEnabled?: boolean | undefined;\n internetAccessEnabled?: boolean | undefined;\n /** Opaque — see the header. */\n ipv4Configuration?: unknown;\n ipv6Configuration?: networks.NetworkIPv6Configuration | undefined;\n isolationEnabled?: boolean | undefined;\n mdnsForwardingEnabled?: boolean | undefined;\n zoneId?: string | undefined;\n deviceId?: string | undefined;\n}\n\nexport interface NetworkAttributes {\n siteId: string;\n networkId: string;\n /** Whether this is the site's undeletable default network — server-derived, never declared. */\n default: boolean;\n name: string;\n enabled: boolean;\n management: string;\n vlanId: number;\n dhcpGuarding: networks.NetworkDHCPGuarding | undefined;\n cellularBackupEnabled: boolean | undefined;\n internetAccessEnabled: boolean | undefined;\n ipv4Configuration: unknown;\n ipv6Configuration: networks.NetworkIPv6Configuration | undefined;\n isolationEnabled: boolean | undefined;\n mdnsForwardingEnabled: boolean | undefined;\n zoneId: string | undefined;\n deviceId: string | undefined;\n}\n\nexport const attributesOf = (\n live: networks.NetworkDetails,\n props: NetworkProps,\n): NetworkAttributes => ({\n siteId: props.siteId,\n networkId: live.id,\n default: live.default,\n name: live.name,\n enabled: live.enabled,\n management: live.management,\n vlanId: live.vlanId,\n dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),\n cellularBackupEnabled: live.cellularBackupEnabled,\n internetAccessEnabled: live.internetAccessEnabled,\n ipv4Configuration: live.ipv4Configuration,\n ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),\n isolationEnabled: live.isolationEnabled,\n mdnsForwardingEnabled: live.mdnsForwardingEnabled,\n zoneId: live.zoneId,\n deviceId: live.deviceId,\n});\n\n/**\n * ⚠️ `stripNullish: true` ON EVERY COMPARISON. UniFi's JSON answers `null` for an unset optional\n * field; a declaration that omits the same prop carries `undefined`. Without this option every\n * plan against an object with one unset optional field would report `update` forever — the\n * exact \"read and write are different shapes\" trap `netbox/values.ts` and `proxmox/user-wire.ts`\n * both work around, here solved once with the engine's own `deepEqual` instead of a per-field\n * coercion, because nothing here needs PVE's or NetBox's extra wire-format translation.\n */\nexport const matches = (attributes: NetworkAttributes, props: NetworkProps): boolean =>\n attributes.name === props.name &&\n attributes.enabled === props.enabled &&\n attributes.management === props.management &&\n attributes.vlanId === props.vlanId &&\n deepEqual(\n normalizeDhcpGuarding(attributes.dhcpGuarding),\n normalizeDhcpGuarding(props.dhcpGuarding),\n {\n stripNullish: true,\n },\n ) &&\n deepEqual(attributes.cellularBackupEnabled, props.cellularBackupEnabled, {\n stripNullish: true,\n }) &&\n deepEqual(attributes.internetAccessEnabled, props.internetAccessEnabled, {\n stripNullish: true,\n }) &&\n deepEqual(attributes.ipv4Configuration, props.ipv4Configuration, { stripNullish: true }) &&\n deepEqual(\n normalizeIpv6Configuration(attributes.ipv6Configuration),\n normalizeIpv6Configuration(props.ipv6Configuration),\n { stripNullish: true },\n ) &&\n deepEqual(attributes.isolationEnabled, props.isolationEnabled, { stripNullish: true }) &&\n deepEqual(attributes.mdnsForwardingEnabled, props.mdnsForwardingEnabled, {\n stripNullish: true,\n }) &&\n deepEqual(attributes.zoneId, props.zoneId, { stripNullish: true }) &&\n deepEqual(attributes.deviceId, props.deviceId, { stripNullish: true });\n\n/**\n * The declaration renderer (task spec: \"given one live object, return the props a declaration\n * needs so its plan is noop\"). A later import script feeds `getNetworkDetails`'s own response\n * straight in and writes the result into `alchemy.run.ts` as `network('<id>', declareNetwork(live,\n * siteId))` — no field is invented or defaulted here beyond what `attributesOf` already reports,\n * so `matches(attributesOf(live, props), declareNetwork(live, siteId))` is `true` by construction\n * (`network.test.ts` proves it) for any live object this SDK can decode.\n */\nexport const declareNetwork = (live: networks.NetworkDetails, siteId: string): NetworkProps => ({\n siteId,\n networkId: live.id,\n name: live.name,\n enabled: live.enabled,\n management: live.management,\n vlanId: live.vlanId,\n dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),\n cellularBackupEnabled: live.cellularBackupEnabled,\n internetAccessEnabled: live.internetAccessEnabled,\n ipv4Configuration: live.ipv4Configuration,\n ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),\n isolationEnabled: live.isolationEnabled,\n mdnsForwardingEnabled: live.mdnsForwardingEnabled,\n zoneId: live.zoneId,\n deviceId: live.deviceId,\n});\n",
|
|
8
|
-
"/**\n *
|
|
9
|
-
"/**\n *
|
|
10
|
-
"/**\n * `Unifi.
|
|
6
|
+
"/**\n * One shape for every UniFi Network read-only object — `Unifi.Network`, `Unifi.FirewallZone`,\n * and whatever import adds next.\n *\n * ★ MIRRORS `../discord/resource.ts` (S7-S10, H1, H5), NOT `../netbox/resource.ts`. Both are\n * \"marker-less API\" families (S8): UniFi Network objects carry no tag or metadata field this\n * stack could stamp as ownership, so a cold read of a match is `Unowned(attrs)` exactly like\n * `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.79#read`, the reference implementation S8 and H1\n * both cite. `adopt(true)`, piped on by each resource's convenience constructor (H5), turns\n * that into a silent one-time bind instead of an `OwnedBySomeoneElse` refusal.\n *\n * ⛔ THE DIFFERENCE FROM EVERY OTHER FAMILY HERE: THERE IS NO `create`, `update` OR `destroy` IN\n * THIS SPEC AT ALL. Tim's rule, 2026-09-24 (`policy.ts`): UniFi and OPNsense are read-only. So\n * `reconcile` below can only ever refuse or report an exact match — it has no write path to\n * fall into by accident, and no resource file wired through this engine can reach one either.\n *\n * ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.\n * Measured against `Plan.ts@v2.0.0-beta.79` (`forceUpdateAfterAdoption`, H6): beta.79 forces\n * ONE reconcile call after every cold adoption, whether or not the diff said noop — so a\n * resource whose `reconcile` always failed would break the ordinary \"adopt an unchanged\n * object\" deploy, not just a real write. The fix, exactly like `discordOperations.reconcile`:\n * read the live object again, and refuse ONLY when it is missing (would need a create) or\n * drifted (would need a write to converge). An exact match returns the live attributes and\n * calls nothing — S10's \"zero write calls when there is no drift\" holds even under the forced\n * post-adoption call.\n *\n * ⚠️ `NotFound` IS FOLDED TO \"ABSENT\" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE —\n * same split netbox's `resource.ts` uses and for the same reason: `Effect.catchTag`'s\n * tag-literal inference needs a concrete error union to resolve `\"NotFound\"` against, and a\n * spec's `E` is only concrete at each resource file's own call site. Every OTHER failure (a\n * real 5xx, a network error, `Forbidden`) is NOT folded anywhere — it fails the plan loudly,\n * per the task's own rule: only a genuine not-found may mean absent.\n */\nimport { Unowned } from 'alchemy/AdoptPolicy';\nimport { isResolved } from 'alchemy/Diff';\nimport type { Input } from 'alchemy/Input';\nimport { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';\nimport type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';\nimport * as Effect from 'effect/Effect';\nimport * as HttpClient from 'effect/unstable/http/HttpClient';\nimport { UNIFI_READ_ONLY_POLICY, refuseWrite } from './policy.ts';\n\n/** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */\nexport type UnifiRequirements = HttpClient.HttpClient;\n\n/**\n * One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a\n * `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error\n * union, with `NotFound` already folded away) so the precise per-operation error union flows\n * through instead of being widened by hand here.\n */\nexport type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {\n /** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */\n readonly type: string;\n /** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */\n readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, UnifiNetworkOpContext>;\n readonly attributes: (live: Live, props: Props) => Attributes;\n readonly matches: (attributes: Attributes, props: Props) => boolean;\n /** For the refusal message and read-back failures. */\n readonly describe: (props: Props) => string;\n};\n\nexport const unifiOperations = <Props extends object, Live, Attributes extends object, E>(\n spec: UnifiSpec<Props, Live, Attributes, E>,\n) => {\n const read = (props: Props) =>\n spec\n .fetchLive(props)\n .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));\n\n return {\n read,\n\n readHandler: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>\n Effect.gen(function* () {\n const attrs = yield* read(olds);\n if (attrs === undefined) return undefined;\n // Warm read (persisted `output` says a prior apply already adopted this exact object):\n // plain attributes. Cold read (fresh store, or state lost): no ownership marker on this\n // vendor exists to tell \"ours\" from \"someone else's\" apart, so Unowned until adopt(true).\n return output !== undefined ? attrs : Unowned(attrs);\n }),\n\n diff: (news: Input<Props>, output: Attributes | undefined) =>\n Effect.gen(function* () {\n if (output === undefined || !isResolved(news)) return undefined;\n const live = yield* read(news);\n if (live === undefined) return { action: 'update' } as const;\n return spec.matches(live, news)\n ? ({ action: 'noop' } as const)\n : ({ action: 'update' } as const);\n }),\n\n reconcile: (news: Props) =>\n Effect.gen(function* () {\n const live = yield* read(news);\n if (live === undefined) return yield* refuseWrite(spec.type, spec.describe(news), 'create');\n if (!spec.matches(live, news)) {\n return yield* refuseWrite(spec.type, spec.describe(news), 'update');\n }\n // Exact match: the object already is what is declared. Zero write calls (S10), correct\n // whether this is a routine deploy or beta.79's forced post-adoption reconcile (H6).\n return live;\n }),\n\n destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),\n };\n};\n\n/**\n * B0a / T12 DEFENSE IN DEPTH. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at\n * `reconcile`/`destroy` — every path this engine exposes already returns that refusal instead of\n * calling an SDK write op. This guard stops the same thing one layer lower, AT THE WIRE: it wraps\n * whatever `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in\n * production, `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same\n * \"wrap, don't replace\" seam) so a future resource file that, by mistake, called an SDK write\n * operation directly — bypassing `unifiOperations` entirely — would still never reach the vendor\n * API. `write-op-reference.test.ts` proves no such call exists today; this is what stops one from\n * ever taking effect if one is ever added.\n *\n * ⚠️ `Effect.die`, NOT A TYPED FAILURE — classified per distilled-doctrine's \"classify every\n * die/orDie\". Every generated SDK operation (`networks.getNetworkDetails`, …) declares its OWN\n * closed error union from the pinned OpenAPI spec, and generated files are never hand-edited to\n * add a member to it. A typed `Effect.fail` here would be a failure mode absent from every op's\n * declared type the moment it occurred — a type-system lie. A defect is the sound way to add a\n * NEW \"this must be structurally impossible\" failure underneath types this package does not\n * own, exactly like `openbao`'s `refuse()` helpers' `Effect.die(new Error(...))` for a\n * configuration move that should never be reachable.\n */\nexport class UnifiNonGetRequest extends Error {\n constructor(\n readonly method: string,\n /** PATH ONLY, never the full URL — see `requestPath` below. */\n readonly path: string,\n ) {\n super(\n `UniFi HttpClient guard: refused ${method} ${path} -- ${UNIFI_READ_ONLY_POLICY}. This is a ` +\n 'defect, not a recoverable condition: something under src/unifi tried to send a non-GET ' +\n \"request, which policy.ts's per-operation refusal should already have made impossible. \" +\n 'Fix the kit code that produced this request.',\n );\n this.name = 'UnifiNonGetRequest';\n }\n}\n\n/**\n * ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID\n * (`docs/unifi.md`'s Credentials section, T3) — a defect message is still a message that can\n * reach a log, a CI failure body or a pasted error report, so it gets the same treatment as\n * `redactUrls` gives the import layer's own error text. Falls back to a fixed placeholder rather\n * than the raw string on a parse failure, so a malformed URL can never leak through unredacted.\n */\nconst requestPath = (url: string): string => {\n try {\n return new URL(url).pathname;\n } catch {\n return '<unparseable request url>';\n }\n};\n\nconst guardGetOnly = (client: HttpClient.HttpClient): HttpClient.HttpClient =>\n client.pipe(\n HttpClient.mapRequestEffect((request) =>\n request.method === 'GET'\n ? Effect.succeed(request)\n : Effect.die(new UnifiNonGetRequest(request.method, requestPath(request.url))),\n ),\n );\n\n/**\n * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,\n * for everything downstream, with `guardGetOnly`'s wrapped version (`HttpClient.layerMergedContext`\n * — see the guard above). Exported so `get-only-guard.test.ts` can prove the mechanism directly,\n * against a bare `HttpClient` service, without needing `CredentialsFromEnv` or any env var at all.\n */\nexport const GetOnlyHttpClient = HttpClient.layerMergedContext(\n Effect.map(HttpClient.HttpClient, guardGetOnly),\n);\n\n/**\n * The four provider handlers for a spec'd read-only UniFi object, wired once.\n *\n * ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;\n * adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.\n */\nexport const unifiHandlers = <Props extends object, Live, Attributes extends object, E>(\n spec: UnifiSpec<Props, Live, Attributes, E>,\n) => {\n const ops = unifiOperations(spec);\n const withCredentials = <A, Err>(effect: Effect.Effect<A, Err, UnifiNetworkOpContext>) =>\n effect.pipe(Effect.provide(GetOnlyHttpClient), Effect.provide(CredentialsFromEnv));\n return {\n list: () => Effect.succeed([]),\n read: (args: { olds: Props; output: Attributes | undefined }) =>\n withCredentials(ops.readHandler(args)),\n diff: (args: { news: Input<Props>; output: Attributes | undefined }) =>\n withCredentials(ops.diff(args.news, args.output)),\n reconcile: (args: { news: Props }) => withCredentials(ops.reconcile(args.news)),\n delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),\n };\n};\n",
|
|
7
|
+
"/**\n * `Unifi.Network` — one network on a UniFi Network site, READ-ONLY (`policy.ts`).\n *\n * ★ WHY NETWORK IS IN THE FIRST IMPORT SET. `getNetworkDetails`/`getNetworksOverviewPage`\n * (`../../distilled-unifi-network/src/services/networks.ts`) is a list+get pair over\n * genuine configuration — VLAN, DHCP guarding, IPv4/IPv6 addressing, isolation — not runtime\n * state like a connected client, a device statistic or a hotspot voucher. It is also the SDK\n * package's own README recommendation for a first read/adopt-only resource, alongside `Site`\n * (skipped here — no `getSite`, only the list operation, so it fails this task's own \"both a\n * list and a get\" bar) and `FirewallZone`/`FirewallPolicy` (`firewall-zone.ts` ships\n * `FirewallZone`; `FirewallPolicy`'s several discriminated filter variants and its\n * list-replacing ordering endpoint are a bigger, separate PR).\n *\n * ⛔ THE WHOLE-OBJECT-PUT TRAP (`docs/unifi-api-notes.md`) NEVER FIRES HERE, BY CONSTRUCTION.\n * `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration`\n * outright when the caller omits them — the exact failure mode a read-only family cannot cause,\n * because `resource.ts`'s `reconcile` never calls `updateNetwork` at all. Recorded here for\n * whoever adds the write path this docs note anticipates: read-merge-write the WHOLE object,\n * never a props-only body.\n */\nimport { Resource } from 'alchemy';\nimport { adopt } from 'alchemy/AdoptPolicy';\nimport * as Provider from 'alchemy/Provider';\nimport * as networks from '@distilled.cloud/unifi-network/networks';\nimport * as Effect from 'effect/Effect';\nimport { type NetworkAttributes, type NetworkProps, attributesOf } from './network-form.ts';\nimport { matches } from './network-drift.ts';\nimport type { UnifiRequirements, UnifiSpec } from './resource.ts';\nimport { unifiHandlers } from './resource.ts';\n\nexport type { NetworkAttributes, NetworkProps } from './network-form.ts';\nexport { declareNetwork } from './network-form.ts';\n// MEDIUM-4: `matches` moved here from `network-form.ts` — see network-drift.ts's own header. Not\n// re-exported from this barrel file either, same as before the move: `spec.matches` below is the\n// only consumer a provider needs.\nexport { driftOf } from './network-drift.ts';\n\nexport interface UnifiNetwork extends Resource<\n 'Unifi.Network',\n NetworkProps,\n NetworkAttributes,\n never,\n UnifiRequirements\n> {}\n\nexport const UnifiNetwork = Resource<UnifiNetwork>('Unifi.Network', {\n defaultRemovalPolicy: 'retain',\n});\n\n/**\n * `network('lan', props)` — `adopt(true)` piped on by default (H5). This family never writes, so\n * the only thing a first deploy against an existing network could ever do is bind to it or refuse\n * `OwnedBySomeoneElse`; piping `adopt(true)` here picks the former without a stack having to\n * remember `--adopt`, mirroring `discord/application-command.ts`'s `applicationCommand`.\n */\nexport const network = (id: string, props: NetworkProps) =>\n UnifiNetwork(id, props).pipe(adopt(true));\n\n/**\n * ★ EXPORTED for direct testing against a fake `Credentials` layer (`fake-unifi.ts`), the same\n * seam `netbox/prefix.ts`'s `spec` and `discord/application-command.ts`'s `spec` use — `handlers`\n * bakes in `CredentialsFromEnv`, which resolves environment variables a test cannot repoint.\n */\nexport const spec: UnifiSpec<\n NetworkProps,\n networks.NetworkDetails,\n NetworkAttributes,\n networks.GetNetworkDetailsError\n> = {\n type: 'Unifi.Network',\n describe: (props) => `sites/${props.siteId}/networks/${props.networkId}`,\n fetchLive: (props) =>\n networks\n .getNetworkDetails({ siteId: props.siteId, networkId: props.networkId })\n .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),\n attributes: attributesOf,\n matches,\n};\n\nexport const handlers = unifiHandlers(spec);\n\nexport const UnifiNetworkProvider = () =>\n Provider.effect(UnifiNetwork, Effect.succeed(UnifiNetwork.Provider.of(handlers)));\n",
|
|
8
|
+
"/**\n * `Unifi.Network`'s wire shape: the declarable subset of `NetworkDetails`, and how a live read\n * becomes both plan attributes and a rendered declaration.\n *\n * ⚠️ PROPS MIRROR `UpdateNetworkRequest`, NOT `NetworkDetails`. `default` and `metadata` are\n * server-derived — no writable endpoint in `networks.ts` accepts either — so they are\n * attributes-only, reported but never compared or declared. Everything else in `NetworkProps`\n * is exactly what `updateNetwork`/`createNetwork` would accept, even though this family never\n * calls either: the shape is what a future write path would need, and what today's read-only\n * `matches` compares against.\n *\n * ⛔ `ipv4Configuration` STAYS `unknown`, ON THE SDK'S OWN AUTHORITY. `networks.ts`'s own comment\n * (\"Shape varies by variant — widened by scripts/convert.ts; see README\") says the OpenAPI\n * discriminator this field varies over was flattened at generation time, not decoded per\n * variant. Comparing it as an opaque JSON value (below) is honest about that; guessing a\n * narrower shape here would silently stop matching the day a new variant's key order differs.\n * ⚠️ It may ALSO hide an unordered array the way `dhcpGuarding` and `ipv6Configuration` do\n * below — this family just can't know, because the shape is never decoded. If a future variant\n * plans spurious `update`s, suspect this field first and widen it into a typed, order-normalized\n * shape rather than adding another opaque comparison.\n *\n * ⛔ THREE FIELDS ARE SETS, NOT SEQUENCES: `dhcpGuarding.trustedDhcpServerIpAddresses`,\n * `ipv6Configuration.additionalHostIpSubnets`, and `ipv6Configuration.dnsServerIpAddressesOverride`.\n * The vendor document gives no indication any of the three is ordered — same reasoning\n * `firewall-zone-form.ts` gives for `networkIds`, and `proxmox/user-wire.ts`'s `groupSet` gives\n * for PVE's `groups` — but `matches` compares via alchemy's `deepEqual`, which sorts object KEYS\n * but NOT ARRAY ELEMENTS (`node_modules/alchemy/lib/Diff.js`). Left alone, a console that\n * returns the same set in a different order between calls plans a spurious `update`, and the\n * read-only reconcile then refuses every deploy. `sortedSet` (dedupe + sort) neutralizes that\n * before any of the three is attributed, declared, or compared. Every OTHER array field in this\n * family (there are none today besides these three) would stay declaration-order as written.\n */\nimport type * as networks from '@distilled.cloud/unifi-network/networks';\n\nconst sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();\n\n// ⚠️ `value == null` (NOT `=== undefined`) ON PURPOSE, IN BOTH NORMALIZERS BELOW. The SDK types\n// `dhcpGuarding`/`ipv6Configuration` `T | undefined`, but `matches`'s own header says UniFi's\n// JSON answers `null` for an unset optional field — a reality `deepEqual`'s `stripNullish`\n// already treats as equivalent to `undefined` (`value == null` in alchemy's `Diff.js`), which\n// this family's props/attributes types don't model at the TS level. Destructuring a `null` with\n// `=== undefined`'s false branch (`{ ...value, field: value.field }`) would throw\n// `TypeError: Cannot read properties of null` the first time a console omits either field —\n// `== null` catches both and returns the value through untouched, matching what the rest of\n// this file already does for every other optional field.\n// ★ EXPORTED (not just used below): `network-drift.ts`'s `driftOf` reuses these exact two\n// normalizers so it can never quietly disagree with `matches` about what counts as a change —\n// see that file's own header.\nexport const normalizeDhcpGuarding = (\n value: networks.NetworkDHCPGuarding | undefined,\n): networks.NetworkDHCPGuarding | undefined =>\n value == null\n ? value\n : { ...value, trustedDhcpServerIpAddresses: sortedSet(value.trustedDhcpServerIpAddresses) };\n\n// ⚠️ `exactOptionalPropertyTypes` (see the props header) means these two subfields must be\n// OMITTED, not set to an explicit `undefined`, when the live/declared value has no override —\n// `NetworkIPv6Configuration` types them `field?: T`, not `field?: T | undefined`. Conditional\n// spread (rather than `field: value.field === undefined ? undefined : sortedSet(...)`) is what\n// keeps an absent key absent.\nexport const normalizeIpv6Configuration = (\n value: networks.NetworkIPv6Configuration | undefined,\n): networks.NetworkIPv6Configuration | undefined =>\n value == null\n ? value\n : {\n ...value,\n ...(value.additionalHostIpSubnets !== undefined\n ? { additionalHostIpSubnets: sortedSet(value.additionalHostIpSubnets) }\n : {}),\n ...(value.dnsServerIpAddressesOverride !== undefined\n ? { dnsServerIpAddressesOverride: sortedSet(value.dnsServerIpAddressesOverride) }\n : {}),\n };\n\n/**\n * ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` ON PURPOSE. `tsconfig.json` sets\n * `exactOptionalPropertyTypes`, which treats `field?: T` (absent-or-T) and\n * `field?: T | undefined` (absent-or-explicitly-undefined) as different types. `declareNetwork`\n * below assigns `live.dhcpGuarding` (typed `T | undefined`) straight through — the whole point\n * of a renderer that echoes a live optional field structurally rather than defaulting it — so\n * the target has to admit an explicit `undefined`, not just omission.\n */\nexport interface NetworkProps {\n siteId: string;\n networkId: string;\n name: string;\n enabled: boolean;\n management: string;\n /** VLAN ID. Must be 1 for the default network and >= 2 for additional networks. */\n vlanId: number;\n dhcpGuarding?: networks.NetworkDHCPGuarding | undefined;\n cellularBackupEnabled?: boolean | undefined;\n internetAccessEnabled?: boolean | undefined;\n /** Opaque — see the header. */\n ipv4Configuration?: unknown;\n ipv6Configuration?: networks.NetworkIPv6Configuration | undefined;\n isolationEnabled?: boolean | undefined;\n mdnsForwardingEnabled?: boolean | undefined;\n zoneId?: string | undefined;\n deviceId?: string | undefined;\n}\n\nexport interface NetworkAttributes {\n siteId: string;\n networkId: string;\n /** Whether this is the site's undeletable default network — server-derived, never declared. */\n default: boolean;\n name: string;\n enabled: boolean;\n management: string;\n vlanId: number;\n dhcpGuarding: networks.NetworkDHCPGuarding | undefined;\n cellularBackupEnabled: boolean | undefined;\n internetAccessEnabled: boolean | undefined;\n ipv4Configuration: unknown;\n ipv6Configuration: networks.NetworkIPv6Configuration | undefined;\n isolationEnabled: boolean | undefined;\n mdnsForwardingEnabled: boolean | undefined;\n zoneId: string | undefined;\n deviceId: string | undefined;\n}\n\nexport const attributesOf = (\n live: networks.NetworkDetails,\n props: NetworkProps,\n): NetworkAttributes => ({\n siteId: props.siteId,\n networkId: live.id,\n default: live.default,\n name: live.name,\n enabled: live.enabled,\n management: live.management,\n vlanId: live.vlanId,\n dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),\n cellularBackupEnabled: live.cellularBackupEnabled,\n internetAccessEnabled: live.internetAccessEnabled,\n ipv4Configuration: live.ipv4Configuration,\n ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),\n isolationEnabled: live.isolationEnabled,\n mdnsForwardingEnabled: live.mdnsForwardingEnabled,\n zoneId: live.zoneId,\n deviceId: live.deviceId,\n});\n\n/**\n * MEDIUM-4 (red team, 2026-09-26): `matches` used to be a hand-written boolean expression here,\n * kept in sync BY HAND with `network-drift.ts`'s separately-maintained field list — two comparisons\n * that could (and, per a red-team mutant, silently did) drift apart. `matches` now lives in\n * `network-drift.ts`, defined as `fieldDrift(...).length === 0` — the SAME comparison `driftOf`\n * runs, not a second one. See that file's own header for the `stripNullish`/normalizer reasoning\n * this expression used to carry.\n */\n\n/**\n * The declaration renderer (task spec: \"given one live object, return the props a declaration\n * needs so its plan is noop\"). A later import script feeds `getNetworkDetails`'s own response\n * straight in and writes the result into `alchemy.run.ts` as `network('<id>', declareNetwork(live,\n * siteId))` — no field is invented or defaulted here beyond what `attributesOf` already reports,\n * so `matches(attributesOf(live, props), declareNetwork(live, siteId))` is `true` by construction\n * (`network.test.ts` proves it) for any live object this SDK can decode.\n */\nexport const declareNetwork = (live: networks.NetworkDetails, siteId: string): NetworkProps => ({\n siteId,\n networkId: live.id,\n name: live.name,\n enabled: live.enabled,\n management: live.management,\n vlanId: live.vlanId,\n dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),\n cellularBackupEnabled: live.cellularBackupEnabled,\n internetAccessEnabled: live.internetAccessEnabled,\n ipv4Configuration: live.ipv4Configuration,\n ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),\n isolationEnabled: live.isolationEnabled,\n mdnsForwardingEnabled: live.mdnsForwardingEnabled,\n zoneId: live.zoneId,\n deviceId: live.deviceId,\n});\n",
|
|
9
|
+
"/**\n * B6: the field-level drift framework every family's own `driftOf(live, props)` is built on\n * (`network-form.ts`, `firewall-zone-form.ts`, and whatever family follows). Consumed by\n * `homeflare-network`'s pre-import drift check (its own C4, not built here) to show WHICH fields\n * a live object and a committed declaration disagree on, before that script overwrites the\n * committed module — never by a `Unifi.*` provider itself, which only ever needs `matches`'s\n * boolean.\n *\n * ★ BUILT ON `matches`, NOT A SEPARATE COMPARISON. Each family's `driftOf` reuses the SAME\n * normalizers (`sortedSet`, the `dhcpGuarding`/`ipv6Configuration` null-vs-undefined handling)\n * its own `matches` already applies — so this reports NOTHING `matches` would call a noop, and\n * everything `matches` would call `update`, broken down by field instead of collapsed to one\n * boolean. Two comparisons drifting apart would be its own bug class; there is exactly one.\n */\nimport { deepEqual } from 'alchemy/Diff';\n\n/** One field whose live and declared values differ, as `makeDriftOf` reports it. */\nexport interface FieldDrift {\n readonly field: string;\n readonly live: unknown;\n readonly declared: unknown;\n}\n\n/** One field's comparison recipe for `makeDriftOf`. */\nexport interface DriftField<Attributes, Props> {\n readonly field: string;\n readonly live: (attributes: Attributes) => unknown;\n readonly declared: (props: Props) => unknown;\n /**\n * Defaults to `alchemy/Diff`'s `deepEqual` with `stripNullish: true` — the same tolerance\n * every `matches` in this directory already gives UniFi's `null`-for-unset optionals (see\n * `network-form.ts`'s header). Pass one explicitly for a field `matches` compares as a\n * normalized SET (`firewall-zone-form.ts`'s `networkIds`), where plain `deepEqual` would\n * report order as drift `matches` never would.\n */\n readonly equal?: (live: unknown, declared: unknown) => boolean;\n}\n\nconst defaultEqual = (live: unknown, declared: unknown) =>\n deepEqual(live, declared, { stripNullish: true });\n\n/**\n * Builds a `(attributes, props) => FieldDrift[]` from a field list — the framework a family's own\n * `*-form.ts` calls once, then wraps as `driftOf(live, props)` by feeding it `attributesOf(live,\n * props)` (the task's own API: callers have a live SDK object, not `Attributes`, on hand).\n */\nexport const makeDriftOf =\n <Attributes, Props>(fields: readonly DriftField<Attributes, Props>[]) =>\n (attributes: Attributes, props: Props): FieldDrift[] =>\n fields.flatMap(({ field, live, declared, equal = defaultEqual }) => {\n const liveValue = live(attributes);\n const declaredValue = declared(props);\n return equal(liveValue, declaredValue)\n ? []\n : [{ field, live: liveValue, declared: declaredValue }];\n });\n",
|
|
10
|
+
"/**\n * `Unifi.Network`'s `matches` AND `driftOf` (B6) — both built on the ONE field list below, not two\n * hand-kept-in-sync comparisons. `matches` used to be a separate boolean expression in\n * `network-form.ts`; MEDIUM-4 (red team, 2026-09-26) found that a `fieldDrift` entry could be\n * deleted entirely and every test still passed, because `matches` never consulted `fieldDrift` at\n * all. `matches(attrs, props) === (driftOf(live, props).length === 0)` is now true BY\n * CONSTRUCTION — there is exactly one comparison — and every `fieldDrift` entry names ONE field\n * plus its own live/declared value instead of collapsing straight to a boolean.\n *\n * ⚠️ `stripNullish: true` ON EVERY FIELD BY DEFAULT (`drift.ts`'s `defaultEqual`). UniFi's JSON\n * answers `null` for an unset optional field; a declaration that omits the same prop carries\n * `undefined`. Without this, every plan against an object with one unset optional field would\n * report `update` forever — the exact \"read and write are different shapes\" trap\n * `netbox/values.ts` and `proxmox/user-wire.ts` both work around, here solved once by\n * `makeDriftOf`'s own default instead of a per-field coercion.\n */\nimport type * as networks from '@distilled.cloud/unifi-network/networks';\nimport { makeDriftOf } from './drift.ts';\nimport {\n type NetworkAttributes,\n type NetworkProps,\n attributesOf,\n normalizeDhcpGuarding,\n normalizeIpv6Configuration,\n} from './network-form.ts';\n\nconst fieldDrift = makeDriftOf<NetworkAttributes, NetworkProps>([\n { field: 'name', live: (a) => a.name, declared: (p) => p.name },\n { field: 'enabled', live: (a) => a.enabled, declared: (p) => p.enabled },\n { field: 'management', live: (a) => a.management, declared: (p) => p.management },\n { field: 'vlanId', live: (a) => a.vlanId, declared: (p) => p.vlanId },\n {\n field: 'dhcpGuarding',\n live: (a) => normalizeDhcpGuarding(a.dhcpGuarding),\n declared: (p) => normalizeDhcpGuarding(p.dhcpGuarding),\n },\n {\n field: 'cellularBackupEnabled',\n live: (a) => a.cellularBackupEnabled,\n declared: (p) => p.cellularBackupEnabled,\n },\n {\n field: 'internetAccessEnabled',\n live: (a) => a.internetAccessEnabled,\n declared: (p) => p.internetAccessEnabled,\n },\n {\n field: 'ipv4Configuration',\n live: (a) => a.ipv4Configuration,\n declared: (p) => p.ipv4Configuration,\n },\n {\n field: 'ipv6Configuration',\n live: (a) => normalizeIpv6Configuration(a.ipv6Configuration),\n declared: (p) => normalizeIpv6Configuration(p.ipv6Configuration),\n },\n {\n field: 'isolationEnabled',\n live: (a) => a.isolationEnabled,\n declared: (p) => p.isolationEnabled,\n },\n {\n field: 'mdnsForwardingEnabled',\n live: (a) => a.mdnsForwardingEnabled,\n declared: (p) => p.mdnsForwardingEnabled,\n },\n { field: 'zoneId', live: (a) => a.zoneId, declared: (p) => p.zoneId },\n { field: 'deviceId', live: (a) => a.deviceId, declared: (p) => p.deviceId },\n]);\n\nexport const matches = (attributes: NetworkAttributes, props: NetworkProps): boolean =>\n fieldDrift(attributes, props).length === 0;\n\n/**\n * Per-field live-vs-declared drift straight from one live read — the task's own API\n * (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive\n * `attributesOf` itself just to call this.\n */\nexport const driftOf = (live: networks.NetworkDetails, props: NetworkProps) =>\n fieldDrift(attributesOf(live, props), props);\n",
|
|
11
|
+
"/**\n * `Unifi.FirewallZone` — one firewall zone on a UniFi Network site, READ-ONLY (`policy.ts`).\n *\n * ★ WHY FIREWALL ZONE IS IN THE FIRST IMPORT SET AND `Unifi.FirewallPolicy` IS NOT.\n * `getFirewallZone`/`getFirewallZones` (`firewall.ts`) is a list+get pair over genuine,\n * simply-shaped configuration: `{id, metadata, name, networkIds}`, four fields, no nested\n * discriminated filter. `FirewallPolicy` (same file) is also list+get and also configuration,\n * but its `source`/`destination`/`ipProtocolScope` fields carry several of `docs/unifi-api-notes.md`'s\n * \"converter-flattened discriminator variants\" each, and `firewall/policies/ordering` replaces\n * the whole ordered list — modelling those correctly is a bigger, separate PR (`network.ts`'s\n * header note names it explicitly) rather than something to rush alongside this one.\n */\nimport { Resource } from 'alchemy';\nimport { adopt } from 'alchemy/AdoptPolicy';\nimport * as Provider from 'alchemy/Provider';\nimport * as firewall from '@distilled.cloud/unifi-network/firewall';\nimport * as Effect from 'effect/Effect';\nimport {\n type FirewallZoneAttributes,\n type FirewallZoneProps,\n attributesOf,\n matches,\n} from './firewall-zone-form.ts';\nimport type { UnifiRequirements, UnifiSpec } from './resource.ts';\nimport { unifiHandlers } from './resource.ts';\n\nexport type { FirewallZoneAttributes, FirewallZoneProps } from './firewall-zone-form.ts';\nexport { declareFirewallZone, driftOf } from './firewall-zone-form.ts';\n\nexport interface UnifiFirewallZone extends Resource<\n 'Unifi.FirewallZone',\n FirewallZoneProps,\n FirewallZoneAttributes,\n never,\n UnifiRequirements\n> {}\n\nexport const UnifiFirewallZone = Resource<UnifiFirewallZone>('Unifi.FirewallZone', {\n defaultRemovalPolicy: 'retain',\n});\n\n/** `firewallZone('lan', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */\nexport const firewallZone = (id: string, props: FirewallZoneProps) =>\n UnifiFirewallZone(id, props).pipe(adopt(true));\n\n/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */\nexport const spec: UnifiSpec<\n FirewallZoneProps,\n firewall.FirewallZone,\n FirewallZoneAttributes,\n firewall.GetFirewallZoneError\n> = {\n type: 'Unifi.FirewallZone',\n describe: (props) => `sites/${props.siteId}/firewall/zones/${props.firewallZoneId}`,\n fetchLive: (props) =>\n firewall\n .getFirewallZone({ siteId: props.siteId, firewallZoneId: props.firewallZoneId })\n .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),\n attributes: attributesOf,\n matches,\n};\n\nexport const handlers = unifiHandlers(spec);\n\nexport const UnifiFirewallZoneProvider = () =>\n Provider.effect(UnifiFirewallZone, Effect.succeed(UnifiFirewallZone.Provider.of(handlers)));\n",
|
|
12
|
+
"/**\n * `Unifi.FirewallZone`'s wire shape — the declarable subset of `FirewallZone`, and how a live\n * read becomes both plan attributes and a rendered declaration.\n *\n * ⚠️ ONLY `name` AND `networkIds` ARE DECLARABLE. `CreateFirewallZoneRequest`\n * (`firewall.ts`) accepts exactly those two beyond `siteId`; `metadata` (origin/source) and\n * `id` are server-derived, so — same split as `network-form.ts`'s `default`/`metadata` —\n * they are attributes-only, reported but never compared or declared.\n *\n * ⛔ `networkIds` ORDER IS NOT PART OF IDENTITY. The vendor document gives no indication the\n * list is ordered (it is a set of attached networks, not a sequence like the ordering\n * endpoints' rule lists), so `matches` compares it as a set — sorted before comparing —\n * the same reasoning `proxmox/user-wire.ts`'s `groupSet` gives for PVE's `groups`.\n */\nimport type * as firewall from '@distilled.cloud/unifi-network/firewall';\nimport { makeDriftOf } from './drift.ts';\n\nexport interface FirewallZoneProps {\n siteId: string;\n firewallZoneId: string;\n /** Name of a firewall zone. */\n name: string;\n /** Network IDs attached to this zone — compared as a set, see the header. */\n networkIds: string[];\n}\n\nexport interface FirewallZoneAttributes {\n siteId: string;\n firewallZoneId: string;\n name: string;\n networkIds: string[];\n /** `origin`/`source` (e.g. `SDWAN`) — server-derived, never declared. */\n metadataOrigin: string;\n}\n\nconst sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();\n\nexport const attributesOf = (\n live: firewall.FirewallZone,\n props: FirewallZoneProps,\n): FirewallZoneAttributes => ({\n siteId: props.siteId,\n firewallZoneId: live.id,\n name: live.name,\n networkIds: sortedSet(live.networkIds),\n metadataOrigin: live.metadata.origin,\n});\n\n/**\n * B6: `fieldDrift` (`makeDriftOf`, `drift.ts`) is now the ONLY comparison this family has — MEDIUM-4\n * (red team, 2026-09-26): a hand-written `matches` kept beside `fieldDrift` is two field lists an\n * edit to one can silently leave out of sync with the other (a mutant deleting a `fieldDrift` entry\n * left every test green, because `matches` never consulted it). `matches(attrs, props) ===\n * (driftOf(live, props).length === 0)` is now true BY CONSTRUCTION, not just by a test that happens\n * to check both. `networkIds`' custom `equal` is the set comparison the family has always used —\n * see the header — and its reported `live`/`declared` values in a `driftOf` result are the SORTED\n * arrays, not the joined strings this `equal` compares internally, so a real diff reads as a set\n * difference, not two opaque strings.\n */\nconst fieldDrift = makeDriftOf<FirewallZoneAttributes, FirewallZoneProps>([\n { field: 'name', live: (a) => a.name, declared: (p) => p.name },\n {\n field: 'networkIds',\n live: (a) => a.networkIds,\n declared: (p) => sortedSet(p.networkIds),\n equal: (live, declared) => (live as string[]).join(',') === (declared as string[]).join(','),\n },\n]);\n\nexport const matches = (attributes: FirewallZoneAttributes, props: FirewallZoneProps): boolean =>\n fieldDrift(attributes, props).length === 0;\n\n/**\n * Per-field live-vs-declared drift straight from one live read — the task's own API\n * (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive\n * `attributesOf` itself just to call this.\n */\nexport const driftOf = (live: firewall.FirewallZone, props: FirewallZoneProps) =>\n fieldDrift(attributesOf(live, props), props);\n\n/**\n * The declaration renderer (task spec: \"given one live object, return the props a declaration\n * needs so its plan is noop\"). `matches(attributesOf(live, props), declareFirewallZone(live,\n * siteId))` is `true` by construction (`firewall-zone.test.ts` proves it).\n */\nexport const declareFirewallZone = (\n live: firewall.FirewallZone,\n siteId: string,\n): FirewallZoneProps => ({\n siteId,\n firewallZoneId: live.id,\n name: live.name,\n networkIds: sortedSet(live.networkIds),\n});\n",
|
|
11
13
|
"/**\n * Every `Unifi.*` provider as one layer for a stack.\n *\n * Layer.mergeAll(unifiProviders(), …the stack's other providers)\n * .pipe(Layer.provide(FetchHttpClient.layer))\n *\n * S16: a subpath's `providers()` plays the role upstream's own `Providers.ts` does — one minimal\n * insertion per family. `FetchHttpClient.layer` is the stack's own responsibility to provide,\n * mirroring `../discord/providers.ts`.\n */\nimport * as Layer from 'effect/Layer';\nimport { UnifiFirewallZoneProvider } from './firewall-zone.ts';\nimport { UnifiNetworkProvider } from './network.ts';\n\nexport const providers = () => Layer.mergeAll(UnifiNetworkProvider(), UnifiFirewallZoneProvider());\n"
|
|
12
14
|
],
|
|
13
|
-
"mappings": ";AAaA;AACA;AAEO,IAAM,yBACX;AAAA;AAKK,MAAM,0BAA+B,iBAAY,mBAAmB,EAMxE;AAAA,MACY,OAAO,GAAW;AAAA,IAC7B,OAAO,GAAG,KAAK,QAAQ,KAAK,yBAAyB,KAAK,aAAa;AAAA;AAE3E;AAGO,IAAM,cAAc,CAAC,MAAc,UAAkB,WACnD,YAAK,IAAI,kBAAkB,EAAE,MAAM,UAAU,OAAO,CAAC,CAAC;;
|
|
14
|
-
"debugId": "
|
|
15
|
+
"mappings": "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",
|
|
16
|
+
"debugId": "557618804604F27464756E2164756E21",
|
|
15
17
|
"names": []
|
|
16
18
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s `matches` AND `driftOf` (B6) — both built on the ONE field list below, not two
|
|
3
|
+
* hand-kept-in-sync comparisons. `matches` used to be a separate boolean expression in
|
|
4
|
+
* `network-form.ts`; MEDIUM-4 (red team, 2026-09-26) found that a `fieldDrift` entry could be
|
|
5
|
+
* deleted entirely and every test still passed, because `matches` never consulted `fieldDrift` at
|
|
6
|
+
* all. `matches(attrs, props) === (driftOf(live, props).length === 0)` is now true BY
|
|
7
|
+
* CONSTRUCTION — there is exactly one comparison — and every `fieldDrift` entry names ONE field
|
|
8
|
+
* plus its own live/declared value instead of collapsing straight to a boolean.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ `stripNullish: true` ON EVERY FIELD BY DEFAULT (`drift.ts`'s `defaultEqual`). UniFi's JSON
|
|
11
|
+
* answers `null` for an unset optional field; a declaration that omits the same prop carries
|
|
12
|
+
* `undefined`. Without this, every plan against an object with one unset optional field would
|
|
13
|
+
* report `update` forever — the exact "read and write are different shapes" trap
|
|
14
|
+
* `netbox/values.ts` and `proxmox/user-wire.ts` both work around, here solved once by
|
|
15
|
+
* `makeDriftOf`'s own default instead of a per-field coercion.
|
|
16
|
+
*/
|
|
17
|
+
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
18
|
+
import { type NetworkAttributes, type NetworkProps } from './network-form.ts';
|
|
19
|
+
export declare const matches: (attributes: NetworkAttributes, props: NetworkProps) => boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Per-field live-vs-declared drift straight from one live read — the task's own API
|
|
22
|
+
* (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive
|
|
23
|
+
* `attributesOf` itself just to call this.
|
|
24
|
+
*/
|
|
25
|
+
export declare const driftOf: (live: networks.NetworkDetails, props: NetworkProps) => import("./drift.ts").FieldDrift[];
|
|
26
|
+
//# sourceMappingURL=network-drift.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"network-drift.d.ts","sourceRoot":"","sources":["../../src/unifi/network-drift.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAEzE,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,YAAY,EAIlB,MAAM,mBAAmB,CAAC;AA8C3B,eAAO,MAAM,OAAO,eAAgB,iBAAiB,SAAS,YAAY,KAAG,OACjC,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,OAAO,SAAU,QAAQ,CAAC,cAAc,SAAS,YAAY,sCAC5B,CAAC"}
|