1import * as ivs from "@distilled.cloud/aws/ivs";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import { retryWhileThrottled, syncIvsTags, toTagRecord } from "./internal.ts";
14
15export interface PlaybackKeyPairProps {
16 /**
17 * The PEM-encoded ECDSA P-384 public key to import. The corresponding
18 * private key is held by the caller and used to sign viewer playback
19 * authorization tokens for private channels. Changing the key material
20 * replaces the key pair (the fingerprint and ARN change).
21 */
22 publicKeyMaterial: string;
23 /**
24 * Name of the playback key pair. If omitted, a deterministic physical
25 * name is generated. Key pairs have no update operation, so changing
26 * the name replaces the key pair.
27 */
28 playbackKeyPairName?: string;
29 /**
30 * Tags to apply to the key pair. Merged with internal Alchemy tags.
31 */
32 tags?: Record<string, string>;
33}
34
35export interface PlaybackKeyPair extends Resource<
36 "AWS.IVS.PlaybackKeyPair",
37 PlaybackKeyPairProps,
38 {
39 /**
40 * The key pair's physical name.
41 */
42 playbackKeyPairName: string;
43 /**
44 * ARN of the imported playback key pair.
45 */
46 playbackKeyPairArn: string;
47 /**
48 * Fingerprint of the imported public key.
49 */
50 fingerprint: string | undefined;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * An Amazon IVS playback key pair for private channels.
58 *
59 * Import the public half of an ECDSA P-384 key pair; sign viewer playback
60 * authorization tokens with the private half. Channels created with
61 * `authorized: true` require viewers to present a token signed by an
62 * imported key pair.
63 * ### Importing a Key Pair
64 * **Example:** Private Channel Playback Authorization
65 * ```typescript
66 * import * as IVS from "alchemy/AWS/IVS";
67 *
68 * const keyPair = yield* IVS.PlaybackKeyPair("ViewerAuth", {
69 * publicKeyMaterial: PUBLIC_KEY_PEM, // ECDSA P-384 public key
70 * });
71 * const channel = yield* IVS.Channel("PrivateChannel", {
72 * authorized: true,
73 * });
74 * ```
75 *
76 * @resource
77 */
78export const PlaybackKeyPair = Resource<PlaybackKeyPair>(
79 "AWS.IVS.PlaybackKeyPair",
80);
81
82/**
83 * Raised when the IVS API returns a playback key pair missing its ARN or
84 * name.
85 */
86export class IvsPlaybackKeyPairIncomplete extends Data.TaggedError(
87 "IvsPlaybackKeyPairIncomplete",
88)<{ message: string }> {}
89
90/**
91 * DeletePlaybackKeyPair intermittently returns InternalServerException even
92 * though a subsequent attempt succeeds. Retry only that typed transient error
93 * on a bounded schedule; other delete errors still fail immediately.
94 */
95const retryDeleteInternalServer = <A, E extends { readonly _tag: string }, R>(
96 self: Effect.Effect<A, E, R>,
97): Effect.Effect<A, E, R> =>
98 Effect.retry(self, {
99 while: (error) => error._tag === "InternalServerException",
100 schedule: Schedule.max([
101 Schedule.exponential("500 millis"),
102 Schedule.recurs(6),
103 ]),
104 });
105
106export const PlaybackKeyPairProvider = () =>
107 Provider.effect(
108 PlaybackKeyPair,
109 Effect.gen(function* () {
110 const toName = (
111 id: string,
112 props: { playbackKeyPairName?: string | undefined },
113 ) =>
114 props.playbackKeyPairName
115 ? Effect.succeed(props.playbackKeyPairName)
116 : createPhysicalName({ id, maxLength: 128 });
117
118 const toAttrs = Effect.fn(function* (keyPair: ivs.PlaybackKeyPair) {
119 if (!keyPair.arn || !keyPair.name) {
120 return yield* Effect.fail(
121 new IvsPlaybackKeyPairIncomplete({
122 message: "IVS playback key pair is missing its ARN or name",
123 }),
124 );
125 }
126 return {
127 playbackKeyPairName: keyPair.name,
128 playbackKeyPairArn: keyPair.arn,
129 fingerprint: keyPair.fingerprint,
130 };
131 });
132
133 const getByArn = Effect.fn(function* (arn: string) {
134 const response = yield* ivs.getPlaybackKeyPair({ arn }).pipe(
135 retryWhileThrottled,
136 Effect.catchTag("ResourceNotFoundException", () =>
137 Effect.succeed(undefined),
138 ),
139 );
140 return response?.keyPair;
141 });
142
143 /**
144 * ListPlaybackKeyPairs has no name filter — enumerate and match
145 * exactly (key pair names are unique per account/region).
146 */
147 const findByName = Effect.fn(function* (name: string) {
148 const summaries = yield* ivs.listPlaybackKeyPairs.pages({}).pipe(
149 Stream.runCollect,
150 Effect.map((chunk) =>
151 Array.from(chunk).flatMap((page) => page.keyPairs),
152 ),
153 retryWhileThrottled,
154 );
155 const match = summaries.find((s) => s.name === name && s.arn);
156 return match?.arn ? yield* getByArn(match.arn) : undefined;
157 });
158
159 return {
160 stables: ["playbackKeyPairName", "playbackKeyPairArn", "fingerprint"],
161
162 read: Effect.fn(function* ({ id, olds, output }) {
163 const keyPair = output?.playbackKeyPairArn
164 ? yield* getByArn(output.playbackKeyPairArn)
165 : yield* findByName(yield* toName(id, olds ?? {}));
166 if (keyPair === undefined) return undefined;
167 const attrs = yield* toAttrs(keyPair);
168 return (yield* hasAlchemyTags(id, toTagRecord(keyPair.tags)))
169 ? attrs
170 : Unowned(attrs);
171 }),
172
173 diff: Effect.fn(function* ({ id, news, olds }) {
174 if (!isResolved(news)) return undefined;
175 // There is no UpdatePlaybackKeyPair — both the key material and
176 // the name are immutable. Key pair names are unique, so replacing
177 // the material under the SAME name must delete the old key pair
178 // first (create-before-delete would find the old one by name and
179 // wrongly adopt it).
180 if (olds.publicKeyMaterial !== news.publicKeyMaterial) {
181 const sameName =
182 (yield* toName(id, olds)) === (yield* toName(id, news));
183 return { action: "replace", deleteFirst: sameName } as const;
184 }
185 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
186 return { action: "replace" } as const;
187 }
188 }),
189
190 reconcile: Effect.fn(function* ({ id, news, output, session }) {
191 const name = yield* toName(id, news);
192 const internalTags = yield* createInternalTags(id);
193 const desiredTags = { ...internalTags, ...news.tags };
194
195 // 1. Observe.
196 let observed = output?.playbackKeyPairArn
197 ? yield* getByArn(output.playbackKeyPairArn)
198 : yield* findByName(name);
199
200 // 2. Ensure — import if missing. A ConflictException means the
201 // name already exists (a race with a peer reconciler or a
202 // state-persistence failure): adopt it by name.
203 if (observed === undefined) {
204 observed = yield* ivs
205 .importPlaybackKeyPair({
206 publicKeyMaterial: news.publicKeyMaterial,
207 name,
208 tags: desiredTags,
209 })
210 .pipe(
211 retryWhileThrottled,
212 Effect.map((r) => r.keyPair),
213 Effect.catchTag("ConflictException", () => findByName(name)),
214 );
215 }
216 const arn = observed?.arn;
217 if (observed === undefined || arn === undefined) {
218 return yield* Effect.fail(
219 new IvsPlaybackKeyPairIncomplete({
220 message: `IVS playback key pair '${name}' could not be imported or found`,
221 }),
222 );
223 }
224
225 // 3. Sync tags — the only mutable aspect.
226 yield* syncIvsTags(arn, desiredTags);
227
228 // 4. Return fresh attributes.
229 const final = yield* getByArn(arn);
230 if (final === undefined) {
231 return yield* Effect.fail(
232 new IvsPlaybackKeyPairIncomplete({
233 message: `IVS playback key pair '${arn}' vanished during reconcile`,
234 }),
235 );
236 }
237 yield* session.note(arn);
238 return yield* toAttrs(final);
239 }),
240
241 delete: Effect.fn(function* ({ output }) {
242 yield* ivs
243 .deletePlaybackKeyPair({ arn: output.playbackKeyPairArn })
244 .pipe(
245 retryWhileThrottled,
246 retryDeleteInternalServer,
247 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
248 );
249 }),
250
251 list: () =>
252 ivs.listPlaybackKeyPairs.pages({}).pipe(
253 Stream.runCollect,
254 Effect.map((chunk) =>
255 Array.from(chunk).flatMap((page) => page.keyPairs),
256 ),
257 Effect.flatMap(
258 Effect.forEach(
259 (summary) =>
260 summary.arn === undefined
261 ? Effect.succeed(undefined)
262 : getByArn(summary.arn).pipe(
263 Effect.flatMap((keyPair) =>
264 keyPair === undefined
265 ? Effect.succeed(undefined)
266 : toAttrs(keyPair),
267 ),
268 ),
269 { concurrency: 5 },
270 ),
271 ),
272 Effect.map((items) => items.filter((item) => item !== undefined)),
273 ),
274 };
275 }),
276 );