@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.
Files changed (58) hide show
  1. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  2. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  3. package/dist/unifi/acl-rule.d.ts +6 -1
  4. package/dist/unifi/acl-rule.d.ts.map +1 -1
  5. package/dist/unifi/dns-policy.d.ts +6 -1
  6. package/dist/unifi/dns-policy.d.ts.map +1 -1
  7. package/dist/unifi/fake-unifi.d.ts +4 -0
  8. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  9. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  10. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  11. package/dist/unifi/firewall-policy.d.ts +6 -1
  12. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  13. package/dist/unifi/firewall-zone.d.ts +6 -1
  14. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  15. package/dist/unifi/index.d.ts +5 -3
  16. package/dist/unifi/index.d.ts.map +1 -1
  17. package/dist/unifi/index.js +339 -86
  18. package/dist/unifi/index.js.map +10 -6
  19. package/dist/unifi/network-scope.d.ts +31 -0
  20. package/dist/unifi/network-scope.d.ts.map +1 -0
  21. package/dist/unifi/network-update.d.ts +44 -0
  22. package/dist/unifi/network-update.d.ts.map +1 -0
  23. package/dist/unifi/network.d.ts +32 -12
  24. package/dist/unifi/network.d.ts.map +1 -1
  25. package/dist/unifi/policy.d.ts +89 -1
  26. package/dist/unifi/policy.d.ts.map +1 -1
  27. package/dist/unifi/resource.d.ts +17 -43
  28. package/dist/unifi/resource.d.ts.map +1 -1
  29. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  30. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  31. package/dist/unifi/update-reconcile.d.ts +31 -0
  32. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  33. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  34. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  35. package/dist/unifi/wire-guard.d.ts +33 -0
  36. package/dist/unifi/wire-guard.d.ts.map +1 -0
  37. package/docs/unifi-network-write.md +90 -0
  38. package/docs/unifi.md +36 -36
  39. package/package.json +1 -1
  40. package/src/unifi/fake-unifi.ts +15 -2
  41. package/src/unifi/handler-allow-news.test.ts +80 -0
  42. package/src/unifi/index.ts +21 -5
  43. package/src/unifi/network-scope.test.ts +203 -0
  44. package/src/unifi/network-scope.ts +75 -0
  45. package/src/unifi/network-update.test.ts +250 -0
  46. package/src/unifi/network-update.ts +95 -0
  47. package/src/unifi/network.test.ts +27 -1
  48. package/src/unifi/network.ts +56 -12
  49. package/src/unifi/policy.ts +128 -10
  50. package/src/unifi/resource.test.ts +15 -3
  51. package/src/unifi/resource.ts +64 -105
  52. package/src/unifi/update-reconcile.ts +126 -0
  53. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  54. package/src/unifi/wire-guard-round2.test.ts +120 -0
  55. package/src/unifi/wire-guard.test.ts +218 -0
  56. package/src/unifi/wire-guard.ts +192 -0
  57. package/src/unifi/write-op-reference.test.ts +38 -2
  58. 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 write paths never reach the vendor API', () => {
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
@@ -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 (`docs/unifi-api-notes.md`) NEVER FIRES HERE, BY CONSTRUCTION.
15
- * `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration`
16
- * outright when the caller omits them — the exact failure mode a read-only family cannot cause,
17
- * because `resource.ts`'s `reconcile` never calls `updateNetwork` at all. Recorded here for
18
- * whoever adds the write path this docs note anticipates: read-merge-write the WHOLE object,
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). This family never writes, so
52
- * the only thing a first deploy against an existing network could ever do is bind to it or refuse
53
- * `OwnedBySomeoneElse`; piping `adopt(true)` here picks the former without a stack having to
54
- * remember `--adopt`, mirroring `discord/application-command.ts`'s `applicationCommand`.
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);
@@ -1,12 +1,14 @@
1
1
  /**
2
- * The one refusal every `Unifi.*` write handler returns — never an SDK write call.
2
+ * The one refusal every refused `Unifi.*` write returns. The family makes exactly one SDK write
3
+ * call — `networks.updateNetwork`, through `network-update.ts` — and that is the only write that
4
+ * never sees this error.
3
5
  *
4
- * ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY FOR NOW. This family has no
5
- * `create`, no `update` body and no delete call anywhere in its source — `reconcile` and
6
- * `delete` both fail with this typed error instead, and `resource.test.ts` proves the point
7
- * with a fake `HttpClient` that fails the test on any request that is not a `GET`. Lifting the
8
- * policy is a kit change (a new PR that adds a write path, reviewed as one), not a flag a stack
9
- * can pass.
6
+ * ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY. LIFTED FOR EXACTLY ONE CASE on
7
+ * 2026-10-10 (Tim): `Unifi.Network` update, through `update-reconcile.ts` + `network-update.ts`.
8
+ * Every other write — create, delete, any other family's update — still fails with this typed
9
+ * error, and `resource.test.ts`/`wire-guard.test.ts` prove it with a fake `HttpClient` that
10
+ * records every request. Widening the policy again is a kit change (a new PR, reviewed as one),
11
+ * not a flag a stack can pass.
10
12
  *
11
13
  * ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a
12
14
  * caller (a future import script, a test) branch on the refusal without parsing a message.
@@ -14,8 +16,14 @@
14
16
  import * as Data from 'effect/Data';
15
17
  import * as Effect from 'effect/Effect';
16
18
 
17
- export const UNIFI_READ_ONLY_POLICY =
18
- "read-only by Tim's rule, 2026-09-24; lifting it is a kit change";
19
+ export const UNIFI_WRITE_POLICY =
20
+ 'UniFi is adopt-only except Unifi.Network update: one whole-object PUT built from the live ' +
21
+ 'object plus the declared field changes, only on a row with prior Alchemy state, only through ' +
22
+ 'deploy-approve; create/delete and every other family stay refused -- widening is a kit change ' +
23
+ '(Tim 2026-10-10 lifts the 2026-09-24 rule for this one case)';
24
+
25
+ /** @deprecated renamed `UNIFI_WRITE_POLICY`; kept so existing consumer imports do not break. */
26
+ export const UNIFI_READ_ONLY_POLICY = UNIFI_WRITE_POLICY;
19
27
 
20
28
  /** What kind of write was asked for and refused — for the message only, never acted on. */
21
29
  export type UnifiWriteAction = 'create' | 'update' | 'delete';
@@ -28,10 +36,120 @@ export class UnifiWriteRefused extends Data.TaggedError('UnifiWriteRefused')<{
28
36
  readonly action: UnifiWriteAction;
29
37
  }> {
30
38
  override get message(): string {
31
- return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_READ_ONLY_POLICY}.`;
39
+ return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_WRITE_POLICY}.`;
32
40
  }
33
41
  }
34
42
 
35
43
  /** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */
36
44
  export const refuseWrite = (type: string, identity: string, action: UnifiWriteAction) =>
37
45
  Effect.fail(new UnifiWriteRefused({ type, identity, action }));
46
+
47
+ // ⛔ THE THREE ERRORS BELOW CARRY FIELD NAMES ONLY, NEVER VALUES: a network row holds addressing
48
+ // detail, and an error message reaches logs, CI bodies and pasted reports.
49
+ const fieldList = (fields: ReadonlyArray<string>) => `[${fields.join(', ')}]`;
50
+
51
+ /** Live no longer equals what the last deploy wrote: a hand edit is never overwritten. */
52
+ export class UnifiLiveDriftedSinceDeploy extends Data.TaggedError('UnifiLiveDriftedSinceDeploy')<{
53
+ readonly type: string;
54
+ readonly identity: string;
55
+ readonly fields: ReadonlyArray<string>;
56
+ }> {
57
+ override get message(): string {
58
+ return (
59
+ `${this.type} ${this.identity}: live differs from the last deployed state on ` +
60
+ `${fieldList(this.fields)}; refusing to PUT over it. Two causes look identical from here: a ` +
61
+ 'hand edit on the console, or an earlier PUT that landed but did not converge ' +
62
+ '(UnifiUpdateDidNotConverge). Re-import the live object first.'
63
+ );
64
+ }
65
+ }
66
+
67
+ /** The merged body equals live: a PUT would change nothing (keeps a re-PUT loop impossible). */
68
+ export class UnifiUpdateWouldBeNoop extends Data.TaggedError('UnifiUpdateWouldBeNoop')<{
69
+ readonly type: string;
70
+ readonly identity: string;
71
+ }> {
72
+ override get message(): string {
73
+ return `${this.type} ${this.identity}: the merged update body equals live; refusing a no-op PUT.`;
74
+ }
75
+ }
76
+
77
+ /** The PUT answered, yet the response still differs from the declaration. */
78
+ export class UnifiUpdateDidNotConverge extends Data.TaggedError('UnifiUpdateDidNotConverge')<{
79
+ readonly type: string;
80
+ readonly identity: string;
81
+ readonly fields: ReadonlyArray<string>;
82
+ }> {
83
+ override get message(): string {
84
+ return (
85
+ `${this.type} ${this.identity}: the PUT LANDED, but live now differs from the declaration on ` +
86
+ `${fieldList(this.fields)}. The change is on the console and is not rolled back; the next ` +
87
+ 'deploy will see this as drift. Recovery is a re-import of the live object.'
88
+ );
89
+ }
90
+ }
91
+
92
+ // ⛔ THE FIVE ERRORS BELOW BOUND THE WRITE SCOPE (`network-scope.ts`, `update-reconcile.ts`): the
93
+ // one intended consumer is a single VLAN's `ipv6Configuration`. Field names only, as above.
94
+
95
+ /** A patch would remove a key outside the removable allowlist (an omitted block is turned off). */
96
+ export class UnifiFieldNotRemovable extends Data.TaggedError('UnifiFieldNotRemovable')<{
97
+ readonly type: string;
98
+ readonly identity: string;
99
+ readonly fields: ReadonlyArray<string>;
100
+ }> {
101
+ override get message(): string {
102
+ return (
103
+ `${this.type} ${this.identity}: refusing to remove ${fieldList(this.fields)}; a whole-object ` +
104
+ 'PUT turns an omitted block off, and only the removable allowlist may be dropped.'
105
+ );
106
+ }
107
+ }
108
+
109
+ /** A patch would SET a key outside the settable allowlist (only `ipv6Configuration` may be written). */
110
+ export class UnifiFieldNotSettable extends Data.TaggedError('UnifiFieldNotSettable')<{
111
+ readonly type: string;
112
+ readonly identity: string;
113
+ readonly fields: ReadonlyArray<string>;
114
+ }> {
115
+ override get message(): string {
116
+ return (
117
+ `${this.type} ${this.identity}: refusing to set ${fieldList(this.fields)}; this write path ` +
118
+ 'only sets the settable allowlist (ipv6Configuration).'
119
+ );
120
+ }
121
+ }
122
+
123
+ /** The live object is the site's default (management) network: never written. */
124
+ export class UnifiManagementNetworkRefused extends Data.TaggedError(
125
+ 'UnifiManagementNetworkRefused',
126
+ )<{
127
+ readonly type: string;
128
+ readonly identity: string;
129
+ }> {
130
+ override get message(): string {
131
+ return `${this.type} ${this.identity}: live is the default (management) network; refusing any write to it.`;
132
+ }
133
+ }
134
+
135
+ /** A patch changes a field that decides where the network lives or what serves it. */
136
+ export class UnifiImmutableFieldChanged extends Data.TaggedError('UnifiImmutableFieldChanged')<{
137
+ readonly type: string;
138
+ readonly identity: string;
139
+ readonly fields: ReadonlyArray<string>;
140
+ }> {
141
+ override get message(): string {
142
+ return `${this.type} ${this.identity}: refusing to change ${fieldList(this.fields)}; those fields are never written.`;
143
+ }
144
+ }
145
+
146
+ /** `olds` and `news` name different objects: the prior state is no baseline for this write. */
147
+ export class UnifiIdentityChanged extends Data.TaggedError('UnifiIdentityChanged')<{
148
+ readonly type: string;
149
+ readonly identity: string;
150
+ readonly previous: string;
151
+ }> {
152
+ override get message(): string {
153
+ return `${this.type} ${this.identity}: the last deploy named ${this.previous}; refusing to PUT against another object.`;
154
+ }
155
+ }
@@ -13,7 +13,12 @@ import { Unowned } from 'alchemy/AdoptPolicy';
13
13
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
14
14
  import * as Effect from 'effect/Effect';
15
15
  import { fakeUnifiLayer } from './fake-unifi.ts';
16
- import { UnifiWriteRefused } from './policy.ts';
16
+ import {
17
+ type UnifiIdentityChanged,
18
+ type UnifiLiveDriftedSinceDeploy,
19
+ type UnifiUpdateDidNotConverge,
20
+ UnifiWriteRefused,
21
+ } from './policy.ts';
17
22
  import { type UnifiSpec, unifiOperations } from './resource.ts';
18
23
 
19
24
  interface WidgetProps {
@@ -52,11 +57,18 @@ const run = <A, E>(effect: Effect.Effect<A, E, UnifiNetworkOpContext>) =>
52
57
  Effect.runPromise(effect.pipe(Effect.provide(fakeUnifiLayer(unreachableFetch))));
53
58
 
54
59
  const refusal = async (
55
- effect: Effect.Effect<unknown, UnifiWriteRefused, UnifiNetworkOpContext>,
60
+ effect: Effect.Effect<
61
+ unknown,
62
+ | UnifiWriteRefused
63
+ | UnifiLiveDriftedSinceDeploy
64
+ | UnifiUpdateDidNotConverge
65
+ | UnifiIdentityChanged,
66
+ UnifiNetworkOpContext
67
+ >,
56
68
  ) => {
57
69
  const failure = await run(Effect.flip(effect));
58
70
  expect(failure).toBeInstanceOf(UnifiWriteRefused);
59
- return failure;
71
+ return failure as UnifiWriteRefused;
60
72
  };
61
73
 
62
74
  describe('unifiOperations.readHandler', () => {