@alchemy-unifi/network 0.0.1 → 0.1.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/index.mjs ADDED
@@ -0,0 +1,2372 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
2
+ import * as Schema from "effect/Schema";
3
+ import * as Sdk from "@alchemy-unifi/network-sdk";
4
+ import { ConfigError } from "@alchemy-unifi/network-sdk";
5
+ import { AuthError, AuthProviderLayer } from "alchemy/Auth/AuthProvider";
6
+ import { displayRedacted } from "alchemy/Auth/Credentials";
7
+ import { getEnv, getEnvRedactedRequired, mapPromptCancellation } from "alchemy/Auth/Env";
8
+ import { storedSecret, storedValueText, validateFieldValues } from "alchemy/Auth/StoredAuthProvider";
9
+ import * as Interaction from "alchemy/Interaction";
10
+ import * as Effect from "effect/Effect";
11
+ import * as HttpClient from "effect/http/HttpClient";
12
+ import * as Redacted from "effect/Redacted";
13
+ import * as Context from "effect/Context";
14
+ import * as Layer from "effect/Layer";
15
+ import * as Predicate from "effect/Predicate";
16
+ import * as Schedule from "effect/Schedule";
17
+ import * as RateLimiter from "effect/persistence/RateLimiter";
18
+ import * as Scope from "effect/Scope";
19
+ import { deferUntilFirstUse, orDieCredentialsUnavailable, resolveProviderConfig } from "alchemy/Auth/Resolve";
20
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
21
+ import { buildConnector } from "@effect/platform-node/Undici";
22
+ import * as Data from "effect/Data";
23
+ import * as net from "node:net";
24
+ import * as tls from "node:tls";
25
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
26
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
27
+ import * as Provider from "alchemy/Provider";
28
+ import { Unowned } from "alchemy/AdoptPolicy";
29
+ import { Resource } from "alchemy/Resource";
30
+ import * as Stream from "effect/Stream";
31
+ import { deepEqual, isResolved } from "alchemy/Diff";
32
+ import * as Deferred from "effect/Deferred";
33
+ import * as Exit from "effect/Exit";
34
+ import { UserFacingError } from "alchemy/UserFacingError";
35
+ import { STATE_STORE_VERSION } from "alchemy/State/HttpStateApi";
36
+ import { State, StateStoreError } from "alchemy/State/State";
37
+ import { encodeState, reviveState } from "alchemy/State/StateEncoding";
38
+ import { recordStateStoreInit } from "alchemy/Telemetry/Metrics";
39
+ import * as Clock from "effect/Clock";
40
+ import * as LogLevel from "effect/LogLevel";
41
+ import * as References from "effect/References";
42
+ import * as Semaphore from "effect/Semaphore";
43
+ import * as zlib from "node:zlib";
44
+ // src/AuthConfig.ts
45
+ const UNIFI_NETWORK_AUTH_PROVIDER_NAME = "UnifiNetwork";
46
+ /** Values stored in a profile for the `UnifiNetwork` auth provider. */
47
+ const UnifiNetworkAuthConfigSchema = Schema.Union([Schema.Struct({
48
+ method: Schema.Literal("local"),
49
+ url: Schema.String,
50
+ apiKey: Schema.String,
51
+ certFingerprint: Schema.optional(Schema.String),
52
+ insecure: Schema.optional(Schema.Boolean),
53
+ site: Schema.optional(Schema.String)
54
+ }), Schema.Struct({
55
+ method: Schema.Literal("remote"),
56
+ consoleId: Schema.String,
57
+ apiKey: Schema.String,
58
+ site: Schema.optional(Schema.String)
59
+ })]);
60
+ // src/Credentials.ts
61
+ /**
62
+ * Lazily-resolved credentials for the active profile (or the environment).
63
+ * The service value is an Effect so nothing resolves at Layer construction;
64
+ * handlers run `yield* yield* UnifiNetworkCredentials`.
65
+ */
66
+ var UnifiNetworkCredentials = class extends Context.Service()("UnifiNetwork::Credentials") {};
67
+ /**
68
+ * Resolves {@link UnifiNetworkCredentials} through the `UnifiNetwork` auth
69
+ * provider: environment variables when they're all set, otherwise the
70
+ * selected Alchemy profile. Resolution is deferred to first use and cached.
71
+ */
72
+ const fromAuthProvider = () => Layer.effect(UnifiNetworkCredentials, Effect.gen(function* () {
73
+ return yield* (yield* resolveProviderConfig(UNIFI_NETWORK_AUTH_PROVIDER_NAME).pipe(Effect.flatMap(({ profileName, resolve }) => resolve.pipe(Effect.mapError((e) => ConfigError.make({ message: `Failed to resolve UniFi Network credentials from ${profileName === void 0 ? "the environment" : `profile '${profileName}'`}: ${e.message}` })))), deferUntilFirstUse)).pipe(orDieCredentialsUnavailable(UNIFI_NETWORK_AUTH_PROVIDER_NAME), Effect.cached);
74
+ }));
75
+ // src/HttpClient.ts
76
+ /** Normalises a SHA-256 fingerprint to Node's `AB:CD:…` form, or returns `undefined` if it isn't one. */
77
+ const normaliseFingerprint = (value) => {
78
+ const hex = value.replace(/[\s:]/g, "").toUpperCase();
79
+ return /^[0-9A-F]{64}$/.test(hex) ? (hex.match(/../g) ?? []).join(":") : void 0;
80
+ };
81
+ /** SHA-256 fingerprints of a peer's certificate chain, leaf first. */
82
+ const chainFingerprints = (socket) => {
83
+ const fingerprints = [];
84
+ let cert = socket.getPeerCertificate(true);
85
+ while (cert?.fingerprint256 !== void 0 && !fingerprints.includes(cert.fingerprint256)) {
86
+ fingerprints.push(cert.fingerprint256);
87
+ cert = cert.issuerCertificate;
88
+ }
89
+ return fingerprints;
90
+ };
91
+ const PINNING_ERROR_CODE = "ERR_UNIFI_CERT_PINNING";
92
+ /**
93
+ * Whether an error (or one of its causes) is a certificate verification or
94
+ * pinning failure. These are deterministic, so they aren't worth retrying.
95
+ */
96
+ const isCertificateError = (error) => {
97
+ for (let e = error, depth = 0; typeof e === "object" && e !== null && depth < 5; depth++) {
98
+ const code = "code" in e ? String(e.code) : "";
99
+ if (code === PINNING_ERROR_CODE || /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(code)) return true;
100
+ e = "cause" in e ? e.cause : "reason" in e ? e.reason : void 0;
101
+ }
102
+ return false;
103
+ };
104
+ /**
105
+ * Pinning can't use `checkServerIdentity`: Node skips it when the chain
106
+ * doesn't verify, which is always the case for a console's self-signed
107
+ * certificate. So verification is off, and the connector checks the
108
+ * presented chain itself once the handshake completes. Session resumption
109
+ * is off too, because a resumed session presents no certificate to check.
110
+ */
111
+ const pinnedConnector = (fingerprint) => {
112
+ const connect = buildConnector({
113
+ rejectUnauthorized: false,
114
+ maxCachedSessions: 0
115
+ });
116
+ return (options, callback) => connect(options, (error, socket) => {
117
+ if (error !== null) return callback(error, null);
118
+ if (socket instanceof tls.TLSSocket && !chainFingerprints(socket).includes(fingerprint)) {
119
+ socket.destroy();
120
+ return callback(Object.assign(/* @__PURE__ */ new Error(`The certificate presented by ${options.hostname} doesn't match the pinned fingerprint ${fingerprint}.`), { code: PINNING_ERROR_CODE }), null);
121
+ }
122
+ callback(null, socket);
123
+ });
124
+ };
125
+ /** An undici-backed `HttpClient` applying {@link TlsOptions}. The dispatcher closes with the scope. */
126
+ const make = (options = {}) => NodeHttpClient.makeDispatcherOptions(options.certFingerprint !== void 0 ? { connect: pinnedConnector(options.certFingerprint) } : options.insecure ? { connect: { rejectUnauthorized: false } } : void 0).pipe(Effect.flatMap((dispatcher) => NodeHttpClient.makeUndici.pipe(Effect.provideService(NodeHttpClient.Dispatcher, dispatcher))));
127
+ var CertificateInspectionError = class extends Data.TaggedError("CertificateInspectionError") {};
128
+ /**
129
+ * Connects to an `https:` URL without verifying it and reports the
130
+ * certificate it presents, so `configure` can offer to pin it.
131
+ */
132
+ const inspectCertificate = (url) => Effect.callback((resume) => {
133
+ const { hostname, port } = new URL(url);
134
+ const host = hostname.replace(/^\[|\]$/g, "");
135
+ const socket = tls.connect({
136
+ host,
137
+ port: Number(port || 443),
138
+ rejectUnauthorized: false,
139
+ ...net.isIP(host) === 0 ? { servername: host } : {}
140
+ });
141
+ socket.setTimeout(1e4, () => socket.destroy(/* @__PURE__ */ new Error(`Timed out connecting to ${url}.`)));
142
+ socket.once("secureConnect", () => {
143
+ const cert = socket.getPeerCertificate();
144
+ resume(Effect.succeed({
145
+ trusted: socket.authorized,
146
+ fingerprint: cert.fingerprint256,
147
+ subject: String(cert.subject?.CN ?? cert.subjectaltname ?? "unknown"),
148
+ validTo: cert.valid_to
149
+ }));
150
+ socket.end();
151
+ });
152
+ socket.once("error", (cause) => resume(Effect.fail(new CertificateInspectionError({
153
+ message: cause.message,
154
+ cause
155
+ }))));
156
+ return Effect.sync(() => socket.destroy());
157
+ });
158
+ // src/Connection.ts
159
+ const SITE_MANAGER_URL = "https://api.ui.com";
160
+ /** Integration API base URL through the cloud connector. */
161
+ const connectorBaseUrl = (consoleId) => `${SITE_MANAGER_URL}/v1/connector/consoles/${encodeURIComponent(consoleId)}/proxy/network/integration`;
162
+ /** Integration API base URLs to try for a connection: a UniFi OS console's, then a standalone controller's. */
163
+ const candidateBaseUrls = (connection) => connection.type === "remote" ? [connectorBaseUrl(connection.consoleId)] : [`${connection.url}/proxy/network/integration`, `${connection.url}/integration`];
164
+ /** The SDK's default retry policy, minus certificate failures, which retrying won't fix. */
165
+ const probeRetry = (lastError) => {
166
+ const defaults = Sdk.Retry.makeDefault(lastError);
167
+ return {
168
+ ...defaults,
169
+ while: (error) => (defaults.while?.(error) ?? false) && !isCertificateError(error)
170
+ };
171
+ };
172
+ /** The connector allows 100 requests/minute per console; stay under it. */
173
+ const REMOTE_REQUESTS_PER_MINUTE = 90;
174
+ /** Joins an error's message with its causes' (e.g. a transport error and the TLS failure behind it). */
175
+ const describeError = (error) => {
176
+ const messages = [];
177
+ for (let e = error, depth = 0; e !== void 0 && e !== null && depth < 5; depth++) {
178
+ const message = typeof e === "object" && "message" in e ? String(e.message) : String(e);
179
+ if (message !== "" && !messages.some((m) => m.includes(message))) messages.push(message);
180
+ e = typeof e === "object" && "cause" in e ? e.cause : void 0;
181
+ }
182
+ return messages.join(": ") || "unknown error";
183
+ };
184
+ const describeTarget = (connection) => connection.type === "remote" ? `console ${connection.consoleId} via ${SITE_MANAGER_URL}` : connection.url;
185
+ const rateLimited = (client, key) => Effect.gen(function* () {
186
+ const limiter = yield* RateLimiter.make.pipe(Effect.provide(RateLimiter.layerStoreMemory));
187
+ const wait = RateLimiter.sleep(limiter, {
188
+ algorithm: "token-bucket",
189
+ window: "1 minute",
190
+ limit: REMOTE_REQUESTS_PER_MINUTE,
191
+ key
192
+ }).pipe(Effect.orDie);
193
+ return HttpClient.transform(client, (response) => Effect.andThen(wait, response));
194
+ });
195
+ /**
196
+ * Builds a client for the connection and finds the Integration API: local
197
+ * connections try the console path, then the standalone controller path.
198
+ * Fails with a `ConfigError` describing what went wrong. `options.client`
199
+ * replaces the TLS-configured client (for tests).
200
+ */
201
+ const connect = (connection, options = {}) => Effect.gen(function* () {
202
+ const base = options.client ?? (yield* make(connection.type === "local" ? connection : {}));
203
+ const client = connection.type === "remote" ? yield* rateLimited(base, `connector:${connection.consoleId}`) : base;
204
+ const servicesFor = (baseUrl) => Context.make(Sdk.Credentials, Effect.succeed({
205
+ baseUrl,
206
+ apiKey: connection.apiKey
207
+ })).pipe(Context.add(HttpClient.HttpClient, client));
208
+ const candidates = candidateBaseUrls(connection);
209
+ for (const baseUrl of candidates) {
210
+ const services = servicesFor(baseUrl);
211
+ const info = yield* Sdk.getInfo({}).pipe(Effect.provide(services), Sdk.Retry.policy(probeRetry), Effect.map(({ applicationVersion }) => ({
212
+ baseUrl,
213
+ applicationVersion,
214
+ services
215
+ })), Effect.catchTag("NotFound", () => Effect.undefined), Effect.mapError((error) => Sdk.ConfigError.make({ message: error._tag === "Unauthorized" || error._tag === "Forbidden" ? `${describeTarget(connection)} rejected the API key (${error._tag}).` : `Could not connect to ${describeTarget(connection)}: ${describeError(error)}` })));
216
+ if (info !== void 0) return info;
217
+ }
218
+ return yield* Sdk.ConfigError.make({ message: connection.type === "remote" ? `The cloud connector found no UniFi Network application on console ${connection.consoleId}.` : `No UniFi Network Integration API at ${connection.url} (tried ${candidates.join(", ")}). Use the console's root URL, e.g. https://192.168.1.1.` });
219
+ });
220
+ const majorMinor = (version) => version.split(".").slice(0, 2).join(".");
221
+ /** Patch releases routinely ship without a published spec, so only major.minor drift is worth a warning. */
222
+ const checkVersion = (applicationVersion) => majorMinor(applicationVersion) === majorMinor(Sdk.SPEC_VERSION) ? Effect.void : Effect.logWarning(`UniFi Network ${applicationVersion} is newer or older than the API spec this provider was built from (${Sdk.SPEC_VERSION}). Resources may not behave as documented.`);
223
+ /**
224
+ * The profile's established connection, created on first use and shared for
225
+ * the rest of the run. Provider-private: its `HttpClient` and SDK
226
+ * `Credentials` never enter the stack's context, so they can't replace other
227
+ * providers' clients.
228
+ */
229
+ var UnifiNetworkConnection = class extends Context.Service()("UnifiNetwork::Connection") {};
230
+ const layer$1 = Layer.effect(UnifiNetworkConnection, Effect.gen(function* () {
231
+ const credentials = yield* UnifiNetworkCredentials;
232
+ const scope = yield* Effect.scope;
233
+ return yield* Effect.cached(credentials.pipe(Effect.flatMap(({ connection }) => connect(connection)), Effect.tap(({ applicationVersion }) => checkVersion(applicationVersion)), Scope.provide(scope)));
234
+ }));
235
+ /**
236
+ * The 422 "Configured network does not exist" the console answers requests
237
+ * with, on any endpoint, for a moment while networks are created or deleted
238
+ * anywhere on it (seen on the test controller under parallel tests, for
239
+ * network, zone and policy requests alike). It's never a real error, so it's
240
+ * retried; other 422s are.
241
+ */
242
+ const isStaleNetworkState = (error) => Predicate.hasProperty(error, "_tag") && error._tag === "UnprocessableEntity" && Predicate.hasProperty(error, "message") && typeof error.message === "string" && error.message.includes("Configured network does not exist");
243
+ /** Runs SDK operations against the profile's console, retrying the console's transient stale-network 422. */
244
+ const withConnection = (effect) => Effect.gen(function* () {
245
+ const { services } = yield* yield* UnifiNetworkConnection;
246
+ return yield* Effect.provide(effect, services).pipe(Effect.retry({
247
+ while: isStaleNetworkState,
248
+ schedule: Schedule.spaced("500 millis").pipe(Schedule.upTo({ times: 10 }))
249
+ }));
250
+ });
251
+ // src/SiteManager.ts
252
+ /**
253
+ * Just enough of the Site Manager API (`GET /v1/hosts`) for `configure` to
254
+ * list the consoles a key can reach. A full SDK belongs in its own package.
255
+ */
256
+ const Host = Schema.Struct({
257
+ id: Schema.String,
258
+ type: Schema.optional(Schema.NullOr(Schema.String)),
259
+ reportedState: Schema.optional(Schema.NullOr(Schema.Struct({
260
+ name: Schema.optional(Schema.NullOr(Schema.String)),
261
+ hostname: Schema.optional(Schema.NullOr(Schema.String))
262
+ })))
263
+ });
264
+ const HostsPage = Schema.Struct({
265
+ data: Schema.Array(Host),
266
+ nextToken: Schema.optional(Schema.NullOr(Schema.String))
267
+ });
268
+ const listConsoles = (apiKey) => Effect.gen(function* () {
269
+ const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
270
+ const consoles = [];
271
+ let nextToken;
272
+ do {
273
+ const page = yield* client.execute(HttpClientRequest.get(`${SITE_MANAGER_URL}/v1/hosts`).pipe(HttpClientRequest.setHeaders({
274
+ "X-API-KEY": Redacted.value(apiKey),
275
+ Accept: "application/json"
276
+ }), HttpClientRequest.setUrlParams(nextToken === void 0 ? {} : { nextToken }))).pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(HostsPage)));
277
+ for (const host of page.data) consoles.push({
278
+ id: host.id,
279
+ name: host.reportedState?.name ?? host.reportedState?.hostname ?? host.id,
280
+ type: host.type ?? void 0
281
+ });
282
+ nextToken = page.nextToken ?? void 0;
283
+ } while (nextToken !== void 0);
284
+ return consoles;
285
+ });
286
+ // src/AuthProvider.ts
287
+ const NAME = UNIFI_NETWORK_AUTH_PROVIDER_NAME;
288
+ const authError = (message, cause) => AuthError.make({
289
+ message: `${NAME}: ${message}`,
290
+ ...cause === void 0 ? {} : { cause }
291
+ });
292
+ /** Normalises a console URL (no trailing slash), or returns `undefined` if it isn't `http(s):`. */
293
+ const parseConsoleUrl = (value) => {
294
+ try {
295
+ const url = new URL(value.trim());
296
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
297
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
298
+ } catch {
299
+ return;
300
+ }
301
+ };
302
+ const parseBoolean = (value) => /^(true|1|yes)$/i.test(value) ? true : /^(false|0|no|)$/i.test(value) ? false : void 0;
303
+ const validateUrl = (value) => parseConsoleUrl(value) === void 0 ? "Expected an http(s) URL such as https://192.168.1.1" : void 0;
304
+ const validateFingerprint = (value) => normaliseFingerprint(value) === void 0 ? "Expected a SHA-256 fingerprint: 64 hex digits, optionally colon-separated" : void 0;
305
+ const validateBoolean = (value) => parseBoolean(value) === void 0 ? "Expected true or false" : void 0;
306
+ const required = (value) => value.length === 0 ? "Required" : void 0;
307
+ /** Validates stored, entered or environment values. `source` names them in errors. */
308
+ const resolve = (input, source) => Effect.gen(function* () {
309
+ const site = input.site || void 0;
310
+ if (input.method === "remote") {
311
+ if (!input.consoleId) return yield* authError(`no console ID (${source}).`);
312
+ return {
313
+ connection: {
314
+ type: "remote",
315
+ consoleId: input.consoleId,
316
+ apiKey: input.apiKey
317
+ },
318
+ site
319
+ };
320
+ }
321
+ const url = parseConsoleUrl(input.url ?? "");
322
+ if (url === void 0) return yield* authError(`'${input.url ?? ""}' is not an http(s) URL (${source}).`);
323
+ const certFingerprint = input.certFingerprint === void 0 ? void 0 : normaliseFingerprint(input.certFingerprint);
324
+ if (input.certFingerprint !== void 0 && certFingerprint === void 0) return yield* authError(`'${input.certFingerprint}' is not a SHA-256 fingerprint (${source}).`);
325
+ if (certFingerprint !== void 0 && input.insecure) return yield* authError(`set a certificate fingerprint or insecure, not both (${source}).`);
326
+ if (certFingerprint !== void 0 && url.startsWith("http:")) return yield* authError(`a certificate fingerprint needs an https: URL (${source}).`);
327
+ return {
328
+ connection: {
329
+ type: "local",
330
+ url,
331
+ apiKey: input.apiKey,
332
+ certFingerprint,
333
+ insecure: input.insecure ?? false
334
+ },
335
+ site
336
+ };
337
+ });
338
+ const fromConfig = (profileName, config) => resolve({
339
+ ...config,
340
+ apiKey: Redacted.make(config.apiKey)
341
+ }, `profile '${profileName}'`).pipe(Effect.map((settings) => ({
342
+ ...settings,
343
+ source: {
344
+ type: "profile",
345
+ profile: profileName
346
+ }
347
+ })));
348
+ const toConfig = ({ connection, site }) => connection.type === "remote" ? {
349
+ method: "remote",
350
+ consoleId: connection.consoleId,
351
+ apiKey: Redacted.value(connection.apiKey),
352
+ ...site === void 0 ? {} : { site }
353
+ } : {
354
+ method: "local",
355
+ url: connection.url,
356
+ apiKey: Redacted.value(connection.apiKey),
357
+ ...connection.certFingerprint === void 0 ? {} : { certFingerprint: connection.certFingerprint },
358
+ ...connection.insecure ? { insecure: true } : {},
359
+ ...site === void 0 ? {} : { site }
360
+ };
361
+ const siteField = {
362
+ name: "site",
363
+ label: "Default site",
364
+ description: "Only for consoles hosting several sites: internal reference (e.g. default) or name of the site resources use when they don't set one.",
365
+ optional: true
366
+ };
367
+ const configureMethods = [{
368
+ method: "local",
369
+ fields: [
370
+ {
371
+ name: "url",
372
+ label: "Console URL",
373
+ placeholder: "https://192.168.1.1",
374
+ validate: validateUrl
375
+ },
376
+ {
377
+ name: "apiKey",
378
+ label: "API key",
379
+ secret: true
380
+ },
381
+ {
382
+ name: "certFingerprint",
383
+ label: "Certificate SHA-256 fingerprint",
384
+ description: "Pin the console's certificate (or a CA in its chain) instead of verifying it.",
385
+ optional: true,
386
+ validate: validateFingerprint
387
+ },
388
+ {
389
+ name: "insecure",
390
+ label: "Skip TLS verification",
391
+ optional: true,
392
+ validate: validateBoolean
393
+ },
394
+ siteField
395
+ ]
396
+ }, {
397
+ method: "remote",
398
+ fields: [
399
+ {
400
+ name: "consoleId",
401
+ label: "Console ID",
402
+ description: "The console's host ID in Site Manager."
403
+ },
404
+ {
405
+ name: "apiKey",
406
+ label: "Site Manager API key",
407
+ secret: true
408
+ },
409
+ siteField
410
+ ]
411
+ }];
412
+ const environment = [
413
+ {
414
+ name: "UNIFI_URL",
415
+ required: true,
416
+ alternatives: ["UNIFI_CONSOLE_ID"],
417
+ description: "URL of the console or controller, e.g. https://192.168.1.1. To go through the ui.com cloud connector instead, set UNIFI_CONSOLE_ID to the console's host ID."
418
+ },
419
+ {
420
+ name: "UNIFI_API_KEY",
421
+ required: true,
422
+ secret: true,
423
+ description: "A Network API key (local) or a Site Manager API key (remote)."
424
+ },
425
+ {
426
+ name: "UNIFI_CERT_FINGERPRINT",
427
+ required: false,
428
+ description: "Local only: SHA-256 fingerprint of the console's certificate (or a CA in its chain) to pin."
429
+ },
430
+ {
431
+ name: "UNIFI_INSECURE",
432
+ required: false,
433
+ description: "Local only: true to skip TLS verification."
434
+ },
435
+ {
436
+ name: "UNIFI_SITE",
437
+ required: false,
438
+ description: "Default site, for consoles hosting several. Unset means the console's only site."
439
+ }
440
+ ];
441
+ const readEnvironment = Effect.gen(function* () {
442
+ const apiKey = yield* getEnvRedactedRequired("UNIFI_API_KEY");
443
+ const url = yield* getEnv("UNIFI_URL");
444
+ const consoleId = yield* getEnv("UNIFI_CONSOLE_ID");
445
+ const site = yield* getEnv("UNIFI_SITE");
446
+ if (url && consoleId) return yield* authError("set UNIFI_URL or UNIFI_CONSOLE_ID, not both.");
447
+ if (consoleId) return yield* resolve({
448
+ method: "remote",
449
+ consoleId,
450
+ apiKey,
451
+ site
452
+ }, "UNIFI_* environment variables");
453
+ if (!url) return yield* authError("set UNIFI_URL (local) or UNIFI_CONSOLE_ID (remote) along with UNIFI_API_KEY.");
454
+ const insecureValue = (yield* getEnv("UNIFI_INSECURE")) ?? "";
455
+ const insecure = parseBoolean(insecureValue);
456
+ if (insecure === void 0) return yield* authError(`UNIFI_INSECURE must be true or false, not '${insecureValue}'.`);
457
+ const certFingerprint = (yield* getEnv("UNIFI_CERT_FINGERPRINT")) || void 0;
458
+ return yield* resolve({
459
+ method: "local",
460
+ url,
461
+ apiKey,
462
+ certFingerprint,
463
+ insecure,
464
+ site
465
+ }, "UNIFI_* environment variables");
466
+ }).pipe(Effect.map((settings) => ({
467
+ ...settings,
468
+ source: { type: "env" }
469
+ })));
470
+ /** Runs `f` against a freshly established connection, mapping connection failures to `AuthError`. */
471
+ const withEstablished = (connection, f) => Effect.scoped(Effect.flatMap(connect(connection), f)).pipe(Effect.mapError((error) => error instanceof AuthError ? error : authError(describeError(error), error)));
472
+ const configureWith = (_profileName, input) => Effect.gen(function* () {
473
+ const method = configureMethods.find((m) => m.method === input.method);
474
+ if (method === void 0) return yield* authError(`unknown method '${input.method}'. Valid methods: local, remote.`);
475
+ const values = yield* validateFieldValues(NAME, method.fields, input.values);
476
+ const insecure = storedValueText(values["insecure"]);
477
+ const resolved = yield* resolve({
478
+ method: method.method === "remote" ? "remote" : "local",
479
+ url: storedValueText(values["url"]),
480
+ consoleId: storedValueText(values["consoleId"]),
481
+ apiKey: storedSecret(values["apiKey"]) ?? Redacted.make(""),
482
+ certFingerprint: storedValueText(values["certFingerprint"]),
483
+ insecure: insecure === void 0 ? void 0 : parseBoolean(insecure),
484
+ site: storedValueText(values["site"])
485
+ }, "--set values");
486
+ return toConfig(resolved);
487
+ });
488
+ const details = (profileName, config) => Effect.gen(function* () {
489
+ const { connection, site } = yield* fromConfig(profileName, config);
490
+ const lines = connection.type === "remote" ? [{
491
+ key: "method",
492
+ value: "remote (ui.com cloud connector)"
493
+ }, {
494
+ key: "consoleId",
495
+ value: connection.consoleId
496
+ }] : [
497
+ {
498
+ key: "method",
499
+ value: "local"
500
+ },
501
+ {
502
+ key: "url",
503
+ value: connection.url
504
+ },
505
+ {
506
+ key: "tls",
507
+ value: connection.url.startsWith("http:") ? "none (http)" : connection.certFingerprint !== void 0 ? `pinned ${connection.certFingerprint}` : connection.insecure ? "not verified" : "verified"
508
+ }
509
+ ];
510
+ lines.push({
511
+ key: "apiKey",
512
+ value: displayRedacted(connection.apiKey)
513
+ });
514
+ if (site !== void 0) lines.push({
515
+ key: "site",
516
+ value: site
517
+ });
518
+ const network = yield* withEstablished(connection, (established) => Effect.succeed(`UniFi Network ${established.applicationVersion}`)).pipe(Effect.catch((error) => Effect.succeed(`unreachable (${error.message})`)));
519
+ lines.push({
520
+ key: "network",
521
+ value: network
522
+ });
523
+ return { lines };
524
+ });
525
+ const UnifiNetworkAuth = AuthProviderLayer()(NAME, Effect.gen(function* () {
526
+ const interaction = Interaction.accessors;
527
+ /**
528
+ * Lists the console's sites and, if there are several, lets the user
529
+ * pick the default (remembering `current`). A single site isn't stored.
530
+ */
531
+ const pickSite = (connection, current) => Effect.gen(function* () {
532
+ const { applicationVersion, sites } = yield* withEstablished(connection, (established) => Sdk.getSiteOverviewPage({ limit: 200 }).pipe(Effect.provide(established.services), Effect.map((page) => ({
533
+ applicationVersion: established.applicationVersion,
534
+ sites: page.data
535
+ }))));
536
+ yield* interaction.output.info(`Connected to UniFi Network ${applicationVersion}.`);
537
+ const [only] = sites;
538
+ if (only === void 0) return yield* authError("the console has no sites.");
539
+ if (sites.length === 1) {
540
+ yield* interaction.output.info(`The console has one site, ${only.name}; resources will use it.`);
541
+ return;
542
+ }
543
+ return yield* interaction.prompt.select({
544
+ message: "Default site",
545
+ searchable: true,
546
+ ...current !== void 0 && sites.some((s) => s.internalReference === current) ? { initialValue: current } : {},
547
+ options: sites.map((s) => ({
548
+ value: s.internalReference,
549
+ label: s.name,
550
+ description: s.internalReference
551
+ }))
552
+ }).pipe(mapPromptCancellation);
553
+ });
554
+ const promptApiKey = (message, description) => interaction.prompt.password({
555
+ message,
556
+ description,
557
+ validate: required
558
+ }).pipe(mapPromptCancellation, Effect.map(Redacted.make));
559
+ const configureLocal = (current) => Effect.gen(function* () {
560
+ const url = yield* interaction.prompt.text({
561
+ message: "Console URL",
562
+ description: "The console's or self-hosted controller's address on your network.",
563
+ placeholder: "https://192.168.1.1",
564
+ ...current?.method === "local" ? { defaultValue: current.url } : {},
565
+ validate: validateUrl
566
+ }).pipe(mapPromptCancellation, Effect.map((value) => parseConsoleUrl(value) ?? value));
567
+ const apiKey = yield* promptApiKey("API key", "Create one in UniFi Network under Settings → Integrations.");
568
+ let tls = {};
569
+ if (url.startsWith("https:")) {
570
+ const cert = yield* inspectCertificate(url).pipe(Effect.mapError((error) => authError(`could not connect to ${url}: ${describeError(error)}`, error)));
571
+ if (cert.trusted) yield* interaction.output.info("The console's certificate is publicly trusted; it will be verified normally.");
572
+ else tls = (yield* interaction.prompt.select({
573
+ message: `${url} presents an untrusted certificate (${cert.subject}, valid until ${cert.validTo})`,
574
+ options: [{
575
+ value: "pin",
576
+ label: "Pin this certificate",
577
+ description: `SHA-256 ${cert.fingerprint}. Check it matches the console's before choosing this.`
578
+ }, {
579
+ value: "insecure",
580
+ label: "Skip verification",
581
+ description: "Accept any certificate. Anyone on the network path could intercept the API key."
582
+ }]
583
+ }).pipe(mapPromptCancellation)) === "pin" ? { certFingerprint: cert.fingerprint } : { insecure: true };
584
+ }
585
+ const resolved = yield* resolve({
586
+ method: "local",
587
+ url,
588
+ apiKey,
589
+ ...tls
590
+ }, "entered values");
591
+ const site = yield* pickSite(resolved.connection, current?.site);
592
+ return toConfig({
593
+ ...resolved,
594
+ site
595
+ });
596
+ });
597
+ const configureRemote = (current) => Effect.gen(function* () {
598
+ const apiKey = yield* promptApiKey("Site Manager API key", "Create one at unifi.ui.com under API.");
599
+ const consoles = yield* Effect.scoped(make().pipe(Effect.flatMap((client) => listConsoles(apiKey).pipe(Effect.provideService(HttpClient.HttpClient, client))))).pipe(Effect.mapError((error) => authError(`could not list consoles: ${describeError(error)}`, error)));
600
+ const [only] = consoles;
601
+ if (only === void 0) return yield* authError("this API key can't see any consoles.");
602
+ const consoleId = consoles.length === 1 ? only.id : yield* interaction.prompt.select({
603
+ message: "Console",
604
+ searchable: true,
605
+ ...current?.method === "remote" && consoles.some((c) => c.id === current.consoleId) ? { initialValue: current.consoleId } : {},
606
+ options: consoles.map((c) => ({
607
+ value: c.id,
608
+ label: c.name,
609
+ description: c.type === void 0 ? c.id : `${c.type} · ${c.id}`
610
+ }))
611
+ }).pipe(mapPromptCancellation);
612
+ if (consoles.length === 1) yield* interaction.output.info(`Using console ${only.name}.`);
613
+ const resolved = yield* resolve({
614
+ method: "remote",
615
+ consoleId,
616
+ apiKey
617
+ }, "entered values");
618
+ const site = yield* pickSite(resolved.connection, current?.site);
619
+ return toConfig({
620
+ ...resolved,
621
+ site
622
+ });
623
+ });
624
+ const configure = (_profileName, current) => Effect.gen(function* () {
625
+ const config = yield* (yield* interaction.prompt.select({
626
+ message: "How should Alchemy reach the console?",
627
+ initialValue: current?.method ?? "local",
628
+ options: [{
629
+ value: "local",
630
+ label: "Directly",
631
+ description: "the console's or controller's URL on your network"
632
+ }, {
633
+ value: "remote",
634
+ label: "Through ui.com",
635
+ description: "the api.ui.com cloud connector, from anywhere"
636
+ }]
637
+ }).pipe(mapPromptCancellation)) === "local" ? configureLocal(current) : configureRemote(current);
638
+ yield* interaction.output.success(`${NAME}: credentials saved.`);
639
+ return config;
640
+ });
641
+ return {
642
+ configSchema: UnifiNetworkAuthConfigSchema,
643
+ configure,
644
+ configureWith,
645
+ configureMethods,
646
+ login: (_profileName, config) => Effect.succeed(config),
647
+ logout: () => Effect.void,
648
+ details,
649
+ read: fromConfig,
650
+ readEnvironment,
651
+ environment
652
+ };
653
+ }));
654
+ // src/internal/Fields.ts
655
+ /** `value` with every `Redacted` unwrapped, recursively, so passphrases compare by contents. */
656
+ const unwrap = (value) => {
657
+ if (Redacted.isRedacted(value)) return Redacted.value(value);
658
+ if (Array.isArray(value)) return value.map(unwrap);
659
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, unwrap(nested)]));
660
+ return value;
661
+ };
662
+ /**
663
+ * Whether the console's object is exactly the desired body: same fields,
664
+ * same values, recursively (key order and `Redacted` wrappers aside; unset
665
+ * fields must be absent on both sides). Leave read-only fields out of
666
+ * `observed` first.
667
+ */
668
+ const isCurrent = (desired, observed) => deepEqual(unwrap(desired), unwrap(observed));
669
+ /**
670
+ * `value` with every array sorted, recursively, for fields whose arrays are
671
+ * sets: the console keeps them in an order of its own (e.g. a firewall
672
+ * policy's port list), so order mustn't count as a difference.
673
+ */
674
+ const sortArrays = (value) => {
675
+ if (Array.isArray(value)) return value.map(sortArrays).toSorted((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
676
+ if (typeof value === "object" && value !== null && !Redacted.isRedacted(value)) return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, sortArrays(nested)]));
677
+ return value;
678
+ };
679
+ /** `record` without `keys`, e.g. read-only fields the console rejects in request bodies. */
680
+ const omitKeys = (record, keys) => Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key)));
681
+ // src/internal/Plan.ts
682
+ /** A prop whose value depends on another resource's output, so isn't known while planning. */
683
+ const UNKNOWN = Symbol("UnifiNetwork.unknown");
684
+ /**
685
+ * A top-level prop of `diff`'s `news` (props that may still hold outputs of
686
+ * resources not yet deployed): its value, `undefined` when unset, or
687
+ * {@link UNKNOWN}.
688
+ */
689
+ const planned = (news, key) => {
690
+ if (typeof news !== "object" || news === null) return UNKNOWN;
691
+ const value = Predicate.hasProperty(news, key) ? news[key] : void 0;
692
+ return isResolved(value) ? value : UNKNOWN;
693
+ };
694
+ /** {@link planned} for string props such as `site`. */
695
+ const plannedString = (news, key) => {
696
+ const value = planned(news, key);
697
+ return value === UNKNOWN || value === void 0 || typeof value === "string" ? value : UNKNOWN;
698
+ };
699
+ // src/internal/Services.ts
700
+ /**
701
+ * The provider's services, captured when the provider is built, as a
702
+ * function that runs a handler with them. Handlers then don't depend on
703
+ * what the stack's merged context happens to hold when they run.
704
+ */
705
+ const captureServices = Effect.map(Effect.context(), (services) => (effect) => Effect.provide(effect, services));
706
+ // src/ReadCache.ts
707
+ var ReadCache_exports = /* @__PURE__ */ __exportAll({
708
+ UnifiNetworkReadCache: () => UnifiNetworkReadCache,
709
+ cached: () => cached,
710
+ invalidate: () => invalidate,
711
+ invalidating: () => invalidating,
712
+ key: () => key,
713
+ layer: () => layer
714
+ });
715
+ /**
716
+ * Per-run memo of API reads, so a plan touching many resources lists each
717
+ * collection once instead of once per resource. One instance per
718
+ * `providers()` build, i.e. per CLI run or test file. The value is only an
719
+ * identity: entries live on each {@link Key}, keyed by it.
720
+ */
721
+ var UnifiNetworkReadCache = class extends Context.Service()("UnifiNetwork::ReadCache") {};
722
+ const layer = Layer.sync(UnifiNetworkReadCache, () => ({ run: {} }));
723
+ const key = (name) => ({
724
+ name,
725
+ entries: /* @__PURE__ */ new WeakMap()
726
+ });
727
+ const entriesFor = (cacheKey, run) => {
728
+ const existing = cacheKey.entries.get(run);
729
+ if (existing !== void 0) return existing;
730
+ const entries = /* @__PURE__ */ new Map();
731
+ cacheKey.entries.set(run, entries);
732
+ return entries;
733
+ };
734
+ /**
735
+ * `load`'s result, shared by every caller in this run until invalidated.
736
+ * Concurrent callers wait for one load. Failures aren't cached.
737
+ */
738
+ const cached = (cacheKey, scope, load) => Effect.gen(function* () {
739
+ const { run } = yield* UnifiNetworkReadCache;
740
+ const entries = entriesFor(cacheKey, run);
741
+ const existing = entries.get(scope);
742
+ if (existing !== void 0) return yield* Deferred.await(existing);
743
+ const deferred = Deferred.makeUnsafe();
744
+ entries.set(scope, deferred);
745
+ return yield* load.pipe(Effect.onExit((exit) => {
746
+ if (Exit.isFailure(exit) && entries.get(scope) === deferred) entries.delete(scope);
747
+ return Deferred.done(deferred, exit);
748
+ }));
749
+ });
750
+ /** Drops an entry, so the next read loads afresh. */
751
+ const invalidate = (cacheKey, scope) => Effect.gen(function* () {
752
+ const { run } = yield* UnifiNetworkReadCache;
753
+ cacheKey.entries.get(run)?.delete(scope);
754
+ });
755
+ /** Runs a write, then invalidates the entry it affects, whether or not the write succeeded. */
756
+ const invalidating = (cacheKey, scope) => (write) => Effect.ensuring(write, invalidate(cacheKey, scope));
757
+ // src/Site.ts
758
+ const describeSites = (sites) => sites.map((site) => `${site.internalReference} (${site.name})`).join(", ") || "none";
759
+ var SiteNotFound = class extends Data.TaggedError("SiteNotFound") {
760
+ [UserFacingError] = true;
761
+ get message() {
762
+ return `The console has no site "${this.site}". Its sites: ${describeSites(this.available)}. Point the profile's site, or the resource's site prop, at one of them.`;
763
+ }
764
+ };
765
+ var SiteRequired = class extends Data.TaggedError("SiteRequired") {
766
+ [UserFacingError] = true;
767
+ get message() {
768
+ return `The console hosts several sites (${describeSites(this.available)}), so resources need one: set site in the profile (alchemy profile edit --reconfigure UnifiNetwork), or the resource's site prop.`;
769
+ }
770
+ };
771
+ var AmbiguousSite = class extends Data.TaggedError("AmbiguousSite") {
772
+ [UserFacingError] = true;
773
+ get message() {
774
+ return `Several sites are named "${this.site}" (${describeSites(this.matches)}). Use the internal reference instead.`;
775
+ }
776
+ };
777
+ /** A resource referenced another that lives in a different site. */
778
+ var CrossSiteReference = class extends Data.TaggedError("CrossSiteReference") {
779
+ [UserFacingError] = true;
780
+ get message() {
781
+ return `The referenced ${this.what} is in another site (${this.referencedSiteId}), not ${this.site.internalReference} (${this.site.name}). UniFi objects can only reference objects in their own site.`;
782
+ }
783
+ };
784
+ /** Fails if a reference carries a site ID (resources and lookups do) other than `site`'s. */
785
+ const checkSameSite = (what, referencedSiteId, site) => referencedSiteId === void 0 || referencedSiteId === site.id ? Effect.void : Effect.fail(new CrossSiteReference({
786
+ what,
787
+ referencedSiteId,
788
+ site
789
+ }));
790
+ const loadSites = withConnection(Sdk.getSiteOverviewPage.items({}).pipe(Stream.map(({ id, internalReference, name }) => ({
791
+ id,
792
+ internalReference,
793
+ name
794
+ })), Stream.runCollect));
795
+ const sitesKey = key("sites");
796
+ /** The console's sites, listed once per run. */
797
+ const listSites = cached(sitesKey, "", loadSites);
798
+ /**
799
+ * The site a resource belongs to: `site` (an ID, internal reference or
800
+ * name), else the profile's site, else the console's only site. Fails,
801
+ * listing the console's sites, when it doesn't exist or is ambiguous, so a
802
+ * plan stops before changing anything.
803
+ */
804
+ const resolveSite = Effect.fn("UnifiNetwork.resolveSite")(function* (site) {
805
+ const requested = site ?? (yield* yield* UnifiNetworkCredentials).site;
806
+ const sites = yield* listSites;
807
+ if (requested === void 0) {
808
+ const [only] = sites;
809
+ if (only !== void 0 && sites.length === 1) return only;
810
+ return yield* new SiteRequired({ available: sites });
811
+ }
812
+ const byReference = sites.find((s) => s.id === requested || s.internalReference === requested);
813
+ if (byReference !== void 0) return byReference;
814
+ const byName = sites.filter((s) => s.name === requested);
815
+ const [match] = byName;
816
+ if (match !== void 0 && byName.length === 1) return match;
817
+ if (byName.length > 1) return yield* new AmbiguousSite({
818
+ site: requested,
819
+ matches: byName
820
+ });
821
+ return yield* new SiteNotFound({
822
+ site: requested,
823
+ available: sites
824
+ });
825
+ });
826
+ // src/DnsPolicy.ts
827
+ const loadPolicies$1 = (siteId) => withConnection(Stream.runCollect(Sdk.getDnsPolicyPage.items({ siteId })));
828
+ const policiesKey$1 = key("dnsPolicies");
829
+ /** A site's DNS policies, listed once per run. */
830
+ const listDnsPolicies = (siteId) => cached(policiesKey$1, siteId, loadPolicies$1(siteId));
831
+ const toAttributes$6 = (siteId, policy) => {
832
+ const { id, metadata: _metadata, ...config } = policy;
833
+ return {
834
+ siteId,
835
+ dnsPolicyId: id,
836
+ type: policy.type,
837
+ domain: policy.domain,
838
+ config
839
+ };
840
+ };
841
+ /** Fields the console reports but rejects in request bodies. */
842
+ const READ_ONLY$4 = ["id", "metadata"];
843
+ /** A policy by ID, from the run's cached list, which returns whole policies (one request for every policy in a plan). */
844
+ const getPolicy$1 = (siteId, dnsPolicyId) => listDnsPolicies(siteId).pipe(Effect.map((policies) => policies.find((p) => p.id === dnsPolicyId)));
845
+ /**
846
+ * The existing policy `body` describes: the only one with its type and
847
+ * domain, or, when a domain has several records of that type (e.g. two A
848
+ * records), the only one of those whose other fields match too.
849
+ */
850
+ const findExisting = (policies, body) => {
851
+ const sameName = policies.filter((p) => p.type === body.type && p.domain === body.domain);
852
+ const candidates = sameName.length > 1 ? sameName.filter((p) => isCurrent(body, omitKeys(p, READ_ONLY$4))) : sameName;
853
+ const [policy] = candidates;
854
+ return candidates.length === 1 ? policy : void 0;
855
+ };
856
+ /**
857
+ * A local DNS record or forwarding rule on the gateway. DNS policies have no
858
+ * name, so existing ones are adopted with `--adopt` by type and domain (and,
859
+ * when a domain has several records of that type, by their values).
860
+ * Changing `type` updates the policy in place.
861
+ *
862
+ * @example
863
+ * ```ts
864
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
865
+ * import * as Alchemy from "alchemy";
866
+ * import * as Effect from "effect/Effect";
867
+ *
868
+ * export default Alchemy.Stack(
869
+ * "HomeNetwork",
870
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
871
+ * Effect.gen(function* () {
872
+ * yield* UnifiNetwork.DnsPolicy("home-assistant", {
873
+ * type: "A_RECORD",
874
+ * enabled: true,
875
+ * domain: "ha.home.arpa",
876
+ * ipv4Address: "192.168.2.20",
877
+ * ttlSeconds: 300,
878
+ * });
879
+ * }),
880
+ * );
881
+ * ```
882
+ */
883
+ const DnsPolicy = Resource("UnifiNetwork.DnsPolicy");
884
+ const DnsPolicyProvider = () => Provider.effect(DnsPolicy, Effect.gen(function* () {
885
+ const run = yield* captureServices;
886
+ return DnsPolicy.Provider.of({
887
+ list: () => run(Effect.gen(function* () {
888
+ const site = yield* resolveSite();
889
+ return (yield* listDnsPolicies(site.id)).map((policy) => toAttributes$6(site.id, policy));
890
+ })),
891
+ read: ({ olds, output }) => run(Effect.gen(function* () {
892
+ if (output !== void 0) {
893
+ const policy = yield* getPolicy$1(output.siteId, output.dnsPolicyId);
894
+ return policy === void 0 ? void 0 : toAttributes$6(output.siteId, policy);
895
+ }
896
+ const site = yield* resolveSite(olds.site);
897
+ const { site: _site, ...body } = olds;
898
+ const policy = findExisting(yield* listDnsPolicies(site.id), body);
899
+ return policy === void 0 ? void 0 : Unowned(toAttributes$6(site.id, policy));
900
+ })),
901
+ diff: ({ news, output }) => run(Effect.gen(function* () {
902
+ const siteProp = plannedString(news, "site");
903
+ if (output === void 0 || siteProp === UNKNOWN) return void 0;
904
+ if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
905
+ })),
906
+ reconcile: ({ news, output }) => run(Effect.gen(function* () {
907
+ const site = yield* resolveSite(news.site);
908
+ const { site: _site, ...body } = news;
909
+ const observed = output !== void 0 && output.siteId === site.id ? yield* getPolicy$1(site.id, output.dnsPolicyId) : void 0;
910
+ const write = invalidating(policiesKey$1, site.id);
911
+ if (observed === void 0) {
912
+ const created = yield* withConnection(Sdk.createDnsPolicy({
913
+ siteId: site.id,
914
+ body
915
+ })).pipe(write);
916
+ return toAttributes$6(site.id, created);
917
+ }
918
+ if (isCurrent(body, omitKeys(observed, READ_ONLY$4))) return toAttributes$6(site.id, observed);
919
+ const updated = yield* withConnection(Sdk.updateDnsPolicy({
920
+ siteId: site.id,
921
+ dnsPolicyId: observed.id,
922
+ body
923
+ })).pipe(write);
924
+ return toAttributes$6(site.id, updated);
925
+ })),
926
+ delete: ({ output }) => run(withConnection(Sdk.deleteDnsPolicy({
927
+ siteId: output.siteId,
928
+ dnsPolicyId: output.dnsPolicyId
929
+ })).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(policiesKey$1, output.siteId)))
930
+ });
931
+ }));
932
+ // src/internal/Networks.ts
933
+ /** A site's networks, uncached. */
934
+ const loadNetworks = (siteId) => withConnection(Stream.runCollect(Sdk.getNetworksOverviewPage.items({ siteId })));
935
+ // src/Lookup.ts
936
+ /** A `lookup` found nothing matching its query. */
937
+ var LookupNotFound = class extends Data.TaggedError("LookupNotFound") {
938
+ [UserFacingError] = true;
939
+ get message() {
940
+ return `No ${this.resource} matches ${this.query} in site ${this.site.internalReference} (${this.site.name}).`;
941
+ }
942
+ };
943
+ /** A `lookup` matched several objects, e.g. networks sharing a name. */
944
+ var LookupAmbiguous = class extends Data.TaggedError("LookupAmbiguous") {
945
+ [UserFacingError] = true;
946
+ get message() {
947
+ return `${this.count} ${this.resource}s match ${this.query} in site ${this.site.internalReference} (${this.site.name}). Use a more specific query.`;
948
+ }
949
+ };
950
+ const describeQuery = (query) => Object.entries(query).filter(([key, value]) => key !== "site" && value !== void 0).map(([key, value]) => `${key} ${JSON.stringify(value)}`).join(" and ");
951
+ // src/FirewallZone.ts
952
+ const loadZones = (siteId) => withConnection(Stream.runCollect(Sdk.getFirewallZones.items({ siteId })));
953
+ const zonesKey = key("firewallZones");
954
+ /** A site's zones, built-in ones included, listed once per run. */
955
+ const listFirewallZones = (siteId) => cached(zonesKey, siteId, loadZones(siteId));
956
+ const toAttributes$5 = (siteId, zone) => ({
957
+ siteId,
958
+ firewallZoneId: zone.id,
959
+ name: zone.name
960
+ });
961
+ const isBuiltin = (zone) => zone.metadata.origin === "SYSTEM_DEFINED";
962
+ /** The zone ID a reference means in `site`. */
963
+ const resolveFirewallZone = Effect.fn("UnifiNetwork.resolveFirewallZone")(function* (reference, site) {
964
+ if ("firewallZoneId" in reference) {
965
+ yield* checkSameSite("firewall zone", reference.siteId, site);
966
+ return reference.firewallZoneId;
967
+ }
968
+ const zone = (yield* listFirewallZones(site.id)).find((z) => isBuiltin(z) && z.name === reference.builtinFirewallZone);
969
+ if (zone === void 0) return yield* new LookupNotFound({
970
+ resource: "built-in firewall zone",
971
+ query: `name "${reference.builtinFirewallZone}" (has the site been migrated to zone-based firewall?)`,
972
+ site
973
+ });
974
+ return zone.id;
975
+ });
976
+ const builtin = (name) => ({ builtinFirewallZone: name });
977
+ /**
978
+ * A custom zone in the zone-based firewall. Networks join zones through
979
+ * `Network`'s `zone` prop; the zone doesn't list its networks, so the two
980
+ * resources never fight over membership.
981
+ *
982
+ * @example
983
+ * ```ts
984
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
985
+ * import * as Alchemy from "alchemy";
986
+ * import * as Effect from "effect/Effect";
987
+ *
988
+ * export default Alchemy.Stack(
989
+ * "HomeNetwork",
990
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
991
+ * Effect.gen(function* () {
992
+ * const cameras = yield* UnifiNetwork.FirewallZone("cameras", { name: "Cameras" });
993
+ * // Networks join zones from the network's side.
994
+ * yield* UnifiNetwork.Network("cctv", {
995
+ * management: "GATEWAY",
996
+ * name: "CCTV",
997
+ * vlanId: 30,
998
+ * enabled: true,
999
+ * cellularBackupEnabled: false,
1000
+ * internetAccessEnabled: false,
1001
+ * isolationEnabled: true,
1002
+ * zone: cameras, // or a built-in zone, e.g. UnifiNetwork.FirewallZone.INTERNAL
1003
+ * ipv4Configuration: { autoScaleEnabled: false, hostIpAddress: "192.168.30.1", prefixLength: 24 },
1004
+ * });
1005
+ * }),
1006
+ * );
1007
+ * ```
1008
+ */
1009
+ const FirewallZone = Resource("UnifiNetwork.FirewallZone")({
1010
+ INTERNAL: builtin("Internal"),
1011
+ EXTERNAL: builtin("External"),
1012
+ GATEWAY: builtin("Gateway"),
1013
+ VPN: builtin("Vpn"),
1014
+ HOTSPOT: builtin("Hotspot"),
1015
+ DMZ: builtin("Dmz"),
1016
+ /**
1017
+ * Any zone in the site, built-in or custom, by name. Dies with a
1018
+ * user-facing `LookupNotFound` if there's none: stack bodies can't fail
1019
+ * with typed errors.
1020
+ */
1021
+ lookup: Effect.fn("UnifiNetwork.FirewallZone.lookup")(function* (query) {
1022
+ const site = yield* resolveSite(query.site);
1023
+ const zone = (yield* listFirewallZones(site.id)).find((z) => z.name === query.name);
1024
+ if (zone === void 0) return yield* new LookupNotFound({
1025
+ resource: "firewall zone",
1026
+ query: describeQuery(query),
1027
+ site
1028
+ });
1029
+ return toAttributes$5(site.id, zone);
1030
+ }, Effect.orDie)
1031
+ });
1032
+ const getZone = (siteId, firewallZoneId) => withConnection(Sdk.getFirewallZone({
1033
+ siteId,
1034
+ firewallZoneId
1035
+ })).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
1036
+ /**
1037
+ * The networks in a zone, from the networks' own `zoneId`s. Updates replace
1038
+ * a zone's member list, and the zone's `networkIds` lags a network joining
1039
+ * it by a fraction of a second, so sending those back could detach a
1040
+ * network that just joined.
1041
+ */
1042
+ const currentMembers = (siteId, firewallZoneId) => loadNetworks(siteId).pipe(Effect.map((networks) => networks.filter((n) => "zoneId" in n && n.zoneId === firewallZoneId).map((n) => n.id)));
1043
+ const FirewallZoneProvider = () => Provider.effect(FirewallZone, Effect.gen(function* () {
1044
+ const run = yield* captureServices;
1045
+ return FirewallZone.Provider.of({
1046
+ list: () => run(Effect.gen(function* () {
1047
+ const site = yield* resolveSite();
1048
+ return (yield* listFirewallZones(site.id)).filter((zone) => !isBuiltin(zone)).map((zone) => toAttributes$5(site.id, zone));
1049
+ })),
1050
+ read: ({ olds, output }) => run(Effect.gen(function* () {
1051
+ if (output !== void 0) {
1052
+ const zone = yield* getZone(output.siteId, output.firewallZoneId);
1053
+ return zone === void 0 ? void 0 : toAttributes$5(output.siteId, zone);
1054
+ }
1055
+ const site = yield* resolveSite(olds.site);
1056
+ const zone = (yield* listFirewallZones(site.id)).find((z) => !isBuiltin(z) && z.name === olds.name);
1057
+ return zone === void 0 ? void 0 : Unowned(toAttributes$5(site.id, zone));
1058
+ })),
1059
+ diff: ({ news, output }) => run(Effect.gen(function* () {
1060
+ const siteProp = plannedString(news, "site");
1061
+ if (output === void 0 || siteProp === UNKNOWN) return void 0;
1062
+ if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
1063
+ })),
1064
+ reconcile: ({ news, output }) => run(Effect.gen(function* () {
1065
+ const site = yield* resolveSite(news.site);
1066
+ const observed = output !== void 0 && output.siteId === site.id ? yield* getZone(site.id, output.firewallZoneId) : void 0;
1067
+ const write = invalidating(zonesKey, site.id);
1068
+ if (observed === void 0) {
1069
+ const created = yield* withConnection(Sdk.createFirewallZone({
1070
+ siteId: site.id,
1071
+ name: news.name,
1072
+ networkIds: []
1073
+ })).pipe(write);
1074
+ return toAttributes$5(site.id, created);
1075
+ }
1076
+ if (observed.name === news.name) return toAttributes$5(site.id, observed);
1077
+ const updated = yield* withConnection(Sdk.updateFirewallZone({
1078
+ siteId: site.id,
1079
+ firewallZoneId: observed.id,
1080
+ name: news.name,
1081
+ networkIds: yield* currentMembers(site.id, observed.id)
1082
+ })).pipe(write);
1083
+ return toAttributes$5(site.id, updated);
1084
+ })),
1085
+ delete: ({ output }) => run(withConnection(Sdk.deleteFirewallZone({
1086
+ siteId: output.siteId,
1087
+ firewallZoneId: output.firewallZoneId
1088
+ })).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(zonesKey, output.siteId)))
1089
+ });
1090
+ }));
1091
+ // src/Network.ts
1092
+ /** A change that would replace the site's default network, which can't be deleted. */
1093
+ var DefaultNetworkReplacement = class extends Data.TaggedError("DefaultNetworkReplacement") {
1094
+ [UserFacingError] = true;
1095
+ get message() {
1096
+ return `${this.networkName} is the site's default network, which can't be replaced (${this.reason}). It must stay gateway-managed in its site.`;
1097
+ }
1098
+ };
1099
+ const networksKey = key("networks");
1100
+ /** A site's networks, listed once per run. */
1101
+ const listNetworks = (siteId) => cached(networksKey, siteId, loadNetworks(siteId));
1102
+ const toAttributes$4 = (siteId, network) => {
1103
+ const { id, metadata: _metadata, default: isDefault, ...config } = network;
1104
+ return {
1105
+ siteId,
1106
+ networkId: id,
1107
+ name: network.name,
1108
+ vlanId: network.vlanId,
1109
+ management: network.management,
1110
+ zoneId: "zoneId" in network ? network.zoneId : void 0,
1111
+ default: isDefault,
1112
+ config
1113
+ };
1114
+ };
1115
+ /** Fields the console reports but rejects in request bodies. */
1116
+ const READ_ONLY$3 = [
1117
+ "id",
1118
+ "metadata",
1119
+ "default"
1120
+ ];
1121
+ /**
1122
+ * `body` with explicit values for the two unset fields that can't simply be
1123
+ * left out (both verified):
1124
+ * - `mdnsForwardingEnabled`: a `PUT` without it keeps the console's value
1125
+ * instead of resetting it, so the default (`true`, what the console sets on
1126
+ * create) is always sent;
1127
+ * - a DHCP server's `domainName`: the console reports `""` when it's unset,
1128
+ * so without the default the body would never match and every deploy
1129
+ * would update it (and drift would report it) forever.
1130
+ */
1131
+ const withDefaults$1 = (body) => {
1132
+ if (body.management !== "GATEWAY") return body;
1133
+ const dhcp = body.ipv4Configuration.dhcpConfiguration;
1134
+ return {
1135
+ ...body,
1136
+ mdnsForwardingEnabled: body.mdnsForwardingEnabled ?? true,
1137
+ ipv4Configuration: {
1138
+ ...body.ipv4Configuration,
1139
+ ...dhcp?.mode === "SERVER" ? { dhcpConfiguration: {
1140
+ ...dhcp,
1141
+ domainName: dhcp.domainName ?? ""
1142
+ } } : {}
1143
+ }
1144
+ };
1145
+ };
1146
+ /** The request body for `props`, with references resolved to IDs. */
1147
+ const toBody$2 = Effect.fn(function* (props, site) {
1148
+ switch (props.management) {
1149
+ case "GATEWAY": {
1150
+ const { site: _site, zone, ...rest } = props;
1151
+ if (zone === void 0) return rest;
1152
+ const zoneId = yield* resolveFirewallZone(zone, site);
1153
+ return {
1154
+ ...rest,
1155
+ zoneId
1156
+ };
1157
+ }
1158
+ case "SWITCH": {
1159
+ const { site: _site, device, ...rest } = props;
1160
+ return {
1161
+ ...rest,
1162
+ deviceId: device.deviceId
1163
+ };
1164
+ }
1165
+ case "UNMANAGED": {
1166
+ const { site: _site, ...rest } = props;
1167
+ return rest;
1168
+ }
1169
+ }
1170
+ });
1171
+ /** The network ID a reference means in `site`. */
1172
+ const resolveNetwork = (reference, site) => checkSameSite("network", reference.siteId, site).pipe(Effect.as(reference.networkId));
1173
+ const getNetwork = (siteId, networkId) => withConnection(Sdk.getNetworkDetails({
1174
+ siteId,
1175
+ networkId
1176
+ })).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
1177
+ /** The attributes of a network known to exist (e.g. from a list). */
1178
+ const attributesOf$1 = (siteId, networkId) => withConnection(Sdk.getNetworkDetails({
1179
+ siteId,
1180
+ networkId
1181
+ })).pipe(Effect.map((network) => toAttributes$4(siteId, network)));
1182
+ const isUserDefined$1 = (network) => network.metadata.origin === "USER_DEFINED";
1183
+ /**
1184
+ * A network (VLAN). `management` picks the kind: `"GATEWAY"` (routed by the
1185
+ * gateway), `"SWITCH"` (routed by a layer-3 switch) or `"UNMANAGED"`.
1186
+ * Changing it, or the site, replaces the network: the old one is deleted
1187
+ * first, since VLAN IDs must be unique.
1188
+ *
1189
+ * Props are authoritative: every update sends the whole network, with
1190
+ * explicit defaults for unset fields (`mdnsForwardingEnabled: true`, and a
1191
+ * DHCP server's `domainName: ""`), so changes made in the UI are put back.
1192
+ * The `config` attribute mirrors the console's object, so `alchemy drift`
1193
+ * sees them.
1194
+ * Existing networks are adopted by VLAN ID
1195
+ * with `--adopt` (and renamed to match), including the site's default
1196
+ * network (VLAN 1), which is never deleted or replaced.
1197
+ *
1198
+ * @example
1199
+ * ```ts
1200
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
1201
+ * import * as Alchemy from "alchemy";
1202
+ * import * as Effect from "effect/Effect";
1203
+ *
1204
+ * export default Alchemy.Stack(
1205
+ * "HomeNetwork",
1206
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
1207
+ * Effect.gen(function* () {
1208
+ * yield* UnifiNetwork.Network("guest", {
1209
+ * management: "GATEWAY",
1210
+ * name: "Guest",
1211
+ * vlanId: 12,
1212
+ * enabled: true,
1213
+ * cellularBackupEnabled: false,
1214
+ * internetAccessEnabled: true,
1215
+ * isolationEnabled: true,
1216
+ * zone: UnifiNetwork.FirewallZone.HOTSPOT,
1217
+ * ipv4Configuration: { autoScaleEnabled: false, hostIpAddress: "192.168.12.1", prefixLength: 24 },
1218
+ * });
1219
+ * }),
1220
+ * );
1221
+ * ```
1222
+ */
1223
+ const Network = Resource("UnifiNetwork.Network")({
1224
+ /**
1225
+ * A network in the site by name or VLAN ID. Dies with a user-facing
1226
+ * `LookupNotFound` or `LookupAmbiguous`: stack bodies can't fail with typed
1227
+ * errors.
1228
+ */
1229
+ lookup: Effect.fn("UnifiNetwork.Network.lookup")(function* (query) {
1230
+ const site = yield* resolveSite(query.site);
1231
+ const networks = (yield* listNetworks(site.id)).filter((n) => "vlanId" in query ? n.vlanId === query.vlanId : n.name === query.name);
1232
+ const [network] = networks;
1233
+ if (network === void 0) return yield* new LookupNotFound({
1234
+ resource: "network",
1235
+ query: describeQuery(query),
1236
+ site
1237
+ });
1238
+ if (networks.length > 1) return yield* new LookupAmbiguous({
1239
+ resource: "network",
1240
+ query: describeQuery(query),
1241
+ site,
1242
+ count: networks.length
1243
+ });
1244
+ return yield* attributesOf$1(site.id, network.id);
1245
+ }, Effect.orDie) });
1246
+ const NetworkProvider = () => Provider.effect(Network, Effect.gen(function* () {
1247
+ const run = yield* captureServices;
1248
+ return Network.Provider.of({
1249
+ list: () => run(Effect.gen(function* () {
1250
+ const site = yield* resolveSite();
1251
+ const networks = yield* listNetworks(site.id);
1252
+ return yield* Effect.forEach(networks.filter(isUserDefined$1), (network) => attributesOf$1(site.id, network.id));
1253
+ })),
1254
+ read: ({ olds, output }) => run(Effect.gen(function* () {
1255
+ if (output !== void 0) {
1256
+ const network = yield* getNetwork(output.siteId, output.networkId);
1257
+ return network === void 0 ? void 0 : toAttributes$4(output.siteId, network);
1258
+ }
1259
+ const site = yield* resolveSite(olds.site);
1260
+ const network = (yield* listNetworks(site.id)).find((n) => n.vlanId === olds.vlanId);
1261
+ return network === void 0 ? void 0 : Unowned(yield* attributesOf$1(site.id, network.id));
1262
+ })),
1263
+ diff: ({ news, output }) => run(Effect.gen(function* () {
1264
+ if (output === void 0) return void 0;
1265
+ const replace = (reason) => output.default ? Effect.fail(new DefaultNetworkReplacement({
1266
+ networkName: output.name,
1267
+ reason
1268
+ })) : Effect.succeed({
1269
+ action: "replace",
1270
+ deleteFirst: true
1271
+ });
1272
+ const management = planned(news, "management");
1273
+ if (management !== UNKNOWN && management !== output.management) return yield* replace(`its management would change to ${String(management)}`);
1274
+ const siteProp = plannedString(news, "site");
1275
+ if (siteProp === UNKNOWN) return void 0;
1276
+ const site = yield* resolveSite(siteProp);
1277
+ if (site.id !== output.siteId) return yield* replace(`it would move to site ${site.internalReference}`);
1278
+ })),
1279
+ reconcile: ({ news, output }) => run(Effect.gen(function* () {
1280
+ const site = yield* resolveSite(news.site);
1281
+ const body = withDefaults$1(yield* toBody$2(news, site));
1282
+ const observed = output !== void 0 && output.siteId === site.id ? yield* getNetwork(site.id, output.networkId) : void 0;
1283
+ const write = invalidating(networksKey, site.id);
1284
+ if (observed === void 0) {
1285
+ const created = yield* withConnection(Sdk.createNetwork({
1286
+ siteId: site.id,
1287
+ body
1288
+ })).pipe(write);
1289
+ return toAttributes$4(site.id, created);
1290
+ }
1291
+ if (isCurrent(body, omitKeys(observed, READ_ONLY$3))) return toAttributes$4(site.id, observed);
1292
+ const updated = yield* withConnection(Sdk.updateNetwork({
1293
+ siteId: site.id,
1294
+ networkId: observed.id,
1295
+ body
1296
+ })).pipe(write);
1297
+ return toAttributes$4(site.id, updated);
1298
+ })),
1299
+ delete: ({ output }) => output.default ? Effect.logInfo(`Leaving ${output.name}, the site's default network, in place.`) : run(withConnection(Sdk.deleteNetwork({
1300
+ siteId: output.siteId,
1301
+ networkId: output.networkId
1302
+ })).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(networksKey, output.siteId)))
1303
+ });
1304
+ }));
1305
+ // src/TrafficMatchingList.ts
1306
+ const loadLists = (siteId) => withConnection(Stream.runCollect(Sdk.getTrafficMatchingLists.items({ siteId })));
1307
+ const listsKey = key("trafficMatchingLists");
1308
+ /** A site's traffic-matching lists, listed once per run. */
1309
+ const listTrafficMatchingLists = (siteId) => cached(listsKey, siteId, loadLists(siteId));
1310
+ const toAttributes$3 = (siteId, list) => {
1311
+ const { id, ...config } = list;
1312
+ return {
1313
+ siteId,
1314
+ trafficMatchingListId: id,
1315
+ name: list.name,
1316
+ type: list.type,
1317
+ config
1318
+ };
1319
+ };
1320
+ /** Fields the console reports but rejects in request bodies. */
1321
+ const READ_ONLY$2 = ["id"];
1322
+ /** A list can't be deleted while something references it. */
1323
+ var TrafficMatchingListInUse = class extends Data.TaggedError("TrafficMatchingListInUse") {
1324
+ [UserFacingError] = true;
1325
+ get message() {
1326
+ const by = this.referencedBy.length > 0 ? `firewall policies ${this.referencedBy.map((name) => `"${name}"`).join(", ")}` : `something the provider couldn't identify (${this.detail})`;
1327
+ return `Can't delete traffic-matching list "${this.listName}": it's still referenced by ${by}. Those references aren't managed by this stack (e.g. made in the UI); remove them, then deploy again.`;
1328
+ }
1329
+ };
1330
+ /** User-defined firewall policies that mention `trafficMatchingListId` in a filter. */
1331
+ const policiesReferencing = (siteId, trafficMatchingListId) => withConnection(Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId }))).pipe(Effect.map((policies) => policies.filter((p) => p.metadata.origin === "USER_DEFINED" && JSON.stringify([p.source, p.destination]).includes(trafficMatchingListId)).map((p) => p.name)), Effect.orElseSucceed(() => []));
1332
+ /** The list ID a reference means in `site`. */
1333
+ const resolveTrafficMatchingList = (reference, site) => checkSameSite("traffic-matching list", reference.siteId, site).pipe(Effect.as(reference.trafficMatchingListId));
1334
+ /** A list by ID, from the run's cached lists, which come whole (one request for every list in a plan). */
1335
+ const getList = (siteId, trafficMatchingListId) => listTrafficMatchingLists(siteId).pipe(Effect.map((lists) => lists.find((l) => l.id === trafficMatchingListId)));
1336
+ /**
1337
+ * A named list of IPv4 addresses, IPv6 addresses or ports, which firewall
1338
+ * policies reference instead of repeating values inline. Names are unique
1339
+ * per site, so existing lists are adopted by name with `--adopt`. Changing
1340
+ * `type` updates the list in place.
1341
+ *
1342
+ * @example
1343
+ * ```ts
1344
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
1345
+ * import * as Alchemy from "alchemy";
1346
+ * import * as Effect from "effect/Effect";
1347
+ *
1348
+ * export default Alchemy.Stack(
1349
+ * "HomeNetwork",
1350
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
1351
+ * Effect.gen(function* () {
1352
+ * const haHosts = yield* UnifiNetwork.TrafficMatchingList("ha-hosts", {
1353
+ * type: "IPV4_ADDRESSES",
1354
+ * name: "Home Assistant hosts",
1355
+ * items: [{ type: "IP_ADDRESS", value: "192.168.2.20" }],
1356
+ * });
1357
+ * // Reference it from a policy's filter: { type: "TRAFFIC_MATCHING_LIST", matchOpposite: false, trafficMatchingList: haHosts }
1358
+ * }),
1359
+ * );
1360
+ * ```
1361
+ */
1362
+ const TrafficMatchingList = Resource("UnifiNetwork.TrafficMatchingList")({
1363
+ /**
1364
+ * A list in the site by name. Dies with a user-facing `LookupNotFound`:
1365
+ * stack bodies can't fail with typed errors.
1366
+ */
1367
+ lookup: Effect.fn("UnifiNetwork.TrafficMatchingList.lookup")(function* (query) {
1368
+ const site = yield* resolveSite(query.site);
1369
+ const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === query.name);
1370
+ if (list === void 0) return yield* new LookupNotFound({
1371
+ resource: "traffic-matching list",
1372
+ query: describeQuery(query),
1373
+ site
1374
+ });
1375
+ return toAttributes$3(site.id, list);
1376
+ }, Effect.orDie) });
1377
+ const TrafficMatchingListProvider = () => Provider.effect(TrafficMatchingList, Effect.gen(function* () {
1378
+ const run = yield* captureServices;
1379
+ return TrafficMatchingList.Provider.of({
1380
+ list: () => run(Effect.gen(function* () {
1381
+ const site = yield* resolveSite();
1382
+ return (yield* listTrafficMatchingLists(site.id)).map((list) => toAttributes$3(site.id, list));
1383
+ })),
1384
+ read: ({ olds, output }) => run(Effect.gen(function* () {
1385
+ if (output !== void 0) {
1386
+ const list = yield* getList(output.siteId, output.trafficMatchingListId);
1387
+ return list === void 0 ? void 0 : toAttributes$3(output.siteId, list);
1388
+ }
1389
+ const site = yield* resolveSite(olds.site);
1390
+ const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === olds.name);
1391
+ return list === void 0 ? void 0 : Unowned(toAttributes$3(site.id, list));
1392
+ })),
1393
+ diff: ({ news, output }) => run(Effect.gen(function* () {
1394
+ const siteProp = plannedString(news, "site");
1395
+ if (output === void 0 || siteProp === UNKNOWN) return void 0;
1396
+ if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
1397
+ })),
1398
+ reconcile: ({ news, output }) => run(Effect.gen(function* () {
1399
+ const site = yield* resolveSite(news.site);
1400
+ const { site: _site, ...body } = news;
1401
+ const observed = output !== void 0 && output.siteId === site.id ? yield* getList(site.id, output.trafficMatchingListId) : void 0;
1402
+ const write = invalidating(listsKey, site.id);
1403
+ if (observed === void 0) {
1404
+ const created = yield* withConnection(Sdk.createTrafficMatchingList({
1405
+ siteId: site.id,
1406
+ body
1407
+ })).pipe(write);
1408
+ return toAttributes$3(site.id, created);
1409
+ }
1410
+ if (isCurrent(body, omitKeys(observed, READ_ONLY$2))) return toAttributes$3(site.id, observed);
1411
+ const updated = yield* withConnection(Sdk.updateTrafficMatchingList({
1412
+ siteId: site.id,
1413
+ trafficMatchingListId: observed.id,
1414
+ body
1415
+ })).pipe(write);
1416
+ return toAttributes$3(site.id, updated);
1417
+ })),
1418
+ delete: ({ output }) => run(withConnection(Sdk.deleteTrafficMatchingList({
1419
+ siteId: output.siteId,
1420
+ trafficMatchingListId: output.trafficMatchingListId
1421
+ })).pipe(Effect.asVoid, Effect.catchTags({
1422
+ NotFound: () => Effect.void,
1423
+ UnprocessableEntity: (error) => policiesReferencing(output.siteId, output.trafficMatchingListId).pipe(Effect.flatMap((referencedBy) => Effect.fail(new TrafficMatchingListInUse({
1424
+ listName: output.name,
1425
+ referencedBy,
1426
+ detail: error.message
1427
+ }))))
1428
+ }), invalidating(listsKey, output.siteId)))
1429
+ });
1430
+ }));
1431
+ // src/FirewallPolicy.ts
1432
+ const loadPolicies = (siteId) => withConnection(Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId })));
1433
+ const policiesKey = key("firewallPolicies");
1434
+ /**
1435
+ * A site's firewall policies, listed once per run: all of them, including
1436
+ * system-defined ones and ones UniFi derives from UI Objects.
1437
+ */
1438
+ const listFirewallPolicies = (siteId) => cached(policiesKey, siteId, loadPolicies(siteId));
1439
+ /** Policies the user made, as opposed to system-defined ones and ones UniFi derives (from UI Objects, say), which the provider never touches. */
1440
+ const isUserDefined = (policy) => policy.metadata.origin === "USER_DEFINED";
1441
+ const toAttributes$2 = (siteId, policy) => {
1442
+ const { id, metadata: _metadata, index: _index, ...config } = policy;
1443
+ return {
1444
+ siteId,
1445
+ firewallPolicyId: id,
1446
+ name: policy.name,
1447
+ sourceFirewallZoneId: policy.source.zoneId,
1448
+ destinationFirewallZoneId: policy.destination.zoneId,
1449
+ config
1450
+ };
1451
+ };
1452
+ /** Fields the console reports but rejects in request bodies, or that change without the policy being edited. */
1453
+ const READ_ONLY$1 = [
1454
+ "id",
1455
+ "metadata",
1456
+ "index"
1457
+ ];
1458
+ const resolvePortFilter = Effect.fn(function* (filter, site) {
1459
+ if (filter.type === "PORTS") return filter;
1460
+ const trafficMatchingListId = yield* resolveTrafficMatchingList(filter.trafficMatchingList, site);
1461
+ return {
1462
+ type: "TRAFFIC_MATCHING_LIST",
1463
+ matchOpposite: filter.matchOpposite,
1464
+ trafficMatchingListId
1465
+ };
1466
+ });
1467
+ const resolveIpAddressFilter = Effect.fn(function* (filter, site) {
1468
+ if (filter.type === "IP_ADDRESSES") return filter;
1469
+ const trafficMatchingListId = yield* resolveTrafficMatchingList(filter.trafficMatchingList, site);
1470
+ return {
1471
+ type: "TRAFFIC_MATCHING_LIST",
1472
+ matchOpposite: filter.matchOpposite,
1473
+ trafficMatchingListId
1474
+ };
1475
+ });
1476
+ const resolveNetworkFilter = (filter, site) => Effect.forEach(filter.networks, (network) => resolveNetwork(network, site)).pipe(Effect.map((networkIds) => ({
1477
+ matchOpposite: filter.matchOpposite,
1478
+ networkIds
1479
+ })));
1480
+ /** `filter` with its optional `portFilter` resolved (left out when unset). */
1481
+ const withPortFilter = Effect.fn(function* (filter, site) {
1482
+ const { portFilter, ...rest } = filter;
1483
+ if (portFilter === void 0) return rest;
1484
+ return {
1485
+ ...rest,
1486
+ portFilter: yield* resolvePortFilter(portFilter, site)
1487
+ };
1488
+ });
1489
+ const resolveSourceFilter = Effect.fn(function* (filter, site) {
1490
+ switch (filter.type) {
1491
+ case "IP_ADDRESS": {
1492
+ const { ipAddressFilter, ...rest } = yield* withPortFilter(filter, site);
1493
+ return {
1494
+ ...rest,
1495
+ ipAddressFilter: yield* resolveIpAddressFilter(ipAddressFilter, site)
1496
+ };
1497
+ }
1498
+ case "NETWORK": {
1499
+ const { networkFilter, ...rest } = yield* withPortFilter(filter, site);
1500
+ return {
1501
+ ...rest,
1502
+ networkFilter: yield* resolveNetworkFilter(networkFilter, site)
1503
+ };
1504
+ }
1505
+ case "PORT": return {
1506
+ ...filter,
1507
+ portFilter: yield* resolvePortFilter(filter.portFilter, site)
1508
+ };
1509
+ case "IPV6_IID": return yield* withPortFilter(filter, site);
1510
+ case "MAC_ADDRESS": return yield* withPortFilter(filter, site);
1511
+ case "REGION": return yield* withPortFilter(filter, site);
1512
+ case "SITE_TO_SITE_VPN_TUNNEL": return yield* withPortFilter(filter, site);
1513
+ case "VPN_SERVER": return yield* withPortFilter(filter, site);
1514
+ }
1515
+ });
1516
+ const resolveDestinationFilter = Effect.fn(function* (filter, site) {
1517
+ switch (filter.type) {
1518
+ case "IP_ADDRESS": {
1519
+ const { ipAddressFilter, ...rest } = yield* withPortFilter(filter, site);
1520
+ return {
1521
+ ...rest,
1522
+ ipAddressFilter: yield* resolveIpAddressFilter(ipAddressFilter, site)
1523
+ };
1524
+ }
1525
+ case "NETWORK": {
1526
+ const { networkFilter, ...rest } = yield* withPortFilter(filter, site);
1527
+ return {
1528
+ ...rest,
1529
+ networkFilter: yield* resolveNetworkFilter(networkFilter, site)
1530
+ };
1531
+ }
1532
+ case "PORT": return {
1533
+ ...filter,
1534
+ portFilter: yield* resolvePortFilter(filter.portFilter, site)
1535
+ };
1536
+ case "APPLICATION": return yield* withPortFilter(filter, site);
1537
+ case "APPLICATION_CATEGORY": return yield* withPortFilter(filter, site);
1538
+ case "DOMAIN": return yield* withPortFilter(filter, site);
1539
+ case "IPV6_IID": return yield* withPortFilter(filter, site);
1540
+ case "REGION": return yield* withPortFilter(filter, site);
1541
+ case "SITE_TO_SITE_VPN_TUNNEL": return yield* withPortFilter(filter, site);
1542
+ case "VPN_SERVER": return yield* withPortFilter(filter, site);
1543
+ }
1544
+ });
1545
+ /** The request body for `props` (without `siteId`), with references resolved to IDs. */
1546
+ const toBody$1 = Effect.fn(function* (props, site) {
1547
+ const { site: _site, source, destination, ...rest } = props;
1548
+ const sourceFilter = source.trafficFilter;
1549
+ const destinationFilter = destination.trafficFilter;
1550
+ return {
1551
+ ...rest,
1552
+ source: {
1553
+ zoneId: yield* resolveFirewallZone(source.zone, site),
1554
+ ...sourceFilter === void 0 ? {} : { trafficFilter: yield* resolveSourceFilter(sourceFilter, site) }
1555
+ },
1556
+ destination: {
1557
+ zoneId: yield* resolveFirewallZone(destination.zone, site),
1558
+ ...destinationFilter === void 0 ? {} : { trafficFilter: yield* resolveDestinationFilter(destinationFilter, site) }
1559
+ }
1560
+ };
1561
+ });
1562
+ /**
1563
+ * A policy by ID, from the run's cached list: the list returns whole
1564
+ * policies, so a plan reading every policy costs one request, not one each
1565
+ * (this matters through the ui.com connector's rate limit).
1566
+ */
1567
+ const getPolicy = (siteId, firewallPolicyId) => listFirewallPolicies(siteId).pipe(Effect.map((policies) => policies.find((p) => p.id === firewallPolicyId)));
1568
+ /** User-defined policies named `name` from `source` to `destination`. Names aren't unique, even within a zone pair. */
1569
+ const findByName = Effect.fn(function* (site, name, source, destination) {
1570
+ const sourceId = source === void 0 ? void 0 : yield* resolveFirewallZone(source, site);
1571
+ const destinationId = destination === void 0 ? void 0 : yield* resolveFirewallZone(destination, site);
1572
+ return (yield* listFirewallPolicies(site.id)).filter((p) => isUserDefined(p) && p.name === name && (sourceId === void 0 || p.source.zoneId === sourceId) && (destinationId === void 0 || p.destination.zoneId === destinationId));
1573
+ });
1574
+ /**
1575
+ * A zone-based firewall policy: allow, block or reject traffic from one
1576
+ * zone to another, optionally narrowed by traffic filters. Changing its
1577
+ * zones updates it in place and moves it to the new zone pair's ordering
1578
+ * (see `FirewallPolicyOrdering`).
1579
+ *
1580
+ * Existing user-defined policies are adopted with `--adopt` by name and zone
1581
+ * pair (names aren't unique, so several matches adopt nothing). Policies
1582
+ * UniFi derives, e.g. from UI Objects, are invisible: never listed, adopted
1583
+ * or ordered.
1584
+ *
1585
+ * @example
1586
+ * ```ts
1587
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
1588
+ * import * as Alchemy from "alchemy";
1589
+ * import * as Effect from "effect/Effect";
1590
+ *
1591
+ * export default Alchemy.Stack(
1592
+ * "HomeNetwork",
1593
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
1594
+ * Effect.gen(function* () {
1595
+ * const iot = yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
1596
+ * yield* UnifiNetwork.FirewallPolicy("block-iot-to-internal", {
1597
+ * name: "Block IoT to Internal",
1598
+ * enabled: true,
1599
+ * action: { type: "BLOCK" },
1600
+ * source: { zone: iot },
1601
+ * destination: { zone: UnifiNetwork.FirewallZone.INTERNAL },
1602
+ * ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
1603
+ * loggingEnabled: false,
1604
+ * });
1605
+ * }),
1606
+ * );
1607
+ * ```
1608
+ */
1609
+ const FirewallPolicy = Resource("UnifiNetwork.FirewallPolicy")({
1610
+ /**
1611
+ * A user-defined policy by name, optionally narrowed to a zone pair. Dies
1612
+ * with a user-facing `LookupNotFound` or `LookupAmbiguous`: stack bodies
1613
+ * can't fail with typed errors.
1614
+ */
1615
+ lookup: Effect.fn("UnifiNetwork.FirewallPolicy.lookup")(function* (query) {
1616
+ const site = yield* resolveSite(query.site);
1617
+ const policies = yield* findByName(site, query.name, query.source, query.destination);
1618
+ const [policy] = policies;
1619
+ const described = describeQuery({ name: query.name });
1620
+ if (policy === void 0) return yield* new LookupNotFound({
1621
+ resource: "firewall policy",
1622
+ query: described,
1623
+ site
1624
+ });
1625
+ if (policies.length > 1) return yield* new LookupAmbiguous({
1626
+ resource: "firewall policy",
1627
+ query: described,
1628
+ site,
1629
+ count: policies.length
1630
+ });
1631
+ return toAttributes$2(site.id, policy);
1632
+ }, Effect.orDie) });
1633
+ const FirewallPolicyProvider = () => Provider.effect(FirewallPolicy, Effect.gen(function* () {
1634
+ const run = yield* captureServices;
1635
+ return FirewallPolicy.Provider.of({
1636
+ list: () => run(Effect.gen(function* () {
1637
+ const site = yield* resolveSite();
1638
+ return (yield* listFirewallPolicies(site.id)).filter(isUserDefined).map((policy) => toAttributes$2(site.id, policy));
1639
+ })),
1640
+ read: ({ olds, output }) => run(Effect.gen(function* () {
1641
+ if (output !== void 0) {
1642
+ const policy = yield* getPolicy(output.siteId, output.firewallPolicyId);
1643
+ return policy === void 0 || !isUserDefined(policy) ? void 0 : toAttributes$2(output.siteId, policy);
1644
+ }
1645
+ const site = yield* resolveSite(olds.site);
1646
+ const matching = yield* findByName(site, olds.name, olds.source.zone, olds.destination.zone);
1647
+ const [policy] = matching;
1648
+ return policy === void 0 || matching.length > 1 ? void 0 : Unowned(toAttributes$2(site.id, policy));
1649
+ })),
1650
+ diff: ({ news, output }) => run(Effect.gen(function* () {
1651
+ const siteProp = plannedString(news, "site");
1652
+ if (output === void 0 || siteProp === UNKNOWN) return void 0;
1653
+ if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
1654
+ })),
1655
+ reconcile: ({ news, output }) => run(Effect.gen(function* () {
1656
+ const site = yield* resolveSite(news.site);
1657
+ const body = yield* toBody$1(news, site);
1658
+ const observed = output !== void 0 && output.siteId === site.id ? yield* getPolicy(site.id, output.firewallPolicyId) : void 0;
1659
+ const write = invalidating(policiesKey, site.id);
1660
+ if (observed === void 0) {
1661
+ const created = yield* withConnection(Sdk.createFirewallPolicy({
1662
+ siteId: site.id,
1663
+ ...body
1664
+ })).pipe(write);
1665
+ return toAttributes$2(site.id, created);
1666
+ }
1667
+ if (isCurrent(sortArrays(body), sortArrays(omitKeys(observed, READ_ONLY$1)))) return toAttributes$2(site.id, observed);
1668
+ const updated = yield* withConnection(Sdk.updateFirewallPolicy({
1669
+ ...body,
1670
+ siteId: site.id,
1671
+ firewallPolicyId: observed.id
1672
+ })).pipe(write);
1673
+ return toAttributes$2(site.id, updated);
1674
+ })),
1675
+ delete: ({ output }) => run(withConnection(Sdk.deleteFirewallPolicy({
1676
+ siteId: output.siteId,
1677
+ firewallPolicyId: output.firewallPolicyId
1678
+ })).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(policiesKey, output.siteId)))
1679
+ });
1680
+ }));
1681
+ // src/State.ts
1682
+ /**
1683
+ * State store on the console itself (see `docs/development/state-store.md`). Each stack and
1684
+ * stage is one disabled Gateway → Gateway firewall policy whose description
1685
+ * holds the stage's state, and a traffic-matching list (whose names, unlike
1686
+ * policies', are unique) is its lock.
1687
+ */
1688
+ /** Name prefix of the policies holding state. Other code leaves these alone. */
1689
+ const STATE_POLICY_PREFIX = "alchemy-state/";
1690
+ /** Name prefix of the traffic-matching lists that lock a stage. */
1691
+ const STATE_LOCK_PREFIX = "alchemy-lock/";
1692
+ /** Prefix of a state policy's description, ahead of the base64 of the Brotli-compressed JSON. */
1693
+ const FORMAT = "alchemy-state:v1:";
1694
+ const DEFAULT_WARN_AT = 4e6;
1695
+ /** How long a lock stays valid without a renewal. */
1696
+ const LOCK_TTL_MS = 12e4;
1697
+ const RENEW_EVERY_MS = 3e4;
1698
+ /** How long a passing check that the lock still exists is trusted. */
1699
+ const LOCK_CHECK_TTL_MS = 5e3;
1700
+ const policyName = (stack, stage) => `${STATE_POLICY_PREFIX}${stack}/${stage}`;
1701
+ const lockName = (stack, stage) => `${STATE_LOCK_PREFIX}${stack}/${stage}`;
1702
+ const parsePolicyName = (name) => {
1703
+ const [stack, stage, ...rest] = name.slice(14).split("/");
1704
+ return stack === void 0 || stage === void 0 || rest.length > 0 ? void 0 : {
1705
+ stack,
1706
+ stage
1707
+ };
1708
+ };
1709
+ const encodeSnapshot = (snapshot) => FORMAT + zlib.brotliCompressSync(JSON.stringify(snapshot), { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 9 } }).toString("base64");
1710
+ const isLockRecord = (value) => Predicate.hasProperty(value, "listId") && typeof value.listId === "string" && Predicate.hasProperty(value, "expiresAt") && typeof value.expiresAt === "number";
1711
+ const decodeSnapshot = (policy) => Effect.try({
1712
+ try: () => {
1713
+ if (!policy.description.startsWith(FORMAT)) throw new Error("its description isn't in a known format");
1714
+ const parsed = JSON.parse(zlib.brotliDecompressSync(Buffer.from(policy.description.slice(17), "base64")).toString());
1715
+ if (!Predicate.isObject(parsed) || !Predicate.isObject(parsed["resources"])) throw new Error("it has no resources");
1716
+ return {
1717
+ lock: isLockRecord(parsed["lock"]) ? parsed["lock"] : void 0,
1718
+ resources: { ...parsed["resources"] },
1719
+ output: parsed["output"]
1720
+ };
1721
+ },
1722
+ catch: (cause) => new StateStoreError({
1723
+ message: `UniFi state policy '${policy.name}' can't be read: ${cause instanceof Error ? cause.message : String(cause)}`,
1724
+ ...cause instanceof Error ? { cause } : {}
1725
+ })
1726
+ });
1727
+ /** A copy of a stored value, revived (e.g. `Redacted` values rebuilt). */
1728
+ const revive = (encoded) => encoded === void 0 ? void 0 : JSON.parse(JSON.stringify(encoded), reviveState);
1729
+ const isRecord = (value) => Predicate.isObject(value);
1730
+ const isReplaced = (value) => isRecord(value) && "status" in value && value.status === "replaced";
1731
+ const heldError = (stack, stage, detail) => new StateStoreError({ message: `Another run holds the UniFi state lock on '${stack}/${stage}': ${detail}` });
1732
+ /**
1733
+ * A {@link StateService} over `backend`. The first operation on a stage
1734
+ * locks it and loads its state, and the lock is released when `Scope`
1735
+ * closes.
1736
+ */
1737
+ const makeUnifiState = (backend, options = {}) => Effect.gen(function* () {
1738
+ const scope = yield* Effect.scope;
1739
+ const warnAt = options.warnAt ?? 4e6;
1740
+ let warned = false;
1741
+ const findPolicy = Effect.fn(function* (stack, stage) {
1742
+ const name = policyName(stack, stage);
1743
+ const found = (yield* backend.listPolicies).filter((p) => p.name === name);
1744
+ if (found.length > 1) return yield* new StateStoreError({ message: `Several firewall policies are named '${name}'. Only one may hold this stage's state: delete the others (keep the one with the newest state).` });
1745
+ return found[0];
1746
+ });
1747
+ const takeLock = Effect.fn(function* (stack, stage) {
1748
+ const name = lockName(stack, stage);
1749
+ for (let attempt = 0; attempt < 2; attempt++) {
1750
+ const created = yield* backend.createLock(name);
1751
+ if (created !== void 0) return created;
1752
+ const holder = yield* backend.findLock(name);
1753
+ if (holder === void 0) continue;
1754
+ const policy = yield* findPolicy(stack, stage);
1755
+ const record = policy === void 0 ? void 0 : (yield* decodeSnapshot(policy)).lock;
1756
+ if (record === void 0 || record.listId !== holder) return yield* heldError(stack, stage, `the traffic-matching list '${name}' exists, but no run has recorded holding it. A run may be starting, or one crashed before recording it. If nothing is using this stage, delete the list in the UniFi UI.`);
1757
+ const now = yield* Clock.currentTimeMillis;
1758
+ if (record.expiresAt > now) return yield* heldError(stack, stage, `it expires at ${new Date(record.expiresAt).toISOString()} unless renewed.`);
1759
+ yield* backend.deleteLock(holder);
1760
+ }
1761
+ return yield* heldError(stack, stage, `the traffic-matching list '${name}' keeps reappearing.`);
1762
+ });
1763
+ const checkLock = (s) => Effect.gen(function* () {
1764
+ const lostError = new StateStoreError({ message: `The UniFi state lock on '${s.stack}/${s.stage}' was lost mid-run (its traffic-matching list was deleted); refusing to continue unlocked.` });
1765
+ if (s.lost) return yield* lostError;
1766
+ const now = yield* Clock.currentTimeMillis;
1767
+ if (s.lockCheckedAt !== void 0 && now - s.lockCheckedAt < LOCK_CHECK_TTL_MS) return;
1768
+ if (!(yield* backend.lockExists(s.listId))) {
1769
+ s.lost = true;
1770
+ return yield* lostError;
1771
+ }
1772
+ s.lockCheckedAt = now;
1773
+ });
1774
+ /**
1775
+ * Writes the stage unless `upTo` is already on the console. Without
1776
+ * `upTo` (a renewal) it writes only an existing policy.
1777
+ */
1778
+ const flush = (s, upTo) => Semaphore.withPermits(s.writer, 1)(Effect.gen(function* () {
1779
+ if (s.closed) return yield* new StateStoreError({ message: `The UniFi state store for '${s.stack}/${s.stage}' is closed.` });
1780
+ if (upTo === void 0 ? s.policyId === void 0 : s.written >= upTo) return;
1781
+ yield* checkLock(s);
1782
+ const version = s.version;
1783
+ const expiresAt = (yield* Clock.currentTimeMillis) + LOCK_TTL_MS;
1784
+ const description = encodeSnapshot({
1785
+ lock: {
1786
+ listId: s.listId,
1787
+ expiresAt
1788
+ },
1789
+ resources: s.resources,
1790
+ output: s.output
1791
+ });
1792
+ if (description.length > warnAt && !warned) {
1793
+ warned = true;
1794
+ yield* Effect.logWarning(`UniFi state for '${s.stack}/${s.stage}' is ${description.length} bytes, over the ${warnAt}-byte warning size. The console stores up to about 16 MiB directly and 7.5 MiB through ui.com: consider moving this stack to another state store.`);
1795
+ }
1796
+ const name = policyName(s.stack, s.stage);
1797
+ if (s.policyId === void 0) s.policyId = yield* backend.createPolicy(name, description);
1798
+ else yield* backend.updatePolicy(s.policyId, name, description);
1799
+ s.written = version;
1800
+ }));
1801
+ const openStage = Effect.fn(function* (stack, stage) {
1802
+ const listId = yield* takeLock(stack, stage);
1803
+ const s = {
1804
+ stack,
1805
+ stage,
1806
+ listId,
1807
+ policyId: void 0,
1808
+ resources: {},
1809
+ output: void 0,
1810
+ version: 0,
1811
+ written: 0,
1812
+ lockCheckedAt: yield* Clock.currentTimeMillis,
1813
+ lost: false,
1814
+ closed: false,
1815
+ writer: Semaphore.makeUnsafe(1)
1816
+ };
1817
+ yield* Scope.addFinalizer(scope, Semaphore.withPermits(s.writer, 1)(Effect.suspend(() => {
1818
+ s.closed = true;
1819
+ return backend.deleteLock(listId);
1820
+ }).pipe(Effect.ignore)));
1821
+ const policy = yield* findPolicy(stack, stage);
1822
+ if (policy !== void 0) {
1823
+ const snapshot = yield* decodeSnapshot(policy);
1824
+ s.policyId = policy.id;
1825
+ s.resources = { ...snapshot.resources };
1826
+ s.output = snapshot.output;
1827
+ yield* flush(s);
1828
+ }
1829
+ yield* flush(s).pipe(Effect.ignore, Effect.delay(RENEW_EVERY_MS), Effect.forever, Effect.interruptible, Effect.forkIn(scope));
1830
+ return s;
1831
+ });
1832
+ const stages = /* @__PURE__ */ new Map();
1833
+ const opening = Semaphore.makeUnsafe(1);
1834
+ const stageFor = (request) => Semaphore.withPermits(opening, 1)(Effect.gen(function* () {
1835
+ const key = `${request.stack}/${request.stage}`;
1836
+ const existing = stages.get(key);
1837
+ if (existing !== void 0) return existing;
1838
+ const opened = yield* openStage(request.stack, request.stage);
1839
+ stages.set(key, opened);
1840
+ return opened;
1841
+ }));
1842
+ const change = (request, apply) => stageFor(request).pipe(Effect.flatMap((s) => {
1843
+ apply(s);
1844
+ s.version++;
1845
+ return flush(s, s.version);
1846
+ }));
1847
+ const deleteStage = (stack, stage) => stageFor({
1848
+ stack,
1849
+ stage
1850
+ }).pipe(Effect.flatMap((s) => Semaphore.withPermits(s.writer, 1)(Effect.gen(function* () {
1851
+ yield* checkLock(s);
1852
+ if (s.policyId !== void 0) yield* backend.deletePolicy(s.policyId);
1853
+ s.policyId = void 0;
1854
+ s.resources = {};
1855
+ s.output = void 0;
1856
+ s.written = s.version;
1857
+ }))));
1858
+ const stored = Effect.map(backend.listPolicies, (policies) => policies.flatMap((p) => {
1859
+ const parsed = parsePolicyName(p.name);
1860
+ return parsed === void 0 ? [] : [parsed];
1861
+ }));
1862
+ const listStages = (stack) => Effect.map(stored, (all) => [...new Set(all.filter((s) => s.stack === stack).map((s) => s.stage))].toSorted());
1863
+ return {
1864
+ id: "unifi-network",
1865
+ getVersion: () => Effect.succeed(STATE_STORE_VERSION),
1866
+ listStacks: () => Effect.map(stored, (all) => [...new Set(all.map((s) => s.stack))].toSorted()),
1867
+ listStages,
1868
+ get: (request) => Effect.map(stageFor(request), (s) => {
1869
+ const record = revive(s.resources[request.fqn]);
1870
+ return isRecord(record) ? record : void 0;
1871
+ }),
1872
+ getReplacedResources: (request) => Effect.map(stageFor(request), (s) => Object.values(s.resources).map(revive).filter(isReplaced)),
1873
+ list: (request) => Effect.map(stageFor(request), (s) => Object.keys(s.resources).toSorted()),
1874
+ set: (request) => change(request, (s) => {
1875
+ s.resources[request.fqn] = encodeState(request.value);
1876
+ }).pipe(Effect.as(request.value)),
1877
+ delete: (request) => change(request, (s) => {
1878
+ delete s.resources[request.fqn];
1879
+ }),
1880
+ deleteStack: ({ stack, stage }) => stage === void 0 ? Effect.flatMap(listStages(stack), (found) => Effect.forEach(found, (each) => deleteStage(stack, each), { discard: true })) : deleteStage(stack, stage),
1881
+ getOutput: (request) => Effect.map(stageFor(request), (s) => revive(s.output)),
1882
+ setOutput: (request) => change(request, (s) => {
1883
+ s.output = encodeState(request.value);
1884
+ }).pipe(Effect.as(request.value))
1885
+ };
1886
+ });
1887
+ const toStateStoreError = (what) => (cause) => new StateStoreError({
1888
+ message: `UniFi state store: ${what} failed: ${describeError(cause)}`,
1889
+ ...cause instanceof Error ? { cause } : {}
1890
+ });
1891
+ const isAlreadyExists = (error) => Predicate.hasProperty(error, "_tag") && error._tag === "UnprocessableEntity" && Predicate.hasProperty(error, "message") && typeof error.message === "string" && error.message.includes("already exist");
1892
+ const policyBody = (zoneId, name, description) => ({
1893
+ enabled: false,
1894
+ name,
1895
+ description,
1896
+ action: { type: "BLOCK" },
1897
+ source: { zoneId },
1898
+ destination: { zoneId },
1899
+ ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
1900
+ loggingEnabled: false
1901
+ });
1902
+ const describeWrite = (description) => `writing ${description.length} bytes of state (a console stores up to about 16 MiB directly, 7.5 MiB through ui.com)`;
1903
+ /**
1904
+ * Runs with Debug and Trace records off: request bodies are the state,
1905
+ * which holds secrets (e.g. Wi-Fi passphrases). Floors already above Info
1906
+ * are left alone.
1907
+ */
1908
+ const withoutDebugLogs = Effect.updateService(References.MinimumLogLevel, (level) => LogLevel.isLessThan(level, "Info") ? "Info" : level);
1909
+ /** The {@link StateBackend} of the profile's console and site. */
1910
+ const consoleBackend = Effect.gen(function* () {
1911
+ const { id: siteId } = yield* resolveSite();
1912
+ const context = yield* Effect.context();
1913
+ const run = (what, effect) => withConnection(effect).pipe(Effect.mapError(toStateStoreError(what)), Effect.provideContext(context), withoutDebugLogs);
1914
+ const gatewayZoneId = yield* Effect.cached(run("finding the Gateway zone", Stream.runCollect(Sdk.getFirewallZones.items({ siteId }))).pipe(Effect.flatMap((zones) => {
1915
+ const zone = zones.find((z) => z.metadata.origin === "SYSTEM_DEFINED" && z.name === "Gateway");
1916
+ return zone === void 0 ? Effect.fail(new StateStoreError({ message: "UniFi state store: the site has no Gateway zone." })) : Effect.succeed(zone.id);
1917
+ })));
1918
+ return {
1919
+ listPolicies: run("listing state policies", Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId })).pipe(Effect.map((policies) => policies.flatMap((p) => p.metadata.origin === "USER_DEFINED" && p.name.startsWith("alchemy-state/") ? [{
1920
+ id: p.id,
1921
+ name: p.name,
1922
+ description: p.description ?? ""
1923
+ }] : [])))),
1924
+ createPolicy: (name, description) => Effect.flatMap(gatewayZoneId, (zoneId) => run(describeWrite(description), Sdk.createFirewallPolicy({
1925
+ siteId,
1926
+ ...policyBody(zoneId, name, description)
1927
+ }))).pipe(Effect.map((created) => created.id)),
1928
+ updatePolicy: (id, name, description) => Effect.flatMap(gatewayZoneId, (zoneId) => run(describeWrite(description), Sdk.updateFirewallPolicy({
1929
+ siteId,
1930
+ firewallPolicyId: id,
1931
+ ...policyBody(zoneId, name, description)
1932
+ }))).pipe(Effect.asVoid),
1933
+ deletePolicy: (id) => run("deleting a state policy", Sdk.deleteFirewallPolicy({
1934
+ siteId,
1935
+ firewallPolicyId: id
1936
+ }).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void))),
1937
+ createLock: (name) => run("taking the lock", Sdk.createTrafficMatchingList({
1938
+ siteId,
1939
+ body: {
1940
+ type: "PORTS",
1941
+ name,
1942
+ items: [{
1943
+ type: "PORT_NUMBER",
1944
+ value: 1
1945
+ }]
1946
+ }
1947
+ }).pipe(Effect.map((created) => created.id), Effect.catchIf(isAlreadyExists, () => Effect.undefined))),
1948
+ findLock: (name) => run("finding the lock", Stream.runCollect(Sdk.getTrafficMatchingLists.items({ siteId })).pipe(Effect.map((lists) => lists.find((l) => l.name === name)?.id))),
1949
+ lockExists: (id) => run("checking the lock", Sdk.getTrafficMatchingList({
1950
+ siteId,
1951
+ trafficMatchingListId: id
1952
+ }).pipe(Effect.as(true), Effect.catchTag("NotFound", () => Effect.succeed(false)))),
1953
+ deleteLock: (id) => run("releasing the lock", Sdk.deleteTrafficMatchingList({
1954
+ siteId,
1955
+ trafficMatchingListId: id
1956
+ }).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void)))
1957
+ };
1958
+ }).pipe(Effect.mapError((error) => error instanceof StateStoreError ? error : toStateStoreError("finding the site")(error)));
1959
+ /**
1960
+ * State store on the console the profile points at: each stack and stage
1961
+ * is a disabled Gateway → Gateway firewall policy holding the stage's
1962
+ * compressed state, locked with a traffic-matching list while a run uses
1963
+ * it. Nothing connects until the first state operation.
1964
+ *
1965
+ * The state includes secrets in plaintext, as in every Alchemy store: a
1966
+ * UniFi stack's are already on the console, but another provider's
1967
+ * resources in the same stack would put theirs there too.
1968
+ *
1969
+ * @example
1970
+ * ```ts
1971
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
1972
+ * import * as Alchemy from "alchemy";
1973
+ * import * as Effect from "effect/Effect";
1974
+ *
1975
+ * export default Alchemy.Stack(
1976
+ * "HomeNetwork",
1977
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
1978
+ * Effect.gen(function* () {
1979
+ * yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
1980
+ * }),
1981
+ * );
1982
+ * ```
1983
+ */
1984
+ const state = (options = {}) => Layer.effect(State, Effect.gen(function* () {
1985
+ const scope = yield* Effect.scope;
1986
+ const context = yield* Effect.context();
1987
+ const make = Effect.flatMap(consoleBackend, (backend) => makeUnifiState(backend, options)).pipe(Scope.provide(scope), recordStateStoreInit, Effect.orDie, Effect.provideContext(context));
1988
+ return yield* Effect.cached(make);
1989
+ })).pipe(Layer.provide(Layer.merge(layer$1, layer).pipe(Layer.provideMerge(fromAuthProvider()), Layer.provideMerge(UnifiNetworkAuth))), Layer.orDie);
1990
+ // src/FirewallPolicyOrdering.ts
1991
+ /** An ordering that can't be applied as given. */
1992
+ var InvalidFirewallPolicyOrdering = class extends Data.TaggedError("InvalidFirewallPolicyOrdering") {
1993
+ [UserFacingError] = true;
1994
+ get message() {
1995
+ return `Can't apply the firewall policy ordering: ${this.reason}`;
1996
+ }
1997
+ };
1998
+ const sameOrder = (a, b) => a.beforeSystemDefined.join() === b.beforeSystemDefined.join() && a.afterSystemDefined.join() === b.afterSystemDefined.join();
1999
+ const resolvePair = Effect.fn(function* (props) {
2000
+ const site = yield* resolveSite(props.site);
2001
+ return {
2002
+ site,
2003
+ siteId: site.id,
2004
+ sourceFirewallZoneId: yield* resolveFirewallZone(props.source, site),
2005
+ destinationFirewallZoneId: yield* resolveFirewallZone(props.destination, site)
2006
+ };
2007
+ });
2008
+ const getOrder = ({ siteId, sourceFirewallZoneId, destinationFirewallZoneId }) => withConnection(Sdk.getFirewallPolicyOrdering({
2009
+ siteId,
2010
+ sourceFirewallZoneId,
2011
+ destinationFirewallZoneId
2012
+ })).pipe(Effect.map((ordering) => ordering.orderedFirewallPolicyIds));
2013
+ const toAttributes$1 = (pair, order, listedFirewallPolicyIds) => ({
2014
+ siteId: pair.siteId,
2015
+ sourceFirewallZoneId: pair.sourceFirewallZoneId,
2016
+ destinationFirewallZoneId: pair.destinationFirewallZoneId,
2017
+ beforeSystemDefined: order.beforeSystemDefined,
2018
+ afterSystemDefined: order.afterSystemDefined,
2019
+ listedFirewallPolicyIds
2020
+ });
2021
+ const resolvePolicies = (references, site) => Effect.forEach(references ?? [], (reference) => checkSameSite("firewall policy", reference.siteId, site).pipe(Effect.as(reference.firewallPolicyId)));
2022
+ /** Why a listed policy isn't in the zone pair's ordering. */
2023
+ const explainMissing = Effect.fn(function* (site, firewallPolicyId, pair) {
2024
+ const policy = (yield* listFirewallPolicies(site.id)).find((p) => p.id === firewallPolicyId);
2025
+ if (policy === void 0) return `policy ${firewallPolicyId} doesn't exist`;
2026
+ if (!isUserDefined(policy)) return `"${policy.name}" isn't user-defined, so it can't be ordered`;
2027
+ if (policy.source.zoneId !== pair.sourceFirewallZoneId || policy.destination.zoneId !== pair.destinationFirewallZoneId) return `"${policy.name}" belongs to another zone pair`;
2028
+ return `"${policy.name}" isn't in the zone pair's ordering`;
2029
+ });
2030
+ /**
2031
+ * The state store's policies (`UnifiNetwork.state()`) among `ids`. The store
2032
+ * may have created one since the run's policy list was read, so policies
2033
+ * missing from it are fetched.
2034
+ */
2035
+ const statePolicies = Effect.fn(function* (site, ids) {
2036
+ const policies = yield* listFirewallPolicies(site.id);
2037
+ const names = yield* Effect.forEach(ids, (id) => {
2038
+ const listed = policies.find((p) => p.id === id);
2039
+ return listed !== void 0 ? Effect.succeed(listed.name) : withConnection(Sdk.getFirewallPolicy({
2040
+ siteId: site.id,
2041
+ firewallPolicyId: id
2042
+ })).pipe(Effect.map((policy) => policy.name), Effect.catchTag("NotFound", () => Effect.undefined));
2043
+ });
2044
+ return ids.filter((_, i) => names[i]?.startsWith(STATE_POLICY_PREFIX) === true);
2045
+ });
2046
+ const describePolicies = Effect.fn(function* (site, ids) {
2047
+ const policies = yield* listFirewallPolicies(site.id);
2048
+ return ids.map((id) => `"${policies.find((p) => p.id === id)?.name ?? id}"`).join(", ");
2049
+ });
2050
+ /**
2051
+ * The evaluation order of user-defined policies from one zone to another.
2052
+ * Listing policies makes the ordering depend on them, so it's applied after
2053
+ * they're created or moved, and re-applied when the list changes.
2054
+ *
2055
+ * UniFi rejects an ordering that leaves out any of the pair's policies, so
2056
+ * unlisted ones (e.g. made in the UI) are kept after the listed ones in each
2057
+ * bucket, in their current relative order, with a warning; `exclusive: true`
2058
+ * fails instead. A policy the ordering listed before but no longer does is
2059
+ * kept quietly, even when exclusive: it's usually being deleted in the same
2060
+ * deploy, which runs after the ordering is applied. The state store's
2061
+ * policies (`UnifiNetwork.state()`, Gateway → Gateway) are kept quietly too.
2062
+ * System-defined policies sit between the two buckets and
2063
+ * can't be reordered; policies UniFi derives from UI Objects aren't ordered
2064
+ * at all.
2065
+ *
2066
+ * The zone pair is the identity: an existing ordering is adopted without
2067
+ * `--adopt`, and deleting the resource leaves the order as it is.
2068
+ *
2069
+ * @example
2070
+ * ```ts
2071
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
2072
+ * import * as Alchemy from "alchemy";
2073
+ * import * as Effect from "effect/Effect";
2074
+ *
2075
+ * export default Alchemy.Stack(
2076
+ * "HomeNetwork",
2077
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
2078
+ * Effect.gen(function* () {
2079
+ * const { INTERNAL } = UnifiNetwork.FirewallZone;
2080
+ * const iot = yield* UnifiNetwork.FirewallZone("iot-zone", { name: "IoT" });
2081
+ * const policy = { enabled: true, source: { zone: INTERNAL }, destination: { zone: iot }, loggingEnabled: false } as const;
2082
+ * const allowHomeAssistant = yield* UnifiNetwork.FirewallPolicy("allow-ha", {
2083
+ * ...policy,
2084
+ * name: "Allow Home Assistant",
2085
+ * action: { type: "ALLOW", allowReturnTraffic: true },
2086
+ * ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
2087
+ * });
2088
+ * const blockTheRest = yield* UnifiNetwork.FirewallPolicy("block-rest", {
2089
+ * ...policy,
2090
+ * name: "Block the rest",
2091
+ * action: { type: "BLOCK" },
2092
+ * ipProtocolScope: { ipVersion: "IPV4_AND_IPV6" },
2093
+ * });
2094
+ * yield* UnifiNetwork.FirewallPolicyOrdering("internal-to-iot", {
2095
+ * source: INTERNAL,
2096
+ * destination: iot,
2097
+ * beforeSystemDefined: [allowHomeAssistant, blockTheRest],
2098
+ * });
2099
+ * }),
2100
+ * );
2101
+ * ```
2102
+ */
2103
+ const FirewallPolicyOrdering = Resource("UnifiNetwork.FirewallPolicyOrdering");
2104
+ const FirewallPolicyOrderingProvider = () => Provider.effect(FirewallPolicyOrdering, Effect.gen(function* () {
2105
+ const run = yield* captureServices;
2106
+ return FirewallPolicyOrdering.Provider.of({
2107
+ list: () => Effect.succeed([]),
2108
+ read: ({ olds, output }) => run(Effect.gen(function* () {
2109
+ if (output !== void 0) return toAttributes$1(output, yield* getOrder(output), output.listedFirewallPolicyIds);
2110
+ const pair = yield* resolvePair(olds);
2111
+ return toAttributes$1(pair, yield* getOrder(pair), []);
2112
+ })),
2113
+ reconcile: ({ news, output }) => run(Effect.gen(function* () {
2114
+ const pair = yield* resolvePair(news);
2115
+ const { site } = pair;
2116
+ const before = yield* resolvePolicies(news.beforeSystemDefined, site);
2117
+ const after = yield* resolvePolicies(news.afterSystemDefined, site);
2118
+ const listed = [...before, ...after];
2119
+ const duplicate = listed.find((id, i) => listed.indexOf(id) !== i);
2120
+ if (duplicate !== void 0) return yield* new InvalidFirewallPolicyOrdering({ reason: `${yield* describePolicies(site, [duplicate])} is listed twice.` });
2121
+ const current = yield* getOrder(pair);
2122
+ const inPair = /* @__PURE__ */ new Set([...current.beforeSystemDefined, ...current.afterSystemDefined]);
2123
+ const missing = listed.find((id) => !inPair.has(id));
2124
+ if (missing !== void 0) return yield* new InvalidFirewallPolicyOrdering({ reason: `${yield* explainMissing(site, missing, pair)}.` });
2125
+ const unlisted = (ids) => ids.filter((id) => !listed.includes(id));
2126
+ const formerlyListed = output?.listedFirewallPolicyIds ?? [];
2127
+ const unlistedNow = [...unlisted(current.beforeSystemDefined), ...unlisted(current.afterSystemDefined)].filter((id) => !formerlyListed.includes(id));
2128
+ const stateIds = yield* statePolicies(site, unlistedNow);
2129
+ const unmanaged = unlistedNow.filter((id) => !stateIds.includes(id));
2130
+ if (unmanaged.length > 0) {
2131
+ const names = yield* describePolicies(site, unmanaged);
2132
+ if (news.exclusive === true) return yield* new InvalidFirewallPolicyOrdering({ reason: `the zone pair has policies the ordering doesn't list: ${names}. List them, delete them, or drop \`exclusive\`.` });
2133
+ yield* Effect.logWarning(`Firewall policy ordering: keeping unlisted policies after the listed ones: ${names}.`);
2134
+ }
2135
+ const desired = {
2136
+ beforeSystemDefined: [...before, ...unlisted(current.beforeSystemDefined)],
2137
+ afterSystemDefined: [...after, ...unlisted(current.afterSystemDefined)]
2138
+ };
2139
+ if (sameOrder(desired, current)) return toAttributes$1(pair, current, listed);
2140
+ const updated = yield* withConnection(Sdk.updateFirewallPolicyOrdering({
2141
+ siteId: pair.siteId,
2142
+ sourceFirewallZoneId: pair.sourceFirewallZoneId,
2143
+ destinationFirewallZoneId: pair.destinationFirewallZoneId,
2144
+ orderedFirewallPolicyIds: desired
2145
+ }));
2146
+ return toAttributes$1(pair, updated.orderedFirewallPolicyIds, listed);
2147
+ })),
2148
+ delete: () => Effect.void
2149
+ });
2150
+ }));
2151
+ // src/WifiBroadcast.ts
2152
+ const loadBroadcasts = (siteId) => withConnection(Stream.runCollect(Sdk.getWifiBroadcastPage.items({ siteId })));
2153
+ const broadcastsKey = key("wifiBroadcasts");
2154
+ /** A site's Wi-Fi broadcasts, listed once per run. */
2155
+ const listWifiBroadcasts = (siteId) => cached(broadcastsKey, siteId, loadBroadcasts(siteId));
2156
+ const toAttributes = (siteId, broadcast) => {
2157
+ const { id, metadata: _metadata, ...config } = broadcast;
2158
+ return {
2159
+ siteId,
2160
+ wifiBroadcastId: id,
2161
+ name: broadcast.name,
2162
+ type: broadcast.type,
2163
+ networkId: broadcast.network?.type === "SPECIFIC" ? broadcast.network.networkId : void 0,
2164
+ config
2165
+ };
2166
+ };
2167
+ const byFrequency = (bands) => bands.toSorted((a, b) => a - b);
2168
+ /**
2169
+ * `body` with its bands sorted (their order means nothing; see
2170
+ * {@link comparable}), and an explicit value for the one unset field that
2171
+ * can't simply be left out (verified): a standard broadcast's
2172
+ * `bandSteeringEnabled`, which the console reports as `false` when unset,
2173
+ * so without the default the body would never match and every deploy would
2174
+ * update it (and drift would report it) forever.
2175
+ */
2176
+ const withDefaults = (body) => body.type === "STANDARD" ? {
2177
+ ...body,
2178
+ bandSteeringEnabled: body.bandSteeringEnabled ?? false,
2179
+ broadcastingFrequenciesGHz: byFrequency(body.broadcastingFrequenciesGHz)
2180
+ } : body;
2181
+ /** Fields the console reports but rejects in request bodies. */
2182
+ const READ_ONLY = ["id", "metadata"];
2183
+ const toBody = Effect.fn(function* (props, site) {
2184
+ const { site: _site, network, ...rest } = props;
2185
+ if (network === void 0) return rest;
2186
+ if ("type" in network || network.default === true) return {
2187
+ ...rest,
2188
+ network: { type: "NATIVE" }
2189
+ };
2190
+ const networkId = yield* resolveNetwork(network, site);
2191
+ return {
2192
+ ...rest,
2193
+ network: {
2194
+ type: "SPECIFIC",
2195
+ networkId
2196
+ }
2197
+ };
2198
+ });
2199
+ /**
2200
+ * The console's broadcast in body form, with its bands sorted: consoles keep
2201
+ * them in an order of their own (not the one sent; the test controller
2202
+ * sorts them), so order mustn't count as a difference.
2203
+ */
2204
+ const comparable = (observed) => {
2205
+ const body = omitKeys(observed, READ_ONLY);
2206
+ return observed.type === "STANDARD" ? {
2207
+ ...body,
2208
+ broadcastingFrequenciesGHz: byFrequency(observed.broadcastingFrequenciesGHz)
2209
+ } : body;
2210
+ };
2211
+ const getBroadcast = (siteId, wifiBroadcastId) => withConnection(Sdk.getWifiBroadcastDetails({
2212
+ siteId,
2213
+ wifiBroadcastId
2214
+ })).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
2215
+ /** The attributes of a broadcast known to exist (e.g. from a list). */
2216
+ const attributesOf = (siteId, wifiBroadcastId) => withConnection(Sdk.getWifiBroadcastDetails({
2217
+ siteId,
2218
+ wifiBroadcastId
2219
+ })).pipe(Effect.map((broadcast) => toAttributes(siteId, broadcast)));
2220
+ /**
2221
+ * A Wi-Fi network (SSID). `type` picks a standard or an IoT-optimised
2222
+ * broadcast; changing it, or the site, replaces the broadcast.
2223
+ *
2224
+ * Props are authoritative: every update sends the whole broadcast, with
2225
+ * explicit defaults for unset fields (a standard broadcast's
2226
+ * `bandSteeringEnabled: false`), so changes made in the UI, passphrases
2227
+ * included, are put back. The `config` attribute mirrors the console's
2228
+ * object, so `alchemy drift` sees them. Existing broadcasts are adopted by name with
2229
+ * `--adopt`.
2230
+ *
2231
+ * @example
2232
+ * ```ts
2233
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
2234
+ * import * as Alchemy from "alchemy";
2235
+ * import * as Config from "effect/Config";
2236
+ * import * as Effect from "effect/Effect";
2237
+ *
2238
+ * export default Alchemy.Stack(
2239
+ * "HomeNetwork",
2240
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
2241
+ * Effect.gen(function* () {
2242
+ * const lan = yield* UnifiNetwork.Network.lookup({ vlanId: 1 });
2243
+ * yield* UnifiNetwork.WifiBroadcast("home", {
2244
+ * type: "STANDARD",
2245
+ * name: "Home",
2246
+ * enabled: true,
2247
+ * network: lan,
2248
+ * securityConfiguration: {
2249
+ * type: "WPA2_PERSONAL",
2250
+ * passphrase: yield* Config.Redacted("WIFI_PASSPHRASE"),
2251
+ * fastRoamingEnabled: false,
2252
+ * },
2253
+ * broadcastingFrequenciesGHz: [2.4, 5],
2254
+ * hideName: false,
2255
+ * clientIsolationEnabled: false,
2256
+ * multicastToUnicastConversionEnabled: false,
2257
+ * uapsdEnabled: false,
2258
+ * channel2gLockedTo6: false,
2259
+ * dtimPeriod2gLockedTo3: false,
2260
+ * advertiseDeviceName: false,
2261
+ * arpProxyEnabled: false,
2262
+ * bssTransitionEnabled: true,
2263
+ * });
2264
+ * }),
2265
+ * );
2266
+ * ```
2267
+ */
2268
+ const WifiBroadcast = Resource("UnifiNetwork.WifiBroadcast")({
2269
+ NATIVE_NETWORK: { type: "NATIVE" },
2270
+ /**
2271
+ * A broadcast in the site by name. Dies with a user-facing
2272
+ * `LookupNotFound` or `LookupAmbiguous`: stack bodies can't fail with typed
2273
+ * errors.
2274
+ */
2275
+ lookup: Effect.fn("UnifiNetwork.WifiBroadcast.lookup")(function* (query) {
2276
+ const site = yield* resolveSite(query.site);
2277
+ const broadcasts = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === query.name);
2278
+ const [broadcast] = broadcasts;
2279
+ if (broadcast === void 0) return yield* new LookupNotFound({
2280
+ resource: "Wi-Fi broadcast",
2281
+ query: describeQuery(query),
2282
+ site
2283
+ });
2284
+ if (broadcasts.length > 1) return yield* new LookupAmbiguous({
2285
+ resource: "Wi-Fi broadcast",
2286
+ query: describeQuery(query),
2287
+ site,
2288
+ count: broadcasts.length
2289
+ });
2290
+ return yield* attributesOf(site.id, broadcast.id);
2291
+ }, Effect.orDie)
2292
+ });
2293
+ const WifiBroadcastProvider = () => Provider.effect(WifiBroadcast, Effect.gen(function* () {
2294
+ const run = yield* captureServices;
2295
+ return WifiBroadcast.Provider.of({
2296
+ list: () => run(Effect.gen(function* () {
2297
+ const site = yield* resolveSite();
2298
+ const broadcasts = yield* listWifiBroadcasts(site.id);
2299
+ return yield* Effect.forEach(broadcasts.filter((b) => b.metadata.origin === "USER_DEFINED"), (b) => attributesOf(site.id, b.id));
2300
+ })),
2301
+ read: ({ olds, output }) => run(Effect.gen(function* () {
2302
+ if (output !== void 0) {
2303
+ const broadcast = yield* getBroadcast(output.siteId, output.wifiBroadcastId);
2304
+ return broadcast === void 0 ? void 0 : toAttributes(output.siteId, broadcast);
2305
+ }
2306
+ const site = yield* resolveSite(olds.site);
2307
+ const matching = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === olds.name);
2308
+ const [broadcast] = matching;
2309
+ return broadcast === void 0 || matching.length > 1 ? void 0 : Unowned(yield* attributesOf(site.id, broadcast.id));
2310
+ })),
2311
+ diff: ({ news, output }) => run(Effect.gen(function* () {
2312
+ if (output === void 0) return void 0;
2313
+ const type = planned(news, "type");
2314
+ if (type !== UNKNOWN && type !== output.type) return { action: "replace" };
2315
+ const siteProp = plannedString(news, "site");
2316
+ if (siteProp === UNKNOWN) return void 0;
2317
+ if ((yield* resolveSite(siteProp)).id !== output.siteId) return { action: "replace" };
2318
+ })),
2319
+ reconcile: ({ news, output }) => run(Effect.gen(function* () {
2320
+ const site = yield* resolveSite(news.site);
2321
+ const body = withDefaults(yield* toBody(news, site));
2322
+ const observed = output !== void 0 && output.siteId === site.id ? yield* getBroadcast(site.id, output.wifiBroadcastId) : void 0;
2323
+ const write = invalidating(broadcastsKey, site.id);
2324
+ if (observed === void 0) {
2325
+ const created = yield* withConnection(Sdk.createWifiBroadcast({
2326
+ siteId: site.id,
2327
+ body
2328
+ })).pipe(write);
2329
+ return toAttributes(site.id, created);
2330
+ }
2331
+ if (isCurrent(body, comparable(observed))) return toAttributes(site.id, observed);
2332
+ const updated = yield* withConnection(Sdk.updateWifiBroadcast({
2333
+ siteId: site.id,
2334
+ wifiBroadcastId: observed.id,
2335
+ body
2336
+ })).pipe(write);
2337
+ return toAttributes(site.id, updated);
2338
+ })),
2339
+ delete: ({ output }) => run(withConnection(Sdk.deleteWifiBroadcast({
2340
+ siteId: output.siteId,
2341
+ wifiBroadcastId: output.wifiBroadcastId
2342
+ })).pipe(Effect.asVoid, Effect.catchTag("NotFound", () => Effect.void), invalidating(broadcastsKey, output.siteId)))
2343
+ });
2344
+ }));
2345
+ // src/Providers.ts
2346
+ var Providers = class extends Provider.ProviderCollection()("UnifiNetwork") {};
2347
+ /**
2348
+ * Registers every UniFi Network resource provider, the `UnifiNetwork` auth
2349
+ * provider, the lazily-resolved credentials and connection, and the per-run
2350
+ * read cache. Merge
2351
+ * alongside other cloud `providers()` layers in a Stack.
2352
+ *
2353
+ * Its requirements (`AuthProviders`, `ProfileStore`, `FileSystem`, `Path`)
2354
+ * come from Alchemy's runtime; it provides only its own services.
2355
+ *
2356
+ * Unlike most providers this doesn't provide an `HttpClient`: ours carries
2357
+ * the console's TLS settings, and lifecycle operations run in the stack's
2358
+ * merged context, so it would replace other providers' clients. SDK calls
2359
+ * get it through `UnifiNetworkConnection` instead.
2360
+ */
2361
+ const providers = () => Layer.effect(Providers, Provider.collection([
2362
+ FirewallZone,
2363
+ Network,
2364
+ WifiBroadcast,
2365
+ TrafficMatchingList,
2366
+ FirewallPolicy,
2367
+ FirewallPolicyOrdering,
2368
+ DnsPolicy
2369
+ ])).pipe(Layer.provide(Layer.mergeAll(FirewallZoneProvider(), NetworkProvider(), WifiBroadcastProvider(), TrafficMatchingListProvider(), FirewallPolicyProvider(), FirewallPolicyOrderingProvider(), DnsPolicyProvider())), Layer.provideMerge(Layer.merge(layer$1, layer)), Layer.provideMerge(fromAuthProvider()), Layer.provideMerge(UnifiNetworkAuth), Layer.orDie);
2370
+ export { AmbiguousSite, CrossSiteReference, DEFAULT_WARN_AT, DefaultNetworkReplacement, DnsPolicy, DnsPolicyProvider, FirewallPolicy, FirewallPolicyOrdering, FirewallPolicyOrderingProvider, FirewallPolicyProvider, FirewallZone, FirewallZoneProvider, InvalidFirewallPolicyOrdering, LookupAmbiguous, LookupNotFound, Network, NetworkProvider, Providers, ReadCache_exports as ReadCache, SiteNotFound, SiteRequired, TrafficMatchingList, TrafficMatchingListInUse, TrafficMatchingListProvider, UNIFI_NETWORK_AUTH_PROVIDER_NAME, UnifiNetworkAuth, UnifiNetworkAuthConfigSchema, UnifiNetworkConnection, UnifiNetworkCredentials, UnifiNetworkReadCache, WifiBroadcast, WifiBroadcastProvider, checkSameSite, describeQuery, fromAuthProvider, isUserDefined, listDnsPolicies, listFirewallPolicies, listFirewallZones, listNetworks, listSites, listTrafficMatchingLists, listWifiBroadcasts, providers, resolveFirewallZone, resolveNetwork, resolveSite, resolveTrafficMatchingList, sitesKey, state, withConnection };
2371
+
2372
+ //# sourceMappingURL=index.mjs.map