@homeflare/alchemy 0.48.1 → 0.49.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 (80) hide show
  1. package/README.md +11 -0
  2. package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
  3. package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
  4. package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
  5. package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
  6. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
  7. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
  8. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
  9. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
  10. package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
  11. package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
  12. package/dist/cloudflare/fake-access.d.ts +40 -0
  13. package/dist/cloudflare/fake-access.d.ts.map +1 -0
  14. package/dist/cloudflare/fake-mesh.d.ts +7 -2
  15. package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
  16. package/dist/cloudflare/fake-tunnel.d.ts +48 -0
  17. package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
  18. package/dist/cloudflare/index.d.ts +15 -4
  19. package/dist/cloudflare/index.d.ts.map +1 -1
  20. package/dist/cloudflare/index.js +518 -7
  21. package/dist/cloudflare/index.js.map +15 -4
  22. package/dist/cloudflare/providers.d.ts +1 -1
  23. package/dist/cloudflare/providers.d.ts.map +1 -1
  24. package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
  25. package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
  26. package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
  27. package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
  28. package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
  29. package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
  30. package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
  31. package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
  32. package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
  33. package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
  34. package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
  35. package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
  36. package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
  37. package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
  38. package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
  39. package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
  40. package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
  41. package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
  42. package/dist/cloudflare/saas-oidc.d.ts +36 -0
  43. package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
  44. package/docs/cloudflared-tunnel.md +136 -0
  45. package/docs/credentials.md +1 -1
  46. package/docs/ownership.md +3 -2
  47. package/docs/saas-oidc.md +185 -0
  48. package/docs/upstream-conformance-families.md +1 -1
  49. package/docs/upstream-conformance.md +3 -0
  50. package/package.json +1 -1
  51. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  52. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  53. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  54. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  55. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  56. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  57. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  58. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  59. package/src/cloudflare/fake-access.ts +196 -0
  60. package/src/cloudflare/fake-mesh.ts +6 -3
  61. package/src/cloudflare/fake-tunnel.ts +148 -0
  62. package/src/cloudflare/index.ts +27 -4
  63. package/src/cloudflare/providers.ts +16 -3
  64. package/src/cloudflare/saas-oidc-api.ts +224 -0
  65. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  66. package/src/cloudflare/saas-oidc-error.ts +52 -0
  67. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  68. package/src/cloudflare/saas-oidc-form.ts +190 -0
  69. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  70. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  71. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  72. package/src/cloudflare/saas-oidc-props.ts +147 -0
  73. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  74. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  75. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  76. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  77. package/src/cloudflare/saas-oidc-team.ts +70 -0
  78. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  79. package/src/cloudflare/saas-oidc.test.ts +201 -0
  80. package/src/cloudflare/saas-oidc.ts +107 -0
@@ -0,0 +1,185 @@
1
+ # SaasOidcApplication — Access as an OIDC provider, with no client secret in state
2
+
3
+ `SaasOidcApplication` (`HomeFlare.Access.SaasOidcApplication`) declares a Cloudflare Access **SaaS
4
+ application** of auth type `oidc`: Access acts as the OpenID Connect provider for one relying
5
+ party (OpenBao's OIDC auth, Headlamp, a Kubernetes API server). It exposes the issuer, the client
6
+ id and the JWKS endpoint as attributes, so the relying party's configuration can be declared from
7
+ them. Read 2026-10-09 against `alchemy@2.0.0-beta.81` and
8
+ `@distilled.cloud/cloudflare@1.0.0-rc.13`.
9
+
10
+ ## Why not `Cloudflare.Access.Application`
11
+
12
+ - **It cannot send or read `saas_app`.** Its source mentions `saas` only as an app type and in two
13
+ comments (`Cloudflare/Access/Application.ts`, beta.81). An OIDC app is the `saas_app` object.
14
+ - **The SDK drops it on the way back.** The request type carries `saasApp`
15
+ (`AccessApplicationsCreateRequestSaasAppOIDCSaaSApp`), so create and update use it. But a fake
16
+ API that answered a full `saas_app` decoded, for both `getAccessApplicationForAccount` and
17
+ `createAccessApplicationForAccount`, to `{ domain, type, id, aud, name, policies }` (measured
18
+ 2026-10-09). So the client id, redirect URIs, scopes and grants are read from the wire JSON by
19
+ one raw `GET /accounts/{account_id}/access/apps/{app_id}`, over the same `HttpClient` and
20
+ credentials the SDK uses. That one read is the only hand-written call; it adds no path.
21
+ ❓ **Open question for Tim (lane contract 1, SDKs over hand-rolling):** the raw GET exists only
22
+ because rc.13 decodes the GET without `saas_app`. Approve it until upstream decodes the field, or
23
+ hold this resource.
24
+ - Track upstream: when Alchemy's `Access.Application` gains `saas_app` (and distilled decodes it),
25
+ this resource should be retired in its favour. Nothing is filed upstream from here.
26
+
27
+ ## At a glance
28
+
29
+ ```ts
30
+ import { SaasOidcApplication } from '@homeflare/alchemy/cloudflare';
31
+
32
+ const app = yield * SaasOidcApplication('Headlamp', { teamDomain, policies, saasApp });
33
+ // app.issuer, app.clientId, app.jwksEndpoint
34
+ ```
35
+
36
+ - ⛔ **The client secret is never read, stored or logged.** A public client declares
37
+ `allowPkceWithoutClientSecret: true` with the PKCE grant, and then no secret exists.
38
+ - ⛔ **The type id is a state key** (moved from homeflare-openbao verbatim) and it **retains on
39
+ destroy**: deleting the app deletes the client id every relying party uses.
40
+
41
+ ## Declaring an app
42
+
43
+ ```ts
44
+ import { SaasOidcApplication, providers } from '@homeflare/alchemy/cloudflare';
45
+
46
+ // providers: Layer.mergeAll(Cloudflare.providers(), providers()),
47
+ const headlamp =
48
+ yield *
49
+ SaasOidcApplication('Headlamp', {
50
+ name: 'Headlamp',
51
+ teamDomain: 'example.cloudflareaccess.com',
52
+ policies: [adminPolicyId],
53
+ saasApp: {
54
+ authType: 'oidc',
55
+ redirectUris: ['https://headlamp.example.com/oidc-callback'],
56
+ scopes: ['openid', 'email', 'groups'],
57
+ grantTypes: ['authorization_code_with_pkce'],
58
+ accessTokenLifetime: '15m',
59
+ allowPkceWithoutClientSecret: true, // a public client: no secret exists
60
+ },
61
+ });
62
+ // headlamp.issuer, headlamp.clientId, headlamp.jwksEndpoint
63
+ ```
64
+
65
+ | prop | notes |
66
+ | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
67
+ | `teamDomain` | **Required.** Bare hostname of the Zero Trust team, no scheme or path. |
68
+ | `applicationId` | Optional. Set it to adopt a known live app; otherwise the app is found by exact `name`. |
69
+ | `name` | Optional. Defaults to a physical name from the logical id. |
70
+ | `policies` | Reusable Access policy ids, in ascending precedence. The order is declared: a reorder syncs. |
71
+ | `sessionDuration`, `allowedIdps` | Optional. Written on create and on any sync. `sessionDuration` defaults to `24h`. |
72
+ | `autoRedirectToIdentity`, `appLauncherVisible` | Optional. `appLauncherVisible` defaults to `false`. |
73
+ | `saasApp.redirectUris`, `scopes`, `grantTypes` | The OIDC client's settings. |
74
+ | `saasApp.accessTokenLifetime` | `m` or `h` units, `1m` to `24h` (the SDK field doc). |
75
+ | `saasApp.refreshTokenLifetime` | Optional. Needed for the `refresh_tokens` grant. |
76
+ | `saasApp.allowPkceWithoutClientSecret` | `true` = public client. Needs the `authorization_code_with_pkce` grant (refused otherwise). |
77
+
78
+ Attributes: everything above that Cloudflare reports, plus `applicationId`, `aud`, `clientId`,
79
+ `accountId`, `teamDomain`, `domain`, and the OIDC URLs `issuer`
80
+ (`https://<teamDomain>/cdn-cgi/access/sso/oidc/<clientId>`), `authorizationEndpoint`,
81
+ `tokenEndpoint`, `jwksEndpoint`, `userinfoEndpoint` and `configurationEndpoint`.
82
+
83
+ ★ **The team domain is a prop, not a constant.** The openbao copy this came from hardcoded one
84
+ account's. A declared `teamDomain` that disagrees with the host Cloudflare reports in the app's own
85
+ `domain` is refused before any write to an existing app, so the issuer cannot point at a different
86
+ team. When Cloudflare reports no `domain` at all, the team already recorded in the row stands in: a
87
+ declaration that changes it is refused too (the issuer is a declared stable and cannot move).
88
+
89
+ ★ **A first create is checked against the account's organization, when it can be read.** No app
90
+ exists to compare, so before the first write the resource reads the Zero Trust organization
91
+ (`GET /accounts/{account_id}/access/organizations`, the SDK's `listOrganizationsForAccount`; its
92
+ `auth_domain` is the team domain, the same read Alchemy's `Access.Organization` does) and refuses a
93
+ `teamDomain` that differs, with **no write**. It is one attempt, so a token that may not read it does
94
+ not wait on retries.
95
+
96
+ ⚠️ **Known limit: that read needs a token permission the app write does not.** Cloudflare's docs
97
+ (read 2026-10-09, not measured against a live token) give `Access: Organizations Read` (or
98
+ `Access: Organizations, Identity Providers, and Groups Read`) for it, and `Access: Apps and Policies
99
+ Write` for creating the app. Where the token cannot read the organization (a 401 or 403, or no
100
+ organization), the check gives no answer and the create goes on as before: a wrong `teamDomain` then
101
+ writes one app, the post-write check fails the run, and a live app is left with no state row.
102
+ **Cleanup:** the next run finds it by name as `Unowned`; fix the `teamDomain` and declare
103
+ `adopt(true)`, or delete the stray app in the Zero Trust dashboard. **To close the limit**, add
104
+ `Access: Organizations Read` to the token the deploy runs with. Any other failure of the read (a
105
+ throttle, a 5xx) fails the run before a write; run again.
106
+
107
+ ## The client secret
108
+
109
+ ⛔ **It is never read, stored or logged by this package.** The SDK schema's own doc says the secret
110
+ is "only returned on POST request". The create response is decoded by the SDK (which drops it, see
111
+ above); the raw read copies named fields and has no field for it; the attributes have none.
112
+ `saas-oidc-state.test.ts` serialises the whole in-memory state after a create whose fake response
113
+ hands a secret out, and asserts it is absent.
114
+
115
+ ⚠️ **Two paths where the SDK itself holds or prints the raw body** (`@distilled.cloud/cloudflare`
116
+ rc.13, `protocol.ts`), so "never logged" is a claim about this package, not about the SDK:
117
+
118
+ 1. `DISTILLED_DEBUG_HTTP` makes it `console.error` the first 400 characters of every response
119
+ (line 334). A write refuses to run while it is set. **This one stays an operator-only switch:**
120
+ the package can refuse to run, it cannot stop the SDK printing, so never set it for a deploy.
121
+ 2. A response that fails schema validation becomes `CloudflareParseError({ body, cause })`
122
+ (lines 452-457), and `body` is the whole parsed response, a create response with its secret
123
+ included. **Sanitized in this package:** every SDK call of the resource (create, update, list,
124
+ delete, and the organization read) catches that tag at the call and fails with a `SaasOidcError`
125
+ that names the operation and carries no body, no response field and no cause
126
+ (`saas-oidc-error.ts`). `saas-oidc-parse-error.test.ts` forces the error with a fake
127
+ `client_secret` planted and checks the message, the fields, `String(err)`, `JSON.stringify(err)`
128
+ and the printed cause. The raw `GET` is not an SDK call and never builds that error.
129
+ The error exists only under strict response validation (`ResponseValidation.strict`, which
130
+ neither Alchemy nor this kit turns on; the default is lenient). Measured 2026-10-09: rc.13
131
+ decodes a create or update response against `S.Unknown`, so it cannot raise the error for those
132
+ two today; the catch stays so a later SDK that types them cannot start leaking.
133
+
134
+ Neither exposes anything for a public PKCE client, which has no secret.
135
+
136
+ - **A public client has no secret to keep** (`allowPkceWithoutClientSecret: true` with the PKCE
137
+ grant). That is the design for Headlamp (Q6 of the Kubernetes platform decisions).
138
+ - **A confidential client's secret is out of band.** This resource does not capture it. How to
139
+ obtain or rotate it for an existing app is UNVERIFIED here (no documented rotate verb was read);
140
+ homeflare-openbao keeps its live one in the macOS Keychain.
141
+ - UNVERIFIED: whether Cloudflare still returns a `client_secret` on POST for a PKCE-without-secret
142
+ app. It does not matter here, since it is dropped either way.
143
+
144
+ ## Adopting the openbao app, and the state key
145
+
146
+ ⛔ **The type id `HomeFlare.Access.SaasOidcApplication` is kept verbatim.** Alchemy state rows are
147
+ keyed by it, and homeflare-openbao's live `OpenBaoOidcSaas` row was written under it. A different
148
+ id plans a second app (new client id, new `aud`) beside the orphaned live one. The state test
149
+ seeds a row in the old shape (no `teamDomain`) and proves the next deploy updates it in place:
150
+ no create, no delete, the same application and client ids.
151
+
152
+ - State written before `teamDomain` existed still reads: the host from the app's own `domain`
153
+ stands in until the next reconcile stores the declared one.
154
+ - homeflare-openbao's stack must merge this package's `providers()` (the kit's collection) where it
155
+ merged `Cloudflare.providers()` for this type, and add `teamDomain` to its declaration.
156
+ - An **owned** row whose app was deleted out of band reads as gone, never as a same-named app; the
157
+ next reconcile then creates a new one (a new client id). The name fallback is for a cold read.
158
+ - A live app is `Unowned` until the declaration says `adopt(true)`, like every kit resource.
159
+
160
+ ## What a change does
161
+
162
+ - **The issuer and every endpoint are `stables`**: an update (a redirect URI, a policy) leaves
163
+ `app.issuer` and the others resolved for consumers at plan time, so a Talos
164
+ `KubeAuthenticationConfig` that reads them is not re-planned. `teamDomain` is deliberately not
165
+ stable (the openbao row has no such attribute). `saas-oidc-stable.test.ts` runs the engine.
166
+
167
+ - **Created** when no app has the id or the exact name. Names are not unique in Access, so two
168
+ saas apps with the declared name are refused: set `applicationId`.
169
+ - **Synced** (one PUT of the whole declaration) when the name, auth type, redirect URIs, scopes,
170
+ grant types, either token lifetime, the PKCE flag or the policies differ. Order is ignored for
171
+ redirect URIs, scopes and grant types. **It is not ignored for `policies`:** they are declared in
172
+ ascending precedence, so a reorder is drift (the live order is read from each policy's
173
+ `precedence`).
174
+ - ⚠️ **Not drift-checked:** `sessionDuration`, `allowedIdps`, `autoRedirectToIdentity`,
175
+ `appLauncherVisible`, `appLauncherUrl`. They are written on create and on any sync, but a change
176
+ to only those does not trigger one. This is carried over from the openbao copy unchanged.
177
+ - A matching live app is **zero writes**.
178
+ - An `applicationId` that is not a `saas` app is refused, never rewritten.
179
+ - Retries follow Alchemy's own `retryTransientAccessError` (same file as above): a freshly created
180
+ policy still propagating, and 403 as Cloudflare's throttle. About 45 seconds, then it fails.
181
+
182
+ ⛔ **Removal policy: `retain` by default**, because deleting the app deletes the client id every
183
+ relying party is configured with. `delete` stays fully implemented (an app already gone is a
184
+ success): opt in with `.pipe(RemovalPolicy.destroy())`. `list` is empty and `nuke` is skipped:
185
+ Alchemy's own `Access.Application` already lists every Access app, saas ones included.
@@ -72,7 +72,7 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
72
72
  in lifecycle operations; it is not a separate credentials-layer prohibition. The same
73
73
  shape in NetBox is therefore not an S20 follow-up. See [LiteLLM's Q6 resolution](./litellm.md#credentials).
74
74
  11. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
75
- `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
75
+ `R2BucketLock`, `MeshNode` and `CloudflaredTunnel` declare `nuke`. The constructor already defaults `list`
76
76
  (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
77
77
  where nuke must never reach the object.
78
78
  12. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
@@ -66,6 +66,9 @@ then tidiness.
66
66
  type string `Cloudflare.MeshNode` sits inside upstream's own namespace (H14).
67
67
  **Options:** contribute `ha` upstream and raise the token-in-state concern, then switch;
68
68
  or keep the house resource as a recorded divergence. **Decision:** maintainer.
69
+ `cloudflare/CloudflaredTunnel` is the same divergence for `Cloudflare.Tunnel.Tunnel`
70
+ ([cloudflared-tunnel.md](./cloudflared-tunnel.md)): the token in state is the concern to raise
71
+ upstream, and its type string `Cloudflare.CloudflaredTunnel` also sits in upstream's namespace (H14).
69
72
  4. **Unguarded `Bun.*`, and the `node:*` modules upstream names, in shipped provider code**
70
73
  (S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
71
74
  under Node, which is the runtime this package promises its consumers. The `node:*` paths
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.48.1",
3
+ "version": "0.49.0",
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",
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The `cfd_tunnel` calls a Cloudflare Tunnel (the cloudflared kind) needs, and nothing else.
3
+ *
4
+ * ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE, the same
5
+ * one mesh-node-api.ts calls. The operations are `createTunnelCloudflared` (POST
6
+ * `/accounts/{account_id}/cfd_tunnel`), `getTunnelCloudflared`, `patchTunnelCloudflared`,
7
+ * `deleteTunnelCloudflared` and the account-wide `listTunnels` (GET `/accounts/{id}/tunnels`,
8
+ * filtered to `cfd_tunnel`), all in distilled `1.0.0-rc.13` `src/services/zero_trust.ts`
9
+ * (read 2026-10-09).
10
+ *
11
+ * ⛔ NO FUNCTION IN THIS FILE CALLS `getTunnelCloudflaredToken`, AND `observe` COPIES NAMED FIELDS
12
+ * ONLY. Alchemy's own `Cloudflare.Tunnel.Tunnel` (beta.81, Tunnel/Tunnel.ts `reconcile`, `read`
13
+ * and `list`) fetches the token on every call and stores it as a `Redacted` attribute, which
14
+ * `encodeState` (State/StateEncoding.ts) writes into state as `{"__redacted__": "<token>"}`:
15
+ * plaintext, and the token runs the tunnel. This package never holds it, so there is nothing to
16
+ * leak. See cloudflared-tunnel-form.ts.
17
+ * ⚠️ `createTunnelCloudflared` takes no `tunnelSecret` here either: the schema calls the field the
18
+ * password for a locally-managed tunnel and marks it optional (distilled rc.13 zero_trust.ts,
19
+ * `CreateTunnelCloudflaredRequest`); that Cloudflare generates one when it is omitted is
20
+ * UNVERIFIED, and a remotely managed tunnel needs no secret on our side.
21
+ */
22
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
23
+ import * as Data from 'effect/Data';
24
+ import * as Effect from 'effect/Effect';
25
+ import * as Stream from 'effect/Stream';
26
+
27
+ /** A live tunnel as this package sees it. ⛔ There is no token or secret field, on purpose. */
28
+ export interface ObservedTunnel {
29
+ readonly id: string;
30
+ readonly name: string;
31
+ /** `undefined` when the response omitted it; only an explicit `local` is refused (lifecycle). */
32
+ readonly configSrc: 'cloudflare' | 'local' | undefined;
33
+ }
34
+
35
+ /** A refusal or failure, carrying the sentence an operator needs. Never a token. */
36
+ export class CloudflaredTunnelError extends Data.TaggedError('CloudflaredTunnelError')<{
37
+ readonly message: string;
38
+ }> {}
39
+
40
+ type Raw = {
41
+ readonly id?: string | null;
42
+ readonly name?: string | null;
43
+ readonly configSrc?: string | null;
44
+ readonly tunType?: string | null;
45
+ readonly deletedAt?: string | null;
46
+ };
47
+
48
+ /**
49
+ * ⚠️ A DELETED TUNNEL IS NOT A TUNNEL. Cloudflare keeps deleted tunnels (every response carries
50
+ * `deleted_at`), so a `GET` by id can answer for one; adopting that tombstone would PATCH it.
51
+ * ⚠️ NOR IS ANOTHER TUNNEL TYPE. `/tunnels` lists every type, and a `warp_connector` (a Mesh node)
52
+ * shares the account's name space. An item whose `tun_type` is anything but `cfd_tunnel` is not
53
+ * ours even when the name matches. A response with no `tun_type` is accepted: it came from a
54
+ * `cfd_tunnel`-scoped call.
55
+ */
56
+ export const observe = (raw: Raw): ObservedTunnel | undefined => {
57
+ if (typeof raw.id !== 'string' || raw.id.length === 0) return undefined;
58
+ if (raw.deletedAt !== undefined && raw.deletedAt !== null) return undefined;
59
+ if (typeof raw.tunType === 'string' && raw.tunType !== 'cfd_tunnel') return undefined;
60
+ const configSrc =
61
+ raw.configSrc === 'cloudflare' || raw.configSrc === 'local' ? raw.configSrc : undefined;
62
+ return { id: raw.id, name: raw.name ?? '', configSrc };
63
+ };
64
+
65
+ /** The tunnel with this id, or `undefined` when it is gone (404 / code 1002, or soft-deleted). */
66
+ export const getTunnel = (accountId: string, tunnelId: string) =>
67
+ zeroTrust.getTunnelCloudflared({ accountId, tunnelId }).pipe(
68
+ Effect.map(observe),
69
+ Effect.catchTag('TunnelNotFound', () => Effect.succeed(undefined)),
70
+ );
71
+
72
+ /**
73
+ * The live cloudflared tunnel with exactly this name, or `undefined`.
74
+ *
75
+ * ★ `name` and `tunTypes` are sent as filters, but the match is re-checked here: the filter's
76
+ * exactness is not documented, and a prefix match would adopt the wrong tunnel.
77
+ * ⛔ Two live matches is refused rather than resolved. Names are unique per account (the create
78
+ * answers code 1013 otherwise), so two means something this package does not understand.
79
+ */
80
+ export const findTunnelByName = (accountId: string, name: string) =>
81
+ zeroTrust.listTunnels.items({ accountId, name, isDeleted: false, tunTypes: ['cfd_tunnel'] }).pipe(
82
+ Stream.map(observe),
83
+ Stream.filter((t): t is ObservedTunnel => t !== undefined && t.name === name),
84
+ Stream.runCollect,
85
+ Effect.flatMap((chunk) => {
86
+ const matches = Array.from(chunk);
87
+ if (matches.length <= 1) return Effect.succeed(matches[0]);
88
+ const ids = matches.map((t) => t.id).join(', ');
89
+ return Effect.fail(
90
+ new CloudflaredTunnelError({
91
+ message: `Tunnel name "${name}" matches ${matches.length} live cloudflared tunnels (${ids}). Names are unique per account, so refusing to guess which one is meant.`,
92
+ }),
93
+ );
94
+ }),
95
+ );
96
+
97
+ /**
98
+ * ⛔ ALWAYS `configSrc: 'cloudflare'` (remotely managed), NEVER OMITTED. The dashboard and the
99
+ * ingress rules a consumer later PUTs are the remote configuration; a `local` tunnel ignores
100
+ * them. The SDK types the field as optional with two values (zero_trust.ts
101
+ * `CreateTunnelCloudflaredRequest`) and does not say which is the default, so it is sent
102
+ * explicitly. Create-only (the PATCH body has `name` and `tunnelSecret` only).
103
+ */
104
+ export const createTunnel = (accountId: string, name: string) =>
105
+ zeroTrust.createTunnelCloudflared({ accountId, name, configSrc: 'cloudflare' }).pipe(
106
+ Effect.flatMap((raw) => {
107
+ const tunnel = observe(raw);
108
+ return tunnel === undefined
109
+ ? Effect.fail(
110
+ new CloudflaredTunnelError({ message: `Creating tunnel "${name}" returned no id.` }),
111
+ )
112
+ : Effect.succeed(tunnel);
113
+ }),
114
+ );
115
+
116
+ /** ★ A rename is a PATCH: same id, same connectors, same `<id>.cfargotunnel.com` target. */
117
+ export const renameTunnel = (accountId: string, tunnelId: string, name: string) =>
118
+ zeroTrust
119
+ .patchTunnelCloudflared({ accountId, tunnelId, name })
120
+ .pipe(Effect.map((raw) => observe(raw) ?? { id: tunnelId, name, configSrc: undefined }));
121
+
122
+ /**
123
+ * ⚠️ The SDK types `deleteTunnelCloudflared`'s errors as `CloudflareOpError`, which has no
124
+ * `NotFound`, yet a 404 reaches the caller as `NotFound` at runtime (protocol.ts `HTTP_STATUS_MAP`,
125
+ * measured through the fake). So the tag is matched by name, not by `catchTag`'s typed union.
126
+ */
127
+ const isNotFound = (error: { readonly _tag: string }) => error._tag === 'NotFound';
128
+
129
+ /**
130
+ * ⛔ DELETE BY THE STORED ID ONLY, AFTER A READ BY THAT ID. Never by name: a name can have been
131
+ * re-used by another tunnel since this one was written, and a delete cannot be taken back. An
132
+ * empty id is refused before any request (a blank path segment would address the collection).
133
+ * ★ Idempotent: a tunnel already gone or soft-deleted is a successful delete, including one that
134
+ * vanishes between the read and the DELETE (see `isNotFound`). The API refuses a
135
+ * delete while connectors are attached ("The tunnel must have no active connections", SDK doc on
136
+ * `deleteTunnelCloudflared`); that error passes through, since the operator must stop cloudflared.
137
+ */
138
+ export const deleteTunnel = (accountId: string, tunnelId: string) =>
139
+ Effect.gen(function* () {
140
+ if (tunnelId.length === 0) {
141
+ return yield* Effect.fail(
142
+ new CloudflaredTunnelError({ message: 'Refusing to delete a tunnel with an empty id.' }),
143
+ );
144
+ }
145
+ const live = yield* getTunnel(accountId, tunnelId);
146
+ if (live === undefined) return;
147
+ yield* zeroTrust
148
+ .deleteTunnelCloudflared({ accountId, tunnelId })
149
+ .pipe(Effect.catchIf(isNotFound, () => Effect.void));
150
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * CloudflaredTunnelProvider's refusals and recoveries — the cases a mutation of each guard must
3
+ * break: the two-match refusal, the tunnel-type check, the tombstone check, the 404 on a stored id
4
+ * (read says missing), the stored account, the 1013 race, the `local` refusal, delete's id
5
+ * precondition and a tunnel vanishing mid-delete.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { extra, ids, read, reconcile, run, stored, writes } from './cloudflared-tunnel-harness.ts';
10
+ import { FAKE_ACCOUNT, fakeFailure } from './fake-mesh.ts';
11
+ import { fakeTunnels } from './fake-tunnel.ts';
12
+
13
+ const OTHER_ACCOUNT = '00000000000000000000000000000002';
14
+ const UNKNOWN_ID = '00000000-0000-4000-9000-00000000ffff';
15
+ const props = { name: 'k8s-admin' };
16
+
17
+ describe('CloudflaredTunnelProvider edges', () => {
18
+ test('lookups filter by name, live-only and cfd_tunnel on the wire', async () => {
19
+ const fake = fakeTunnels();
20
+ await run(fake, (p) => read(p, props));
21
+ const list = fake.seen.find((s) => s.method === 'GET');
22
+ expect(list?.path).toContain('/tunnels?');
23
+ expect(list?.path).toContain('name=k8s-admin');
24
+ expect(list?.path).toContain('is_deleted=false');
25
+ expect(list?.path).toContain('tun_types=cfd_tunnel');
26
+ });
27
+
28
+ test('a prefix-only match, a deleted tunnel and a Mesh node of the same name are not adopted', async () => {
29
+ const fake = fakeTunnels();
30
+ fake.seed({ name: 'k8s-admin-2' });
31
+ fake.seed({ name: 'k8s-admin', deleted_at: '2026-10-08T00:00:00Z' });
32
+ // ⚠️ The fake's list honours tun_types, so this proves the client's own re-check by id: GET.
33
+ fake.seed({ name: 'k8s-admin', tun_type: 'warp_connector' });
34
+ expect(await run(fake, (p) => read(p, props))).toBeUndefined();
35
+ const mesh = [...fake.tunnels.values()].find((t) => t.tun_type === 'warp_connector');
36
+ const viaId = stored(FAKE_ACCOUNT, { id: mesh?.id ?? '' });
37
+ expect(await run(fake, (p) => read(p, { name: 'zz' }, viaId))).toBeUndefined();
38
+ });
39
+
40
+ test('two live tunnels with the declared name are refused, both named', async () => {
41
+ const fake = fakeTunnels();
42
+ const a = fake.seed({ name: 'k8s-admin' });
43
+ const b = fake.seed({ name: 'k8s-admin' });
44
+ const failure = await run(fake, (p) => Effect.flip(read(p, props)));
45
+ expect(failure.message).toContain(a.id);
46
+ expect(failure.message).toContain(b.id);
47
+ });
48
+
49
+ test('a stored id the API no longer knows (404): read says missing and never probes the name, reconcile creates', async () => {
50
+ const fake = fakeTunnels();
51
+ // ⚠️ A same-named foreign tunnel: Drift treats what read returns as ours, so adopting it
52
+ // here would bypass adopt(true). The probe is for a row-less read only.
53
+ fake.seed({ name: 'k8s-admin' });
54
+ const gone = stored(FAKE_ACCOUNT, { id: UNKNOWN_ID, name: 'k8s-admin' });
55
+ expect(await run(fake, (p) => read(p, props, gone))).toBeUndefined();
56
+ expect(fake.seen.map((s) => s.method)).toEqual(['GET']);
57
+ expect(fake.seen.some((s) => s.path.includes('/tunnels?'))).toBe(false);
58
+
59
+ const fresh = fakeTunnels();
60
+ const created = await run(fresh, (p) => reconcile(p, { name: 'k8s-other' }, gone));
61
+ expect(created.id).not.toBe(UNKNOWN_ID);
62
+ expect(writes(fresh)).toEqual(['POST']);
63
+ });
64
+
65
+ test('a gone row over a same-named foreign tunnel: reconcile refuses and names it, writes nothing', async () => {
66
+ const fake = fakeTunnels();
67
+ const foreign = fake.seed({ name: 'k8s-admin' });
68
+ const gone = stored(FAKE_ACCOUNT, { id: UNKNOWN_ID });
69
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, props, gone)));
70
+ expect(failure.message).toContain(foreign.id);
71
+ expect(failure.message).toContain('already exists');
72
+ expect(writes(fake)).toEqual([]);
73
+ });
74
+
75
+ test('read refreshes in the account the tunnel was written to, not the current environment', async () => {
76
+ const fake = fakeTunnels();
77
+ const tunnel = fake.seed({ name: 'k8s-admin', status: 'healthy' });
78
+ const output = stored(FAKE_ACCOUNT, { id: tunnel.id });
79
+ // ⚠️ The fake throws on any other account, so reading in OTHER_ACCOUNT fails this test.
80
+ const fresh = await run(fake, (p) => read(p, props, output), OTHER_ACCOUNT);
81
+ expect(fresh).toMatchObject({ id: tunnel.id, accountId: FAKE_ACCOUNT });
82
+ });
83
+
84
+ test('a rename onto a name another tunnel holds is refused, not forced', async () => {
85
+ const fake = fakeTunnels();
86
+ fake.seed({ name: 'k8s-taken' });
87
+ const failure = await run(fake, (p) =>
88
+ Effect.gen(function* () {
89
+ const first = yield* reconcile(p, props);
90
+ return yield* Effect.flip(reconcile(p, { name: 'k8s-taken' }, first));
91
+ }),
92
+ );
93
+ expect(failure.message).toContain('already exists');
94
+ });
95
+
96
+ test('create never converges on a tunnel it did not create', async () => {
97
+ const fake = fakeTunnels();
98
+ fake.seed({ name: 'k8s-admin' });
99
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, props)));
100
+ expect(failure.message).toContain('already exists');
101
+ expect(failure.message).toContain('do not delete it');
102
+ expect(writes(fake)).toEqual([]);
103
+ });
104
+
105
+ test('a 1013 after a clean lookup names the tunnel that appeared, and does not adopt it', async () => {
106
+ const fake = fakeTunnels({
107
+ onCreate: (name) => {
108
+ fake.seed({ name });
109
+ return fakeFailure(409, 1013, 'tunnel with name already exists');
110
+ },
111
+ });
112
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, props)));
113
+ const holder = fake.live()[0];
114
+ expect(failure.message).toContain(holder?.id ?? 'no holder');
115
+ expect(failure.message).toContain('adopt(true)');
116
+ });
117
+
118
+ test('a locally configured tunnel is refused on adoption and left untouched', async () => {
119
+ const fake = fakeTunnels();
120
+ const tunnel = fake.seed({ name: 'k8s-admin', config_src: 'local' });
121
+ const failure = await run(fake, (p) =>
122
+ Effect.gen(function* () {
123
+ const adopted = yield* read(p, props);
124
+ return yield* Effect.flip(reconcile(p, props, adopted));
125
+ }),
126
+ );
127
+ expect(failure.message).toContain('config_src local');
128
+ expect(writes(fake)).toEqual([]);
129
+ expect(fake.tunnels.get(tunnel.id)?.name).toBe('k8s-admin');
130
+ });
131
+
132
+ test('delete addresses the stored id only: a same-named newer tunnel survives', async () => {
133
+ const fake = fakeTunnels();
134
+ const old = fake.seed({ name: 'k8s-admin', deleted_at: '2026-10-08T00:00:00Z' });
135
+ const newer = fake.seed({ name: 'k8s-admin' });
136
+ await run(fake, (p) =>
137
+ p.delete({ ...ids, ...extra, olds: props, output: stored(FAKE_ACCOUNT, { id: old.id }) }),
138
+ );
139
+ expect(writes(fake)).toEqual([]);
140
+ expect(fake.live().map((t) => t.id)).toEqual([newer.id]);
141
+ });
142
+
143
+ test('delete refuses an empty id before any request', async () => {
144
+ const fake = fakeTunnels();
145
+ const failure = await run(fake, (p) =>
146
+ Effect.flip(
147
+ p.delete({ ...ids, ...extra, olds: props, output: stored(FAKE_ACCOUNT, { id: '' }) }),
148
+ ),
149
+ );
150
+ expect(failure.message).toContain('empty id');
151
+ expect(fake.seen).toHaveLength(0);
152
+ });
153
+
154
+ test('a tunnel that vanishes between the pre-read and the DELETE is a successful delete', async () => {
155
+ const fake = fakeTunnels({ onDelete: () => fakeFailure(404, 1002, 'Tunnel not found') });
156
+ const tunnel = fake.seed({ name: 'k8s-admin' });
157
+ await run(fake, (p) =>
158
+ p.delete({ ...ids, ...extra, olds: props, output: stored(FAKE_ACCOUNT, { id: tunnel.id }) }),
159
+ );
160
+ expect(writes(fake)).toEqual(['DELETE']);
161
+ });
162
+
163
+ test('delete while connectors are attached surfaces the API refusal, and deletes nothing', async () => {
164
+ const fake = fakeTunnels();
165
+ const tunnel = fake.seed({ name: 'k8s-admin', connected: true });
166
+ const failure = await run(fake, (p) =>
167
+ Effect.flip(
168
+ p.delete({
169
+ ...ids,
170
+ ...extra,
171
+ olds: props,
172
+ output: stored(FAKE_ACCOUNT, { id: tunnel.id }),
173
+ }),
174
+ ),
175
+ );
176
+ expect(String(JSON.stringify(failure))).toContain('active connections');
177
+ expect(fake.live()).toHaveLength(1);
178
+ });
179
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `Cloudflare.CloudflaredTunnel` — its props, its attributes, and the pure `diff`.
3
+ *
4
+ * ⛔ THE ATTRIBUTES NEVER HOLD A TOKEN OR A SECRET. That is the reason this resource exists next to
5
+ * Alchemy's `Cloudflare.Tunnel.Tunnel` (beta.81): its `reconcile`, `read` and `list` all call
6
+ * `getTunnelCloudflaredToken` and keep the result as `token: Redacted<string>`, which
7
+ * `encodeState` persists as `{"__redacted__": "<token>"}` in the (unencrypted) state store. That
8
+ * token is what `cloudflared tunnel run --token` accepts: whoever reads state can run a connector
9
+ * for the tunnel and receive its traffic. It also makes every PLAN need a token with a Write
10
+ * permission. This resource never calls the endpoint, so a plan runs on a read token.
11
+ * ★ THE CONNECTOR'S TOKEN IS A RUNTIME CREDENTIAL, NOT INFRASTRUCTURE STATE. Whatever installs the
12
+ * connector fetches it at that moment and hands it straight to the workload's secret store.
13
+ * Nothing here does, and nothing here exports a helper that would.
14
+ * ★ SCOPE: the tunnel object only. No ingress rules, no routes, no DNS, no connector Deployment:
15
+ * those are separate declarations that name this tunnel's `id`.
16
+ */
17
+ import type { Diff } from 'alchemy/Diff';
18
+ import type { Input } from 'alchemy/Input';
19
+ import { CloudflaredTunnelError } from './cloudflared-tunnel-api.ts';
20
+
21
+ export interface CloudflaredTunnelProps {
22
+ /**
23
+ * The tunnel's name in the account — and its identity when adopting (names are unique per
24
+ * account). ★ Changing it RENAMES the tunnel in place (`PATCH`): the id, the connectors and the
25
+ * `<id>.cfargotunnel.com` target survive.
26
+ */
27
+ readonly name: string;
28
+ }
29
+
30
+ export interface CloudflaredTunnelAttributes {
31
+ /** The tunnel's UUID: the `<id>.cfargotunnel.com` target and the key for ingress rules. */
32
+ readonly id: string;
33
+ /**
34
+ * The account the provider environment resolved when the tunnel was written. Not a secret; it
35
+ * is what `delete` and `read` address, so they never act on the current environment's account.
36
+ */
37
+ readonly accountId: string;
38
+ readonly name: string;
39
+ // ★ No `status`: it moves on its own as connectors attach and drop, so a stored copy would make
40
+ // every `alchemy drift` report this tunnel as drifted. Health is the API's or the metrics' to say.
41
+ }
42
+
43
+ /** ⛔ Fail closed on a declaration the API would accept but nobody meant. */
44
+ export const validateCloudflaredTunnel = (
45
+ props: CloudflaredTunnelProps,
46
+ ): CloudflaredTunnelError | undefined => {
47
+ if (typeof props.name !== 'string' || props.name.trim().length === 0) {
48
+ return new CloudflaredTunnelError({
49
+ message: 'CloudflaredTunnel `name` must be a non-empty string.',
50
+ });
51
+ }
52
+ if (props.name !== props.name.trim()) {
53
+ return new CloudflaredTunnelError({
54
+ message: `CloudflaredTunnel name "${props.name}" has leading or trailing whitespace; adoption matches names exactly.`,
55
+ });
56
+ }
57
+ return undefined;
58
+ };
59
+
60
+ /**
61
+ * update or replace, from the declaration and what was last written.
62
+ *
63
+ * - Account changed → `replace`, create-first: names are unique per ACCOUNT, so the two
64
+ * generations cannot collide.
65
+ * - Name changed → `update` (a PATCH rename).
66
+ * - Otherwise → no opinion; the engine compares props.
67
+ */
68
+ export const diffCloudflaredTunnel = (
69
+ news: Input<CloudflaredTunnelProps>,
70
+ output: CloudflaredTunnelAttributes | undefined,
71
+ accountId: string,
72
+ ): Diff | undefined => {
73
+ if (output === undefined) return undefined;
74
+ if (output.accountId !== accountId) return { action: 'replace' };
75
+ const name = (news as { readonly name?: unknown }).name;
76
+ if (typeof name === 'string' && name !== output.name) return { action: 'update' };
77
+ return undefined;
78
+ };