@homeflare/alchemy 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  2. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  3. package/dist/unifi/acl-rule.d.ts +6 -1
  4. package/dist/unifi/acl-rule.d.ts.map +1 -1
  5. package/dist/unifi/dns-policy.d.ts +6 -1
  6. package/dist/unifi/dns-policy.d.ts.map +1 -1
  7. package/dist/unifi/fake-unifi.d.ts +4 -0
  8. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  9. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  10. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  11. package/dist/unifi/firewall-policy.d.ts +6 -1
  12. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  13. package/dist/unifi/firewall-zone.d.ts +6 -1
  14. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  15. package/dist/unifi/index.d.ts +5 -3
  16. package/dist/unifi/index.d.ts.map +1 -1
  17. package/dist/unifi/index.js +339 -86
  18. package/dist/unifi/index.js.map +10 -6
  19. package/dist/unifi/network-scope.d.ts +31 -0
  20. package/dist/unifi/network-scope.d.ts.map +1 -0
  21. package/dist/unifi/network-update.d.ts +44 -0
  22. package/dist/unifi/network-update.d.ts.map +1 -0
  23. package/dist/unifi/network.d.ts +32 -12
  24. package/dist/unifi/network.d.ts.map +1 -1
  25. package/dist/unifi/policy.d.ts +89 -1
  26. package/dist/unifi/policy.d.ts.map +1 -1
  27. package/dist/unifi/resource.d.ts +17 -43
  28. package/dist/unifi/resource.d.ts.map +1 -1
  29. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  30. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  31. package/dist/unifi/update-reconcile.d.ts +31 -0
  32. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  33. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  34. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  35. package/dist/unifi/wire-guard.d.ts +33 -0
  36. package/dist/unifi/wire-guard.d.ts.map +1 -0
  37. package/docs/unifi-network-write.md +90 -0
  38. package/docs/unifi.md +36 -36
  39. package/package.json +1 -1
  40. package/src/unifi/fake-unifi.ts +15 -2
  41. package/src/unifi/handler-allow-news.test.ts +80 -0
  42. package/src/unifi/index.ts +21 -5
  43. package/src/unifi/network-scope.test.ts +203 -0
  44. package/src/unifi/network-scope.ts +75 -0
  45. package/src/unifi/network-update.test.ts +250 -0
  46. package/src/unifi/network-update.ts +95 -0
  47. package/src/unifi/network.test.ts +27 -1
  48. package/src/unifi/network.ts +56 -12
  49. package/src/unifi/policy.ts +128 -10
  50. package/src/unifi/resource.test.ts +15 -3
  51. package/src/unifi/resource.ts +64 -105
  52. package/src/unifi/update-reconcile.ts +126 -0
  53. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  54. package/src/unifi/wire-guard-round2.test.ts +120 -0
  55. package/src/unifi/wire-guard.test.ts +218 -0
  56. package/src/unifi/wire-guard.ts +192 -0
  57. package/src/unifi/write-op-reference.test.ts +38 -2
  58. package/src/unifi/get-only-guard.test.ts +0 -165
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Red team round 2 on PR 377, against the wire guard: an OUTER `RequestInit` that tries to turn
3
+ * redirects back on (F2), a consumer transform that replaces `manual` (F3, the response-URL
4
+ * backstop) and a query parameter added with `setUrlParam` (F5). Real `Bun.serve` + real fetch.
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 Layer from 'effect/Layer';
11
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
12
+ import * as HttpClient from 'effect/http/HttpClient';
13
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
14
+ import { networkAllowedWrite } from './network.ts';
15
+ import { UnifiRefusedRequest, guardedHttpClient } from './wire-guard.ts';
16
+
17
+ const hits: Array<{ method: string; path: string }> = [];
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, {
26
+ status: 307,
27
+ headers: { location: '/elsewhere/v1/sites/s/networks/O' },
28
+ })
29
+ : Response.json({ ok: true });
30
+ },
31
+ });
32
+ afterAll(() => server.stop(true));
33
+
34
+ const BASE = `http://127.0.0.1:${server.port}/proxy/network/integration`;
35
+ const ALLOW = [networkAllowedWrite({ siteId: 's', networkId: 'n' } as never)];
36
+ const PUT = () =>
37
+ HttpClientRequest.put(`${BASE}/v1/sites/s/networks/n`).pipe(
38
+ HttpClientRequest.bodyText('{"x":1}', 'application/json'),
39
+ );
40
+
41
+ const defectOf = (exit: Exit.Exit<unknown, unknown>) => {
42
+ expect(Exit.isFailure(exit)).toBe(true);
43
+ if (!Exit.isFailure(exit)) throw new Error('unreachable');
44
+ expect(Cause.hasDies(exit.cause)).toBe(true);
45
+ const defect = Cause.squash(exit.cause);
46
+ expect(defect).toBeInstanceOf(UnifiRefusedRequest);
47
+ return defect as UnifiRefusedRequest;
48
+ };
49
+
50
+ describe('an OUTER RequestInit that asks to follow redirects (F2)', () => {
51
+ test('redirect stays manual, and keepalive plus a tls option still reach fetch', async () => {
52
+ hits.length = 0;
53
+ const inits: Array<RequestInit & { tls?: unknown }> = [];
54
+ const spy = ((url: string | URL | Request, init?: RequestInit) => {
55
+ inits.push((init ?? {}) as RequestInit);
56
+ return fetch(url, init);
57
+ }) as typeof fetch;
58
+ const exit = await Effect.runPromiseExit(
59
+ HttpClient.HttpClient.pipe(
60
+ Effect.flatMap((client) => client.execute(PUT())),
61
+ Effect.provide(guardedHttpClient(ALLOW, BASE)),
62
+ Effect.provideService(FetchHttpClient.RequestInit, {
63
+ redirect: 'follow',
64
+ keepalive: true,
65
+ tls: { rejectUnauthorized: false },
66
+ } as never),
67
+ Effect.provideService(FetchHttpClient.Fetch, spy),
68
+ Effect.provide(FetchHttpClient.layer),
69
+ ),
70
+ );
71
+ expect(defectOf(exit).message).toContain('307 redirect');
72
+ expect(hits).toHaveLength(1);
73
+ expect(inits).toHaveLength(1);
74
+ expect(inits[0]?.redirect).toBe('manual');
75
+ expect(inits[0]?.keepalive).toBe(true);
76
+ expect(inits[0]?.tls).toEqual({ rejectUnauthorized: false });
77
+ });
78
+ });
79
+
80
+ describe('a consumer transform that replaces RequestInit without merging (F3 backstop)', () => {
81
+ test('a followed redirect dies on the response URL, even though manual was lost', async () => {
82
+ hits.length = 0;
83
+ const consumer = Layer.effect(
84
+ HttpClient.HttpClient,
85
+ Effect.map(HttpClient.HttpClient, (client) =>
86
+ HttpClient.transform(client, (effect) =>
87
+ Effect.provideService(effect, FetchHttpClient.RequestInit, { redirect: 'follow' }),
88
+ ),
89
+ ),
90
+ ).pipe(Layer.provide(FetchHttpClient.layer));
91
+ const exit = await Effect.runPromiseExit(
92
+ HttpClient.HttpClient.pipe(
93
+ Effect.flatMap((client) => client.execute(PUT())),
94
+ Effect.provide(guardedHttpClient(ALLOW, BASE)),
95
+ Effect.provide(consumer),
96
+ ),
97
+ );
98
+ // The redirect WAS followed (the consumer's transform won), so a second request hit the server...
99
+ expect(hits.map((h) => h.path)).toContain('/elsewhere/v1/sites/s/networks/O');
100
+ // ...but the guard refuses to hand the answer back as a success.
101
+ expect(defectOf(exit).message).toContain('response URL differs');
102
+ });
103
+ });
104
+
105
+ describe('query parameters added with setUrlParam (F5)', () => {
106
+ test('a PUT with ?force=true built by setUrlParam is refused before the transport', async () => {
107
+ hits.length = 0;
108
+ const exit = await Effect.runPromiseExit(
109
+ HttpClient.HttpClient.pipe(
110
+ Effect.flatMap((client) =>
111
+ client.execute(PUT().pipe(HttpClientRequest.setUrlParam('force', 'true'))),
112
+ ),
113
+ Effect.provide(guardedHttpClient(ALLOW, BASE)),
114
+ Effect.provide(FetchHttpClient.layer),
115
+ ),
116
+ );
117
+ defectOf(exit);
118
+ expect(hits).toEqual([]);
119
+ });
120
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `guardedHttpClient` (`wire-guard.ts`, B0a/T12) in isolation — against a bare `HttpClient`
3
+ * service, no `CredentialsFromEnv`, no real SDK operation — plus its installation in
4
+ * `unifiHandlers`. A `GET` reaches the wrapped transport; any other method dies with
5
+ * `UnifiRefusedRequest` BEFORE the fake server sees it (`fake.seen` stays empty) unless the row's
6
+ * allow entry names exactly that `(method, path)`.
7
+ *
8
+ * ⚠️ HISTORY (2026-09-26, red team, IMPORTANT-1): deleting `Effect.provide(GetOnlyHttpClient)` from
9
+ * `withCredentials` once left every test green, because the family tests call `unifiOperations`
10
+ * directly and bypass `unifiHandlers`. The `unifiHandlers` block below drives the real handler
11
+ * entry points through a `ConfigProvider` override (the pattern `opnsense/write-refusal.test.ts`
12
+ * uses for `CredentialsFromEnv`), so it fails if a guard is ever removed from `withCredentials`.
13
+ * ★ `fakeUnifi` records `seen` BEFORE calling `route`, so even a route that would throw still proves
14
+ * whether the guard let a request through — the assertions are on `seen`, never on a caught throw.
15
+ */
16
+ import { describe, expect, test } from 'bun:test';
17
+ import * as Cause from 'effect/Cause';
18
+ import * as ConfigProvider from 'effect/ConfigProvider';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Exit from 'effect/Exit';
21
+ import * as Layer from 'effect/Layer';
22
+ import * as HttpClient from 'effect/http/HttpClient';
23
+ import type * as HttpClientError from 'effect/http/HttpClientError';
24
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
25
+ import { FAKE_BASE, FAKE_KEY, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
26
+ import { networkAllowedWrite } from './network.ts';
27
+ import { type UnifiSpec, unifiHandlers } from './resource.ts';
28
+ import { GetOnlyHttpClient, UnifiRefusedRequest, guardedHttpClient } from './wire-guard.ts';
29
+
30
+ const ROOT = '/proxy/network/integration';
31
+ const NET_PATH = `${ROOT}/v1/sites/s/networks/n`;
32
+ const ALLOW = [networkAllowedWrite({ siteId: 's', networkId: 'n' } as never)];
33
+ const url = (path: string) => `https://unifi.example.com${path}`;
34
+
35
+ const send = (
36
+ method: string,
37
+ path: string,
38
+ fetchFn: typeof globalThis.fetch,
39
+ guard: Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient>,
40
+ ) =>
41
+ Effect.runPromiseExit(
42
+ Effect.gen(function* () {
43
+ const client = yield* HttpClient.HttpClient;
44
+ return yield* client.execute(HttpClientRequest.make(method as never)(url(path)));
45
+ }).pipe(Effect.provide(guard), Effect.provide(fakeUnifiLayer(fetchFn))),
46
+ );
47
+
48
+ const expectRefused = (exit: Exit.Exit<unknown, unknown>, method?: string) => {
49
+ expect(Exit.isFailure(exit)).toBe(true);
50
+ if (!Exit.isFailure(exit)) return;
51
+ expect(Cause.hasDies(exit.cause)).toBe(true);
52
+ expect(Cause.hasFails(exit.cause)).toBe(false);
53
+ const defect = Cause.squash(exit.cause);
54
+ expect(defect).toBeInstanceOf(UnifiRefusedRequest);
55
+ if (method) expect((defect as UnifiRefusedRequest).method).toBe(method);
56
+ };
57
+
58
+ const neverReached = (what: string) =>
59
+ fakeUnifi(() => {
60
+ throw new Error(`wire-guard.test.ts: unexpected ${what} reached the fake transport`);
61
+ });
62
+
63
+ describe('GetOnlyHttpClient (empty allow list)', () => {
64
+ test("a GET passes through and reaches the transport under redirect: 'manual' (LOW-7)", async () => {
65
+ const fake = fakeUnifi(() => Response.json({ ok: true }));
66
+ const exit = await send('GET', `${ROOT}/v1/sites/s/networks`, fake.fetch, GetOnlyHttpClient);
67
+ expect(Exit.isSuccess(exit)).toBe(true);
68
+ // 'manual' on GETs is DELIBERATE since 2026-10-10: the response guard refuses a GET's 3xx too,
69
+ // and under the default 'follow' the guard would never see it (the redirect would be followed).
70
+ expect(fake.seen).toEqual([
71
+ { method: 'GET', path: `${ROOT}/v1/sites/s/networks`, redirect: 'manual' },
72
+ ]);
73
+ });
74
+
75
+ test.each(['POST', 'PUT', 'PATCH', 'DELETE'])('%s dies before the transport', async (method) => {
76
+ const fake = neverReached(method);
77
+ expectRefused(await send(method, NET_PATH, fake.fetch, GetOnlyHttpClient), method);
78
+ // ⛔ THE PROOF: the transport never recorded the call -- refused in `mapRequestEffect`.
79
+ expect(fake.seen).toEqual([]);
80
+ });
81
+ });
82
+
83
+ describe('guardedHttpClient with the row allow entry', () => {
84
+ test('the one PUT to this row reaches the transport, with fetch redirect: manual', async () => {
85
+ const fake = fakeUnifi(() => Response.json({ ok: true }));
86
+ const exit = await send('PUT', NET_PATH, fake.fetch, guardedHttpClient(ALLOW, FAKE_BASE));
87
+ expect(Exit.isSuccess(exit)).toBe(true);
88
+ expect(fake.seen).toEqual([{ method: 'PUT', path: NET_PATH, redirect: 'manual' }]);
89
+ });
90
+
91
+ test.each([
92
+ ['the list route', 'PUT', `${ROOT}/v1/sites/s/networks`],
93
+ ['another network id', 'PUT', `${ROOT}/v1/sites/s/networks/other`],
94
+ ['an id this one is a prefix of', 'PUT', `${ROOT}/v1/sites/s/networks/n2`],
95
+ ['a sub-route of this network', 'PUT', `${NET_PATH}/extra`],
96
+ ['an id this one is a suffix of', 'PUT', `${ROOT}/v1/sites/s/networks/xn`],
97
+ ['another site', 'PUT', `${ROOT}/v1/sites/t/networks/n`],
98
+ ['another family', 'PUT', `${ROOT}/v1/sites/s/firewall/zones/z`],
99
+ ['POST on the row path', 'POST', NET_PATH],
100
+ ['PATCH on the row path', 'PATCH', NET_PATH],
101
+ ['DELETE on the row path', 'DELETE', NET_PATH],
102
+ ])('%s dies before the transport', async (_name, method, path) => {
103
+ const fake = neverReached(`${method} ${path}`);
104
+ expectRefused(
105
+ await send(method, path, fake.fetch, guardedHttpClient(ALLOW, FAKE_BASE)),
106
+ method,
107
+ );
108
+ expect(fake.seen).toEqual([]);
109
+ });
110
+
111
+ test('a 307 answer to the allowed PUT dies after exactly one request, never followed', async () => {
112
+ const fake = fakeUnifi(
113
+ () => new Response(null, { status: 307, headers: { location: 'https://evil.example/x' } }),
114
+ );
115
+ expectRefused(
116
+ await send('PUT', NET_PATH, fake.fetch, guardedHttpClient(ALLOW, FAKE_BASE)),
117
+ 'PUT',
118
+ );
119
+ expect(fake.seen).toHaveLength(1);
120
+ expect(fake.seen[0]?.redirect).toBe('manual');
121
+ });
122
+ });
123
+
124
+ interface ProbeProps {
125
+ id: string;
126
+ }
127
+
128
+ /** A `fetchLive` that sends one raw request of the given method (not a real SDK operation). */
129
+ const probeSpec = (
130
+ method: string,
131
+ withUpdate = false,
132
+ ): UnifiSpec<ProbeProps, ProbeProps, ProbeProps, HttpClientError.HttpClientError> => ({
133
+ type: 'Test.Probe',
134
+ describe: (props) => `probe/${props.id}`,
135
+ fetchLive: (props) =>
136
+ Effect.gen(function* () {
137
+ const client = yield* HttpClient.HttpClient;
138
+ yield* client.execute(HttpClientRequest.make(method as never)(`${FAKE_BASE}/probe`));
139
+ return props;
140
+ }),
141
+ attributes: (live) => live,
142
+ matches: () => true,
143
+ ...(withUpdate
144
+ ? {
145
+ update: {
146
+ allowedWrite: () => ({ method: 'PUT' as const, tail: '/probe' }),
147
+ patchKeys: [],
148
+ driftOf: () => [],
149
+ checkScope: () => Effect.void,
150
+ write: (live: ProbeProps) => Effect.succeed(live),
151
+ },
152
+ }
153
+ : {}),
154
+ });
155
+
156
+ const configProvider = ConfigProvider.layer(
157
+ ConfigProvider.fromUnknown({
158
+ UNIFI_NETWORK_API_BASE_URL: FAKE_BASE,
159
+ UNIFI_NETWORK_API_KEY: FAKE_KEY,
160
+ }),
161
+ );
162
+ const session = { note: () => Effect.void };
163
+ const args = { news: { id: 'w1' }, olds: undefined, output: undefined, session };
164
+
165
+ const runHandler = <A, E>(
166
+ effect: Effect.Effect<A, E, HttpClient.HttpClient>,
167
+ fetchFn: typeof globalThis.fetch,
168
+ ): Promise<Exit.Exit<A, E>> =>
169
+ Effect.runPromiseExit(
170
+ effect.pipe(Effect.provide(Layer.mergeAll(fakeUnifiLayer(fetchFn), configProvider))) as never,
171
+ ) as never;
172
+
173
+ describe('guards are installed in unifiHandlers (IMPORTANT-1, red team 2026-09-26)', () => {
174
+ test('a GET through the real read entry point reaches the transport', async () => {
175
+ const fake = fakeUnifi(() => Response.json({ id: 'w1' }));
176
+ const exit = await runHandler(
177
+ unifiHandlers(probeSpec('GET')).read({ olds: { id: 'w1' }, output: undefined }),
178
+ fake.fetch,
179
+ );
180
+ expect(Exit.isSuccess(exit)).toBe(true);
181
+ expect(fake.seen).toEqual([{ method: 'GET', path: `${ROOT}/probe`, redirect: 'manual' }]);
182
+ });
183
+
184
+ test('read dies on a PUT even when the spec declares update (guard scope is reconcile only)', async () => {
185
+ const fake = neverReached('PUT');
186
+ const exit = await runHandler(
187
+ unifiHandlers(probeSpec('PUT', true)).read({ olds: { id: 'w1' }, output: undefined }),
188
+ fake.fetch,
189
+ );
190
+ expectRefused(exit, 'PUT');
191
+ expect(fake.seen).toEqual([]);
192
+ });
193
+
194
+ test('reconcile dies on a PUT when the spec has no update (firewall-zone style)', async () => {
195
+ const fake = neverReached('PUT');
196
+ expectRefused(await runHandler(unifiHandlers(probeSpec('PUT')).reconcile(args), fake.fetch));
197
+ expect(fake.seen).toEqual([]);
198
+ });
199
+
200
+ test('reconcile lets the allowed PUT through when the spec declares update', async () => {
201
+ const fake = fakeUnifi(() => Response.json({ id: 'w1' }));
202
+ const exit = await runHandler(
203
+ unifiHandlers(probeSpec('PUT', true)).reconcile(args),
204
+ fake.fetch,
205
+ );
206
+ expect(Exit.isSuccess(exit)).toBe(true);
207
+ expect(fake.seen.map((s) => s.method)).toEqual(['PUT']);
208
+ });
209
+
210
+ test('reconcile still dies on a POST even when the spec declares update', async () => {
211
+ const fake = neverReached('POST');
212
+ expectRefused(
213
+ await runHandler(unifiHandlers(probeSpec('POST', true)).reconcile(args), fake.fetch),
214
+ 'POST',
215
+ );
216
+ expect(fake.seen).toEqual([]);
217
+ });
218
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * B0a / T12 DEFENSE IN DEPTH, AT THE WIRE. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at
3
+ * `reconcile`/`destroy`; this guard stops the same thing one layer lower. It wraps whatever
4
+ * `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in production,
5
+ * `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same "wrap, don't
6
+ * replace" seam), so an SDK write operation called by mistake, bypassing `unifiOperations`, still
7
+ * never reaches the vendor API. `write-op-reference.test.ts` proves no such call exists outside
8
+ * `network-update.ts`; this is what stops one from ever taking effect.
9
+ *
10
+ * ★ A GET ALWAYS PASSES THE REQUEST GUARD; ANY OTHER METHOD PASSES ONLY WHEN `(method, pathname)`
11
+ * MATCHES AN ENTRY OF THE `allow` LIST. `unifiHandlers` builds that list PER ROW
12
+ * (`spec.update.allowedWrite(news)`): the one PUT that can pass is to this row's own
13
+ * `/v1/sites/<siteId>/networks/<networkId>` under the CONFIGURED base URL (local and
14
+ * cloud-connector base paths differ, so the base is passed in and compared exactly, `isAllowed`).
15
+ * A PUT to another network id, the list route, or another family dies here.
16
+ *
17
+ * ⚠️ `Effect.die`, NOT A TYPED FAILURE — every generated SDK operation declares its OWN closed
18
+ * error union from the pinned OpenAPI spec and generated files are never hand-edited, so a typed
19
+ * `Effect.fail` here would be a failure absent from every op's declared type. A defect is the
20
+ * sound way to add a "structurally impossible" failure underneath types this package does not
21
+ * own, like `openbao`'s `refuse()` helpers.
22
+ *
23
+ * ⛔ REDIRECTS (LOW-7), REFUSED FOR EVERY METHOD — GETs included, 2026-10-10. The layer sets fetch
24
+ * `redirect: 'manual'` on EVERY guarded request and the guard dies on any 3xx answer. Default
25
+ * fetch follows a redirect and re-sends the request to a `Location` this guard never saw — a PUT
26
+ * with its body on 307/308, a GET still carrying the API-key header — so following is never
27
+ * safe. Scoping `manual` to a non-empty allow list (the pre-2026-10-10 shape) also made the two
28
+ * postures DIVERGE by accident: reconcile's own GETs ran manual, so a 3xx surfaced raw and failed
29
+ * the SDK decode, while read/diff under `GetOnlyHttpClient` followed the redirect silently. One
30
+ * rule for both postures now: a redirect is refused everywhere, fail closed.
31
+ */
32
+ import * as Effect from 'effect/Effect';
33
+ import * as Option from 'effect/Option';
34
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
35
+ import * as HttpClient from 'effect/http/HttpClient';
36
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
37
+ import type * as HttpClientResponse from 'effect/http/HttpClientResponse';
38
+ import { UNIFI_WRITE_POLICY } from './policy.ts';
39
+
40
+ /**
41
+ * One non-GET request the guard may let through. `tail` is the exact route below the configured
42
+ * base URL (`/v1/sites/<site>/networks/<id>`): the guard compares the request's origin and path to
43
+ * `baseUrl + tail` and requires an empty query and fragment (`isAllowed`).
44
+ */
45
+ export type AllowedWrite = { readonly method: 'PUT'; readonly tail: string };
46
+
47
+ export class UnifiRefusedRequest extends Error {
48
+ constructor(
49
+ readonly method: string,
50
+ /** PATH ONLY, never the full URL — see `requestPath` below. */
51
+ readonly path: string,
52
+ detail = "not on this row's allow list",
53
+ ) {
54
+ super(
55
+ `UniFi HttpClient guard: refused ${method} ${path} (${detail}) -- ${UNIFI_WRITE_POLICY}. ` +
56
+ 'This is a defect, not a recoverable condition: something under src/unifi sent a request ' +
57
+ "policy.ts's per-operation refusal should already have made impossible. Fix the kit " +
58
+ 'code that produced this request.',
59
+ );
60
+ this.name = 'UnifiRefusedRequest';
61
+ }
62
+ }
63
+
64
+ /** @deprecated renamed `UnifiRefusedRequest`; kept so existing consumer imports do not break. */
65
+ export const UnifiNonGetRequest = UnifiRefusedRequest;
66
+ export type UnifiNonGetRequest = UnifiRefusedRequest;
67
+
68
+ /**
69
+ * ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID
70
+ * (`docs/unifi.md`'s Credentials section, T3) — a defect message can still reach a log or a CI
71
+ * failure body. Falls back to a fixed placeholder on a parse failure so a malformed URL can
72
+ * never leak through unredacted.
73
+ */
74
+ const requestPath = (url: string): string => {
75
+ try {
76
+ // ⛔ The cloud connector path is `/v1/connector/consoles/<consoleId>/...`: the id is dropped too.
77
+ return new URL(url).pathname.replace(/(\/consoles\/)[^/]*/g, '$1<redacted>');
78
+ } catch {
79
+ return '<unparseable request url>';
80
+ }
81
+ };
82
+
83
+ /**
84
+ * ⛔ ANCHORED TO THE CONFIGURED BASE URL, NOT A SUFFIX. The request must share the base's origin,
85
+ * its path must be exactly `<base path><tail>` (so `//v1/...`, another base path or a nested
86
+ * `/v1/sites/X/v1/sites/s/...` all miss), and it must carry no query or fragment (`?force=true`).
87
+ * Without a base URL nothing is allowed: fail closed.
88
+ */
89
+ const isAllowed = (
90
+ allow: ReadonlyArray<AllowedWrite>,
91
+ baseUrl: string | undefined,
92
+ method: string,
93
+ request: HttpClientRequest.HttpClientRequest,
94
+ ) => {
95
+ if (baseUrl === undefined) return false;
96
+ try {
97
+ const base = new URL(baseUrl);
98
+ // ⛔ `toUrl`, NOT `request.url`: `setUrlParam` keeps its parameters OUTSIDE `request.url`, so
99
+ // the bare string looked query-free while the wire request carried `?force=true` (round 2, F5).
100
+ const resolved = HttpClientRequest.toUrl(request);
101
+ if (Option.isNone(resolved)) return false;
102
+ const target = resolved.value;
103
+ if (target.origin !== base.origin || target.search !== '' || target.hash !== '') return false;
104
+ const basePath = base.pathname.replace(/\/+$/, '');
105
+ return allow.some(
106
+ (entry) => entry.method === method && target.pathname === `${basePath}${entry.tail}`,
107
+ );
108
+ } catch {
109
+ return false;
110
+ }
111
+ };
112
+
113
+ /** True when the response says it came from a different URL than the request named. */
114
+ const followedElsewhere = (res: HttpClientResponse.HttpClientResponse): boolean => {
115
+ const sent = HttpClientRequest.toUrl(res.request);
116
+ return res.url !== '' && Option.isSome(sent) && res.url !== sent.value.href.split('#')[0];
117
+ };
118
+
119
+ const guard = (
120
+ client: HttpClient.HttpClient,
121
+ allow: ReadonlyArray<AllowedWrite>,
122
+ baseUrl: string | undefined,
123
+ ) =>
124
+ client.pipe(
125
+ HttpClient.mapRequestEffect((request) =>
126
+ request.method === 'GET' || isAllowed(allow, baseUrl, request.method, request)
127
+ ? Effect.succeed(request)
128
+ : Effect.die(new UnifiRefusedRequest(request.method, requestPath(request.url))),
129
+ ),
130
+ HttpClient.transformResponse((response) =>
131
+ Effect.flatMap(response, (res) =>
132
+ // ⛔ ANY METHOD: a GET's 3xx is refused exactly like a PUT's — see REDIRECTS in the header.
133
+ res.status >= 300 && res.status < 400
134
+ ? Effect.die(
135
+ new UnifiRefusedRequest(
136
+ res.request.method,
137
+ requestPath(res.request.url),
138
+ `answered a ${res.status} redirect`,
139
+ ),
140
+ )
141
+ : // ⛔ BACKSTOP (round 2, F3): if a consumer's own transform replaced `manual` and fetch
142
+ // followed a redirect, the response URL is no longer the request's. Die on that.
143
+ followedElsewhere(res)
144
+ ? Effect.die(
145
+ new UnifiRefusedRequest(
146
+ res.request.method,
147
+ requestPath(res.request.url),
148
+ 'the response URL differs from the request URL (a redirect was followed)',
149
+ ),
150
+ )
151
+ : Effect.succeed(res),
152
+ ),
153
+ ),
154
+ );
155
+
156
+ /**
157
+ * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
158
+ * for everything downstream, with the guarded version. Exported so `wire-guard.test.ts` can prove
159
+ * the mechanism against a bare `HttpClient`, without `CredentialsFromEnv` or any env var.
160
+ */
161
+ export const guardedHttpClient = (allow: ReadonlyArray<AllowedWrite>, baseUrl?: string) => {
162
+ return HttpClient.layerMergedContext(
163
+ Effect.map(HttpClient.HttpClient, (client) =>
164
+ // ⛔ MANUAL REDIRECT ON BOTH POSTURES, not just an allow list: the response guard refuses a
165
+ // 3xx for GETs too, and under the default 'follow' a GET redirect is silently followed —
166
+ // the guard would never see it, and read/diff would diverge from reconcile again.
167
+ HttpClient.transform(guard(client, allow, baseUrl), manualRedirect),
168
+ ),
169
+ );
170
+ };
171
+
172
+ /**
173
+ * ⛔ `redirect: 'manual'` IS SET PER REQUEST, ON THE CALLING FIBER'S `RequestInit`. A layer-provided
174
+ * `RequestInit` is silently REPLACED by an outer one (Effect 4.0.1 `layerMergedContext` merges the
175
+ * caller's context over the layer's), which would turn redirects back on. Same read-merge-provide
176
+ * pattern as `openbao/bao-http.ts`'s `overSocket`; outer fetch options are kept.
177
+ */
178
+ const manualRedirect = <E, R>(
179
+ effect: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
180
+ ): Effect.Effect<HttpClientResponse.HttpClientResponse, E, R> =>
181
+ Effect.flatMap(Effect.serviceOption(FetchHttpClient.RequestInit), (existing) =>
182
+ Effect.provideService(effect, FetchHttpClient.RequestInit, {
183
+ ...Option.getOrElse(existing, () => ({})),
184
+ redirect: 'manual',
185
+ }),
186
+ );
187
+
188
+ /**
189
+ * No write may pass; the posture of `read`/`diff`/`delete` and of every non-updatable family. A 3xx
190
+ * answer to a GET is refused here exactly as under the allow-list posture — see REDIRECTS above.
191
+ */
192
+ export const GetOnlyHttpClient = guardedHttpClient([]);
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { describe, expect, test } from 'bun:test';
27
27
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
28
- import { dirname, join, resolve } from 'node:path';
28
+ import { dirname, join, relative, resolve } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { stripComments, walk } from './scan-source.ts';
31
31
 
@@ -54,17 +54,34 @@ const sdkWriteOpNames = (): ReadonlySet<string> => {
54
54
 
55
55
  const REAL_WRITE_OP_NAMES = sdkWriteOpNames();
56
56
 
57
+ /**
58
+ * ⛔ THE ONE FILE ALLOWED TO NAME ONE WRITE OP (Tim 2026-10-10: `Unifi.Network` update). Keyed by
59
+ * path relative to `src/unifi`. Widening this object is a policy change — `policy.ts` — and a
60
+ * test below pins it to exactly this entry so it cannot grow unnoticed. Everything else the SDK
61
+ * exports (`createNetwork`, `deleteNetwork`, `patchFirewallPolicy`, …) stays an offense even
62
+ * inside the allowlisted file.
63
+ */
64
+ const WRITE_OP_ALLOWLIST: Record<string, ReadonlyArray<string>> = {
65
+ 'network-update.ts': ['updateNetwork'],
66
+ };
67
+
57
68
  /** Recursive by construction (IMPORTANT-2): a future `src/unifi/<subdir>/*.ts` is walked too. */
58
69
  const sourceFiles = () => walk(DIR).filter((path) => path !== SELF);
59
70
 
60
71
  /** Every offense in one file: any real SDK write-op name appearing as a token anywhere in its
61
72
  * comment-stripped text, GATED on the file mentioning the SDK's package specifier at all (a file
62
73
  * that never imports it — by any syntax — cannot meaningfully reference one of its exports). */
63
- const offensesIn = (path: string, rawSrc: string): string[] => {
74
+ const offensesIn = (
75
+ path: string,
76
+ rawSrc: string,
77
+ allowlist: Record<string, ReadonlyArray<string>> = WRITE_OP_ALLOWLIST,
78
+ ): string[] => {
64
79
  if (!rawSrc.includes(SDK_MARKER)) return [];
65
80
  const codeSrc = stripComments(rawSrc);
81
+ const allowed = allowlist[relative(DIR, path)] ?? [];
66
82
  const offenses: string[] = [];
67
83
  for (const name of REAL_WRITE_OP_NAMES) {
84
+ if (allowed.includes(name)) continue;
68
85
  if (new RegExp(`\\b${name}\\b`).test(codeSrc)) {
69
86
  offenses.push(`${path}: references SDK write op '${name}'`);
70
87
  }
@@ -169,6 +186,25 @@ describe('src/unifi never references an SDK write operation (T12)', () => {
169
186
  }
170
187
  });
171
188
 
189
+ test('the allowlist is exactly network-update.ts -> updateNetwork, nothing else', () => {
190
+ expect(WRITE_OP_ALLOWLIST).toEqual({ 'network-update.ts': ['updateNetwork'] });
191
+ });
192
+
193
+ test('network-update.ts may name updateNetwork but still not create/delete/patch ops', () => {
194
+ const path = join(DIR, 'network-update.ts');
195
+ const src = (op: string) => `import * as n from '${SDK_MARKER}/networks';\nn.${op}({});\n`;
196
+ expect(offensesIn(path, src('updateNetwork'))).toEqual([]);
197
+ for (const op of ['createNetwork', 'deleteNetwork', 'patchFirewallPolicy']) {
198
+ expect(offensesIn(path, src(op))).toEqual([`${path}: references SDK write op '${op}'`]);
199
+ }
200
+ });
201
+
202
+ test('any other file naming updateNetwork is an offense', () => {
203
+ const path = join(DIR, 'network.ts');
204
+ const src = `import * as n from '${SDK_MARKER}/networks';\nn.updateNetwork({});\n`;
205
+ expect(offensesIn(path, src)).toEqual([`${path}: references SDK write op 'updateNetwork'`]);
206
+ });
207
+
172
208
  test('the walk is recursive -- a future src/unifi/<subdir>/*.ts is not silently unscanned', () => {
173
209
  const probeDir = join(DIR, '.write-op-reference-recursion-probe');
174
210
  const probeFile = join(probeDir, 'nested.ts');