@homeflare/alchemy 0.48.1 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +11 -0
  2. package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
  3. package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
  4. package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
  5. package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
  6. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
  7. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
  8. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
  9. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
  10. package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
  11. package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
  12. package/dist/cloudflare/fake-access.d.ts +40 -0
  13. package/dist/cloudflare/fake-access.d.ts.map +1 -0
  14. package/dist/cloudflare/fake-mesh.d.ts +7 -2
  15. package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
  16. package/dist/cloudflare/fake-tunnel.d.ts +48 -0
  17. package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
  18. package/dist/cloudflare/index.d.ts +15 -4
  19. package/dist/cloudflare/index.d.ts.map +1 -1
  20. package/dist/cloudflare/index.js +518 -7
  21. package/dist/cloudflare/index.js.map +15 -4
  22. package/dist/cloudflare/providers.d.ts +1 -1
  23. package/dist/cloudflare/providers.d.ts.map +1 -1
  24. package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
  25. package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
  26. package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
  27. package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
  28. package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
  29. package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
  30. package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
  31. package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
  32. package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
  33. package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
  34. package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
  35. package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
  36. package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
  37. package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
  38. package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
  39. package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
  40. package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
  41. package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
  42. package/dist/cloudflare/saas-oidc.d.ts +36 -0
  43. package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
  44. package/docs/cloudflared-tunnel.md +136 -0
  45. package/docs/credentials.md +1 -1
  46. package/docs/ownership.md +3 -2
  47. package/docs/saas-oidc.md +185 -0
  48. package/docs/upstream-conformance-families.md +1 -1
  49. package/docs/upstream-conformance.md +3 -0
  50. package/package.json +1 -1
  51. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  52. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  53. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  54. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  55. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  56. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  57. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  58. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  59. package/src/cloudflare/fake-access.ts +196 -0
  60. package/src/cloudflare/fake-mesh.ts +6 -3
  61. package/src/cloudflare/fake-tunnel.ts +148 -0
  62. package/src/cloudflare/index.ts +27 -4
  63. package/src/cloudflare/providers.ts +16 -3
  64. package/src/cloudflare/saas-oidc-api.ts +224 -0
  65. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  66. package/src/cloudflare/saas-oidc-error.ts +52 -0
  67. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  68. package/src/cloudflare/saas-oidc-form.ts +190 -0
  69. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  70. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  71. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  72. package/src/cloudflare/saas-oidc-props.ts +147 -0
  73. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  74. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  75. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  76. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  77. package/src/cloudflare/saas-oidc-team.ts +70 -0
  78. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  79. package/src/cloudflare/saas-oidc.test.ts +201 -0
  80. package/src/cloudflare/saas-oidc.ts +107 -0
@@ -0,0 +1,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(
@@ -0,0 +1,148 @@
1
+ /**
2
+ * A fake Cloudflare `cfd_tunnel` API for the CloudflaredTunnel tests — an in-memory `fetch` handed
3
+ * to Effect's real FetchHttpClient, the same way fake-mesh.ts does it, so the tests exercise
4
+ * distilled's real path assembly, query and body encoding, envelope decoding and error matching.
5
+ *
6
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. Any request outside
7
+ * `/accounts/<FAKE_ACCOUNT>/cfd_tunnel` or `/accounts/<FAKE_ACCOUNT>/tunnels` throws, so a
8
+ * mis-wired test fails instead of reaching a real API.
9
+ * ★ THE FAKE HANDS OUT A TOKEN ON EVERY SURFACE IT CAN: a `token` field on the create response and
10
+ * on every wire object (worst case: if Cloudflare ever adds one to the documented shape), and the
11
+ * `/token` endpoint. The tests then prove none of it reaches an attribute, a state row or a
12
+ * request log. The SDK's decoder drops fields outside its schema; the provider must not depend on
13
+ * that alone.
14
+ * ★ THE NAME FILTER IS A SUBSTRING MATCH, as in fake-mesh.ts, so the client's exact re-check is
15
+ * exercised. `/tunnels` honours `tun_types` and `is_deleted` as the SDK documents them, and
16
+ * answers pages of two by default.
17
+ */
18
+ import { FAKE_ACCOUNT, type Seen, fakeFailure, ok } from './fake-mesh.ts';
19
+
20
+ export type FakeTunnel = {
21
+ id: string;
22
+ name: string;
23
+ tun_type: string;
24
+ config_src: string;
25
+ status: string;
26
+ token: string;
27
+ deleted_at: string | null;
28
+ /** The fake refuses a DELETE while this is set, as the SDK doc says it must ("no active connections"). */
29
+ connected: boolean;
30
+ };
31
+
32
+ const wire = (tunnel: FakeTunnel) => ({
33
+ id: tunnel.id,
34
+ account_tag: FAKE_ACCOUNT,
35
+ config_src: tunnel.config_src,
36
+ created_at: '2026-10-09T00:00:00Z',
37
+ deleted_at: tunnel.deleted_at,
38
+ name: tunnel.name,
39
+ status: tunnel.status,
40
+ tun_type: tunnel.tun_type,
41
+ token: tunnel.token,
42
+ });
43
+
44
+ export type FakeTunnelOptions = {
45
+ readonly perPage?: number;
46
+ /** Return a Response to answer the POST with it instead (e.g. `fakeFailure(409, 1013, …)`). */
47
+ readonly onCreate?: (name: string) => Response | void;
48
+ /** Return a Response to answer the DELETE with it instead (e.g. the tunnel vanishing mid-delete). */
49
+ readonly onDelete?: (id: string) => Response | void;
50
+ };
51
+
52
+ export const fakeTunnels = (options: FakeTunnelOptions = {}) => {
53
+ const tunnels = new Map<string, FakeTunnel>();
54
+ const seen: Seen[] = [];
55
+ const auth: string[] = [];
56
+ let next = 1;
57
+ const live = () => [...tunnels.values()].filter((t) => t.deleted_at === null);
58
+
59
+ const seed = (tunnel: Partial<FakeTunnel> & { name: string }): FakeTunnel => {
60
+ const id = tunnel.id ?? `00000000-0000-4000-9000-${String(next++).padStart(12, '0')}`;
61
+ const full: FakeTunnel = {
62
+ id,
63
+ tun_type: 'cfd_tunnel',
64
+ config_src: 'cloudflare',
65
+ status: 'inactive',
66
+ token: `fake-tunnel-token-${id}`,
67
+ deleted_at: null,
68
+ connected: false,
69
+ ...tunnel,
70
+ };
71
+ tunnels.set(id, full);
72
+ return full;
73
+ };
74
+
75
+ const list = (url: URL): Response => {
76
+ const filter = url.searchParams.get('name') ?? '';
77
+ const types = (url.searchParams.get('tun_types') ?? '').split(',').filter(Boolean);
78
+ const isDeleted = url.searchParams.get('is_deleted');
79
+ const page = Number(url.searchParams.get('page') ?? '1');
80
+ const perPage = Number(url.searchParams.get('per_page') ?? options.perPage ?? 2);
81
+ const hits = [...tunnels.values()]
82
+ .filter((t) => types.length === 0 || types.includes(t.tun_type))
83
+ .filter((t) => isDeleted === null || (t.deleted_at !== null) === (isDeleted === 'true'))
84
+ .filter((t) => t.name.includes(filter));
85
+ const slice = hits.slice((page - 1) * perPage, page * perPage);
86
+ return ok(slice.map(wire), { result_info: { page, per_page: perPage, count: slice.length } });
87
+ };
88
+
89
+ const route = (method: string, url: URL, body: Record<string, unknown>): Response => {
90
+ const base = `/client/v4/accounts/${FAKE_ACCOUNT}`;
91
+ if (url.host !== 'api.example.com' || !url.pathname.startsWith(base)) {
92
+ throw new Error(`fake-tunnel: unexpected request ${method} ${url.href}`);
93
+ }
94
+ const rest = url.pathname.slice(base.length);
95
+ if (rest === '/tunnels' && method === 'GET') return list(url);
96
+ if (!rest.startsWith('/cfd_tunnel')) {
97
+ throw new Error(`fake-tunnel: unexpected ${method} ${url.pathname}`);
98
+ }
99
+ const [id, sub] = rest.slice('/cfd_tunnel'.length).split('/').filter(Boolean);
100
+ if (id === undefined && method === 'POST') {
101
+ const name = String(body['name']);
102
+ const override = options.onCreate?.(name);
103
+ if (override !== undefined) return override;
104
+ if (live().some((t) => t.name === name)) {
105
+ return fakeFailure(409, 1013, 'tunnel with name already exists');
106
+ }
107
+ const tunnel = seed({ name, config_src: String(body['config_src'] ?? 'local') });
108
+ return ok(wire(tunnel));
109
+ }
110
+ const tunnel = id === undefined ? undefined : tunnels.get(id);
111
+ if (tunnel === undefined) return fakeFailure(404, 1002, 'Tunnel not found');
112
+ if (sub === 'token' && method === 'GET') return ok(tunnel.token);
113
+ if (sub !== undefined) throw new Error(`fake-tunnel: unexpected ${method} ${url.pathname}`);
114
+ if (method === 'GET') return ok(wire(tunnel));
115
+ if (method === 'PATCH') {
116
+ const name = String(body['name']);
117
+ if (live().some((other) => other.name === name && other.id !== tunnel.id)) {
118
+ return fakeFailure(409, 1013, 'tunnel with name already exists');
119
+ }
120
+ tunnel.name = name;
121
+ return ok(wire(tunnel));
122
+ }
123
+ if (method === 'DELETE') {
124
+ const override = options.onDelete?.(tunnel.id);
125
+ if (override !== undefined) return override;
126
+ if (tunnel.deleted_at !== null) return fakeFailure(404, 1002, 'Tunnel not found');
127
+ if (tunnel.connected) return fakeFailure(400, 1000, 'tunnel has active connections');
128
+ tunnel.deleted_at = '2026-10-09T01:00:00Z';
129
+ return ok(wire(tunnel));
130
+ }
131
+ throw new Error(`fake-tunnel: unexpected ${method} ${url.pathname}`);
132
+ };
133
+
134
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
135
+ const request =
136
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
137
+ const url = new URL(request.url);
138
+ const text = await request.text();
139
+ const body = text.length === 0 ? {} : (JSON.parse(text) as Record<string, unknown>);
140
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}`, body });
141
+ auth.push(request.headers.get('authorization') ?? '');
142
+ return route(request.method, url, body);
143
+ }) as typeof globalThis.fetch;
144
+
145
+ return { auth, fetch, live, tunnels, seed, seen };
146
+ };
147
+
148
+ export type FakeTunnels = ReturnType<typeof fakeTunnels>;
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Cloudflare providers for Alchemy — the gaps the vendor SDK leaves.
3
3
  *
4
- * ★ Built on vendor SDKs, not hand-rolled HTTP. Both `R2BucketLock` and `MeshNode` call
5
- * `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare providers use — R2BucketLock
6
- * over `/r2` (`getBucketLock`/`putBucketLock`), MeshNode over `/zero-trust`, because
7
- * `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). Neither invents a path string.
4
+ * ★ Built on vendor SDKs, not hand-rolled HTTP. `R2BucketLock`, `MeshNode`, `CloudflaredTunnel` and
5
+ * `SaasOidcApplication` all call `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare
6
+ * providers use — R2BucketLock over `/r2` (`getBucketLock`/`putBucketLock`), MeshNode over
7
+ * `/zero-trust`, because `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). None
8
+ * invents a path string.
9
+ * ⚠️ ONE EXCEPTION, A READ: `SaasOidcApplication` writes and looks up names through the SDK, but its
10
+ * responses drop `saas_app`, so `getApp` reads that one object from the wire JSON — the same
11
+ * `GET /accounts/{account_id}/access/apps/{app_id}` the SDK's get verb sends, over the SDK's own
12
+ * `HttpClient`, credentials and base URL (saas-oidc-api.ts).
8
13
  */
9
14
  export type { R2LockRule } from './lock-rules.ts';
10
15
  export { R2BucketLock, type R2BucketLockProps } from './r2-bucket-lock.ts';
@@ -12,6 +17,24 @@ export { MeshNode, MeshNodeProvider, type MeshNodeServices } from './mesh-node.t
12
17
  export type { MeshNodeAttributes, MeshNodeProps } from './mesh-node-form.ts';
13
18
  export { MeshNodeError, type MeshNodeStatus } from './mesh-node-api.ts';
14
19
  export { fetchMeshNodeToken, type MeshNodeRef } from './mesh-node-token.ts';
20
+ export {
21
+ CloudflaredTunnel,
22
+ CloudflaredTunnelProvider,
23
+ type CloudflaredTunnelServices,
24
+ } from './cloudflared-tunnel.ts';
25
+ export type {
26
+ CloudflaredTunnelAttributes,
27
+ CloudflaredTunnelProps,
28
+ } from './cloudflared-tunnel-form.ts';
29
+ export { CloudflaredTunnelError } from './cloudflared-tunnel-api.ts';
30
+ export { SaasOidcApplication, SaasOidcApplicationProvider } from './saas-oidc.ts';
31
+ export type {
32
+ OidcGrantType,
33
+ OidcScope,
34
+ SaasOidcApplicationAttributes,
35
+ SaasOidcApplicationProps,
36
+ } from './saas-oidc-form.ts';
37
+ export { SaasOidcError } from './saas-oidc-error.ts';
15
38
  export { providers } from './providers.ts';
16
39
  export {
17
40
  astroWebsite,
@@ -13,26 +13,39 @@
13
13
  * `Cloudflare.providers()` does (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
14
14
  * `CLOUDFLARE_ACCOUNT_ID` in CI) — a lock and the Gateway rules that reference the same bucket
15
15
  * land in the same account.
16
- * ⚠️ THE LAYER IS BUILT TWICE ON PURPOSE, once per provider, even when the stack also merges
16
+ * ⚠️ THE LAYER IS BUILT ONCE PER PROVIDER ON PURPOSE, even when the stack also merges
17
17
  * `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: each feeds its own provider
18
18
  * only and adds nothing to the stack's context.
19
19
  */
20
20
  import * as Cloudflare from 'alchemy/Cloudflare';
21
21
  import * as Provider from 'alchemy/Provider';
22
22
  import * as Layer from 'effect/Layer';
23
+ import { CloudflaredTunnel, CloudflaredTunnelProvider } from './cloudflared-tunnel.ts';
23
24
  import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
24
25
  import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
26
+ import { SaasOidcApplication, SaasOidcApplicationProvider } from './saas-oidc.ts';
25
27
 
26
28
  export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
27
29
 
28
- // ⛔ `orDie` ON BOTH, as `Cloudflare.providers()` ends with: a stack's providers layer must not
30
+ // ⛔ `orDie` ON EACH of the four, as `Cloudflare.providers()` ends with: a stack's providers layer must not
29
31
  // fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
30
32
  export const providers = () =>
31
- Layer.effect(Providers, Provider.collection([R2BucketLock, MeshNode])).pipe(
33
+ Layer.effect(
34
+ Providers,
35
+ Provider.collection([R2BucketLock, MeshNode, CloudflaredTunnel, SaasOidcApplication]),
36
+ ).pipe(
32
37
  Layer.provide(
33
38
  Layer.mergeAll(
34
39
  R2BucketLockProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
35
40
  MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
41
+ CloudflaredTunnelProvider().pipe(
42
+ Layer.provide(Cloudflare.CloudflareApiLive()),
43
+ Layer.orDie,
44
+ ),
45
+ SaasOidcApplicationProvider().pipe(
46
+ Layer.provide(Cloudflare.CloudflareApiLive()),
47
+ Layer.orDie,
48
+ ),
36
49
  ),
37
50
  ),
38
51
  );