@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.
- package/dist/linux/container-env-quote.d.ts +39 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +14 -13
- package/dist/linux/container-form.d.ts.map +1 -1
- package/dist/linux/container-paths.d.ts +17 -0
- package/dist/linux/container-paths.d.ts.map +1 -0
- package/dist/linux/index.js +41 -8
- package/dist/linux/index.js.map +6 -4
- package/dist/unifi/acl-rule-ordering.d.ts +6 -1
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
- package/dist/unifi/acl-rule.d.ts +6 -1
- package/dist/unifi/acl-rule.d.ts.map +1 -1
- package/dist/unifi/dns-policy.d.ts +6 -1
- package/dist/unifi/dns-policy.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +4 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
- package/dist/unifi/firewall-policy.d.ts +6 -1
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +6 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +5 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +339 -86
- package/dist/unifi/index.js.map +10 -6
- package/dist/unifi/network-scope.d.ts +31 -0
- package/dist/unifi/network-scope.d.ts.map +1 -0
- package/dist/unifi/network-update.d.ts +44 -0
- package/dist/unifi/network-update.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +34 -13
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/policy.d.ts +89 -1
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +17 -43
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +6 -1
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
- package/dist/unifi/update-reconcile.d.ts +31 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +6 -1
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
- package/dist/unifi/wire-guard.d.ts +33 -0
- package/dist/unifi/wire-guard.d.ts.map +1 -0
- package/docs/unifi-network-write.md +90 -0
- package/docs/unifi.md +40 -39
- package/package.json +1 -1
- package/src/linux/container-env-chain.test.ts +135 -0
- package/src/linux/container-env-live.test.ts +115 -0
- package/src/linux/container-env-oracle.ts +109 -0
- package/src/linux/container-env-quadlet-oracle.ts +174 -0
- package/src/linux/container-env-quote.test.ts +131 -0
- package/src/linux/container-env-quote.ts +126 -0
- package/src/linux/container-fixture.test.ts +3 -1
- package/src/linux/container-form.ts +32 -28
- package/src/linux/container-paths.ts +21 -0
- package/src/unifi/fake-unifi.ts +15 -2
- package/src/unifi/handler-allow-news.test.ts +80 -0
- package/src/unifi/index.ts +21 -5
- package/src/unifi/network-scope.test.ts +224 -0
- package/src/unifi/network-scope.ts +75 -0
- package/src/unifi/network-update.test.ts +250 -0
- package/src/unifi/network-update.ts +95 -0
- package/src/unifi/network.test.ts +27 -1
- package/src/unifi/network.ts +58 -13
- package/src/unifi/policy.ts +128 -10
- package/src/unifi/resource.test.ts +15 -3
- package/src/unifi/resource.ts +64 -105
- package/src/unifi/update-reconcile.ts +126 -0
- package/src/unifi/wire-guard-anchor.test.ts +198 -0
- package/src/unifi/wire-guard-round2.test.ts +120 -0
- package/src/unifi/wire-guard.test.ts +218 -0
- package/src/unifi/wire-guard.ts +192 -0
- package/src/unifi/write-op-reference.test.ts +41 -4
- package/src/unifi/get-only-guard.test.ts +0 -165
package/src/unifi/policy.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one refusal every `Unifi.*` write
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* policy is a kit change (a new PR
|
|
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
|
|
18
|
-
|
|
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} -- ${
|
|
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 {
|
|
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<
|
|
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', () => {
|
package/src/unifi/resource.ts
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One shape for every UniFi Network
|
|
3
|
-
*
|
|
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.
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* `reconcile`
|
|
15
|
-
*
|
|
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.
|
|
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
|
|
23
|
-
*
|
|
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 {
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
*
|
|
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>(
|
|
191
|
-
effect.
|
|
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
|
|
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
|
+
});
|