| 1 | import * as stream from "@distilled.cloud/cloudflare/stream"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.Stream.SigningKey" as const; |
| 13 | type TypeId = typeof TypeId; |
| 14 | |
| 15 | export type SigningKeyProps = {}; |
| 16 | |
| 17 | export 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 | |
| 42 | export 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 | */ |
| 78 | export const SigningKey = Resource<SigningKey>(TypeId); |
| 79 | |
| 80 | /** |
| 81 | * Returns true if the given value is a SigningKey resource. |
| 82 | */ |
| 83 | export const isSigningKey = (value: unknown): value is SigningKey => |
| 84 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 85 | |
| 86 | export 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 | */ |
| 181 | const 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 | ); |