1import * as stream from "@distilled.cloud/cloudflare/stream";
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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Stream.SigningKey" as const;
13type TypeId = typeof TypeId;
14
15export type SigningKeyProps = {};
16
17export type SigningKeyAttributes = {
18 /**
19 * The unique identifier of the signing key.
20 */
21 keyId: string;
22 /**
23 * The Cloudflare account the signing key belongs to.
24 */
25 accountId: string;
26 /**
27 * The date and time the signing key was created.
28 */
29 created: string | undefined;
30 /**
31 * The signing key in PEM format. Only returned by Cloudflare at
32 * creation time — preserved in state thereafter.
33 */
34 pem: Redacted.Redacted<string>;
35 /**
36 * The signing key in JWK format. Only returned by Cloudflare at
37 * creation time — preserved in state thereafter.
38 */
39 jwk: Redacted.Redacted<string>;
40};
41
42export type SigningKey = Resource<
43 TypeId,
44 SigningKeyProps,
45 SigningKeyAttributes,
46 never,
47 Providers
48>;
49
50/**
51 * A Cloudflare Stream signing key — an RSA key pair used to sign viewer
52 * playback tokens for videos that require signed URLs.
53 *
54 * The key material (`pem`/`jwk`) is returned by Cloudflare **only at
55 * creation time**; it is persisted as redacted attributes in state and
56 * can never be re-read from the API. If the key is deleted out-of-band,
57 * reconcile creates a brand-new key with new material — anything derived
58 * from the old key (signed tokens) must be re-derived from the new
59 * attributes.
60 *
61 * Requires the Stream subscription to be enabled on the account.
62 * ### Creating a signing key
63 * **Example:** Signing key for signed playback URLs
64 * ```typescript
65 * const key = yield* Cloudflare.Stream.SigningKey("PlaybackKey", {});
66 *
67 * // key.pem / key.jwk are Redacted<string> — use them server-side to
68 * // sign playback tokens for videos with requireSignedURLs enabled.
69 * const pem = key.pem;
70 * ```
71 *
72 * @see https://developers.cloudflare.com/stream/viewing-videos/securing-your-stream/
73 *
74 * @resource
75 * @product Stream
76 * @category Media
77 */
78export const SigningKey = Resource<SigningKey>(TypeId);
79
80/**
81 * Returns true if the given value is a SigningKey resource.
82 */
83export const isSigningKey = (value: unknown): value is SigningKey =>
84 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
85
86export const SigningKeyProvider = () =>
87 Provider.succeed(SigningKey, {
88 stables: ["keyId", "accountId", "created", "pem", "jwk"],
89
90 list: Effect.fn(function* () {
91 const { accountId } = yield* yield* CloudflareEnvironment;
92 // Account-scoped collection: the keys list endpoint only returns
93 // `id` + `created`. The key material (pem/jwk) is create-only and
94 // can never be re-read, so — matching `read` for a key with no
95 // cached state — we surface it as empty redacted values.
96 return yield* stream.getKey.pages({ accountId }).pipe(
97 Stream.runCollect,
98 Effect.map((chunk) =>
99 Array.from(chunk).flatMap((page) =>
100 (page.result ?? [])
101 .filter(
102 (key): key is stream.KeysGetResultItem & { id: string } =>
103 typeof key.id === "string",
104 )
105 .map(
106 (key) =>
107 ({
108 keyId: key.id,
109 accountId,
110 created: key.created ?? undefined,
111 pem: Redacted.make(""),
112 jwk: Redacted.make(""),
113 }) satisfies SigningKeyAttributes,
114 ),
115 ),
116 ),
117 // Accounts without the Stream subscription are forbidden from the
118 // keys endpoint — treat as an empty collection.
119 Effect.catchTag("Forbidden", () => Effect.succeed([])),
120 );
121 }),
122
123 diff: Effect.fn(function* ({ output }) {
124 const { accountId } = yield* yield* CloudflareEnvironment;
125 if (output !== undefined && output.accountId !== accountId) {
126 return { action: "replace" } as const;
127 }
128 // No mutable props.
129 return undefined;
130 }),
131
132 read: Effect.fn(function* ({ output }) {
133 // The list endpoint only returns `id` + `created` — the key
134 // material is create-only, so without cached state there is
135 // nothing to recover.
136 if (output?.keyId === undefined) return undefined;
137 const found = yield* findKey(output.accountId, output.keyId);
138 // Preserve the stored pem/jwk; the API can never return them again.
139 return found ? output : undefined;
140 }),
141
142 reconcile: Effect.fn(function* ({ output }) {
143 const { accountId } = yield* yield* CloudflareEnvironment;
144
145 // Observe — existence-only resource: check the key is still listed.
146 const exists = output?.keyId
147 ? yield* findKey(output.accountId ?? accountId, output.keyId)
148 : false;
149
150 if (exists && output !== undefined) {
151 // Nothing mutable to sync — keep the cached attributes (they
152 // carry the create-only key material).
153 return output;
154 }
155
156 // Ensure — create a new key (`createKey` takes an empty body).
157 const created = yield* stream.createKey({ accountId });
158 return {
159 keyId: created.id ?? "",
160 accountId,
161 created: created.created ?? undefined,
162 pem: Redacted.make(created.pem ?? ""),
163 jwk: Redacted.make(created.jwk ?? ""),
164 } satisfies SigningKeyAttributes;
165 }),
166
167 delete: Effect.fn(function* ({ output }) {
168 // Idempotent — a key already deleted out-of-band surfaces as
169 // `SigningKeyNotFound` (Cloudflare error code 10003).
170 yield* stream
171 .deleteKey({ accountId: output.accountId, identifier: output.keyId })
172 .pipe(Effect.catchTag("SigningKeyNotFound", () => Effect.void));
173 }),
174 });
175
176/**
177 * True when the signing key with the given id is present in the
178 * account's key list. Cloudflare returns `result: null` (not `[]`) when
179 * the account has no keys.
180 */
181const findKey = (accountId: string, keyId: string) =>
182 stream
183 .getKey({ accountId })
184 .pipe(
185 Effect.map((response) =>
186 (response.result ?? []).some((key) => key.id === keyId),
187 ),
188 );