@homeflare/alchemy 0.50.0 → 0.51.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 +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 +32 -12
- 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 +36 -36
- package/package.json +1 -1
- 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 +203 -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 +56 -12
- 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 +38 -2
- package/src/unifi/get-only-guard.test.ts +0 -165
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s one write: `updateNetwork`. THE ONLY FILE UNDER `src/unifi` ALLOWED TO NAME IT
|
|
3
|
+
* (`write-op-reference.test.ts`'s `WRITE_OP_ALLOWLIST` pins that, and still refuses
|
|
4
|
+
* `createNetwork`/`deleteNetwork` here).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE WHOLE-OBJECT-PUT TRAP (packages/distilled-unifi-network/README.md). `PUT .../networks/{id}` disables
|
|
7
|
+
* `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration` outright when the body omits them. So
|
|
8
|
+
* the body is never `props`: it is the RAW live object (as `fetchLive` decoded it) minus the
|
|
9
|
+
* server fields (`SERVER_FIELDS`: `id`/`default`/`metadata`), plus ONLY the patch (HIGH-2). A
|
|
10
|
+
* hand edit on a field
|
|
11
|
+
* the declaration never touched rides through byte-for-byte; `update-reconcile.ts` has already
|
|
12
|
+
* refused any hand edit on a declared field.
|
|
13
|
+
*
|
|
14
|
+
* ★ WHY RAW LIVE, NOT THE ATTRIBUTES VIEW (LOW-6). `attributesOf` normalizes (sorted sets) and
|
|
15
|
+
* drops keys this kit does not model. Unknown keys must survive the round trip or the PUT would
|
|
16
|
+
* silently delete a setting the console added after the SDK was generated (F2). Measured by
|
|
17
|
+
* reading `@distilled.cloud/core` `protocol-http.ts`: `validateResponse` returns the ORIGINAL
|
|
18
|
+
* decoded value and `mapKeys` passes unknown keys through at every level, so top-level and
|
|
19
|
+
* nested unknown keys both survive; `network-update.test.ts` is the alarm if that ever changes.
|
|
20
|
+
*
|
|
21
|
+
* ★ WHY `unset` EXISTS (HIGH-1). Reverting a declaration (dropping `ipv6Configuration`) must plan
|
|
22
|
+
* and execute a real update back to the pre-image. A key in `patch.unset` is DELETED from the
|
|
23
|
+
* body, which is exactly "absent = off" for the optional blocks above.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ NO-OP GUARD. If the merged body equals live (ignoring the ids), the PUT would change nothing;
|
|
26
|
+
* `UnifiUpdateWouldBeNoop` refuses so a normalizer gap can never become a re-PUT loop.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ NO RETRY ON THE PUT (red team round 1, reversing the earlier Q7 decision). The SDK's default
|
|
29
|
+
* policy would replay a write whose first attempt may already have landed (a dropped response
|
|
30
|
+
* during a reprovision); a replay then runs against a controller state this reconcile never
|
|
31
|
+
* read. One attempt, and a failure surfaces for the operator. Reads keep the default policy.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ SCOPE FIRST (`network-scope.ts`): the management LAN, immutable fields and non-removable keys
|
|
34
|
+
* are refused before the body is even built, so none of them can reach the wire.
|
|
35
|
+
*/
|
|
36
|
+
import * as networks from '@distilled.cloud/unifi-network/networks';
|
|
37
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
38
|
+
import { deepEqual } from 'alchemy/Diff';
|
|
39
|
+
import * as Effect from 'effect/Effect';
|
|
40
|
+
import { type NetworkProps } from './network-form.ts';
|
|
41
|
+
import { checkNetworkWriteScope } from './network-scope.ts';
|
|
42
|
+
import { UnifiUpdateWouldBeNoop } from './policy.ts';
|
|
43
|
+
import type { Patch } from './update-reconcile.ts';
|
|
44
|
+
|
|
45
|
+
export const SERVER_FIELDS = ['id', 'default', 'metadata'] as const;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The live object minus `SERVER_FIELDS` — ONE list drives the strip. The destructure this
|
|
49
|
+
* replaced hard-coded the same three names a second time, so a key added to the export was
|
|
50
|
+
* silently NOT stripped (and a removed one still was): two lists that could drift apart. Own
|
|
51
|
+
* enumerable string keys only, exactly what object-rest destructure copies; a decoded JSON body
|
|
52
|
+
* has no symbol keys.
|
|
53
|
+
*/
|
|
54
|
+
const withoutServerFields = (live: networks.NetworkDetails): Record<string, unknown> => {
|
|
55
|
+
const server = new Set<string>(SERVER_FIELDS);
|
|
56
|
+
const rest: Record<string, unknown> = {};
|
|
57
|
+
for (const [key, value] of Object.entries(live)) {
|
|
58
|
+
if (!server.has(key)) rest[key] = value;
|
|
59
|
+
}
|
|
60
|
+
return rest;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const mergeForUpdate = (
|
|
64
|
+
live: networks.NetworkDetails,
|
|
65
|
+
patch: Patch<NetworkProps>,
|
|
66
|
+
props: NetworkProps,
|
|
67
|
+
): Effect.Effect<networks.UpdateNetworkRequest, UnifiUpdateWouldBeNoop> => {
|
|
68
|
+
// Raw copy minus the server fields: unknown top-level keys stay in `rest` (F2).
|
|
69
|
+
const rest = withoutServerFields(live);
|
|
70
|
+
const body: Record<string, unknown> = { ...rest, ...patch.set };
|
|
71
|
+
for (const key of patch.unset) delete body[key];
|
|
72
|
+
if (deepEqual(body, rest, { stripNullish: true })) {
|
|
73
|
+
return Effect.fail(
|
|
74
|
+
new UnifiUpdateWouldBeNoop({
|
|
75
|
+
type: 'Unifi.Network',
|
|
76
|
+
identity: `sites/${props.siteId}/networks/${props.networkId}`,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return Effect.succeed({
|
|
81
|
+
...body,
|
|
82
|
+
siteId: props.siteId,
|
|
83
|
+
networkId: live.id,
|
|
84
|
+
} as networks.UpdateNetworkRequest);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const writeNetwork = (
|
|
88
|
+
live: networks.NetworkDetails,
|
|
89
|
+
patch: Patch<NetworkProps>,
|
|
90
|
+
props: NetworkProps,
|
|
91
|
+
) =>
|
|
92
|
+
checkNetworkWriteScope(live, patch, props).pipe(
|
|
93
|
+
Effect.andThen(mergeForUpdate(live, patch, props)),
|
|
94
|
+
Effect.flatMap((body) => networks.updateNetwork(body).pipe(Retry.none)),
|
|
95
|
+
);
|
|
@@ -90,7 +90,7 @@ describe('declareNetwork -- the declaration renderer', () => {
|
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
describe('Unifi.Network
|
|
93
|
+
describe('Unifi.Network: only the one allowed PUT ever reaches the vendor API', () => {
|
|
94
94
|
const ops = unifiOperations(spec);
|
|
95
95
|
|
|
96
96
|
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
@@ -119,6 +119,32 @@ describe('Unifi.Network write paths never reach the vendor API', () => {
|
|
|
119
119
|
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
test('a missing network still refuses create -- nothing is ever POSTed', async () => {
|
|
123
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
124
|
+
const failure = await Effect.runPromise(
|
|
125
|
+
Effect.flip(ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
126
|
+
);
|
|
127
|
+
expect(failure).toMatchObject({ _tag: 'UnifiWriteRefused', action: 'create' });
|
|
128
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('drift with olds undefined (first deploy) refuses update, all-GET', async () => {
|
|
132
|
+
const fake = fakeUnifi((method, url) =>
|
|
133
|
+
method === 'GET' && url.pathname === NETWORK_PATH
|
|
134
|
+
? Response.json(liveNetwork({ vlanId: 999 }))
|
|
135
|
+
: fakeFailure(400, 'unexpected request'),
|
|
136
|
+
);
|
|
137
|
+
const failure = await Effect.runPromise(
|
|
138
|
+
Effect.flip(
|
|
139
|
+
ops
|
|
140
|
+
.reconcile(PROPS, { olds: undefined, output: undefined })
|
|
141
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
expect(failure).toMatchObject({ _tag: 'UnifiWriteRefused', action: 'update' });
|
|
145
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
122
148
|
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
123
149
|
const fake = fakeUnifi((method, url) =>
|
|
124
150
|
method === 'GET' && url.pathname === NETWORK_PATH
|
package/src/unifi/network.ts
CHANGED
|
@@ -11,12 +11,11 @@
|
|
|
11
11
|
* `FirewallZone`; `FirewallPolicy`'s several discriminated filter variants and its
|
|
12
12
|
* list-replacing ordering endpoint are a bigger, separate PR).
|
|
13
13
|
*
|
|
14
|
-
* ⛔ THE WHOLE-OBJECT-PUT TRAP (
|
|
15
|
-
* `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration
|
|
16
|
-
* outright when the caller omits them
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* never a props-only body.
|
|
14
|
+
* ⛔ THE WHOLE-OBJECT-PUT TRAP (packages/distilled-unifi-network/README.md) FIRES HERE, THROUGH `network-update.ts`;
|
|
15
|
+
* see its header. `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration`/
|
|
16
|
+
* `ipv6Configuration` outright when the caller omits them, so the body is the raw live object
|
|
17
|
+
* plus only the fields the declaration changed since the last deploy — never props alone.
|
|
18
|
+
* This is the ONE family with a write path (Tim 2026-10-10, `policy.ts`); create/delete refuse.
|
|
20
19
|
*/
|
|
21
20
|
import { Resource } from 'alchemy';
|
|
22
21
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
@@ -24,9 +23,15 @@ import * as Provider from 'alchemy/Provider';
|
|
|
24
23
|
import * as networks from '@distilled.cloud/unifi-network/networks';
|
|
25
24
|
import * as Effect from 'effect/Effect';
|
|
26
25
|
import { type NetworkAttributes, type NetworkProps, attributesOf } from './network-form.ts';
|
|
27
|
-
import { matches } from './network-drift.ts';
|
|
26
|
+
import { driftOf, matches } from './network-drift.ts';
|
|
27
|
+
import { type ScopeError, checkNetworkWriteScope } from './network-scope.ts';
|
|
28
|
+
import { writeNetwork } from './network-update.ts';
|
|
29
|
+
import type { UnifiUpdateWouldBeNoop } from './policy.ts';
|
|
28
30
|
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
29
31
|
import { unifiHandlers } from './resource.ts';
|
|
32
|
+
import type { AllowedWrite } from './wire-guard.ts';
|
|
33
|
+
|
|
34
|
+
type UpdateError = networks.UpdateNetworkError | UnifiUpdateWouldBeNoop | ScopeError;
|
|
30
35
|
|
|
31
36
|
export type { NetworkAttributes, NetworkProps } from './network-form.ts';
|
|
32
37
|
export { declareNetwork } from './network-form.ts';
|
|
@@ -48,10 +53,11 @@ export const UnifiNetwork = Resource<UnifiNetwork>('Unifi.Network', {
|
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
|
-
* `network('lan', props)` — `adopt(true)` piped on by default (H5).
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* remember `--adopt`, mirroring
|
|
56
|
+
* `network('lan', props)` — `adopt(true)` piped on by default (H5). A cold first deploy against an
|
|
57
|
+
* existing network can still only bind to it or refuse `OwnedBySomeoneElse` — the update path
|
|
58
|
+
* needs prior Alchemy state (`update-reconcile.ts`), so it cannot fire on adoption. Piping
|
|
59
|
+
* `adopt(true)` here picks the bind without a stack having to remember `--adopt`, mirroring
|
|
60
|
+
* `discord/application-command.ts`'s `applicationCommand`.
|
|
55
61
|
*/
|
|
56
62
|
export const network = (id: string, props: NetworkProps) =>
|
|
57
63
|
UnifiNetwork(id, props).pipe(adopt(true));
|
|
@@ -61,11 +67,42 @@ export const network = (id: string, props: NetworkProps) =>
|
|
|
61
67
|
* seam `netbox/prefix.ts`'s `spec` and `discord/application-command.ts`'s `spec` use — `handlers`
|
|
62
68
|
* bakes in `CredentialsFromEnv`, which resolves environment variables a test cannot repoint.
|
|
63
69
|
*/
|
|
70
|
+
/**
|
|
71
|
+
* Every declarable key: `NetworkProps` minus the two identity ids. A test pins this against a
|
|
72
|
+
* fully-populated `NetworkProps` literal so a new prop cannot be silently unpatchable.
|
|
73
|
+
*/
|
|
74
|
+
export const NETWORK_PATCH_KEYS: ReadonlyArray<keyof NetworkProps> = [
|
|
75
|
+
'name',
|
|
76
|
+
'enabled',
|
|
77
|
+
'management',
|
|
78
|
+
'vlanId',
|
|
79
|
+
'dhcpGuarding',
|
|
80
|
+
'cellularBackupEnabled',
|
|
81
|
+
'internetAccessEnabled',
|
|
82
|
+
'ipv4Configuration',
|
|
83
|
+
'ipv6Configuration',
|
|
84
|
+
'isolationEnabled',
|
|
85
|
+
'mdnsForwardingEnabled',
|
|
86
|
+
'zoneId',
|
|
87
|
+
'deviceId',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The only PUT that may pass for this row: its own `/v1/sites/<siteId>/networks/<networkId>` under
|
|
92
|
+
* the configured base URL. ⛔ Built from the row's NEW props (the ids the write is about to use) and
|
|
93
|
+
* compared by exact string against the request path, so no id can act as a pattern.
|
|
94
|
+
*/
|
|
95
|
+
export const networkAllowedWrite = (props: NetworkProps): AllowedWrite => ({
|
|
96
|
+
method: 'PUT',
|
|
97
|
+
tail: `/v1/sites/${props.siteId}/networks/${props.networkId}`,
|
|
98
|
+
});
|
|
99
|
+
|
|
64
100
|
export const spec: UnifiSpec<
|
|
65
101
|
NetworkProps,
|
|
66
102
|
networks.NetworkDetails,
|
|
67
103
|
NetworkAttributes,
|
|
68
|
-
networks.GetNetworkDetailsError
|
|
104
|
+
networks.GetNetworkDetailsError,
|
|
105
|
+
UpdateError
|
|
69
106
|
> = {
|
|
70
107
|
type: 'Unifi.Network',
|
|
71
108
|
describe: (props) => `sites/${props.siteId}/networks/${props.networkId}`,
|
|
@@ -75,6 +112,13 @@ export const spec: UnifiSpec<
|
|
|
75
112
|
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
76
113
|
attributes: attributesOf,
|
|
77
114
|
matches,
|
|
115
|
+
update: {
|
|
116
|
+
allowedWrite: networkAllowedWrite,
|
|
117
|
+
patchKeys: NETWORK_PATCH_KEYS,
|
|
118
|
+
driftOf,
|
|
119
|
+
checkScope: checkNetworkWriteScope,
|
|
120
|
+
write: writeNetwork,
|
|
121
|
+
},
|
|
78
122
|
};
|
|
79
123
|
|
|
80
124
|
export const handlers = unifiHandlers(spec);
|
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', () => {
|