@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,136 @@
1
+ # CloudflaredTunnel — a Cloudflare Tunnel without its token in state
2
+
3
+ `CloudflaredTunnel` (`Cloudflare.CloudflaredTunnel`) declares a Cloudflare Tunnel of the cloudflared
4
+ kind (`cfd_tunnel`): the object whose `<id>.cfargotunnel.com` target DNS records point at and whose
5
+ remote configuration holds the ingress rules. It declares **the tunnel object only**: no ingress
6
+ rules, no routes, no DNS record, no connector Deployment. Those are separate declarations that name
7
+ this tunnel's `id`. Read 2026-10-09 against `alchemy@2.0.0-beta.81` and
8
+ `@distilled.cloud/cloudflare@1.0.0-rc.13`.
9
+
10
+ ## Why not `Cloudflare.Tunnel.Tunnel`
11
+
12
+ - ⛔ **It puts the connector token in state.** Its `reconcile`, `read` and `list` call
13
+ `getTunnelCloudflaredToken` and keep the result as `token: Redacted<string>`
14
+ (`alchemy/src/Cloudflare/Tunnel/Tunnel.ts`). Alchemy's state encoder writes a `Redacted` value as
15
+ `{"__redacted__": "<the value>"}` (`alchemy/src/State/StateEncoding.ts`, `encodeState`, which
16
+ LocalState, HttpStateStore and PostgresState all call before writing). The state store is not
17
+ encrypted, and the token is what `cloudflared tunnel run --token` accepts, so anyone who can read
18
+ state can run a connector for the tunnel and receive its traffic.
19
+ - **Its plans need a Write token.** The token endpoint is expected to need a Write permission, so even a plan
20
+ would. **UNVERIFIED for `cfd_tunnel`**: the only citation is the sibling Mesh node token endpoint
21
+ ("Cloudflare One Connectors Write", per its API reference, read 2026-09-21; see
22
+ `src/cloudflare/mesh-node-form.ts`), and the `cfd_tunnel` token endpoint's permission was not
23
+ read. `CloudflaredTunnel` never calls it, so a plan does not depend on the answer.
24
+
25
+ `CloudflaredTunnel` follows the same pattern as [MeshNode](./mesh-node.md): attributes with no
26
+ secret, adoption by exact name, `retain` by default. The type string is `CloudflaredTunnel`, not
27
+ `Tunnel`, so it cannot be confused with upstream's `Cloudflare.Tunnel.Tunnel` in a stack that uses both.
28
+
29
+ ## Declaring a tunnel
30
+
31
+ ```ts
32
+ import { CloudflaredTunnel, providers as homeflareCloudflare } from '@homeflare/alchemy/cloudflare';
33
+
34
+ // providers: Layer.mergeAll(Cloudflare.providers(), homeflareCloudflare()),
35
+ const admin = yield * CloudflaredTunnel('k8s-admin', { name: 'k8s-admin' });
36
+ // admin.id → the tunnel UUID: `${admin.id}.cfargotunnel.com` is the DNS target
37
+ ```
38
+
39
+ | prop | type | notes |
40
+ | ------ | -------- | --------------------------------------------------------------------- |
41
+ | `name` | `string` | Unique per account. The identity used for adoption. Renames in place. |
42
+
43
+ Attributes: `id`, `accountId` and `name`. ★ There is no `status`: it moves as connectors attach and
44
+ drop, so a stored copy would make every `alchemy drift` flag the tunnel; read health from the API
45
+ or the metrics. ⛔ There is no token, secret or `tunnelSecret` attribute, and none is sent. The request schema documents `tunnel_secret` as the password for a _locally-managed_ tunnel
46
+ (`@distilled.cloud/cloudflare@1.0.0-rc.13`, `src/services/zero_trust.ts`, `CreateTunnelCloudflaredRequest`);
47
+ this resource always sends `config_src: "cloudflare"`. **UNVERIFIED**: that Cloudflare itself generates and
48
+ keeps a secret when `tunnel_secret` is omitted for such a tunnel; the schema says only that the field is optional. `accountId` is not a secret; it is what
49
+ `read` and `delete` address, so they never act on whatever account the environment names today.
50
+
51
+ The account and credentials come from Alchemy's own Cloudflare environment
52
+ (`Cloudflare.CloudflareApiLive()`): an Alchemy profile, or `CLOUDFLARE_API_TOKEN` and
53
+ `CLOUDFLARE_ACCOUNT_ID` in CI.
54
+
55
+ ### Remotely configured only
56
+
57
+ The tunnel is always created with `config_src: "cloudflare"` (managed through the API and the Zero
58
+ Trust dashboard), sent explicitly. The SDK types the field as optional (`local` or `cloudflare`)
59
+ and does not document the default (`CreateTunnelCloudflaredRequest`, `zero_trust.ts`). It is
60
+ create-only: the PATCH body has `name` and `tunnelSecret` only. A tunnel found by name whose
61
+ `config_src` is `local` is **refused on adoption** with a sentence and left untouched, because its
62
+ ingress is a YAML file on its origin and remote rules would be accepted and never applied.
63
+ ⚠️ UNVERIFIED: that every real GET/list response carries `config_src`. A response without it is
64
+ accepted; only an explicit `local` is refused.
65
+
66
+ ## The token is not this resource's business
67
+
68
+ Whatever installs the connector fetches the token at that moment and hands it straight to the
69
+ workload's secret store (`GET /accounts/{id}/cfd_tunnel/{id}/token`, which needs a Write
70
+ permission). It must not go into Alchemy state, a log line or a CI variable. No function in this
71
+ package calls that endpoint, `observe` copies named fields only, and
72
+ `cloudflared-tunnel-state.test.ts` proves the persisted state holds no token. ⚠️ Never deploy with
73
+ `DISTILLED_DEBUG_HTTP` set: distilled prints the start of every response body.
74
+
75
+ ## What each change does
76
+
77
+ | change | answer | what happens on Cloudflare |
78
+ | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
79
+ | `name` | `update` | `PATCH`. The id, connectors and `cfargotunnel.com` target survive. |
80
+ | account (provider env) | `replace`, create-first | A new tunnel in the new account. Under `retain` the old one stays. |
81
+ | nothing | none | Zero writes and no token read. An existing row's plan makes no request (`diff` is pure); the cold probe is one list. |
82
+
83
+ ⛔ **Removal policy: `retain` by default**, like MeshNode and the other kit resources whose deletion
84
+ breaks their consumers. Deleting a tunnel drops its remote configuration and orphans every DNS record
85
+ that targets it; a new tunnel has a new id and token. Dropping the declaration or `alchemy destroy`
86
+ leaves the tunnel live and only drops it from state. Opt a declaration into deletion with
87
+ `.pipe(RemovalPolicy.destroy())`. ⚠️ Under `retain` an account-change replace leaves the old tunnel
88
+ live and unmanaged.
89
+
90
+ ## Adoption
91
+
92
+ An existing tunnel is found by **exact** name among `cfd_tunnel`s and returned `Unowned`. Alchemy
93
+ refuses to take it over until the resource is wrapped in `adopt(true)` (or `--adopt` is passed). A
94
+ deleted tunnel, a tunnel of another type (a `warp_connector` Mesh node shares the name space) and a
95
+ prefix match are never matched, and a name that matches two live tunnels is refused. Adoption is
96
+ idempotent: the adopted row holds the id, so a repeat deploy takes the owned path and writes
97
+ nothing. ⛔ The name probe runs only when there is **no** stored row. A stored id that the API no
98
+ longer resolves (deleted out of band) reads as missing, never as a same-named tunnel: `alchemy drift`
99
+ treats what `read` returns as already ours, so a probe there would write a foreign tunnel into this
100
+ row without `adopt(true)`. Drift reports it missing and recreates, and the create refuses and
101
+ names the holder. Without `adopt(true)`, a create whose name is held by another tunnel refuses and names the
102
+ holder; it never converges on it, and the sentence says not to delete it.
103
+
104
+ An interrupted create (the tunnel was made, its state was not saved) is found the same way on the
105
+ next plan, so it too needs `adopt(true)`. ⚠️ distilled retries a create after a lost response or a
106
+ 5xx, and the retry is answered code 1013 by the tunnel the first attempt made: the error then names
107
+ that tunnel's id and is not adopted silently, because a concurrent creator looks the same.
108
+
109
+ ## Deleting
110
+
111
+ `delete` runs only under `RemovalPolicy.destroy()`. It addresses the **stored id and account**,
112
+ never a name: it reads that id first, does nothing when the tunnel is already gone or soft-deleted,
113
+ and refuses an empty id before any request. The API refuses a delete while connectors are attached
114
+ ("The tunnel must have no active connections", the SDK's own doc on `deleteTunnelCloudflared`); that
115
+ error passes through unchanged, so scale cloudflared to zero first. No retry loop is added: a
116
+ connector does not detach within seconds on its own. A tunnel that vanishes between the pre-read and
117
+ the DELETE is a successful delete: the 404 reaches the caller as `NotFound` at runtime though the SDK's
118
+ typed error union omits it (`protocol.ts`, `HTTP_STATUS_MAP`), so it is matched by tag name and caught. ⚠️ UNVERIFIED against the real API: the fake
119
+ answers 404 / code 1002 there, the same shape `getTunnelCloudflared` documents.
120
+
121
+ `list` is empty and `nuke` skips this type: Alchemy's own `Tunnel.Tunnel` already enumerates every
122
+ `cfd_tunnel` for `alchemy unsafe nuke` (fetching each token to do it), and a second listing would
123
+ delete each tunnel twice.
124
+
125
+ ## Tests
126
+
127
+ Three test files in `src/cloudflare/` (plus `cloudflared-tunnel-harness.ts`, their shared helpers)
128
+ run the provider against a fake API (`fake-tunnel.ts`) that hands out a token on every surface it can:
129
+
130
+ - `cloudflared-tunnel.test.ts`: the lifecycle; the create body is `{name, config_src}` and no
131
+ request ever touches `/token`.
132
+ - `cloudflared-tunnel-edges.test.ts`: each refusal and recovery, written to fail when its guard
133
+ is deleted.
134
+ - `cloudflared-tunnel-state.test.ts`: Alchemy's real plan/apply, then `encodeState` over the stored
135
+ rows: no `__redacted__` marker, no `token` key, no token value, with a negative control showing
136
+ the same encoder does write a `Redacted` token. Also the removal policy and adoption.
@@ -3,7 +3,7 @@
3
3
  Moved out of the README (2026-09-21) to make room under its 200-line cap; the text is unchanged.
4
4
 
5
5
  `CLOUDFLARE_API_TOKEN` is read from the environment at call time, never at module scope.
6
- `MeshNode` instead resolves credentials and the account the way `Cloudflare.providers()` does
6
+ `MeshNode` and `CloudflaredTunnel` instead resolve credentials and the account the way `Cloudflare.providers()` does
7
7
  (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in CI).
8
8
 
9
9
  ⛔ **Mint a short-lived, scoped token** — do not reuse a long-lived one, and never a Global
@@ -0,0 +1,146 @@
1
+ # HomeFlare.Kubernetes.Ready
2
+
3
+ `@homeflare/alchemy/kubernetes` — a readiness gate for a Kubernetes cluster reached through the
4
+ `talos-openbao` adapter. Walked against `alchemy@2.0.0-beta.81`. Proven offline only (fake `bao`,
5
+ fake apiserver); **not** exercised against a live cluster.
6
+
7
+ ## Why it exists
8
+
9
+ Upstream ships no readiness resource. `Kubernetes.HelmChart` reconcile returns when server-side
10
+ apply returns, and Alchemy releases every downstream row at that moment. A row chained after the
11
+ Cilium chart would start before one Cilium pod runs. Declare a `Ready` row with
12
+ `after: [chart.objects]` and chain later rows on `ready.ready`.
13
+
14
+ ## Declaring it
15
+
16
+ ```ts
17
+ const cniReady =
18
+ yield *
19
+ KubernetesReady('cni-ready', {
20
+ connection: talosOpenBaoConnection(PINNED_UID), // a literal uid, never an Output
21
+ checks: [
22
+ { kind: 'DaemonSet', namespace: 'kube-system', name: 'cilium', minReady: 2 },
23
+ { kind: 'Deployment', namespace: 'kube-system', name: 'cilium-operator' },
24
+ { kind: 'CustomResourceDefinition', name: 'ciliumloadbalancerippools.cilium.io' },
25
+ ],
26
+ waitTimeout: '10m0s', // Go style h/m/s, default 10m0s
27
+ pollInterval: '10s', // default 10s
28
+ after: [cilium.objects],
29
+ });
30
+ ```
31
+
32
+ Providers: `Layer.mergeAll(Kubernetes.providers(), TalosOpenBaoAdapter(...), KubernetesReadyProvider())`.
33
+ The provider requires `ChildProcessSpawner` (the adapter shells to `bao`).
34
+
35
+ ## What "ready" means
36
+
37
+ The rules are `kubectl rollout status` (kubectl v0.34.0 `rollout_status.go`), not "Available":
38
+
39
+ | kind | ready when |
40
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
41
+ | DaemonSet | `updateStrategy` is `RollingUpdate`, `observedGeneration >= generation`, `updatedNumberScheduled >= minReady`, `numberAvailable - (desiredNumberScheduled - updatedNumberScheduled) >= minReady` |
42
+ | Deployment | `observedGeneration >= generation`, `updatedReplicas >= spec.replicas`, `replicas == updatedReplicas`, `availableReplicas >= updatedReplicas` |
43
+ | CustomResourceDefinition | condition `Established` is `True` |
44
+
45
+ `minReady` defaults to `desiredNumberScheduled`, and a DaemonSet that wants zero pods is never ready.
46
+ `numberAvailable` also counts available pods of the OLD template, so it is never compared to
47
+ `minReady` alone: subtracting `desired - updated` (the most old pods there can be) leaves a lower
48
+ bound on pods that are both updated and available. Without it, minReady 2 of 4 with two crashlooping
49
+ new pods and two healthy old ones would pass.
50
+ A Deployment whose `Progressing` condition has reason `ProgressDeadlineExceeded` (for the
51
+ generation the controller has observed), and a DaemonSet whose `updateStrategy` is not
52
+ `RollingUpdate` (absent counts as `RollingUpdate`), are typed failures, `KubernetesRolloutFailed`
53
+ (`reason`), not pending.
54
+
55
+ ## Refused at declaration
56
+
57
+ - `checks` must be non-empty; each needs a known `kind`, a non-empty `name`, a non-empty
58
+ `namespace` for DaemonSet and Deployment, and `minReady` (DaemonSet only) an integer >= 1:
59
+ `KubernetesReadyBadCheck { index, problem }`.
60
+ - `after` must hold lazy Outputs of the chart (`chart.objects`): `KubernetesReadyBadAfter { index }`
61
+ for a plain value, a bare `chart`, or a stable such as `chart.connection`. Those resolve at plan
62
+ time, so the row would plan `noop` before the chart's update and the gate would be skipped.
63
+
64
+ ## Errors
65
+
66
+ | outcome | `read` / `diff` (one pass) | `reconcile` (poll) |
67
+ | -------------------------------------------------------------- | -------------------------- | --------------------------------------------------------- |
68
+ | 404 (`_tag` `KubernetesNotFound`, or `KubernetesApiError` 404) | pending | pending |
69
+ | per-GET timeout (5 s), 5xx, 429, upstream transport `Error` | read: throws; diff: update | pending, recorded as `lastTransient` (tag + status) |
70
+ | the same on the connect's identity GET (incl. its 5 s timeout) | propagates | connect retried every `pollInterval`, inside the deadline |
71
+ | 401, 403, other 4xx | propagates | propagates |
72
+ | uid mismatch, vault (`bao`) failure | propagates | propagates |
73
+ | any other untagged `Error` (a defect, e.g. an empty namespace) | propagates | propagates |
74
+
75
+ - "Upstream transport `Error`" is only the `Failed Kubernetes ...` wrapper (alchemy `client.ts`).
76
+ - `lastTransient` is the transient of the LAST pass; one that has cleared is not blamed.
77
+ - `read` with no prior output (Alchemy's deferred adoption read on a first create) returns
78
+ `undefined` without touching the cluster: the row owns nothing to adopt.
79
+ - Every error leaving a GET, the connect's identity GET included, is scrubbed of the response body.
80
+
81
+ - 404 is matched by tag string with no import, so it is proof against alchemy raising
82
+ `KubernetesNotFound` instead of `KubernetesApiError` (standing watch: drop the second shape once
83
+ upstream settles).
84
+ - A deadline fails with `KubernetesReadyTimeout { failing, seconds, lastTransient?, states? }`.
85
+ `failing` holds **check keys** (`DaemonSet/kube-system/cilium`) only; `states` records per key
86
+ what the last pass saw: `{ notFound: true }` (a 404 that never resolves) or `{ counts }`
87
+ (generation and rollout numbers), so a missing object reads differently from a slow rollout.
88
+ - A propagated apiserver refusal is rewrapped as `KubernetesReadyApiError { method, path,
89
+ statusCode }`: upstream's `KubernetesApiError` message quotes up to 1000 bytes of the response
90
+ body, which this row does not republish.
91
+ - Upstream `readObject` takes no signal, so a timed-out GET abandons its socket rather than
92
+ aborting it, and process exit is not a bound for a long apply. The poll therefore rations
93
+ timeouts: after one GET times out the rest of that pass is pending with no GET, and the pause
94
+ doubles per consecutive timeout (cap 30 s), resetting on a clean pass. Standing watch: drop this
95
+ when upstream's `readObject` takes an AbortSignal (alchemy PR 1948, per-attempt deadlines).
96
+ - Upstream also retries a transport error itself, for about 40 s (a `spaced` 5 s schedule capped
97
+ by `recurs(8)`), longer than the per-GET bound, so a dead socket surfaces as the GET timeout.
98
+
99
+ ## Lifecycle
100
+
101
+ - `read`: one pass, `ready: false` when any check is pending. Never waits.
102
+ - `diff`: refuses a non-literal `connection` (`TalosUidNotLiteral`, also at declaration);
103
+ `undefined` when unresolved; `replace` when the connection differs (it is in `stables`, so
104
+ dependants would otherwise see the old one for a deploy; `delete` is a no-op, so nothing live
105
+ is touched); `noop` when the live pass is ready and the check keys are unchanged; otherwise
106
+ `update`. It is live on every plan, like `Talos.ClusterHealth`, but a transient (5xx, 429, a
107
+ timeout, on the connect or a GET) is `update`, never a failed plan: `reconcile` does the
108
+ waiting. 401/403 and vault failures still propagate.
109
+ - `reconcile`: polls until every check passes or fails typed.
110
+ - `delete` is a no-op and `list` is empty: the row owns nothing in the cluster. `connection` is
111
+ `stables`, and is copied onto the attributes so the row is `ClusterLike`.
112
+
113
+ ## Secrecy
114
+
115
+ Attributes are `{ connection, ready, checks }`; the connection is `{ kind, uid }`. No kubeconfig,
116
+ PEM, token or response body reaches an attribute, an error, a log or a file
117
+ (`ready-secrecy.test.ts`).
118
+
119
+ ## Bounded HelmChart (`boundedHelmChartProvider`, ledger row `t10-k1b`)
120
+
121
+ Upstream's `Kubernetes.HelmChart` has no deadline: alchemy 2.0.0-beta.81's `requestJson` sets no
122
+ timeout and no signal, so a hung apiserver blocks `reconcile`/`read` forever and would hold a 1h
123
+ platform token across a stuck deploy. `boundedHelmChartProvider()` wraps upstream's provider (never
124
+ re-implements it) in `Effect.timeoutOrElse`, failing typed `KubernetesReconcileTimeout { id,
125
+ operation, seconds }`: `HELM_RECONCILE_TIMEOUT = '5m0s'` (render, discovery, ~60 SSA PATCHes) and
126
+ `HELM_READ_TIMEOUT = '1m0s'`.
127
+
128
+ - Merge it beside `Kubernetes.providers()`: `Layer.mergeAll(Kubernetes.providers(),
129
+ boundedHelmChartProvider())`. A direct `Provider(HelmChart)` service beats the collection
130
+ (`Provider.ts` `tryFindProviderRegistrationByType`), so the wrapper is the one the engine resolves.
131
+ - `diff`, `delete`, `stables` and `aliases` are upstream's by spread: state identity and replace
132
+ semantics do not change.
133
+ - The interrupted fiber abandons its socket (upstream takes no signal); the run fails typed and the
134
+ process exits once main completes. Standing watch: upstream puts no `AbortSignal` on its
135
+ requests; drop the wrapper when it ships request deadlines (alchemy PR 1948).
136
+ - A malformed override dies at layer build rather than leaving the call unbounded, as
137
+ `KubernetesReadyBadDuration` naming `Kubernetes.HelmChart` and the field
138
+ (`reconcileTimeout`/`readTimeout`). The timeout message names the row and seconds only.
139
+ - Errors that pass through (an upstream `KubernetesApiError` quotes up to 1000 bytes of the
140
+ apiserver body, `client.ts:39-41`) are scrubbed to the body-free `KubernetesReadyApiError`.
141
+ - The read bound rarely fires with the `talos-openbao` adapter: upstream's read only connects, and
142
+ the kit already caps connect at 10 s and the uid read at 5 s. It is a backstop.
143
+ - `delete` stays unbounded on purpose: every platform row is `retain`, replace cleanup skips
144
+ deletion of retained rows (alchemy `Apply.ts:1111-1112`), and design §8 only drops state.
145
+ - `isBoundedHelmChartProvider(service)` is true for a service this layer built, so a consuming
146
+ stack can assert its real `providers` layer resolves the wrapper and not upstream's.
package/docs/ownership.md CHANGED
@@ -100,8 +100,9 @@ escalating to `--adopt` only on `OwnedBySomeoneElse`
100
100
  ([inspecting state](https://alchemy.run/cli/inspecting-state#recover-from-bad-state)). For the
101
101
  families above that escalation always comes (decided 2026-09-21): an object whose row is gone is
102
102
  `Unowned`, so the deploy needs `--adopt`, or `adopt(true)` on its declaration. `MeshNode`
103
- ([mesh-node.md](./mesh-node.md)) and `R2BucketLock` (`src/cloudflare/r2-bucket-lock.ts`) need it
104
- too. A `CaddyConfig` running the declared config re-imports without it, and so does every family
103
+ ([mesh-node.md](./mesh-node.md)), `CloudflaredTunnel`
104
+ ([cloudflared-tunnel.md](./cloudflared-tunnel.md)), `SaasOidcApplication` ([saas-oidc.md](./saas-oidc.md)) and
105
+ `R2BucketLock` (`src/cloudflare/r2-bucket-lock.ts`) need it too. A `CaddyConfig` running the declared config re-imports without it, and so does every family
105
106
  whose `read` never answers `Unowned`: the `pveHandlers` resources (Limits), the `forgejoHandlers`
106
107
  resources and the Talos resources. Read the takeover first with `hf-adopt-verify`
107
108
  ([adopt-verify.md](./adopt-verify.md)).
package/docs/peers.md CHANGED
@@ -29,6 +29,8 @@ asserted against that field; change the field first, then copy its `overrides` b
29
29
  "@effect/platform-node": "4.0.1",
30
30
  "@effect/platform-node-shared": "4.0.1",
31
31
  "@effect/platform-bun": "4.0.1",
32
+ "@effect/sql-d1": "4.0.1",
33
+ "@effect/sql-sqlite-do": "4.0.1",
32
34
  "rolldown": "1.2.8",
33
35
  "redis": "6.3.0"
34
36
  }
@@ -50,6 +52,7 @@ next rc and breaks at import. Pin the whole set.
50
52
  | `rolldown` → 1.2.9 via vite's `~1.2.6` (measured 2026-09-16) | `GET …/rolldown-1.2.9.tgz - 404` at `bun add` |
51
53
  | no `mime` (measured 2026-09-17 against Alchemy 78) | `Cannot find package 'mime'` from cloudflare-runtime |
52
54
  | `redis` → 6.3.0 via the `>=5.0.0 <7.0.0` peer of platform-node/sql-pg (measured 2026-09-30) | `No version matching "6.3.0" found for specifier "@redis/time-series" (but package exists)` at `bun add` |
55
+ | `@effect/sql-d1`/`-sqlite-do` → 4.0.2 via alchemy's `^4.0.0` (measured 2026-10-09) | unmet peer: 4.0.2 wants `effect ^4.0.2`, the contract pins 4.0.1 |
53
56
 
54
57
  Alchemy 78 adapted to rc.115 — that first row is why we used to pin 112, not a reason to
55
58
  stay there. The override is still the only thing that holds the set together.
@@ -62,6 +65,16 @@ failed outright for that window. The line completed the same hour — every `@re
62
65
  sub-package published at 6.3.0 (measured 11:12:19Z) — and the pin moved onto that complete
63
66
  set. The pin stays exact because the same class of gap can recur with any future redis minor.
64
67
 
68
+ ⛔ **`@effect/sql-d1` and `@effect/sql-sqlite-do` (added 2026-10-09, ledger row
69
+ kit-contract-effect-402) are exact in `overrides` because alchemy beta.81 itself depends on
70
+ both at `^4.0.0`** (`npm view alchemy@2.0.0-beta.81 dependencies`). A fresh, lockless install
71
+ resolves `^4.0.0` to 4.0.2, whose peer is `effect ^4.0.2` — an **unmet peer against the
72
+ contract's effect 4.0.1 on every fresh consumer install**; only an already-locked tree hid it
73
+ (the kit's own `bun.lock` said 4.0.1 because it was locked earlier). 4.0.1 peers on
74
+ `effect ^4.0.1`, so the pin holds the D1 / Durable-Object SQL resources on the version the
75
+ kit is tested on — the runtime path is unverified, as no stack uses those resources yet. They
76
+ are overrides, not install-line pins: they arrive transitively through `alchemy`.
77
+
65
78
  ⚠️ `@effect/platform-node` is **required, not optional**: Alchemy's module graph reaches
66
79
  `Cloudflare/Workers/WorkerBridge → @effect/platform-node/NodeServices` even when you only
67
80
  import the Proxmox subpath.
@@ -98,6 +98,13 @@ already ships is a finding, even when it works.
98
98
  `Redacted` values in plaintext, and only one state store encrypts them.
99
99
  - **Bounded waits (S26).** A retry stops after at most 8 to 10 attempts and under about a
100
100
  minute. A poll never runs past about 90 seconds.
101
+ - **The one exception: readiness gates** (`Talos.ClusterHealth`, `Kubernetes.Ready`). For these,
102
+ waiting IS the resource's job: a CNI or control-plane rollout takes minutes. So the wait may
103
+ run to a declared `waitTimeout` (default minutes, never unbounded) when ALL of these hold:
104
+ the timeout is a typed, declared prop; every single request inside it is bounded (a per-call
105
+ deadline); transient errors are pending, never a retry storm; and the timeout is a typed error
106
+ naming what was still pending. Any other resource keeps the 90-second rule. (Added
107
+ 2026-10-10 with `Kubernetes.Ready`, PR 373; `Talos.ClusterHealth` already waited 20m.)
101
108
 
102
109
  ## The Bun line for this package (S42–S45)
103
110
 
@@ -0,0 +1,185 @@
1
+ # SaasOidcApplication — Access as an OIDC provider, with no client secret in state
2
+
3
+ `SaasOidcApplication` (`HomeFlare.Access.SaasOidcApplication`) declares a Cloudflare Access **SaaS
4
+ application** of auth type `oidc`: Access acts as the OpenID Connect provider for one relying
5
+ party (OpenBao's OIDC auth, Headlamp, a Kubernetes API server). It exposes the issuer, the client
6
+ id and the JWKS endpoint as attributes, so the relying party's configuration can be declared from
7
+ them. Read 2026-10-09 against `alchemy@2.0.0-beta.81` and
8
+ `@distilled.cloud/cloudflare@1.0.0-rc.13`.
9
+
10
+ ## Why not `Cloudflare.Access.Application`
11
+
12
+ - **It cannot send or read `saas_app`.** Its source mentions `saas` only as an app type and in two
13
+ comments (`Cloudflare/Access/Application.ts`, beta.81). An OIDC app is the `saas_app` object.
14
+ - **The SDK drops it on the way back.** The request type carries `saasApp`
15
+ (`AccessApplicationsCreateRequestSaasAppOIDCSaaSApp`), so create and update use it. But a fake
16
+ API that answered a full `saas_app` decoded, for both `getAccessApplicationForAccount` and
17
+ `createAccessApplicationForAccount`, to `{ domain, type, id, aud, name, policies }` (measured
18
+ 2026-10-09). So the client id, redirect URIs, scopes and grants are read from the wire JSON by
19
+ one raw `GET /accounts/{account_id}/access/apps/{app_id}`, over the same `HttpClient` and
20
+ credentials the SDK uses. That one read is the only hand-written call; it adds no path.
21
+ ❓ **Open question for Tim (lane contract 1, SDKs over hand-rolling):** the raw GET exists only
22
+ because rc.13 decodes the GET without `saas_app`. Approve it until upstream decodes the field, or
23
+ hold this resource.
24
+ - Track upstream: when Alchemy's `Access.Application` gains `saas_app` (and distilled decodes it),
25
+ this resource should be retired in its favour. Nothing is filed upstream from here.
26
+
27
+ ## At a glance
28
+
29
+ ```ts
30
+ import { SaasOidcApplication } from '@homeflare/alchemy/cloudflare';
31
+
32
+ const app = yield * SaasOidcApplication('Headlamp', { teamDomain, policies, saasApp });
33
+ // app.issuer, app.clientId, app.jwksEndpoint
34
+ ```
35
+
36
+ - ⛔ **The client secret is never read, stored or logged.** A public client declares
37
+ `allowPkceWithoutClientSecret: true` with the PKCE grant, and then no secret exists.
38
+ - ⛔ **The type id is a state key** (moved from homeflare-openbao verbatim) and it **retains on
39
+ destroy**: deleting the app deletes the client id every relying party uses.
40
+
41
+ ## Declaring an app
42
+
43
+ ```ts
44
+ import { SaasOidcApplication, providers } from '@homeflare/alchemy/cloudflare';
45
+
46
+ // providers: Layer.mergeAll(Cloudflare.providers(), providers()),
47
+ const headlamp =
48
+ yield *
49
+ SaasOidcApplication('Headlamp', {
50
+ name: 'Headlamp',
51
+ teamDomain: 'example.cloudflareaccess.com',
52
+ policies: [adminPolicyId],
53
+ saasApp: {
54
+ authType: 'oidc',
55
+ redirectUris: ['https://headlamp.example.com/oidc-callback'],
56
+ scopes: ['openid', 'email', 'groups'],
57
+ grantTypes: ['authorization_code_with_pkce'],
58
+ accessTokenLifetime: '15m',
59
+ allowPkceWithoutClientSecret: true, // a public client: no secret exists
60
+ },
61
+ });
62
+ // headlamp.issuer, headlamp.clientId, headlamp.jwksEndpoint
63
+ ```
64
+
65
+ | prop | notes |
66
+ | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
67
+ | `teamDomain` | **Required.** Bare hostname of the Zero Trust team, no scheme or path. |
68
+ | `applicationId` | Optional. Set it to adopt a known live app; otherwise the app is found by exact `name`. |
69
+ | `name` | Optional. Defaults to a physical name from the logical id. |
70
+ | `policies` | Reusable Access policy ids, in ascending precedence. The order is declared: a reorder syncs. |
71
+ | `sessionDuration`, `allowedIdps` | Optional. Written on create and on any sync. `sessionDuration` defaults to `24h`. |
72
+ | `autoRedirectToIdentity`, `appLauncherVisible` | Optional. `appLauncherVisible` defaults to `false`. |
73
+ | `saasApp.redirectUris`, `scopes`, `grantTypes` | The OIDC client's settings. |
74
+ | `saasApp.accessTokenLifetime` | `m` or `h` units, `1m` to `24h` (the SDK field doc). |
75
+ | `saasApp.refreshTokenLifetime` | Optional. Needed for the `refresh_tokens` grant. |
76
+ | `saasApp.allowPkceWithoutClientSecret` | `true` = public client. Needs the `authorization_code_with_pkce` grant (refused otherwise). |
77
+
78
+ Attributes: everything above that Cloudflare reports, plus `applicationId`, `aud`, `clientId`,
79
+ `accountId`, `teamDomain`, `domain`, and the OIDC URLs `issuer`
80
+ (`https://<teamDomain>/cdn-cgi/access/sso/oidc/<clientId>`), `authorizationEndpoint`,
81
+ `tokenEndpoint`, `jwksEndpoint`, `userinfoEndpoint` and `configurationEndpoint`.
82
+
83
+ ★ **The team domain is a prop, not a constant.** The openbao copy this came from hardcoded one
84
+ account's. A declared `teamDomain` that disagrees with the host Cloudflare reports in the app's own
85
+ `domain` is refused before any write to an existing app, so the issuer cannot point at a different
86
+ team. When Cloudflare reports no `domain` at all, the team already recorded in the row stands in: a
87
+ declaration that changes it is refused too (the issuer is a declared stable and cannot move).
88
+
89
+ ★ **A first create is checked against the account's organization, when it can be read.** No app
90
+ exists to compare, so before the first write the resource reads the Zero Trust organization
91
+ (`GET /accounts/{account_id}/access/organizations`, the SDK's `listOrganizationsForAccount`; its
92
+ `auth_domain` is the team domain, the same read Alchemy's `Access.Organization` does) and refuses a
93
+ `teamDomain` that differs, with **no write**. It is one attempt, so a token that may not read it does
94
+ not wait on retries.
95
+
96
+ ⚠️ **Known limit: that read needs a token permission the app write does not.** Cloudflare's docs
97
+ (read 2026-10-09, not measured against a live token) give `Access: Organizations Read` (or
98
+ `Access: Organizations, Identity Providers, and Groups Read`) for it, and `Access: Apps and Policies
99
+ Write` for creating the app. Where the token cannot read the organization (a 401 or 403, or no
100
+ organization), the check gives no answer and the create goes on as before: a wrong `teamDomain` then
101
+ writes one app, the post-write check fails the run, and a live app is left with no state row.
102
+ **Cleanup:** the next run finds it by name as `Unowned`; fix the `teamDomain` and declare
103
+ `adopt(true)`, or delete the stray app in the Zero Trust dashboard. **To close the limit**, add
104
+ `Access: Organizations Read` to the token the deploy runs with. Any other failure of the read (a
105
+ throttle, a 5xx) fails the run before a write; run again.
106
+
107
+ ## The client secret
108
+
109
+ ⛔ **It is never read, stored or logged by this package.** The SDK schema's own doc says the secret
110
+ is "only returned on POST request". The create response is decoded by the SDK (which drops it, see
111
+ above); the raw read copies named fields and has no field for it; the attributes have none.
112
+ `saas-oidc-state.test.ts` serialises the whole in-memory state after a create whose fake response
113
+ hands a secret out, and asserts it is absent.
114
+
115
+ ⚠️ **Two paths where the SDK itself holds or prints the raw body** (`@distilled.cloud/cloudflare`
116
+ rc.13, `protocol.ts`), so "never logged" is a claim about this package, not about the SDK:
117
+
118
+ 1. `DISTILLED_DEBUG_HTTP` makes it `console.error` the first 400 characters of every response
119
+ (line 334). A write refuses to run while it is set. **This one stays an operator-only switch:**
120
+ the package can refuse to run, it cannot stop the SDK printing, so never set it for a deploy.
121
+ 2. A response that fails schema validation becomes `CloudflareParseError({ body, cause })`
122
+ (lines 452-457), and `body` is the whole parsed response, a create response with its secret
123
+ included. **Sanitized in this package:** every SDK call of the resource (create, update, list,
124
+ delete, and the organization read) catches that tag at the call and fails with a `SaasOidcError`
125
+ that names the operation and carries no body, no response field and no cause
126
+ (`saas-oidc-error.ts`). `saas-oidc-parse-error.test.ts` forces the error with a fake
127
+ `client_secret` planted and checks the message, the fields, `String(err)`, `JSON.stringify(err)`
128
+ and the printed cause. The raw `GET` is not an SDK call and never builds that error.
129
+ The error exists only under strict response validation (`ResponseValidation.strict`, which
130
+ neither Alchemy nor this kit turns on; the default is lenient). Measured 2026-10-09: rc.13
131
+ decodes a create or update response against `S.Unknown`, so it cannot raise the error for those
132
+ two today; the catch stays so a later SDK that types them cannot start leaking.
133
+
134
+ Neither exposes anything for a public PKCE client, which has no secret.
135
+
136
+ - **A public client has no secret to keep** (`allowPkceWithoutClientSecret: true` with the PKCE
137
+ grant). That is the design for Headlamp (Q6 of the Kubernetes platform decisions).
138
+ - **A confidential client's secret is out of band.** This resource does not capture it. How to
139
+ obtain or rotate it for an existing app is UNVERIFIED here (no documented rotate verb was read);
140
+ homeflare-openbao keeps its live one in the macOS Keychain.
141
+ - UNVERIFIED: whether Cloudflare still returns a `client_secret` on POST for a PKCE-without-secret
142
+ app. It does not matter here, since it is dropped either way.
143
+
144
+ ## Adopting the openbao app, and the state key
145
+
146
+ ⛔ **The type id `HomeFlare.Access.SaasOidcApplication` is kept verbatim.** Alchemy state rows are
147
+ keyed by it, and homeflare-openbao's live `OpenBaoOidcSaas` row was written under it. A different
148
+ id plans a second app (new client id, new `aud`) beside the orphaned live one. The state test
149
+ seeds a row in the old shape (no `teamDomain`) and proves the next deploy updates it in place:
150
+ no create, no delete, the same application and client ids.
151
+
152
+ - State written before `teamDomain` existed still reads: the host from the app's own `domain`
153
+ stands in until the next reconcile stores the declared one.
154
+ - homeflare-openbao's stack must merge this package's `providers()` (the kit's collection) where it
155
+ merged `Cloudflare.providers()` for this type, and add `teamDomain` to its declaration.
156
+ - An **owned** row whose app was deleted out of band reads as gone, never as a same-named app; the
157
+ next reconcile then creates a new one (a new client id). The name fallback is for a cold read.
158
+ - A live app is `Unowned` until the declaration says `adopt(true)`, like every kit resource.
159
+
160
+ ## What a change does
161
+
162
+ - **The issuer and every endpoint are `stables`**: an update (a redirect URI, a policy) leaves
163
+ `app.issuer` and the others resolved for consumers at plan time, so a Talos
164
+ `KubeAuthenticationConfig` that reads them is not re-planned. `teamDomain` is deliberately not
165
+ stable (the openbao row has no such attribute). `saas-oidc-stable.test.ts` runs the engine.
166
+
167
+ - **Created** when no app has the id or the exact name. Names are not unique in Access, so two
168
+ saas apps with the declared name are refused: set `applicationId`.
169
+ - **Synced** (one PUT of the whole declaration) when the name, auth type, redirect URIs, scopes,
170
+ grant types, either token lifetime, the PKCE flag or the policies differ. Order is ignored for
171
+ redirect URIs, scopes and grant types. **It is not ignored for `policies`:** they are declared in
172
+ ascending precedence, so a reorder is drift (the live order is read from each policy's
173
+ `precedence`).
174
+ - ⚠️ **Not drift-checked:** `sessionDuration`, `allowedIdps`, `autoRedirectToIdentity`,
175
+ `appLauncherVisible`, `appLauncherUrl`. They are written on create and on any sync, but a change
176
+ to only those does not trigger one. This is carried over from the openbao copy unchanged.
177
+ - A matching live app is **zero writes**.
178
+ - An `applicationId` that is not a `saas` app is refused, never rewritten.
179
+ - Retries follow Alchemy's own `retryTransientAccessError` (same file as above): a freshly created
180
+ policy still propagating, and 403 as Cloudflare's throttle. About 45 seconds, then it fails.
181
+
182
+ ⛔ **Removal policy: `retain` by default**, because deleting the app deletes the client id every
183
+ relying party is configured with. `delete` stays fully implemented (an app already gone is a
184
+ success): opt in with `.pipe(RemovalPolicy.destroy())`. `list` is empty and `nuke` is skipped:
185
+ Alchemy's own `Access.Application` already lists every Access app, saas ones included.
@@ -72,7 +72,7 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
72
72
  in lifecycle operations; it is not a separate credentials-layer prohibition. The same
73
73
  shape in NetBox is therefore not an S20 follow-up. See [LiteLLM's Q6 resolution](./litellm.md#credentials).
74
74
  11. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
75
- `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
75
+ `R2BucketLock`, `MeshNode` and `CloudflaredTunnel` declare `nuke`. The constructor already defaults `list`
76
76
  (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
77
77
  where nuke must never reach the object.
78
78
  12. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
@@ -66,6 +66,9 @@ then tidiness.
66
66
  type string `Cloudflare.MeshNode` sits inside upstream's own namespace (H14).
67
67
  **Options:** contribute `ha` upstream and raise the token-in-state concern, then switch;
68
68
  or keep the house resource as a recorded divergence. **Decision:** maintainer.
69
+ `cloudflare/CloudflaredTunnel` is the same divergence for `Cloudflare.Tunnel.Tunnel`
70
+ ([cloudflared-tunnel.md](./cloudflared-tunnel.md)): the token in state is the concern to raise
71
+ upstream, and its type string `Cloudflare.CloudflaredTunnel` also sits in upstream's namespace (H14).
69
72
  4. **Unguarded `Bun.*`, and the `node:*` modules upstream names, in shipped provider code**
70
73
  (S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
71
74
  under Node, which is the runtime this package promises its consumers. The `node:*` paths
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.48.1",
3
+ "version": "0.50.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -55,6 +55,10 @@
55
55
  "types": "./dist/talos/index.d.ts",
56
56
  "default": "./dist/talos/index.js"
57
57
  },
58
+ "./kubernetes": {
59
+ "types": "./dist/kubernetes/index.d.ts",
60
+ "default": "./dist/kubernetes/index.js"
61
+ },
58
62
  "./proxmox": {
59
63
  "types": "./dist/proxmox/index.d.ts",
60
64
  "default": "./dist/proxmox/index.js"
@@ -181,6 +185,8 @@
181
185
  "@effect/platform-bun": "4.0.1",
182
186
  "@effect/platform-node": "4.0.1",
183
187
  "@effect/platform-node-shared": "4.0.1",
188
+ "@effect/sql-d1": "4.0.1",
189
+ "@effect/sql-sqlite-do": "4.0.1",
184
190
  "rolldown": "1.2.8",
185
191
  "redis": "6.3.0"
186
192
  },