@homeflare/alchemy 0.50.0 → 0.52.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 (75) hide show
  1. package/dist/linux/container-env-quote.d.ts +39 -0
  2. package/dist/linux/container-env-quote.d.ts.map +1 -0
  3. package/dist/linux/container-form.d.ts +14 -13
  4. package/dist/linux/container-form.d.ts.map +1 -1
  5. package/dist/linux/container-paths.d.ts +17 -0
  6. package/dist/linux/container-paths.d.ts.map +1 -0
  7. package/dist/linux/index.js +41 -8
  8. package/dist/linux/index.js.map +6 -4
  9. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  10. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  11. package/dist/unifi/acl-rule.d.ts +6 -1
  12. package/dist/unifi/acl-rule.d.ts.map +1 -1
  13. package/dist/unifi/dns-policy.d.ts +6 -1
  14. package/dist/unifi/dns-policy.d.ts.map +1 -1
  15. package/dist/unifi/fake-unifi.d.ts +4 -0
  16. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  17. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  18. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  19. package/dist/unifi/firewall-policy.d.ts +6 -1
  20. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  21. package/dist/unifi/firewall-zone.d.ts +6 -1
  22. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  23. package/dist/unifi/index.d.ts +5 -3
  24. package/dist/unifi/index.d.ts.map +1 -1
  25. package/dist/unifi/index.js +339 -86
  26. package/dist/unifi/index.js.map +10 -6
  27. package/dist/unifi/network-scope.d.ts +31 -0
  28. package/dist/unifi/network-scope.d.ts.map +1 -0
  29. package/dist/unifi/network-update.d.ts +44 -0
  30. package/dist/unifi/network-update.d.ts.map +1 -0
  31. package/dist/unifi/network.d.ts +34 -13
  32. package/dist/unifi/network.d.ts.map +1 -1
  33. package/dist/unifi/policy.d.ts +89 -1
  34. package/dist/unifi/policy.d.ts.map +1 -1
  35. package/dist/unifi/resource.d.ts +17 -43
  36. package/dist/unifi/resource.d.ts.map +1 -1
  37. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  38. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  39. package/dist/unifi/update-reconcile.d.ts +31 -0
  40. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  41. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  42. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  43. package/dist/unifi/wire-guard.d.ts +33 -0
  44. package/dist/unifi/wire-guard.d.ts.map +1 -0
  45. package/docs/unifi-network-write.md +90 -0
  46. package/docs/unifi.md +40 -39
  47. package/package.json +1 -1
  48. package/src/linux/container-env-chain.test.ts +135 -0
  49. package/src/linux/container-env-live.test.ts +115 -0
  50. package/src/linux/container-env-oracle.ts +109 -0
  51. package/src/linux/container-env-quadlet-oracle.ts +174 -0
  52. package/src/linux/container-env-quote.test.ts +131 -0
  53. package/src/linux/container-env-quote.ts +126 -0
  54. package/src/linux/container-fixture.test.ts +3 -1
  55. package/src/linux/container-form.ts +32 -28
  56. package/src/linux/container-paths.ts +21 -0
  57. package/src/unifi/fake-unifi.ts +15 -2
  58. package/src/unifi/handler-allow-news.test.ts +80 -0
  59. package/src/unifi/index.ts +21 -5
  60. package/src/unifi/network-scope.test.ts +224 -0
  61. package/src/unifi/network-scope.ts +75 -0
  62. package/src/unifi/network-update.test.ts +250 -0
  63. package/src/unifi/network-update.ts +95 -0
  64. package/src/unifi/network.test.ts +27 -1
  65. package/src/unifi/network.ts +58 -13
  66. package/src/unifi/policy.ts +128 -10
  67. package/src/unifi/resource.test.ts +15 -3
  68. package/src/unifi/resource.ts +64 -105
  69. package/src/unifi/update-reconcile.ts +126 -0
  70. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  71. package/src/unifi/wire-guard-round2.test.ts +120 -0
  72. package/src/unifi/wire-guard.test.ts +218 -0
  73. package/src/unifi/wire-guard.ts +192 -0
  74. package/src/unifi/write-op-reference.test.ts +41 -4
  75. package/src/unifi/get-only-guard.test.ts +0 -165
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The `[Container]` `Environment=` value renderer — the one place a declared env var crosses THREE
3
+ * parsers between `container.environment` and the container's own environment, each able to mangle
4
+ * a value the props held intact:
5
+ *
6
+ * 1. Quadlet's generator reads the raw line value with systemd's exec-like splitter:
7
+ * `pkg/systemd/parser/unitfile.go` `LookupAllKeyVal` calls `splitString(value,
8
+ * WhitespaceSeparators, SplitRelax | SplitUnquote | SplitCUnescape)` (podman v5.4.0). An
9
+ * unquoted space ENDS the assignment there; a quote starts/ends a quoted region wherever it
10
+ * sits; C-escapes are processed; and an unsupported escape (`\d` in a regex, say) fails the
11
+ * split — `LookupAllKeyVal` then drops EVERY assignment of that line, silently.
12
+ * 2. Quadlet re-emits each assignment as `--env KEY=value` arguments of the generated unit's
13
+ * `ExecStart=` (`podman.addEnv` → `AddCmdline` → `escapeWords`, same source), re-quoting for
14
+ * the next parser but never touching `%` or `$` — both pass through verbatim.
15
+ * 3. systemd parses that `ExecStart=` and expands BOTH syntaxes there. `%`: `%%` folds to one
16
+ * literal `%` and an unknown `%x` makes the setting invalid ("Specifiers must be known and
17
+ * resolvable for the setting to be valid", systemd.unit(5) §Specifiers). `$`
18
+ * (systemd.service(5) §Command lines, v257): the generated ExecStart has no `:` prefix, so
19
+ * variables are always substituted — `${FOO}` is "erased and replaced by the exact value of
20
+ * the environment variable", "to pass a literal dollar sign, use "$$"", and a bare `$` in
21
+ * the middle of a word stays literal (unbraced expansion applies only to a whole `$FOO`
22
+ * word, which a `KEY=value` word never is). So `pa$$word` arrives halved as `pa$word` and
23
+ * `a${HOME}b` arrives as `ab` — the variable is resolved from the UNIT's environment, not
24
+ * the one this file declares.
25
+ *
26
+ * ⛔ MEASURED 2026-10-11 (ice2/grafana-oidc.md; reproduced locally with the real podman 5.4.2
27
+ * generator): the previous renderer emitted `Environment=KEY=value` UNQUOTED, so Grafana's
28
+ * `ROLE_ATTRIBUTE_PATH=contains(groups[*], '…')` reached the container as
29
+ * `ROLE_ATTRIBUTE_PATH=contains(groups[*],` — split at the first space — plus a bogus `--env
30
+ * cn=…` variable, and every login failed `role_attribute_strict_violation`.
31
+ * ⛔ MEASURED 2026-10-12 (same generator dry run, plus a live systemd 257 unit): escaping `\`, `"`,
32
+ * `%` and `$` is what survives all three parsers. `Environment="KEY=a b"` generates `--env
33
+ * "KEY=a b"`; `Environment="D=pa$$$$word"` generates `--env D=pa$$$$word` and
34
+ * `Environment="B=a$${HOME}b"` generates `--env B=a$${HOME}b` — podman passes `$` through
35
+ * untouched — and the live unit folded them back to `pa$$word` and `a${HOME}b`, exactly the
36
+ * declared values, while the same values UNQUOTED arrived as `pa$word` and `ab`.
37
+ * ⛔ NEWLINES ARE REFUSED, NOT ENCODED — a raw newline in a key or value breaks the LINE the whole
38
+ * format is built on: quadlet reads the file line by line, so the assignment ends mid-value and
39
+ * anything after the newline is parsed as a NEW directive (attacker-controllable key injection
40
+ * when a value comes from external config). The refusal is a typed error so a caller can catch
41
+ * it by tag; `containerProblems` (container-form.ts) turns it into a problem string so one plan
42
+ * still shows every refusal at once.
43
+ */
44
+ import * as Data from 'effect/Data';
45
+
46
+ /** Same shape as container-form.ts's `UnitLine` — structural, so this module imports nothing back. */
47
+ export type EnvironmentLine = readonly [key: string, value: string];
48
+
49
+ /** The shared rule text — the typed error's message and the problem string are the same sentence. */
50
+ const environmentValueProblem = (key: string): string =>
51
+ `environment key ${JSON.stringify(key)} contains a newline; a .container file is line-based, ` +
52
+ 'so quadlet would end the assignment mid-value and parse whatever follows as a new directive. ' +
53
+ 'Nothing was written.';
54
+
55
+ /**
56
+ * ⛔ CARRIES THE KEY AND THE RULE, NEVER THE VALUE — an error is logged, retried and pasted into a
57
+ * PR, and a newline-containing value may be arbitrarily large or semi-secret; the key name (which
58
+ * `secretLikeEnvironment` already names in its own messages) is enough to locate the prop.
59
+ */
60
+ export class QuadletEnvironmentValueError extends Data.TaggedError('QuadletEnvironmentValueError')<{
61
+ readonly key: string;
62
+ }> {
63
+ override get message(): string {
64
+ return environmentValueProblem(this.key);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * `Environment="KEY=value"` — the whole assignment double-quoted, with `\`, `"`, `%` and `$`
70
+ * escaped (in that order, over the joined `KEY=value`), so the wrapping quotes this function adds
71
+ * can never be confused with quotes the value contained, every `%` reaches systemd's specifier
72
+ * expansion as the documented `%%` literal instead of an unknown `%x` that invalidates the unit,
73
+ * and every `$` reaches it as `$$` — the documented literal-dollar escape (systemd.service(5)
74
+ * §Command lines) — instead of a `${FOO}` systemd would erase and replace with a variable of ITS
75
+ * choosing, or a `$$` it would fold and halve.
76
+ */
77
+ export const quoteEnvironmentValue = (key: string, value: string): string => {
78
+ if (key.includes('\n') || value.includes('\n')) throw new QuadletEnvironmentValueError({ key });
79
+ // ⛔ THE `$` REPLACEMENT MUST BE A FUNCTION — measured 2026-10-12: `replaceAll`'s replacement
80
+ // STRING runs its own `$`-pattern mini-language (same fold as systemd: `$$` in it IS one
81
+ // literal `$`, and `$&` is the match), so a plain `'$$'` replacement silently no-ops and the
82
+ // value ships bare. A function's return value is inserted verbatim, no patterns read.
83
+ // The earlier `\`, `"` and `%` replacements keep string form: only `$` starts a pattern.
84
+ return `"${`${key}=${value}`
85
+ .replaceAll('\\', '\\\\')
86
+ .replaceAll('"', '\\"')
87
+ .replaceAll('%', '%%')
88
+ .replaceAll('$', () => '$$')}"`;
89
+ };
90
+
91
+ /**
92
+ * The sorted, quoted `Environment=` lines of a `[Container]` section — spliced by container-form.ts
93
+ * between `EnvironmentFile=` and `Volume=`, exactly where the unquoted renderer emitted them, so
94
+ * the render ORDER does not change (only the bytes of the Environment lines themselves do).
95
+ */
96
+ export const environmentLines = (
97
+ environment: Readonly<Record<string, string>>,
98
+ ): readonly EnvironmentLine[] =>
99
+ // ★ Object.entries, not keys+index: under noUncheckedIndexedAccess an indexed read is
100
+ // `string | undefined`, and the sort below reproduces the old `keys().sort()` order exactly
101
+ // (both compare UTF-16 code units), so the render order does not change.
102
+ Object.entries(environment)
103
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
104
+ .map(([key, value]): EnvironmentLine => ['Environment', quoteEnvironmentValue(key, value)]);
105
+
106
+ /**
107
+ * The newline refusal as problem STRINGS (containerProblems' currency), so one plan still shows the
108
+ * whole list instead of aborting on the first newline — the typed error above stays the contract
109
+ * for anyone rendering directly.
110
+ */
111
+ export const environmentValueProblems = (
112
+ environment: Readonly<Record<string, string>>,
113
+ ): string[] => {
114
+ const found: string[] = [];
115
+ for (const [key, value] of Object.entries(environment).sort(([a], [b]) =>
116
+ a < b ? -1 : a > b ? 1 : 0,
117
+ )) {
118
+ try {
119
+ quoteEnvironmentValue(key, value);
120
+ } catch (error) {
121
+ if (!(error instanceof QuadletEnvironmentValueError)) throw error;
122
+ found.push(environmentValueProblem(key));
123
+ }
124
+ }
125
+ return found;
126
+ };
@@ -66,7 +66,9 @@ test('renders every directive CT100’s live caddy.container carries', () => {
66
66
  'ContainerName=caddy',
67
67
  'Network=host',
68
68
  'EnvironmentFile=/etc/caddy/cf-token.env',
69
- 'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
69
+ // ⛔ Quoted, like every Environment= value now (container-env-quote.ts) — the caddy value has
70
+ // no spaces, but uniform quoting is the rule; container-env-quote.test.ts has the story.
71
+ 'Environment="CADDY_CONFIG=/etc/caddy/Caddyfile"',
70
72
  'Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
71
73
  'Volume=/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
72
74
  'Volume=/etc/caddy/cf-token.env:/run/cf/token.env:ro',
@@ -16,6 +16,15 @@
16
16
  * `containerProblems` below. A container that needs a secret takes `EnvironmentFile=` pointing at
17
17
  * a host path an out-of-band secret renderer (openbao-agent) maintains; this family never reads
18
18
  * or renders that file's contents.
19
+ * ⛔ `Environment=` VALUES ARE ALWAYS QUOTED, VIA container-env-quote.ts — measured 2026-10-11:
20
+ * unquoted, a spaced value splits at its first space, a quoted fragment re-parses into a bogus
21
+ * variable, and an unknown C escape drops the line's assignments silently (ice2/grafana-oidc.md;
22
+ * the live podman 5.4.2 generator reproduced all three). Quoting costs the PARSE nothing — the
23
+ * generator reads a quoted line as exactly one assignment — and it also stops systemd expanding
24
+ * `%` specifiers in values. It is NOT free at deploy: every Environment line's BYTES change, so
25
+ * the first deploy after this bump rewrites the file and RESTARTS every container declaring
26
+ * `environment` (all 13 on ct100; only grafana's values change meaning) — plan it as one window.
27
+ * Newlines are refused, never encoded (same module).
19
28
  * ⛔ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
20
29
  * "Currently, only the Alias, WantedBy and RequiredBy keys are supported") — NOT `Also=`, which
21
30
  * plain `systemd.unit(5)` allows and unit-text.ts's `INSTALL_KEYS` therefore lists. See
@@ -23,9 +32,14 @@
23
32
  */
24
33
  import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
25
34
  import { podmanArgsProblems, secretLikeEnvironment, secretLikeLines } from './container-secrets.ts';
35
+ import { environmentLines, environmentValueProblems } from './container-env-quote.ts';
36
+ import { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES } from './container-paths.ts';
26
37
 
27
38
  /** ★ Reused, not redefined: same digest and `restartOn`-join contract as Systemd.Unit. */
28
39
  export { configDigest, digestOf };
40
+ // ★ The search path lives in container-paths.ts (file-length cap) and is re-exported here so every
41
+ // importer and the barrel keep one import site — the API does not move.
42
+ export { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES };
29
43
 
30
44
  export type UnitLine = readonly [key: string, value: string];
31
45
 
@@ -64,7 +78,8 @@ export type ContainerSection = {
64
78
  export type ContainerProps = {
65
79
  /** The Quadlet file's basename, WITHOUT `.container`. Written as `<name>.container`. */
66
80
  name: string;
67
- /** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES (below). */
81
+ /** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES
82
+ * (container-paths.ts, where the search path now lives). */
68
83
  directory?: string;
69
84
  /** `[Unit]` — `Description=`/`Documentation=` typed, everything else verbatim (see file header). */
70
85
  unit?: { description?: string; documentation?: string; lines?: readonly UnitLine[] };
@@ -103,25 +118,8 @@ export type ContainerAttributes = {
103
118
  };
104
119
 
105
120
  /**
106
- * Podman's OWN rootful search path, most specific first — `podman-systemd.unit(5)`, "Podman Unit
107
- * Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
108
- * following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
109
- * ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
110
- * 2026-09-24: `/etc/containers/systemd/caddy.container`.
111
- * ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
112
- * a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
113
- * unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
114
- * it were enabled. See `containerProblems`.
121
+ * ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`.
115
122
  */
116
- export const QUADLET_SEARCH_DIRECTORIES = [
117
- '/run/containers/systemd',
118
- '/etc/containers/systemd',
119
- '/usr/share/containers/systemd',
120
- ] as const;
121
-
122
- export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
123
-
124
- /** ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`. */
125
123
  export const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
126
124
 
127
125
  export const containerPathFor = (props: Pick<ContainerProps, 'name' | 'directory'>): string =>
@@ -149,13 +147,8 @@ const containerLines = (c: ContainerSection): UnitLine[] => [
149
147
  ...(c.exec === undefined ? [] : [['Exec', c.exec] as UnitLine]),
150
148
  ...(c.autoUpdate === undefined ? [] : [['AutoUpdate', c.autoUpdate] as UnitLine]),
151
149
  ...(c.environmentFile ?? []).map((file): UnitLine => ['EnvironmentFile', file]),
152
- // ★ Sorted by key: a stable render for a plain object whose own key order JS does not promise.
153
- ...Object.keys(c.environment ?? {})
154
- .sort()
155
- .map((key): UnitLine => [
156
- 'Environment',
157
- `${key}=${(c.environment as Record<string, string>)[key]}`,
158
- ]),
150
+ // ⛔ Always quoted, via container-env-quote.ts (see this file's header) — sorted there too.
151
+ ...environmentLines(c.environment ?? {}),
159
152
  ...(c.volume ?? []).map((volume): UnitLine => ['Volume', volume]),
160
153
  ...(c.publishPort ?? []).map((port): UnitLine => ['PublishPort', port]),
161
154
  ...(c.podmanArgs ?? []).map((arg): UnitLine => ['PodmanArgs', arg]),
@@ -225,19 +218,30 @@ export const containerProblems = (props: ContainerProps): string[] => {
225
218
  (problem) => `container.${problem}`,
226
219
  ),
227
220
  );
221
+ // ⛔ Newlines in a key or value are refused before anything renders: a .container file is read
222
+ // line by line, so a raw newline ends the assignment mid-value and whatever follows parses as a
223
+ // NEW directive (key injection when a value comes from external config). Strings here so one
224
+ // plan shows every refusal at once; the typed error stays the contract for direct renderers.
225
+ const envProblems = environmentValueProblems(props.container.environment ?? {});
226
+ found.push(...envProblems);
228
227
  // ★ Every OTHER rendered line — `Exec=`/`PodmanArgs=` and the `unit`/`service`/`install` escape
229
228
  // hatches — checked too, so a secret cannot reach the file through a field this typed check does
230
229
  // not otherwise inspect. `container.environment`'s OWN lines are excluded: `secretLikeEnvironment`
231
230
  // above already checked them, with a better error message (the actual env var name).
231
+ // ⛔ The sweep renders WITHOUT the environment (a newline value cannot throw here); the NUL
232
+ // check is skipped when the environment could not render — the refusals above are its result.
233
+ const { environment: _env, ...withoutEnvironment } = props.container;
232
234
  found.push(
233
235
  ...secretLikeLines([
234
236
  ...unitLines(props),
235
- ...containerLines(props.container).filter(([key]) => key !== 'Environment'),
237
+ // ⛔ No `Environment` filter needed: the destructure above removed the only field whose
238
+ // lines would carry it, so `containerLines` here emits none — a filter would be dead code.
239
+ ...containerLines(withoutEnvironment),
236
240
  ...serviceLines(props),
237
241
  ...installLines(props),
238
242
  ]),
239
243
  );
240
- if (renderContainerFile(props).includes('\u0000'))
244
+ if (envProblems.length === 0 && renderContainerFile(props).includes('\u0000'))
241
245
  found.push('the rendered .container file contains NUL');
242
246
  return found;
243
247
  };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Where Quadlet looks for `.container` files — split out of container-form.ts under the file-length
3
+ * cap, with the measured context that justifies every entry kept intact.
4
+ *
5
+ * Podman's OWN rootful search path, most specific first — `podman-systemd.unit(5)`, "Podman Unit
6
+ * Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
7
+ * following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
8
+ * ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
9
+ * 2026-09-24: `/etc/containers/systemd/caddy.container`.
10
+ * ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
11
+ * a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
12
+ * unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
13
+ * it were enabled. See `containerProblems`.
14
+ */
15
+ export const QUADLET_SEARCH_DIRECTORIES = [
16
+ '/run/containers/systemd',
17
+ '/etc/containers/systemd',
18
+ '/usr/share/containers/systemd',
19
+ ] as const;
20
+
21
+ export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
@@ -17,7 +17,14 @@ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
17
17
  export const FAKE_BASE = 'https://unifi.example.com/proxy/network/integration';
18
18
  export const FAKE_KEY = 'placeholder-unifi-key';
19
19
 
20
- export type Seen = { readonly method: string; readonly path: string };
20
+ export type Seen = {
21
+ readonly method: string;
22
+ readonly path: string;
23
+ /** Parsed JSON request body, when one was sent. */
24
+ readonly body?: unknown;
25
+ /** The `redirect` mode fetch was handed (`FetchHttpClient.RequestInit`), when set. */
26
+ readonly redirect?: NonNullable<RequestInit['redirect']>;
27
+ };
21
28
 
22
29
  /**
23
30
  * Plain-text failure. `protocol.ts`'s `errorEnvelope` also decodes a documented JSON shape
@@ -42,7 +49,13 @@ export const fakeUnifi = (route: (method: string, url: URL) => Response) => {
42
49
  const request =
43
50
  input instanceof Request ? new Request(input, init) : new Request(String(input), init);
44
51
  const url = new URL(request.url);
45
- seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
52
+ const text = request.method === 'GET' ? '' : await request.clone().text();
53
+ seen.push({
54
+ method: request.method,
55
+ path: `${url.pathname}${url.search}`,
56
+ ...(text === '' ? {} : { body: JSON.parse(text) as unknown }),
57
+ ...(init?.redirect === undefined ? {} : { redirect: init.redirect }),
58
+ });
46
59
  return route(request.method, url);
47
60
  }) as typeof globalThis.fetch;
48
61
  return { fetch, seen };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `unifiHandlers.reconcile` builds the allowed PUT from `news`, never `olds` (red team round 1,
3
+ * mutation gap), and anchors it to the CONFIGURED base URL read from the credentials.
4
+ *
5
+ * ★ A probe spec whose `describe` is constant, so `updateReconcile`'s identity check passes and the
6
+ * ONLY thing separating `news.id` from `olds.id` is the wire guard's allow entry.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as ConfigProvider from 'effect/ConfigProvider';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Exit from 'effect/Exit';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as HttpClient from 'effect/http/HttpClient';
14
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
15
+ import { FAKE_BASE, FAKE_KEY, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
16
+ import { type UnifiSpec, unifiHandlers } from './resource.ts';
17
+
18
+ interface Probe {
19
+ id: string;
20
+ }
21
+ interface State {
22
+ written: boolean;
23
+ }
24
+
25
+ const probe: UnifiSpec<Probe, State, State, unknown, never> = {
26
+ type: 'Test.Probe',
27
+ describe: () => 'probe',
28
+ fetchLive: () =>
29
+ Effect.gen(function* () {
30
+ const client = yield* HttpClient.HttpClient;
31
+ const res = yield* client.execute(HttpClientRequest.get(`${FAKE_BASE}/probe`));
32
+ return (yield* res.json) as unknown as State;
33
+ }) as never,
34
+ attributes: (live) => live,
35
+ matches: (a) => a.written,
36
+ update: {
37
+ allowedWrite: (props) => ({ method: 'PUT', tail: `/probe/${props.id}` }),
38
+ // `id` differs between news and olds, so the patch is non-empty and the write really runs.
39
+ patchKeys: ['id'],
40
+ driftOf: () => [],
41
+ checkScope: () => Effect.void,
42
+ write: (_live, _patch, props) =>
43
+ Effect.gen(function* () {
44
+ const client = yield* HttpClient.HttpClient;
45
+ const res = yield* client.execute(HttpClientRequest.put(`${FAKE_BASE}/probe/${props.id}`));
46
+ return (yield* res.json) as unknown as State;
47
+ }) as never,
48
+ },
49
+ };
50
+
51
+ const config = ConfigProvider.layer(
52
+ ConfigProvider.fromUnknown({
53
+ UNIFI_NETWORK_API_BASE_URL: FAKE_BASE,
54
+ UNIFI_NETWORK_API_KEY: FAKE_KEY,
55
+ }),
56
+ );
57
+
58
+ const reconcile = (news: Probe, olds: Probe) => {
59
+ let written = false;
60
+ const fake = fakeUnifi((method) => {
61
+ if (method === 'PUT') written = true;
62
+ return Response.json({ written });
63
+ });
64
+ const exit = Effect.runPromiseExit(
65
+ unifiHandlers(probe)
66
+ .reconcile({ news, olds, output: { written: false }, session: { note: () => Effect.void } })
67
+ .pipe(Effect.provide(Layer.mergeAll(fakeUnifiLayer(fake.fetch), config))) as never,
68
+ );
69
+ return { fake, exit };
70
+ };
71
+
72
+ describe('unifiHandlers.reconcile allow entry', () => {
73
+ test('is built from news: a PUT to news.id passes even though olds.id differs', async () => {
74
+ const { fake, exit } = reconcile({ id: 'NEW' }, { id: 'OLD' });
75
+ expect(Exit.isSuccess(await exit)).toBe(true);
76
+ expect(fake.seen.filter((s) => s.method === 'PUT').map((s) => s.path)).toEqual([
77
+ '/proxy/network/integration/probe/NEW',
78
+ ]);
79
+ });
80
+ });
@@ -1,20 +1,36 @@
1
1
  /**
2
- * UniFi Network providers for Alchemy — READ-ONLY, by Tim's rule (2026-09-24; `policy.ts`).
2
+ * UniFi Network providers for Alchemy — ADOPT-ONLY, except `Unifi.Network` update (Tim's rule of
3
+ * 2026-09-24 lifted for that one case on 2026-10-10; `policy.ts`).
3
4
  *
4
5
  * ⛔ THIS BARREL IS THE PUBLIC API, SMALLER THAN THE DIRECTORY. `resource.ts`'s generic engine
5
6
  * and each `*-form.ts`'s wire helpers are internals a provider needs but a consumer should
6
7
  * not depend on — mirrors `../netbox/index.ts`.
7
8
  */
8
- export { UNIFI_READ_ONLY_POLICY, UnifiWriteRefused, type UnifiWriteAction } from './policy.ts';
9
+ export {
10
+ UNIFI_READ_ONLY_POLICY,
11
+ UNIFI_WRITE_POLICY,
12
+ UnifiFieldNotRemovable,
13
+ UnifiFieldNotSettable,
14
+ UnifiIdentityChanged,
15
+ UnifiImmutableFieldChanged,
16
+ UnifiLiveDriftedSinceDeploy,
17
+ UnifiManagementNetworkRefused,
18
+ UnifiUpdateDidNotConverge,
19
+ UnifiUpdateWouldBeNoop,
20
+ UnifiWriteRefused,
21
+ type UnifiWriteAction,
22
+ } from './policy.ts';
9
23
  export {
10
24
  // Barrel-exported (not just internal to `unifiHandlers`) so `homeflare-network`'s own
11
25
  // import-layer guard (plan item C0) can reuse this instead of re-implementing the same
12
26
  // `HttpClient` wrap against a second copy of the SDK's `HttpClient.HttpClient` service.
27
+ type AllowedWrite,
13
28
  GetOnlyHttpClient,
14
29
  UnifiNonGetRequest,
15
- type UnifiRequirements,
16
- type UnifiSpec,
17
- } from './resource.ts';
30
+ UnifiRefusedRequest,
31
+ guardedHttpClient,
32
+ } from './wire-guard.ts';
33
+ export { type UnifiRequirements, type UnifiSpec } from './resource.ts';
18
34
  export type { FieldDrift } from './drift.ts';
19
35
  export {
20
36
  UnifiNetwork,
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `Unifi.Network`'s write SCOPE (`network-scope.ts`), identity check, no-retry PUT and message
3
+ * wording — red team round 1 on PR 377. Drives `unifiOperations(spec).reconcile` against `fakeUnifi`,
4
+ * so the real SDK `updateNetwork` assembles the request and the fake records every one.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Cause from 'effect/Cause';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Exit from 'effect/Exit';
10
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
11
+ import { type NetworkProps, spec } from './network.ts';
12
+ import { IMMUTABLE_KEYS, REMOVABLE_KEYS, SETTABLE_KEYS } from './network-scope.ts';
13
+ import { UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
14
+ import { unifiOperations } from './resource.ts';
15
+
16
+ const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
17
+ const IPV6 = {
18
+ interfaceType: 'STATIC',
19
+ prefixLength: 64,
20
+ hostIpAddress: '2602:f660:0:16::254',
21
+ clientAddressAssignment: { slaacEnabled: true },
22
+ routerAdvertisement: { priority: 'HIGH' },
23
+ };
24
+ const BASE: NetworkProps = {
25
+ siteId: 'site-1',
26
+ networkId: 'net-1',
27
+ name: 'Compute',
28
+ enabled: true,
29
+ management: 'ADVANCED',
30
+ vlanId: 16,
31
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
32
+ ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
33
+ };
34
+ const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
35
+
36
+ const live = (extra: Record<string, unknown> = {}) => ({
37
+ id: 'net-1',
38
+ default: false,
39
+ metadata: { origin: 'USER' },
40
+ ...BASE,
41
+ siteId: undefined,
42
+ networkId: undefined,
43
+ ...extra,
44
+ });
45
+
46
+ const run = (
47
+ liveBody: object,
48
+ news: NetworkProps,
49
+ olds: NetworkProps,
50
+ put: () => Response = () => Response.json(liveBody),
51
+ ) => {
52
+ const fake = fakeUnifi((method, url) =>
53
+ url.pathname === PATH
54
+ ? method === 'GET'
55
+ ? Response.json(liveBody)
56
+ : put()
57
+ : fakeFailure(400, 'unexpected'),
58
+ );
59
+ const exit = Effect.runPromiseExit(
60
+ unifiOperations(spec)
61
+ .reconcile(news, { olds, output: {} })
62
+ .pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
63
+ );
64
+ return { fake, exit };
65
+ };
66
+
67
+ const tagOf = (exit: Exit.Exit<unknown, unknown>) => {
68
+ if (!Exit.isFailure(exit)) return 'Success';
69
+ const error = Cause.squash(exit.cause) as { _tag?: string; message?: string };
70
+ return error._tag ?? 'defect';
71
+ };
72
+ const puts = (fake: { seen: ReadonlyArray<{ method: string }> }) =>
73
+ fake.seen.filter((s) => s.method === 'PUT');
74
+
75
+ // ⛔ EXACT-CONTENTS PIN. The three allowlists are the whole write scope: a widened SETTABLE_KEYS or
76
+ // REMOVABLE_KEYS would let a whole-object PUT reach a key the controller reads "off" when omitted,
77
+ // and a narrowed IMMUTABLE_KEYS would let a patch move the network (`zoneId`/`vlanId`/`management`/
78
+ // `deviceId`) or the management LAN (`default` is refused separately, not listed here). A widening
79
+ // with a key no behaviour test below names would keep them all green, so each list is pinned to
80
+ // its EXACT contents — a mutation fails this test even when nothing else does.
81
+ describe('the three key sets are pinned to their exact contents', () => {
82
+ test('SETTABLE_KEYS is exactly ipv6Configuration', () => {
83
+ expect([...SETTABLE_KEYS]).toEqual(['ipv6Configuration']);
84
+ });
85
+
86
+ test('REMOVABLE_KEYS is exactly ipv6Configuration', () => {
87
+ expect([...REMOVABLE_KEYS]).toEqual(['ipv6Configuration']);
88
+ });
89
+
90
+ test('IMMUTABLE_KEYS is exactly zoneId/vlanId/management/deviceId', () => {
91
+ expect([...IMMUTABLE_KEYS]).toEqual(['zoneId', 'vlanId', 'management', 'deviceId']);
92
+ });
93
+ });
94
+
95
+ describe('removable-field allowlist (omission would turn the block off)', () => {
96
+ test.each([
97
+ ['ipv4Configuration', 'ipv4Configuration'],
98
+ ['dhcpGuarding', 'dhcpGuarding'],
99
+ ['a required key (name)', 'name'],
100
+ ])('dropping %s => UnifiFieldNotRemovable, zero PUTs', async (_n, key) => {
101
+ const { [key as 'name']: _gone, ...news } = { ...WITH_V6 } as NetworkProps;
102
+ const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news as NetworkProps, WITH_V6);
103
+ const result = await exit;
104
+ expect(tagOf(result)).toBe('UnifiFieldNotRemovable');
105
+ expect(JSON.stringify(result)).toContain(key);
106
+ expect(puts(fake)).toHaveLength(0);
107
+ });
108
+
109
+ // ⚠️ Mutation gap: an explicit `null` removes a key just like absence (`== null`, not `=== undefined`).
110
+ test('an explicit null on ipv6Configuration removes it from the body', async () => {
111
+ const news = { ...BASE, ipv6Configuration: null } as unknown as NetworkProps;
112
+ const { fake, exit } = run(live({ ipv6Configuration: IPV6 }), news, WITH_V6, () =>
113
+ Response.json(live()),
114
+ );
115
+ expect(tagOf(await exit)).toBe('Success');
116
+ const body = puts(fake)[0] as { body?: Record<string, unknown> };
117
+ expect(body.body).toBeDefined();
118
+ expect('ipv6Configuration' in (body.body as object)).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe('settable-field allowlist (only ipv6Configuration may be written)', () => {
123
+ // ⛔ Round-2 BLOCKING finding: the PR claimed this scope but sent these PUTs. Each case is a real
124
+ // change to a non-ipv6 key, with live matching the prior state, so only the allowlist stops it.
125
+ test.each([
126
+ ['enabled', { enabled: false }, {}],
127
+ ['internetAccessEnabled', { internetAccessEnabled: false }, { internetAccessEnabled: true }],
128
+ [
129
+ 'ipv4Configuration',
130
+ { ipv4Configuration: { dhcpConfiguration: { mode: 'NONE' }, hostIpAddress: '10.9.9.1' } },
131
+ {},
132
+ ],
133
+ ['dhcpGuarding', { dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.9'] } }, {}],
134
+ ])('a patch that sets %s => UnifiFieldNotSettable, zero PUTs', async (field, change, olds) => {
135
+ const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
136
+ const { fake, exit } = run(
137
+ live({ ipv6Configuration: IPV6, ...olds }),
138
+ { ...oldProps, ...change } as NetworkProps,
139
+ oldProps,
140
+ );
141
+ const result = await exit;
142
+ expect(tagOf(result)).toBe('UnifiFieldNotSettable');
143
+ expect(JSON.stringify(result)).toContain(field);
144
+ expect(puts(fake)).toHaveLength(0);
145
+ });
146
+
147
+ test('setting ipv6Configuration is still allowed', async () => {
148
+ const { fake, exit } = run(live(), WITH_V6, BASE, () =>
149
+ Response.json(live({ ipv6Configuration: IPV6 })),
150
+ );
151
+ expect(tagOf(await exit)).toBe('Success');
152
+ expect(puts(fake)).toHaveLength(1);
153
+ });
154
+ });
155
+
156
+ describe('write scope', () => {
157
+ test('a live default (management) network is refused, zero PUTs', async () => {
158
+ const { fake, exit } = run(live({ default: true }), WITH_V6, BASE);
159
+ expect(tagOf(await exit)).toBe('UnifiManagementNetworkRefused');
160
+ expect(puts(fake)).toHaveLength(0);
161
+ });
162
+
163
+ test.each([
164
+ ['zoneId', { zoneId: 'zone-2' }, { zoneId: 'zone-1' }],
165
+ ['vlanId', { vlanId: 17 }, {}],
166
+ ['management', { management: 'GATEWAY' }, {}],
167
+ ['deviceId', { deviceId: 'dev-2' }, { deviceId: 'dev-1' }],
168
+ ])('a patch that changes %s is refused, zero PUTs', async (field, change, olds) => {
169
+ const oldProps = { ...WITH_V6, ...olds } as NetworkProps;
170
+ const { fake, exit } = run(
171
+ live({ ipv6Configuration: IPV6, ...olds }),
172
+ { ...oldProps, ...change } as NetworkProps,
173
+ oldProps,
174
+ );
175
+ const result = await exit;
176
+ expect(tagOf(result)).toBe('UnifiImmutableFieldChanged');
177
+ expect(JSON.stringify(result)).toContain(field);
178
+ expect(puts(fake)).toHaveLength(0);
179
+ });
180
+
181
+ test('olds naming another network id => UnifiIdentityChanged, zero PUTs', async () => {
182
+ const { fake, exit } = run(live(), WITH_V6, { ...BASE, networkId: 'net-OLD' });
183
+ expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
184
+ expect(puts(fake)).toHaveLength(0);
185
+ });
186
+
187
+ test('olds naming another site id => UnifiIdentityChanged, zero PUTs', async () => {
188
+ const { fake, exit } = run(live(), WITH_V6, { ...BASE, siteId: 'site-OLD' });
189
+ expect(tagOf(await exit)).toBe('UnifiIdentityChanged');
190
+ expect(puts(fake)).toHaveLength(0);
191
+ });
192
+ });
193
+
194
+ describe('the PUT is attempted once', () => {
195
+ test('a 503 on the PUT is not retried', async () => {
196
+ const { fake, exit } = run(live(), WITH_V6, BASE, () => fakeFailure(503, 'busy'));
197
+ expect((await exit)._tag).toBe('Failure');
198
+ expect(puts(fake)).toHaveLength(1);
199
+ });
200
+ });
201
+
202
+ describe('error wording', () => {
203
+ test('did-not-converge says the PUT landed and names re-import', () => {
204
+ const message = new UnifiUpdateDidNotConverge({
205
+ type: 'Unifi.Network',
206
+ identity: 'sites/s/networks/n',
207
+ fields: ['ipv6Configuration'],
208
+ }).message;
209
+ expect(message).toContain('LANDED');
210
+ expect(message).toContain('differs from the declaration');
211
+ expect(message).toContain('re-import');
212
+ expect(message).toContain('[ipv6Configuration]');
213
+ });
214
+
215
+ test('live-drifted names both causes, not only a hand edit', () => {
216
+ const message = new UnifiLiveDriftedSinceDeploy({
217
+ type: 'Unifi.Network',
218
+ identity: 'sites/s/networks/n',
219
+ fields: ['name'],
220
+ }).message;
221
+ expect(message).toContain('hand edit');
222
+ expect(message).toContain('UnifiUpdateDidNotConverge');
223
+ });
224
+ });