@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,28 +1,27 @@
1
1
  /**
2
- * One shape for every UniFi Network read-only object — `Unifi.Network`, `Unifi.FirewallZone`,
3
- * and whatever import adds next.
2
+ * One shape for every UniFi Network object — `Unifi.Network`, `Unifi.FirewallZone`, and whatever
3
+ * import adds next. ADOPT-ONLY, except the one `update` a spec may opt into (`Unifi.Network`).
4
4
  *
5
5
  * ★ MIRRORS `../discord/resource.ts` (S7-S10, H1, H5), NOT `../netbox/resource.ts`. Both are
6
6
  * "marker-less API" families (S8): UniFi Network objects carry no tag or metadata field this
7
7
  * stack could stamp as ownership, so a cold read of a match is `Unowned(attrs)` exactly like
8
- * `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.79#read`, the reference implementation S8 and H1
8
+ * `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.81#read`, the reference implementation S8 and H1
9
9
  * both cite. `adopt(true)`, piped on by each resource's convenience constructor (H5), turns
10
10
  * that into a silent one-time bind instead of an `OwnedBySomeoneElse` refusal.
11
11
  *
12
- * ⛔ THE DIFFERENCE FROM EVERY OTHER FAMILY HERE: THERE IS NO `create`, `update` OR `destroy` IN
13
- * THIS SPEC AT ALL. Tim's rule, 2026-09-24 (`policy.ts`): UniFi and OPNsense are read-only. So
14
- * `reconcile` below can only ever refuse or report an exact match — it has no write path to
15
- * fall into by accident, and no resource file wired through this engine can reach one either.
12
+ * ⛔ THE POSTURE (`policy.ts`, Tim 2026-09-24, lifted for ONE case 2026-10-10): there is no
13
+ * `create` and no `destroy` in this spec at all, and `update` exists only when a spec carries an
14
+ * `update` block (`update-reconcile.ts`). `reconcile` is `updateReconcile`: refuse a create,
15
+ * report an exact match with zero writes, and otherwise either refuse or run the three-way PUT.
16
+ * A spec without `update` (every family but Network) can only ever refuse or report a match.
16
17
  *
17
18
  * ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.
18
- * Measured against `Plan.ts@v2.0.0-beta.79` (`forceUpdateAfterAdoption`, H6): beta.79 forces
19
+ * Measured against `Plan.ts@v2.0.0-beta.81` (`forceUpdateAfterAdoption`, H6): beta.81 forces
19
20
  * ONE reconcile call after every cold adoption, whether or not the diff said noop — so a
20
21
  * resource whose `reconcile` always failed would break the ordinary "adopt an unchanged
21
22
  * object" deploy, not just a real write. The fix, exactly like `discordOperations.reconcile`:
22
- * read the live object again, and refuse ONLY when it is missing (would need a create) or
23
- * drifted (would need a write to converge). An exact match returns the live attributes and
24
- * calls nothing — S10's "zero write calls when there is no drift" holds even under the forced
25
- * post-adoption call.
23
+ * read the live object again and return it on a match. S10's "zero write calls when there is
24
+ * no drift" holds even under the forced post-adoption call.
26
25
  *
27
26
  * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE —
28
27
  * same split netbox's `resource.ts` uses and for the same reason: `Effect.catchTag`'s
@@ -30,15 +29,21 @@
30
29
  * spec's `E` is only concrete at each resource file's own call site. Every OTHER failure (a
31
30
  * real 5xx, a network error, `Forbidden`) is NOT folded anywhere — it fails the plan loudly,
32
31
  * per the task's own rule: only a genuine not-found may mean absent.
32
+ *
33
+ * ★ The wire guard (`wire-guard.ts`) and the three-way reconcile (`update-reconcile.ts`) live in
34
+ * their own files; `read`/`diff`/`delete` run under `GetOnlyHttpClient`, only `reconcile` gets
35
+ * the per-row allow entry of a spec that declares `update`.
33
36
  */
34
37
  import { Unowned } from 'alchemy/AdoptPolicy';
35
38
  import { isResolved } from 'alchemy/Diff';
36
39
  import type { Input } from 'alchemy/Input';
37
- import { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
40
+ import { Credentials, CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
38
41
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
39
42
  import * as Effect from 'effect/Effect';
40
- import * as HttpClient from 'effect/http/HttpClient';
41
- import { UNIFI_READ_ONLY_POLICY, refuseWrite } from './policy.ts';
43
+ import type * as HttpClient from 'effect/http/HttpClient';
44
+ import { refuseWrite } from './policy.ts';
45
+ import { type ReconcileArgs, type UnifiUpdate, updateReconcile } from './update-reconcile.ts';
46
+ import { type AllowedWrite, guardedHttpClient } from './wire-guard.ts';
42
47
 
43
48
  /** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
44
49
  export type UnifiRequirements = HttpClient.HttpClient;
@@ -47,9 +52,9 @@ export type UnifiRequirements = HttpClient.HttpClient;
47
52
  * One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a
48
53
  * `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error
49
54
  * union, with `NotFound` already folded away) so the precise per-operation error union flows
50
- * through instead of being widened by hand here.
55
+ * through instead of being widened by hand here. `E2` is the error union of the optional write.
51
56
  */
52
- export type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {
57
+ export type UnifiSpec<Props extends object, Live, Attributes extends object, E, E2 = never> = {
53
58
  /** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
54
59
  readonly type: string;
55
60
  /** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
@@ -58,10 +63,12 @@ export type UnifiSpec<Props extends object, Live, Attributes extends object, E>
58
63
  readonly matches: (attributes: Attributes, props: Props) => boolean;
59
64
  /** For the refusal message and read-back failures. */
60
65
  readonly describe: (props: Props) => string;
66
+ /** Present only on a family allowed to write (`Unifi.Network`); see `update-reconcile.ts`. */
67
+ readonly update?: UnifiUpdate<Props, Live, E2>;
61
68
  };
62
69
 
63
- export const unifiOperations = <Props extends object, Live, Attributes extends object, E>(
64
- spec: UnifiSpec<Props, Live, Attributes, E>,
70
+ export const unifiOperations = <Props extends object, Live, Attributes extends object, E, E2>(
71
+ spec: UnifiSpec<Props, Live, Attributes, E, E2>,
65
72
  ) => {
66
73
  const read = (props: Props) =>
67
74
  spec
@@ -91,16 +98,13 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
91
98
  : ({ action: 'update' } as const);
92
99
  }),
93
100
 
94
- reconcile: (news: Props) =>
95
- Effect.gen(function* () {
96
- const live = yield* read(news);
97
- if (live === undefined) return yield* refuseWrite(spec.type, spec.describe(news), 'create');
98
- if (!spec.matches(live, news)) {
99
- return yield* refuseWrite(spec.type, spec.describe(news), 'update');
100
- }
101
- // Exact match: the object already is what is declared. Zero write calls (S10), correct
102
- // whether this is a routine deploy or beta.79's forced post-adoption reconcile (H6).
103
- return live;
101
+ /** Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset. */
102
+ reconcile: (news: Props, rest: Partial<Omit<ReconcileArgs<Props>, 'news'>> = {}) =>
103
+ updateReconcile(spec, {
104
+ news,
105
+ olds: rest.olds,
106
+ output: rest.output,
107
+ note: rest.note ?? (() => Effect.void),
104
108
  }),
105
109
 
106
110
  destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),
@@ -108,94 +112,49 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
108
112
  };
109
113
 
110
114
  /**
111
- * B0a / T12 DEFENSE IN DEPTH. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at
112
- * `reconcile`/`destroy` — every path this engine exposes already returns that refusal instead of
113
- * calling an SDK write op. This guard stops the same thing one layer lower, AT THE WIRE: it wraps
114
- * whatever `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in
115
- * production, `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same
116
- * "wrap, don't replace" seam) so a future resource file that, by mistake, called an SDK write
117
- * operation directly — bypassing `unifiOperations` entirely — would still never reach the vendor
118
- * API. `write-op-reference.test.ts` proves no such call exists today; this is what stops one from
119
- * ever taking effect if one is ever added.
120
- *
121
- * ⚠️ `Effect.die`, NOT A TYPED FAILURE — classified per distilled-doctrine's "classify every
122
- * die/orDie". Every generated SDK operation (`networks.getNetworkDetails`, …) declares its OWN
123
- * closed error union from the pinned OpenAPI spec, and generated files are never hand-edited to
124
- * add a member to it. A typed `Effect.fail` here would be a failure mode absent from every op's
125
- * declared type the moment it occurred — a type-system lie. A defect is the sound way to add a
126
- * NEW "this must be structurally impossible" failure underneath types this package does not
127
- * own, exactly like `openbao`'s `refuse()` helpers' `Effect.die(new Error(...))` for a
128
- * configuration move that should never be reachable.
129
- */
130
- export class UnifiNonGetRequest extends Error {
131
- constructor(
132
- readonly method: string,
133
- /** PATH ONLY, never the full URL — see `requestPath` below. */
134
- readonly path: string,
135
- ) {
136
- super(
137
- `UniFi HttpClient guard: refused ${method} ${path} -- ${UNIFI_READ_ONLY_POLICY}. This is a ` +
138
- 'defect, not a recoverable condition: something under src/unifi tried to send a non-GET ' +
139
- "request, which policy.ts's per-operation refusal should already have made impossible. " +
140
- 'Fix the kit code that produced this request.',
141
- );
142
- this.name = 'UnifiNonGetRequest';
143
- }
144
- }
145
-
146
- /**
147
- * ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID
148
- * (`docs/unifi.md`'s Credentials section, T3) — a defect message is still a message that can
149
- * reach a log, a CI failure body or a pasted error report, so it gets the same treatment as
150
- * `redactUrls` gives the import layer's own error text. Falls back to a fixed placeholder rather
151
- * than the raw string on a parse failure, so a malformed URL can never leak through unredacted.
152
- */
153
- const requestPath = (url: string): string => {
154
- try {
155
- return new URL(url).pathname;
156
- } catch {
157
- return '<unparseable request url>';
158
- }
159
- };
160
-
161
- const guardGetOnly = (client: HttpClient.HttpClient): HttpClient.HttpClient =>
162
- client.pipe(
163
- HttpClient.mapRequestEffect((request) =>
164
- request.method === 'GET'
165
- ? Effect.succeed(request)
166
- : Effect.die(new UnifiNonGetRequest(request.method, requestPath(request.url))),
167
- ),
168
- );
169
-
170
- /**
171
- * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
172
- * for everything downstream, with `guardGetOnly`'s wrapped version (`HttpClient.layerMergedContext`
173
- * — see the guard above). Exported so `get-only-guard.test.ts` can prove the mechanism directly,
174
- * against a bare `HttpClient` service, without needing `CredentialsFromEnv` or any env var at all.
175
- */
176
- export const GetOnlyHttpClient = HttpClient.layerMergedContext(
177
- Effect.map(HttpClient.HttpClient, guardGetOnly),
178
- );
179
-
180
- /**
181
- * The four provider handlers for a spec'd read-only UniFi object, wired once.
115
+ * The four provider handlers for a spec'd UniFi object, wired once.
182
116
  *
183
117
  * ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
184
118
  * adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
185
119
  */
186
- export const unifiHandlers = <Props extends object, Live, Attributes extends object, E>(
187
- spec: UnifiSpec<Props, Live, Attributes, E>,
120
+ export const unifiHandlers = <Props extends object, Live, Attributes extends object, E, E2>(
121
+ spec: UnifiSpec<Props, Live, Attributes, E, E2>,
188
122
  ) => {
189
123
  const ops = unifiOperations(spec);
190
- const withCredentials = <A, Err>(effect: Effect.Effect<A, Err, UnifiNetworkOpContext>) =>
191
- effect.pipe(Effect.provide(GetOnlyHttpClient), Effect.provide(CredentialsFromEnv));
124
+ const withCredentials = <A, Err>(
125
+ effect: Effect.Effect<A, Err, UnifiNetworkOpContext>,
126
+ allow: ReadonlyArray<AllowedWrite> = [],
127
+ ) =>
128
+ Effect.gen(function* () {
129
+ // ⛔ The guard anchors the allowed PUT to the CONFIGURED base URL (`wire-guard.ts`), so it is
130
+ // read from the same credentials the SDK uses.
131
+ const { apiBaseUrl } = yield* yield* Credentials;
132
+ return yield* effect.pipe(Effect.provide(guardedHttpClient(allow, apiBaseUrl)));
133
+ }).pipe(Effect.provide(CredentialsFromEnv)) as Effect.Effect<A, Err, HttpClient.HttpClient>;
134
+
192
135
  return {
193
136
  list: () => Effect.succeed([]),
194
137
  read: (args: { olds: Props; output: Attributes | undefined }) =>
195
138
  withCredentials(ops.readHandler(args)),
196
139
  diff: (args: { news: Input<Props>; output: Attributes | undefined }) =>
197
140
  withCredentials(ops.diff(args.news, args.output)),
198
- reconcile: (args: { news: Props }) => withCredentials(ops.reconcile(args.news)),
141
+ // ⛔ ONLY `reconcile` gets the per-row allow entry, and only when the spec declares `update`.
142
+ reconcile: (args: {
143
+ news: Props;
144
+ olds: Props | undefined;
145
+ output: Attributes | undefined;
146
+ session: { note: (message: string) => Effect.Effect<void, never, never> };
147
+ }) =>
148
+ withCredentials(
149
+ ops.reconcile(args.news, {
150
+ olds: args.olds,
151
+ output: args.output,
152
+ note: (message) => args.session.note(message),
153
+ }),
154
+ // ⛔ From `news` (the ids the write will use), never `olds`; reconcile also refuses when
155
+ // `olds` names another object (`update-reconcile.ts`).
156
+ spec.update ? [spec.update.allowedWrite(args.news)] : [],
157
+ ),
199
158
  delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),
200
159
  };
201
160
  };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The three-way `reconcile` every updatable `Unifi.*` family shares (today: `Unifi.Network`).
3
+ *
4
+ * ⛔ THREE INPUTS, NOT TWO. `news` is the declaration now, `olds` is the declaration the LAST
5
+ * DEPLOY wrote (Alchemy state), `live` is the raw object read right now. The PUT body is built
6
+ * from `live` plus ONLY the fields where `news` differs from `olds` (`computePatch`) — never
7
+ * from `news` alone. So (HIGH-2) a hand edit on a field the declaration never touched is carried
8
+ * through unchanged instead of reverted, and a hand edit on ANY declared field is refused
9
+ * outright (step 4) rather than overwritten.
10
+ *
11
+ * ★ `unset` IS WHAT MAKES A REVERT REAL (HIGH-1). Deleting an override from a declaration makes
12
+ * `news[k]` absent while `olds[k]` is set; a patch that only carried `set` would see "nothing
13
+ * changed" and the revert would never write. A key present in `olds` and null/absent in `news`
14
+ * lands in `unset`, and the family's `write` removes it from the body.
15
+ *
16
+ * ⛔ FIRST DEPLOY IS ADOPT-ONLY BY CONSTRUCTION (F1, LOW-8). Without BOTH `olds` and `output` there
17
+ * is no last-deployed baseline to patch against, so the write refuses. That also covers beta.81's
18
+ * forced post-adoption reconcile (H6): on a match (step 2) it returns live with zero writes.
19
+ * `olds` must also name the SAME object as `news` (`UnifiIdentityChanged`).
20
+ *
21
+ * ⛔ SCOPE (`update.checkScope`) runs before the note and any request: the family decides what a
22
+ * patch may touch (`network-scope.ts`). An omitted optional block is turned OFF by a whole-object
23
+ * PUT, so removing a key is allowed only for an explicit allowlist.
24
+ *
25
+ * ⚠️ NOTES AND ERRORS CARRY FIELD NAMES ONLY, NEVER VALUES.
26
+ */
27
+ import { deepEqual } from 'alchemy/Diff';
28
+ import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
29
+ import * as Effect from 'effect/Effect';
30
+ import type { FieldDrift } from './drift.ts';
31
+ import {
32
+ UnifiIdentityChanged,
33
+ UnifiLiveDriftedSinceDeploy,
34
+ UnifiUpdateDidNotConverge,
35
+ refuseWrite,
36
+ } from './policy.ts';
37
+ import type { UnifiSpec } from './resource.ts';
38
+ import type { AllowedWrite } from './wire-guard.ts';
39
+
40
+ export type Patch<Props> = { set: Partial<Props>; unset: ReadonlyArray<keyof Props> };
41
+
42
+ /** The write half of a spec: everything needed to PUT one object, and nothing to create/delete. */
43
+ export type UnifiUpdate<Props extends object, Live, E2> = {
44
+ /** The one PUT the wire guard may pass for this row (path pinned to the row's own ids). */
45
+ readonly allowedWrite: (props: Props) => AllowedWrite;
46
+ /** Declarable keys; never the identity keys. */
47
+ readonly patchKeys: ReadonlyArray<keyof Props>;
48
+ readonly driftOf: (live: Live, props: Props) => ReadonlyArray<FieldDrift>;
49
+ /** Refuses (typed, before any note or request) a patch outside this family's write scope. */
50
+ readonly checkScope: (live: Live, patch: Patch<Props>, props: Props) => Effect.Effect<void, E2>;
51
+ readonly write: (
52
+ live: Live,
53
+ patch: Patch<Props>,
54
+ props: Props,
55
+ ) => Effect.Effect<Live, E2, UnifiNetworkOpContext>;
56
+ };
57
+
58
+ /** Keys whose `news` value differs from `olds`: set when still declared, unset when dropped. */
59
+ export const computePatch = <Props extends object>(
60
+ news: Props,
61
+ olds: Props,
62
+ patchKeys: ReadonlyArray<keyof Props>,
63
+ ): Patch<Props> => {
64
+ const set: Partial<Props> = {};
65
+ const unset: Array<keyof Props> = [];
66
+ for (const key of patchKeys) {
67
+ if (deepEqual(news[key], olds[key], { stripNullish: true })) continue;
68
+ if (news[key] == null) unset.push(key);
69
+ else set[key] = news[key];
70
+ }
71
+ return { set, unset };
72
+ };
73
+
74
+ export type ReconcileArgs<Props extends object> = {
75
+ readonly news: Props;
76
+ readonly olds: Props | undefined;
77
+ readonly output: unknown;
78
+ readonly note: (message: string) => Effect.Effect<void>;
79
+ };
80
+
81
+ export const updateReconcile = <Props extends object, Live, Attributes extends object, E, E2>(
82
+ spec: UnifiSpec<Props, Live, Attributes, E, E2>,
83
+ { news, olds, output, note }: ReconcileArgs<Props>,
84
+ ) =>
85
+ Effect.gen(function* () {
86
+ const identity = spec.describe(news);
87
+ const live = yield* spec.fetchLive(news);
88
+ if (live === undefined) return yield* refuseWrite(spec.type, identity, 'create');
89
+ const attrs = spec.attributes(live, news);
90
+ // Exact match: zero writes, correct for a routine deploy and the forced post-adoption call.
91
+ if (spec.matches(attrs, news)) return attrs;
92
+ const update = spec.update;
93
+ if (update === undefined || olds === undefined || output === undefined) {
94
+ return yield* refuseWrite(spec.type, identity, 'update');
95
+ }
96
+ // ⛔ `olds` is the baseline for THIS object only; another site or network id is no baseline.
97
+ const previous = spec.describe(olds);
98
+ if (previous !== identity) {
99
+ return yield* new UnifiIdentityChanged({ type: spec.type, identity, previous });
100
+ }
101
+ const drifted = update.driftOf(live, olds).map((d) => d.field);
102
+ if (drifted.length > 0) {
103
+ return yield* new UnifiLiveDriftedSinceDeploy({
104
+ type: spec.type,
105
+ identity,
106
+ fields: drifted,
107
+ });
108
+ }
109
+ const patch = computePatch(news, olds, update.patchKeys);
110
+ if (Object.keys(patch.set).length === 0 && patch.unset.length === 0) {
111
+ return yield* refuseWrite(spec.type, identity, 'update');
112
+ }
113
+ yield* update.checkScope(live, patch, news);
114
+ yield* note(
115
+ `${spec.type} ${identity}: PUT changes [${Object.keys(patch.set).join(', ')}] ` +
116
+ `removes [${patch.unset.map(String).join(', ')}]`,
117
+ );
118
+ const written = yield* update.write(live, patch, news);
119
+ const writtenAttrs = spec.attributes(written, news);
120
+ if (spec.matches(writtenAttrs, news)) return writtenAttrs;
121
+ return yield* new UnifiUpdateDidNotConverge({
122
+ type: spec.type,
123
+ identity,
124
+ fields: update.driftOf(written, news).map((d) => d.field),
125
+ });
126
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The wire guard's ANCHORING, redirect handling and redaction — red team round 1 on PR 377.
3
+ * The first block runs a real `Bun.serve` through the real `FetchHttpClient` (no fake fetch), because
4
+ * the redirect finding is about what real fetch does with the fiber's `RequestInit`.
5
+ */
6
+ import { afterAll, describe, expect, test } from 'bun:test';
7
+ import * as Cause from 'effect/Cause';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Exit from 'effect/Exit';
10
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
11
+ import * as HttpClient from 'effect/http/HttpClient';
12
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
13
+ import { networkAllowedWrite } from './network.ts';
14
+ import { GetOnlyHttpClient, UnifiRefusedRequest, guardedHttpClient } from './wire-guard.ts';
15
+
16
+ const hits: Array<{ method: string; path: string }> = [];
17
+ let status = 307;
18
+ const server = Bun.serve({
19
+ port: 0,
20
+ hostname: '127.0.0.1',
21
+ fetch(req) {
22
+ const u = new URL(req.url);
23
+ hits.push({ method: req.method, path: u.pathname + u.search });
24
+ return u.pathname.endsWith('/networks/n')
25
+ ? new Response(null, { status, headers: { location: '/elsewhere/v1/sites/s/networks/O' } })
26
+ : Response.json({ ok: true });
27
+ },
28
+ });
29
+ afterAll(() => server.stop(true));
30
+
31
+ const ROOT = '/proxy/network/integration';
32
+ const PORT = server.port as number;
33
+ const BASE = `http://127.0.0.1:${PORT}${ROOT}`;
34
+ const ALLOW = [networkAllowedWrite({ siteId: 's', networkId: 'n' } as never)];
35
+
36
+ const call = (
37
+ method: string,
38
+ url: string,
39
+ base: string | null = BASE,
40
+ outer: FetchHttpClient.RequestInit['Service'] = {},
41
+ ) =>
42
+ Effect.runPromiseExit(
43
+ Effect.gen(function* () {
44
+ const client = yield* HttpClient.HttpClient;
45
+ return yield* client.execute(
46
+ HttpClientRequest.make(method as never)(url).pipe(
47
+ HttpClientRequest.bodyText('{"x":1}', 'application/json'),
48
+ ),
49
+ );
50
+ }).pipe(
51
+ Effect.provide(guardedHttpClient(ALLOW, base ?? undefined)),
52
+ // ⛔ An OUTER RequestInit on the calling fiber: `layerMergedContext` lets it replace the guard's.
53
+ Effect.provideService(FetchHttpClient.RequestInit, outer),
54
+ Effect.provide(FetchHttpClient.layer),
55
+ ),
56
+ );
57
+
58
+ const refusal = (exit: Exit.Exit<unknown, unknown>) => {
59
+ expect(Exit.isFailure(exit)).toBe(true);
60
+ if (!Exit.isFailure(exit)) throw new Error('unreachable');
61
+ expect(Cause.hasDies(exit.cause)).toBe(true);
62
+ const defect = Cause.squash(exit.cause);
63
+ expect(defect).toBeInstanceOf(UnifiRefusedRequest);
64
+ return defect as UnifiRefusedRequest;
65
+ };
66
+
67
+ describe('redirect: manual survives an OUTER FetchHttpClient.RequestInit', () => {
68
+ test('outer RequestInit, real 307 => exactly one request, then the refusal', async () => {
69
+ status = 307;
70
+ hits.length = 0;
71
+ const exit = await call('PUT', `${BASE}/v1/sites/s/networks/n`, BASE, {
72
+ keepalive: false,
73
+ });
74
+ expect(refusal(exit).message).toContain('307 redirect');
75
+ expect(hits).toEqual([{ method: 'PUT', path: `${ROOT}/v1/sites/s/networks/n` }]);
76
+ });
77
+
78
+ // ⚠️ Mutation gap: narrowing the 3xx check to 307 only. Real fetch with redirect:manual returns
79
+ // each of these as-is, and the guard must refuse every one.
80
+ test.each([301, 302, 303, 307, 308])('a %d answer is refused', async (code) => {
81
+ status = code;
82
+ hits.length = 0;
83
+ refusal(await call('PUT', `${BASE}/v1/sites/s/networks/n`));
84
+ expect(hits).toHaveLength(1);
85
+ });
86
+
87
+ test('a 3xx answer to HEAD is refused too (HEAD is not GET)', async () => {
88
+ status = 302;
89
+ // HEAD is not on the allow list, so it dies at the request guard before any redirect exists.
90
+ hits.length = 0;
91
+ refusal(await call('HEAD', `${BASE}/v1/sites/s/networks/n`));
92
+ expect(hits).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe('a 3xx answer to a GET is refused under BOTH postures (2026-10-10 symmetry fix)', () => {
97
+ // ⛔ ROUND-2 FINDING: `redirect: 'manual'` was scoped to a non-empty allow list, so reconcile's
98
+ // OWN GETs surfaced a raw 3xx and failed the SDK decode, while read/diff's GETs silently
99
+ // followed it. Both postures must behave identically — and a followed GET re-sends the request,
100
+ // API-key header included, to a URL the guard never vetted — so both refuse it, never follow.
101
+ // Real fetch, no fake: the point is what fetch does with the fiber's `RequestInit`.
102
+ test.each([
103
+ ['GetOnlyHttpClient (read/diff/delete)', () => GetOnlyHttpClient],
104
+ ['the row allow list (reconcile)', () => guardedHttpClient(ALLOW, BASE)],
105
+ ])('%s: refused, never followed', async (_n, layer) => {
106
+ status = 301;
107
+ hits.length = 0;
108
+ const exit = await Effect.runPromiseExit(
109
+ Effect.gen(function* () {
110
+ const client = yield* HttpClient.HttpClient;
111
+ return yield* client.execute(HttpClientRequest.get(`${BASE}/v1/sites/s/networks/n`));
112
+ }).pipe(Effect.provide(layer()), Effect.provide(FetchHttpClient.layer)),
113
+ );
114
+ const defect = refusal(exit);
115
+ expect(defect.method).toBe('GET');
116
+ expect(defect.message).toContain('301 redirect');
117
+ // Exactly ONE request reached the server: the redirect was never followed.
118
+ expect(hits).toEqual([{ method: 'GET', path: `${ROOT}/v1/sites/s/networks/n` }]);
119
+ });
120
+ });
121
+
122
+ describe('the allowed path is anchored to the configured base URL', () => {
123
+ test('the exact route passes', async () => {
124
+ status = 200;
125
+ hits.length = 0;
126
+ expect(Exit.isSuccess(await call('PUT', `${BASE}/v1/sites/s/networks/n`))).toBe(true);
127
+ expect(hits).toHaveLength(1);
128
+ });
129
+
130
+ test.each([
131
+ ['a query string', `${ROOT}/v1/sites/s/networks/n?force=true`],
132
+ ['a nested route', `${ROOT}/v1/sites/OTHER/v1/sites/s/networks/n`],
133
+ ['a doubled slash', `${ROOT}//v1/sites/s/networks/n`],
134
+ ['a doubled slash at the root', `//v1/sites/s/networks/n`],
135
+ ['a trailing slash', `${ROOT}/v1/sites/s/networks/n/`],
136
+ ['a fragment', `${ROOT}/v1/sites/s/networks/n#frag`],
137
+ ['a different base path', `/totally/different/v1/sites/s/networks/n`],
138
+ ])('%s is refused before the transport', async (_n, path) => {
139
+ status = 200;
140
+ hits.length = 0;
141
+ refusal(await call('PUT', `http://127.0.0.1:${PORT}${path}`));
142
+ expect(hits).toEqual([]);
143
+ });
144
+
145
+ test('another origin is refused even with the right path', async () => {
146
+ hits.length = 0;
147
+ refusal(await call('PUT', `http://127.0.0.1:${PORT + 1}${ROOT}/v1/sites/s/networks/n`));
148
+ expect(hits).toEqual([]);
149
+ });
150
+
151
+ test('no configured base URL allows nothing (fail closed)', async () => {
152
+ hits.length = 0;
153
+ refusal(await call('PUT', `${BASE}/v1/sites/s/networks/n`, null));
154
+ expect(hits).toEqual([]);
155
+ });
156
+
157
+ // ⚠️ Mutation gap: a regex-escaped id. A `.` in an id must be literal, never "any character".
158
+ test('an id is compared as text, never as a pattern', async () => {
159
+ status = 200;
160
+ const dotted = [networkAllowedWrite({ siteId: 's', networkId: 'a.c' } as never)];
161
+ const run = (id: string) =>
162
+ Effect.runPromiseExit(
163
+ Effect.gen(function* () {
164
+ const client = yield* HttpClient.HttpClient;
165
+ return yield* client.execute(
166
+ HttpClientRequest.put(`${BASE}/v1/sites/s/networks/${id}`).pipe(
167
+ HttpClientRequest.bodyText('{}', 'application/json'),
168
+ ),
169
+ );
170
+ }).pipe(
171
+ Effect.provide(guardedHttpClient(dotted, BASE)),
172
+ Effect.provide(FetchHttpClient.layer),
173
+ ),
174
+ );
175
+ expect(Exit.isSuccess(await run('a.c'))).toBe(true);
176
+ refusal(await run('abc'));
177
+ });
178
+ });
179
+
180
+ describe('refusal messages redact the cloud connector Console ID', () => {
181
+ test('the segment after consoles/ never reaches the message or the path field', async () => {
182
+ const cloud =
183
+ 'https://api.ui.com/v1/connector/consoles/SECRET-CONSOLE-ID/proxy/network/integration';
184
+ const exit = await Effect.runPromiseExit(
185
+ Effect.gen(function* () {
186
+ const client = yield* HttpClient.HttpClient;
187
+ return yield* client.execute(HttpClientRequest.put(`${cloud}/v1/sites/s/networks/OTHER`));
188
+ }).pipe(
189
+ Effect.provide(guardedHttpClient(ALLOW, cloud)),
190
+ Effect.provide(FetchHttpClient.layer),
191
+ ),
192
+ );
193
+ const defect = refusal(exit);
194
+ expect(defect.message).not.toContain('SECRET-CONSOLE-ID');
195
+ expect(defect.path).not.toContain('SECRET-CONSOLE-ID');
196
+ expect(defect.path).toContain('/consoles/<redacted>/proxy/network/integration');
197
+ });
198
+ });