@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.
- package/README.md +3 -2
- package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
- package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +4 -5
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +79 -103
- package/dist/cloudflare/index.js.map +6 -7
- package/dist/cloudflare/mesh-node-api.d.ts +3 -2
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +16 -5
- package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +34 -3
- package/dist/forgejo/branch-protection.d.ts.map +1 -1
- package/dist/forgejo/fake-forgejo.d.ts +41 -0
- package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
- package/dist/forgejo/index.js +220 -284
- package/dist/forgejo/index.js.map +14 -15
- package/dist/forgejo/org-actions-secrets.d.ts +42 -12
- package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
- package/dist/forgejo/org-label.d.ts +30 -3
- package/dist/forgejo/org-label.d.ts.map +1 -1
- package/dist/forgejo/org-team.d.ts +37 -4
- package/dist/forgejo/org-team.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook-form.d.ts +9 -18
- package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook.d.ts +35 -3
- package/dist/forgejo/repo-webhook.d.ts.map +1 -1
- package/dist/forgejo/repository.d.ts +47 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +37 -37
- package/dist/forgejo/resource.d.ts.map +1 -1
- package/dist/forgejo/team-member.d.ts +48 -4
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/forgejo/values.d.ts +9 -5
- package/dist/forgejo/values.d.ts.map +1 -1
- package/dist/launchd/index.js.map +2 -2
- package/dist/launchd/port-claims.d.ts +2 -2
- package/dist/launchd/port-claims.d.ts.map +1 -1
- package/docs/credentials.md +6 -0
- package/docs/distilled-interim.md +151 -0
- package/docs/launchd-ports.md +14 -17
- package/docs/peers.md +15 -6
- package/docs/upstream-conformance.md +46 -22
- package/package.json +2 -2
- package/src/cloudflare/fake-r2-lock.ts +120 -0
- package/src/cloudflare/index.ts +4 -5
- package/src/cloudflare/mesh-node-api.ts +3 -2
- package/src/cloudflare/providers.ts +13 -16
- package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
- package/src/cloudflare/r2-bucket-lock.ts +59 -51
- package/src/forgejo/branch-protection-form.ts +12 -5
- package/src/forgejo/branch-protection.ts +58 -26
- package/src/forgejo/fake-forgejo.ts +54 -0
- package/src/forgejo/org-actions-secrets.ts +44 -36
- package/src/forgejo/org-label.test.ts +100 -0
- package/src/forgejo/org-label.ts +46 -36
- package/src/forgejo/org-team.ts +40 -27
- package/src/forgejo/repo-webhook-form.ts +10 -27
- package/src/forgejo/repo-webhook.ts +66 -35
- package/src/forgejo/repository.test.ts +125 -0
- package/src/forgejo/repository.ts +72 -52
- package/src/forgejo/resource.ts +80 -79
- package/src/forgejo/team-member.test.ts +97 -0
- package/src/forgejo/team-member.ts +56 -38
- package/src/forgejo/values.ts +9 -17
- package/src/launchd/port-claims.ts +8 -9
- package/dist/cloudflare/client.d.ts +0 -45
- package/dist/cloudflare/client.d.ts.map +0 -1
- package/dist/forgejo/client.d.ts +0 -37
- package/dist/forgejo/client.d.ts.map +0 -1
- package/src/cloudflare/client.ts +0 -76
- package/src/forgejo/client.ts +0 -136
package/docs/launchd-ports.md
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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,
|
|
95
|
-
starts covering a port once a kit stack declares the job that
|
|
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
|
|
99
|
-
|
|
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
|
|
10
|
-
@distilled.cloud/cloudflare@1.0.0-rc.12 @
|
|
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
|
-
|
|
37
|
-
subpath would degrade without it — measured 2026-09-16,
|
|
38
|
-
`Cannot find package 'cloudflare'
|
|
39
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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);
|
package/src/cloudflare/index.ts
CHANGED
|
@@ -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`
|
|
5
|
-
* SDK
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
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,
|
|
10
|
-
*
|
|
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
|
-
* ★
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
+
});
|