@homeflare/alchemy 0.56.0 → 0.58.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/unifi/acl-rule-ordering.d.ts +1 -1
- package/dist/unifi/acl-rule.d.ts +1 -1
- package/dist/unifi/dns-policy.d.ts +1 -1
- package/dist/unifi/firewall-policy-create.d.ts +55 -0
- package/dist/unifi/firewall-policy-create.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +3 -3
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-form.d.ts +27 -17
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +1 -1
- package/dist/unifi/firewall-policy.d.ts +45 -12
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/index.d.ts +1 -1
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +175 -28
- package/dist/unifi/index.js.map +11 -10
- package/dist/unifi/network-update.fixtures.d.ts +53 -0
- package/dist/unifi/network-update.fixtures.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +1 -1
- package/dist/unifi/policy.d.ts +22 -0
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +26 -7
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +1 -1
- package/dist/unifi/update-reconcile.d.ts +36 -2
- package/dist/unifi/update-reconcile.d.ts.map +1 -1
- package/dist/unifi/wifi-broadcast.d.ts +1 -1
- package/dist/unifi/wire-guard.d.ts +1 -1
- package/dist/unifi/wire-guard.d.ts.map +1 -1
- package/docs/distilled-interim.md +4 -2
- package/package.json +1 -1
- package/src/unifi/firewall-policy-create.ts +94 -0
- package/src/unifi/firewall-policy-drift.test.ts +64 -0
- package/src/unifi/firewall-policy-drift.ts +24 -7
- package/src/unifi/firewall-policy-form.ts +70 -17
- package/src/unifi/firewall-policy.test.ts +188 -1
- package/src/unifi/firewall-policy.ts +108 -12
- package/src/unifi/index.ts +1 -0
- package/src/unifi/network-update-accept.test.ts +59 -0
- package/src/unifi/network-update.fixtures.ts +74 -0
- package/src/unifi/network-update.test.ts +11 -69
- package/src/unifi/policy.ts +45 -12
- package/src/unifi/resource.test.ts +103 -0
- package/src/unifi/resource.ts +73 -21
- package/src/unifi/update-reconcile.ts +109 -5
- package/src/unifi/wire-guard.test.ts +86 -1
- package/src/unifi/wire-guard.ts +8 -1
- package/src/unifi/write-op-reference.test.ts +21 -7
|
@@ -8,81 +8,23 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { describe, expect, test } from 'bun:test';
|
|
10
10
|
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
|
|
11
|
+
import { fakeFailure } from './fake-unifi.ts';
|
|
12
|
+
import { NETWORK_PATCH_KEYS, type NetworkProps } from './network.ts';
|
|
13
|
+
import {
|
|
14
|
+
BASE,
|
|
15
|
+
IPV6,
|
|
16
|
+
WITH_V6,
|
|
17
|
+
answering,
|
|
18
|
+
live,
|
|
19
|
+
putBody,
|
|
20
|
+
reconcile,
|
|
21
|
+
} from './network-update.fixtures.ts';
|
|
15
22
|
import { SERVER_FIELDS } from './network-update.ts';
|
|
16
23
|
import {
|
|
17
24
|
UnifiLiveDriftedSinceDeploy,
|
|
18
25
|
UnifiUpdateDidNotConverge,
|
|
19
26
|
UnifiWriteRefused,
|
|
20
27
|
} from './policy.ts';
|
|
21
|
-
import { unifiOperations } from './resource.ts';
|
|
22
|
-
|
|
23
|
-
const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
|
|
24
|
-
const IPV6 = {
|
|
25
|
-
interfaceType: 'STATIC',
|
|
26
|
-
prefixLength: 64,
|
|
27
|
-
hostIpAddress: '2602:f660:0:16::254',
|
|
28
|
-
clientAddressAssignment: { slaacEnabled: true },
|
|
29
|
-
routerAdvertisement: { priority: 'HIGH' },
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const BASE: NetworkProps = {
|
|
33
|
-
siteId: 'site-1',
|
|
34
|
-
networkId: 'net-1',
|
|
35
|
-
name: 'Compute',
|
|
36
|
-
enabled: true,
|
|
37
|
-
management: 'ADVANCED',
|
|
38
|
-
vlanId: 16,
|
|
39
|
-
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
|
|
40
|
-
ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
|
|
41
|
-
};
|
|
42
|
-
const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
|
|
43
|
-
|
|
44
|
-
/** Raw live object, with a key the SDK schema has never heard of. */
|
|
45
|
-
const live = (extra: Record<string, unknown> = {}) => ({
|
|
46
|
-
id: 'net-1',
|
|
47
|
-
default: false,
|
|
48
|
-
metadata: { origin: 'USER' },
|
|
49
|
-
futureTopLevelKey: 'x',
|
|
50
|
-
...BASE,
|
|
51
|
-
siteId: undefined,
|
|
52
|
-
networkId: undefined,
|
|
53
|
-
...extra,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const reconcile = (
|
|
57
|
-
route: (method: string, url: URL) => Response,
|
|
58
|
-
news: NetworkProps,
|
|
59
|
-
olds: NetworkProps | undefined,
|
|
60
|
-
outputArg: object | null = {},
|
|
61
|
-
) => {
|
|
62
|
-
const output = outputArg ?? undefined;
|
|
63
|
-
const fake = fakeUnifi(route);
|
|
64
|
-
const notes: string[] = [];
|
|
65
|
-
const run = unifiOperations(spec)
|
|
66
|
-
.reconcile(news, {
|
|
67
|
-
olds,
|
|
68
|
-
output,
|
|
69
|
-
note: (m) => Effect.sync(() => void notes.push(m)),
|
|
70
|
-
})
|
|
71
|
-
.pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch)));
|
|
72
|
-
return { fake, notes, exit: Effect.runPromiseExit(run) };
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const answering =
|
|
76
|
-
(liveBody: object, put: () => Response = () => Response.json(liveBody)) =>
|
|
77
|
-
(method: string, url: URL) =>
|
|
78
|
-
url.pathname === PATH
|
|
79
|
-
? method === 'GET'
|
|
80
|
-
? Response.json(liveBody)
|
|
81
|
-
: put()
|
|
82
|
-
: fakeFailure(400, 'unexpected');
|
|
83
|
-
|
|
84
|
-
const putBody = (seen: ReadonlyArray<{ method: string; body?: unknown }>) =>
|
|
85
|
-
seen.filter((s) => s.method === 'PUT').map((s) => s.body as Record<string, unknown>);
|
|
86
28
|
|
|
87
29
|
describe('Unifi.Network update', () => {
|
|
88
30
|
test('(1) one GET then one PUT: live carried through byte-equal, server fields dropped, patch added', async () => {
|
package/src/unifi/policy.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one refusal every refused `Unifi.*` write returns. The family makes exactly
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The one refusal every refused `Unifi.*` write returns. The family makes exactly two SDK write
|
|
3
|
+
* calls — `networks.updateNetwork` (`network-update.ts`) and, as of the lift below,
|
|
4
|
+
* `firewall.createFirewallPolicy` (`firewall-policy-create.ts`) — and those are the only writes
|
|
5
|
+
* that do not see this error.
|
|
5
6
|
*
|
|
6
|
-
* ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY. LIFTED FOR
|
|
7
|
-
* 2026-10-10
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY. LIFTED FOR TWO CASES SINCE:
|
|
8
|
+
* 2026-10-10, `Unifi.Network` update, through `update-reconcile.ts` + `network-update.ts`; and
|
|
9
|
+
* 2026-10-19 ~16:20Z ("Allow kit to create hf: rules"), `Unifi.FirewallPolicy` CREATE, and ONLY
|
|
10
|
+
* when the declared `name` starts `hf: ` (`isHfPolicyName` below). Every other write — every
|
|
11
|
+
* other family's create, every update of an `hf:` row (or any other), every delete — still fails
|
|
12
|
+
* with this typed error, and `resource.test.ts`/`wire-guard.test.ts`/`firewall-policy.test.ts`
|
|
13
|
+
* prove it with a fake `HttpClient` that records every request. Widening the policy again is a
|
|
14
|
+
* kit change (a new PR, reviewed as one), not a flag a stack can pass.
|
|
12
15
|
*
|
|
13
16
|
* ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a
|
|
14
17
|
* caller (a future import script, a test) branch on the refusal without parsing a message.
|
|
@@ -17,10 +20,22 @@ import * as Data from 'effect/Data';
|
|
|
17
20
|
import * as Effect from 'effect/Effect';
|
|
18
21
|
|
|
19
22
|
export const UNIFI_WRITE_POLICY =
|
|
20
|
-
'UniFi is adopt-only except Unifi.Network update: one whole-object PUT built from the live ' +
|
|
23
|
+
'UniFi is adopt-only except (1) Unifi.Network update: one whole-object PUT built from the live ' +
|
|
21
24
|
'object plus the declared field changes, only on a row with prior Alchemy state, only through ' +
|
|
22
|
-
'deploy-approve;
|
|
23
|
-
'
|
|
25
|
+
'deploy-approve; and (2) Unifi.FirewallPolicy create, only when the declared name starts "hf: ", ' +
|
|
26
|
+
'one POST built from only the declared fields, read back and checked to match after; every ' +
|
|
27
|
+
'other create, every update of an hf: row, every delete and every other family stay refused -- ' +
|
|
28
|
+
'widening is a kit change (Tim 2026-10-10 lifts the 2026-09-24 rule for Network update, Tim ' +
|
|
29
|
+
'2026-10-19 lifts it again for hf:-named FirewallPolicy create)';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ⛔ THE ONE GATE THAT DECIDES "hf:" (Tim 2026-10-19, `nas-firewall`'s own naming:
|
|
33
|
+
* `hf: CT100 to NAS https`). The space after the colon is deliberate and checked: `"hf:foo"`
|
|
34
|
+
* (no space) is NOT an hf: name under this policy, so a typo can never silently slip past the
|
|
35
|
+
* create gate by omitting it. Exported so `firewall-policy-create.ts`'s `checkScope` and every
|
|
36
|
+
* test that proves the boundary use this ONE function, never a second regex hand-copied nearby.
|
|
37
|
+
*/
|
|
38
|
+
export const isHfPolicyName = (name: string): boolean => name.startsWith('hf: ');
|
|
24
39
|
|
|
25
40
|
/** @deprecated renamed `UNIFI_WRITE_POLICY`; kept so existing consumer imports do not break. */
|
|
26
41
|
export const UNIFI_READ_ONLY_POLICY = UNIFI_WRITE_POLICY;
|
|
@@ -89,6 +104,24 @@ export class UnifiUpdateDidNotConverge extends Data.TaggedError('UnifiUpdateDidN
|
|
|
89
104
|
}
|
|
90
105
|
}
|
|
91
106
|
|
|
107
|
+
/** The POST answered, a read-back ran, yet the read-back object still differs from the
|
|
108
|
+
* declaration — the hf: create path's own convergence check (`create-reconcile.ts`'s sibling in
|
|
109
|
+
* `update-reconcile.ts`), same shape as `UnifiUpdateDidNotConverge` for the same reason: the
|
|
110
|
+
* object now exists on the console, unrolled-back, so the operator needs to know and re-import. */
|
|
111
|
+
export class UnifiCreateDidNotConverge extends Data.TaggedError('UnifiCreateDidNotConverge')<{
|
|
112
|
+
readonly type: string;
|
|
113
|
+
readonly identity: string;
|
|
114
|
+
readonly fields: ReadonlyArray<string>;
|
|
115
|
+
}> {
|
|
116
|
+
override get message(): string {
|
|
117
|
+
return (
|
|
118
|
+
`${this.type} ${this.identity}: the POST created an object, but the read-back differs from ` +
|
|
119
|
+
`the declaration on ${fieldList(this.fields)}. The object is on the console and is not ` +
|
|
120
|
+
'rolled back; the next deploy will see this as drift. Recovery is a re-import of the live object.'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
92
125
|
// ⛔ THE FIVE ERRORS BELOW BOUND THE WRITE SCOPE (`network-scope.ts`, `update-reconcile.ts`): the
|
|
93
126
|
// one intended consumer is a single VLAN's `ipv6Configuration`. Field names only, as above.
|
|
94
127
|
|
|
@@ -14,10 +14,12 @@ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Proto
|
|
|
14
14
|
import * as Effect from 'effect/Effect';
|
|
15
15
|
import { fakeUnifiLayer } from './fake-unifi.ts';
|
|
16
16
|
import {
|
|
17
|
+
type UnifiCreateDidNotConverge,
|
|
17
18
|
type UnifiIdentityChanged,
|
|
18
19
|
type UnifiLiveDriftedSinceDeploy,
|
|
19
20
|
type UnifiUpdateDidNotConverge,
|
|
20
21
|
UnifiWriteRefused,
|
|
22
|
+
refuseWrite,
|
|
21
23
|
} from './policy.ts';
|
|
22
24
|
import { type UnifiSpec, unifiOperations } from './resource.ts';
|
|
23
25
|
|
|
@@ -62,6 +64,7 @@ const refusal = async (
|
|
|
62
64
|
| UnifiWriteRefused
|
|
63
65
|
| UnifiLiveDriftedSinceDeploy
|
|
64
66
|
| UnifiUpdateDidNotConverge
|
|
67
|
+
| UnifiCreateDidNotConverge
|
|
65
68
|
| UnifiIdentityChanged,
|
|
66
69
|
UnifiNetworkOpContext
|
|
67
70
|
>,
|
|
@@ -143,3 +146,103 @@ describe('unifiOperations.destroy', () => {
|
|
|
143
146
|
expect(failure.action).toBe('delete');
|
|
144
147
|
});
|
|
145
148
|
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The generic `create` branch (`resource.ts`'s `reconcile`, `update-reconcile.ts`'s
|
|
152
|
+
* `createReconcile`), against a synthetic spec -- mirrors `specFor` above, but models a family
|
|
153
|
+
* that DOES declare `create`, the same way `firewall-policy.test.ts` proves the real one. Every
|
|
154
|
+
* assertion here is about the ENGINE's own gating (no prior state, live absent, `checkScope`
|
|
155
|
+
* passes before `write` ever runs); `firewall-policy.test.ts` separately proves the real hf: name
|
|
156
|
+
* check and the real POST body.
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* `store` models "nothing live yet" (`undefined`) until `write` is called, at which point
|
|
160
|
+
* `fetchLive` (called again by `createReconcile`'s own read-back, never trusting `write`'s return
|
|
161
|
+
* value alone) starts answering the written row -- the same before/after a real `createFirewallPolicy`
|
|
162
|
+
* POST gives `getFirewallPolicy`.
|
|
163
|
+
*/
|
|
164
|
+
const specWithCreate = (
|
|
165
|
+
initiallyLive: WidgetLive | undefined,
|
|
166
|
+
opts: { allow: boolean; writeCalls: WidgetProps[]; writtenMismatch?: boolean },
|
|
167
|
+
): UnifiSpec<WidgetProps, WidgetLive, WidgetAttrs, never, never, UnifiWriteRefused> => {
|
|
168
|
+
let store = initiallyLive;
|
|
169
|
+
return {
|
|
170
|
+
type: 'Test.Widget',
|
|
171
|
+
describe: (props) => `widgets/${props.id}`,
|
|
172
|
+
fetchLive: () => Effect.succeed(store),
|
|
173
|
+
attributes: (l) => l,
|
|
174
|
+
matches: (attrs, props) => attrs.value === props.value,
|
|
175
|
+
create: {
|
|
176
|
+
allowedWrite: () => ({ method: 'POST', tail: '/widgets' }),
|
|
177
|
+
checkScope: (props) =>
|
|
178
|
+
opts.allow ? Effect.void : refuseWrite('Test.Widget', `widgets/${props.id}`, 'create'),
|
|
179
|
+
write: (props) => {
|
|
180
|
+
opts.writeCalls.push(props);
|
|
181
|
+
store = { ...props, value: opts.writtenMismatch ? props.value + 1 : props.value };
|
|
182
|
+
return Effect.succeed(store);
|
|
183
|
+
},
|
|
184
|
+
identify: (props) => props,
|
|
185
|
+
driftOf: (liveNow, props) =>
|
|
186
|
+
liveNow.value === props.value
|
|
187
|
+
? []
|
|
188
|
+
: [{ field: 'value', live: liveNow.value, declared: props.value }],
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
describe('unifiOperations.reconcile -- the create branch', () => {
|
|
194
|
+
test('no prior state, live absent, scope allows: one write call, reads back and matches', async () => {
|
|
195
|
+
const writeCalls: WidgetProps[] = [];
|
|
196
|
+
const spec = specWithCreate(undefined, { allow: true, writeCalls });
|
|
197
|
+
const attrs = await run(unifiOperations(spec).reconcile(MATCH));
|
|
198
|
+
expect(attrs).toEqual(MATCH);
|
|
199
|
+
expect(writeCalls).toEqual([MATCH]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('no prior state, live absent, scope refuses: zero write calls, typed refusal', async () => {
|
|
203
|
+
const writeCalls: WidgetProps[] = [];
|
|
204
|
+
const spec = specWithCreate(undefined, { allow: false, writeCalls });
|
|
205
|
+
const failure = await refusal(unifiOperations(spec).reconcile(MATCH));
|
|
206
|
+
expect(failure.action).toBe('create');
|
|
207
|
+
expect(writeCalls).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a read-back mismatch after a successful create fails typed, not silently', async () => {
|
|
211
|
+
const writeCalls: WidgetProps[] = [];
|
|
212
|
+
const spec = specWithCreate(undefined, { allow: true, writeCalls, writtenMismatch: true });
|
|
213
|
+
const failure = await run(
|
|
214
|
+
Effect.flip(
|
|
215
|
+
unifiOperations(spec).reconcile(MATCH) as Effect.Effect<
|
|
216
|
+
unknown,
|
|
217
|
+
UnifiCreateDidNotConverge,
|
|
218
|
+
UnifiNetworkOpContext
|
|
219
|
+
>,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
expect(failure._tag).toBe('UnifiCreateDidNotConverge');
|
|
223
|
+
expect(writeCalls).toEqual([MATCH]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('live already exists (first deploy, no state, but a row matches): no create attempted', async () => {
|
|
227
|
+
const writeCalls: WidgetProps[] = [];
|
|
228
|
+
const spec = specWithCreate(MATCH, { allow: true, writeCalls });
|
|
229
|
+
const attrs = await run(unifiOperations(spec).reconcile(MATCH));
|
|
230
|
+
expect(attrs).toEqual(MATCH);
|
|
231
|
+
expect(writeCalls).toEqual([]); // exact match -- zero writes, same H6 guarantee as every other family
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('prior state present (olds/output set): create is never attempted even if live is absent', async () => {
|
|
235
|
+
const writeCalls: WidgetProps[] = [];
|
|
236
|
+
const spec = specWithCreate(undefined, { allow: true, writeCalls });
|
|
237
|
+
const failure = await refusal(
|
|
238
|
+
unifiOperations(spec).reconcile(MATCH, { olds: MATCH, output: MATCH }),
|
|
239
|
+
);
|
|
240
|
+
expect(failure.action).toBe('create'); // falls through to updateReconcile's own "create" refusal
|
|
241
|
+
expect(writeCalls).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('a family with no create block behaves exactly as before (back-compat)', async () => {
|
|
245
|
+
const failure = await refusal(unifiOperations(specFor(undefined)).reconcile(MATCH));
|
|
246
|
+
expect(failure.action).toBe('create');
|
|
247
|
+
});
|
|
248
|
+
});
|
package/src/unifi/resource.ts
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
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 POSTURE (`policy.ts`, Tim 2026-09-24, lifted for
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* ⛔ THE POSTURE (`policy.ts`, Tim 2026-09-24, lifted for TWO cases since): there is no `destroy`
|
|
13
|
+
* in this spec at all, ever. `update` exists only when a spec carries an `update` block
|
|
14
|
+
* (`update-reconcile.ts`'s `updateReconcile`, `Unifi.Network` only). `create` exists only when a
|
|
15
|
+
* spec carries a `create` block (`update-reconcile.ts`'s `createReconcile`, `Unifi.FirewallPolicy`
|
|
16
|
+
* only, and only for a row whose declared name passes `create.checkScope`). `reconcile`: an exact
|
|
17
|
+
* match reports with zero writes; otherwise a family with `create` and no prior state (`olds`/
|
|
18
|
+
* `output` both absent) AND an absent live read attempts the create branch; everything else
|
|
19
|
+
* either refuses or runs the three-way PUT. A spec with neither block can only ever refuse or
|
|
20
|
+
* report a match.
|
|
17
21
|
*
|
|
18
22
|
* ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.
|
|
19
23
|
* Measured against `Plan.ts@v2.0.0-beta.81` (`forceUpdateAfterAdoption`, H6): beta.81 forces
|
|
@@ -42,7 +46,13 @@ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Proto
|
|
|
42
46
|
import * as Effect from 'effect/Effect';
|
|
43
47
|
import type * as HttpClient from 'effect/http/HttpClient';
|
|
44
48
|
import { refuseWrite } from './policy.ts';
|
|
45
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
type ReconcileArgs,
|
|
51
|
+
type UnifiCreate,
|
|
52
|
+
type UnifiUpdate,
|
|
53
|
+
createReconcile,
|
|
54
|
+
updateReconcile,
|
|
55
|
+
} from './update-reconcile.ts';
|
|
46
56
|
import { type AllowedWrite, guardedHttpClient } from './wire-guard.ts';
|
|
47
57
|
|
|
48
58
|
/** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
|
|
@@ -54,7 +64,14 @@ export type UnifiRequirements = HttpClient.HttpClient;
|
|
|
54
64
|
* union, with `NotFound` already folded away) so the precise per-operation error union flows
|
|
55
65
|
* through instead of being widened by hand here. `E2` is the error union of the optional write.
|
|
56
66
|
*/
|
|
57
|
-
export type UnifiSpec<
|
|
67
|
+
export type UnifiSpec<
|
|
68
|
+
Props extends object,
|
|
69
|
+
Live,
|
|
70
|
+
Attributes extends object,
|
|
71
|
+
E,
|
|
72
|
+
E2 = never,
|
|
73
|
+
E3 = never,
|
|
74
|
+
> = {
|
|
58
75
|
/** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
|
|
59
76
|
readonly type: string;
|
|
60
77
|
/** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
|
|
@@ -65,10 +82,13 @@ export type UnifiSpec<Props extends object, Live, Attributes extends object, E,
|
|
|
65
82
|
readonly describe: (props: Props) => string;
|
|
66
83
|
/** Present only on a family allowed to write (`Unifi.Network`); see `update-reconcile.ts`. */
|
|
67
84
|
readonly update?: UnifiUpdate<Props, Live, E2>;
|
|
85
|
+
/** Present only on a family allowed to create (`Unifi.FirewallPolicy`, `hf:` rows only); see
|
|
86
|
+
* `update-reconcile.ts`'s `createReconcile`. */
|
|
87
|
+
readonly create?: UnifiCreate<Props, Live, E3>;
|
|
68
88
|
};
|
|
69
89
|
|
|
70
|
-
export const unifiOperations = <Props extends object, Live, Attributes extends object, E, E2>(
|
|
71
|
-
spec: UnifiSpec<Props, Live, Attributes, E, E2>,
|
|
90
|
+
export const unifiOperations = <Props extends object, Live, Attributes extends object, E, E2, E3>(
|
|
91
|
+
spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
|
|
72
92
|
) => {
|
|
73
93
|
const read = (props: Props) =>
|
|
74
94
|
spec
|
|
@@ -98,14 +118,36 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
|
|
|
98
118
|
: ({ action: 'update' } as const);
|
|
99
119
|
}),
|
|
100
120
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset — the
|
|
123
|
+
* SAME calling convention a plain cold adoption already uses (H6), so the existence GET must
|
|
124
|
+
* run FIRST, for every name, exactly as it always has: a row that already exists is adopted
|
|
125
|
+
* (exact match, zero writes) regardless of its name, and ONLY an absent row ever reaches
|
|
126
|
+
* `create.checkScope`. "A non-hf: create sends nothing" therefore means no WRITE (no
|
|
127
|
+
* POST/PUT/DELETE) — the same guarantee every other refusal in this engine gives, and the one
|
|
128
|
+
* `wire-guard.ts`'s allow list enforces at the wire; the existence GET is not a write.
|
|
129
|
+
*
|
|
130
|
+
* ⛔ THE CREATE BRANCH ONLY RUNS WHEN THERE IS NO PRIOR STATE AT ALL (`olds`/`output` both
|
|
131
|
+
* absent — a genuine first deploy, never a re-declare of an adopted row) AND the family
|
|
132
|
+
* declares `create` AND the live read comes back absent. A row that already exists (someone
|
|
133
|
+
* else's object with a colliding identity, or a retried deploy that already created it)
|
|
134
|
+
* takes the ordinary `updateReconcile` path instead — exact match reports with zero writes,
|
|
135
|
+
* anything else refuses (there is no `olds` to PUT a patch against, so it can never silently
|
|
136
|
+
* fall through to an update of an existing row).
|
|
137
|
+
*/
|
|
138
|
+
reconcile: (news: Props, rest: Partial<Omit<ReconcileArgs<Props>, 'news'>> = {}) => {
|
|
139
|
+
const note = rest.note ?? (() => Effect.void);
|
|
140
|
+
const create = spec.create;
|
|
141
|
+
if (create !== undefined && rest.olds === undefined && rest.output === undefined) {
|
|
142
|
+
return Effect.gen(function* () {
|
|
143
|
+
const live = yield* spec.fetchLive(news);
|
|
144
|
+
return live === undefined
|
|
145
|
+
? yield* createReconcile(spec, news, note)
|
|
146
|
+
: yield* updateReconcile(spec, { news, olds: undefined, output: undefined, note });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return updateReconcile(spec, { news, olds: rest.olds, output: rest.output, note });
|
|
150
|
+
},
|
|
109
151
|
|
|
110
152
|
destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),
|
|
111
153
|
};
|
|
@@ -117,8 +159,8 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
|
|
|
117
159
|
* ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
|
|
118
160
|
* adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
|
|
119
161
|
*/
|
|
120
|
-
export const unifiHandlers = <Props extends object, Live, Attributes extends object, E, E2>(
|
|
121
|
-
spec: UnifiSpec<Props, Live, Attributes, E, E2>,
|
|
162
|
+
export const unifiHandlers = <Props extends object, Live, Attributes extends object, E, E2, E3>(
|
|
163
|
+
spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
|
|
122
164
|
) => {
|
|
123
165
|
const ops = unifiOperations(spec);
|
|
124
166
|
const withCredentials = <A, Err>(
|
|
@@ -152,8 +194,18 @@ export const unifiHandlers = <Props extends object, Live, Attributes extends obj
|
|
|
152
194
|
note: (message) => args.session.note(message),
|
|
153
195
|
}),
|
|
154
196
|
// ⛔ From `news` (the ids the write will use), never `olds`; reconcile also refuses when
|
|
155
|
-
// `olds` names another object (`update-reconcile.ts`).
|
|
156
|
-
|
|
197
|
+
// `olds` names another object (`update-reconcile.ts`). The create entry (a POST to the
|
|
198
|
+
// site's fixed collection route, never a per-row path) is listed ONLY when there is no
|
|
199
|
+
// prior state at all — the exact same condition `ops.reconcile` itself gates the create
|
|
200
|
+
// branch on, so the wire guard never allows a POST wider than what the engine could ever
|
|
201
|
+
// attempt. The hf: NAME check is NOT this list's job (`create.checkScope`'s, before the
|
|
202
|
+
// POST): this only bounds METHOD and PATH.
|
|
203
|
+
[
|
|
204
|
+
...(spec.update ? [spec.update.allowedWrite(args.news)] : []),
|
|
205
|
+
...(spec.create && args.olds === undefined && args.output === undefined
|
|
206
|
+
? [spec.create.allowedWrite(args.news)]
|
|
207
|
+
: []),
|
|
208
|
+
],
|
|
157
209
|
),
|
|
158
210
|
delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),
|
|
159
211
|
};
|
|
@@ -29,6 +29,7 @@ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Proto
|
|
|
29
29
|
import * as Effect from 'effect/Effect';
|
|
30
30
|
import type { FieldDrift } from './drift.ts';
|
|
31
31
|
import {
|
|
32
|
+
UnifiCreateDidNotConverge,
|
|
32
33
|
UnifiIdentityChanged,
|
|
33
34
|
UnifiLiveDriftedSinceDeploy,
|
|
34
35
|
UnifiUpdateDidNotConverge,
|
|
@@ -71,6 +72,21 @@ export const computePatch = <Props extends object>(
|
|
|
71
72
|
return { set, unset };
|
|
72
73
|
};
|
|
73
74
|
|
|
75
|
+
/** `olds` with each accepted field taken from `news` (which equals live there), so the patch
|
|
76
|
+
* skips it. A `FieldDrift.field` names a props key; one that is not is left alone. */
|
|
77
|
+
const acceptInto = <Props extends object>(
|
|
78
|
+
olds: Props,
|
|
79
|
+
news: Props,
|
|
80
|
+
accepted: ReadonlyArray<unknown>,
|
|
81
|
+
): Props => {
|
|
82
|
+
const next: Record<string, unknown> = { ...(olds as Record<string, unknown>) };
|
|
83
|
+
for (const field of accepted) {
|
|
84
|
+
const key = String(field);
|
|
85
|
+
if (key in (news as object) || key in next) next[key] = (news as Record<string, unknown>)[key];
|
|
86
|
+
}
|
|
87
|
+
return next as Props;
|
|
88
|
+
};
|
|
89
|
+
|
|
74
90
|
export type ReconcileArgs<Props extends object> = {
|
|
75
91
|
readonly news: Props;
|
|
76
92
|
readonly olds: Props | undefined;
|
|
@@ -78,8 +94,8 @@ export type ReconcileArgs<Props extends object> = {
|
|
|
78
94
|
readonly note: (message: string) => Effect.Effect<void>;
|
|
79
95
|
};
|
|
80
96
|
|
|
81
|
-
export const updateReconcile = <Props extends object, Live, Attributes extends object, E, E2>(
|
|
82
|
-
spec: UnifiSpec<Props, Live, Attributes, E, E2>,
|
|
97
|
+
export const updateReconcile = <Props extends object, Live, Attributes extends object, E, E2, E3>(
|
|
98
|
+
spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
|
|
83
99
|
{ news, olds, output, note }: ReconcileArgs<Props>,
|
|
84
100
|
) =>
|
|
85
101
|
Effect.gen(function* () {
|
|
@@ -98,15 +114,34 @@ export const updateReconcile = <Props extends object, Live, Attributes extends o
|
|
|
98
114
|
if (previous !== identity) {
|
|
99
115
|
return yield* new UnifiIdentityChanged({ type: spec.type, identity, previous });
|
|
100
116
|
}
|
|
117
|
+
// ★ A FIELD THAT DRIFTED SINCE THE LAST DEPLOY IS A CONFLICT ONLY IF THE DECLARATION STILL
|
|
118
|
+
// DISAGREES WITH LIVE ON IT. When the declaration already equals live there (a re-import
|
|
119
|
+
// recorded the console edit, the documented recovery for UnifiLiveDriftedSinceDeploy),
|
|
120
|
+
// the edit is ACCEPTED: folded into the baseline, so it is neither refused nor patched. The
|
|
121
|
+
// PUT is live plus the patch, so live's value survives untouched. Without this, `olds`
|
|
122
|
+
// (the last deployed state) only moves on a successful deploy, so a re-import could never
|
|
123
|
+
// clear the refusal (homeflare-network VLAN 16, 2026-10-19: mdnsForwardingEnabled edited on
|
|
124
|
+
// the console, re-imported, and still refused on every deploy).
|
|
125
|
+
// ⛔ A drifted field the declaration DISAGREES with stays a refusal: the PUT would overwrite
|
|
126
|
+
// a hand edit nobody re-imported.
|
|
127
|
+
const disagrees = new Set(update.driftOf(live, news).map((d) => String(d.field)));
|
|
101
128
|
const drifted = update.driftOf(live, olds).map((d) => d.field);
|
|
102
|
-
|
|
129
|
+
const conflicts = drifted.filter((field) => disagrees.has(String(field)));
|
|
130
|
+
if (conflicts.length > 0) {
|
|
103
131
|
return yield* new UnifiLiveDriftedSinceDeploy({
|
|
104
132
|
type: spec.type,
|
|
105
133
|
identity,
|
|
106
|
-
fields:
|
|
134
|
+
fields: conflicts,
|
|
107
135
|
});
|
|
108
136
|
}
|
|
109
|
-
const
|
|
137
|
+
const accepted = drifted.filter((field) => !disagrees.has(String(field)));
|
|
138
|
+
const baseline = acceptInto(olds, news, accepted);
|
|
139
|
+
if (accepted.length > 0) {
|
|
140
|
+
yield* note(
|
|
141
|
+
`${spec.type} ${identity}: live edits since the last deploy already match the declaration, accepted: [${accepted.map(String).join(', ')}]`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const patch = computePatch(news, baseline, update.patchKeys);
|
|
110
145
|
if (Object.keys(patch.set).length === 0 && patch.unset.length === 0) {
|
|
111
146
|
return yield* refuseWrite(spec.type, identity, 'update');
|
|
112
147
|
}
|
|
@@ -124,3 +159,72 @@ export const updateReconcile = <Props extends object, Live, Attributes extends o
|
|
|
124
159
|
fields: update.driftOf(written, news).map((d) => d.field),
|
|
125
160
|
});
|
|
126
161
|
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The write half of a CREATE-capable family: today, only `Unifi.FirewallPolicy`, and only for an
|
|
165
|
+
* `hf:`-named row (`policy.ts`'s `isHfPolicyName`). Deliberately NOT a merge-with-live update —
|
|
166
|
+
* there is no live object yet — so there is no `patchKeys`/`checkScope(live, patch, …)` here: a
|
|
167
|
+
* create sends exactly the fields the declaration owns, nothing echoed from elsewhere, nothing to
|
|
168
|
+
* accidentally reset.
|
|
169
|
+
*
|
|
170
|
+
* ⛔ `checkScope` RUNS BEFORE THE POST (same ordering `update.checkScope` already has): a
|
|
171
|
+
* non-`hf:` name is refused here, typed, never reaching the wire at all.
|
|
172
|
+
* ★ `identify` CLOSES THE LOOP FROM THE POST'S RESPONSE TO A FRESH GET. The vendor assigns the id
|
|
173
|
+
* on create; `write`'s response carries it, but a response body is not proof the object is
|
|
174
|
+
* actually readable back (`createReconcile` re-fetches with the id `identify` folds in, rather
|
|
175
|
+
* than trusting the POST's own echo — same reasoning `network-update.ts`'s header gives for
|
|
176
|
+
* never trusting a write's own response over a real read).
|
|
177
|
+
*/
|
|
178
|
+
export type UnifiCreate<Props extends object, Live, E3> = {
|
|
179
|
+
/** The one POST the wire guard may pass for this family (the site's fixed collection route,
|
|
180
|
+
* built from `props` the same way `UnifiUpdate.allowedWrite` reads a row's ids from `props`). */
|
|
181
|
+
readonly allowedWrite: (props: Props) => AllowedWrite;
|
|
182
|
+
/** Refuses (typed, before any note or request) a create outside this family's write scope. */
|
|
183
|
+
readonly checkScope: (props: Props) => Effect.Effect<void, E3>;
|
|
184
|
+
readonly write: (props: Props) => Effect.Effect<Live, E3, UnifiNetworkOpContext>;
|
|
185
|
+
/** `props` plus the vendor id the POST's response assigned, for the read-back `fetchLive`. */
|
|
186
|
+
readonly identify: (props: Props, written: Live) => Props;
|
|
187
|
+
readonly driftOf: (live: Live, props: Props) => ReadonlyArray<FieldDrift>;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* CREATE for a family whose live read came back absent AND which has no prior Alchemy state
|
|
192
|
+
* (`resource.ts`'s `unifiOperations.reconcile` is the only caller, and only under that same
|
|
193
|
+
* condition — see its own header). Refuses (untyped-to-typed, same `refuseWrite` every other
|
|
194
|
+
* refusal uses) when the family declares no `create` at all; `create.checkScope` then refuses
|
|
195
|
+
* again, more specifically, for a declared row the create scope itself does not allow.
|
|
196
|
+
*/
|
|
197
|
+
export const createReconcile = <Props extends object, Live, Attributes extends object, E, E2, E3>(
|
|
198
|
+
spec: UnifiSpec<Props, Live, Attributes, E, E2, E3>,
|
|
199
|
+
news: Props,
|
|
200
|
+
note: (message: string) => Effect.Effect<void, never, never>,
|
|
201
|
+
) =>
|
|
202
|
+
Effect.gen(function* () {
|
|
203
|
+
const identity = spec.describe(news);
|
|
204
|
+
const create = spec.create;
|
|
205
|
+
if (create === undefined) return yield* refuseWrite(spec.type, identity, 'create');
|
|
206
|
+
yield* create.checkScope(news);
|
|
207
|
+
yield* note(`${spec.type} ${identity}: POST create`);
|
|
208
|
+
const written = yield* create.write(news);
|
|
209
|
+
const withId = create.identify(news, written);
|
|
210
|
+
// ⛔ A FRESH GET, NOT THE POST'S OWN RESPONSE (see the header): `fetchLive` is the same read
|
|
211
|
+
// every adoption and every later plan uses, so the convergence check and every future read
|
|
212
|
+
// of this row go through one path.
|
|
213
|
+
const confirmed = yield* spec.fetchLive(withId);
|
|
214
|
+
if (confirmed === undefined) {
|
|
215
|
+
return yield* Effect.die(
|
|
216
|
+
new Error(
|
|
217
|
+
`${spec.type} ${identity}: the create POST answered, but the read-back found nothing ` +
|
|
218
|
+
'at the id it assigned. The object may or may not exist on the console; re-import ' +
|
|
219
|
+
'before trying again rather than retrying this create blindly.',
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const attrs = spec.attributes(confirmed, withId);
|
|
224
|
+
if (spec.matches(attrs, withId)) return attrs;
|
|
225
|
+
return yield* new UnifiCreateDidNotConverge({
|
|
226
|
+
type: spec.type,
|
|
227
|
+
identity,
|
|
228
|
+
fields: create.driftOf(confirmed, withId).map((d) => d.field),
|
|
229
|
+
});
|
|
230
|
+
});
|