@homeflare/alchemy 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/unifi/acl-rule-ordering.d.ts +6 -1
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
- package/dist/unifi/acl-rule.d.ts +6 -1
- package/dist/unifi/acl-rule.d.ts.map +1 -1
- package/dist/unifi/dns-policy.d.ts +6 -1
- package/dist/unifi/dns-policy.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +4 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
- package/dist/unifi/firewall-policy.d.ts +6 -1
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +6 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +5 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +339 -86
- package/dist/unifi/index.js.map +10 -6
- package/dist/unifi/network-scope.d.ts +31 -0
- package/dist/unifi/network-scope.d.ts.map +1 -0
- package/dist/unifi/network-update.d.ts +44 -0
- package/dist/unifi/network-update.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +32 -12
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/policy.d.ts +89 -1
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +17 -43
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +6 -1
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
- package/dist/unifi/update-reconcile.d.ts +31 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +6 -1
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
- package/dist/unifi/wire-guard.d.ts +33 -0
- package/dist/unifi/wire-guard.d.ts.map +1 -0
- package/docs/unifi-network-write.md +90 -0
- package/docs/unifi.md +36 -36
- package/package.json +1 -1
- package/src/unifi/fake-unifi.ts +15 -2
- package/src/unifi/handler-allow-news.test.ts +80 -0
- package/src/unifi/index.ts +21 -5
- package/src/unifi/network-scope.test.ts +203 -0
- package/src/unifi/network-scope.ts +75 -0
- package/src/unifi/network-update.test.ts +250 -0
- package/src/unifi/network-update.ts +95 -0
- package/src/unifi/network.test.ts +27 -1
- package/src/unifi/network.ts +56 -12
- package/src/unifi/policy.ts +128 -10
- package/src/unifi/resource.test.ts +15 -3
- package/src/unifi/resource.ts +64 -105
- package/src/unifi/update-reconcile.ts +126 -0
- package/src/unifi/wire-guard-anchor.test.ts +198 -0
- package/src/unifi/wire-guard-round2.test.ts +120 -0
- package/src/unifi/wire-guard.test.ts +218 -0
- package/src/unifi/wire-guard.ts +192 -0
- package/src/unifi/write-op-reference.test.ts +38 -2
- package/src/unifi/get-only-guard.test.ts +0 -165
package/src/unifi/resource.ts
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One shape for every UniFi Network
|
|
3
|
-
*
|
|
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.
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* `reconcile`
|
|
15
|
-
*
|
|
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.
|
|
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
|
|
23
|
-
*
|
|
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 {
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
*
|
|
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>(
|
|
191
|
-
effect.
|
|
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
|
|
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
|
+
});
|