@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
@@ -1,12 +1,14 @@
1
1
  /**
2
- * The one refusal every `Unifi.*` write handler returns — never an SDK write call.
2
+ * The one refusal every refused `Unifi.*` write returns. The family makes exactly one SDK write
3
+ * call — `networks.updateNetwork`, through `network-update.ts` — and that is the only write that
4
+ * never sees this error.
3
5
  *
4
- * ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY FOR NOW. This family has no
5
- * `create`, no `update` body and no delete call anywhere in its source — `reconcile` and
6
- * `delete` both fail with this typed error instead, and `resource.test.ts` proves the point
7
- * with a fake `HttpClient` that fails the test on any request that is not a `GET`. Lifting the
8
- * policy is a kit change (a new PR that adds a write path, reviewed as one), not a flag a stack
9
- * can pass.
6
+ * ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY. LIFTED FOR EXACTLY ONE CASE on
7
+ * 2026-10-10 (Tim): `Unifi.Network` update, through `update-reconcile.ts` + `network-update.ts`.
8
+ * Every other write — create, delete, any other family's update — still fails with this typed
9
+ * error, and `resource.test.ts`/`wire-guard.test.ts` prove it with a fake `HttpClient` that
10
+ * records every request. Widening the policy again is a kit change (a new PR, reviewed as one),
11
+ * not a flag a stack can pass.
10
12
  *
11
13
  * ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a
12
14
  * caller (a future import script, a test) branch on the refusal without parsing a message.
@@ -14,8 +16,14 @@
14
16
  import * as Data from 'effect/Data';
15
17
  import * as Effect from 'effect/Effect';
16
18
 
17
- export const UNIFI_READ_ONLY_POLICY =
18
- "read-only by Tim's rule, 2026-09-24; lifting it is a kit change";
19
+ export const UNIFI_WRITE_POLICY =
20
+ 'UniFi is adopt-only except Unifi.Network update: one whole-object PUT built from the live ' +
21
+ 'object plus the declared field changes, only on a row with prior Alchemy state, only through ' +
22
+ 'deploy-approve; create/delete and every other family stay refused -- widening is a kit change ' +
23
+ '(Tim 2026-10-10 lifts the 2026-09-24 rule for this one case)';
24
+
25
+ /** @deprecated renamed `UNIFI_WRITE_POLICY`; kept so existing consumer imports do not break. */
26
+ export const UNIFI_READ_ONLY_POLICY = UNIFI_WRITE_POLICY;
19
27
 
20
28
  /** What kind of write was asked for and refused — for the message only, never acted on. */
21
29
  export type UnifiWriteAction = 'create' | 'update' | 'delete';
@@ -28,10 +36,120 @@ export class UnifiWriteRefused extends Data.TaggedError('UnifiWriteRefused')<{
28
36
  readonly action: UnifiWriteAction;
29
37
  }> {
30
38
  override get message(): string {
31
- return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_READ_ONLY_POLICY}.`;
39
+ return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_WRITE_POLICY}.`;
32
40
  }
33
41
  }
34
42
 
35
43
  /** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */
36
44
  export const refuseWrite = (type: string, identity: string, action: UnifiWriteAction) =>
37
45
  Effect.fail(new UnifiWriteRefused({ type, identity, action }));
46
+
47
+ // ⛔ THE THREE ERRORS BELOW CARRY FIELD NAMES ONLY, NEVER VALUES: a network row holds addressing
48
+ // detail, and an error message reaches logs, CI bodies and pasted reports.
49
+ const fieldList = (fields: ReadonlyArray<string>) => `[${fields.join(', ')}]`;
50
+
51
+ /** Live no longer equals what the last deploy wrote: a hand edit is never overwritten. */
52
+ export class UnifiLiveDriftedSinceDeploy extends Data.TaggedError('UnifiLiveDriftedSinceDeploy')<{
53
+ readonly type: string;
54
+ readonly identity: string;
55
+ readonly fields: ReadonlyArray<string>;
56
+ }> {
57
+ override get message(): string {
58
+ return (
59
+ `${this.type} ${this.identity}: live differs from the last deployed state on ` +
60
+ `${fieldList(this.fields)}; refusing to PUT over it. Two causes look identical from here: a ` +
61
+ 'hand edit on the console, or an earlier PUT that landed but did not converge ' +
62
+ '(UnifiUpdateDidNotConverge). Re-import the live object first.'
63
+ );
64
+ }
65
+ }
66
+
67
+ /** The merged body equals live: a PUT would change nothing (keeps a re-PUT loop impossible). */
68
+ export class UnifiUpdateWouldBeNoop extends Data.TaggedError('UnifiUpdateWouldBeNoop')<{
69
+ readonly type: string;
70
+ readonly identity: string;
71
+ }> {
72
+ override get message(): string {
73
+ return `${this.type} ${this.identity}: the merged update body equals live; refusing a no-op PUT.`;
74
+ }
75
+ }
76
+
77
+ /** The PUT answered, yet the response still differs from the declaration. */
78
+ export class UnifiUpdateDidNotConverge extends Data.TaggedError('UnifiUpdateDidNotConverge')<{
79
+ readonly type: string;
80
+ readonly identity: string;
81
+ readonly fields: ReadonlyArray<string>;
82
+ }> {
83
+ override get message(): string {
84
+ return (
85
+ `${this.type} ${this.identity}: the PUT LANDED, but live now differs from the declaration on ` +
86
+ `${fieldList(this.fields)}. The change is on the console and is not rolled back; the next ` +
87
+ 'deploy will see this as drift. Recovery is a re-import of the live object.'
88
+ );
89
+ }
90
+ }
91
+
92
+ // ⛔ THE FIVE ERRORS BELOW BOUND THE WRITE SCOPE (`network-scope.ts`, `update-reconcile.ts`): the
93
+ // one intended consumer is a single VLAN's `ipv6Configuration`. Field names only, as above.
94
+
95
+ /** A patch would remove a key outside the removable allowlist (an omitted block is turned off). */
96
+ export class UnifiFieldNotRemovable extends Data.TaggedError('UnifiFieldNotRemovable')<{
97
+ readonly type: string;
98
+ readonly identity: string;
99
+ readonly fields: ReadonlyArray<string>;
100
+ }> {
101
+ override get message(): string {
102
+ return (
103
+ `${this.type} ${this.identity}: refusing to remove ${fieldList(this.fields)}; a whole-object ` +
104
+ 'PUT turns an omitted block off, and only the removable allowlist may be dropped.'
105
+ );
106
+ }
107
+ }
108
+
109
+ /** A patch would SET a key outside the settable allowlist (only `ipv6Configuration` may be written). */
110
+ export class UnifiFieldNotSettable extends Data.TaggedError('UnifiFieldNotSettable')<{
111
+ readonly type: string;
112
+ readonly identity: string;
113
+ readonly fields: ReadonlyArray<string>;
114
+ }> {
115
+ override get message(): string {
116
+ return (
117
+ `${this.type} ${this.identity}: refusing to set ${fieldList(this.fields)}; this write path ` +
118
+ 'only sets the settable allowlist (ipv6Configuration).'
119
+ );
120
+ }
121
+ }
122
+
123
+ /** The live object is the site's default (management) network: never written. */
124
+ export class UnifiManagementNetworkRefused extends Data.TaggedError(
125
+ 'UnifiManagementNetworkRefused',
126
+ )<{
127
+ readonly type: string;
128
+ readonly identity: string;
129
+ }> {
130
+ override get message(): string {
131
+ return `${this.type} ${this.identity}: live is the default (management) network; refusing any write to it.`;
132
+ }
133
+ }
134
+
135
+ /** A patch changes a field that decides where the network lives or what serves it. */
136
+ export class UnifiImmutableFieldChanged extends Data.TaggedError('UnifiImmutableFieldChanged')<{
137
+ readonly type: string;
138
+ readonly identity: string;
139
+ readonly fields: ReadonlyArray<string>;
140
+ }> {
141
+ override get message(): string {
142
+ return `${this.type} ${this.identity}: refusing to change ${fieldList(this.fields)}; those fields are never written.`;
143
+ }
144
+ }
145
+
146
+ /** `olds` and `news` name different objects: the prior state is no baseline for this write. */
147
+ export class UnifiIdentityChanged extends Data.TaggedError('UnifiIdentityChanged')<{
148
+ readonly type: string;
149
+ readonly identity: string;
150
+ readonly previous: string;
151
+ }> {
152
+ override get message(): string {
153
+ return `${this.type} ${this.identity}: the last deploy named ${this.previous}; refusing to PUT against another object.`;
154
+ }
155
+ }
@@ -13,7 +13,12 @@ import { Unowned } from 'alchemy/AdoptPolicy';
13
13
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
14
14
  import * as Effect from 'effect/Effect';
15
15
  import { fakeUnifiLayer } from './fake-unifi.ts';
16
- import { UnifiWriteRefused } from './policy.ts';
16
+ import {
17
+ type UnifiIdentityChanged,
18
+ type UnifiLiveDriftedSinceDeploy,
19
+ type UnifiUpdateDidNotConverge,
20
+ UnifiWriteRefused,
21
+ } from './policy.ts';
17
22
  import { type UnifiSpec, unifiOperations } from './resource.ts';
18
23
 
19
24
  interface WidgetProps {
@@ -52,11 +57,18 @@ const run = <A, E>(effect: Effect.Effect<A, E, UnifiNetworkOpContext>) =>
52
57
  Effect.runPromise(effect.pipe(Effect.provide(fakeUnifiLayer(unreachableFetch))));
53
58
 
54
59
  const refusal = async (
55
- effect: Effect.Effect<unknown, UnifiWriteRefused, UnifiNetworkOpContext>,
60
+ effect: Effect.Effect<
61
+ unknown,
62
+ | UnifiWriteRefused
63
+ | UnifiLiveDriftedSinceDeploy
64
+ | UnifiUpdateDidNotConverge
65
+ | UnifiIdentityChanged,
66
+ UnifiNetworkOpContext
67
+ >,
56
68
  ) => {
57
69
  const failure = await run(Effect.flip(effect));
58
70
  expect(failure).toBeInstanceOf(UnifiWriteRefused);
59
- return failure;
71
+ return failure as UnifiWriteRefused;
60
72
  };
61
73
 
62
74
  describe('unifiOperations.readHandler', () => {
@@ -1,28 +1,27 @@
1
1
  /**
2
- * One shape for every UniFi Network read-only object — `Unifi.Network`, `Unifi.FirewallZone`,
3
- * and whatever import adds next.
2
+ * One shape for every UniFi Network object — `Unifi.Network`, `Unifi.FirewallZone`, and whatever
3
+ * import adds next. ADOPT-ONLY, except the one `update` a spec may opt into (`Unifi.Network`).
4
4
  *
5
5
  * ★ MIRRORS `../discord/resource.ts` (S7-S10, H1, H5), NOT `../netbox/resource.ts`. Both are
6
6
  * "marker-less API" families (S8): UniFi Network objects carry no tag or metadata field this
7
7
  * stack could stamp as ownership, so a cold read of a match is `Unowned(attrs)` exactly like
8
- * `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.79#read`, the reference implementation S8 and H1
8
+ * `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.81#read`, the reference implementation S8 and H1
9
9
  * both cite. `adopt(true)`, piped on by each resource's convenience constructor (H5), turns
10
10
  * that into a silent one-time bind instead of an `OwnedBySomeoneElse` refusal.
11
11
  *
12
- * ⛔ THE DIFFERENCE FROM EVERY OTHER FAMILY HERE: THERE IS NO `create`, `update` OR `destroy` IN
13
- * THIS SPEC AT ALL. Tim's rule, 2026-09-24 (`policy.ts`): UniFi and OPNsense are read-only. So
14
- * `reconcile` below can only ever refuse or report an exact match — it has no write path to
15
- * fall into by accident, and no resource file wired through this engine can reach one either.
12
+ * ⛔ THE POSTURE (`policy.ts`, Tim 2026-09-24, lifted for ONE case 2026-10-10): there is no
13
+ * `create` and no `destroy` in this spec at all, and `update` exists only when a spec carries an
14
+ * `update` block (`update-reconcile.ts`). `reconcile` is `updateReconcile`: refuse a create,
15
+ * report an exact match with zero writes, and otherwise either refuse or run the three-way PUT.
16
+ * A spec without `update` (every family but Network) can only ever refuse or report a match.
16
17
  *
17
18
  * ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.
18
- * Measured against `Plan.ts@v2.0.0-beta.79` (`forceUpdateAfterAdoption`, H6): beta.79 forces
19
+ * Measured against `Plan.ts@v2.0.0-beta.81` (`forceUpdateAfterAdoption`, H6): beta.81 forces
19
20
  * ONE reconcile call after every cold adoption, whether or not the diff said noop — so a
20
21
  * resource whose `reconcile` always failed would break the ordinary "adopt an unchanged
21
22
  * object" deploy, not just a real write. The fix, exactly like `discordOperations.reconcile`:
22
- * read the live object again, and refuse ONLY when it is missing (would need a create) or
23
- * drifted (would need a write to converge). An exact match returns the live attributes and
24
- * calls nothing — S10's "zero write calls when there is no drift" holds even under the forced
25
- * post-adoption call.
23
+ * read the live object again and return it on a match. S10's "zero write calls when there is
24
+ * no drift" holds even under the forced post-adoption call.
26
25
  *
27
26
  * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE —
28
27
  * same split netbox's `resource.ts` uses and for the same reason: `Effect.catchTag`'s
@@ -30,15 +29,21 @@
30
29
  * spec's `E` is only concrete at each resource file's own call site. Every OTHER failure (a
31
30
  * real 5xx, a network error, `Forbidden`) is NOT folded anywhere — it fails the plan loudly,
32
31
  * per the task's own rule: only a genuine not-found may mean absent.
32
+ *
33
+ * ★ The wire guard (`wire-guard.ts`) and the three-way reconcile (`update-reconcile.ts`) live in
34
+ * their own files; `read`/`diff`/`delete` run under `GetOnlyHttpClient`, only `reconcile` gets
35
+ * the per-row allow entry of a spec that declares `update`.
33
36
  */
34
37
  import { Unowned } from 'alchemy/AdoptPolicy';
35
38
  import { isResolved } from 'alchemy/Diff';
36
39
  import type { Input } from 'alchemy/Input';
37
- import { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
40
+ import { Credentials, CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
38
41
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
39
42
  import * as Effect from 'effect/Effect';
40
- import * as HttpClient from 'effect/http/HttpClient';
41
- import { UNIFI_READ_ONLY_POLICY, refuseWrite } from './policy.ts';
43
+ import type * as HttpClient from 'effect/http/HttpClient';
44
+ import { refuseWrite } from './policy.ts';
45
+ import { type ReconcileArgs, type UnifiUpdate, updateReconcile } from './update-reconcile.ts';
46
+ import { type AllowedWrite, guardedHttpClient } from './wire-guard.ts';
42
47
 
43
48
  /** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
44
49
  export type UnifiRequirements = HttpClient.HttpClient;
@@ -47,9 +52,9 @@ export type UnifiRequirements = HttpClient.HttpClient;
47
52
  * One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a
48
53
  * `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error
49
54
  * union, with `NotFound` already folded away) so the precise per-operation error union flows
50
- * through instead of being widened by hand here.
55
+ * through instead of being widened by hand here. `E2` is the error union of the optional write.
51
56
  */
52
- export type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {
57
+ export type UnifiSpec<Props extends object, Live, Attributes extends object, E, E2 = never> = {
53
58
  /** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
54
59
  readonly type: string;
55
60
  /** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
@@ -58,10 +63,12 @@ export type UnifiSpec<Props extends object, Live, Attributes extends object, E>
58
63
  readonly matches: (attributes: Attributes, props: Props) => boolean;
59
64
  /** For the refusal message and read-back failures. */
60
65
  readonly describe: (props: Props) => string;
66
+ /** Present only on a family allowed to write (`Unifi.Network`); see `update-reconcile.ts`. */
67
+ readonly update?: UnifiUpdate<Props, Live, E2>;
61
68
  };
62
69
 
63
- export const unifiOperations = <Props extends object, Live, Attributes extends object, E>(
64
- spec: UnifiSpec<Props, Live, Attributes, E>,
70
+ export const unifiOperations = <Props extends object, Live, Attributes extends object, E, E2>(
71
+ spec: UnifiSpec<Props, Live, Attributes, E, E2>,
65
72
  ) => {
66
73
  const read = (props: Props) =>
67
74
  spec
@@ -91,16 +98,13 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
91
98
  : ({ action: 'update' } as const);
92
99
  }),
93
100
 
94
- reconcile: (news: Props) =>
95
- Effect.gen(function* () {
96
- const live = yield* read(news);
97
- if (live === undefined) return yield* refuseWrite(spec.type, spec.describe(news), 'create');
98
- if (!spec.matches(live, news)) {
99
- return yield* refuseWrite(spec.type, spec.describe(news), 'update');
100
- }
101
- // Exact match: the object already is what is declared. Zero write calls (S10), correct
102
- // whether this is a routine deploy or beta.79's forced post-adoption reconcile (H6).
103
- return live;
101
+ /** Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset. */
102
+ reconcile: (news: Props, rest: Partial<Omit<ReconcileArgs<Props>, 'news'>> = {}) =>
103
+ updateReconcile(spec, {
104
+ news,
105
+ olds: rest.olds,
106
+ output: rest.output,
107
+ note: rest.note ?? (() => Effect.void),
104
108
  }),
105
109
 
106
110
  destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),
@@ -108,94 +112,49 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
108
112
  };
109
113
 
110
114
  /**
111
- * B0a / T12 DEFENSE IN DEPTH. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at
112
- * `reconcile`/`destroy` — every path this engine exposes already returns that refusal instead of
113
- * calling an SDK write op. This guard stops the same thing one layer lower, AT THE WIRE: it wraps
114
- * whatever `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in
115
- * production, `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same
116
- * "wrap, don't replace" seam) so a future resource file that, by mistake, called an SDK write
117
- * operation directly — bypassing `unifiOperations` entirely — would still never reach the vendor
118
- * API. `write-op-reference.test.ts` proves no such call exists today; this is what stops one from
119
- * ever taking effect if one is ever added.
120
- *
121
- * ⚠️ `Effect.die`, NOT A TYPED FAILURE — classified per distilled-doctrine's "classify every
122
- * die/orDie". Every generated SDK operation (`networks.getNetworkDetails`, …) declares its OWN
123
- * closed error union from the pinned OpenAPI spec, and generated files are never hand-edited to
124
- * add a member to it. A typed `Effect.fail` here would be a failure mode absent from every op's
125
- * declared type the moment it occurred — a type-system lie. A defect is the sound way to add a
126
- * NEW "this must be structurally impossible" failure underneath types this package does not
127
- * own, exactly like `openbao`'s `refuse()` helpers' `Effect.die(new Error(...))` for a
128
- * configuration move that should never be reachable.
129
- */
130
- export class UnifiNonGetRequest extends Error {
131
- constructor(
132
- readonly method: string,
133
- /** PATH ONLY, never the full URL — see `requestPath` below. */
134
- readonly path: string,
135
- ) {
136
- super(
137
- `UniFi HttpClient guard: refused ${method} ${path} -- ${UNIFI_READ_ONLY_POLICY}. This is a ` +
138
- 'defect, not a recoverable condition: something under src/unifi tried to send a non-GET ' +
139
- "request, which policy.ts's per-operation refusal should already have made impossible. " +
140
- 'Fix the kit code that produced this request.',
141
- );
142
- this.name = 'UnifiNonGetRequest';
143
- }
144
- }
145
-
146
- /**
147
- * ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID
148
- * (`docs/unifi.md`'s Credentials section, T3) — a defect message is still a message that can
149
- * reach a log, a CI failure body or a pasted error report, so it gets the same treatment as
150
- * `redactUrls` gives the import layer's own error text. Falls back to a fixed placeholder rather
151
- * than the raw string on a parse failure, so a malformed URL can never leak through unredacted.
152
- */
153
- const requestPath = (url: string): string => {
154
- try {
155
- return new URL(url).pathname;
156
- } catch {
157
- return '<unparseable request url>';
158
- }
159
- };
160
-
161
- const guardGetOnly = (client: HttpClient.HttpClient): HttpClient.HttpClient =>
162
- client.pipe(
163
- HttpClient.mapRequestEffect((request) =>
164
- request.method === 'GET'
165
- ? Effect.succeed(request)
166
- : Effect.die(new UnifiNonGetRequest(request.method, requestPath(request.url))),
167
- ),
168
- );
169
-
170
- /**
171
- * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
172
- * for everything downstream, with `guardGetOnly`'s wrapped version (`HttpClient.layerMergedContext`
173
- * — see the guard above). Exported so `get-only-guard.test.ts` can prove the mechanism directly,
174
- * against a bare `HttpClient` service, without needing `CredentialsFromEnv` or any env var at all.
175
- */
176
- export const GetOnlyHttpClient = HttpClient.layerMergedContext(
177
- Effect.map(HttpClient.HttpClient, guardGetOnly),
178
- );
179
-
180
- /**
181
- * The four provider handlers for a spec'd read-only UniFi object, wired once.
115
+ * The four provider handlers for a spec'd UniFi object, wired once.
182
116
  *
183
117
  * ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
184
118
  * adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
185
119
  */
186
- export const unifiHandlers = <Props extends object, Live, Attributes extends object, E>(
187
- spec: UnifiSpec<Props, Live, Attributes, E>,
120
+ export const unifiHandlers = <Props extends object, Live, Attributes extends object, E, E2>(
121
+ spec: UnifiSpec<Props, Live, Attributes, E, E2>,
188
122
  ) => {
189
123
  const ops = unifiOperations(spec);
190
- const withCredentials = <A, Err>(effect: Effect.Effect<A, Err, UnifiNetworkOpContext>) =>
191
- effect.pipe(Effect.provide(GetOnlyHttpClient), Effect.provide(CredentialsFromEnv));
124
+ const withCredentials = <A, Err>(
125
+ effect: Effect.Effect<A, Err, UnifiNetworkOpContext>,
126
+ allow: ReadonlyArray<AllowedWrite> = [],
127
+ ) =>
128
+ Effect.gen(function* () {
129
+ // ⛔ The guard anchors the allowed PUT to the CONFIGURED base URL (`wire-guard.ts`), so it is
130
+ // read from the same credentials the SDK uses.
131
+ const { apiBaseUrl } = yield* yield* Credentials;
132
+ return yield* effect.pipe(Effect.provide(guardedHttpClient(allow, apiBaseUrl)));
133
+ }).pipe(Effect.provide(CredentialsFromEnv)) as Effect.Effect<A, Err, HttpClient.HttpClient>;
134
+
192
135
  return {
193
136
  list: () => Effect.succeed([]),
194
137
  read: (args: { olds: Props; output: Attributes | undefined }) =>
195
138
  withCredentials(ops.readHandler(args)),
196
139
  diff: (args: { news: Input<Props>; output: Attributes | undefined }) =>
197
140
  withCredentials(ops.diff(args.news, args.output)),
198
- reconcile: (args: { news: Props }) => withCredentials(ops.reconcile(args.news)),
141
+ // ⛔ ONLY `reconcile` gets the per-row allow entry, and only when the spec declares `update`.
142
+ reconcile: (args: {
143
+ news: Props;
144
+ olds: Props | undefined;
145
+ output: Attributes | undefined;
146
+ session: { note: (message: string) => Effect.Effect<void, never, never> };
147
+ }) =>
148
+ withCredentials(
149
+ ops.reconcile(args.news, {
150
+ olds: args.olds,
151
+ output: args.output,
152
+ note: (message) => args.session.note(message),
153
+ }),
154
+ // ⛔ From `news` (the ids the write will use), never `olds`; reconcile also refuses when
155
+ // `olds` names another object (`update-reconcile.ts`).
156
+ spec.update ? [spec.update.allowedWrite(args.news)] : [],
157
+ ),
199
158
  delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),
200
159
  };
201
160
  };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The three-way `reconcile` every updatable `Unifi.*` family shares (today: `Unifi.Network`).
3
+ *
4
+ * ⛔ THREE INPUTS, NOT TWO. `news` is the declaration now, `olds` is the declaration the LAST
5
+ * DEPLOY wrote (Alchemy state), `live` is the raw object read right now. The PUT body is built
6
+ * from `live` plus ONLY the fields where `news` differs from `olds` (`computePatch`) — never
7
+ * from `news` alone. So (HIGH-2) a hand edit on a field the declaration never touched is carried
8
+ * through unchanged instead of reverted, and a hand edit on ANY declared field is refused
9
+ * outright (step 4) rather than overwritten.
10
+ *
11
+ * ★ `unset` IS WHAT MAKES A REVERT REAL (HIGH-1). Deleting an override from a declaration makes
12
+ * `news[k]` absent while `olds[k]` is set; a patch that only carried `set` would see "nothing
13
+ * changed" and the revert would never write. A key present in `olds` and null/absent in `news`
14
+ * lands in `unset`, and the family's `write` removes it from the body.
15
+ *
16
+ * ⛔ FIRST DEPLOY IS ADOPT-ONLY BY CONSTRUCTION (F1, LOW-8). Without BOTH `olds` and `output` there
17
+ * is no last-deployed baseline to patch against, so the write refuses. That also covers beta.81's
18
+ * forced post-adoption reconcile (H6): on a match (step 2) it returns live with zero writes.
19
+ * `olds` must also name the SAME object as `news` (`UnifiIdentityChanged`).
20
+ *
21
+ * ⛔ SCOPE (`update.checkScope`) runs before the note and any request: the family decides what a
22
+ * patch may touch (`network-scope.ts`). An omitted optional block is turned OFF by a whole-object
23
+ * PUT, so removing a key is allowed only for an explicit allowlist.
24
+ *
25
+ * ⚠️ NOTES AND ERRORS CARRY FIELD NAMES ONLY, NEVER VALUES.
26
+ */
27
+ import { deepEqual } from 'alchemy/Diff';
28
+ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
29
+ import * as Effect from 'effect/Effect';
30
+ import type { FieldDrift } from './drift.ts';
31
+ import {
32
+ UnifiIdentityChanged,
33
+ UnifiLiveDriftedSinceDeploy,
34
+ UnifiUpdateDidNotConverge,
35
+ refuseWrite,
36
+ } from './policy.ts';
37
+ import type { UnifiSpec } from './resource.ts';
38
+ import type { AllowedWrite } from './wire-guard.ts';
39
+
40
+ export type Patch<Props> = { set: Partial<Props>; unset: ReadonlyArray<keyof Props> };
41
+
42
+ /** The write half of a spec: everything needed to PUT one object, and nothing to create/delete. */
43
+ export type UnifiUpdate<Props extends object, Live, E2> = {
44
+ /** The one PUT the wire guard may pass for this row (path pinned to the row's own ids). */
45
+ readonly allowedWrite: (props: Props) => AllowedWrite;
46
+ /** Declarable keys; never the identity keys. */
47
+ readonly patchKeys: ReadonlyArray<keyof Props>;
48
+ readonly driftOf: (live: Live, props: Props) => ReadonlyArray<FieldDrift>;
49
+ /** Refuses (typed, before any note or request) a patch outside this family's write scope. */
50
+ readonly checkScope: (live: Live, patch: Patch<Props>, props: Props) => Effect.Effect<void, E2>;
51
+ readonly write: (
52
+ live: Live,
53
+ patch: Patch<Props>,
54
+ props: Props,
55
+ ) => Effect.Effect<Live, E2, UnifiNetworkOpContext>;
56
+ };
57
+
58
+ /** Keys whose `news` value differs from `olds`: set when still declared, unset when dropped. */
59
+ export const computePatch = <Props extends object>(
60
+ news: Props,
61
+ olds: Props,
62
+ patchKeys: ReadonlyArray<keyof Props>,
63
+ ): Patch<Props> => {
64
+ const set: Partial<Props> = {};
65
+ const unset: Array<keyof Props> = [];
66
+ for (const key of patchKeys) {
67
+ if (deepEqual(news[key], olds[key], { stripNullish: true })) continue;
68
+ if (news[key] == null) unset.push(key);
69
+ else set[key] = news[key];
70
+ }
71
+ return { set, unset };
72
+ };
73
+
74
+ export type ReconcileArgs<Props extends object> = {
75
+ readonly news: Props;
76
+ readonly olds: Props | undefined;
77
+ readonly output: unknown;
78
+ readonly note: (message: string) => Effect.Effect<void>;
79
+ };
80
+
81
+ export const updateReconcile = <Props extends object, Live, Attributes extends object, E, E2>(
82
+ spec: UnifiSpec<Props, Live, Attributes, E, E2>,
83
+ { news, olds, output, note }: ReconcileArgs<Props>,
84
+ ) =>
85
+ Effect.gen(function* () {
86
+ const identity = spec.describe(news);
87
+ const live = yield* spec.fetchLive(news);
88
+ if (live === undefined) return yield* refuseWrite(spec.type, identity, 'create');
89
+ const attrs = spec.attributes(live, news);
90
+ // Exact match: zero writes, correct for a routine deploy and the forced post-adoption call.
91
+ if (spec.matches(attrs, news)) return attrs;
92
+ const update = spec.update;
93
+ if (update === undefined || olds === undefined || output === undefined) {
94
+ return yield* refuseWrite(spec.type, identity, 'update');
95
+ }
96
+ // ⛔ `olds` is the baseline for THIS object only; another site or network id is no baseline.
97
+ const previous = spec.describe(olds);
98
+ if (previous !== identity) {
99
+ return yield* new UnifiIdentityChanged({ type: spec.type, identity, previous });
100
+ }
101
+ const drifted = update.driftOf(live, olds).map((d) => d.field);
102
+ if (drifted.length > 0) {
103
+ return yield* new UnifiLiveDriftedSinceDeploy({
104
+ type: spec.type,
105
+ identity,
106
+ fields: drifted,
107
+ });
108
+ }
109
+ const patch = computePatch(news, olds, update.patchKeys);
110
+ if (Object.keys(patch.set).length === 0 && patch.unset.length === 0) {
111
+ return yield* refuseWrite(spec.type, identity, 'update');
112
+ }
113
+ yield* update.checkScope(live, patch, news);
114
+ yield* note(
115
+ `${spec.type} ${identity}: PUT changes [${Object.keys(patch.set).join(', ')}] ` +
116
+ `removes [${patch.unset.map(String).join(', ')}]`,
117
+ );
118
+ const written = yield* update.write(live, patch, news);
119
+ const writtenAttrs = spec.attributes(written, news);
120
+ if (spec.matches(writtenAttrs, news)) return writtenAttrs;
121
+ return yield* new UnifiUpdateDidNotConverge({
122
+ type: spec.type,
123
+ identity,
124
+ fields: update.driftOf(written, news).map((d) => d.field),
125
+ });
126
+ });