@homeflare/alchemy 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +44 -7
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/linux/container-form.d.ts +129 -0
  5. package/dist/linux/container-form.d.ts.map +1 -0
  6. package/dist/linux/container-generator.d.ts +74 -0
  7. package/dist/linux/container-generator.d.ts.map +1 -0
  8. package/dist/linux/container-handlers.d.ts +15 -0
  9. package/dist/linux/container-handlers.d.ts.map +1 -0
  10. package/dist/linux/container-lifecycle.d.ts +37 -0
  11. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  12. package/dist/linux/container-preflight.d.ts +34 -0
  13. package/dist/linux/container-preflight.d.ts.map +1 -0
  14. package/dist/linux/container-secrets.d.ts +37 -0
  15. package/dist/linux/container-secrets.d.ts.map +1 -0
  16. package/dist/linux/container-settle.d.ts +34 -0
  17. package/dist/linux/container-settle.d.ts.map +1 -0
  18. package/dist/linux/container.d.ts +30 -0
  19. package/dist/linux/container.d.ts.map +1 -0
  20. package/dist/linux/fake-quadlet.d.ts +25 -0
  21. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  22. package/dist/linux/index.d.ts +6 -3
  23. package/dist/linux/index.d.ts.map +1 -1
  24. package/dist/linux/index.js +641 -268
  25. package/dist/linux/index.js.map +15 -7
  26. package/dist/linux/providers.d.ts +1 -1
  27. package/dist/linux/providers.d.ts.map +1 -1
  28. package/dist/linux/systemctl.d.ts +7 -1
  29. package/dist/linux/systemctl.d.ts.map +1 -1
  30. package/dist/opnsense/alias-form.d.ts +29 -0
  31. package/dist/opnsense/alias-form.d.ts.map +1 -0
  32. package/dist/opnsense/alias.d.ts +81 -0
  33. package/dist/opnsense/alias.d.ts.map +1 -0
  34. package/dist/opnsense/category-form.d.ts +10 -0
  35. package/dist/opnsense/category-form.d.ts.map +1 -0
  36. package/dist/opnsense/category.d.ts +58 -0
  37. package/dist/opnsense/category.d.ts.map +1 -0
  38. package/dist/opnsense/credentials.d.ts +36 -0
  39. package/dist/opnsense/credentials.d.ts.map +1 -0
  40. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  41. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  42. package/dist/opnsense/group-form.d.ts +11 -0
  43. package/dist/opnsense/group-form.d.ts.map +1 -0
  44. package/dist/opnsense/group.d.ts +62 -0
  45. package/dist/opnsense/group.d.ts.map +1 -0
  46. package/dist/opnsense/index.d.ts +16 -0
  47. package/dist/opnsense/index.d.ts.map +1 -0
  48. package/dist/opnsense/index.js +211 -0
  49. package/dist/opnsense/index.js.map +20 -0
  50. package/dist/opnsense/policy.d.ts +20 -0
  51. package/dist/opnsense/policy.d.ts.map +1 -0
  52. package/dist/opnsense/providers.d.ts +13 -0
  53. package/dist/opnsense/providers.d.ts.map +1 -0
  54. package/dist/opnsense/resource.d.ts +74 -0
  55. package/dist/opnsense/resource.d.ts.map +1 -0
  56. package/dist/opnsense/wire.d.ts +39 -0
  57. package/dist/opnsense/wire.d.ts.map +1 -0
  58. package/dist/proxmox/acl.d.ts.map +1 -1
  59. package/dist/proxmox/api-token.d.ts.map +1 -1
  60. package/dist/proxmox/group-wire.d.ts +44 -5
  61. package/dist/proxmox/group-wire.d.ts.map +1 -1
  62. package/dist/proxmox/group.d.ts.map +1 -1
  63. package/dist/proxmox/index.js +326 -264
  64. package/dist/proxmox/index.js.map +13 -11
  65. package/dist/proxmox/role.d.ts.map +1 -1
  66. package/dist/proxmox/storage-form.d.ts +54 -0
  67. package/dist/proxmox/storage-form.d.ts.map +1 -0
  68. package/dist/proxmox/storage-wire.d.ts +56 -0
  69. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  70. package/dist/proxmox/storage.d.ts +55 -60
  71. package/dist/proxmox/storage.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +34 -5
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/dist/proxmox/user.d.ts +6 -6
  75. package/dist/proxmox/user.d.ts.map +1 -1
  76. package/dist/unifi/fake-unifi.d.ts +48 -0
  77. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  78. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  79. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  80. package/dist/unifi/firewall-zone.d.ts +50 -0
  81. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  82. package/dist/unifi/index.d.ts +13 -0
  83. package/dist/unifi/index.d.ts.map +1 -0
  84. package/dist/unifi/index.js +190 -0
  85. package/dist/unifi/index.js.map +16 -0
  86. package/dist/unifi/network-form.d.ts +67 -0
  87. package/dist/unifi/network-form.d.ts.map +1 -0
  88. package/dist/unifi/network.d.ts +67 -0
  89. package/dist/unifi/network.d.ts.map +1 -0
  90. package/dist/unifi/policy.d.ts +20 -0
  91. package/dist/unifi/policy.d.ts.map +1 -0
  92. package/dist/unifi/providers.d.ts +13 -0
  93. package/dist/unifi/providers.d.ts.map +1 -0
  94. package/dist/unifi/resource.d.ts +64 -0
  95. package/dist/unifi/resource.d.ts.map +1 -0
  96. package/dist/verify/fake-engine.d.ts +14 -0
  97. package/dist/verify/fake-engine.d.ts.map +1 -1
  98. package/docs/grafana.md +47 -17
  99. package/docs/opnsense.md +146 -0
  100. package/docs/provider-standard.md +1 -1
  101. package/docs/quadlet-container.md +132 -0
  102. package/docs/unifi.md +93 -0
  103. package/docs/upstream-conformance.md +14 -4
  104. package/package.json +13 -3
  105. package/src/grafana/index.ts +6 -5
  106. package/src/linux/container-fixture.test.ts +92 -0
  107. package/src/linux/container-form.ts +243 -0
  108. package/src/linux/container-generator.test.ts +195 -0
  109. package/src/linux/container-generator.ts +113 -0
  110. package/src/linux/container-handlers.ts +73 -0
  111. package/src/linux/container-lifecycle.test.ts +209 -0
  112. package/src/linux/container-lifecycle.ts +217 -0
  113. package/src/linux/container-preflight.ts +97 -0
  114. package/src/linux/container-secrets.test.ts +171 -0
  115. package/src/linux/container-secrets.ts +112 -0
  116. package/src/linux/container-settle.ts +97 -0
  117. package/src/linux/container.ts +60 -0
  118. package/src/linux/fake-quadlet.ts +127 -0
  119. package/src/linux/index.ts +11 -3
  120. package/src/linux/providers.ts +2 -0
  121. package/src/linux/systemctl.ts +9 -0
  122. package/src/opnsense/alias-form.test.ts +89 -0
  123. package/src/opnsense/alias-form.ts +53 -0
  124. package/src/opnsense/alias.test.ts +203 -0
  125. package/src/opnsense/alias.ts +117 -0
  126. package/src/opnsense/category-form.test.ts +54 -0
  127. package/src/opnsense/category-form.ts +20 -0
  128. package/src/opnsense/category.test.ts +138 -0
  129. package/src/opnsense/category.ts +82 -0
  130. package/src/opnsense/credentials.ts +41 -0
  131. package/src/opnsense/fake-opnsense.ts +79 -0
  132. package/src/opnsense/group-form.test.ts +75 -0
  133. package/src/opnsense/group-form.ts +25 -0
  134. package/src/opnsense/group.test.ts +141 -0
  135. package/src/opnsense/group.ts +78 -0
  136. package/src/opnsense/index.ts +45 -0
  137. package/src/opnsense/policy.ts +49 -0
  138. package/src/opnsense/providers.ts +21 -0
  139. package/src/opnsense/resource.ts +146 -0
  140. package/src/opnsense/wire.ts +56 -0
  141. package/src/opnsense/write-refusal.test.ts +148 -0
  142. package/src/proxmox/acl.ts +10 -4
  143. package/src/proxmox/api-token.ts +20 -12
  144. package/src/proxmox/group-wire.ts +62 -8
  145. package/src/proxmox/group.ts +23 -35
  146. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  147. package/src/proxmox/role.ts +9 -2
  148. package/src/proxmox/storage-form.ts +144 -0
  149. package/src/proxmox/storage-read-failure.test.ts +214 -0
  150. package/src/proxmox/storage-wire.ts +124 -0
  151. package/src/proxmox/storage.ts +165 -175
  152. package/src/proxmox/user-wire.ts +44 -8
  153. package/src/proxmox/user.ts +29 -27
  154. package/src/unifi/fake-unifi.ts +60 -0
  155. package/src/unifi/firewall-zone-form.ts +65 -0
  156. package/src/unifi/firewall-zone.test.ts +88 -0
  157. package/src/unifi/firewall-zone.ts +66 -0
  158. package/src/unifi/index.ts +26 -0
  159. package/src/unifi/network-form.ts +207 -0
  160. package/src/unifi/network.test.ts +209 -0
  161. package/src/unifi/network.ts +83 -0
  162. package/src/unifi/policy.ts +37 -0
  163. package/src/unifi/providers.ts +15 -0
  164. package/src/unifi/resource.test.ts +133 -0
  165. package/src/unifi/resource.ts +131 -0
  166. package/src/verify/fake-engine.ts +23 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `Opnsense.Firewall.Category` — a read-only declaration of one `firewall/category` item (the
3
+ * tag OPNsense's UI groups aliases, rules etc. by) on the edge. READ-ONLY BY DESIGN — see
4
+ * policy.ts's header. `reconcile`/`delete` always refuse. Mirrors alias.ts exactly; see that
5
+ * file's header for why `get()` (whole model) is the read source rather than `getCategory` —
6
+ * the same generation gap (no `uuid` in `GetCategoryRequest`'s schema) applies here too.
7
+ */
8
+ import { adopt } from 'alchemy/AdoptPolicy';
9
+ import { Resource } from 'alchemy';
10
+ import * as Provider from 'alchemy/Provider';
11
+ import * as Category from '@distilled.cloud/opnsense/firewall_category';
12
+ import type { OpnsenseOpError } from '@distilled.cloud/opnsense/Protocol';
13
+ import * as Effect from 'effect/Effect';
14
+ import { attributesOf, matches } from './category-form.ts';
15
+ import type { OpnsenseRequirements, OpnsenseSpec } from './resource.ts';
16
+ import { opnsenseHandlers } from './resource.ts';
17
+
18
+ export interface CategoryProps {
19
+ readonly uuid: string;
20
+ /** Pattern `/[^,]+/` on the wire — a comma in the name would collide with the multi-select join. */
21
+ readonly name: string;
22
+ /** Whether OPNsense itself created this category (its own subsystems tag things this way); a
23
+ * category a person made is `false`/absent. */
24
+ readonly auto?: boolean;
25
+ /** Six hex digits, no `#`, e.g. `0000FF`. */
26
+ readonly color?: string;
27
+ }
28
+
29
+ export interface CategoryAttributes {
30
+ readonly uuid: string;
31
+ readonly name: string;
32
+ readonly auto: boolean;
33
+ readonly color: string;
34
+ }
35
+
36
+ export interface OpnsenseFirewallCategory extends Resource<
37
+ 'Opnsense.Firewall.Category',
38
+ CategoryProps,
39
+ CategoryAttributes,
40
+ never,
41
+ OpnsenseRequirements
42
+ > {}
43
+
44
+ export const OpnsenseFirewallCategory = Resource<OpnsenseFirewallCategory>(
45
+ 'Opnsense.Firewall.Category',
46
+ {
47
+ defaultRemovalPolicy: 'retain',
48
+ },
49
+ );
50
+
51
+ export const isOpnsenseFirewallCategory = (value: unknown): value is OpnsenseFirewallCategory =>
52
+ typeof value === 'object' &&
53
+ value !== null &&
54
+ (value as { Type?: unknown }).Type === 'Opnsense.Firewall.Category';
55
+
56
+ export const spec: OpnsenseSpec<
57
+ CategoryProps,
58
+ Category.CategoryItem,
59
+ CategoryAttributes,
60
+ OpnsenseOpError
61
+ > = {
62
+ attributes: attributesOf,
63
+ fetchLive: (props) =>
64
+ Category.get({}).pipe(Effect.map((res) => res.category?.categories?.category?.[props.uuid])),
65
+ matches,
66
+ resourceType: 'Opnsense.Firewall.Category',
67
+ };
68
+
69
+ /** The pure declaration renderer — see alias.ts's `propsFromLive` for the full contract note. */
70
+ export const propsFromLive = attributesOf;
71
+
72
+ export const handlers = opnsenseHandlers(spec);
73
+
74
+ export const OpnsenseFirewallCategoryProvider = () =>
75
+ Provider.effect(
76
+ OpnsenseFirewallCategory,
77
+ Effect.succeed(OpnsenseFirewallCategory.Provider.of(handlers)),
78
+ );
79
+
80
+ /** `firewallCategory('name', { uuid, name })` — `adopt(true)` piped by default (H5). */
81
+ export const firewallCategory = (id: string, props: CategoryProps) =>
82
+ OpnsenseFirewallCategory(id, props).pipe(adopt(true));
@@ -0,0 +1,41 @@
1
+ /**
2
+ * OPNsense credentials — a key/secret pair REFERENCE, resolved lazily (S24), never a prop.
3
+ *
4
+ * ⛔ NO KEY OR SECRET IS EVER A PROP OR AN ATTRIBUTE. Alchemy persists Attributes unencrypted
5
+ * (`State/StateEncoding.ts@v2.0.0-beta.79#encodeState`), so nothing a stack file holds may be a
6
+ * credential. `@distilled.cloud/opnsense/Credentials` already ships exactly the shape S24 asks
7
+ * for: `Credentials` is a lazy `Context.Service<Credentials, Effect.Effect<Config>>`, resolved
8
+ * on the calling fiber inside each operation (`OpnsenseProtocol`'s `encode`), never at layer
9
+ * build. This file re-exports it rather than re-implementing it — the same choice
10
+ * `../discord/credentials.ts` made for its own SDK's `CredentialsFromEnv`.
11
+ *
12
+ * ★ `CredentialsFromEnv` READS `OPNSENSE_URL` / `OPNSENSE_API_KEY` / `OPNSENSE_API_SECRET` via
13
+ * Effect's `Config` (the SDK's own `credentials.ts` — no vendor-standard env var names exist
14
+ * for OPNsense's API, unlike Forgejo's `FORGEJO_URL`). The resource never sees the values,
15
+ * only the deploy process's environment does, and the secret is `Redacted` the moment it is
16
+ * read — exactly the "reference, never a value" shape S25 asks for.
17
+ *
18
+ * ⚠️ NO OpenBao MINT YET, THE SAME GAP `../discord/credentials.ts` DOCUMENTS. H8's target
19
+ * (`Alchemy.Stack({ secrets })`) is unreleased (PR 1728, `main` only). Until the estate is on a
20
+ * release that carries it, the deploy process's own environment is the credential seam — a
21
+ * human or CI job exports the three `OPNSENSE_*` variables from wherever it mints them. This
22
+ * package never mints, reads or logs the key/secret itself; it only names the variables.
23
+ */
24
+ import type { Config } from '@distilled.cloud/opnsense/Credentials';
25
+ import {
26
+ Credentials,
27
+ CredentialsFromEnv,
28
+ credentials,
29
+ } from '@distilled.cloud/opnsense/Credentials';
30
+
31
+ /** Re-exported so a consumer never has to reach into `@distilled.cloud/opnsense` by hand. */
32
+ export { Credentials, CredentialsFromEnv, credentials };
33
+ export type { Config as OpnsenseCredentialsConfig };
34
+
35
+ /**
36
+ * `OpnsenseCredentials.fromEnv()` — the layer `opnsenseHandlers` (resource.ts) provides by
37
+ * default. A thin, named alias over `CredentialsFromEnv` so a stack's import reads `Opnsense.*`
38
+ * throughout, the way `../discord/credentials.ts`'s `fromEnv()` keeps `Discord.*` names at the
39
+ * front door.
40
+ */
41
+ export const fromEnv = () => CredentialsFromEnv;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A minimal in-memory OPNsense API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring `../discord/fake-discord.ts`
4
+ * (itself mirroring `../netbox/fake-netbox.ts`). Tests exercise distilled's REAL path assembly,
5
+ * JSON encode/decode and Basic-auth header construction — nothing about any of that is
6
+ * re-implemented here, only the wire responses a scenario needs.
7
+ *
8
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
9
+ * `opnsense.example.com`, and the key/secret are placeholders — never the edge's real ones,
10
+ * which this task has no access to and must not look for (see policy.ts's header).
11
+ */
12
+ import { type Credentials, credentials } from '@distilled.cloud/opnsense/Credentials';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+
16
+ export const FAKE_BASE = 'https://opnsense.example.com';
17
+ export const FAKE_KEY = 'placeholder-opnsense-key';
18
+ export const FAKE_SECRET = 'placeholder-opnsense-secret';
19
+
20
+ export type Seen = { readonly method: string; readonly path: string; readonly body: unknown };
21
+
22
+ export const fakeFailure = (status: number, body: unknown) => Response.json(body, { status });
23
+
24
+ /**
25
+ * `route` decides every response; this wrapper only records what was sent and adapts to
26
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
27
+ */
28
+ export const fakeOpnsense = (
29
+ route: (method: string, url: URL, body: unknown, callNumber: number) => Response,
30
+ ) => {
31
+ const seen: Seen[] = [];
32
+ let callNumber = 0;
33
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
34
+ const request =
35
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
36
+ const url = new URL(request.url);
37
+ const bodyText = await request.clone().text();
38
+ const body: unknown = bodyText === '' ? undefined : JSON.parse(bodyText);
39
+ seen.push({ body, method: request.method, path: `${url.pathname}${url.search}` });
40
+ callNumber += 1;
41
+ return route(request.method, url, body, callNumber);
42
+ }) as typeof globalThis.fetch;
43
+ return { fetch, seen };
44
+ };
45
+
46
+ /**
47
+ * ★ FAILS THE TEST ITSELF ON ANY NON-GET REQUEST — the "no SDK write operation is reachable"
48
+ * proof `write-refusal.test.ts` and every resource's own test build on. `respond` only ever
49
+ * sees a GET, so it never has to branch on method.
50
+ */
51
+ export const getOnlyOrFail = (respond: (url: URL, callNumber: number) => Response) =>
52
+ fakeOpnsense((method, url, _body, callNumber) => {
53
+ if (method !== 'GET') {
54
+ throw new Error(
55
+ `fake-opnsense: unexpected ${method} ${url.pathname} — Opnsense.Firewall.* may never ` +
56
+ 'write (policy.ts); this call should not have happened.',
57
+ );
58
+ }
59
+ return respond(url, callNumber);
60
+ });
61
+
62
+ /**
63
+ * distilled Credentials + the real FetchHttpClient over the fake — `OpnsenseOpContext`, what
64
+ * every resource file's exported `spec.fetchLive` needs.
65
+ *
66
+ * ⚠️ NOT PAIRED WITH `handlers` (each resource file's `opnsenseHandlers(spec)` export). `handlers`
67
+ * bakes in `CredentialsFromEnv`, which resolves through Effect's `Config` snapshot of
68
+ * `process.env` — untestable against a fake server without setting real env vars a parallel
69
+ * test run could race on. So a test calls `spec.fetchLive` (or `opnsenseOperations(spec)`)
70
+ * directly, with this explicit layer, exercising exactly the same production code.
71
+ */
72
+ export const fakeOpnsenseLayer = (
73
+ fetchFn: typeof globalThis.fetch,
74
+ creds: Layer.Layer<Credentials> = credentials({
75
+ apiKey: FAKE_KEY,
76
+ apiSecret: FAKE_SECRET,
77
+ baseUrl: FAKE_BASE,
78
+ }),
79
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `group-form.ts`'s pure functions against literal `GroupItem` fixtures — no network.
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import type * as group from '@distilled.cloud/opnsense/firewall_group';
6
+ import { attributesOf, matches } from './group-form.ts';
7
+ import type { GroupProps } from './group.ts';
8
+
9
+ const UUID = '33333333-3333-3333-3333-333333333333';
10
+
11
+ const liveGroup = (overrides: Partial<group.GroupItem> = {}): group.GroupItem => ({
12
+ ifname: 'IOT_DEVICES',
13
+ members: 'opt1,opt2',
14
+ sequence: '1',
15
+ ...overrides,
16
+ });
17
+
18
+ describe('group-form attributesOf', () => {
19
+ test('settles OPNsense wire spellings to plain values', () => {
20
+ expect(attributesOf(UUID, liveGroup())).toEqual({
21
+ description: '',
22
+ ifname: 'IOT_DEVICES',
23
+ members: ['opt1', 'opt2'],
24
+ nogroup: false,
25
+ sequence: 1,
26
+ uuid: UUID,
27
+ });
28
+ });
29
+
30
+ test('members are split, sorted and deduplicated', () => {
31
+ const live = liveGroup({ members: 'opt2,opt1,opt1' });
32
+ expect(attributesOf(UUID, live).members).toEqual(['opt1', 'opt2']);
33
+ });
34
+
35
+ test('a non-numeric sequence falls back rather than becoming NaN', () => {
36
+ expect(attributesOf(UUID, liveGroup({ sequence: 'not-a-number' })).sequence).toBe(0);
37
+ });
38
+ });
39
+
40
+ describe('group-form matches', () => {
41
+ const props: GroupProps = {
42
+ ifname: 'IOT_DEVICES',
43
+ members: ['opt1', 'opt2'],
44
+ sequence: 1,
45
+ uuid: UUID,
46
+ };
47
+
48
+ test('a live item decoded through attributesOf matches its own declaration', () => {
49
+ expect(matches(attributesOf(UUID, liveGroup()), props)).toBe(true);
50
+ });
51
+
52
+ test('member-list order alone is not drift', () => {
53
+ const live = attributesOf(UUID, liveGroup({ members: 'opt2,opt1' }));
54
+ const declared: GroupProps = { ...props, members: ['opt1', 'opt2'] };
55
+ expect(matches(live, declared)).toBe(true);
56
+ });
57
+
58
+ test('a removed member is drift', () => {
59
+ const live = attributesOf(UUID, liveGroup({ members: 'opt1' }));
60
+ expect(matches(live, props)).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('propsFromLive round-trip', () => {
65
+ test('the props it returns plan noop against the exact live object they came from', () => {
66
+ const live = liveGroup({
67
+ descr: 'IoT VLANs',
68
+ members: 'opt3,opt1,opt2',
69
+ nogroup: '1',
70
+ sequence: '5',
71
+ });
72
+ const rendered = attributesOf(UUID, live);
73
+ expect(matches(attributesOf(UUID, live), rendered)).toBe(true);
74
+ });
75
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `Opnsense.Firewall.Group`'s wire shape: how a live `GroupItem` (an interface group) becomes
3
+ * attributes, and the comparison that decides whether a declaration already matches it. Mirrors
4
+ * alias-form.ts.
5
+ */
6
+ import type { GroupAttributes, GroupProps } from './group.ts';
7
+ import type * as group from '@distilled.cloud/opnsense/firewall_group';
8
+ import { bool01, csvSet, csvSetOf, numField } from './wire.ts';
9
+
10
+ /** ★ Attributes and the declaration renderer are one function — see alias-form.ts's header note. */
11
+ export const attributesOf = (uuid: string, live: group.GroupItem): GroupAttributes => ({
12
+ description: live.descr ?? '',
13
+ ifname: live.ifname,
14
+ members: csvSet(live.members),
15
+ nogroup: bool01(live.nogroup, false),
16
+ sequence: numField(live.sequence, 0),
17
+ uuid,
18
+ });
19
+
20
+ export const matches = (attributes: GroupAttributes, props: GroupProps): boolean =>
21
+ attributes.ifname === props.ifname &&
22
+ attributes.nogroup === (props.nogroup ?? false) &&
23
+ attributes.sequence === (props.sequence ?? 0) &&
24
+ attributes.description === (props.description ?? '') &&
25
+ attributes.members.join(',') === csvSetOf(props.members).join(',');
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `Opnsense.Firewall.Group`'s `spec` against a fake OPNsense — mirrors `alias.test.ts` for the
3
+ * interface-group family. See that file's header for what each block proves.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { Unowned } from 'alchemy/AdoptPolicy';
7
+ import type * as group from '@distilled.cloud/opnsense/firewall_group';
8
+ import * as Effect from 'effect/Effect';
9
+ import { attributesOf } from './group-form.ts';
10
+ import type { GroupProps } from './group.ts';
11
+ import { spec } from './group.ts';
12
+ import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
13
+ import type { OpnsenseWriteRefused } from './policy.ts';
14
+ import { opnsenseOperations } from './resource.ts';
15
+
16
+ const asRefusal = (value: unknown) => value as OpnsenseWriteRefused;
17
+
18
+ const UUID = '33333333-3333-3333-3333-333333333333';
19
+ const GET_PATH = '/api/firewall/group/get';
20
+ const PROPS: GroupProps = {
21
+ ifname: 'IOT_DEVICES',
22
+ members: ['opt1', 'opt2'],
23
+ sequence: 1,
24
+ uuid: UUID,
25
+ };
26
+
27
+ const liveItem = (overrides: Partial<group.GroupItem> = {}): group.GroupItem => ({
28
+ ifname: 'IOT_DEVICES',
29
+ members: 'opt1,opt2',
30
+ sequence: '1',
31
+ ...overrides,
32
+ });
33
+
34
+ const getResponse = (items: Record<string, group.GroupItem>) =>
35
+ Response.json({ group: { ifgroupentry: items } });
36
+
37
+ describe('Opnsense.Firewall.Group spec.fetchLive', () => {
38
+ test('finds the item whose uuid is the map key, among others', async () => {
39
+ const fake = getOnlyOrFail((url) =>
40
+ url.pathname === GET_PATH
41
+ ? getResponse({ 'other-uuid': liveItem({ ifname: 'OTHER' }), [UUID]: liveItem() })
42
+ : fakeFailure(400, { errorMessage: 'unexpected request' }),
43
+ );
44
+ const live = await Effect.runPromise(
45
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
46
+ );
47
+ expect(live?.ifname).toBe('IOT_DEVICES');
48
+ });
49
+
50
+ test('no matching uuid key is absent, not an error', async () => {
51
+ const fake = getOnlyOrFail(() => getResponse({ 'other-uuid': liveItem() }));
52
+ const live = await Effect.runPromise(
53
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
54
+ );
55
+ expect(live).toBeUndefined();
56
+ });
57
+
58
+ test('a 403 propagates — never folded to absent', async () => {
59
+ const fake = fakeOpnsense(() => fakeFailure(403, { message: 'Forbidden' }));
60
+ const failure = await Effect.runPromise(
61
+ Effect.flip(spec.fetchLive(PROPS).pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch)))),
62
+ );
63
+ expect(failure._tag).toBe('Forbidden');
64
+ });
65
+ });
66
+
67
+ describe('opnsenseOperations(spec).read — S7/S8, marker-less API', () => {
68
+ test('cold read answers Unowned; warm read answers plain attributes', async () => {
69
+ const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
70
+ const cold = await Effect.runPromise(
71
+ opnsenseOperations(spec)
72
+ .read({ olds: PROPS, output: undefined })
73
+ .pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
74
+ );
75
+ expect(Unowned.is(cold)).toBe(true);
76
+
77
+ const fakeWarm = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
78
+ const warm = await Effect.runPromise(
79
+ opnsenseOperations(spec)
80
+ .read({ olds: PROPS, output: attributesOf(UUID, liveItem()) })
81
+ .pipe(Effect.provide(fakeOpnsenseLayer(fakeWarm.fetch))),
82
+ );
83
+ expect(Unowned.is(warm)).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe('opnsenseOperations(spec).diff', () => {
88
+ test('noop when the declared member set matches live, regardless of order', async () => {
89
+ const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ members: 'opt2,opt1' }) }));
90
+ const result = await Effect.runPromise(
91
+ opnsenseOperations(spec)
92
+ .diff(PROPS, attributesOf(UUID, liveItem({ members: 'opt2,opt1' })))
93
+ .pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
94
+ );
95
+ expect(result).toEqual({ action: 'noop' });
96
+ });
97
+
98
+ test('update when a member was removed live', async () => {
99
+ const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ members: 'opt1' }) }));
100
+ const result = await Effect.runPromise(
101
+ opnsenseOperations(spec)
102
+ .diff(PROPS, attributesOf(UUID, liveItem()))
103
+ .pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
104
+ );
105
+ expect(result).toEqual({ action: 'update' });
106
+ });
107
+ });
108
+
109
+ describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
110
+ test('reconcile observes with one GET, then refuses — never a write', async () => {
111
+ const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ members: 'opt1' }) }));
112
+ const failure = asRefusal(
113
+ await Effect.runPromise(
114
+ Effect.flip(
115
+ opnsenseOperations(spec)
116
+ .reconcile(PROPS)
117
+ .pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
118
+ ),
119
+ ),
120
+ );
121
+ expect(failure._tag).toBe('OpnsenseWriteRefused');
122
+ expect(failure.action).toBe('update');
123
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
124
+ expect(fake.seen).toHaveLength(1);
125
+ });
126
+
127
+ test('delete refuses unconditionally, with zero requests', async () => {
128
+ const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem() }));
129
+ const failure = asRefusal(
130
+ await Effect.runPromise(
131
+ Effect.flip(
132
+ opnsenseOperations(spec)
133
+ .destroy(PROPS)
134
+ .pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
135
+ ),
136
+ ),
137
+ );
138
+ expect(failure.action).toBe('delete');
139
+ expect(fake.seen).toHaveLength(0);
140
+ });
141
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `Opnsense.Firewall.Group` — a read-only declaration of one `firewall/group` item (an
3
+ * "Interface Group", not a permission group) on the edge. READ-ONLY BY DESIGN — see policy.ts's
4
+ * header. `reconcile`/`delete` always refuse. Mirrors alias.ts exactly; see that file's header
5
+ * for why `get()` (whole model) is the read source rather than `getGroup` — the same generation
6
+ * gap (no `uuid` in `GetGroupRequest`'s schema) applies here too.
7
+ */
8
+ import { adopt } from 'alchemy/AdoptPolicy';
9
+ import { Resource } from 'alchemy';
10
+ import * as Provider from 'alchemy/Provider';
11
+ import * as Group from '@distilled.cloud/opnsense/firewall_group';
12
+ import type { OpnsenseOpError } from '@distilled.cloud/opnsense/Protocol';
13
+ import * as Effect from 'effect/Effect';
14
+ import { attributesOf, matches } from './group-form.ts';
15
+ import type { OpnsenseRequirements, OpnsenseSpec } from './resource.ts';
16
+ import { opnsenseHandlers } from './resource.ts';
17
+
18
+ export interface GroupProps {
19
+ readonly uuid: string;
20
+ /** OPNsense's own `GroupNameField` — the interface group's name. */
21
+ readonly ifname: string;
22
+ /** Member interface names (`lan`, `opt1`, …), not their descriptions. */
23
+ readonly members: readonly string[];
24
+ /** OPNsense's own "no group" flag — excludes it from certain interface pickers. Default false. */
25
+ readonly nogroup?: boolean;
26
+ /** UI list position, `0`..`9999`. Carries no evaluation-order meaning for this object type. */
27
+ readonly sequence?: number;
28
+ readonly description?: string;
29
+ }
30
+
31
+ export interface GroupAttributes {
32
+ readonly uuid: string;
33
+ readonly ifname: string;
34
+ readonly members: readonly string[];
35
+ readonly nogroup: boolean;
36
+ readonly sequence: number;
37
+ readonly description: string;
38
+ }
39
+
40
+ export interface OpnsenseFirewallGroup extends Resource<
41
+ 'Opnsense.Firewall.Group',
42
+ GroupProps,
43
+ GroupAttributes,
44
+ never,
45
+ OpnsenseRequirements
46
+ > {}
47
+
48
+ export const OpnsenseFirewallGroup = Resource<OpnsenseFirewallGroup>('Opnsense.Firewall.Group', {
49
+ defaultRemovalPolicy: 'retain',
50
+ });
51
+
52
+ export const isOpnsenseFirewallGroup = (value: unknown): value is OpnsenseFirewallGroup =>
53
+ typeof value === 'object' &&
54
+ value !== null &&
55
+ (value as { Type?: unknown }).Type === 'Opnsense.Firewall.Group';
56
+
57
+ export const spec: OpnsenseSpec<GroupProps, Group.GroupItem, GroupAttributes, OpnsenseOpError> = {
58
+ attributes: attributesOf,
59
+ fetchLive: (props) =>
60
+ Group.get({}).pipe(Effect.map((res) => res.group?.ifgroupentry?.[props.uuid])),
61
+ matches,
62
+ resourceType: 'Opnsense.Firewall.Group',
63
+ };
64
+
65
+ /** The pure declaration renderer — see alias.ts's `propsFromLive` for the full contract note. */
66
+ export const propsFromLive = attributesOf;
67
+
68
+ export const handlers = opnsenseHandlers(spec);
69
+
70
+ export const OpnsenseFirewallGroupProvider = () =>
71
+ Provider.effect(
72
+ OpnsenseFirewallGroup,
73
+ Effect.succeed(OpnsenseFirewallGroup.Provider.of(handlers)),
74
+ );
75
+
76
+ /** `firewallGroup('name', { uuid, ifname, members })` — `adopt(true)` piped by default (H5). */
77
+ export const firewallGroup = (id: string, props: GroupProps) =>
78
+ OpnsenseFirewallGroup(id, props).pipe(adopt(true));
@@ -0,0 +1,45 @@
1
+ /**
2
+ * OPNsense providers for Alchemy — READ-ONLY (policy.ts). `reconcile` and `delete` on every
3
+ * resource here always refuse; this family exists to plan and adopt against the live edge,
4
+ * never to change it.
5
+ *
6
+ * ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY.
7
+ * `resource.ts`, `wire.ts`, `policy.ts` and the three `*-form.ts` files are internals every
8
+ * resource file shares — a consumer builds against the three resources and `providers()`.
9
+ */
10
+ export {
11
+ firewallAlias,
12
+ isOpnsenseFirewallAlias,
13
+ OpnsenseFirewallAlias,
14
+ OpnsenseFirewallAliasProvider,
15
+ propsFromLive as aliasPropsFromLive,
16
+ type AliasAttributes,
17
+ type AliasProps,
18
+ } from './alias.ts';
19
+ export {
20
+ firewallCategory,
21
+ isOpnsenseFirewallCategory,
22
+ OpnsenseFirewallCategory,
23
+ OpnsenseFirewallCategoryProvider,
24
+ propsFromLive as categoryPropsFromLive,
25
+ type CategoryAttributes,
26
+ type CategoryProps,
27
+ } from './category.ts';
28
+ export {
29
+ firewallGroup,
30
+ isOpnsenseFirewallGroup,
31
+ OpnsenseFirewallGroup,
32
+ OpnsenseFirewallGroupProvider,
33
+ propsFromLive as groupPropsFromLive,
34
+ type GroupAttributes,
35
+ type GroupProps,
36
+ } from './group.ts';
37
+ export {
38
+ Credentials,
39
+ CredentialsFromEnv,
40
+ type OpnsenseCredentialsConfig,
41
+ credentials,
42
+ fromEnv,
43
+ } from './credentials.ts';
44
+ export { OpnsenseWriteRefused, type OpnsenseWriteAction } from './policy.ts';
45
+ export { providers } from './providers.ts';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `OpnsenseWriteRefused` — the one error every resource in this family can raise for a write,
3
+ * and the only thing standing between "read-only for now" and an actual change against the edge.
4
+ *
5
+ * ⛔ TIM, 2026-09-24: UniFi AND OPNSENSE ARE READ-ONLY FOR NOW. `Opnsense.Firewall.Alias`,
6
+ * `.Category` and `.Group` (alias.ts, category.ts, group.ts) exist to `read`, `diff` and adopt
7
+ * against the live edge — never to change it. This is the estate's EDGE firewall: a failed
8
+ * probe gets the caller's IP CrowdSec-banned (`edge-crowdsec-bans-lan-ssh-probes` in the shared
9
+ * estate memory), and nothing about that risk is worth a resource that can also reconfigure it.
10
+ * Every `reconcile` and `delete` in this directory calls `refuseWrite` below and NOTHING ELSE —
11
+ * none of them imports an `add`/`set`/`del`/`toggle` operation from `@distilled.cloud/opnsense`
12
+ * at all, and `write-refusal.test.ts` proves it with a fake HTTP client that fails the test on
13
+ * any request that is not a GET.
14
+ *
15
+ * ★ LIFTING THIS IS A KIT CHANGE, NEVER A PROP. There is no `allowWrite` flag on any resource
16
+ * here — a flag is one bad `true` (or one bad default) away from a live edit to the box that
17
+ * fronts the whole estate. Lifting the policy means editing this file, and writing the
18
+ * create/update/delete bodies these resources do not have, a decision Tim makes explicitly by
19
+ * changing code and review, not a runtime toggle a stack can set.
20
+ */
21
+ import * as Data from 'effect/Data';
22
+ import * as Effect from 'effect/Effect';
23
+
24
+ export type OpnsenseWriteAction = 'create' | 'update' | 'delete';
25
+
26
+ export class OpnsenseWriteRefused extends Data.TaggedError('OpnsenseWriteRefused')<{
27
+ readonly resourceType: string;
28
+ readonly id: string;
29
+ readonly action: OpnsenseWriteAction;
30
+ }> {
31
+ override get message(): string {
32
+ return (
33
+ `${this.resourceType} ${this.id}: refusing to ${this.action} — read-only by Tim's rule, ` +
34
+ '2026-09-24; lifting it is a kit change, not a prop.'
35
+ );
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Every `reconcile`/`delete` handler in this family funnels through this one call — see
41
+ * `resource.ts`'s `opnsenseOperations`. Nothing downstream of this function can reach a write:
42
+ * it never calls an SDK operation, it only builds and fails with the typed refusal above.
43
+ */
44
+ export const refuseWrite = (
45
+ resourceType: string,
46
+ id: string,
47
+ action: OpnsenseWriteAction,
48
+ ): Effect.Effect<never, OpnsenseWriteRefused> =>
49
+ Effect.fail(new OpnsenseWriteRefused({ action, id, resourceType }));
@@ -0,0 +1,21 @@
1
+ /**
2
+ * All three OPNsense firewall providers as one layer for a stack.
3
+ *
4
+ * Layer.mergeAll(opnsenseProviders(), …the stack's other providers)
5
+ * .pipe(Layer.provide(FetchHttpClient.layer))
6
+ *
7
+ * S16: a subpath's `providers()` plays the role upstream's own `Providers.ts` does — one minimal
8
+ * insertion per family, requirements that never contain `unknown`. `FetchHttpClient.layer` is
9
+ * the stack's own responsibility to provide, mirroring `../discord/providers.ts`.
10
+ */
11
+ import * as Layer from 'effect/Layer';
12
+ import { OpnsenseFirewallAliasProvider } from './alias.ts';
13
+ import { OpnsenseFirewallCategoryProvider } from './category.ts';
14
+ import { OpnsenseFirewallGroupProvider } from './group.ts';
15
+
16
+ export const providers = () =>
17
+ Layer.mergeAll(
18
+ OpnsenseFirewallAliasProvider(),
19
+ OpnsenseFirewallCategoryProvider(),
20
+ OpnsenseFirewallGroupProvider(),
21
+ );