@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,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
  );
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The Access application calls a SaaS OIDC application needs, and nothing else.
3
+ *
4
+ * ★ OVER `@distilled.cloud/cloudflare` FOR EVERY WRITE AND FOR THE NAME LOOKUP; ONE RAW READ. Alchemy
5
+ * `2.0.0-beta.81`'s `Cloudflare.Access.Application` has no `saas_app` at all (Application.ts:
6
+ * `saas` appears only as an app type and in two doc comments), so this resource exists. The SDK
7
+ * does carry `saasApp` on the REQUEST (`AccessApplicationsCreateRequestSaasAppOIDCSaaSApp`,
8
+ * zero_trust.ts, `@distilled.cloud/cloudflare@1.0.0-rc.13`), so create and update go through it.
9
+ * ⚠️ BUT ITS RESPONSES DROP IT. Measured 2026-10-09 against rc.13 with a fake API that answered a
10
+ * full `saas_app` object: `getAccessApplicationForAccount` and `createAccessApplicationForAccount`
11
+ * both decoded to `{ domain, type, id, aud, name, policies }`, with no `saasApp`. The response
12
+ * union is discriminated by field names (`T.UnionCases`) and a saas app falls in a variant with
13
+ * no `saas_app`. So the OIDC client id, redirect URIs, scopes and grant types can only be read
14
+ * from the wire JSON, and `getApp` below does that through the `HttpClient` the SDK itself runs
15
+ * on, with the SDK's own credentials and base URL. It invents no path: it is the same
16
+ * `GET /accounts/{account_id}/access/apps/{app_id}` the SDK's get verb sends.
17
+ *
18
+ * ⛔ THE CLIENT SECRET NEVER REACHES THIS PACKAGE'S MEMORY. The field doc on the SDK schema says
19
+ * "The application client secret, only returned on POST request". The create response is
20
+ * decoded by the SDK, whose `saasApp`-less variant drops it, and `getApp` copies named fields
21
+ * only and never reads `client_secret`. TWO paths remain where the SDK itself holds or prints the
22
+ * raw body (`@distilled.cloud/cloudflare@1.0.0-rc.13`, protocol.ts):
23
+ * 1. `DISTILLED_DEBUG_HTTP` makes it `console.error` the first 400 characters of every response
24
+ * (line 334), so `refuseDebugHttp` stops a write while it is set. THIS ONE STAYS AN
25
+ * OPERATOR-ONLY SWITCH: the package can refuse to run, it cannot stop the SDK printing.
26
+ * 2. A response that fails schema validation becomes `CloudflareParseError({ body, cause })`
27
+ * (lines 452-457), whose `body` is the whole parsed response, create response included.
28
+ * ⛔ SANITISED HERE: every SDK call of this resource (create, update, list, delete, and the
29
+ * organization read in saas-oidc-team.ts) catches that tag at the call and fails with a
30
+ * `SaasOidcError` that names the operation and nothing from the response. Why, when it can
31
+ * happen at all, and what rc.13 will and will not raise: saas-oidc-error.ts (`withheld`).
32
+ * The raw `get` below is not an SDK call and never builds that error.
33
+ * A PUBLIC PKCE client (`allowPkceWithoutClientSecret: true`) has no secret at all, so neither
34
+ * path exposes one for the Headlamp app.
35
+ */
36
+ import { Credentials, formatHeaders } from '@distilled.cloud/cloudflare/Credentials';
37
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
38
+ import * as Effect from 'effect/Effect';
39
+ import * as Schedule from 'effect/Schedule';
40
+ import * as Stream from 'effect/Stream';
41
+ import * as HttpClient from 'effect/http/HttpClient';
42
+ import * as HttpClientRequest from 'effect/http/HttpClientRequest';
43
+ import { SaasOidcError, withheld } from './saas-oidc-error.ts';
44
+ import { type AppWrite, parseApp, record } from './saas-oidc-wire.ts';
45
+
46
+ /**
47
+ * The two SDK writes, as properties of an object so saas-oidc-parse-error.test.ts can stand in for
48
+ * one: rc.13 cannot be made to fail a create or update with `CloudflareParseError` (the ⚠️ in
49
+ * saas-oidc-error.ts), and the catch on those two calls still has to be proven wired. Nothing
50
+ * else assigns here.
51
+ */
52
+ export const sdkWrites = {
53
+ create: zeroTrust.createAccessApplicationForAccount,
54
+ update: zeroTrust.updateAccessApplicationForAccount,
55
+ };
56
+
57
+ const TRANSIENT_STATUS = (status: number): boolean =>
58
+ status === 403 || status === 429 || status >= 500;
59
+
60
+ /**
61
+ * ★ UPSTREAM'S OWN RETRY, EXACTLY (alchemy beta.81 `Cloudflare/Access/Application.ts`, the comment
62
+ * and `retryTransientAccessError` above `ApplicationProvider`): `AccessReferenceNotFound` is a
63
+ * freshly created policy still propagating, and `Forbidden` is how Cloudflare throttles a valid
64
+ * token. Capped exponential, about 45 seconds, then fail. The raw read joins it for the same
65
+ * statuses so it is throttled the way the SDK calls are.
66
+ */
67
+ export const retryTransient = <A, E extends { readonly _tag: string }, R>(
68
+ effect: Effect.Effect<A, E, R>,
69
+ ) =>
70
+ effect.pipe(
71
+ Effect.retry({
72
+ while: (e) =>
73
+ e._tag === 'AccessReferenceNotFound' ||
74
+ e._tag === 'Forbidden' ||
75
+ (e._tag === 'SaasOidcError' &&
76
+ TRANSIENT_STATUS((e as unknown as SaasOidcError).status ?? 0)),
77
+ schedule: Schedule.max([
78
+ Schedule.min([Schedule.exponential('1 second', 1.5), Schedule.spaced('5 seconds')]),
79
+ Schedule.recurs(12),
80
+ ]),
81
+ }),
82
+ );
83
+
84
+ /**
85
+ * ⛔ REFUSED BEFORE ANY WRITE while `DISTILLED_DEBUG_HTTP` is set: the SDK would print the create
86
+ * response, which carries the client secret, to stderr (protocol.ts). Read from `process.env`
87
+ * because that is what distilled reads, as mesh-node-token.ts does.
88
+ */
89
+ export const refuseDebugHttp = Effect.suspend(() =>
90
+ (globalThis.process?.env?.['DISTILLED_DEBUG_HTTP'] ?? '') === ''
91
+ ? Effect.void
92
+ : Effect.fail(
93
+ new SaasOidcError({
94
+ message:
95
+ 'Refusing to write a SaaS OIDC application while DISTILLED_DEBUG_HTTP is set: the SDK would print the response, which carries the client secret, to stderr. Unset it and run again.',
96
+ }),
97
+ ),
98
+ );
99
+
100
+ /**
101
+ * The app with this id, from the wire JSON, or `undefined` when it is gone (404).
102
+ * ⚠️ Error messages carry the reason and the status only: the request holds the bearer token.
103
+ */
104
+ export const getApp = (accountId: string, appId: string) =>
105
+ Effect.gen(function* () {
106
+ const credentials = yield* yield* Credentials;
107
+ const client = yield* HttpClient.HttpClient;
108
+ const request = HttpClientRequest.get(
109
+ `${credentials.apiBaseUrl}/accounts/${accountId}/access/apps/${appId}`,
110
+ ).pipe(HttpClientRequest.setHeaders(formatHeaders(credentials)), HttpClientRequest.acceptJson);
111
+ const transport = (cause: { readonly message: string }) =>
112
+ new SaasOidcError({ message: `Access application GET failed: ${cause.message}`, status: 0 });
113
+ const response = yield* client.execute(request).pipe(Effect.mapError(transport));
114
+ if (response.status === 404) return undefined;
115
+ if (response.status < 200 || response.status >= 300) {
116
+ return yield* Effect.fail(
117
+ new SaasOidcError({
118
+ message: `Access application GET returned ${response.status}`,
119
+ status: response.status,
120
+ }),
121
+ );
122
+ }
123
+ const text = yield* response.text.pipe(Effect.mapError(transport));
124
+ const body = yield* Effect.try({
125
+ try: () => record(JSON.parse(text) as unknown),
126
+ catch: () =>
127
+ new SaasOidcError({ message: 'Access application GET was not JSON', status: 200 }),
128
+ });
129
+ const result = record(body?.['result']);
130
+ return result === undefined ? undefined : parseApp(result);
131
+ }).pipe(retryTransient);
132
+
133
+ /**
134
+ * The live saas app with exactly this name, or `undefined`. The list payload carries no `saas_app`
135
+ * (see the ⚠️ above), so the caller re-reads the match by id with `getApp`.
136
+ * ⛔ Two matches are refused rather than resolved: Access does not enforce unique app names, so two
137
+ * means this package cannot tell which one is meant.
138
+ */
139
+ export const findSaasByName = (accountId: string, name: string) =>
140
+ zeroTrust.listAccessApplicationsForAccount.items({ accountId }).pipe(
141
+ Stream.filter((app) => app.type === 'saas' && app.name === name),
142
+ Stream.runCollect,
143
+ Effect.catchTag('CloudflareParseError', withheld('application list')),
144
+ retryTransient,
145
+ Effect.flatMap((chunk) => {
146
+ const ids = Array.from(chunk).flatMap((app) => (app.id == null ? [] : [app.id]));
147
+ if (ids.length <= 1) return Effect.succeed(ids[0]);
148
+ return Effect.fail(
149
+ new SaasOidcError({
150
+ message: `SaaS application name "${name}" matches ${ids.length} live apps (${ids.join(', ')}). Refusing to guess which one is meant; set \`applicationId\`.`,
151
+ }),
152
+ );
153
+ }),
154
+ );
155
+
156
+ const sdkBody = (write: AppWrite) => ({
157
+ type: 'saas' as const,
158
+ name: write.name,
159
+ sessionDuration: write.sessionDuration,
160
+ ...(write.allowedIdps === undefined ? {} : { allowedIdps: [...write.allowedIdps] }),
161
+ ...(write.autoRedirectToIdentity === undefined
162
+ ? {}
163
+ : { autoRedirectToIdentity: write.autoRedirectToIdentity }),
164
+ appLauncherVisible: write.appLauncherVisible,
165
+ policies: [...write.policies],
166
+ saasApp: {
167
+ authType: write.saasApp.authType,
168
+ redirectUris: [...write.saasApp.redirectUris],
169
+ scopes: [...write.saasApp.scopes],
170
+ grantTypes: [...write.saasApp.grantTypes],
171
+ accessTokenLifetime: write.saasApp.accessTokenLifetime,
172
+ allowPkceWithoutClientSecret: write.saasApp.allowPkceWithoutClientSecret,
173
+ ...(write.saasApp.refreshTokenOptions === undefined
174
+ ? {}
175
+ : { refreshTokenOptions: write.saasApp.refreshTokenOptions }),
176
+ ...(write.saasApp.appLauncherUrl === undefined
177
+ ? {}
178
+ : { appLauncherUrl: write.saasApp.appLauncherUrl }),
179
+ } satisfies zeroTrust.AccessApplicationsCreateRequestSaasAppOIDCSaaSApp,
180
+ });
181
+
182
+ /** Create the app and return its id. The response's other fields are not trusted: re-read it. */
183
+ export const createApp = (accountId: string, write: AppWrite) =>
184
+ refuseDebugHttp.pipe(
185
+ Effect.andThen(
186
+ sdkWrites
187
+ .create({ accountId, ...sdkBody(write) })
188
+ .pipe(
189
+ Effect.catchTag('CloudflareParseError', withheld('application create')),
190
+ retryTransient,
191
+ ),
192
+ ),
193
+ Effect.flatMap((created) =>
194
+ created.id == null
195
+ ? Effect.fail(new SaasOidcError({ message: 'SaaS OIDC create did not return an id.' }))
196
+ : Effect.succeed(created.id),
197
+ ),
198
+ );
199
+
200
+ export const updateApp = (accountId: string, appId: string, write: AppWrite) =>
201
+ refuseDebugHttp.pipe(
202
+ Effect.andThen(
203
+ sdkWrites
204
+ .update({ accountId, appId, ...sdkBody(write) })
205
+ .pipe(
206
+ Effect.catchTag('CloudflareParseError', withheld('application update')),
207
+ retryTransient,
208
+ ),
209
+ ),
210
+ Effect.asVoid,
211
+ );
212
+
213
+ /**
214
+ * Idempotent: an app already gone is a successful delete. Any other failure propagates, except
215
+ * that a response the SDK cannot validate is withheld (see `withheld`).
216
+ */
217
+ export const deleteApp = (accountId: string, appId: string) =>
218
+ zeroTrust.deleteAccessApplicationForAccount({ accountId, appId }).pipe(
219
+ Effect.asVoid,
220
+ Effect.catchTags({
221
+ AccessApplicationNotFound: () => Effect.void,
222
+ CloudflareParseError: withheld('application delete'),
223
+ }),
224
+ );
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A test-only downstream resource for saas-oidc-stable.test.ts: it takes the app's issuer as a prop
3
+ * and counts how often the engine reconciles it, so a test can tell "planned noop" from "planned
4
+ * update" for a consumer of `app.issuer`. ⛔ TEST-ONLY. No provider imports this file.
5
+ */
6
+ import * as Provider from 'alchemy/Provider';
7
+ import { Resource } from 'alchemy/Resource';
8
+ import * as Effect from 'effect/Effect';
9
+
10
+ export interface IssuerConsumer extends Resource<
11
+ 'Test.IssuerConsumer',
12
+ { readonly issuer: string; readonly jwks: string },
13
+ { readonly issuer: string; readonly jwks: string }
14
+ > {}
15
+ export const IssuerConsumer = Resource<IssuerConsumer>('Test.IssuerConsumer');
16
+
17
+ /** The provider, and the list of props it was reconciled with (one entry per reconcile). */
18
+ export const consumerProvider = () => {
19
+ const reconciled: string[] = [];
20
+ const layer = Provider.effect(
21
+ IssuerConsumer,
22
+ Effect.succeed(
23
+ IssuerConsumer.Provider.of({
24
+ list: () => Effect.succeed([]),
25
+ reconcile: ({ news }) =>
26
+ Effect.sync(() => {
27
+ reconciled.push(news.issuer);
28
+ return { issuer: news.issuer, jwks: news.jwks };
29
+ }),
30
+ delete: () => Effect.void,
31
+ }),
32
+ ),
33
+ );
34
+ return { layer, reconciled };
35
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The one error type of the SaaS OIDC resource, and the handler that keeps the SDK's raw
3
+ * `CloudflareParseError` (which can hold the client secret) from leaving this package.
4
+ *
5
+ * ⛔ THE LEAK. A response that fails schema validation becomes `CloudflareParseError({ body, cause })`
6
+ * (`@distilled.cloud/cloudflare@1.0.0-rc.13` protocol.ts:452-457). `body` is the WHOLE parsed
7
+ * response, a create response included, and for a confidential client that carries
8
+ * `client_secret`; `cause` is a SchemaError that quotes the values that failed. A raw SDK error
9
+ * that reached an engine log or a trace would print both.
10
+ * ★ THE FIX is at every SDK call of this resource (create, update, list, delete, and the
11
+ * organization read in saas-oidc-team.ts): `Effect.catchTag('CloudflareParseError', withheld(…))`,
12
+ * applied before the retry or any other combinator sees the error. It is the house's catchTag
13
+ * doctrine (a typed tag, no status or message sniffing), and the replacement carries the
14
+ * operation name and nothing from the response. The raw `get` in saas-oidc-api.ts is not an SDK
15
+ * call and never builds this error.
16
+ * ⚠️ WHEN IT CAN HAPPEN AT ALL. The error exists only under STRICT response validation (core
17
+ * response-validation.ts:29 "without one, every call is lenient", :104-116); neither Alchemy nor
18
+ * this kit turns strict on, so a consumer's own `ResponseValidation.strict` is what makes it
19
+ * reachable. MEASURED 2026-10-09 against rc.13 under strict validation: a create or update
20
+ * response decodes against `S.Unknown` (`AccessApplicationsCreateResult`, zero_trust.ts:35025;
21
+ * `AccessApplicationsUpdateResult`, :210218), which accepts anything, so the real SDK cannot
22
+ * raise it for those two today (the tests reach them through `sdkWrites` in saas-oidc-api.ts).
23
+ * List (a non-array result, :147403-147410), delete (`id` typed, :61601-61607) and the organization
24
+ * read (:169193-169253) are typed and can. The catch is on all five so a later SDK that types the
25
+ * union cannot start leaking.
26
+ * ⛔ `DISTILLED_DEBUG_HTTP` is the other raw-body path and stays an operator-only switch: the
27
+ * package refuses a write while it is set (`refuseDebugHttp`), it cannot stop the SDK printing.
28
+ */
29
+ import * as Data from 'effect/Data';
30
+ import * as Effect from 'effect/Effect';
31
+
32
+ /** A refusal or failure, carrying the sentence an operator needs. Never a secret. */
33
+ export class SaasOidcError extends Data.TaggedError('SaasOidcError')<{
34
+ readonly message: string;
35
+ /** The HTTP status when the failure is a response (0 for a transport failure). */
36
+ readonly status?: number;
37
+ }> {}
38
+
39
+ /**
40
+ * ⛔ THE ONLY THING THIS PACKAGE SAYS ABOUT A RESPONSE THE SDK COULD NOT VALIDATE. The handler for
41
+ * `Effect.catchTag('CloudflareParseError', …)`: it never touches the error it replaces, because
42
+ * that error's `body` is the whole response (a create's carries `client_secret`) and its `cause`
43
+ * quotes the values that failed. The message names the operation only. No status is claimed: the
44
+ * SDK builds this error after its failure branch (protocol.ts:359, :452), so the response was a
45
+ * success as far as HTTP and the envelope go, and nothing more is known.
46
+ */
47
+ export const withheld = (operation: string) => (): Effect.Effect<never, SaasOidcError> =>
48
+ Effect.fail(
49
+ new SaasOidcError({
50
+ message: `Access ${operation} succeeded at the HTTP level but the SDK could not validate the response. The response is withheld on purpose (a create response carries the client secret). Check the application in the Zero Trust dashboard, then run again.`,
51
+ }),
52
+ );