1import * as ivs from "@distilled.cloud/aws/ivs";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import type * as Redacted from "effect/Redacted";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import { toRedactedString } from "../IAM/common.ts";
11import type { Providers } from "../Providers.ts";
12import { retryWhileThrottled, syncIvsTags, toTagRecord } from "./internal.ts";
13
14export interface StreamKeyProps {
15 /**
16 * ARN of the channel the stream key authorizes broadcasts to.
17 * Changing the channel replaces the stream key.
18 */
19 channelArn: string;
20 /**
21 * Tags to apply to the stream key. Merged with internal Alchemy tags.
22 */
23 tags?: Record<string, string>;
24}
25
26export interface StreamKey extends Resource<
27 "AWS.IVS.StreamKey",
28 StreamKeyProps,
29 {
30 /**
31 * ARN of the stream key.
32 */
33 streamKeyArn: string;
34 /**
35 * ARN of the channel the stream key authorizes broadcasts to.
36 */
37 channelArn: string;
38 /**
39 * The secret stream key value used by broadcast software to
40 * authenticate against the channel's ingest endpoint.
41 */
42 value: Redacted.Redacted<string> | undefined;
43 },
44 never,
45 Providers
46> {}
47
48/**
49 * An Amazon IVS stream key — the secret credential broadcast software
50 * uses to authenticate against a channel's ingest endpoint.
51 *
52 * IVS allows at most one stream key per channel, and `CreateChannel`
53 * provisions one automatically. This resource therefore *manages the
54 * channel's stream key*: if the channel already has its auto-created key,
55 * the resource takes ownership of it (tagging it with Alchemy's internal
56 * tags) instead of failing the per-channel quota.
57 * ### Creating Stream Keys
58 * **Example:** Stream Key for a Channel
59 * ```typescript
60 * import * as IVS from "alchemy/AWS/IVS";
61 *
62 * const channel = yield* IVS.Channel("LiveChannel");
63 * const streamKey = yield* IVS.StreamKey("LiveKey", {
64 * channelArn: channel.channelArn,
65 * });
66 * ```
67 *
68 * @resource
69 */
70export const StreamKey = Resource<StreamKey>("AWS.IVS.StreamKey");
71
72/**
73 * Raised when the IVS API returns a stream key missing its ARN or channel
74 * ARN.
75 */
76export class IvsStreamKeyIncomplete extends Data.TaggedError(
77 "IvsStreamKeyIncomplete",
78)<{ message: string }> {}
79
80export const StreamKeyProvider = () =>
81 Provider.effect(
82 StreamKey,
83 Effect.gen(function* () {
84 const toAttrs = Effect.fn(function* (streamKey: ivs.StreamKey) {
85 if (!streamKey.arn || !streamKey.channelArn) {
86 return yield* Effect.fail(
87 new IvsStreamKeyIncomplete({
88 message: "IVS stream key is missing its ARN or channel ARN",
89 }),
90 );
91 }
92 return {
93 streamKeyArn: streamKey.arn,
94 channelArn: streamKey.channelArn,
95 value: toRedactedString(streamKey.value),
96 };
97 });
98
99 const getByArn = Effect.fn(function* (arn: string) {
100 const response = yield* ivs.getStreamKey({ arn }).pipe(
101 retryWhileThrottled,
102 Effect.catchTag("ResourceNotFoundException", () =>
103 Effect.succeed(undefined),
104 ),
105 );
106 return response?.streamKey;
107 });
108
109 /**
110 * A channel has at most one stream key, so "the channel's stream
111 * key" is a well-defined lookup. Tolerates a deleted channel (the
112 * key is gone with it).
113 */
114 const findByChannel = Effect.fn(function* (channelArn: string) {
115 const page = yield* ivs.listStreamKeys({ channelArn }).pipe(
116 retryWhileThrottled,
117 Effect.catchTag("ResourceNotFoundException", () =>
118 Effect.succeed(undefined),
119 ),
120 );
121 const arn = page?.streamKeys.find((s) => s.arn !== undefined)?.arn;
122 return arn === undefined ? undefined : yield* getByArn(arn);
123 });
124
125 return {
126 stables: ["streamKeyArn", "channelArn"],
127
128 read: Effect.fn(function* ({ id, olds, output }) {
129 const streamKey = output?.streamKeyArn
130 ? yield* getByArn(output.streamKeyArn)
131 : olds?.channelArn
132 ? yield* findByChannel(olds.channelArn)
133 : undefined;
134 if (streamKey === undefined) return undefined;
135 const attrs = yield* toAttrs(streamKey);
136 const tags = toTagRecord(streamKey.tags);
137 if (yield* hasAlchemyTags(id, tags)) return attrs;
138 // CreateChannel provisions an untagged default key before the
139 // engine can resolve this resource's channelArn. It belongs to the
140 // channel lifecycle, so an owned channel may claim its default key.
141 // Keys carrying user or ownership tags still require explicit adoption.
142 if (
143 output === undefined &&
144 olds?.channelArn !== undefined &&
145 Object.keys(tags).length === 0
146 ) {
147 const response = yield* ivs
148 .getChannel({ arn: olds.channelArn })
149 .pipe(
150 retryWhileThrottled,
151 Effect.catchTag("ResourceNotFoundException", () =>
152 Effect.succeed(undefined),
153 ),
154 );
155 const channelTags = toTagRecord(response?.channel?.tags);
156 const channelId = channelTags["alchemy::id"];
157 if (
158 channelId !== undefined &&
159 (yield* hasAlchemyTags(channelId, channelTags))
160 ) {
161 return attrs;
162 }
163 }
164 return Unowned(attrs);
165 }),
166
167 diff: Effect.fn(function* ({ news, olds }) {
168 if (!isResolved(news)) return undefined;
169 // A stream key is bound to its channel at create time — moving
170 // it to another channel requires a replacement.
171 if (olds.channelArn !== news.channelArn) {
172 return { action: "replace" } as const;
173 }
174 }),
175
176 reconcile: Effect.fn(function* ({ id, news, output, session }) {
177 const internalTags = yield* createInternalTags(id);
178 const desiredTags = { ...internalTags, ...news.tags };
179
180 // 1. Observe — prefer the cached ARN; fall back to the channel's
181 // (at most one) existing key.
182 let observed = output?.streamKeyArn
183 ? yield* getByArn(output.streamKeyArn)
184 : yield* findByChannel(news.channelArn);
185 // A cached ARN can belong to a previous channel after a failed
186 // replacement — only trust keys on the desired channel.
187 if (
188 observed !== undefined &&
189 observed.channelArn !== news.channelArn
190 ) {
191 observed = undefined;
192 }
193
194 // 2. Ensure — create if missing. The per-channel quota is 1 and
195 // CreateChannel auto-provisions a key, so a quota rejection means
196 // the channel already has its key: adopt it (tags are converged
197 // below).
198 if (observed === undefined) {
199 observed = yield* ivs
200 .createStreamKey({
201 channelArn: news.channelArn,
202 tags: desiredTags,
203 })
204 .pipe(
205 retryWhileThrottled,
206 Effect.map((r) => r.streamKey),
207 Effect.catchTag("ServiceQuotaExceededException", () =>
208 findByChannel(news.channelArn),
209 ),
210 );
211 }
212 const arn = observed?.arn;
213 if (observed === undefined || arn === undefined) {
214 return yield* Effect.fail(
215 new IvsStreamKeyIncomplete({
216 message: `IVS channel '${news.channelArn}' has no stream key and one could not be created`,
217 }),
218 );
219 }
220
221 // 3. Sync tags — the only mutable aspect. Diff against OBSERVED
222 // cloud tags so adopting the auto-created key converges.
223 yield* syncIvsTags(arn, desiredTags);
224
225 // 4. Return fresh attributes (GetStreamKey carries the secret
226 // value; the ListStreamKeys summary does not).
227 const final = yield* getByArn(arn);
228 if (final === undefined) {
229 return yield* Effect.fail(
230 new IvsStreamKeyIncomplete({
231 message: `IVS stream key '${arn}' vanished during reconcile`,
232 }),
233 );
234 }
235 yield* session.note(arn);
236 return yield* toAttrs(final);
237 }),
238
239 delete: Effect.fn(function* ({ output }) {
240 yield* ivs.deleteStreamKey({ arn: output.streamKeyArn }).pipe(
241 retryWhileThrottled,
242 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
243 );
244 }),
245
246 // Stream keys are sub-resources keyed by their parent channel —
247 // there is no account-level enumeration to reconcile against.
248 list: () => Effect.succeed([]),
249 };
250 }),
251 );