@homeflare/alchemy 0.27.0 → 0.27.2

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 (76) hide show
  1. package/README.md +3 -2
  2. package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
  3. package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
  4. package/dist/cloudflare/index.d.ts +4 -5
  5. package/dist/cloudflare/index.d.ts.map +1 -1
  6. package/dist/cloudflare/index.js +79 -103
  7. package/dist/cloudflare/index.js.map +6 -7
  8. package/dist/cloudflare/mesh-node-api.d.ts +3 -2
  9. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
  10. package/dist/cloudflare/providers.d.ts +1 -1
  11. package/dist/cloudflare/providers.d.ts.map +1 -1
  12. package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
  13. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  14. package/dist/forgejo/branch-protection-form.d.ts +16 -5
  15. package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
  16. package/dist/forgejo/branch-protection.d.ts +34 -3
  17. package/dist/forgejo/branch-protection.d.ts.map +1 -1
  18. package/dist/forgejo/fake-forgejo.d.ts +41 -0
  19. package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
  20. package/dist/forgejo/index.js +220 -284
  21. package/dist/forgejo/index.js.map +14 -15
  22. package/dist/forgejo/org-actions-secrets.d.ts +42 -12
  23. package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
  24. package/dist/forgejo/org-label.d.ts +30 -3
  25. package/dist/forgejo/org-label.d.ts.map +1 -1
  26. package/dist/forgejo/org-team.d.ts +37 -4
  27. package/dist/forgejo/org-team.d.ts.map +1 -1
  28. package/dist/forgejo/repo-webhook-form.d.ts +9 -18
  29. package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
  30. package/dist/forgejo/repo-webhook.d.ts +35 -3
  31. package/dist/forgejo/repo-webhook.d.ts.map +1 -1
  32. package/dist/forgejo/repository.d.ts +47 -2
  33. package/dist/forgejo/repository.d.ts.map +1 -1
  34. package/dist/forgejo/resource.d.ts +37 -37
  35. package/dist/forgejo/resource.d.ts.map +1 -1
  36. package/dist/forgejo/team-member.d.ts +48 -4
  37. package/dist/forgejo/team-member.d.ts.map +1 -1
  38. package/dist/forgejo/values.d.ts +9 -5
  39. package/dist/forgejo/values.d.ts.map +1 -1
  40. package/dist/launchd/index.js.map +2 -2
  41. package/dist/launchd/port-claims.d.ts +2 -2
  42. package/dist/launchd/port-claims.d.ts.map +1 -1
  43. package/docs/credentials.md +6 -0
  44. package/docs/distilled-interim.md +151 -0
  45. package/docs/launchd-ports.md +14 -17
  46. package/docs/peers.md +15 -6
  47. package/docs/upstream-conformance.md +46 -22
  48. package/package.json +2 -2
  49. package/src/cloudflare/fake-r2-lock.ts +120 -0
  50. package/src/cloudflare/index.ts +4 -5
  51. package/src/cloudflare/mesh-node-api.ts +3 -2
  52. package/src/cloudflare/providers.ts +13 -16
  53. package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
  54. package/src/cloudflare/r2-bucket-lock.ts +59 -51
  55. package/src/forgejo/branch-protection-form.ts +12 -5
  56. package/src/forgejo/branch-protection.ts +58 -26
  57. package/src/forgejo/fake-forgejo.ts +54 -0
  58. package/src/forgejo/org-actions-secrets.ts +44 -36
  59. package/src/forgejo/org-label.test.ts +100 -0
  60. package/src/forgejo/org-label.ts +46 -36
  61. package/src/forgejo/org-team.ts +40 -27
  62. package/src/forgejo/repo-webhook-form.ts +10 -27
  63. package/src/forgejo/repo-webhook.ts +66 -35
  64. package/src/forgejo/repository.test.ts +125 -0
  65. package/src/forgejo/repository.ts +72 -52
  66. package/src/forgejo/resource.ts +80 -79
  67. package/src/forgejo/team-member.test.ts +97 -0
  68. package/src/forgejo/team-member.ts +56 -38
  69. package/src/forgejo/values.ts +9 -17
  70. package/src/launchd/port-claims.ts +8 -9
  71. package/dist/cloudflare/client.d.ts +0 -45
  72. package/dist/cloudflare/client.d.ts.map +0 -1
  73. package/dist/forgejo/client.d.ts +0 -37
  74. package/dist/forgejo/client.d.ts.map +0 -1
  75. package/src/cloudflare/client.ts +0 -76
  76. package/src/forgejo/client.ts +0 -136
@@ -36,18 +36,13 @@ right before the jobs it describes), and fails the **plan** — nothing is fetch
36
36
 
37
37
  ## Why it keys on the port number alone
38
38
 
39
- `house/nix/homeflare-config/hosts/macmini/modules/lib-ports.nix` (a different repo —
40
- `homeflare/homeflare`, read at `92cff7f`, 2026-09-23) is the check this replaces for a
41
- kit-declared job. Its `assertNoCollision` is:
42
-
43
- ```nix
44
- dupes = lib.subtractLists (lib.unique vals) vals; # vals = lib.attrValues ports
45
- ```
46
-
47
- — every port **value** in the registry, address and protocol never read. `portClaimProblems`
48
- reproduces that rule rather than a looser one, because that is the strictness the registry already
49
- enforces across the whole estate, and a kit-side check that disagreed with it would be a second,
50
- inconsistent source of truth.
39
+ A stack's jobs share one estate, and a kit-side check that disagreed with the estate's existing
40
+ port-collision convention — every port **value**, address and protocol never read — would be a
41
+ second, looser source of truth rather than a replacement for the stricter one. So
42
+ `portClaimProblems` reproduces that same strictness deliberately, keyed on the port number alone,
43
+ rather than inventing a narrower rule of its own. (A host still on Nix keeps that convention
44
+ enforced by its own port registry until the job migrates — see "What it cannot see" below; this
45
+ doc states the kit's rule on its own terms and does not depend on that registry's file.)
51
46
 
52
47
  ### The measured bind table
53
48
 
@@ -88,15 +83,17 @@ refuses that pairing anyway — the port number is the key, exactly like the reg
88
83
  - **A job the stack does not pass.** `claimPorts` takes exactly the array a stack builds and
89
84
  hands it; there is no argv-parsing, no config-file reading and no host inspection here to
90
85
  recover a port a stack forgot to include. Leaving a job's claim out of the list is invisible to
91
- this check, the same way a port left out of `lib-ports.nix` was invisible on 2026-09-02.
86
+ this check, the same way a port left out of a host's own Nix-based port registry was invisible
87
+ on 2026-09-02.
92
88
  - **A daemon still on Nix**, unless the migrating stack also passes that daemon's port as a claim
93
89
  of its own (owner naming the Nix job, so the message is legible during a cutover). Until a job
94
- moves off Nix, `lib-ports.nix` is still the thing keeping it collision-free; `claimPorts` only
95
- starts covering a port once a kit stack declares the job that binds it.
90
+ moves off Nix, that host's Nix-based port registry is still the thing keeping it
91
+ collision-free; `claimPorts` only starts covering a port once a kit stack declares the job that
92
+ binds it.
96
93
  - **Two protocols of the same port number.** Keying on the port alone means a daemon on `tcp/9094`
97
94
  and another on `udp/9094` would be refused as a collision, same as the Nix registry. No mini job
98
- does that today (checked against the registry at `92cff7f`); if one needs to, the key widens
99
- deliberately, in its own change — not silently here.
95
+ does that today; if one needs to, the key widens deliberately, in its own change — not silently
96
+ here.
100
97
 
101
98
  ## The error is a defect, not a typed channel
102
99
 
package/docs/peers.md CHANGED
@@ -6,8 +6,9 @@ The install line for `@homeflare/alchemy`, and why every peer on it and every en
6
6
 
7
7
  ```sh
8
8
  bun add @homeflare/alchemy alchemy@2.0.0-beta.79 effect@4.0.0-rc.115 \
9
- @effect/platform-node@4.0.0-rc.115 cloudflare@4.5.0 mime@4.1.0 \
10
- @distilled.cloud/cloudflare@1.0.0-rc.12 @effect/sql-pg@4.0.0-rc.115
9
+ @effect/platform-node@4.0.0-rc.115 mime@4.1.0 \
10
+ @distilled.cloud/cloudflare@1.0.0-rc.12 @distilled.cloud/forgejo@1.0.0-rc.12 \
11
+ @effect/sql-pg@4.0.0-rc.115
11
12
  ```
12
13
 
13
14
  🔴 **Why, measured 2026-09-16 on 0.1.0 and re-checked 2026-09-17 against Alchemy 78.**
@@ -33,10 +34,18 @@ import the Proxmox subpath.
33
34
  declaring it (measured 2026-09-17). 79 declares it; the peer stays so a consumer
34
35
  that followed the 78 README does not drop a required line.
35
36
 
36
- ⚠️ So is `cloudflare`. It was marked optional in 0.1.1, which claimed the `/cloudflare`
37
- subpath would degrade without it — measured 2026-09-16, the subpath does not load at all:
38
- `Cannot find package 'cloudflare'`. An optional peer should mean a feature is absent, not
39
- that an import fails.
37
+ ★ `cloudflare` **is gone, not merely tolerated.** It was a required peer (marked optional in
38
+ 0.1.1, which claimed the `/cloudflare` subpath would degrade without it — measured 2026-09-16,
39
+ the subpath did not load at all: `Cannot find package 'cloudflare'`) until 2026-09-23, when
40
+ `R2BucketLock` moved off the `cloudflare` npm SDK onto `@distilled.cloud/cloudflare/r2`, the same
41
+ one `MeshNode` already called. Nothing else in this package imported it, so the peer — and the
42
+ `client.ts` wrapper it existed for — were dropped rather than left declared and unused.
43
+
44
+ ⚠️ **`@distilled.cloud/forgejo` (added 2026-09-23, moving `/forgejo` off a hand-rolled
45
+ `HttpClient` client) is required for the same reason `@distilled.cloud/cloudflare` is: it is
46
+ a plain dependency of this package, not something a hoisting installer can hide from a strict
47
+ one.** Pinned to `1.0.0-rc.12` — the distilled release's own lockstep version, not alchemy's,
48
+ since alchemy does not bundle a Forgejo SDK the way it bundles Cloudflare's.
40
49
 
41
50
  ⚠️ **`@effect/sql-pg` (added for `/postgres`, 2026-09-23) is required too, for the same
42
51
  reason, not because every subpath imports it.** This package has one flat peer set for the
@@ -11,30 +11,54 @@ A decision marked **maintainer** is not an agent's to make.
11
11
  Findings are ranked by what they can break: runtime safety first, then contributability,
12
12
  then tidiness.
13
13
 
14
- 1. **`cloudflare/R2BucketLock` diverges on four rules.**
15
- - `readLock` ends in `Effect.orDie` (S20).
16
- - `reconcileLock` and `deleteLock` use `Effect.promise` (S19).
17
- - `reconcileLock` skips the PUT whenever `output.rules` equals the declaration, without
18
- reading the live lock (S10). So neither `--force` nor `drift --repair` can restore
19
- lock rules that were removed out of band. ⚠️ Reasoned from the source, not measured
20
- live.
21
- - `accountId` is a prop (S15).
22
-
23
- It is built on a second Cloudflare SDK, `cloudflare@4.5.0` (S23). Upstream
24
- `Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is a real gap.
25
- **Fix:** move to `@distilled.cloud/cloudflare/r2` `getBucketLock`/`putBucketLock`,
26
- observe before the PUT, and resolve the account from `CloudflareEnvironment`. Then drop
27
- the `cloudflare` peer. That makes it contributable as `lockRules` on `R2.Bucket`, or as
14
+ 1. **`cloudflare/R2BucketLock` diverges on three rules.**
15
+
16
+ ✅ **S23 fixed 2026-09-23** (branch `claude2/distilled-r2-bucket-lock`, an agent task
17
+ scoped to exactly this SDK swap): it now calls `@distilled.cloud/cloudflare/r2`'s
18
+ `getBucketLock`/`putBucketLock`, the same package `MeshNode` uses, with
19
+ `catchTag('NoSuchBucket', …)` in place of the old `instanceof NotFoundError` status
20
+ check. The `cloudflare` peer and `client.ts` are gone from the package — nothing else
21
+ imported either. As a side effect this also cleared the `Effect.promise` calls in
22
+ `reconcileLock`/`deleteLock` (S19): distilled's operations are already Effects, so there
23
+ is nothing left to promise-wrap.
24
+
25
+ Still open, and out of scope for that task (props/attributes were required to stay
26
+ byte-identical, so neither touches the wire contract):
27
+ - `readLock`, and now `reconcileLock`/`deleteLock` too, end in `Effect.orDie` for the
28
+ error channel distilled leaves after `NoSuchBucket` (S20). A full fix means a typed
29
+ refusal error for `InvalidRoute`/`CloudflareRateLimited`/`CloudflareError`, which is a
30
+ separate, larger change than the transport swap.
31
+ - `reconcileLock` still skips the PUT whenever `output.rules` equals the declaration,
32
+ without reading the live lock (S10). Neither `--force` nor `drift --repair` can
33
+ restore lock rules removed out of band. ⚠️ Reasoned from the source, not measured live.
34
+ - `accountId` is still a prop (S15).
35
+
36
+ Upstream `Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is
37
+ still a real gap. **Remaining fix:** observe before the PUT, and resolve the account
38
+ from `CloudflareEnvironment` — the same shape change `MeshNode` uses instead of a prop.
39
+ That makes it contributable as `lockRules` on `R2.Bucket`, or as
28
40
  `Cloudflare.R2.BucketLock`. **Decision:** maintainer, on the upstream shape.
29
41
 
30
- 2. **`forgejo/*` duplicates an upstream building block.** `client.ts` is a hand-rolled
31
- Effect `HttpClient` client with a status-carrying `ForgejoError`, while
32
- `@distilled.cloud/forgejo@1.0.0-rc.12` exists. That package's Smithy service version is
33
- 16.0.3, the version this family measured (S21, S23). Credentials come from
34
- `FORGEJO_TOKEN` at call time instead of an Auth provider (S24). `read` never answers
35
- `Unowned` (H1). **Replacement:** the distilled SDK plus an `alchemy/Auth` provider.
36
- Untyped errors become distilled patches. **Decision:** maintainer, because it changes
37
- the error surface.
42
+ 2. **`forgejo/*` duplicates an upstream building block.**
43
+ - ✅ **S23 fixed 2026-09-23** (branch `claude2/distilled-forgejo`, PR #180 — Tim, decision
44
+ 42, "build inside the kit first, dogfood and test"; an agent task scoped to exactly this
45
+ SDK swap). Every call now goes through
46
+ `@distilled.cloud/forgejo@1.0.0-rc.12`'s typed operations, `catchTag('NotFound', ...)`
47
+ replaced the status-carrying `ForgejoError`, and `client.ts` is deleted. Verified
48
+ operation by operation against the package before relying on it (every operation this
49
+ family calls exists, every error it handles has a tag) — nothing was missing, so no
50
+ distilled patch was needed. State did not move: every prop and attribute stays
51
+ byte-identical, proven by keeping the family's existing tests unchanged plus new tests
52
+ against a fake Forgejo exercising the real distilled protocol. No stack in this estate
53
+ currently imports `@homeflare/alchemy/forgejo` (measured 2026-09-23 across
54
+ homeflare-landscape and the house monorepo), so there is no live plan to re-run —
55
+ `house/forgejo` in the house monorepo declares the same seven resource types but against
56
+ its OWN independent hand-rolled copy under `house/forgejo/src/`, not this package; this
57
+ PR does not touch it.
58
+ - ⛔ **`read` still never answers `Unowned` (H1), open.** Credentials still come from
59
+ `FORGEJO_TOKEN` at call time rather than an `alchemy/Auth` provider (S24). Both need a
60
+ maintainer decision on the ownership-check shape before a distilled-backed
61
+ implementation is worth writing — the client swap did not attempt either.
38
62
  3. **`cloudflare/MeshNode` duplicates `Cloudflare.Tunnel.WarpConnector`.** The divergence is
39
63
  deliberate and documented in [mesh-node.md](./mesh-node.md): upstream persists the
40
64
  connector token in state as a `Redacted` attribute, and it cannot create an HA node. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -95,10 +95,10 @@
95
95
  },
96
96
  "peerDependencies": {
97
97
  "@distilled.cloud/cloudflare": "1.0.0-rc.12",
98
+ "@distilled.cloud/forgejo": "1.0.0-rc.12",
98
99
  "@effect/platform-node": "4.0.0-rc.115",
99
100
  "@effect/sql-pg": "4.0.0-rc.115",
100
101
  "alchemy": "2.0.0-beta.79",
101
- "cloudflare": ">=4.5.0",
102
102
  "effect": "4.0.0-rc.115",
103
103
  "mime": "4.1.0"
104
104
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * A fake Cloudflare R2 bucket-lock API for r2-bucket-lock.test.ts — an in-memory `fetch` handed to
3
+ * Effect's real FetchHttpClient through its `Fetch` reference, so the tests exercise distilled's
4
+ * real path assembly, header encoding, envelope decoding and error matching. Same idiom as
5
+ * fake-mesh.ts, for the route `GET`/`PUT /accounts/<FAKE_ACCOUNT>/r2/buckets/<name>/lock`.
6
+ *
7
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
8
+ * `api.example.com`, and any request outside that route throws, so a mis-wired test fails
9
+ * instead of reaching a real API.
10
+ * ★ `existingBuckets` MODELS THE VENDOR'S OWN DISTINCTION. A bucket not in the set answers
11
+ * `NoSuchBucket` (code 10006) on both verbs, matching the 404 the doc comment in
12
+ * r2-bucket-lock.ts measured. A bucket in the set with no rules answers `{}` on GET, matching
13
+ * "never locked" — both collapse to "absent" in `readLock`, but the fake keeps them distinct so
14
+ * a test can put a bucket in either state on purpose.
15
+ * ★ `onRoute` OVERRIDES ONE REQUEST. A test uses it to answer a request with an arbitrary
16
+ * `Response` — a non-404 status carrying code 10006 (proves matching is by code, not status), or
17
+ * a different tagged error entirely (`InvalidRoute`, code 7003) to prove it does NOT fall into
18
+ * `readLock`'s `NoSuchBucket` catch.
19
+ */
20
+ import { type Credentials, fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
21
+ import * as Layer from 'effect/Layer';
22
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
23
+ import type { R2LockRule } from './lock-rules.ts';
24
+
25
+ export const FAKE_BASE = 'https://api.example.com/client/v4';
26
+ export const FAKE_ACCOUNT = '00000000000000000000000000000002';
27
+ /** ⚠️ A placeholder, not a credential. */
28
+ export const FAKE_API_TOKEN = 'placeholder-api-token';
29
+
30
+ export type Seen = {
31
+ readonly method: string;
32
+ readonly path: string;
33
+ readonly headers: Record<string, string>;
34
+ readonly body: unknown;
35
+ };
36
+
37
+ const ok = (result: unknown) => Response.json({ success: true, errors: [], messages: [], result });
38
+
39
+ export const fakeFailure = (status: number, code: number, message: string) =>
40
+ Response.json(
41
+ { success: false, errors: [{ code, message }], messages: [], result: null },
42
+ { status },
43
+ );
44
+
45
+ /** The response `NoSuchBucket` matches: code 10006, conventionally at 404. */
46
+ export const noSuchBucket = (status = 404) =>
47
+ fakeFailure(status, 10006, 'The specified bucket does not exist.');
48
+
49
+ export type FakeOptions = {
50
+ /** Buckets the account has. Any other name is `NoSuchBucket` on both GET and PUT. */
51
+ readonly existingBuckets?: readonly string[];
52
+ /** Override one request's response. `undefined` falls through to the default route. */
53
+ readonly onRoute?: (method: string, bucketName: string) => Response | undefined;
54
+ };
55
+
56
+ export const fakeR2Lock = (options: FakeOptions = {}) => {
57
+ const existing = new Set(options.existingBuckets ?? []);
58
+ const locks = new Map<string, readonly R2LockRule[]>();
59
+ const seen: Seen[] = [];
60
+
61
+ const route = (method: string, url: URL, body: Record<string, unknown>): Response => {
62
+ const prefix = `/client/v4/accounts/${FAKE_ACCOUNT}/r2/buckets/`;
63
+ if (url.host !== 'api.example.com' || !url.pathname.startsWith(prefix)) {
64
+ throw new Error(`fake-r2-lock: unexpected request ${method} ${url.href}`);
65
+ }
66
+ const rest = url.pathname.slice(prefix.length).split('/');
67
+ const bucketName = rest[0];
68
+ if (bucketName === undefined || rest[1] !== 'lock' || rest.length !== 2) {
69
+ throw new Error(`fake-r2-lock: unexpected path ${url.pathname}`);
70
+ }
71
+ const override = options.onRoute?.(method, bucketName);
72
+ if (override !== undefined) return override;
73
+ if (!existing.has(bucketName)) return noSuchBucket();
74
+ if (method === 'GET') return ok({ rules: locks.get(bucketName) ?? [] });
75
+ if (method === 'PUT') {
76
+ const rules = (body['rules'] as readonly R2LockRule[] | undefined) ?? [];
77
+ if (rules.length === 0) locks.delete(bucketName);
78
+ else locks.set(bucketName, rules);
79
+ return ok({});
80
+ }
81
+ throw new Error(`fake-r2-lock: unexpected ${method} ${url.pathname}`);
82
+ };
83
+
84
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
85
+ const request =
86
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
87
+ const url = new URL(request.url);
88
+ const text = await request.text();
89
+ const body = text.length === 0 ? {} : (JSON.parse(text) as Record<string, unknown>);
90
+ seen.push({
91
+ method: request.method,
92
+ path: `${url.pathname}${url.search}`,
93
+ headers: Object.fromEntries(request.headers.entries()),
94
+ body,
95
+ });
96
+ return route(request.method, url, body);
97
+ }) as typeof globalThis.fetch;
98
+
99
+ return { existing, fetch, locks, seen };
100
+ };
101
+
102
+ export type FakeR2Lock = ReturnType<typeof fakeR2Lock>;
103
+
104
+ /** distilled Credentials + the real FetchHttpClient over the fake — `CloudflareOpContext`. */
105
+ export const fakeR2LockLayer = (
106
+ fake: FakeR2Lock,
107
+ credentials: Layer.Layer<Credentials> = fromApiToken({
108
+ apiToken: FAKE_API_TOKEN,
109
+ apiBaseUrl: FAKE_BASE,
110
+ }),
111
+ ) =>
112
+ Layer.mergeAll(
113
+ credentials,
114
+ FetchHttpClient.layer,
115
+ Layer.succeed(FetchHttpClient.Fetch, fake.fetch),
116
+ );
117
+
118
+ /** The writes the fake saw, in order: `['PUT', 'GET', …]`. */
119
+ export const writes = (fake: FakeR2Lock) =>
120
+ fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method);
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Cloudflare providers for Alchemy — the gaps the vendor SDK leaves.
3
3
  *
4
- * ★ Built on vendor SDKs, not hand-rolled HTTP. `R2BucketLock` uses the official `cloudflare`
5
- * SDK, which already has `r2.buckets.locks.update/get` over the documented endpoints.
6
- * `MeshNode` uses `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare providers
7
- * use, because `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). Either way
8
- * there is no path string this package invents.
4
+ * ★ Built on vendor SDKs, not hand-rolled HTTP. Both `R2BucketLock` and `MeshNode` call
5
+ * `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare providers use — R2BucketLock
6
+ * over `/r2` (`getBucketLock`/`putBucketLock`), MeshNode over `/zero-trust`, because
7
+ * `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). Neither invents a path string.
9
8
  */
10
9
  export type { R2LockRule } from './lock-rules.ts';
11
10
  export { R2BucketLock, type R2BucketLockProps } from './r2-bucket-lock.ts';
@@ -6,8 +6,9 @@
6
6
  * `GET …/warp_connector/{id}/token` (developers.cloudflare.com/mesh/get-started, read
7
7
  * 2026-09-21). There is no separate "mesh node" endpoint.
8
8
  *
9
- * ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE, not the
10
- * `cloudflare` npm SDK the R2 lock uses. Measured 2026-09-21: `cloudflare@4.5.0`'s
9
+ * ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE — and, as
10
+ * of 2026-09-23, the same one `R2BucketLock` calls too (r2-bucket-lock.ts), not the `cloudflare`
11
+ * npm SDK either used before that. Measured 2026-09-21: `cloudflare@4.5.0`'s
11
12
  * `WARPConnectorCreateParams` has only `account_id` and `name`, so it cannot create an HA node.
12
13
  * distilled `1.0.0-rc.12` (the version alchemy@2.0.0-beta.79 pins) has `ha?: boolean` on
13
14
  * `CreateTunnelWarpConnectorRequest`, typed errors (`TunnelNotFound` code 1002 / 404,
@@ -1,41 +1,38 @@
1
1
  /**
2
2
  * The HomeFlare Cloudflare provider collection, in the shape `LiteLLM.providers()` has.
3
3
  *
4
- * ★ ONE LAYER, BUILT ONCE, so the single `Config.Redacted('CLOUDFLARE_API_TOKEN')` read is shared
5
- * by every resource in the stack rather than repeated per resource.
6
- *
7
4
  * ⚠️ THIS IS NOT `alchemy/Cloudflare`'s COLLECTION AND DOES NOT REPLACE IT. A stack that declares
8
5
  * both a `Cloudflare.R2.Bucket` and this lock merges the two:
9
6
  * `Layer.mergeAll(Cloudflare.providers(), HomeflareCloudflare.providers())` — the shape
10
7
  * a consuming stack names for one talking to more than one account.
11
8
  *
12
- * ★ TWO CREDENTIAL PATHS, ONE PER SDK. `R2BucketLock` uses the `cloudflare` SDK through
13
- * `CloudflareApiLive` (`CLOUDFLARE_API_TOKEN`, fail-closed). `MeshNode` uses Alchemy's own SDK
14
- * and resolves credentials and the account through `Cloudflare.CloudflareApiLive()` — the same
15
- * source `Cloudflare.providers()` uses (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
16
- * `CLOUDFLARE_ACCOUNT_ID` in CI), so a node lands in the account its Gateway rules do.
17
- * ⚠️ That second layer is built again here even when the stack also merges
18
- * `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: it feeds MeshNode only and adds
19
- * nothing to the stack's context.
9
+ * ★ ONE CREDENTIAL PATH NOW, NOT TWO. Until 2026-09-23 `R2BucketLock` ran over the `cloudflare`
10
+ * npm SDK with its own `CLOUDFLARE_API_TOKEN` read (`client.ts`, since deleted) while `MeshNode`
11
+ * ran over `@distilled.cloud/cloudflare` through `Cloudflare.CloudflareApiLive()`. Both
12
+ * providers now call distilled, so both resolve credentials and the account the same way
13
+ * `Cloudflare.providers()` does (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
14
+ * `CLOUDFLARE_ACCOUNT_ID` in CI) — a lock and the Gateway rules that reference the same bucket
15
+ * land in the same account.
16
+ * ⚠️ THE LAYER IS BUILT TWICE ON PURPOSE, once per provider, even when the stack also merges
17
+ * `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: each feeds its own provider
18
+ * only and adds nothing to the stack's context.
20
19
  */
21
20
  import * as Cloudflare from 'alchemy/Cloudflare';
22
21
  import * as Provider from 'alchemy/Provider';
23
22
  import * as Layer from 'effect/Layer';
24
- import { CloudflareApiLive } from './client.ts';
25
23
  import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
26
24
  import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
27
25
 
28
26
  export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
29
27
 
28
+ // ⛔ `orDie` ON BOTH, as `Cloudflare.providers()` ends with: a stack's providers layer must not
29
+ // fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
30
30
  export const providers = () =>
31
31
  Layer.effect(Providers, Provider.collection([R2BucketLock, MeshNode])).pipe(
32
32
  Layer.provide(
33
33
  Layer.mergeAll(
34
- R2BucketLockProvider(),
35
- // ⛔ `orDie`, as `Cloudflare.providers()` ends with: a stack's providers layer must not
36
- // fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
34
+ R2BucketLockProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
37
35
  MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
38
36
  ),
39
37
  ),
40
- Layer.provideMerge(CloudflareApiLive),
41
38
  );
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `readLock` / `reconcileLock` / `deleteLock` against the fake Cloudflare in fake-r2-lock.ts, the
3
+ * same direct style mesh-node-token.test.ts uses for `fetchMeshNodeToken`: run the plain Effect
4
+ * under `fakeR2LockLayer`, no Provider or engine harness needed.
5
+ *
6
+ * ★ THE POINT OF THIS FILE IS THE TAGGED-ERROR PATHS, per the transport swap onto
7
+ * `@distilled.cloud/cloudflare/r2`: `NoSuchBucket` is matched by Cloudflare's own error code
8
+ * (10006), never by HTTP status, and only `readLock` treats it as "nothing to adopt" —
9
+ * `reconcileLock`/`deleteLock` let it die like any other tagged error.
10
+ */
11
+ import type { CloudflareOpContext } from '@distilled.cloud/cloudflare/r2';
12
+ import { Unowned } from 'alchemy/AdoptPolicy';
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Cause from 'effect/Cause';
15
+ import * as Effect from 'effect/Effect';
16
+ import * as Exit from 'effect/Exit';
17
+ import {
18
+ FAKE_ACCOUNT,
19
+ type FakeR2Lock,
20
+ fakeFailure,
21
+ fakeR2Lock,
22
+ fakeR2LockLayer,
23
+ writes,
24
+ } from './fake-r2-lock.ts';
25
+ import type { R2LockRule } from './lock-rules.ts';
26
+ import { type R2BucketLockProps, deleteLock, readLock, reconcileLock } from './r2-bucket-lock.ts';
27
+
28
+ const run = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
29
+ Effect.runPromise(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
30
+
31
+ const dies = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
32
+ Effect.runPromiseExit(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
33
+
34
+ const rule: R2LockRule = {
35
+ id: 'keep-30d',
36
+ enabled: true,
37
+ condition: { type: 'Age', maxAgeSeconds: 2_592_000 },
38
+ };
39
+
40
+ const props = (over: Partial<R2BucketLockProps> = {}): R2BucketLockProps => ({
41
+ accountId: FAKE_ACCOUNT,
42
+ bucketName: 'my-backups',
43
+ rules: [rule],
44
+ ...over,
45
+ });
46
+
47
+ describe('readLock', () => {
48
+ test('a bucket that does not exist (NoSuchBucket, code 10006) reads as absent', async () => {
49
+ const fake = fakeR2Lock();
50
+ expect(await run(fake, readLock(props(), false))).toBeUndefined();
51
+ });
52
+
53
+ test('a bucket that exists but was never locked reads as absent', async () => {
54
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
55
+ expect(await run(fake, readLock(props(), false))).toBeUndefined();
56
+ });
57
+
58
+ test('a locked bucket, written by this stack, reads as plain attributes', async () => {
59
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
60
+ await run(fake, reconcileLock(props(), undefined));
61
+ const attrs = await run(fake, readLock(props(), true));
62
+ expect(Unowned.is(attrs)).toBe(false);
63
+ expect(attrs).toEqual({ bucketName: 'my-backups', jurisdiction: 'default', rules: [rule] });
64
+ });
65
+
66
+ test('a locked bucket, not yet written by this stack, reads Unowned', async () => {
67
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
68
+ await run(fake, reconcileLock(props(), undefined));
69
+ const attrs = await run(fake, readLock(props(), false));
70
+ expect(Unowned.is(attrs)).toBe(true);
71
+ });
72
+
73
+ test('NoSuchBucket is matched by code, not by HTTP status', async () => {
74
+ // ⛔ THE PROOF THAT THIS IS catchTag, NOT A STATUS CHECK: code 10006 at a non-404 status is
75
+ // still caught. The old `cloudflare` SDK's `instanceof NotFoundError` could only ever match
76
+ // a 404; distilled's error matcher does not look at status at all for this tag.
77
+ const fake = fakeR2Lock({
78
+ onRoute: () => fakeFailure(400, 10006, 'The specified bucket does not exist.'),
79
+ });
80
+ expect(await run(fake, readLock(props(), false))).toBeUndefined();
81
+ });
82
+
83
+ test('a different tagged error at 404 is NOT swallowed as NoSuchBucket', async () => {
84
+ // ⛔ code 7003 is `InvalidRoute`, not `NoSuchBucket` — even at the same 404 status a plain
85
+ // status check would have treated as "no bucket". `catchTag('NoSuchBucket', …)` leaves it
86
+ // alone, and `Effect.orDie` turns it into a defect.
87
+ const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
88
+ const exit = await dies(fake, readLock(props(), false));
89
+ expect(Exit.isFailure(exit)).toBe(true);
90
+ expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe('reconcileLock', () => {
95
+ test('writes nothing when the declared rules already match', async () => {
96
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
97
+ const output = await run(fake, reconcileLock(props(), undefined));
98
+ fake.seen.length = 0;
99
+ const again = await run(fake, reconcileLock(props(), output));
100
+ expect(again).toEqual(output);
101
+ expect(writes(fake)).toEqual([]);
102
+ });
103
+
104
+ test('PUTs the whole rule set when it differs from output', async () => {
105
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
106
+ await run(fake, reconcileLock(props(), undefined));
107
+ expect(writes(fake)).toEqual(['PUT']);
108
+ expect(fake.locks.get('my-backups')).toEqual([rule]);
109
+ });
110
+
111
+ test('NoSuchBucket is NOT caught here: a genuinely missing bucket dies', async () => {
112
+ const fake = fakeR2Lock();
113
+ const exit = await dies(fake, reconcileLock(props(), undefined));
114
+ expect(Exit.isFailure(exit)).toBe(true);
115
+ expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe('deleteLock', () => {
120
+ test('PUTs an empty rule set, unlocking the bucket', async () => {
121
+ const fake = fakeR2Lock({ existingBuckets: ['my-backups'] });
122
+ const output = await run(fake, reconcileLock(props(), undefined));
123
+ fake.seen.length = 0;
124
+ await run(fake, deleteLock(output, FAKE_ACCOUNT));
125
+ expect(writes(fake)).toEqual(['PUT']);
126
+ expect(fake.locks.has('my-backups')).toBe(false);
127
+ });
128
+
129
+ test('NoSuchBucket is NOT caught here either: a genuinely missing bucket dies', async () => {
130
+ const fake = fakeR2Lock();
131
+ const output = await run(
132
+ fakeR2Lock({ existingBuckets: ['my-backups'] }),
133
+ reconcileLock(props(), undefined),
134
+ );
135
+ const exit = await dies(fake, deleteLock(output, FAKE_ACCOUNT));
136
+ expect(Exit.isFailure(exit)).toBe(true);
137
+ expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
138
+ });
139
+ });