1import * as ivs from "@distilled.cloud/aws/ivs";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 retryWhileConflict,
13 retryWhileThrottled,
14 syncIvsTags,
15 toTagRecord,
16} from "./internal.ts";
17
18export interface PlaybackRestrictionPolicyProps {
19 /**
20 * ISO 3166-1 alpha-2 country codes playback is allowed from. An empty
21 * array denies playback from every country.
22 */
23 allowedCountries: string[];
24 /**
25 * Origins (per the HTTP `Origin` header) playback is allowed from, e.g.
26 * `https://example.com`. An empty array denies playback from every
27 * origin.
28 */
29 allowedOrigins: string[];
30 /**
31 * Whether to enforce the origin restriction strictly (checks the
32 * viewer's origin on every video segment request, not just the initial
33 * playlist request).
34 * @default false
35 */
36 enableStrictOriginEnforcement?: boolean;
37 /**
38 * Name of the playback restriction policy. If omitted, a deterministic
39 * physical name is generated. Names are mutable — changing the name
40 * updates the policy in place.
41 */
42 playbackRestrictionPolicyName?: string;
43 /**
44 * Tags to apply to the policy. Merged with internal Alchemy tags.
45 */
46 tags?: Record<string, string>;
47}
48
49export interface PlaybackRestrictionPolicy extends Resource<
50 "AWS.IVS.PlaybackRestrictionPolicy",
51 PlaybackRestrictionPolicyProps,
52 {
53 /**
54 * ARN of the playback restriction policy.
55 */
56 playbackRestrictionPolicyArn: string;
57 /**
58 * The policy's physical name.
59 */
60 playbackRestrictionPolicyName: string | undefined;
61 /**
62 * Country codes playback is allowed from.
63 */
64 allowedCountries: string[];
65 /**
66 * Origins playback is allowed from.
67 */
68 allowedOrigins: string[];
69 /**
70 * Whether strict origin enforcement is enabled.
71 */
72 enableStrictOriginEnforcement: boolean | undefined;
73 },
74 never,
75 Providers
76> {}
77
78/**
79 * An Amazon IVS playback restriction policy, constraining channel playback
80 * by viewer country and/or request origin.
81 *
82 * Attach the policy to a channel via the channel's
83 * `playbackRestrictionPolicyArn` prop. All policy settings are mutable and
84 * update in place.
85 * ### Restricting Playback
86 * **Example:** Restrict Playback by Country and Origin
87 * ```typescript
88 * import * as IVS from "alchemy/AWS/IVS";
89 *
90 * const policy = yield* IVS.PlaybackRestrictionPolicy("GeoFence", {
91 * allowedCountries: ["US", "CA"],
92 * allowedOrigins: ["https://example.com"],
93 * });
94 * const channel = yield* IVS.Channel("LiveChannel", {
95 * playbackRestrictionPolicyArn: policy.playbackRestrictionPolicyArn,
96 * });
97 * ```
98 *
99 * **Example:** Strict Origin Enforcement
100 * ```typescript
101 * const policy = yield* IVS.PlaybackRestrictionPolicy("StrictFence", {
102 * allowedCountries: ["US"],
103 * allowedOrigins: ["https://example.com"],
104 * enableStrictOriginEnforcement: true,
105 * });
106 * ```
107 *
108 * @resource
109 */
110export const PlaybackRestrictionPolicy = Resource<PlaybackRestrictionPolicy>(
111 "AWS.IVS.PlaybackRestrictionPolicy",
112);
113
114/**
115 * Raised when the IVS API returns a playback restriction policy missing
116 * its ARN.
117 */
118export class IvsPlaybackRestrictionPolicyIncomplete extends Data.TaggedError(
119 "IvsPlaybackRestrictionPolicyIncomplete",
120)<{ message: string }> {}
121
122/** Order-insensitive equality for the policy's country/origin lists. */
123const sameList = (a: readonly string[], b: readonly string[]): boolean =>
124 a.length === b.length && [...a].sort().join("") === [...b].sort().join("");
125
126export const PlaybackRestrictionPolicyProvider = () =>
127 Provider.effect(
128 PlaybackRestrictionPolicy,
129 Effect.gen(function* () {
130 const toName = (
131 id: string,
132 props: { playbackRestrictionPolicyName?: string | undefined },
133 ) =>
134 props.playbackRestrictionPolicyName
135 ? Effect.succeed(props.playbackRestrictionPolicyName)
136 : createPhysicalName({ id, maxLength: 128 });
137
138 const toAttrs = (policy: ivs.PlaybackRestrictionPolicy) => ({
139 playbackRestrictionPolicyArn: policy.arn,
140 playbackRestrictionPolicyName: policy.name,
141 allowedCountries: [...policy.allowedCountries],
142 allowedOrigins: [...policy.allowedOrigins],
143 enableStrictOriginEnforcement: policy.enableStrictOriginEnforcement,
144 });
145
146 const getByArn = Effect.fn(function* (arn: string) {
147 const response = yield* ivs.getPlaybackRestrictionPolicy({ arn }).pipe(
148 retryWhileThrottled,
149 Effect.catchTag("ResourceNotFoundException", () =>
150 Effect.succeed(undefined),
151 ),
152 );
153 return response?.playbackRestrictionPolicy;
154 });
155
156 /**
157 * ListPlaybackRestrictionPolicies has no name filter — enumerate and
158 * match exactly, taking the first hit (names are not unique). Used
159 * only when the output ARN cache is unavailable.
160 */
161 const findByName = Effect.fn(function* (name: string) {
162 const summaries = yield* ivs.listPlaybackRestrictionPolicies
163 .pages({})
164 .pipe(
165 Stream.runCollect,
166 Effect.map((chunk) =>
167 Array.from(chunk).flatMap(
168 (page) => page.playbackRestrictionPolicies,
169 ),
170 ),
171 retryWhileThrottled,
172 );
173 const match = summaries.find((s) => s.name === name);
174 return match === undefined ? undefined : yield* getByArn(match.arn);
175 });
176
177 return {
178 stables: ["playbackRestrictionPolicyArn"],
179
180 read: Effect.fn(function* ({ id, olds, output }) {
181 const policy = output?.playbackRestrictionPolicyArn
182 ? yield* getByArn(output.playbackRestrictionPolicyArn)
183 : yield* findByName(yield* toName(id, olds ?? {}));
184 if (policy === undefined) return undefined;
185 const attrs = toAttrs(policy);
186 return (yield* hasAlchemyTags(id, toTagRecord(policy.tags)))
187 ? attrs
188 : Unowned(attrs);
189 }),
190
191 reconcile: Effect.fn(function* ({ id, news, output, session }) {
192 const name = yield* toName(id, news);
193 const internalTags = yield* createInternalTags(id);
194 const desiredTags = { ...internalTags, ...news.tags };
195
196 // 1. Observe — the live policy is authoritative; the output ARN
197 // is only an identifier cache.
198 let observed = output?.playbackRestrictionPolicyArn
199 ? yield* getByArn(output.playbackRestrictionPolicyArn)
200 : yield* findByName(name);
201
202 // 2. Ensure — create if missing.
203 if (observed === undefined) {
204 const created = yield* ivs
205 .createPlaybackRestrictionPolicy({
206 name,
207 allowedCountries: news.allowedCountries,
208 allowedOrigins: news.allowedOrigins,
209 enableStrictOriginEnforcement:
210 news.enableStrictOriginEnforcement,
211 tags: desiredTags,
212 })
213 .pipe(retryWhileThrottled);
214 observed = created.playbackRestrictionPolicy;
215 }
216 if (observed === undefined) {
217 return yield* Effect.fail(
218 new IvsPlaybackRestrictionPolicyIncomplete({
219 message:
220 "IVS CreatePlaybackRestrictionPolicy returned no policy",
221 }),
222 );
223 }
224 const arn = observed.arn;
225
226 // 3. Sync — every setting is mutable; diff observed against
227 // desired and apply only the delta.
228 const patch: Partial<ivs.UpdatePlaybackRestrictionPolicyRequest> = {};
229 if (observed.name !== name) patch.name = name;
230 if (!sameList(observed.allowedCountries, news.allowedCountries)) {
231 patch.allowedCountries = news.allowedCountries;
232 }
233 if (!sameList(observed.allowedOrigins, news.allowedOrigins)) {
234 patch.allowedOrigins = news.allowedOrigins;
235 }
236 if (
237 news.enableStrictOriginEnforcement !== undefined &&
238 (observed.enableStrictOriginEnforcement ?? false) !==
239 news.enableStrictOriginEnforcement
240 ) {
241 patch.enableStrictOriginEnforcement =
242 news.enableStrictOriginEnforcement;
243 }
244 if (Object.keys(patch).length > 0) {
245 yield* ivs
246 .updatePlaybackRestrictionPolicy({ arn, ...patch })
247 .pipe(retryWhileThrottled, retryWhileConflict);
248 }
249
250 // 3b. Sync tags — diff against OBSERVED cloud tags so adoption
251 // converges.
252 yield* syncIvsTags(arn, desiredTags);
253
254 // 4. Return fresh attributes.
255 const final = yield* getByArn(arn);
256 if (final === undefined) {
257 return yield* Effect.fail(
258 new IvsPlaybackRestrictionPolicyIncomplete({
259 message: `IVS playback restriction policy '${arn}' vanished during reconcile`,
260 }),
261 );
262 }
263 yield* session.note(arn);
264 return toAttrs(final);
265 }),
266
267 delete: Effect.fn(function* ({ output }) {
268 // Deleting a policy still attached to a channel raises
269 // ConflictException — retry through a bounded window (the channel
270 // detaches first in a same-deploy destroy), then tolerate
271 // already-gone.
272 yield* ivs
273 .deletePlaybackRestrictionPolicy({
274 arn: output.playbackRestrictionPolicyArn,
275 })
276 .pipe(
277 retryWhileThrottled,
278 retryWhileConflict,
279 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
280 );
281 }),
282
283 list: () =>
284 ivs.listPlaybackRestrictionPolicies.pages({}).pipe(
285 Stream.runCollect,
286 Effect.map((chunk) =>
287 Array.from(chunk).flatMap(
288 (page) => page.playbackRestrictionPolicies,
289 ),
290 ),
291 Effect.map((summaries) =>
292 summaries.map((summary) => toAttrs(summary)),
293 ),
294 ),
295 };
296 }),
297 );