@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
@@ -1,5 +1,7 @@
1
1
  import * as Effect from 'effect/Effect';
2
- export declare const UNIFI_READ_ONLY_POLICY = "read-only by Tim's rule, 2026-09-24; lifting it is a kit change";
2
+ export declare const UNIFI_WRITE_POLICY: string;
3
+ /** @deprecated renamed `UNIFI_WRITE_POLICY`; kept so existing consumer imports do not break. */
4
+ export declare const UNIFI_READ_ONLY_POLICY: string;
3
5
  /** What kind of write was asked for and refused — for the message only, never acted on. */
4
6
  export type UnifiWriteAction = 'create' | 'update' | 'delete';
5
7
  declare const UnifiWriteRefused_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
@@ -16,5 +18,91 @@ export declare class UnifiWriteRefused extends UnifiWriteRefused_base<{
16
18
  }
17
19
  /** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */
18
20
  export declare const refuseWrite: (type: string, identity: string, action: UnifiWriteAction) => Effect.Effect<never, UnifiWriteRefused, never>;
21
+ declare const UnifiLiveDriftedSinceDeploy_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
22
+ readonly _tag: "UnifiLiveDriftedSinceDeploy";
23
+ } & Readonly<A>;
24
+ /** Live no longer equals what the last deploy wrote: a hand edit is never overwritten. */
25
+ export declare class UnifiLiveDriftedSinceDeploy extends UnifiLiveDriftedSinceDeploy_base<{
26
+ readonly type: string;
27
+ readonly identity: string;
28
+ readonly fields: ReadonlyArray<string>;
29
+ }> {
30
+ get message(): string;
31
+ }
32
+ declare const UnifiUpdateWouldBeNoop_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
33
+ readonly _tag: "UnifiUpdateWouldBeNoop";
34
+ } & Readonly<A>;
35
+ /** The merged body equals live: a PUT would change nothing (keeps a re-PUT loop impossible). */
36
+ export declare class UnifiUpdateWouldBeNoop extends UnifiUpdateWouldBeNoop_base<{
37
+ readonly type: string;
38
+ readonly identity: string;
39
+ }> {
40
+ get message(): string;
41
+ }
42
+ declare const UnifiUpdateDidNotConverge_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
43
+ readonly _tag: "UnifiUpdateDidNotConverge";
44
+ } & Readonly<A>;
45
+ /** The PUT answered, yet the response still differs from the declaration. */
46
+ export declare class UnifiUpdateDidNotConverge extends UnifiUpdateDidNotConverge_base<{
47
+ readonly type: string;
48
+ readonly identity: string;
49
+ readonly fields: ReadonlyArray<string>;
50
+ }> {
51
+ get message(): string;
52
+ }
53
+ declare const UnifiFieldNotRemovable_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
54
+ readonly _tag: "UnifiFieldNotRemovable";
55
+ } & Readonly<A>;
56
+ /** A patch would remove a key outside the removable allowlist (an omitted block is turned off). */
57
+ export declare class UnifiFieldNotRemovable extends UnifiFieldNotRemovable_base<{
58
+ readonly type: string;
59
+ readonly identity: string;
60
+ readonly fields: ReadonlyArray<string>;
61
+ }> {
62
+ get message(): string;
63
+ }
64
+ declare const UnifiFieldNotSettable_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
65
+ readonly _tag: "UnifiFieldNotSettable";
66
+ } & Readonly<A>;
67
+ /** A patch would SET a key outside the settable allowlist (only `ipv6Configuration` may be written). */
68
+ export declare class UnifiFieldNotSettable extends UnifiFieldNotSettable_base<{
69
+ readonly type: string;
70
+ readonly identity: string;
71
+ readonly fields: ReadonlyArray<string>;
72
+ }> {
73
+ get message(): string;
74
+ }
75
+ declare const UnifiManagementNetworkRefused_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
76
+ readonly _tag: "UnifiManagementNetworkRefused";
77
+ } & Readonly<A>;
78
+ /** The live object is the site's default (management) network: never written. */
79
+ export declare class UnifiManagementNetworkRefused extends UnifiManagementNetworkRefused_base<{
80
+ readonly type: string;
81
+ readonly identity: string;
82
+ }> {
83
+ get message(): string;
84
+ }
85
+ declare const UnifiImmutableFieldChanged_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
86
+ readonly _tag: "UnifiImmutableFieldChanged";
87
+ } & Readonly<A>;
88
+ /** A patch changes a field that decides where the network lives or what serves it. */
89
+ export declare class UnifiImmutableFieldChanged extends UnifiImmutableFieldChanged_base<{
90
+ readonly type: string;
91
+ readonly identity: string;
92
+ readonly fields: ReadonlyArray<string>;
93
+ }> {
94
+ get message(): string;
95
+ }
96
+ declare const UnifiIdentityChanged_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
97
+ readonly _tag: "UnifiIdentityChanged";
98
+ } & Readonly<A>;
99
+ /** `olds` and `news` name different objects: the prior state is no baseline for this write. */
100
+ export declare class UnifiIdentityChanged extends UnifiIdentityChanged_base<{
101
+ readonly type: string;
102
+ readonly identity: string;
103
+ readonly previous: string;
104
+ }> {
105
+ get message(): string;
106
+ }
19
107
  export {};
20
108
  //# sourceMappingURL=policy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/unifi/policy.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,eAAO,MAAM,sBAAsB,oEACgC,CAAC;AAEpE,2FAA2F;AAC3F,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;;;;AAE9D,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;CACnC,CAAC;IACA,IAAa,OAAO,IAAI,MAAM,CAE7B;CACF;AAED,2FAA2F;AAC3F,eAAO,MAAM,WAAW,SAAU,MAAM,YAAY,MAAM,UAAU,gBAAgB,mDACpB,CAAC"}
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/unifi/policy.ts"],"names":[],"mappings":"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"}
@@ -1,16 +1,17 @@
1
1
  import type { Input } from 'alchemy/Input';
2
2
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
3
3
  import * as Effect from 'effect/Effect';
4
- import * as HttpClient from 'effect/http/HttpClient';
4
+ import type * as HttpClient from 'effect/http/HttpClient';
5
+ import { type ReconcileArgs, type UnifiUpdate } from './update-reconcile.ts';
5
6
  /** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
6
7
  export type UnifiRequirements = HttpClient.HttpClient;
7
8
  /**
8
9
  * One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a
9
10
  * `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error
10
11
  * union, with `NotFound` already folded away) so the precise per-operation error union flows
11
- * through instead of being widened by hand here.
12
+ * through instead of being widened by hand here. `E2` is the error union of the optional write.
12
13
  */
13
- export type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {
14
+ export type UnifiSpec<Props extends object, Live, Attributes extends object, E, E2 = never> = {
14
15
  /** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
15
16
  readonly type: string;
16
17
  /** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
@@ -19,8 +20,10 @@ export type UnifiSpec<Props extends object, Live, Attributes extends object, E>
19
20
  readonly matches: (attributes: Attributes, props: Props) => boolean;
20
21
  /** For the refusal message and read-back failures. */
21
22
  readonly describe: (props: Props) => string;
23
+ /** Present only on a family allowed to write (`Unifi.Network`); see `update-reconcile.ts`. */
24
+ readonly update?: UnifiUpdate<Props, Live, E2>;
22
25
  };
23
- export declare const unifiOperations: <Props extends object, Live, Attributes extends object, E>(spec: UnifiSpec<Props, Live, Attributes, E>) => {
26
+ export declare const unifiOperations: <Props extends object, Live, Attributes extends object, E, E2>(spec: UnifiSpec<Props, Live, Attributes, E, E2>) => {
24
27
  read: (props: Props) => Effect.Effect<Attributes | undefined, E, UnifiNetworkOpContext>;
25
28
  readHandler: ({ olds, output }: {
26
29
  olds: Props;
@@ -31,51 +34,17 @@ export declare const unifiOperations: <Props extends object, Live, Attributes ex
31
34
  } | {
32
35
  readonly action: 'noop';
33
36
  } | undefined, E, UnifiNetworkOpContext>;
34
- reconcile: (news: Props) => Effect.Effect<Attributes, E | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
37
+ /** Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset. */
38
+ reconcile: (news: Props, rest?: Partial<Omit<ReconcileArgs<Props>, 'news'>>) => Effect.Effect<Attributes, E | E2 | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
35
39
  destroy: (olds: Props) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, never>;
36
40
  };
37
41
  /**
38
- * B0a / T12 DEFENSE IN DEPTH. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at
39
- * `reconcile`/`destroy` — every path this engine exposes already returns that refusal instead of
40
- * calling an SDK write op. This guard stops the same thing one layer lower, AT THE WIRE: it wraps
41
- * whatever `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in
42
- * production, `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same
43
- * "wrap, don't replace" seam) so a future resource file that, by mistake, called an SDK write
44
- * operation directly — bypassing `unifiOperations` entirely — would still never reach the vendor
45
- * API. `write-op-reference.test.ts` proves no such call exists today; this is what stops one from
46
- * ever taking effect if one is ever added.
47
- *
48
- * ⚠️ `Effect.die`, NOT A TYPED FAILURE — classified per distilled-doctrine's "classify every
49
- * die/orDie". Every generated SDK operation (`networks.getNetworkDetails`, …) declares its OWN
50
- * closed error union from the pinned OpenAPI spec, and generated files are never hand-edited to
51
- * add a member to it. A typed `Effect.fail` here would be a failure mode absent from every op's
52
- * declared type the moment it occurred — a type-system lie. A defect is the sound way to add a
53
- * NEW "this must be structurally impossible" failure underneath types this package does not
54
- * own, exactly like `openbao`'s `refuse()` helpers' `Effect.die(new Error(...))` for a
55
- * configuration move that should never be reachable.
56
- */
57
- export declare class UnifiNonGetRequest extends Error {
58
- readonly method: string;
59
- /** PATH ONLY, never the full URL — see `requestPath` below. */
60
- readonly path: string;
61
- constructor(method: string,
62
- /** PATH ONLY, never the full URL — see `requestPath` below. */
63
- path: string);
64
- }
65
- /**
66
- * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
67
- * for everything downstream, with `guardGetOnly`'s wrapped version (`HttpClient.layerMergedContext`
68
- * — see the guard above). Exported so `get-only-guard.test.ts` can prove the mechanism directly,
69
- * against a bare `HttpClient` service, without needing `CredentialsFromEnv` or any env var at all.
70
- */
71
- export declare const GetOnlyHttpClient: import("effect/Layer").Layer<HttpClient.HttpClient, never, HttpClient.HttpClient>;
72
- /**
73
- * The four provider handlers for a spec'd read-only UniFi object, wired once.
42
+ * The four provider handlers for a spec'd UniFi object, wired once.
74
43
  *
75
44
  * ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
76
45
  * adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
77
46
  */
78
- export declare const unifiHandlers: <Props extends object, Live, Attributes extends object, E>(spec: UnifiSpec<Props, Live, Attributes, E>) => {
47
+ export declare const unifiHandlers: <Props extends object, Live, Attributes extends object, E, E2>(spec: UnifiSpec<Props, Live, Attributes, E, E2>) => {
79
48
  list: () => Effect.Effect<never[], never, never>;
80
49
  read: (args: {
81
50
  olds: Props;
@@ -91,7 +60,12 @@ export declare const unifiHandlers: <Props extends object, Live, Attributes exte
91
60
  } | undefined, E, HttpClient.HttpClient>;
92
61
  reconcile: (args: {
93
62
  news: Props;
94
- }) => Effect.Effect<Attributes, E | import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
63
+ olds: Props | undefined;
64
+ output: Attributes | undefined;
65
+ session: {
66
+ note: (message: string) => Effect.Effect<void, never, never>;
67
+ };
68
+ }) => Effect.Effect<Attributes, E | E2 | import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
95
69
  delete: (args: {
96
70
  olds: Props;
97
71
  }) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
@@ -1 +1 @@
1
- {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/unifi/resource.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAE3C,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AACrF,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAC;AAGrD,gGAAgG;AAChG,MAAM,MAAM,iBAAiB,GAAG,UAAU,CAAC,UAAU,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,CAAC,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,IAAI;IAChF,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6FAA6F;IAC7F,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,qBAAqB,CAAC,CAAC;IAChG,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,KAAK,UAAU,CAAC;IAC9D,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACpE,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC;CAC7C,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,QAChF,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;kBAEtB,KAAK;oCAQQ;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;iBAUlE,KAAK,CAAC,KAAK,CAAC,UAAU,UAAU,GAAG,SAAS;yBAIZ,QAAQ;;yBAElC,MAAM;;sBAIP,KAAK;oBAYP,KAAK;CAExB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAEzC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHvB,YACW,MAAM,EAAE,MAAM;IACvB,+DAA+D;IACtD,IAAI,EAAE,MAAM,EAStB;CACF;AA0BD;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,mFAE7B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,QAC9E,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;IAMzC,IAAI;IACJ,IAAI,SAAS;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;IAE5D,IAAI,SAAS;QAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;yBA5GtB,QAAQ;;yBAElC,MAAM;;IA4GzB,SAAS,SAAS;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IACjC,MAAM,SAAS;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;CAEjC,CAAC"}
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/unifi/resource.ts"],"names":[],"mappings":"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"}
@@ -42,7 +42,12 @@ export declare const handlers: {
42
42
  } | undefined, trafficMatchingLists.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
43
43
  reconcile: (args: {
44
44
  news: TrafficMatchingListProps;
45
- }) => Effect.Effect<TrafficMatchingListAttributes, import("./policy.ts").UnifiWriteRefused | trafficMatchingLists.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
45
+ olds: TrafficMatchingListProps | undefined;
46
+ output: TrafficMatchingListAttributes | undefined;
47
+ session: {
48
+ note: (message: string) => Effect.Effect<void, never, never>;
49
+ };
50
+ }) => Effect.Effect<TrafficMatchingListAttributes, import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused | trafficMatchingLists.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
46
51
  delete: (args: {
47
52
  olds: TrafficMatchingListProps;
48
53
  }) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, import("effect/http/HttpClient").HttpClient>;
@@ -1 +1 @@
1
- {"version":3,"file":"traffic-matching-list.d.ts","sourceRoot":"","sources":["../../src/unifi/traffic-matching-list.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,oBAAoB,MAAM,uDAAuD,CAAC;AAC9F,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EACL,KAAK,6BAA6B,EAClC,KAAK,wBAAwB,EAE9B,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAGlE,YAAY,EACV,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAC7E,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAE3D,MAAM,WAAW,wBAAyB,SAAQ,QAAQ,CACxD,2BAA2B,EAC3B,wBAAwB,EACxB,6BAA6B,EAC7B,KAAK,EACL,iBAAiB,CAClB;CAAG;AAEJ,eAAO,MAAM,wBAAwB,2DAGpC,CAAC;AAEF;oBACoB;AACpB,eAAO,MAAM,mBAAmB,OAAQ,MAAM,SAAS,wBAAwB,gGACxB,CAAC;AAExD,iGAAiG;AACjG,eAAO,MAAM,IAAI,EAAE,SAAS,CAC1B,wBAAwB,EACxB,oBAAoB,CAAC,mBAAmB,EACxC,6BAA6B,EAC7B,oBAAoB,CAAC,2BAA2B,CAcjD,CAAC;AAEF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;CAAsB,CAAC;AAE5C,eAAO,MAAM,gCAAgC,qIAI1C,CAAC"}
1
+ {"version":3,"file":"traffic-matching-list.d.ts","sourceRoot":"","sources":["../../src/unifi/traffic-matching-list.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,oBAAoB,MAAM,uDAAuD,CAAC;AAC9F,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EACL,KAAK,6BAA6B,EAClC,KAAK,wBAAwB,EAE9B,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAGlE,YAAY,EACV,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAC7E,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAE3D,MAAM,WAAW,wBAAyB,SAAQ,QAAQ,CACxD,2BAA2B,EAC3B,wBAAwB,EACxB,6BAA6B,EAC7B,KAAK,EACL,iBAAiB,CAClB;CAAG;AAEJ,eAAO,MAAM,wBAAwB,2DAGpC,CAAC;AAEF;oBACoB;AACpB,eAAO,MAAM,mBAAmB,OAAQ,MAAM,SAAS,wBAAwB,gGACxB,CAAC;AAExD,iGAAiG;AACjG,eAAO,MAAM,IAAI,EAAE,SAAS,CAC1B,wBAAwB,EACxB,oBAAoB,CAAC,mBAAmB,EACxC,6BAA6B,EAC7B,oBAAoB,CAAC,2BAA2B,CAcjD,CAAC;AAEF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;CAAsB,CAAC;AAE5C,eAAO,MAAM,gCAAgC,qIAI1C,CAAC"}
@@ -0,0 +1,31 @@
1
+ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
2
+ import * as Effect from 'effect/Effect';
3
+ import type { FieldDrift } from './drift.ts';
4
+ import { UnifiIdentityChanged, UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
5
+ import type { UnifiSpec } from './resource.ts';
6
+ import type { AllowedWrite } from './wire-guard.ts';
7
+ export type Patch<Props> = {
8
+ set: Partial<Props>;
9
+ unset: ReadonlyArray<keyof Props>;
10
+ };
11
+ /** The write half of a spec: everything needed to PUT one object, and nothing to create/delete. */
12
+ export type UnifiUpdate<Props extends object, Live, E2> = {
13
+ /** The one PUT the wire guard may pass for this row (path pinned to the row's own ids). */
14
+ readonly allowedWrite: (props: Props) => AllowedWrite;
15
+ /** Declarable keys; never the identity keys. */
16
+ readonly patchKeys: ReadonlyArray<keyof Props>;
17
+ readonly driftOf: (live: Live, props: Props) => ReadonlyArray<FieldDrift>;
18
+ /** Refuses (typed, before any note or request) a patch outside this family's write scope. */
19
+ readonly checkScope: (live: Live, patch: Patch<Props>, props: Props) => Effect.Effect<void, E2>;
20
+ readonly write: (live: Live, patch: Patch<Props>, props: Props) => Effect.Effect<Live, E2, UnifiNetworkOpContext>;
21
+ };
22
+ /** Keys whose `news` value differs from `olds`: set when still declared, unset when dropped. */
23
+ export declare const computePatch: <Props extends object>(news: Props, olds: Props, patchKeys: ReadonlyArray<keyof Props>) => Patch<Props>;
24
+ export type ReconcileArgs<Props extends object> = {
25
+ readonly news: Props;
26
+ readonly olds: Props | undefined;
27
+ readonly output: unknown;
28
+ readonly note: (message: string) => Effect.Effect<void>;
29
+ };
30
+ export declare const updateReconcile: <Props extends object, Live, Attributes extends object, E, E2>(spec: UnifiSpec<Props, Live, Attributes, E, E2>, { news, olds, output, note }: ReconcileArgs<Props>) => Effect.Effect<Attributes, E | E2 | UnifiIdentityChanged | UnifiLiveDriftedSinceDeploy | UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
31
+ //# sourceMappingURL=update-reconcile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-reconcile.d.ts","sourceRoot":"","sources":["../../src/unifi/update-reconcile.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AACrF,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EACL,oBAAoB,EACpB,2BAA2B,EAC3B,yBAAyB,EAE1B,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAEpD,MAAM,MAAM,KAAK,CAAC,KAAK,IAAI;IAAE,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAAC,KAAK,EAAE,aAAa,CAAC,MAAM,KAAK,CAAC,CAAA;CAAE,CAAC;AAEtF,mGAAmG;AACnG,MAAM,MAAM,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,EAAE,IAAI;IACxD,2FAA2F;IAC3F,QAAQ,CAAC,YAAY,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,YAAY,CAAC;IACtD,gDAAgD;IAChD,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC,MAAM,KAAK,CAAC,CAAC;IAC/C,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,KAAK,aAAa,CAAC,UAAU,CAAC,CAAC;IAC1E,6FAA6F;IAC7F,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAChG,QAAQ,CAAC,KAAK,EAAE,CACd,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EACnB,KAAK,EAAE,KAAK,KACT,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,EAAE,qBAAqB,CAAC,CAAC;CACrD,CAAC;AAEF,gGAAgG;AAChG,eAAO,MAAM,YAAY,GAAI,KAAK,SAAS,MAAM,QACzC,KAAK,QACL,KAAK,aACA,aAAa,CAAC,MAAM,KAAK,CAAC,KACpC,KAAK,CAAC,KAAK,CASb,CAAC;AAEF,MAAM,MAAM,aAAa,CAAC,KAAK,SAAS,MAAM,IAAI;IAChD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,KAAK,GAAG,SAAS,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACzD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,EAAE,EAAE,QACpF,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,EAAE,CAAC,gCACjB,aAAa,CAAC,KAAK,CAAC,wLA2ChD,CAAC"}
@@ -64,7 +64,12 @@ export declare const handlers: {
64
64
  } | undefined, UnifiPaginationInconsistent | wifiBroadcasts.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
65
65
  reconcile: (args: {
66
66
  news: WifiBroadcastProps;
67
- }) => Effect.Effect<WifiBroadcastAttributes, UnifiPaginationInconsistent | import("./policy.ts").UnifiWriteRefused | wifiBroadcasts.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
67
+ olds: WifiBroadcastProps | undefined;
68
+ output: WifiBroadcastAttributes | undefined;
69
+ session: {
70
+ note: (message: string) => Effect.Effect<void, never, never>;
71
+ };
72
+ }) => Effect.Effect<WifiBroadcastAttributes, import("./policy.ts").UnifiIdentityChanged | import("./policy.ts").UnifiLiveDriftedSinceDeploy | UnifiPaginationInconsistent | import("./policy.ts").UnifiUpdateDidNotConverge | import("./policy.ts").UnifiWriteRefused | wifiBroadcasts.UnifiNetworkOpError, import("effect/http/HttpClient").HttpClient>;
68
73
  delete: (args: {
69
74
  olds: WifiBroadcastProps;
70
75
  }) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, import("effect/http/HttpClient").HttpClient>;
@@ -1 +1 @@
1
- {"version":3,"file":"wifi-broadcast.d.ts","sourceRoot":"","sources":["../../src/unifi/wifi-broadcast.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,cAAc,MAAM,gDAAgD,CAAC;AACjF,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EACL,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EAExB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAAE,KAAK,2BAA2B,EAAW,MAAM,eAAe,CAAC;AAC1E,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAGlE,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC5F,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEpD,MAAM,WAAW,kBAAmB,SAAQ,QAAQ,CAClD,qBAAqB,EACrB,kBAAkB,EAClB,uBAAuB,EACvB,KAAK,EACL,iBAAiB,CAClB;CAAG;AAEJ,eAAO,MAAM,kBAAkB,qDAE7B,CAAC;AAEH,kGAAkG;AAClG,eAAO,MAAM,aAAa,OAAQ,MAAM,SAAS,kBAAkB,0FAClB,CAAC;AAElD,iGAAiG;AACjG,eAAO,MAAM,IAAI,EAAE,SAAS,CAC1B,kBAAkB,EAClB,cAAc,CAAC,qBAAqB,EACpC,uBAAuB,EACvB,cAAc,CAAC,yBAAyB,GAAG,2BAA2B,CAWvE,CAAC;AAEF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;CAAsB,CAAC;AAE5C,eAAO,MAAM,0BAA0B,+HACwD,CAAC"}
1
+ {"version":3,"file":"wifi-broadcast.d.ts","sourceRoot":"","sources":["../../src/unifi/wifi-broadcast.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,cAAc,MAAM,gDAAgD,CAAC;AACjF,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EACL,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EAExB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAAE,KAAK,2BAA2B,EAAW,MAAM,eAAe,CAAC;AAC1E,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAGlE,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC5F,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEpD,MAAM,WAAW,kBAAmB,SAAQ,QAAQ,CAClD,qBAAqB,EACrB,kBAAkB,EAClB,uBAAuB,EACvB,KAAK,EACL,iBAAiB,CAClB;CAAG;AAEJ,eAAO,MAAM,kBAAkB,qDAE7B,CAAC;AAEH,kGAAkG;AAClG,eAAO,MAAM,aAAa,OAAQ,MAAM,SAAS,kBAAkB,0FAClB,CAAC;AAElD,iGAAiG;AACjG,eAAO,MAAM,IAAI,EAAE,SAAS,CAC1B,kBAAkB,EAClB,cAAc,CAAC,qBAAqB,EACpC,uBAAuB,EACvB,cAAc,CAAC,yBAAyB,GAAG,2BAA2B,CAWvE,CAAC;AAEF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;CAAsB,CAAC;AAE5C,eAAO,MAAM,0BAA0B,+HACwD,CAAC"}
@@ -0,0 +1,33 @@
1
+ import * as HttpClient from 'effect/http/HttpClient';
2
+ /**
3
+ * One non-GET request the guard may let through. `tail` is the exact route below the configured
4
+ * base URL (`/v1/sites/<site>/networks/<id>`): the guard compares the request's origin and path to
5
+ * `baseUrl + tail` and requires an empty query and fragment (`isAllowed`).
6
+ */
7
+ export type AllowedWrite = {
8
+ readonly method: 'PUT';
9
+ readonly tail: string;
10
+ };
11
+ export declare class UnifiRefusedRequest extends Error {
12
+ readonly method: string;
13
+ /** PATH ONLY, never the full URL — see `requestPath` below. */
14
+ readonly path: string;
15
+ constructor(method: string,
16
+ /** PATH ONLY, never the full URL — see `requestPath` below. */
17
+ path: string, detail?: string);
18
+ }
19
+ /** @deprecated renamed `UnifiRefusedRequest`; kept so existing consumer imports do not break. */
20
+ export declare const UnifiNonGetRequest: typeof UnifiRefusedRequest;
21
+ export type UnifiNonGetRequest = UnifiRefusedRequest;
22
+ /**
23
+ * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
24
+ * for everything downstream, with the guarded version. Exported so `wire-guard.test.ts` can prove
25
+ * the mechanism against a bare `HttpClient`, without `CredentialsFromEnv` or any env var.
26
+ */
27
+ export declare const guardedHttpClient: (allow: ReadonlyArray<AllowedWrite>, baseUrl?: string) => import("effect/Layer").Layer<HttpClient.HttpClient, never, HttpClient.HttpClient>;
28
+ /**
29
+ * No write may pass; the posture of `read`/`diff`/`delete` and of every non-updatable family. A 3xx
30
+ * answer to a GET is refused here exactly as under the allow-list posture — see REDIRECTS above.
31
+ */
32
+ export declare const GetOnlyHttpClient: import("effect/Layer").Layer<HttpClient.HttpClient, never, HttpClient.HttpClient>;
33
+ //# sourceMappingURL=wire-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire-guard.d.ts","sourceRoot":"","sources":["../../src/unifi/wire-guard.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAC;AAKrD;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7E,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHvB,YACW,MAAM,EAAE,MAAM;IACvB,+DAA+D;IACtD,IAAI,EAAE,MAAM,EACrB,MAAM,SAAiC,EASxC;CACF;AAED,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,4BAAsB,CAAC;AACtD,MAAM,MAAM,kBAAkB,GAAG,mBAAmB,CAAC;AA0FrD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,UAAW,aAAa,CAAC,YAAY,CAAC,YAAY,MAAM,sFASrF,CAAC;AAkBF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,mFAAwB,CAAC"}
@@ -0,0 +1,90 @@
1
+ # `Unifi.Network` write scope — what the one PUT may and may not do
2
+
3
+ `docs/unifi.md` describes the three-way update. This page records the limits placed on it after
4
+ the red team rounds 1 and 2 of PR 377. The only intended consumer is ONE VLAN's
5
+ `ipv6Configuration`: a patch may SET only `ipv6Configuration` and REMOVE only `ipv6Configuration`;
6
+ everything else a whole-object `PUT` could reach is refused with a typed error that carries field
7
+ names only, never values.
8
+
9
+ | refusal | when |
10
+ | ------------------------------- | --------------------------------------------------------------------------- |
11
+ | `UnifiFieldNotSettable` | a patch sets any key but `ipv6Configuration` (`enabled`, `dhcpGuarding`, …) |
12
+ | `UnifiFieldNotRemovable` | a patch drops any key but `ipv6Configuration` |
13
+ | `UnifiManagementNetworkRefused` | the live object has `default: true` (the management LAN) |
14
+ | `UnifiImmutableFieldChanged` | a patch sets or drops `zoneId`, `vlanId`, `management`, `deviceId` |
15
+ | `UnifiIdentityChanged` | `olds` names another `siteId`/`networkId` than `news` |
16
+ | `UnifiUpdateWouldBeNoop` | the merged body equals live |
17
+
18
+ Every one of them fires after the live GET and before any PUT.
19
+
20
+ ⛔ **Why a removable allowlist.** A key in `patch.unset` is deleted from the PUT body, and the
21
+ controller reads an omitted `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration` as "off". So
22
+ leaving a field out of a declaration would turn it off on the console. Only `ipv6Configuration`
23
+ (the block this path exists for, and the revert target) may be removed. `network-scope.ts` holds
24
+ the lists (`SETTABLE_KEYS`, `REMOVABLE_KEYS`, `IMMUTABLE_KEYS`); widening them is a kit change.
25
+
26
+ ⚠️ **No version check: a hand edit between the read and the write is overwritten.** The PUT is the
27
+ live GET plus the patch, sent whole; the API offers no ETag or revision, so a change made on the
28
+ console between that GET and the PUT is replaced by the GET's copy. The window is one reconcile.
29
+
30
+ Non-USER `metadata.origin` is deliberately not handled: there is no rule for it yet.
31
+
32
+ ## The wire guard (`wire-guard.ts`)
33
+
34
+ ⛔ **The allowed PUT is anchored to the configured base URL**, not matched by suffix. The request
35
+ must share the base's origin, its path must be exactly `<base path>/v1/sites/<site>/networks/<id>`,
36
+ and it must carry no query string or fragment. `?force=true`, a nested
37
+ `/v1/sites/OTHER/v1/sites/s/networks/n`, a different base path and `//v1/...` all die before the
38
+ transport. `unifiHandlers` reads the base URL from the same `Credentials` the SDK uses; with none
39
+ the guard allows nothing.
40
+
41
+ ⛔ **`redirect: 'manual'` is set per request, on the calling fiber's `RequestInit`, for EVERY
42
+ guarded request — GETs included — and any 3xx answer dies with `UnifiRefusedRequest`, whatever the
43
+ method.** Effect 4.0.1 `layerMergedContext` lets an outer `FetchHttpClient.RequestInit` REPLACE one
44
+ the layer provides, so a layer-provided `redirect: 'manual'` is silently lost under any consumer
45
+ that sets its own `RequestInit`. The guard reads the fiber's value and merges `redirect: 'manual'`
46
+ over it, the `openbao/bao-http.ts` `overSocket` pattern. `wire-guard-anchor.test.ts` proves it
47
+ against a real `Bun.serve` answering 301/302/303/307/308.
48
+
49
+ A GET's redirect is refused for the same reason a PUT's is, under both postures: fetch would
50
+ follow it and re-send the request — still carrying the API-key header — to a URL the guard never
51
+ vetted. Before the 2026-10-10 fix only reconcile's GETs ran `manual` (a 3xx surfaced raw and failed
52
+ the SDK decode) while `read`/`diff` followed it silently; one rule everywhere now, fail closed.
53
+
54
+ ⛔ **Where a TLS `RequestInit` must live: on the CALLING FIBER**, provided around the program with
55
+ `Effect.provideService(FetchHttpClient.RequestInit, { tls: … })`, not inside the fetch layer's
56
+ build context (`FetchHttpClient.layer.pipe(Layer.provide(…))`). The guard re-provides `RequestInit` per
57
+ request, merged over the fiber's value, so an option set only inside the layer is overridden on every
58
+ guarded request, GETs included, and a consumer wired that way silently loses its TLS option (it
59
+ fails closed: the handshake is refused, nothing is sent insecurely).
60
+
61
+ ⛔ **Backstop: the response URL must equal the request URL.** If a consumer's own transform provides
62
+ `RequestInit` WITHOUT merging, `manual` is lost and fetch follows a redirect. The guard dies when
63
+ `res.url` differs from `HttpClientRequest.toUrl(res.request)`. The allowed-path check likewise reads
64
+ `toUrl`, so a `setUrlParam` query is seen and refused. Tests: `wire-guard-round2.test.ts`.
65
+
66
+ ⛔ **Refusal messages redact the cloud connector Console ID**: the path segment after `/consoles/`
67
+ becomes `<redacted>`.
68
+
69
+ ## The static check (`write-op-reference.test.ts`)
70
+
71
+ It harvests the SDK's real write-op export names straight from its own service modules (every
72
+ name starting `create`/`update`/`delete`/`patch`/`execute`/`remove`/`adopt`) and fails any file
73
+ that mentions the SDK's package specifier and contains one of those names as a token anywhere — an
74
+ identifier, a bracket key, a destructured binding, a re-export or a dynamic-import property access
75
+ all read the same way, so one scan catches every syntax form without parsing which one it is. Its
76
+ `WRITE_OP_ALLOWLIST` is exactly `network-update.ts` → `updateNetwork`; any other file naming that
77
+ op, or that file naming `createNetwork`/`deleteNetwork`, is an offense.
78
+
79
+ ## The PUT is attempted once
80
+
81
+ `updateNetwork` runs under `Retry.none`. The SDK's default policy would replay a write whose first
82
+ attempt may already have landed; a replay then runs against a controller state this reconcile never
83
+ read. A failure surfaces for the operator instead. Reads keep the default policy.
84
+
85
+ ## After a PUT that landed
86
+
87
+ `UnifiUpdateDidNotConverge` means the PUT LANDED and live now differs from the declaration. It is
88
+ not rolled back. The next deploy sees drift and answers `UnifiLiveDriftedSinceDeploy`, whose
89
+ message names both causes (a hand edit, or an earlier non-converging PUT). Recovery in both cases
90
+ is a re-import of the live object.
package/docs/unifi.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # UniFi Network — `@homeflare/alchemy/unifi`
2
2
 
3
- ⛔ **READ-ONLY, BY TIM'S RULE (2026-09-24).** `Unifi.Network` and `Unifi.FirewallZone` read and
4
- adopt; neither has a create, an update body or a working delete. `reconcile`/`delete` both fail
5
- with a typed `UnifiWriteRefused` naming the policy rather than calling any SDK write operation.
6
- Lifting the rule is a kit change — a PR that adds a write path, reviewed as one — not a flag a
7
- stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
3
+ ⛔ **ADOPT-ONLY, EXCEPT ONE WRITE (Tim 2026-09-24, lifted for one case 2026-10-10).** Every UniFi
4
+ family reads and adopts; `Unifi.Network` alone may UPDATE (a three-way whole-object PUT, below).
5
+ Create and delete, and every other family's update, fail with a typed `UnifiWriteRefused`. Widening
6
+ the rule is a kit change, not a flag a stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
8
7
 
9
8
  This page covers `Unifi.Network`/`Unifi.FirewallZone` only. Every other read-only family has its
10
9
  own doc, per family (`docs/unifi.md` would breach the 200-line cap otherwise): [`Unifi.DnsPolicy`](./unifi-dns-policy.md), [`Unifi.AclRule`/`Unifi.AclRuleOrdering`](./unifi-acl-rule.md),
@@ -59,7 +58,7 @@ credential, untouched by this family. Only reads were probed, by hand, outside t
59
58
  test suite — nothing in this PR's code exercises the vendor API live, and a write path would still
60
59
  need its own verification.
61
60
 
62
- ## Adopt is the default posture, and it never writes
61
+ ## Adopt is the default posture, and the one write is a three-way Network update
63
62
 
64
63
  `adopt(true)` is piped onto every resource by its convenience constructor (`network(...)`,
65
64
  `firewallZone(...)`) — H5. A cold read of a live match answers `Unowned(attrs)` (H1, the
@@ -68,36 +67,36 @@ objects carry no tag or metadata field this stack could stamp), and `adopt(true)
68
67
  a silent bind instead of an `OwnedBySomeoneElse` refusal — the deploy never needs `--adopt`.
69
68
 
70
69
  ⚠️ **A forced post-adoption reconcile still runs once (H6), and it still makes no write.**
71
- Beta.79 forces one `reconcile` call after every cold adoption whether or not `diff` said noop.
70
+ Beta.81 forces one `reconcile` call after every cold adoption whether or not `diff` said noop.
72
71
  `resource.ts`'s `reconcile` handles this the same way `discord/resource.ts` does: it reads the
73
- live object again and only refuses when it is missing (would need a create) or drifted (would
74
- need a write); an exact match returns the live attributes and calls nothing.
72
+ live object again; an exact match returns the live attributes and calls nothing, a missing object
73
+ refuses `create`, and drift refuses `update` unless the row has prior state (`olds` AND `output`)
74
+ and a `Unifi.Network` spec: then `update-reconcile.ts` refuses if live differs from `olds` (a hand
75
+ edit), else PUTs live-plus-the-`news≠olds`-patch (`network-update.ts`; a dropped key is removed).
76
+
77
+ The limits on that PUT (removable allowlist, management LAN, immutable fields, anchored path,
78
+ no retry) are in [`unifi-network-write.md`](./unifi-network-write.md).
75
79
 
76
80
  ## `list` answers `[]`
77
81
 
78
82
  `GET /v1/sites/{siteId}/networks` answers every network on the site. Adoption stays explicit —
79
83
  the same reasoning `Proxmox.User`'s and NetBox's own `list` give.
80
84
 
81
- ## Defense in depth: GET-only at the wire (2026-09-26)
82
-
83
- `policy.ts`'s `UnifiWriteRefused` stops write INTENT at `reconcile`/`delete` — this family calls
84
- no SDK write op anywhere today. `GetOnlyHttpClient` (`resource.ts`, barrel-exported so
85
- `homeflare-network`'s own import-layer guard can reuse it instead of re-implementing the same
86
- wrap), installed in `unifiHandlers`'s `withCredentials`, stops the same thing one layer lower, AT
87
- THE WIRE: it wraps whatever `HttpClient` the caller provides so ANY non-`GET` method dies with
88
- `UnifiNonGetRequest` (its message carries the request PATH only, never the host — a cloud
89
- connector's base URL embeds the account's Console ID) before the request reaches the transport —
90
- a backstop for a future resource file that, by mistake, called an SDK write operation directly.
91
- `write-op-reference.test.ts` is the matching static check: it harvests the SDK's real write-op
92
- export names straight from its own service modules (every name starting
93
- `create`/`update`/`delete`/`patch`/`execute`/`remove`/`adopt`) and fails any file that mentions
94
- the SDK's package specifier and contains one of those names as a token anywhere — an identifier, a
95
- bracket key, a destructured binding, a re-export or a dynamic-import property access all read the
96
- same way, so one scan catches every syntax form without parsing which one it is.
97
- `get-only-guard.test.ts` proves the wire guard's mechanism directly against a fake `HttpClient`,
98
- AND that `unifiHandlers` actually installs it (a handler-level probe, not just the mechanism in
99
- isolation — `network.test.ts`/`firewall-zone.test.ts` call the lower-level `unifiOperations(spec)`
100
- directly and never exercise `withCredentials` at all).
85
+ ## Defense in depth: GET-only at the wire, one per-row exception (2026-09-26, 2026-10-10)
86
+
87
+ `policy.ts`'s `UnifiWriteRefused` stops write INTENT at `reconcile`/`delete`. `wire-guard.ts`'s
88
+ `guardedHttpClient(allow)` (`GetOnlyHttpClient` = empty list; barrel-exported so `homeflare-network`'s
89
+ import-layer guard can reuse it), installed in `unifiHandlers`, stops it one layer lower, AT THE
90
+ WIRE: any non-`GET` dies with `UnifiRefusedRequest` (alias `UnifiNonGetRequest`; PATH only, never
91
+ the host — a cloud base URL embeds the Console ID) unless `(method, path)` matches the row's own
92
+ allow entry, `PUT /v1/sites/<siteId>/networks/<networkId>`, supplied only to `reconcile` and only
93
+ by a spec with `update`. The guard also sets fetch `redirect: 'manual'` on every request, GETs
94
+ included, and dies on any 3xx answer (fetch would re-send the request — a PUT with its body, a GET
95
+ with the API key — to a URL the guard never saw).
96
+ `wire-guard.test.ts` proves the guard directly against a fake `HttpClient`, AND that
97
+ `unifiHandlers` installs it (the family tests call `unifiOperations(spec)` and never exercise
98
+ `withCredentials`). The static check `write-op-reference.test.ts` is described in
99
+ [`unifi-network-write.md`](./unifi-network-write.md).
101
100
 
102
101
  ## Pagination helper
103
102
 
@@ -144,8 +143,7 @@ UniFi Network Integration API **10.4.57** (OpenAPI 3.1.0, 44 paths), via
144
143
  (`docs/distilled-interim.md`). Traps in the underlying API that this read-only family cannot
145
144
  trigger but a future write path must respect — whole-object `PUT`, ordering endpoints that
146
145
  replace the whole list, `removeDevice` unadopting and factory-resetting hardware — are recorded
147
- in the distilled package's own `README.md` and in the estate's `docs/unifi-api-notes.md`
148
- (outside this repo).
146
+ in the distilled package's own `README.md` (`packages/distilled-unifi-network`).
149
147
 
150
148
  ## Spec-version check against the live-measured console (2026-09-24)
151
149
 
@@ -192,8 +190,10 @@ attributed, declared or compared. Both declaration renderers and both `matches`
192
190
  `value == null` / `deepEqual(..., { stripNullish: true })` consistently, per `network-form.ts`'s
193
191
  own header. No departure found — nothing in this family needed changing.
194
192
 
195
- ⚠️ **Correction (2026-09-26):** the line above is no longer true as written. `resource.ts` now
196
- has exactly one `Effect.die` — `GetOnlyHttpClient`'s guard (see "Defense in depth" above) —
197
- classified in its own comment per distilled-doctrine's "classify every die/orDie": it raises a
198
- NEW defect for a failure mode outside every generated SDK operation's declared error union, never
199
- converts an existing typed failure the way `orDie` would. Still no `Effect.orDie` anywhere.
193
+ ⚠️ **Correction (2026-09-26, recounted 2026-10-10):** the line above is no longer true as written.
194
+ The family has exactly TWO `Effect.die` call sites, both in `wire-guard.ts`'s guard (see "Defense
195
+ in depth" above): the request-side refusal of a non-GET off the row's allow list, and the
196
+ response-side refusal of a 3xx answer. Each raises a NEW defect for a failure mode outside every
197
+ generated SDK operation's declared error union, per distilled-doctrine's "classify every
198
+ die/orDie" — neither converts an existing typed failure the way `orDie` would. Still no
199
+ `Effect.orDie` anywhere.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -17,7 +17,14 @@ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
17
17
  export const FAKE_BASE = 'https://unifi.example.com/proxy/network/integration';
18
18
  export const FAKE_KEY = 'placeholder-unifi-key';
19
19
 
20
- export type Seen = { readonly method: string; readonly path: string };
20
+ export type Seen = {
21
+ readonly method: string;
22
+ readonly path: string;
23
+ /** Parsed JSON request body, when one was sent. */
24
+ readonly body?: unknown;
25
+ /** The `redirect` mode fetch was handed (`FetchHttpClient.RequestInit`), when set. */
26
+ readonly redirect?: NonNullable<RequestInit['redirect']>;
27
+ };
21
28
 
22
29
  /**
23
30
  * Plain-text failure. `protocol.ts`'s `errorEnvelope` also decodes a documented JSON shape
@@ -42,7 +49,13 @@ export const fakeUnifi = (route: (method: string, url: URL) => Response) => {
42
49
  const request =
43
50
  input instanceof Request ? new Request(input, init) : new Request(String(input), init);
44
51
  const url = new URL(request.url);
45
- seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
52
+ const text = request.method === 'GET' ? '' : await request.clone().text();
53
+ seen.push({
54
+ method: request.method,
55
+ path: `${url.pathname}${url.search}`,
56
+ ...(text === '' ? {} : { body: JSON.parse(text) as unknown }),
57
+ ...(init?.redirect === undefined ? {} : { redirect: init.redirect }),
58
+ });
46
59
  return route(request.method, url);
47
60
  }) as typeof globalThis.fetch;
48
61
  return { fetch, seen };