@homeflare/alchemy 0.48.0 → 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.
- package/README.md +11 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/fake-access.d.ts +40 -0
- package/dist/cloudflare/fake-access.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +7 -2
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
- package/dist/cloudflare/fake-tunnel.d.ts +48 -0
- package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +15 -4
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +518 -7
- package/dist/cloudflare/index.js.map +15 -4
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
- package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
- package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
- package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
- package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc.d.ts +36 -0
- package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
- package/dist/talos/cluster-adapter-errors.d.ts.map +1 -1
- package/dist/talos/cluster-adapter.d.ts +2 -1
- package/dist/talos/cluster-adapter.d.ts.map +1 -1
- package/dist/talos/index.js +19 -8
- package/dist/talos/index.js.map +6 -6
- package/dist/talos/mint-temp-file.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +29 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/docs/cloudflared-tunnel.md +136 -0
- package/docs/credentials.md +1 -1
- package/docs/ownership.md +3 -2
- package/docs/saas-oidc.md +185 -0
- package/docs/talos-openbao-adapter.md +3 -2
- package/docs/upstream-conformance-families.md +1 -1
- package/docs/upstream-conformance.md +3 -0
- package/package.json +1 -1
- package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
- package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
- package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
- package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
- package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
- package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
- package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
- package/src/cloudflare/cloudflared-tunnel.ts +116 -0
- package/src/cloudflare/fake-access.ts +196 -0
- package/src/cloudflare/fake-mesh.ts +6 -3
- package/src/cloudflare/fake-tunnel.ts +148 -0
- package/src/cloudflare/index.ts +27 -4
- package/src/cloudflare/providers.ts +16 -3
- package/src/cloudflare/saas-oidc-api.ts +224 -0
- package/src/cloudflare/saas-oidc-consumer.ts +35 -0
- package/src/cloudflare/saas-oidc-error.ts +52 -0
- package/src/cloudflare/saas-oidc-form.test.ts +227 -0
- package/src/cloudflare/saas-oidc-form.ts +190 -0
- package/src/cloudflare/saas-oidc-harness.ts +140 -0
- package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
- package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
- package/src/cloudflare/saas-oidc-props.ts +147 -0
- package/src/cloudflare/saas-oidc-read.test.ts +85 -0
- package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
- package/src/cloudflare/saas-oidc-state.test.ts +128 -0
- package/src/cloudflare/saas-oidc-team.test.ts +88 -0
- package/src/cloudflare/saas-oidc-team.ts +70 -0
- package/src/cloudflare/saas-oidc-wire.ts +116 -0
- package/src/cloudflare/saas-oidc.test.ts +201 -0
- package/src/cloudflare/saas-oidc.ts +107 -0
- package/src/talos/cluster-adapter-errors.ts +2 -1
- package/src/talos/cluster-adapter-move.test.ts +2 -0
- package/src/talos/cluster-adapter.ts +2 -1
- package/src/talos/mint-temp-file.test.ts +44 -0
- package/src/talos/mint-temp-file.ts +16 -4
- package/src/talos/talosctl-probe-kill.test.ts +56 -0
- package/src/talos/talosctl.test.ts +54 -1
- package/src/talos/talosctl.ts +34 -2
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# CloudflaredTunnel — a Cloudflare Tunnel without its token in state
|
|
2
|
+
|
|
3
|
+
`CloudflaredTunnel` (`Cloudflare.CloudflaredTunnel`) declares a Cloudflare Tunnel of the cloudflared
|
|
4
|
+
kind (`cfd_tunnel`): the object whose `<id>.cfargotunnel.com` target DNS records point at and whose
|
|
5
|
+
remote configuration holds the ingress rules. It declares **the tunnel object only**: no ingress
|
|
6
|
+
rules, no routes, no DNS record, no connector Deployment. Those are separate declarations that name
|
|
7
|
+
this tunnel's `id`. 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.Tunnel.Tunnel`
|
|
11
|
+
|
|
12
|
+
- ⛔ **It puts the connector token in state.** Its `reconcile`, `read` and `list` call
|
|
13
|
+
`getTunnelCloudflaredToken` and keep the result as `token: Redacted<string>`
|
|
14
|
+
(`alchemy/src/Cloudflare/Tunnel/Tunnel.ts`). Alchemy's state encoder writes a `Redacted` value as
|
|
15
|
+
`{"__redacted__": "<the value>"}` (`alchemy/src/State/StateEncoding.ts`, `encodeState`, which
|
|
16
|
+
LocalState, HttpStateStore and PostgresState all call before writing). The state store is not
|
|
17
|
+
encrypted, and the token is what `cloudflared tunnel run --token` accepts, so anyone who can read
|
|
18
|
+
state can run a connector for the tunnel and receive its traffic.
|
|
19
|
+
- **Its plans need a Write token.** The token endpoint is expected to need a Write permission, so even a plan
|
|
20
|
+
would. **UNVERIFIED for `cfd_tunnel`**: the only citation is the sibling Mesh node token endpoint
|
|
21
|
+
("Cloudflare One Connectors Write", per its API reference, read 2026-09-21; see
|
|
22
|
+
`src/cloudflare/mesh-node-form.ts`), and the `cfd_tunnel` token endpoint's permission was not
|
|
23
|
+
read. `CloudflaredTunnel` never calls it, so a plan does not depend on the answer.
|
|
24
|
+
|
|
25
|
+
`CloudflaredTunnel` follows the same pattern as [MeshNode](./mesh-node.md): attributes with no
|
|
26
|
+
secret, adoption by exact name, `retain` by default. The type string is `CloudflaredTunnel`, not
|
|
27
|
+
`Tunnel`, so it cannot be confused with upstream's `Cloudflare.Tunnel.Tunnel` in a stack that uses both.
|
|
28
|
+
|
|
29
|
+
## Declaring a tunnel
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { CloudflaredTunnel, providers as homeflareCloudflare } from '@homeflare/alchemy/cloudflare';
|
|
33
|
+
|
|
34
|
+
// providers: Layer.mergeAll(Cloudflare.providers(), homeflareCloudflare()),
|
|
35
|
+
const admin = yield * CloudflaredTunnel('k8s-admin', { name: 'k8s-admin' });
|
|
36
|
+
// admin.id → the tunnel UUID: `${admin.id}.cfargotunnel.com` is the DNS target
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| prop | type | notes |
|
|
40
|
+
| ------ | -------- | --------------------------------------------------------------------- |
|
|
41
|
+
| `name` | `string` | Unique per account. The identity used for adoption. Renames in place. |
|
|
42
|
+
|
|
43
|
+
Attributes: `id`, `accountId` and `name`. ★ There is no `status`: it moves as connectors attach and
|
|
44
|
+
drop, so a stored copy would make every `alchemy drift` flag the tunnel; read health from the API
|
|
45
|
+
or the metrics. ⛔ There is no token, secret or `tunnelSecret` attribute, and none is sent. The request schema documents `tunnel_secret` as the password for a _locally-managed_ tunnel
|
|
46
|
+
(`@distilled.cloud/cloudflare@1.0.0-rc.13`, `src/services/zero_trust.ts`, `CreateTunnelCloudflaredRequest`);
|
|
47
|
+
this resource always sends `config_src: "cloudflare"`. **UNVERIFIED**: that Cloudflare itself generates and
|
|
48
|
+
keeps a secret when `tunnel_secret` is omitted for such a tunnel; the schema says only that the field is optional. `accountId` is not a secret; it is what
|
|
49
|
+
`read` and `delete` address, so they never act on whatever account the environment names today.
|
|
50
|
+
|
|
51
|
+
The account and credentials come from Alchemy's own Cloudflare environment
|
|
52
|
+
(`Cloudflare.CloudflareApiLive()`): an Alchemy profile, or `CLOUDFLARE_API_TOKEN` and
|
|
53
|
+
`CLOUDFLARE_ACCOUNT_ID` in CI.
|
|
54
|
+
|
|
55
|
+
### Remotely configured only
|
|
56
|
+
|
|
57
|
+
The tunnel is always created with `config_src: "cloudflare"` (managed through the API and the Zero
|
|
58
|
+
Trust dashboard), sent explicitly. The SDK types the field as optional (`local` or `cloudflare`)
|
|
59
|
+
and does not document the default (`CreateTunnelCloudflaredRequest`, `zero_trust.ts`). It is
|
|
60
|
+
create-only: the PATCH body has `name` and `tunnelSecret` only. A tunnel found by name whose
|
|
61
|
+
`config_src` is `local` is **refused on adoption** with a sentence and left untouched, because its
|
|
62
|
+
ingress is a YAML file on its origin and remote rules would be accepted and never applied.
|
|
63
|
+
⚠️ UNVERIFIED: that every real GET/list response carries `config_src`. A response without it is
|
|
64
|
+
accepted; only an explicit `local` is refused.
|
|
65
|
+
|
|
66
|
+
## The token is not this resource's business
|
|
67
|
+
|
|
68
|
+
Whatever installs the connector fetches the token at that moment and hands it straight to the
|
|
69
|
+
workload's secret store (`GET /accounts/{id}/cfd_tunnel/{id}/token`, which needs a Write
|
|
70
|
+
permission). It must not go into Alchemy state, a log line or a CI variable. No function in this
|
|
71
|
+
package calls that endpoint, `observe` copies named fields only, and
|
|
72
|
+
`cloudflared-tunnel-state.test.ts` proves the persisted state holds no token. ⚠️ Never deploy with
|
|
73
|
+
`DISTILLED_DEBUG_HTTP` set: distilled prints the start of every response body.
|
|
74
|
+
|
|
75
|
+
## What each change does
|
|
76
|
+
|
|
77
|
+
| change | answer | what happens on Cloudflare |
|
|
78
|
+
| ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| `name` | `update` | `PATCH`. The id, connectors and `cfargotunnel.com` target survive. |
|
|
80
|
+
| account (provider env) | `replace`, create-first | A new tunnel in the new account. Under `retain` the old one stays. |
|
|
81
|
+
| nothing | none | Zero writes and no token read. An existing row's plan makes no request (`diff` is pure); the cold probe is one list. |
|
|
82
|
+
|
|
83
|
+
⛔ **Removal policy: `retain` by default**, like MeshNode and the other kit resources whose deletion
|
|
84
|
+
breaks their consumers. Deleting a tunnel drops its remote configuration and orphans every DNS record
|
|
85
|
+
that targets it; a new tunnel has a new id and token. Dropping the declaration or `alchemy destroy`
|
|
86
|
+
leaves the tunnel live and only drops it from state. Opt a declaration into deletion with
|
|
87
|
+
`.pipe(RemovalPolicy.destroy())`. ⚠️ Under `retain` an account-change replace leaves the old tunnel
|
|
88
|
+
live and unmanaged.
|
|
89
|
+
|
|
90
|
+
## Adoption
|
|
91
|
+
|
|
92
|
+
An existing tunnel is found by **exact** name among `cfd_tunnel`s and returned `Unowned`. Alchemy
|
|
93
|
+
refuses to take it over until the resource is wrapped in `adopt(true)` (or `--adopt` is passed). A
|
|
94
|
+
deleted tunnel, a tunnel of another type (a `warp_connector` Mesh node shares the name space) and a
|
|
95
|
+
prefix match are never matched, and a name that matches two live tunnels is refused. Adoption is
|
|
96
|
+
idempotent: the adopted row holds the id, so a repeat deploy takes the owned path and writes
|
|
97
|
+
nothing. ⛔ The name probe runs only when there is **no** stored row. A stored id that the API no
|
|
98
|
+
longer resolves (deleted out of band) reads as missing, never as a same-named tunnel: `alchemy drift`
|
|
99
|
+
treats what `read` returns as already ours, so a probe there would write a foreign tunnel into this
|
|
100
|
+
row without `adopt(true)`. Drift reports it missing and recreates, and the create refuses and
|
|
101
|
+
names the holder. Without `adopt(true)`, a create whose name is held by another tunnel refuses and names the
|
|
102
|
+
holder; it never converges on it, and the sentence says not to delete it.
|
|
103
|
+
|
|
104
|
+
An interrupted create (the tunnel was made, its state was not saved) is found the same way on the
|
|
105
|
+
next plan, so it too needs `adopt(true)`. ⚠️ distilled retries a create after a lost response or a
|
|
106
|
+
5xx, and the retry is answered code 1013 by the tunnel the first attempt made: the error then names
|
|
107
|
+
that tunnel's id and is not adopted silently, because a concurrent creator looks the same.
|
|
108
|
+
|
|
109
|
+
## Deleting
|
|
110
|
+
|
|
111
|
+
`delete` runs only under `RemovalPolicy.destroy()`. It addresses the **stored id and account**,
|
|
112
|
+
never a name: it reads that id first, does nothing when the tunnel is already gone or soft-deleted,
|
|
113
|
+
and refuses an empty id before any request. The API refuses a delete while connectors are attached
|
|
114
|
+
("The tunnel must have no active connections", the SDK's own doc on `deleteTunnelCloudflared`); that
|
|
115
|
+
error passes through unchanged, so scale cloudflared to zero first. No retry loop is added: a
|
|
116
|
+
connector does not detach within seconds on its own. A tunnel that vanishes between the pre-read and
|
|
117
|
+
the DELETE is a successful delete: the 404 reaches the caller as `NotFound` at runtime though the SDK's
|
|
118
|
+
typed error union omits it (`protocol.ts`, `HTTP_STATUS_MAP`), so it is matched by tag name and caught. ⚠️ UNVERIFIED against the real API: the fake
|
|
119
|
+
answers 404 / code 1002 there, the same shape `getTunnelCloudflared` documents.
|
|
120
|
+
|
|
121
|
+
`list` is empty and `nuke` skips this type: Alchemy's own `Tunnel.Tunnel` already enumerates every
|
|
122
|
+
`cfd_tunnel` for `alchemy unsafe nuke` (fetching each token to do it), and a second listing would
|
|
123
|
+
delete each tunnel twice.
|
|
124
|
+
|
|
125
|
+
## Tests
|
|
126
|
+
|
|
127
|
+
Three test files in `src/cloudflare/` (plus `cloudflared-tunnel-harness.ts`, their shared helpers)
|
|
128
|
+
run the provider against a fake API (`fake-tunnel.ts`) that hands out a token on every surface it can:
|
|
129
|
+
|
|
130
|
+
- `cloudflared-tunnel.test.ts`: the lifecycle; the create body is `{name, config_src}` and no
|
|
131
|
+
request ever touches `/token`.
|
|
132
|
+
- `cloudflared-tunnel-edges.test.ts`: each refusal and recovery, written to fail when its guard
|
|
133
|
+
is deleted.
|
|
134
|
+
- `cloudflared-tunnel-state.test.ts`: Alchemy's real plan/apply, then `encodeState` over the stored
|
|
135
|
+
rows: no `__redacted__` marker, no `token` key, no token value, with a negative control showing
|
|
136
|
+
the same encoder does write a `Redacted` token. Also the removal policy and adoption.
|
package/docs/credentials.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Moved out of the README (2026-09-21) to make room under its 200-line cap; the text is unchanged.
|
|
4
4
|
|
|
5
5
|
`CLOUDFLARE_API_TOKEN` is read from the environment at call time, never at module scope.
|
|
6
|
-
`MeshNode` instead
|
|
6
|
+
`MeshNode` and `CloudflaredTunnel` instead resolve credentials and the account the way `Cloudflare.providers()` does
|
|
7
7
|
(an Alchemy profile, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in CI).
|
|
8
8
|
|
|
9
9
|
⛔ **Mint a short-lived, scoped token** — do not reuse a long-lived one, and never a Global
|
package/docs/ownership.md
CHANGED
|
@@ -100,8 +100,9 @@ escalating to `--adopt` only on `OwnedBySomeoneElse`
|
|
|
100
100
|
([inspecting state](https://alchemy.run/cli/inspecting-state#recover-from-bad-state)). For the
|
|
101
101
|
families above that escalation always comes (decided 2026-09-21): an object whose row is gone is
|
|
102
102
|
`Unowned`, so the deploy needs `--adopt`, or `adopt(true)` on its declaration. `MeshNode`
|
|
103
|
-
([mesh-node.md](./mesh-node.md))
|
|
104
|
-
|
|
103
|
+
([mesh-node.md](./mesh-node.md)), `CloudflaredTunnel`
|
|
104
|
+
([cloudflared-tunnel.md](./cloudflared-tunnel.md)), `SaasOidcApplication` ([saas-oidc.md](./saas-oidc.md)) and
|
|
105
|
+
`R2BucketLock` (`src/cloudflare/r2-bucket-lock.ts`) need it too. A `CaddyConfig` running the declared config re-imports without it, and so does every family
|
|
105
106
|
whose `read` never answers `Unowned`: the `pveHandlers` resources (Limits), the `forgejoHandlers`
|
|
106
107
|
resources and the Talos resources. Read the takeover first with `hf-adopt-verify`
|
|
107
108
|
([adopt-verify.md](./adopt-verify.md)).
|
|
@@ -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.
|
|
@@ -8,8 +8,9 @@ The design, the identity rule and the failure modes are in the header of
|
|
|
8
8
|
|
|
9
9
|
## Connect deadline
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
with `TalosOpenBaoConnectTimeout` (the `bao` child is killed
|
|
11
|
+
The vault read and the uid GET share one 10 s deadline, and the uid GET has its own 5 s deadline
|
|
12
|
+
inside it. A hung `bao` fails closed with `TalosOpenBaoConnectTimeout` (the `bao` child is killed
|
|
13
|
+
with its process group); a silent apiserver fails closed first, with `TalosClusterIdentityTimeout`. The HTTPS
|
|
13
14
|
GET itself cannot be cancelled: upstream `readObject` takes no abort signal, so the request may
|
|
14
15
|
linger until the OS gives up. That is an upstream ask, not something to re-implement here.
|
|
15
16
|
|
|
@@ -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 `
|
|
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.
|
|
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
|
+
});
|