@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,104 @@
1
+ /**
2
+ * What Alchemy WRITES for a CloudflaredTunnel, through the real engine (`engine` in
3
+ * cloudflared-tunnel-harness.ts) over the fake Cloudflare: the serialization proof the resource
4
+ * exists for, plus the removal policy and adoption, which live in the engine, not the provider.
5
+ *
6
+ * ⛔ THE PROOF: the rows in the state store, encoded by Alchemy's own `encodeState` (the function
7
+ * LocalState, HttpStateStore and PostgresState all call before writing), contain no `__redacted__`
8
+ * marker, no `token` key and no token value — and the fake handed out a token on the create
9
+ * response, every wire object and its `/token` endpoint. A negative control shows the same
10
+ * encoder DOES write a `Redacted` token as plaintext, so the absence above means something.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { adopt } from 'alchemy/AdoptPolicy';
14
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
15
+ import * as State from 'alchemy/State';
16
+ import * as Effect from 'effect/Effect';
17
+ import * as Redacted from 'effect/Redacted';
18
+ import { CloudflaredTunnel } from './cloudflared-tunnel.ts';
19
+ import { engine, failureOf, tokenReads, writes } from './cloudflared-tunnel-harness.ts';
20
+ import { fakeTunnels } from './fake-tunnel.ts';
21
+
22
+ const ADMIN = 'admin';
23
+ const admin = (name = 'k8s-admin') => CloudflaredTunnel(ADMIN, { name });
24
+
25
+ const persisted = (rows: object) => JSON.stringify(State.encodeState(rows));
26
+
27
+ describe('CloudflaredTunnel state serialization', () => {
28
+ test('the persisted state holds no token, no __redacted__ marker and no secret field', async () => {
29
+ const fake = fakeTunnels();
30
+ const stack = engine(fake);
31
+ expect(failureOf(await stack.deploy(admin()))).toBe('');
32
+ // A second deploy re-reads and re-reconciles: the paths Alchemy's own Tunnel reads the token on.
33
+ expect(failureOf(await stack.deploy(admin('k8s-admin-2')))).toBe('');
34
+ const [tunnel] = fake.live();
35
+ const json = persisted(stack.rows);
36
+ expect(json).toContain(tunnel?.id ?? 'no tunnel');
37
+ expect(json).not.toContain(State.REDACTED_MARKER);
38
+ expect(json).not.toContain(tunnel?.token ?? 'no tunnel');
39
+ expect(json).not.toContain('fake-tunnel-token');
40
+ expect(json.toLowerCase()).not.toContain('token');
41
+ expect(json.toLowerCase()).not.toContain('secret');
42
+ expect(tokenReads(fake)).toBe(0);
43
+ });
44
+
45
+ test('the attributes in the row are exactly id, accountId and name', async () => {
46
+ const fake = fakeTunnels();
47
+ const stack = engine(fake);
48
+ await stack.deploy(admin());
49
+ const row = stack.rows['tunnel']?.['test']?.[ADMIN] as { attr?: object } | undefined;
50
+ expect(Object.keys(row?.attr ?? {}).sort()).toEqual(['accountId', 'id', 'name']);
51
+ });
52
+
53
+ test('negative control: the same encoder writes a Redacted token as a plaintext marker', () => {
54
+ const json = persisted({ attr: { token: Redacted.make('t0ken') } });
55
+ expect(json).toContain(State.REDACTED_MARKER);
56
+ expect(json).toContain('t0ken');
57
+ });
58
+ });
59
+
60
+ describe('CloudflaredTunnel through the real engine', () => {
61
+ test('retain is the default: dropping the declaration leaves the tunnel and sends no DELETE', async () => {
62
+ const fake = fakeTunnels();
63
+ const stack = engine(fake);
64
+ await stack.deploy(admin());
65
+ expect(failureOf(await stack.deploy(Effect.void))).toBe('');
66
+ expect(stack.status(ADMIN)).toBeUndefined();
67
+ expect(fake.live()).toHaveLength(1);
68
+ expect(writes(fake)).toEqual(['POST']);
69
+ });
70
+
71
+ test('RemovalPolicy.destroy() still reaches the implemented delete', async () => {
72
+ const fake = fakeTunnels();
73
+ const stack = engine(fake);
74
+ await stack.deploy(admin().pipe(RemovalPolicy.destroy()));
75
+ expect(failureOf(await stack.deploy(Effect.void))).toBe('');
76
+ expect(fake.live()).toHaveLength(0);
77
+ expect(writes(fake)).toEqual(['POST', 'DELETE']);
78
+ });
79
+
80
+ test('a rename deploys as a PATCH on the same tunnel', async () => {
81
+ const fake = fakeTunnels();
82
+ const stack = engine(fake);
83
+ await stack.deploy(admin());
84
+ const [before] = fake.live();
85
+ expect(failureOf(await stack.deploy(admin('k8s-admin-2')))).toBe('');
86
+ expect(fake.live().map((t) => [t.id, t.name])).toEqual([[before?.id ?? '', 'k8s-admin-2']]);
87
+ expect(writes(fake)).toEqual(['POST', 'PATCH']);
88
+ });
89
+
90
+ test('an existing tunnel is refused without adopt(true), adopted with it, and a repeat writes nothing', async () => {
91
+ const fake = fakeTunnels();
92
+ const tunnel = fake.seed({ name: 'k8s-admin', status: 'healthy' });
93
+ const stack = engine(fake);
94
+ expect(failureOf(await stack.deploy(admin()))).not.toBe('');
95
+ expect(writes(fake)).toEqual([]);
96
+ expect(failureOf(await stack.deploy(admin().pipe(adopt(true))))).toBe('');
97
+ expect(failureOf(await stack.deploy(admin().pipe(adopt(true))))).toBe('');
98
+ expect(writes(fake)).toEqual([]);
99
+ expect(fake.live().map((t) => t.id)).toEqual([tunnel.id]);
100
+ expect(tokenReads(fake)).toBe(0);
101
+ const row = stack.rows['tunnel']?.['test']?.[ADMIN] as { attr?: { id?: string } } | undefined;
102
+ expect(row?.attr?.id).toBe(tunnel.id);
103
+ });
104
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * CloudflaredTunnelProvider against a fake Cloudflare (fake-tunnel.ts), its handlers called the way
3
+ * the engine calls them.
4
+ *
5
+ * ⛔ THE ASSERTION THAT MATTERS MOST: no lifecycle handler ever requests `/token`, and nothing the
6
+ * provider returns contains the tunnel token — even though the fake puts one on every wire object.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { Unowned } from 'alchemy/AdoptPolicy';
10
+ import type { StackServices } from 'alchemy/Stack';
11
+ import * as Effect from 'effect/Effect';
12
+ import type * as Layer from 'effect/Layer';
13
+ import {
14
+ diff,
15
+ extra,
16
+ ids,
17
+ read,
18
+ reconcile,
19
+ run,
20
+ stored,
21
+ tokenReads,
22
+ writes,
23
+ } from './cloudflared-tunnel-harness.ts';
24
+ import { FAKE_ACCOUNT, FAKE_API_TOKEN } from './fake-mesh.ts';
25
+ import { fakeTunnels } from './fake-tunnel.ts';
26
+ import { providers } from './providers.ts';
27
+
28
+ // ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`.
29
+ type Built = Layer.Success<ReturnType<typeof providers>>;
30
+ const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
31
+ void _stackShaped;
32
+
33
+ const props = { name: 'k8s-admin' };
34
+
35
+ describe('CloudflaredTunnelProvider', () => {
36
+ test('create sends config_src cloudflare and no secret, and the attributes carry no token', async () => {
37
+ const fake = fakeTunnels();
38
+ const out = await run(fake, (p) => reconcile(p, props));
39
+ const post = fake.seen.find((s) => s.method === 'POST');
40
+ expect(post?.body).toEqual({ name: 'k8s-admin', config_src: 'cloudflare' });
41
+ expect(out).toMatchObject({ name: 'k8s-admin', accountId: FAKE_ACCOUNT });
42
+ expect(Object.keys(out).sort()).toEqual(['accountId', 'id', 'name']);
43
+ expect(JSON.stringify(out)).not.toContain('fake-tunnel-token');
44
+ expect(tokenReads(fake)).toBe(0);
45
+ expect(new Set(fake.auth)).toEqual(new Set([`Bearer ${FAKE_API_TOKEN}`]));
46
+ });
47
+
48
+ test('read with state refreshes by id; without state it adopts by exact name, Unowned', async () => {
49
+ const fake = fakeTunnels();
50
+ fake.seed({ name: 'k8s-admin-old' });
51
+ fake.seed({ name: 'k8s-admin-older' });
52
+ const tunnel = fake.seed({ name: 'k8s-admin', status: 'healthy' });
53
+ const [owned, probe] = await run(fake, (p) =>
54
+ Effect.gen(function* () {
55
+ const output = stored(FAKE_ACCOUNT, { id: tunnel.id });
56
+ return [yield* read(p, props, output), yield* read(p, props)] as const;
57
+ }),
58
+ );
59
+ expect(Unowned.is(owned)).toBe(false);
60
+ expect(owned).toMatchObject({ id: tunnel.id, name: 'k8s-admin' });
61
+ expect(Unowned.is(probe)).toBe(true);
62
+ expect(probe).toMatchObject({ id: tunnel.id, name: 'k8s-admin' });
63
+ expect(tokenReads(fake)).toBe(0);
64
+ expect(writes(fake)).toEqual([]);
65
+ });
66
+
67
+ test('adopting twice is idempotent: the second reconcile over the adopted row writes nothing', async () => {
68
+ const fake = fakeTunnels();
69
+ const tunnel = fake.seed({ name: 'k8s-admin', status: 'healthy' });
70
+ const [first, second] = await run(fake, (p) =>
71
+ Effect.gen(function* () {
72
+ const adopted = yield* read(p, props);
73
+ expect(Unowned.is(adopted)).toBe(true);
74
+ const first = yield* reconcile(p, props, adopted);
75
+ return [first, yield* reconcile(p, props, first)] as const;
76
+ }),
77
+ );
78
+ expect(first).toEqual(second);
79
+ expect(first.id).toBe(tunnel.id);
80
+ expect(writes(fake)).toEqual([]);
81
+ expect(tokenReads(fake)).toBe(0);
82
+ });
83
+
84
+ test('a rename is a PATCH on the same id', async () => {
85
+ const fake = fakeTunnels();
86
+ const [first, second] = await run(fake, (p) =>
87
+ Effect.gen(function* () {
88
+ const first = yield* reconcile(p, props);
89
+ return [first, yield* reconcile(p, { name: 'k8s-admin-2' }, first)] as const;
90
+ }),
91
+ );
92
+ expect(second.id).toBe(first.id);
93
+ expect(second.name).toBe('k8s-admin-2');
94
+ expect(writes(fake)).toEqual(['POST', 'PATCH']);
95
+ expect(fake.seen.find((s) => s.method === 'PATCH')?.body).toEqual({ name: 'k8s-admin-2' });
96
+ });
97
+
98
+ test('an unchanged tunnel costs no write', async () => {
99
+ const fake = fakeTunnels();
100
+ await run(fake, (p) => Effect.flatMap(reconcile(p, props), (out) => reconcile(p, props, out)));
101
+ expect(writes(fake)).toEqual(['POST']);
102
+ });
103
+
104
+ test('a tunnel deleted out of band is created again', async () => {
105
+ const fake = fakeTunnels();
106
+ const [first, second] = await run(fake, (p) =>
107
+ Effect.gen(function* () {
108
+ const first = yield* reconcile(p, props);
109
+ const tunnel = fake.tunnels.get(first.id);
110
+ if (tunnel !== undefined) tunnel.deleted_at = '2026-10-09T02:00:00Z';
111
+ return [first, yield* reconcile(p, props, first)] as const;
112
+ }),
113
+ );
114
+ expect(second.id).not.toBe(first.id);
115
+ expect(fake.live()).toHaveLength(1);
116
+ });
117
+
118
+ test('delete removes the tunnel by id, and deleting it again sends nothing', async () => {
119
+ const fake = fakeTunnels();
120
+ await run(fake, (p) =>
121
+ Effect.gen(function* () {
122
+ const output = yield* reconcile(p, props);
123
+ yield* p.delete({ ...ids, ...extra, olds: props, output });
124
+ yield* p.delete({ ...ids, ...extra, olds: props, output });
125
+ }),
126
+ );
127
+ expect(fake.live()).toHaveLength(0);
128
+ expect(writes(fake)).toEqual(['POST', 'DELETE']);
129
+ expect(tokenReads(fake)).toBe(0);
130
+ });
131
+
132
+ test('diff reads the account from the provider environment', async () => {
133
+ const fake = fakeTunnels();
134
+ const plan = (p: Parameters<typeof diff>[0]) => diff(p, props, props, stored(FAKE_ACCOUNT));
135
+ expect(await run(fake, plan)).toBeUndefined();
136
+ expect(await run(fake, plan, '00000000000000000000000000000002')).toEqual({
137
+ action: 'replace',
138
+ });
139
+ const renamed = await run(fake, (p) =>
140
+ diff(p, { name: 'k8s-admin-2' }, props, stored(FAKE_ACCOUNT)),
141
+ );
142
+ expect(renamed).toEqual({ action: 'update' });
143
+ });
144
+
145
+ test('an invalid declaration fails the PLAN', async () => {
146
+ const fake = fakeTunnels();
147
+ const failure = await run(fake, (p) =>
148
+ Effect.flip(diff(p, { name: 'k8s-admin ' }, props, stored(FAKE_ACCOUNT))),
149
+ );
150
+ expect(String(failure)).toContain('whitespace');
151
+ expect(fake.seen).toHaveLength(0);
152
+ });
153
+
154
+ test('list is empty and nuke skips it: Alchemy Tunnel already enumerates these', async () => {
155
+ const fake = fakeTunnels();
156
+ fake.seed({ name: 'k8s-admin' });
157
+ const [listed, nuke] = await run(fake, (p) =>
158
+ Effect.map(p.list(), (rows) => [rows, p.nuke] as const),
159
+ );
160
+ expect(listed).toEqual([]);
161
+ expect(nuke).toEqual({ skip: true });
162
+ expect(fake.seen).toHaveLength(0);
163
+ });
164
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `Cloudflare.CloudflaredTunnel` — a Cloudflare Tunnel (the cloudflared kind, `cfd_tunnel`),
3
+ * declared without its connector token ever touching Alchemy state. Usage: docs/cloudflared-tunnel.md.
4
+ *
5
+ * 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Tunnel.Tunnel`. Alchemy beta.81's resource stores the
6
+ * token as a `Redacted` attribute, and the state encoder writes it out in plaintext. See
7
+ * cloudflared-tunnel-form.ts, which also says what this resource leaves to other declarations.
8
+ *
9
+ * ★ REMOVAL POLICY: `retain` BY DEFAULT — see the ★ on `CloudflaredTunnel` below.
10
+ */
11
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
12
+ import * as Cloudflare from 'alchemy/Cloudflare';
13
+ import { isResolved } from 'alchemy/Diff';
14
+ import * as Provider from 'alchemy/Provider';
15
+ import { Resource, type ResourceClass } from 'alchemy/Resource';
16
+ import * as Context from 'effect/Context';
17
+ import * as Effect from 'effect/Effect';
18
+ import * as HttpClient from 'effect/http/HttpClient';
19
+ import {
20
+ type CloudflaredTunnelAttributes,
21
+ type CloudflaredTunnelProps,
22
+ diffCloudflaredTunnel,
23
+ validateCloudflaredTunnel,
24
+ } from './cloudflared-tunnel-form.ts';
25
+ import {
26
+ deleteCloudflaredTunnel,
27
+ readCloudflaredTunnel,
28
+ reconcileCloudflaredTunnel,
29
+ } from './cloudflared-tunnel-lifecycle.ts';
30
+ import type { Providers } from './providers.ts';
31
+
32
+ export interface CloudflaredTunnel extends Resource<
33
+ 'Cloudflare.CloudflaredTunnel',
34
+ CloudflaredTunnelProps,
35
+ CloudflaredTunnelAttributes,
36
+ never,
37
+ Providers
38
+ > {}
39
+
40
+ /**
41
+ * ★ `defaultRemovalPolicy: 'retain'`, LIKE EVERY KIT RESOURCE WHOSE DELETION BREAKS ITS CONSUMERS
42
+ * (MeshNode, the `Bao.*` families, `R2BucketLock`; the reasoning is written once in
43
+ * proxmox/resource.ts). Deleting a tunnel drops its remote configuration (ingress rules) and
44
+ * orphans every DNS record that targets `<id>.cfargotunnel.com`, and a new tunnel has a new id
45
+ * and a new token. So dropping the declaration, or `alchemy destroy`, leaves the tunnel live.
46
+ * ⚠️ IT ALSO SKIPS THE OLD TUNNEL'S DELETE IN AN ACCOUNT-CHANGE REPLACE (create-first), which
47
+ * leaves the old tunnel live and unmanaged (Apply.ts, `deleteOldGenerations`, beta.81).
48
+ * ⛔ `delete` STAYS FULLY IMPLEMENTED. Retain is Terraform's `prevent_destroy`, not a stub: it runs
49
+ * the moment a declaration opts in with `.pipe(RemovalPolicy.destroy())`.
50
+ */
51
+ export const CloudflaredTunnel: ResourceClass<CloudflaredTunnel> = Resource<CloudflaredTunnel>(
52
+ 'Cloudflare.CloudflaredTunnel',
53
+ { defaultRemovalPolicy: 'retain' },
54
+ );
55
+
56
+ /** What every call below needs: the account, credentials and HTTP — Alchemy's own trio. */
57
+ export type CloudflaredTunnelServices =
58
+ | Cloudflare.CloudflareEnvironment
59
+ | Credentials
60
+ | HttpClient.HttpClient;
61
+
62
+ /** The account id, resolved the way every `alchemy/Cloudflare` provider resolves it. */
63
+ const accountId = Effect.gen(function* () {
64
+ const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
65
+ return resolved.accountId;
66
+ });
67
+
68
+ /**
69
+ * ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD, exactly as `MeshNodeProvider` does
70
+ * (mesh-node.ts says why): correct whether or not the stack also merges `Cloudflare.providers()`.
71
+ */
72
+ export const CloudflaredTunnelProvider = () =>
73
+ Provider.effect(
74
+ CloudflaredTunnel,
75
+ Effect.gen(function* () {
76
+ const services = Context.make(
77
+ Cloudflare.CloudflareEnvironment,
78
+ yield* Cloudflare.CloudflareEnvironment,
79
+ ).pipe(
80
+ Context.add(Credentials, yield* Credentials),
81
+ Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
82
+ );
83
+ const run = <A, E>(effect: Effect.Effect<A, E, CloudflaredTunnelServices>) =>
84
+ Effect.provideContext(effect, services);
85
+
86
+ return CloudflaredTunnel.Provider.of({
87
+ stables: ['id', 'accountId'],
88
+ /**
89
+ * ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Tunnel.Tunnel` already lists every
90
+ * `cfd_tunnel` in the account for `alchemy unsafe nuke` (and fetches each token to do
91
+ * it); a second listing here would make nuke delete each tunnel twice. `read` does not
92
+ * depend on this.
93
+ */
94
+ list: () => Effect.succeed([]),
95
+ nuke: { skip: true },
96
+ /** ⛔ Validate at plan time too, so a bad declaration never reaches a replace. */
97
+ diff: ({ news, output }) =>
98
+ run(
99
+ Effect.gen(function* () {
100
+ const invalid = isResolved(news) ? validateCloudflaredTunnel(news) : undefined;
101
+ if (invalid !== undefined) return yield* Effect.fail(invalid);
102
+ return diffCloudflaredTunnel(news, output, yield* accountId);
103
+ }),
104
+ ),
105
+ read: ({ olds, output }) =>
106
+ run(Effect.flatMap(accountId, (account) => readCloudflaredTunnel(account, olds, output))),
107
+ reconcile: ({ news, output }) =>
108
+ run(
109
+ Effect.flatMap(accountId, (account) =>
110
+ reconcileCloudflaredTunnel(account, news, output),
111
+ ),
112
+ ),
113
+ delete: ({ output }) => run(deleteCloudflaredTunnel(output)),
114
+ });
115
+ }),
116
+ );
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A fake Cloudflare Access applications API for the SaasOidcApplication tests — an in-memory
3
+ * `fetch` handed to Effect's real FetchHttpClient (see fake-mesh.ts, which this mirrors), so the
4
+ * tests exercise distilled's real path assembly, body encoding and envelope decoding AND the raw
5
+ * read in saas-oidc-api.ts.
6
+ *
7
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. Any request outside
8
+ * `/accounts/<FAKE_ACCOUNT>/access/apps` throws, so a mis-wired test fails instead of reaching a
9
+ * real API. Every credential-looking value is a placeholder.
10
+ * ★ THE WIRE IS CLOUDFLARE'S, snake_case: `saas_app`, `client_id`, `redirect_uris`… The create
11
+ * response carries `saas_app.client_secret` (the SDK schema's own doc: "only returned on POST
12
+ * request"); a GET and a PUT never do. So a test that asserts the secret appears nowhere is
13
+ * asserting against a fake that does hand it out once.
14
+ * ★ THE LIST IS PAGES OF TWO by default, so every name lookup walks more than one page.
15
+ * ★ THE ORGANIZATION READ (`GET /access/organizations`, the pre-create team check) answers with the
16
+ * fake's own team by default; `org` makes it answer a different domain, a 403 in either of the
17
+ * two shapes a token without the permission can get (code 10000 and 10001, see
18
+ * saas-oidc-team.ts), or a 404.
19
+ * ★ `malformed` makes the named calls answer a 2xx body the SDK's STRICT validation cannot decode,
20
+ * with `FAKE_CLIENT_SECRET` planted in it, so a test can force `CloudflareParseError` and look
21
+ * for the secret in what this package throws. ⚠️ ONLY THE CALLS rc.13 TYPES CAN BE MADE TO FAIL:
22
+ * a create or update response decodes against `S.Unknown` (zero_trust.ts:35025, :210218), which
23
+ * accepts anything, so those two are not offered here (saas-oidc-parse-error.test.ts reaches them
24
+ * through the `sdkWrites` seam instead). `list` fails by answering an object where an array is
25
+ * expected, `delete` and `org` by answering a number where the schema wants a string.
26
+ */
27
+ import { FAKE_ACCOUNT, fakeFailure } from './fake-mesh.ts';
28
+
29
+ export const TEAM = 'example.cloudflareaccess.com';
30
+ /** ⚠️ A placeholder, not a credential. Tests assert it is stored and logged nowhere. */
31
+ export const FAKE_CLIENT_SECRET = 'fake-client-secret-0123456789abcdef';
32
+
33
+ export type FakeApp = {
34
+ id: string;
35
+ aud: string;
36
+ type: string;
37
+ name: string;
38
+ session_duration: string;
39
+ policies: string[];
40
+ saas_app?: Record<string, unknown> | undefined;
41
+ };
42
+
43
+ export type Seen = { readonly method: string; readonly path: string; readonly body: unknown };
44
+
45
+ /** The calls `malformed` can corrupt: `get` is the raw read, the rest are SDK calls. */
46
+ export type FakeOp = 'get' | 'list' | 'delete' | 'org';
47
+ /** How the organization read answers; an object names the `auth_domain` it returns. */
48
+ export type FakeOrg =
49
+ | 'matching'
50
+ | 'unauthorized'
51
+ | 'forbidden'
52
+ | 'not_found'
53
+ | { authDomain: string };
54
+
55
+ export type FakeOptions = {
56
+ readonly perPage?: number;
57
+ readonly team?: string;
58
+ readonly org?: FakeOrg;
59
+ readonly malformed?: ReadonlyArray<FakeOp>;
60
+ };
61
+
62
+ const ok = (result: unknown, extra: Record<string, unknown> = {}) =>
63
+ Response.json({ success: true, errors: [], messages: [], result, ...extra });
64
+
65
+ /** The wire shape of an app: no `client_secret`, and `domain` as Cloudflare derives it. */
66
+ const wire = (app: FakeApp, team: string) => {
67
+ const clientId = app.saas_app?.['client_id'];
68
+ const { saas_app: saas, ...rest } = app;
69
+ const { client_secret: _secret, ...publicSaas } = saas ?? {};
70
+ return {
71
+ ...rest,
72
+ domain: typeof clientId === 'string' ? `${team}/cdn-cgi/access/sso/oidc/${clientId}` : 'x.test',
73
+ created_at: '2026-10-09T00:00:00Z',
74
+ updated_at: '2026-10-09T00:00:00Z',
75
+ policies: app.policies.map((id, i) => ({ id, precedence: i + 1 })),
76
+ ...(saas === undefined ? {} : { saas_app: publicSaas }),
77
+ };
78
+ };
79
+
80
+ /**
81
+ * A 2xx `result` the typed decoders reject (`id` and `auth_domain` are numbers, and it is not an
82
+ * array), carrying the secret where a create puts it.
83
+ */
84
+ const garbage = () => ({
85
+ id: 12345,
86
+ auth_domain: 12345,
87
+ client_secret: FAKE_CLIENT_SECRET,
88
+ saas_app: { client_secret: FAKE_CLIENT_SECRET },
89
+ });
90
+
91
+ const orgBody = (org: FakeOrg, team: string): Response => {
92
+ if (org === 'unauthorized') return fakeFailure(403, 10000, 'Authentication error');
93
+ if (org === 'forbidden') return fakeFailure(403, 10001, 'Method not allowed for token');
94
+ if (org === 'not_found') return fakeFailure(404, 12004, 'access.api.error.not_found');
95
+ const authDomain = org === 'matching' ? team : org.authDomain;
96
+ return ok({ auth_domain: authDomain, name: 'team', session_duration: '24h' });
97
+ };
98
+
99
+ export const fakeAccess = (options: FakeOptions = {}) => {
100
+ const team = options.team ?? TEAM;
101
+ const broken = (op: FakeOp): boolean => options.malformed?.includes(op) ?? false;
102
+ const apps = new Map<string, FakeApp>();
103
+ const seen: Seen[] = [];
104
+ let next = 1;
105
+
106
+ const seed = (app: Partial<FakeApp> & { name: string }): FakeApp => {
107
+ const n = String(next++).padStart(12, '0');
108
+ const full: FakeApp = {
109
+ id: `00000000-0000-4000-8000-${n}`,
110
+ aud: `aud-${n}`,
111
+ type: 'saas',
112
+ session_duration: '24h',
113
+ policies: [],
114
+ ...app,
115
+ };
116
+ if (full.type === 'saas' && full.saas_app?.['client_id'] === undefined) {
117
+ full.saas_app = { auth_type: 'oidc', client_id: `client-${n}`, ...full.saas_app };
118
+ }
119
+ apps.set(full.id, full);
120
+ return full;
121
+ };
122
+
123
+ const route = (method: string, url: URL, body: Record<string, unknown>): Response => {
124
+ const prefix = `/client/v4/accounts/${FAKE_ACCOUNT}/access/apps`;
125
+ if (
126
+ url.host === 'api.example.com' &&
127
+ url.pathname === prefix.replace(/apps$/, 'organizations')
128
+ ) {
129
+ if (method !== 'GET') throw new Error(`fake-access: unexpected ${method} ${url.pathname}`);
130
+ return broken('org') ? ok(garbage()) : orgBody(options.org ?? 'matching', team);
131
+ }
132
+ if (url.host !== 'api.example.com' || !url.pathname.startsWith(prefix)) {
133
+ throw new Error(`fake-access: unexpected request ${method} ${url.href}`);
134
+ }
135
+ const id = url.pathname.slice(prefix.length).split('/').filter(Boolean)[0];
136
+ if (id === undefined && method === 'GET') {
137
+ if (broken('list')) return ok(garbage());
138
+ const page = Number(url.searchParams.get('page') ?? '1');
139
+ const perPage = Number(url.searchParams.get('per_page') ?? options.perPage ?? 2);
140
+ const all = [...apps.values()];
141
+ const slice = all.slice((page - 1) * perPage, page * perPage);
142
+ return ok(
143
+ slice.map((app) => wire(app, team)),
144
+ {
145
+ result_info: { page, per_page: perPage, count: slice.length, total_count: all.length },
146
+ },
147
+ );
148
+ }
149
+ if (id === undefined && method === 'POST') {
150
+ const app = seed({
151
+ name: String(body['name']),
152
+ type: String(body['type']),
153
+ policies: (body['policies'] as string[] | undefined) ?? [],
154
+ saas_app: body['saas_app'] as Record<string, unknown> | undefined,
155
+ });
156
+ // ⛔ The one response that hands the secret out, as the API does on POST.
157
+ const created = wire(app, team) as Record<string, unknown>;
158
+ created['saas_app'] = {
159
+ ...(created['saas_app'] as object),
160
+ client_secret: FAKE_CLIENT_SECRET,
161
+ };
162
+ return ok(created);
163
+ }
164
+ const app = id === undefined ? undefined : apps.get(id);
165
+ if (app === undefined) return fakeFailure(404, 12006, 'access.api.error.unknown_application');
166
+ if (method === 'GET') {
167
+ return broken('get') ? new Response(`not json ${FAKE_CLIENT_SECRET}`) : ok(wire(app, team));
168
+ }
169
+ if (method === 'PUT') {
170
+ app.name = String(body['name']);
171
+ app.policies = (body['policies'] as string[] | undefined) ?? [];
172
+ app.saas_app = { ...app.saas_app, ...(body['saas_app'] as Record<string, unknown>) };
173
+ return ok(wire(app, team));
174
+ }
175
+ if (method === 'DELETE') {
176
+ if (broken('delete')) return ok(garbage());
177
+ apps.delete(app.id);
178
+ return ok({ id: app.id });
179
+ }
180
+ throw new Error(`fake-access: unexpected ${method} ${url.pathname}`);
181
+ };
182
+
183
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
184
+ const request =
185
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
186
+ const url = new URL(request.url);
187
+ const text = await request.text();
188
+ const body = text.length === 0 ? {} : (JSON.parse(text) as Record<string, unknown>);
189
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}`, body });
190
+ return route(request.method, url, body);
191
+ }) as typeof globalThis.fetch;
192
+
193
+ return { apps, fetch, seed, seen, team };
194
+ };
195
+
196
+ export type FakeAccess = ReturnType<typeof fakeAccess>;
@@ -37,7 +37,7 @@ export type FakeNode = {
37
37
 
38
38
  export type Seen = { readonly method: string; readonly path: string; readonly body: unknown };
39
39
 
40
- const ok = (result: unknown, extra: Record<string, unknown> = {}) =>
40
+ export const ok = (result: unknown, extra: Record<string, unknown> = {}) =>
41
41
  Response.json({ success: true, errors: [], messages: [], result, ...extra });
42
42
 
43
43
  export const fakeFailure = (status: number, code: number, message: string) =>
@@ -153,7 +153,7 @@ export type FakeMesh = ReturnType<typeof fakeMesh>;
153
153
 
154
154
  /** distilled Credentials + the real FetchHttpClient over the fake — what a Bun script provides. */
155
155
  export const fakeClientLayer = (
156
- fake: FakeMesh,
156
+ fake: { readonly fetch: typeof globalThis.fetch },
157
157
  credentials: Layer.Layer<Credentials> = fromApiToken({
158
158
  apiToken: FAKE_API_TOKEN,
159
159
  apiBaseUrl: FAKE_BASE,
@@ -166,7 +166,10 @@ export const fakeClientLayer = (
166
166
  );
167
167
 
168
168
  /** …plus the account, as Alchemy's CloudflareEnvironment hands it to a provider. */
169
- export const fakeProviderLayer = (fake: FakeMesh, accountId: string = FAKE_ACCOUNT) =>
169
+ export const fakeProviderLayer = (
170
+ fake: { readonly fetch: typeof globalThis.fetch },
171
+ accountId: string = FAKE_ACCOUNT,
172
+ ) =>
170
173
  Layer.mergeAll(
171
174
  fakeClientLayer(fake),
172
175
  Layer.succeed(