| 1 | --- |
| 2 | title: 2.0.0-beta.49 - DNS Bindings & Output.flatMap |
| 3 | date: 2026-06-01T19:00:00Z |
| 4 | category: release |
| 5 | excerpt: v2.0.0-beta.49 adds runtime DNS record bindings, an `Output.flatMap` combinator (plus method-style `.map`/`.flatMap`), and a hotfix that ensures `Config` values yielded in a Worker are always bound as `secret_text`, not plain text. |
| 6 | --- |
| 7 | |
| 8 | :::caution |
| 9 | **Security hotfix — upgrade if you bind secrets via `Config`.** |
| 10 | Some `Config` values `yield*`ed in a Worker's Init phase were |
| 11 | being bound as **plain text** instead of `secret_text`. beta.49 |
| 12 | ensures every yielded `Config` is bound as `secret_text`. If |
| 13 | you've deployed secrets through `Config` on an earlier beta, |
| 14 | upgrade and redeploy so they're stored encrypted. |
| 15 | ::: |
| 16 | |
| 17 | `v2.0.0-beta.49` ships runtime **DNS record bindings**, a new |
| 18 | **`Output.flatMap`** combinator, and the `secret_text` fix above. |
| 19 | |
| 20 | ## DNS record bindings |
| 21 | |
| 22 | Same ergonomics as the [Tunnel bindings](/blog/2026-06-01-beta-48) |
| 23 | from beta.48: bind a `Zone` once in Init, and a Worker can manage |
| 24 | that zone's DNS records at runtime through a typed client. Calls |
| 25 | take no `zoneId` — the bound zone fixes it. |
| 26 | |
| 27 | ```typescript |
| 28 | const Zone = Cloudflare.Zone.Zone("MyZone", { name: "example.com" }); |
| 29 | |
| 30 | export class Worker extends Cloudflare.Worker<Worker>()( |
| 31 | "Worker", |
| 32 | { main: import.meta.filename }, |
| 33 | Effect.gen(function* () { |
| 34 | // bind the Zone (it's an Effect) — or `yield* Zone` first |
| 35 | const dns = yield* Cloudflare.DnsReadWrite.bind(Zone); |
| 36 | |
| 37 | return { |
| 38 | fetch: Effect.gen(function* () { |
| 39 | const { result } = yield* dns.createDnsRecord({ |
| 40 | type: "A", |
| 41 | name: "app.example.com", |
| 42 | content: "192.0.2.1", |
| 43 | ttl: 1, |
| 44 | }); |
| 45 | yield* dns.deleteDnsRecord(result.id); |
| 46 | return yield* HttpServerResponse.json({ id: result.id }); |
| 47 | }), |
| 48 | }; |
| 49 | }).pipe(Effect.provide(Cloudflare.DnsReadWriteLive)), |
| 50 | ) {} |
| 51 | ``` |
| 52 | |
| 53 | Pick the surface you need: |
| 54 | |
| 55 | - `Cloudflare.DNS.ReadDns` — `getDnsRecord`, `listDnsRecords` |
| 56 | - `Cloudflare.DNS.WriteDns` — `createDnsRecord`, `updateDnsRecord`, `patchDnsRecord`, `deleteDnsRecord`, `batchDnsRecords` |
| 57 | - `Cloudflare.DNS.ReadWriteDns` — the full record CRUD surface |
| 58 | |
| 59 | :::note[Under the covers] |
| 60 | Like the Tunnel bindings, binding a DNS client creates a |
| 61 | least-privilege `AccountApiToken` for the Worker, scoped to that |
| 62 | **single zone** with just the `DNS Read` / `DNS Write` |
| 63 | permissions, and binds its value (plus the zone id) into the |
| 64 | Worker as `secret_text`. The policy resource is the zone itself, |
| 65 | derived from the zone's id: |
| 66 | |
| 67 | ```typescript |
| 68 | resources: { |
| 69 | [`com.cloudflare.api.account.zone.${zoneId}`]: "*", |
| 70 | } |
| 71 | ``` |
| 72 | ::: |
| 73 | |
| 74 | ([#511](https://github.com/alchemy-run/alchemy/pull/511)) |
| 75 | |
| 76 | ## `Output.flatMap` |
| 77 | |
| 78 | `Output.flatMap` resolves an `Output<A>`, hands the value to `f`, |
| 79 | and flattens the `Output<B>` that `f` returns — the missing piece |
| 80 | alongside `Output.map` and `Output.mapEffect`: |
| 81 | |
| 82 | ```typescript |
| 83 | const expr = zone.zoneId.pipe( |
| 84 | Output.flatMap((zoneId) => |
| 85 | Output.interpolate`com.cloudflare.api.account.zone.${zoneId}`, |
| 86 | ), |
| 87 | ); |
| 88 | ``` |
| 89 | |
| 90 | The combinators are now also callable as **methods** directly on |
| 91 | an `Output`, with `apply`/`effect` kept as aliases: |
| 92 | |
| 93 | ```typescript |
| 94 | output.map(fn); // ApplyExpr |
| 95 | output.mapEffect(fn); // EffectExpr |
| 96 | output.flatMap(fn); // FlatMapExpr |
| 97 | ``` |
| 98 | |
| 99 | Like the other combinators, `flatMap` short-circuits while its |
| 100 | source is unresolved, so it composes cleanly in the deploy graph. |
| 101 | ([#512](https://github.com/alchemy-run/alchemy/pull/512)) |
| 102 | |
| 103 | ## The `secret_text` fix in detail |
| 104 | |
| 105 | Two related changes land the fix: |
| 106 | |
| 107 | - **Yielded `Config` always binds as `secret_text`** — the |
| 108 | Worker now records every `Config` resolved in Init as a secret |
| 109 | binding, so a value can't slip through as plain text |
| 110 | ([#510](https://github.com/alchemy-run/alchemy/pull/510)). |
| 111 | - **Don't unwrap non-redacted values** — when reading a bound |
| 112 | value back at runtime, Alchemy now only calls `Redacted.value` |
| 113 | on values that are actually `Redacted`, leaving plain values |
| 114 | untouched. |
| 115 | |
| 116 | ## Where to go next |
| 117 | |
| 118 | - [Concepts › Secrets and Config](/environments/secrets) |
| 119 | - [beta.48 — Tunnel bindings](/blog/2026-06-01-beta-48) |
| 120 | - [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta49) |
| 121 | - [Compare v2.0.0-beta.48 → v2.0.0-beta.49](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.48...v2.0.0-beta.49) |