1import * as sns from "@distilled.cloud/aws/sns";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11
12export type PlatformApplicationArn = string;
13
14export interface PlatformApplicationProps {
15 /**
16 * Name of the platform application. Up to 256 characters of letters,
17 * numbers, underscores, hyphens, and periods.
18 * @default ${app}-${stage}-${id}
19 */
20 name?: string;
21 /**
22 * Push notification platform, e.g. `GCM` (Firebase Cloud Messaging),
23 * `APNS`, `APNS_SANDBOX`, `ADM`, or `BAIDU`. Changing the platform
24 * replaces the application.
25 */
26 platform: string;
27 /**
28 * Platform credential — the FCM API key / service account JSON (GCM), the
29 * APNS private key, or the ADM client secret. SNS validates the credential
30 * at creation time. Write-only: SNS never returns it, so it is pushed on
31 * create/adopt and whenever the configured value changes.
32 */
33 platformCredential: Redacted.Redacted<string>;
34 /**
35 * Platform principal — the APNS SSL certificate or ADM client id. Not
36 * applicable for GCM. Write-only like `platformCredential`.
37 */
38 platformPrincipal?: Redacted.Redacted<string>;
39 /**
40 * Additional mutable SNS platform application attributes keyed by AWS
41 * attribute name, such as `EventEndpointCreated`, `EventDeliveryFailure`,
42 * `SuccessFeedbackRoleArn`, or `SuccessFeedbackSampleRate`.
43 */
44 attributes?: Record<string, string>;
45}
46
47export interface PlatformApplication extends Resource<
48 "AWS.SNS.PlatformApplication",
49 PlatformApplicationProps,
50 {
51 /** ARN of the platform application. */
52 platformApplicationArn: PlatformApplicationArn;
53 /** Name of the platform application. */
54 name: string;
55 /** Push notification platform (e.g. `GCM`, `APNS`). */
56 platform: string;
57 /** Whether the platform application is enabled (credentials valid). */
58 enabled: boolean;
59 /** Observed non-sensitive SNS attributes keyed by AWS attribute name. */
60 attributes: Record<string, string>;
61 },
62 never,
63 Providers
64> {}
65
66/**
67 * An Amazon SNS platform application for mobile push notifications (FCM,
68 * APNS, ADM, Baidu).
69 *
70 * A platform application holds the push service credentials; device tokens
71 * are registered against it at runtime with the
72 * `CreatePlatformEndpoint` binding, and messages are delivered with
73 * `PublishToEndpoint`. SNS validates the credential when the application is
74 * created, so a real push-service credential is required.
75 * ### Creating Platform Applications
76 * **Example:** FCM (GCM) Application
77 * ```typescript
78 * import * as SNS from "alchemy/AWS/SNS";
79 * import * as Redacted from "effect/Redacted";
80 *
81 * const app = yield* SNS.PlatformApplication("PushApp", {
82 * platform: "GCM",
83 * platformCredential: Redacted.make(process.env.FCM_API_KEY!),
84 * });
85 * ```
86 *
87 * **Example:** APNS Application
88 * ```typescript
89 * const app = yield* SNS.PlatformApplication("IosPushApp", {
90 * platform: "APNS",
91 * platformCredential: Redacted.make(apnsPrivateKey),
92 * platformPrincipal: Redacted.make(apnsCertificate),
93 * });
94 * ```
95 *
96 * ### Runtime Endpoints
97 * **Example:** Register a Device Token at Runtime
98 * ```typescript
99 * // init
100 * const createEndpoint = yield* SNS.CreatePlatformEndpoint(app);
101 * const publishToEndpoint = yield* SNS.PublishToEndpoint(app);
102 *
103 * // runtime
104 * const endpoint = yield* createEndpoint({ Token: deviceToken });
105 * yield* publishToEndpoint({
106 * TargetArn: endpoint.EndpointArn!,
107 * Message: "hello",
108 * });
109 * ```
110 *
111 * @resource
112 */
113export const PlatformApplication = Resource<PlatformApplication>(
114 "AWS.SNS.PlatformApplication",
115);
116
117export const PlatformApplicationProvider = () =>
118 Provider.succeed(PlatformApplication, {
119 list: Effect.fn(function* () {
120 const applications = yield* sns.listPlatformApplications.pages({}).pipe(
121 Stream.runCollect,
122 Effect.map((chunk) =>
123 Array.from(chunk).flatMap((page) => page.PlatformApplications ?? []),
124 ),
125 );
126
127 return applications.flatMap((application) => {
128 const arn = application.PlatformApplicationArn;
129 if (!arn) return [];
130 return [toApplicationAttributes(arn, application.Attributes)];
131 });
132 }),
133 read: Effect.fn(function* ({ id, olds, output }) {
134 const arn =
135 output?.platformApplicationArn ??
136 (yield* toApplicationArn(id, olds ?? { platform: "" }));
137 return yield* readApplication(arn);
138 }),
139 stables: ["platformApplicationArn", "name", "platform"],
140 diff: Effect.fn(function* ({ id, news, olds }) {
141 if (!isResolved(news)) return undefined;
142 if (news.platform !== olds.platform) {
143 return { action: "replace" } as const;
144 }
145 const oldName = yield* toApplicationName(id, olds);
146 const newName = yield* toApplicationName(id, news);
147 if (oldName !== newName) {
148 return { action: "replace" } as const;
149 }
150 }),
151 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
152 const name = yield* toApplicationName(id, news);
153 const arn =
154 output?.platformApplicationArn ?? (yield* toApplicationArn(id, news));
155
156 // Observe — the credential attributes are write-only, so observation
157 // covers existence plus the non-sensitive mutable attributes.
158 const observed = yield* sns
159 .getPlatformApplicationAttributes({ PlatformApplicationArn: arn })
160 .pipe(
161 Effect.map((response) => toAttributeMap(response.Attributes)),
162 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
163 );
164
165 const credentialAttributes: Record<string, string> = {
166 PlatformCredential: Redacted.value(news.platformCredential),
167 ...(news.platformPrincipal
168 ? { PlatformPrincipal: Redacted.value(news.platformPrincipal) }
169 : undefined),
170 };
171 const desiredAttributes = toAttributeMap(news.attributes);
172
173 // Ensure — create carries the full attribute set (credentials +
174 // mutable attributes) in one call.
175 if (observed === undefined) {
176 const created = yield* sns.createPlatformApplication({
177 Name: name,
178 Platform: news.platform,
179 Attributes: { ...desiredAttributes, ...credentialAttributes },
180 });
181 const platformApplicationArn = created.PlatformApplicationArn ?? arn;
182 yield* session.note(platformApplicationArn);
183 const state = yield* readApplication(platformApplicationArn);
184 return (
185 state ??
186 toApplicationAttributes(platformApplicationArn, desiredAttributes)
187 );
188 }
189
190 // Sync — mutable non-secret attributes diff observed vs desired;
191 // removed previously-managed keys reset to "". Credentials cannot be
192 // observed, so they are pushed on adoption (`olds` absent) or when the
193 // configured value changes (`olds` is only a hint to skip a no-op).
194 const updates: Record<string, string> = {};
195 for (const [key, value] of Object.entries(desiredAttributes)) {
196 if (observed[key] !== value) {
197 updates[key] = value;
198 }
199 }
200 for (const key of Object.keys(toAttributeMap(olds?.attributes))) {
201 if (!(key in desiredAttributes)) {
202 updates[key] = "";
203 }
204 }
205
206 const credentialChanged =
207 olds === undefined ||
208 Redacted.value(olds.platformCredential) !==
209 Redacted.value(news.platformCredential) ||
210 (olds.platformPrincipal === undefined) !==
211 (news.platformPrincipal === undefined) ||
212 (olds.platformPrincipal !== undefined &&
213 news.platformPrincipal !== undefined &&
214 Redacted.value(olds.platformPrincipal) !==
215 Redacted.value(news.platformPrincipal));
216 if (credentialChanged) {
217 Object.assign(updates, credentialAttributes);
218 }
219
220 if (Object.keys(updates).length > 0) {
221 yield* sns.setPlatformApplicationAttributes({
222 PlatformApplicationArn: arn,
223 Attributes: updates,
224 });
225 }
226
227 yield* session.note(arn);
228 const state = yield* readApplication(arn);
229 return state ?? toApplicationAttributes(arn, desiredAttributes);
230 }),
231 delete: Effect.fn(function* ({ output }) {
232 // `DeletePlatformApplication` is idempotent — deleting a non-existent
233 // application succeeds — so only genuine failures propagate.
234 yield* sns.deletePlatformApplication({
235 PlatformApplicationArn: output.platformApplicationArn,
236 });
237 }),
238 });
239
240const toApplicationName = Effect.fn(function* (
241 id: string,
242 props: Pick<PlatformApplicationProps, "name">,
243) {
244 if (props.name) {
245 return props.name;
246 }
247 return yield* createPhysicalName({ id, maxLength: 256 });
248});
249
250const toApplicationArn = Effect.fn(function* (
251 id: string,
252 props: Pick<PlatformApplicationProps, "name" | "platform">,
253) {
254 const name = yield* toApplicationName(id, props);
255 const { accountId, region } = yield* AWSEnvironment.current;
256 return `arn:aws:sns:${region}:${accountId}:app/${props.platform}/${name}`;
257});
258
259const toAttributeMap = (
260 attributes: Record<string, string | undefined> | undefined,
261): Record<string, string> =>
262 Object.fromEntries(
263 Object.entries(attributes ?? {}).filter(
264 (entry): entry is [string, string] => typeof entry[1] === "string",
265 ),
266 );
267
268const toApplicationAttributes = (
269 arn: string,
270 attributes: Record<string, string | undefined> | undefined,
271) => {
272 const observed = toAttributeMap(attributes);
273 // arn:aws:sns:{region}:{account}:app/{Platform}/{Name}
274 const [, platform = "", name = ""] = arn.split(":").at(-1)?.split("/") ?? [];
275 return {
276 platformApplicationArn: arn,
277 name,
278 platform,
279 enabled: observed.Enabled !== "false",
280 attributes: observed,
281 };
282};
283
284const readApplication = Effect.fn(function* (arn: string) {
285 const observed = yield* sns
286 .getPlatformApplicationAttributes({ PlatformApplicationArn: arn })
287 .pipe(
288 Effect.map((response) => toAttributeMap(response.Attributes)),
289 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
290 );
291 if (observed === undefined) {
292 return undefined;
293 }
294 return toApplicationAttributes(arn, observed);
295});