@homeflare/alchemy 0.48.1 → 0.50.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 (126) 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/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
  45. package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
  46. package/dist/kubernetes/index.d.ts +12 -0
  47. package/dist/kubernetes/index.d.ts.map +1 -0
  48. package/dist/kubernetes/index.js +585 -0
  49. package/dist/kubernetes/index.js.map +19 -0
  50. package/dist/kubernetes/ready-checks.d.ts +45 -0
  51. package/dist/kubernetes/ready-checks.d.ts.map +1 -0
  52. package/dist/kubernetes/ready-classify.d.ts +23 -0
  53. package/dist/kubernetes/ready-classify.d.ts.map +1 -0
  54. package/dist/kubernetes/ready-connect.d.ts +29 -0
  55. package/dist/kubernetes/ready-connect.d.ts.map +1 -0
  56. package/dist/kubernetes/ready-declare.d.ts +5 -0
  57. package/dist/kubernetes/ready-declare.d.ts.map +1 -0
  58. package/dist/kubernetes/ready-errors.d.ts +80 -0
  59. package/dist/kubernetes/ready-errors.d.ts.map +1 -0
  60. package/dist/kubernetes/ready-poll.d.ts +61 -0
  61. package/dist/kubernetes/ready-poll.d.ts.map +1 -0
  62. package/dist/kubernetes/ready.d.ts +74 -0
  63. package/dist/kubernetes/ready.d.ts.map +1 -0
  64. package/dist/talos/fake-apiserver.d.ts +11 -2
  65. package/dist/talos/fake-apiserver.d.ts.map +1 -1
  66. package/docs/cloudflared-tunnel.md +136 -0
  67. package/docs/credentials.md +1 -1
  68. package/docs/kubernetes-ready.md +146 -0
  69. package/docs/ownership.md +3 -2
  70. package/docs/peers.md +13 -0
  71. package/docs/provider-standard.md +7 -0
  72. package/docs/saas-oidc.md +185 -0
  73. package/docs/upstream-conformance-families.md +1 -1
  74. package/docs/upstream-conformance.md +3 -0
  75. package/package.json +7 -1
  76. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  77. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  78. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  79. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  80. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  81. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  82. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  83. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  84. package/src/cloudflare/fake-access.ts +196 -0
  85. package/src/cloudflare/fake-mesh.ts +6 -3
  86. package/src/cloudflare/fake-tunnel.ts +148 -0
  87. package/src/cloudflare/index.ts +27 -4
  88. package/src/cloudflare/providers.ts +16 -3
  89. package/src/cloudflare/saas-oidc-api.ts +224 -0
  90. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  91. package/src/cloudflare/saas-oidc-error.ts +52 -0
  92. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  93. package/src/cloudflare/saas-oidc-form.ts +190 -0
  94. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  95. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  96. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  97. package/src/cloudflare/saas-oidc-props.ts +147 -0
  98. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  99. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  100. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  101. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  102. package/src/cloudflare/saas-oidc-team.ts +70 -0
  103. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  104. package/src/cloudflare/saas-oidc.test.ts +201 -0
  105. package/src/cloudflare/saas-oidc.ts +107 -0
  106. package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
  107. package/src/kubernetes/helm-chart-bounded.ts +110 -0
  108. package/src/kubernetes/index.ts +28 -0
  109. package/src/kubernetes/ready-backoff.test.ts +124 -0
  110. package/src/kubernetes/ready-checks.test.ts +106 -0
  111. package/src/kubernetes/ready-checks.ts +165 -0
  112. package/src/kubernetes/ready-classify.ts +76 -0
  113. package/src/kubernetes/ready-connect.ts +52 -0
  114. package/src/kubernetes/ready-declare.test.ts +85 -0
  115. package/src/kubernetes/ready-declare.ts +50 -0
  116. package/src/kubernetes/ready-errors.ts +102 -0
  117. package/src/kubernetes/ready-first-install.test.ts +126 -0
  118. package/src/kubernetes/ready-poll-states.test.ts +92 -0
  119. package/src/kubernetes/ready-poll.test.ts +194 -0
  120. package/src/kubernetes/ready-poll.ts +215 -0
  121. package/src/kubernetes/ready-rollout.test.ts +58 -0
  122. package/src/kubernetes/ready-secrecy.test.ts +131 -0
  123. package/src/kubernetes/ready.fixtures.ts +103 -0
  124. package/src/kubernetes/ready.test.ts +205 -0
  125. package/src/kubernetes/ready.ts +188 -0
  126. package/src/talos/fake-apiserver.ts +21 -3
@@ -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
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * CloudflaredTunnelProvider's handlers, called the way the engine calls them, over the fake
3
+ * Cloudflare in fake-tunnel.ts — and `engine`, which runs Alchemy's real plan/apply over the same
4
+ * fake and exposes the state rows it wrote. Shared by cloudflared-tunnel.test.ts,
5
+ * cloudflared-tunnel-edges.test.ts and cloudflared-tunnel-state.test.ts.
6
+ *
7
+ * ⛔ TEST-ONLY, like fake-tunnel.ts: no provider imports this file and it is not on the barrel.
8
+ */
9
+ import { apply } from 'alchemy/Apply';
10
+ import * as Plan from 'alchemy/Plan';
11
+ import { type CompiledStack, make as makeStack } from 'alchemy/Stack';
12
+ import { Stage } from 'alchemy/Stage';
13
+ import * as State from 'alchemy/State';
14
+ import * as Cause from 'effect/Cause';
15
+ import * as Effect from 'effect/Effect';
16
+ import * as Exit from 'effect/Exit';
17
+ import * as Layer from 'effect/Layer';
18
+ import * as FetchHttpClient from 'effect/http/FetchHttpClient';
19
+ import { CloudflaredTunnel, CloudflaredTunnelProvider } from './cloudflared-tunnel.ts';
20
+ import type {
21
+ CloudflaredTunnelAttributes,
22
+ CloudflaredTunnelProps,
23
+ } from './cloudflared-tunnel-form.ts';
24
+ import { fakeProviderLayer } from './fake-mesh.ts';
25
+ import type { FakeTunnels } from './fake-tunnel.ts';
26
+
27
+ export const ids = { fqn: 'stack/admin', id: 'admin', instanceId: 'i-1' };
28
+ export const extra = { bindings: [] as never, session: undefined as never };
29
+
30
+ export type P = Effect.Success<typeof CloudflaredTunnel.Provider>;
31
+
32
+ export const handler = <F>(name: string, fn: F | undefined): F => {
33
+ if (fn === undefined) throw new Error(`provider has no ${name} handler`);
34
+ return fn;
35
+ };
36
+
37
+ /** Build the provider over the fake (as `accountId`'s environment) and hand it to `use`. */
38
+ export const run = <A, E>(
39
+ fake: FakeTunnels,
40
+ use: (p: P) => Effect.Effect<A, E>,
41
+ accountId?: string,
42
+ ) =>
43
+ Effect.runPromise(
44
+ Effect.gen(function* () {
45
+ return yield* use(yield* CloudflaredTunnel.Provider);
46
+ }).pipe(
47
+ Effect.provide(
48
+ CloudflaredTunnelProvider().pipe(Layer.provideMerge(fakeProviderLayer(fake, accountId))),
49
+ ),
50
+ ),
51
+ );
52
+
53
+ export const reconcile = (
54
+ p: P,
55
+ news: CloudflaredTunnelProps,
56
+ output?: CloudflaredTunnelAttributes,
57
+ ) => p.reconcile({ ...ids, ...extra, news, olds: undefined, output });
58
+
59
+ export const read = (p: P, olds: CloudflaredTunnelProps, output?: CloudflaredTunnelAttributes) =>
60
+ handler('read', p.read)({ ...ids, olds, output });
61
+
62
+ export const diff = (
63
+ p: P,
64
+ news: CloudflaredTunnelProps,
65
+ olds: CloudflaredTunnelProps,
66
+ output: CloudflaredTunnelAttributes,
67
+ ) =>
68
+ handler(
69
+ 'diff',
70
+ p.diff,
71
+ )({ ...ids, news, olds, newBindings: [] as never, oldBindings: [] as never, output });
72
+
73
+ /** A stored attribute set for a tunnel that may or may not exist in the fake. */
74
+ export const stored = (
75
+ accountId: string,
76
+ over: Partial<CloudflaredTunnelAttributes> = {},
77
+ ): CloudflaredTunnelAttributes => ({
78
+ id: 'x',
79
+ accountId,
80
+ name: 'k8s-admin',
81
+ ...over,
82
+ });
83
+
84
+ /** Requests the fake saw for any `/token` path. The provider must never make one. */
85
+ export const tokenReads = (fake: FakeTunnels) =>
86
+ fake.seen.filter((s) => s.path.includes('/token')).length;
87
+
88
+ /** The writes the fake saw, in order: `['POST', 'DELETE', …]`. */
89
+ export const writes = (fake: FakeTunnels) =>
90
+ fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method);
91
+
92
+ type Rows = Record<string, Record<string, Record<string, State.ResourceState>>>;
93
+
94
+ /**
95
+ * ★ THE REAL ENGINE, NOT A MODEL OF IT: Alchemy's own `Plan.make` + `apply` over an in-memory
96
+ * state store (the same construction as mesh-node-harness.ts, which says why
97
+ * `FetchHttpClient.Fetch` is provided around the whole deploy). `rows` is the store itself, so a
98
+ * test can serialize exactly what Alchemy would persist.
99
+ */
100
+ export const engine = (fake: FakeTunnels) => {
101
+ const rows: Rows = {};
102
+ const state = Layer.succeed(State.State, State.InMemoryService(rows));
103
+ const providers = CloudflaredTunnelProvider().pipe(Layer.provide(fakeProviderLayer(fake)));
104
+ const deploy = <A, E, R>(declare: Effect.Effect<A, E, R>) =>
105
+ Effect.runPromiseExit(
106
+ declare.pipe(
107
+ makeStack({ name: 'tunnel', providers, state }) as never,
108
+ Effect.flatMap((compiled: CompiledStack) =>
109
+ Plan.make(compiled).pipe(Effect.flatMap(apply), Effect.provide(compiled.services)),
110
+ ),
111
+ Effect.provide(Layer.succeed(Stage, 'test')),
112
+ Effect.scoped,
113
+ Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
114
+ ) as Effect.Effect<unknown, unknown>,
115
+ );
116
+ return {
117
+ deploy,
118
+ rows,
119
+ /** The stored row's status (`created`, `replacing`, …), or `undefined` when there is none. */
120
+ status: (fqn: string) => rows['tunnel']?.['test']?.[fqn]?.status,
121
+ };
122
+ };
123
+
124
+ /** The failure sentence of a deploy, or `''` when it succeeded. */
125
+ export const failureOf = (exit: Exit.Exit<unknown, unknown>) =>
126
+ Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : '';
@@ -0,0 +1,146 @@
1
+ /**
2
+ * read / reconcile / delete for `Cloudflare.CloudflaredTunnel`, as plain Effects over the SDK calls
3
+ * in cloudflared-tunnel-api.ts. cloudflared-tunnel.ts only wires them into Alchemy.
4
+ */
5
+ import { Unowned } from 'alchemy/AdoptPolicy';
6
+ import * as Effect from 'effect/Effect';
7
+ import {
8
+ CloudflaredTunnelError,
9
+ type ObservedTunnel,
10
+ createTunnel,
11
+ deleteTunnel,
12
+ findTunnelByName,
13
+ getTunnel,
14
+ renameTunnel,
15
+ } from './cloudflared-tunnel-api.ts';
16
+ import {
17
+ type CloudflaredTunnelAttributes,
18
+ type CloudflaredTunnelProps,
19
+ validateCloudflaredTunnel,
20
+ } from './cloudflared-tunnel-form.ts';
21
+
22
+ const attributesOf = (tunnel: ObservedTunnel, accountId: string): CloudflaredTunnelAttributes => ({
23
+ id: tunnel.id,
24
+ accountId,
25
+ name: tunnel.name,
26
+ });
27
+
28
+ /**
29
+ * Owned: refresh by the stored id. Cold (no stored row): find the live tunnel by exact name and
30
+ * hand it back `Unowned`, so Alchemy refuses to take it over until the stack says `adopt(true)` —
31
+ * a `cfd_tunnel` carries no ownership marker to prove it was ours. Adoption is idempotent: the
32
+ * adopted row holds the id, so every later `read` takes the owned path and writes nothing.
33
+ *
34
+ * ⛔ A STORED ID THAT NO LONGER RESOLVES IS "MISSING", NEVER A NAME PROBE. Drift (alchemy beta.81
35
+ * src/Drift.ts) passes the row's output and treats whatever `read` returns as already ours
36
+ * (`stripUnowned`), then repairs by reconciling onto it. A by-name fallback there would write a
37
+ * same-named tunnel somebody else made into this row, bypassing `adopt(true)`; a later rename
38
+ * would PATCH it and `RemovalPolicy.destroy` would DELETE it. Returning `undefined` makes Drift
39
+ * report "missing" and recreate through `reconcile`, where `createFresh` names the holder.
40
+ */
41
+ export const readCloudflaredTunnel = (
42
+ accountId: string,
43
+ olds: CloudflaredTunnelProps | undefined,
44
+ output: CloudflaredTunnelAttributes | undefined,
45
+ ) =>
46
+ Effect.gen(function* () {
47
+ const account = output?.accountId ?? accountId;
48
+ if (output !== undefined) {
49
+ const tunnel = yield* getTunnel(account, output.id);
50
+ return tunnel === undefined ? undefined : attributesOf(tunnel, account);
51
+ }
52
+ const name = olds?.name;
53
+ if (name === undefined) return undefined;
54
+ const match = yield* findTunnelByName(account, name);
55
+ return match === undefined ? undefined : Unowned(attributesOf(match, account));
56
+ });
57
+
58
+ /**
59
+ * ⛔ NEVER CONVERGE ON A TUNNEL THIS RESOURCE DID NOT FIND BY ITS OWN ID. Alchemy's own Tunnel
60
+ * answers a duplicate-name failure by re-reading the holder and returning it (when its `adopt`
61
+ * prop is set). Here adoption belongs to `read` and `adopt(true)` alone, where it is visible in
62
+ * the plan. The holder is named, never deleted: it may be a live tunnel that serves traffic.
63
+ */
64
+ const nameHeld = (name: string, holder: string) =>
65
+ new CloudflaredTunnelError({
66
+ message:
67
+ `A tunnel named "${name}" already exists (${holder}) and is not the tunnel this resource ` +
68
+ 'tracks, so it is left alone: names are unique per account. If it is meant to be this ' +
69
+ 'one, deploy under adopt(true). Otherwise choose another name; do not delete it.',
70
+ });
71
+
72
+ /**
73
+ * 🔴 A 1013 AFTER A CLEAN LOOKUP IS USUALLY THIS DEPLOY'S OWN TUNNEL. distilled retries a POST on a
74
+ * transport error or a 5xx, so a create that succeeded but lost its response is sent again and
75
+ * answered 1013 by the tunnel the first attempt made. The lookup is repeated so the sentence
76
+ * names that tunnel; it is still not adopted here, because a concurrent creator looks the same.
77
+ */
78
+ const raced = (accountId: string, name: string) =>
79
+ findTunnelByName(accountId, name).pipe(
80
+ Effect.flatMap((holder) =>
81
+ Effect.fail(
82
+ holder === undefined
83
+ ? nameHeld(
84
+ name,
85
+ 'not a live cloudflared tunnel: another tunnel type, or one just deleted',
86
+ )
87
+ : new CloudflaredTunnelError({
88
+ message:
89
+ `Tunnel "${name}" (${holder.id}) appeared between this deploy's lookup and its ` +
90
+ 'create. The likeliest cause is this deploy itself: the SDK retries a create ' +
91
+ 'whose response was lost. If so, deploy again under adopt(true). Otherwise ' +
92
+ 'another creator raced this one.',
93
+ }),
94
+ ),
95
+ ),
96
+ );
97
+
98
+ const createFresh = (accountId: string, name: string) =>
99
+ Effect.gen(function* () {
100
+ const existing = yield* findTunnelByName(accountId, name);
101
+ if (existing !== undefined) return yield* Effect.fail(nameHeld(name, existing.id));
102
+ const tunnel = yield* createTunnel(accountId, name).pipe(
103
+ Effect.catchTag('DuplicateTunnelName', () => raced(accountId, name)),
104
+ );
105
+ return attributesOf(tunnel, accountId);
106
+ });
107
+
108
+ /**
109
+ * ⛔ `local` IS REFUSED. A tunnel's `config_src` is create-only, and adoption by name could land on
110
+ * a locally configured tunnel, whose ingress is a YAML file on its origin: a consumer's remote
111
+ * rules would be accepted by the API and never applied. The tunnel is left untouched.
112
+ */
113
+ const requireRemote = (tunnel: ObservedTunnel) =>
114
+ tunnel.configSrc === 'local'
115
+ ? Effect.fail(
116
+ new CloudflaredTunnelError({
117
+ message: `Tunnel "${tunnel.name}" (${tunnel.id}) is locally configured (config_src local), which cannot be changed after creation. CloudflaredTunnel manages remotely configured tunnels only; create a new tunnel under another name.`,
118
+ }),
119
+ )
120
+ : Effect.void;
121
+
122
+ export const reconcileCloudflaredTunnel = (
123
+ accountId: string,
124
+ news: CloudflaredTunnelProps,
125
+ output: CloudflaredTunnelAttributes | undefined,
126
+ ) =>
127
+ Effect.gen(function* () {
128
+ const invalid = validateCloudflaredTunnel(news);
129
+ if (invalid !== undefined) return yield* Effect.fail(invalid);
130
+ const owned = output !== undefined && output.accountId === accountId ? output : undefined;
131
+ const live = owned === undefined ? undefined : yield* getTunnel(accountId, owned.id);
132
+ // ★ Gone out of band (or a replace's new generation): create, under the same refusals.
133
+ if (owned === undefined || live === undefined) return yield* createFresh(accountId, news.name);
134
+ yield* requireRemote(live);
135
+ if (live.name === news.name) return attributesOf(live, accountId);
136
+ const renamed = yield* renameTunnel(accountId, live.id, news.name).pipe(
137
+ Effect.catchTag('DuplicateTunnelName', () =>
138
+ Effect.fail(nameHeld(news.name, 'held by another tunnel')),
139
+ ),
140
+ );
141
+ return attributesOf(renamed, accountId);
142
+ });
143
+
144
+ /** By the stored id and account only (cloudflared-tunnel-api.ts, `deleteTunnel`). */
145
+ export const deleteCloudflaredTunnel = (output: CloudflaredTunnelAttributes) =>
146
+ deleteTunnel(output.accountId, output.id);