@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.
Files changed (165) hide show
  1. package/README.md +22 -0
  2. package/dist/forgejo/index.js +4 -2
  3. package/dist/forgejo/index.js.map +3 -3
  4. package/dist/forgejo/team-member.d.ts +25 -0
  5. package/dist/forgejo/team-member.d.ts.map +1 -1
  6. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  7. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  8. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  9. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  10. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  11. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  12. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  13. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  14. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  15. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  16. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  17. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  18. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  19. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  20. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  21. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  22. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  23. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  24. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  25. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  26. package/dist/proxmox/index.d.ts +14 -3
  27. package/dist/proxmox/index.d.ts.map +1 -1
  28. package/dist/proxmox/index.js +565 -133
  29. package/dist/proxmox/index.js.map +33 -23
  30. package/dist/proxmox/qemu-form.d.ts +27 -18
  31. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  32. package/dist/proxmox/qemu-judge.d.ts +30 -0
  33. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  34. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  35. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  36. package/dist/proxmox/qemu-net.d.ts +9 -0
  37. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  38. package/dist/proxmox/qemu-props.d.ts +112 -0
  39. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  40. package/dist/proxmox/qemu-volume.d.ts +17 -0
  41. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  42. package/dist/proxmox/qemu.d.ts +14 -27
  43. package/dist/proxmox/qemu.d.ts.map +1 -1
  44. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  45. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  46. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  47. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  48. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  49. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  50. package/dist/proxmox/storage-download-props.d.ts +36 -0
  51. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  52. package/dist/proxmox/storage-download-task.d.ts +17 -0
  53. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  54. package/dist/proxmox/storage-download.d.ts +44 -0
  55. package/dist/proxmox/storage-download.d.ts.map +1 -0
  56. package/dist/telemetry/fake-collector.d.ts +36 -0
  57. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  58. package/dist/telemetry/index.d.ts +11 -0
  59. package/dist/telemetry/index.d.ts.map +1 -0
  60. package/dist/telemetry/index.js +223 -0
  61. package/dist/telemetry/index.js.map +12 -0
  62. package/dist/telemetry/layer.d.ts +86 -0
  63. package/dist/telemetry/layer.d.ts.map +1 -0
  64. package/dist/telemetry/redact-serialization.d.ts +21 -0
  65. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  66. package/dist/telemetry/redact.d.ts +46 -0
  67. package/dist/telemetry/redact.d.ts.map +1 -0
  68. package/dist/unifi/drift.d.ts +27 -0
  69. package/dist/unifi/drift.d.ts.map +1 -0
  70. package/dist/unifi/fake-unifi.d.ts +5 -1
  71. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  72. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  73. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  74. package/dist/unifi/firewall-zone.d.ts +1 -1
  75. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  76. package/dist/unifi/index.d.ts +4 -3
  77. package/dist/unifi/index.d.ts.map +1 -1
  78. package/dist/unifi/index.js +138 -58
  79. package/dist/unifi/index.js.map +10 -8
  80. package/dist/unifi/network-drift.d.ts +26 -0
  81. package/dist/unifi/network-drift.d.ts.map +1 -0
  82. package/dist/unifi/network-form.d.ts +40 -7
  83. package/dist/unifi/network-form.d.ts.map +1 -1
  84. package/dist/unifi/network.d.ts +1 -0
  85. package/dist/unifi/network.d.ts.map +1 -1
  86. package/dist/unifi/paginate.d.ts +36 -0
  87. package/dist/unifi/paginate.d.ts.map +1 -0
  88. package/dist/unifi/resource.d.ts +36 -1
  89. package/dist/unifi/resource.d.ts.map +1 -1
  90. package/docs/telemetry-spike.md +94 -0
  91. package/docs/telemetry.md +109 -0
  92. package/docs/unifi.md +56 -0
  93. package/package.json +6 -2
  94. package/src/forgejo/team-member.test.ts +14 -0
  95. package/src/forgejo/team-member.ts +28 -1
  96. package/src/proxmox/constraints-required.test.ts +1 -0
  97. package/src/proxmox/generated/constraints/index.ts +3 -1
  98. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/index.test.ts +11 -6
  116. package/src/proxmox/index.ts +19 -3
  117. package/src/proxmox/qemu-form.ts +49 -13
  118. package/src/proxmox/qemu-identity.test.ts +30 -0
  119. package/src/proxmox/qemu-judge.test.ts +65 -0
  120. package/src/proxmox/qemu-judge.ts +84 -0
  121. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  122. package/src/proxmox/qemu-lifecycle.ts +131 -32
  123. package/src/proxmox/qemu-net.test.ts +48 -0
  124. package/src/proxmox/qemu-net.ts +78 -0
  125. package/src/proxmox/qemu-ownership.test.ts +44 -0
  126. package/src/proxmox/qemu-props.test.ts +87 -0
  127. package/src/proxmox/qemu-props.ts +157 -0
  128. package/src/proxmox/qemu-volume.test.ts +80 -0
  129. package/src/proxmox/qemu-volume.ts +121 -0
  130. package/src/proxmox/qemu.ts +15 -29
  131. package/src/proxmox/storage-download-errors.ts +7 -0
  132. package/src/proxmox/storage-download-identity.test.ts +54 -0
  133. package/src/proxmox/storage-download-identity.ts +50 -0
  134. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  135. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  136. package/src/proxmox/storage-download-props.ts +38 -0
  137. package/src/proxmox/storage-download-task.ts +72 -0
  138. package/src/proxmox/storage-download.test.ts +216 -0
  139. package/src/proxmox/storage-download.ts +58 -0
  140. package/src/telemetry/fake-collector.ts +59 -0
  141. package/src/telemetry/http-client-sealing.test.ts +82 -0
  142. package/src/telemetry/index.ts +10 -0
  143. package/src/telemetry/layer.test.ts +204 -0
  144. package/src/telemetry/layer.ts +152 -0
  145. package/src/telemetry/redact-serialization.ts +178 -0
  146. package/src/telemetry/redact.test.ts +124 -0
  147. package/src/telemetry/redact.ts +147 -0
  148. package/src/unifi/drift.test.ts +78 -0
  149. package/src/unifi/drift.ts +56 -0
  150. package/src/unifi/errors-and-secrets.test.ts +127 -0
  151. package/src/unifi/fake-unifi.ts +5 -1
  152. package/src/unifi/firewall-zone-form.ts +31 -2
  153. package/src/unifi/firewall-zone.test.ts +21 -1
  154. package/src/unifi/firewall-zone.ts +1 -1
  155. package/src/unifi/get-only-guard.test.ts +165 -0
  156. package/src/unifi/index.ts +15 -1
  157. package/src/unifi/network-drift.test.ts +207 -0
  158. package/src/unifi/network-drift.ts +80 -0
  159. package/src/unifi/network-form.ts +11 -39
  160. package/src/unifi/network.test.ts +7 -82
  161. package/src/unifi/network.ts +6 -6
  162. package/src/unifi/paginate.test.ts +228 -0
  163. package/src/unifi/paginate.ts +129 -0
  164. package/src/unifi/resource.ts +73 -3
  165. package/src/unifi/write-op-reference.test.ts +236 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * URL/header redaction for outgoing spans — applied at the `Tracer.Tracer` boundary so it holds for
3
+ * EVERY span source (a provider's own `Effect.withSpan`, effect's `HttpClient` client spans, and
4
+ * alchemy's own plan/apply/resource spans), not just the ones a caller remembers to annotate.
5
+ *
6
+ * ★ WHY THE TRACER, NOT AN HTTPCLIENT MIDDLEWARE. effect's `HttpClient.make` (src/unstable/http/
7
+ * HttpClient.ts) hard-codes `span.attribute("url.full", …)`, `"url.query"` and
8
+ * `http.request.header.<name>` on the span it creates — there is no hook to stop it recording
9
+ * them. The one seam every span (HttpClient's included) passes through is `Tracer.span()`
10
+ * returning a `Tracer.Span`, so `wrapTracer` intercepts `Span.attribute()` there instead.
11
+ * ⛔ NEVER RECORD HEADERS. A header NAME can itself be sensitive (`x-api-key`, a session cookie
12
+ * name), so every `http.{request,response}.header.*` key is DROPPED, not value-redacted.
13
+ * ⚠️ QUERY STRINGS ARE STRIPPED WHOLE, not redacted per-parameter: an allowlist of "safe" params
14
+ * would silently leak the next secret param a provider adds. `url.query` is dropped outright and
15
+ * `url.full`/`url.path` never carry a `?…` past this point.
16
+ */
17
+ import type * as Tracer from 'effect/Tracer';
18
+
19
+ /** Host or path segments to blank wherever they appear in a URL attribute — exact-segment match. */
20
+ export interface RedactionPolicy {
21
+ /**
22
+ * e.g. a UniFi console id embedded in a path (`/proxy/network/api/site/<id>/...`) or a private
23
+ * hostname a consumer never wants leaving its own process. Compared against the full hostname and
24
+ * against each `/`-separated path segment — never a substring match, so `"db"` cannot accidentally
25
+ * blank `"db1.example.test"`.
26
+ */
27
+ readonly denylist?: ReadonlyArray<string>;
28
+ }
29
+
30
+ export const REDACTED = '<redacted>';
31
+
32
+ /** Returned by `redactAttribute` to mean "drop this attribute entirely — do not forward it". */
33
+ export const DROP: unique symbol = Symbol.for('homeflare/alchemy/telemetry/drop-attribute');
34
+
35
+ const HEADER_ATTRIBUTE = /^http\.(?:request|response)\.header\./;
36
+
37
+ const redactSegments = (value: string, denylist: ReadonlySet<string>): string =>
38
+ value
39
+ .split('/')
40
+ .map((segment) => (denylist.has(segment) ? REDACTED : segment))
41
+ .join('/');
42
+
43
+ /**
44
+ * Applies `policy` to one span attribute. Returns `DROP` when the key must never be recorded at
45
+ * all (every header); otherwise returns the value to record, with URLs stripped of their query
46
+ * string and hash and their denylisted host/path segments blanked. A value this function does not
47
+ * recognize (not a URL-shaped key, or not a string) passes through unchanged — alchemy's own
48
+ * engine attributes (`resource.fqn`, `plan.action`, …) are never URLs and are never touched.
49
+ */
50
+ export const redactAttribute = (key: string, value: unknown, policy: RedactionPolicy): unknown => {
51
+ if (HEADER_ATTRIBUTE.test(key)) return DROP;
52
+ if (key === 'url.query') return DROP;
53
+ if (typeof value !== 'string') return value;
54
+ const denylist = new Set(policy.denylist ?? []);
55
+ if (denylist.size === 0 && key !== 'url.full' && key !== 'server.address') return value;
56
+ // ⚠️ MEASURED 2026-09-26 (docs/telemetry-spike.md): effect's own `HttpClient.make` sets
57
+ // `server.address` to `url.origin` (`http://host:port`), NOT a bare hostname — a denylist entry
58
+ // naming just a hostname would never match the raw value, so this parses it out the same way
59
+ // `url.full` does.
60
+ // ⛔ FIX (redteam PR 302 MINOR item 5, 2026-09-26): the OTel semantic convention's own default for
61
+ // `server.address` IS a bare hostname with no scheme (`new URL(value)` throws on that), and other
62
+ // instrumentation could emit exactly that even though effect's `HttpClient` doesn't today —
63
+ // `redactAttribute('server.address', 'db1.example.test', { denylist: ['db1.example.test'] })`
64
+ // returned the value verbatim before this fallback. Compare the raw string directly when it
65
+ // isn't a parseable URL, instead of giving up.
66
+ if (key === 'server.address') {
67
+ try {
68
+ const url = new URL(value);
69
+ return denylist.has(url.hostname) ? REDACTED : value;
70
+ } catch {
71
+ return denylist.has(value) ? REDACTED : value;
72
+ }
73
+ }
74
+ if (key === 'url.path') return redactSegments(value, denylist);
75
+ if (key === 'url.full') {
76
+ try {
77
+ const url = new URL(value);
78
+ const host = denylist.has(url.hostname) ? REDACTED : url.host;
79
+ return `${url.protocol}//${host}${redactSegments(url.pathname, denylist)}`;
80
+ } catch {
81
+ // Not a URL we can parse (a relative path effect couldn't resolve) — leave it verbatim
82
+ // rather than guess; `url.full` is the one HttpClient always sets from a resolved `URL`, so
83
+ // this branch is a defensive fallback, not the expected path.
84
+ return value;
85
+ }
86
+ }
87
+ return value;
88
+ };
89
+
90
+ const redactAttributes = (
91
+ attributes: Record<string, unknown>,
92
+ policy: RedactionPolicy,
93
+ ): Record<string, unknown> => {
94
+ const out: Record<string, unknown> = {};
95
+ for (const [key, value] of Object.entries(attributes)) {
96
+ const redacted = redactAttribute(key, value, policy);
97
+ if (redacted !== DROP) out[key] = redacted;
98
+ }
99
+ return out;
100
+ };
101
+
102
+ /**
103
+ * Wraps a `Tracer.Tracer` so every span it creates redacts its attributes and event attributes
104
+ * through `redactAttribute`/`redactAttributes` before they reach the real span (and therefore
105
+ * before OTLP serialization ever sees them). Everything else — timing, status, links, the span's
106
+ * own identity — passes straight through to `inner`.
107
+ */
108
+ export const wrapTracer = (inner: Tracer.Tracer, policy: RedactionPolicy): Tracer.Tracer => ({
109
+ context: inner.context,
110
+ span(options): Tracer.Span {
111
+ const real = inner.span(options);
112
+ return {
113
+ _tag: 'Span',
114
+ spanId: real.spanId,
115
+ traceId: real.traceId,
116
+ parent: real.parent,
117
+ annotations: real.annotations,
118
+ get name() {
119
+ return real.name;
120
+ },
121
+ get status() {
122
+ return real.status;
123
+ },
124
+ get attributes() {
125
+ return real.attributes;
126
+ },
127
+ get links() {
128
+ return real.links;
129
+ },
130
+ sampled: real.sampled,
131
+ kind: real.kind,
132
+ end: (endTime, exit) => {
133
+ real.end(endTime, exit);
134
+ },
135
+ attribute: (key, value) => {
136
+ const redacted = redactAttribute(key, value, policy);
137
+ if (redacted !== DROP) real.attribute(key, redacted);
138
+ },
139
+ event: (name, startTime, attributes) => {
140
+ real.event(name, startTime, attributes ? redactAttributes(attributes, policy) : attributes);
141
+ },
142
+ addLinks: (links) => {
143
+ real.addLinks(links);
144
+ },
145
+ };
146
+ },
147
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `makeDriftOf` (B6) against a synthetic field list — no SDK, no HTTP. `network.test.ts` and
3
+ * `firewall-zone.test.ts` each prove their own family's `driftOf` on top of this; this file proves
4
+ * the framework itself: an exact match reports nothing, a drifted field reports its own live and
5
+ * declared values (never swallowing a real difference into a single boolean), several drifted
6
+ * fields all report, and a custom `equal` overrides the default `deepEqual`.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { type DriftField, makeDriftOf } from './drift.ts';
10
+
11
+ interface Widget {
12
+ name: string;
13
+ tags: string[];
14
+ nested: { count: number } | undefined;
15
+ }
16
+
17
+ const FIELDS: readonly DriftField<Widget, Widget>[] = [
18
+ { field: 'name', live: (a) => a.name, declared: (p) => p.name },
19
+ { field: 'nested', live: (a) => a.nested, declared: (p) => p.nested },
20
+ {
21
+ field: 'tags',
22
+ live: (a) => a.tags,
23
+ declared: (p) => p.tags,
24
+ equal: (live, declared) =>
25
+ [...(live as string[])].sort().join(',') === [...(declared as string[])].sort().join(','),
26
+ },
27
+ ];
28
+
29
+ const widget = (overrides: Partial<Widget> = {}): Widget => ({
30
+ name: 'lan',
31
+ nested: { count: 1 },
32
+ tags: ['a', 'b'],
33
+ ...overrides,
34
+ });
35
+
36
+ describe('makeDriftOf', () => {
37
+ const driftOf = makeDriftOf(FIELDS);
38
+
39
+ test('an exact match reports nothing', () => {
40
+ const attrs = widget();
41
+ expect(driftOf(attrs, widget())).toEqual([]);
42
+ });
43
+
44
+ test('a drifted field reports its own live and declared values', () => {
45
+ const attrs = widget({ name: 'lan' });
46
+ const props = widget({ name: 'guest' });
47
+ expect(driftOf(attrs, props)).toEqual([{ field: 'name', live: 'lan', declared: 'guest' }]);
48
+ });
49
+
50
+ test('several drifted fields all report, independently', () => {
51
+ const attrs = widget({ name: 'lan', nested: { count: 1 } });
52
+ const props = widget({ name: 'guest', nested: { count: 2 } });
53
+ expect(driftOf(attrs, props)).toEqual([
54
+ { field: 'name', live: 'lan', declared: 'guest' },
55
+ { field: 'nested', live: { count: 1 }, declared: { count: 2 } },
56
+ ]);
57
+ });
58
+
59
+ test('the default comparison strips nullish, matching every matches() in this directory', () => {
60
+ const attrs = widget({ nested: undefined });
61
+ const props = { ...widget(), nested: null as unknown as undefined };
62
+ expect(driftOf(attrs, props)).toEqual([]);
63
+ });
64
+
65
+ test('a custom `equal` overrides the default -- an unordered set is not drift', () => {
66
+ const attrs = widget({ tags: ['b', 'a'] });
67
+ const props = widget({ tags: ['a', 'b'] });
68
+ expect(driftOf(attrs, props)).toEqual([]);
69
+ });
70
+
71
+ test('a custom `equal` still reports a genuine difference', () => {
72
+ const attrs = widget({ tags: ['a', 'b'] });
73
+ const props = widget({ tags: ['a', 'c'] });
74
+ expect(driftOf(attrs, props)).toEqual([
75
+ { field: 'tags', live: ['a', 'b'], declared: ['a', 'c'] },
76
+ ]);
77
+ });
78
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * B6: the field-level drift framework every family's own `driftOf(live, props)` is built on
3
+ * (`network-form.ts`, `firewall-zone-form.ts`, and whatever family follows). Consumed by
4
+ * `homeflare-network`'s pre-import drift check (its own C4, not built here) to show WHICH fields
5
+ * a live object and a committed declaration disagree on, before that script overwrites the
6
+ * committed module — never by a `Unifi.*` provider itself, which only ever needs `matches`'s
7
+ * boolean.
8
+ *
9
+ * ★ BUILT ON `matches`, NOT A SEPARATE COMPARISON. Each family's `driftOf` reuses the SAME
10
+ * normalizers (`sortedSet`, the `dhcpGuarding`/`ipv6Configuration` null-vs-undefined handling)
11
+ * its own `matches` already applies — so this reports NOTHING `matches` would call a noop, and
12
+ * everything `matches` would call `update`, broken down by field instead of collapsed to one
13
+ * boolean. Two comparisons drifting apart would be its own bug class; there is exactly one.
14
+ */
15
+ import { deepEqual } from 'alchemy/Diff';
16
+
17
+ /** One field whose live and declared values differ, as `makeDriftOf` reports it. */
18
+ export interface FieldDrift {
19
+ readonly field: string;
20
+ readonly live: unknown;
21
+ readonly declared: unknown;
22
+ }
23
+
24
+ /** One field's comparison recipe for `makeDriftOf`. */
25
+ export interface DriftField<Attributes, Props> {
26
+ readonly field: string;
27
+ readonly live: (attributes: Attributes) => unknown;
28
+ readonly declared: (props: Props) => unknown;
29
+ /**
30
+ * Defaults to `alchemy/Diff`'s `deepEqual` with `stripNullish: true` — the same tolerance
31
+ * every `matches` in this directory already gives UniFi's `null`-for-unset optionals (see
32
+ * `network-form.ts`'s header). Pass one explicitly for a field `matches` compares as a
33
+ * normalized SET (`firewall-zone-form.ts`'s `networkIds`), where plain `deepEqual` would
34
+ * report order as drift `matches` never would.
35
+ */
36
+ readonly equal?: (live: unknown, declared: unknown) => boolean;
37
+ }
38
+
39
+ const defaultEqual = (live: unknown, declared: unknown) =>
40
+ deepEqual(live, declared, { stripNullish: true });
41
+
42
+ /**
43
+ * Builds a `(attributes, props) => FieldDrift[]` from a field list — the framework a family's own
44
+ * `*-form.ts` calls once, then wraps as `driftOf(live, props)` by feeding it `attributesOf(live,
45
+ * props)` (the task's own API: callers have a live SDK object, not `Attributes`, on hand).
46
+ */
47
+ export const makeDriftOf =
48
+ <Attributes, Props>(fields: readonly DriftField<Attributes, Props>[]) =>
49
+ (attributes: Attributes, props: Props): FieldDrift[] =>
50
+ fields.flatMap(({ field, live, declared, equal = defaultEqual }) => {
51
+ const liveValue = live(attributes);
52
+ const declaredValue = declared(props);
53
+ return equal(liveValue, declaredValue)
54
+ ? []
55
+ : [{ field, live: liveValue, declared: declaredValue }];
56
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Runtime proof for A12 (T8/T23): the distilled regen's two behavioral changes actually decode
3
+ * through the real fake-fetch path every other `*.test.ts` in this family uses — not just typecheck.
4
+ *
5
+ * ⛔ WITHOUT THESE, A FUTURE REGEN THAT DROPS `sensitivePatterns` OR `errorEnvelope` STAYS GREEN.
6
+ * Every other test here fixes response bodies before A2/A1 landed, so none of them would notice
7
+ * either regressing back to a plain string / an always-`undefined` envelope.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
11
+ import * as applicationInfo from '@distilled.cloud/unifi-network/application_info';
12
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
13
+ import * as Effect from 'effect/Effect';
14
+ import * as Redacted from 'effect/Redacted';
15
+ import { fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
16
+
17
+ const WIFI_PATH = '/proxy/network/integration/v1/sites/site-1/wifi/broadcasts/wifi-1';
18
+
19
+ // A fabricated sentinel, not a real WiFi passphrase (kit is PUBLIC — see fake-unifi.ts).
20
+ const SENTINEL = 'sentinel-wpa-passphrase-do-not-log-me';
21
+
22
+ const liveWifiBroadcast = () => ({
23
+ channel2gLockedTo6: false,
24
+ clientIsolationEnabled: false,
25
+ dtimPeriod2gLockedTo3: false,
26
+ enabled: true,
27
+ hideName: false,
28
+ id: 'wifi-1',
29
+ metadata: { origin: 'USER' },
30
+ multicastToUnicastConversionEnabled: false,
31
+ name: 'Guest',
32
+ type: 'STANDARD',
33
+ securityConfiguration: {
34
+ type: 'WPA_PERSONAL',
35
+ passphrase: SENTINEL,
36
+ presharedKeys: [{ network: { type: 'VLAN', networkId: 'net-1' }, passphrase: SENTINEL }],
37
+ },
38
+ });
39
+
40
+ describe('A2/T23 — WPA/PPSK passphrase decodes Redacted, not a plain string', () => {
41
+ test('both the single passphrase and every PPSK entry come back Redacted', async () => {
42
+ const fake = fakeUnifi((method, url) =>
43
+ method === 'GET' && url.pathname === WIFI_PATH
44
+ ? Response.json(liveWifiBroadcast())
45
+ : new Response('unexpected request', { status: 400 }),
46
+ );
47
+ const live = await Effect.runPromise(
48
+ wifiBroadcasts
49
+ .getWifiBroadcastDetails({ siteId: 'site-1', wifiBroadcastId: 'wifi-1' })
50
+ .pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
51
+ );
52
+ const { passphrase, presharedKeys } = live.securityConfiguration;
53
+ expect(Redacted.isRedacted(passphrase)).toBe(true);
54
+ expect(Redacted.value(passphrase as Redacted.Redacted<string>)).toBe(SENTINEL);
55
+ expect(Redacted.isRedacted(presharedKeys?.[0]?.passphrase)).toBe(true);
56
+ });
57
+
58
+ test('neither JSON.stringify nor String() ever shows the sentinel value', async () => {
59
+ const fake = fakeUnifi(() => Response.json(liveWifiBroadcast()));
60
+ const live = await Effect.runPromise(
61
+ wifiBroadcasts
62
+ .getWifiBroadcastDetails({ siteId: 'site-1', wifiBroadcastId: 'wifi-1' })
63
+ .pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
64
+ );
65
+ expect(JSON.stringify(live)).not.toContain(SENTINEL);
66
+ expect(String(live.securityConfiguration.passphrase)).not.toContain(SENTINEL);
67
+ });
68
+ });
69
+
70
+ /** The pinned spec's `Error Message` schema (T8) — see `src/protocol.ts`'s module doc. */
71
+ const envelopeBody = (over: Record<string, unknown> = {}) => ({
72
+ code: 'api.example.not-found',
73
+ message: 'vendor-provided failure text',
74
+ requestId: 'req-sentinel-1',
75
+ requestPath: '/v1/sites/site-1/secret-console-path',
76
+ statusCode: 404,
77
+ statusName: 'Not Found',
78
+ timestamp: '2026-01-01T00:00:00.000Z',
79
+ ...over,
80
+ });
81
+
82
+ describe('A1/T8 — the vendor Error Message envelope actually decodes', () => {
83
+ test('a mapped status (404) carries the vendor message, not a bare "HTTP 404"', async () => {
84
+ const fake = fakeUnifi(() => Response.json(envelopeBody(), { status: 404 }));
85
+ const failure = await Effect.runPromise(
86
+ Effect.flip(applicationInfo.getInfo({}).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
87
+ );
88
+ expect(failure._tag).toBe('NotFound');
89
+ expect((failure as { message?: string }).message).toBe('vendor-provided failure text');
90
+ });
91
+
92
+ test('an unmapped status (418) becomes UnknownUnifiNetworkError with the extra envelope fields', async () => {
93
+ // ⚠️ `Retry.none`: `UnknownUnifiNetworkError` is tagged a retryable server error by core's
94
+ // default policy regardless of the actual (client, 4xx) status — this test asserts the
95
+ // FAILURE, not the retry schedule (same reason `network.test.ts`'s 500 test needs it).
96
+ const fake = fakeUnifi(() =>
97
+ Response.json(envelopeBody({ statusCode: 418, statusName: "I'm a teapot" }), {
98
+ status: 418,
99
+ }),
100
+ );
101
+ const failure = await Effect.runPromise(
102
+ Effect.flip(
103
+ applicationInfo.getInfo({}).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
104
+ ),
105
+ );
106
+ expect(failure._tag).toBe('UnknownUnifiNetworkError');
107
+ const err = failure as unknown as {
108
+ code?: string;
109
+ requestId?: string;
110
+ statusCode?: number;
111
+ statusName?: string;
112
+ timestamp?: string;
113
+ body: unknown;
114
+ };
115
+ expect(err.code).toBe('api.example.not-found');
116
+ expect(err.requestId).toBe('req-sentinel-1');
117
+ expect(err.statusCode).toBe(418);
118
+ expect(err.statusName).toBe("I'm a teapot");
119
+ expect(err.timestamp).toBe('2026-01-01T00:00:00.000Z');
120
+ // T3: requestPath is never plucked into its own field or into `message` …
121
+ expect('requestPath' in err).toBe(false);
122
+ // … but it IS still inside the raw body for anyone who reads that on purpose (README/errors.ts).
123
+ expect((err.body as { requestPath?: string }).requestPath).toBe(
124
+ '/v1/sites/site-1/secret-console-path',
125
+ );
126
+ });
127
+ });
@@ -19,7 +19,11 @@ export const FAKE_KEY = 'placeholder-unifi-key';
19
19
 
20
20
  export type Seen = { readonly method: string; readonly path: string };
21
21
 
22
- /** No documented failure envelope (see `protocol.ts`) — plain text is all a failure needs. */
22
+ /**
23
+ * Plain-text failure. `protocol.ts`'s `errorEnvelope` also decodes a documented JSON shape
24
+ * (`components.schemas["Error Message"]`, A1/T8) when a scenario needs that instead — see
25
+ * `errors-and-secrets.test.ts`'s `envelopeBody` helper for a fixture in that shape.
26
+ */
23
27
  export const fakeFailure = (status: number, detail: string) => new Response(detail, { status });
24
28
 
25
29
  /**
@@ -13,6 +13,7 @@
13
13
  * the same reasoning `proxmox/user-wire.ts`'s `groupSet` gives for PVE's `groups`.
14
14
  */
15
15
  import type * as firewall from '@distilled.cloud/unifi-network/firewall';
16
+ import { makeDriftOf } from './drift.ts';
16
17
 
17
18
  export interface FirewallZoneProps {
18
19
  siteId: string;
@@ -45,9 +46,37 @@ export const attributesOf = (
45
46
  metadataOrigin: live.metadata.origin,
46
47
  });
47
48
 
49
+ /**
50
+ * B6: `fieldDrift` (`makeDriftOf`, `drift.ts`) is now the ONLY comparison this family has — MEDIUM-4
51
+ * (red team, 2026-09-26): a hand-written `matches` kept beside `fieldDrift` is two field lists an
52
+ * edit to one can silently leave out of sync with the other (a mutant deleting a `fieldDrift` entry
53
+ * left every test green, because `matches` never consulted it). `matches(attrs, props) ===
54
+ * (driftOf(live, props).length === 0)` is now true BY CONSTRUCTION, not just by a test that happens
55
+ * to check both. `networkIds`' custom `equal` is the set comparison the family has always used —
56
+ * see the header — and its reported `live`/`declared` values in a `driftOf` result are the SORTED
57
+ * arrays, not the joined strings this `equal` compares internally, so a real diff reads as a set
58
+ * difference, not two opaque strings.
59
+ */
60
+ const fieldDrift = makeDriftOf<FirewallZoneAttributes, FirewallZoneProps>([
61
+ { field: 'name', live: (a) => a.name, declared: (p) => p.name },
62
+ {
63
+ field: 'networkIds',
64
+ live: (a) => a.networkIds,
65
+ declared: (p) => sortedSet(p.networkIds),
66
+ equal: (live, declared) => (live as string[]).join(',') === (declared as string[]).join(','),
67
+ },
68
+ ]);
69
+
48
70
  export const matches = (attributes: FirewallZoneAttributes, props: FirewallZoneProps): boolean =>
49
- attributes.name === props.name &&
50
- attributes.networkIds.join(',') === sortedSet(props.networkIds).join(',');
71
+ fieldDrift(attributes, props).length === 0;
72
+
73
+ /**
74
+ * Per-field live-vs-declared drift straight from one live read — the task's own API
75
+ * (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive
76
+ * `attributesOf` itself just to call this.
77
+ */
78
+ export const driftOf = (live: firewall.FirewallZone, props: FirewallZoneProps) =>
79
+ fieldDrift(attributesOf(live, props), props);
51
80
 
52
81
  /**
53
82
  * The declaration renderer (task spec: "given one live object, return the props a declaration
@@ -5,7 +5,7 @@ import { describe, expect, test } from 'bun:test';
5
5
  import type * as firewall from '@distilled.cloud/unifi-network/firewall';
6
6
  import * as Effect from 'effect/Effect';
7
7
  import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
8
- import { attributesOf, matches } from './firewall-zone-form.ts';
8
+ import { attributesOf, driftOf, matches } from './firewall-zone-form.ts';
9
9
  import { type FirewallZoneProps, declareFirewallZone, spec } from './firewall-zone.ts';
10
10
  import { unifiOperations } from './resource.ts';
11
11
 
@@ -59,6 +59,26 @@ describe('declareFirewallZone -- the declaration renderer', () => {
59
59
  });
60
60
  });
61
61
 
62
+ describe('driftOf -- B6 field-level drift, straight from one live read', () => {
63
+ test('a live object that matches its declaration drifts on nothing, order included', () => {
64
+ // `networkIds` in a different order is a noop for `matches` (the header's own rule) --
65
+ // `driftOf` must agree, not report a set that only LOOKS different because of order.
66
+ expect(driftOf(liveZone(), PROPS)).toEqual([]);
67
+ });
68
+
69
+ test('a real name change reports its own field, live and declared', () => {
70
+ const live = liveZone({ name: 'Garage' });
71
+ expect(driftOf(live, PROPS)).toEqual([{ field: 'name', live: 'Garage', declared: 'Cameras' }]);
72
+ });
73
+
74
+ test('a genuine networkIds difference reports SORTED arrays, not joined strings', () => {
75
+ const live = liveZone({ networkIds: ['net-1'] });
76
+ expect(driftOf(live, PROPS)).toEqual([
77
+ { field: 'networkIds', live: ['net-1'], declared: ['net-1', 'net-2'] },
78
+ ]);
79
+ });
80
+ });
81
+
62
82
  describe('Unifi.FirewallZone write paths never reach the vendor API', () => {
63
83
  test('reconcile on an exact-match adoption (H6) sends only the one read', async () => {
64
84
  const fake = fakeUnifi((method, url) =>
@@ -25,7 +25,7 @@ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
25
25
  import { unifiHandlers } from './resource.ts';
26
26
 
27
27
  export type { FirewallZoneAttributes, FirewallZoneProps } from './firewall-zone-form.ts';
28
- export { declareFirewallZone } from './firewall-zone-form.ts';
28
+ export { declareFirewallZone, driftOf } from './firewall-zone-form.ts';
29
29
 
30
30
  export interface UnifiFirewallZone extends Resource<
31
31
  'Unifi.FirewallZone',