1import * as calls from "@distilled.cloud/cloudflare/calls";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Calls.TurnKey" as const;
14type TypeId = typeof TypeId;
15
16export type TurnKeyProps = {
17 /**
18 * A short description of the TURN key, not shown to end users and not
19 * unique. Mutable in place. If omitted, a unique name is generated from
20 * the app, stage, and logical ID.
21 * @default ${app}-${stage}-${id}
22 */
23 name?: string;
24};
25
26export type TurnKeyAttributes = {
27 /**
28 * Cloudflare-generated unique identifier for the TURN key. Used in the
29 * credential-minting API path
30 * (`https://rtc.live.cloudflare.com/v1/turn/keys/{keyId}/credentials/generate`).
31 */
32 keyId: string;
33 /**
34 * The Cloudflare account the TURN key belongs to.
35 */
36 accountId: string;
37 /**
38 * TURN key secret (bearer token) used to mint short-lived TURN
39 * credentials. Returned only at creation time and never re-readable —
40 * Alchemy persists it in state and carries it forward across updates.
41 */
42 key: Redacted.Redacted<string>;
43 /**
44 * A short description of the TURN key.
45 */
46 name: string;
47 /**
48 * When the TURN key was created.
49 */
50 created: string;
51 /**
52 * When the TURN key was last modified.
53 */
54 modified: string;
55};
56
57export type TurnKey = Resource<
58 TypeId,
59 TurnKeyProps,
60 TurnKeyAttributes,
61 never,
62 Providers
63>;
64
65/**
66 * A Cloudflare Realtime (formerly "Calls") TURN key.
67 *
68 * A TURN key authenticates your backend against Cloudflare's managed TURN
69 * service: you exchange the create-only `key` (a bearer token) for
70 * short-lived TURN credentials that WebRTC clients use to relay traffic
71 * through Cloudflare's network. The only configurable property is the
72 * human-readable `name`, which is mutable in place.
73 * ### Creating a TURN key
74 * **Example:** TURN key with a generated name
75 * ```typescript
76 * const turnKey = yield* Cloudflare.Calls.TurnKey("turn", {});
77 * ```
78 *
79 * **Example:** TURN key with an explicit name
80 * ```typescript
81 * const turnKey = yield* Cloudflare.Calls.TurnKey("turn", {
82 * name: "my-turn-key",
83 * });
84 * ```
85 *
86 * ### Using the key
87 * **Example:** Minting TURN credentials server-side
88 * ```typescript
89 * // keyId is public — it appears in the credential-minting URL:
90 * const keyId = turnKey.keyId;
91 *
92 * // The key is redacted — POST it as a bearer token to
93 * // https://rtc.live.cloudflare.com/v1/turn/keys/{keyId}/credentials/generate
94 * const apiToken = turnKey.key; // Redacted<string>
95 * ```
96 *
97 * @see https://developers.cloudflare.com/realtime/turn/
98 *
99 * @resource
100 * @product Calls
101 * @category Media
102 */
103export const TurnKey = Resource<TurnKey>(TypeId);
104
105/**
106 * Returns true if the given value is a TurnKey resource.
107 */
108export const isTurnKey = (value: unknown): value is TurnKey =>
109 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
110
111export const TurnKeyProvider = () =>
112 Provider.succeed(TurnKey, {
113 stables: ["keyId", "accountId", "key", "created"],
114 diff: Effect.fn(function* ({ output }) {
115 const { accountId } = yield* yield* CloudflareEnvironment;
116 if (output !== undefined && output.accountId !== accountId) {
117 return { action: "replace" } as const;
118 }
119 return undefined;
120 }),
121 read: Effect.fn(function* ({ output }) {
122 // The TURN key secret is returned only at creation time, so a key
123 // cannot be re-hydrated without prior state — no cold read /
124 // adoption path.
125 if (!output?.keyId) return undefined;
126 const observed = yield* getTurnKey(output.accountId, output.keyId);
127 return observed
128 ? toAttributes(observed, output.accountId, output.key)
129 : undefined;
130 }),
131 list: Effect.fn(function* () {
132 const { accountId } = yield* yield* CloudflareEnvironment;
133 // Account-scoped collection: paginate the TURN keys list exhaustively.
134 // The list response carries every readable field (uid/name/created/
135 // modified) — the bearer `key` is returned only at creation and is
136 // never re-readable, so we hydrate it as an empty redacted value to
137 // match exactly what `read` would produce on a cold (stateless) read.
138 return yield* calls.listTurns.pages({ accountId }).pipe(
139 Stream.runCollect,
140 Effect.map((chunk) =>
141 Array.from(chunk).flatMap((page) =>
142 (page.result ?? []).map((turnKey) =>
143 toAttributes(turnKey, accountId, Redacted.make("")),
144 ),
145 ),
146 ),
147 );
148 }),
149 reconcile: Effect.fn(function* ({ id, news, output }) {
150 const { accountId } = yield* yield* CloudflareEnvironment;
151 const name = yield* createTurnKeyName(id, news.name);
152
153 // Observe — the keyId cached on `output` is a hint, not a guarantee:
154 // a missing key (code 20008) falls through and we recreate.
155 const observed = output?.keyId
156 ? yield* getTurnKey(output.accountId ?? accountId, output.keyId)
157 : undefined;
158
159 if (!observed || !output) {
160 // Ensure — greenfield (or out-of-band delete): create and capture
161 // the create-only key. Names are not unique on Cloudflare's side,
162 // so there is no AlreadyExists race to tolerate.
163 const created = yield* calls.createTurn({ accountId, name });
164 return toAttributes(
165 created,
166 accountId,
167 Redacted.make(created.key ?? ""),
168 );
169 }
170
171 // Sync — the only mutable aspect is `name`; diff observed cloud
172 // state against desired and skip the PUT entirely on a no-op.
173 if (observed.name === name) {
174 return toAttributes(observed, output.accountId, output.key);
175 }
176 const updated = yield* calls.updateTurn({
177 accountId: output.accountId,
178 keyId: output.keyId,
179 name,
180 });
181 return toAttributes(updated, output.accountId, output.key);
182 }),
183 delete: Effect.fn(function* ({ output }) {
184 yield* calls
185 .deleteTurn({ accountId: output.accountId, keyId: output.keyId })
186 .pipe(Effect.catchTag("TurnKeyNotFound", () => Effect.void));
187 }),
188 });
189
190/**
191 * Read a TURN key by id, mapping "gone" (`TurnKeyNotFound`, Cloudflare
192 * error code 20008) to `undefined`.
193 */
194const getTurnKey = (accountId: string, keyId: string) =>
195 calls
196 .getTurn({ accountId, keyId })
197 .pipe(Effect.catchTag("TurnKeyNotFound", () => Effect.succeed(undefined)));
198
199const createTurnKeyName = (id: string, name: string | undefined) =>
200 Effect.gen(function* () {
201 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
202 });
203
204const toAttributes = (
205 turnKey:
206 | calls.GetTurnResponse
207 | calls.CreateTurnResponse
208 | calls.UpdateTurnResponse,
209 accountId: string,
210 key: Redacted.Redacted<string>,
211): TurnKeyAttributes => ({
212 keyId: turnKey.uid ?? "",
213 accountId,
214 key,
215 name: turnKey.name ?? "",
216 created: turnKey.created ?? "",
217 modified: turnKey.modified ?? "",
218});