1import * as appintegrations from "@distilled.cloud/aws/appintegrations";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { definedTags } from "./internal.ts";
13
14export interface ApplicationProps {
15 /**
16 * Name of the application. If omitted, a unique name is generated from
17 * the app, stage, and logical ID. The name can be updated in place.
18 */
19 name?: string;
20 /**
21 * The unique namespace of the application, e.g. `com.example.myapp`.
22 * Changing the namespace replaces the application.
23 */
24 namespace: string;
25 /**
26 * Description of the application (1-1000 characters).
27 */
28 description?: string;
29 /**
30 * The URL where the application is hosted and rendered from, e.g.
31 * `https://example.com`.
32 */
33 accessUrl: string;
34 /**
35 * Additional origins the application is allowed to be loaded from.
36 */
37 approvedOrigins?: string[];
38 /**
39 * The configuration of events or requests that the application has access
40 * to, e.g. `["User.Details.View"]`.
41 */
42 permissions?: string[];
43 /**
44 * Tags to apply to the application. Merged with internal Alchemy tags.
45 */
46 tags?: Record<string, string>;
47}
48
49export interface Application extends Resource<
50 "AWS.AppIntegrations.Application",
51 ApplicationProps,
52 {
53 applicationId: string;
54 applicationArn: string;
55 applicationName: string;
56 namespace: string;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * An Amazon AppIntegrations application. Applications register external
64 * (iframe-hosted) apps — most commonly Amazon Connect agent workspace
65 * third-party applications — with a name, namespace, and the URL they are
66 * served from.
67 *
68 * The namespace is immutable; changing it replaces the application. The
69 * name, description, access URL, approved origins, and permissions can all
70 * be updated in place.
71 * ### Creating an Application
72 * **Example:** Basic Application
73 * ```typescript
74 * import * as AppIntegrations from "alchemy/AWS/AppIntegrations";
75 *
76 * const app = yield* AppIntegrations.Application("AgentApp", {
77 * namespace: "com.example.agentapp",
78 * accessUrl: "https://example.com",
79 * });
80 * ```
81 *
82 * **Example:** Application with Permissions and Tags
83 * ```typescript
84 * const app = yield* AppIntegrations.Application("AgentApp", {
85 * namespace: "com.example.agentapp",
86 * accessUrl: "https://example.com",
87 * description: "Agent workspace application",
88 * permissions: ["User.Details.View"],
89 * tags: { team: "contact-center" },
90 * });
91 * ```
92 *
93 * @resource
94 */
95export const Application = Resource<Application>(
96 "AWS.AppIntegrations.Application",
97);
98
99/**
100 * Raised when the AppIntegrations API returns an application without the
101 * fields required to build the resource attributes.
102 */
103export class ApplicationIncomplete extends Data.TaggedError(
104 "AppIntegrationsApplicationIncomplete",
105)<{ message: string }> {}
106
107export const ApplicationProvider = () =>
108 Provider.effect(
109 Application,
110 Effect.gen(function* () {
111 const createName = Effect.fn(function* (
112 id: string,
113 props: { name?: string },
114 ) {
115 return (
116 props.name ?? (yield* createPhysicalName({ id, maxLength: 255 }))
117 );
118 });
119
120 /** Get a single application by ARN or ID; undefined if absent. */
121 const observe = (arnOrId: string) =>
122 appintegrations
123 .getApplication({ Arn: arnOrId })
124 .pipe(
125 Effect.catchTag("ResourceNotFoundException", () =>
126 Effect.succeed(undefined),
127 ),
128 );
129
130 /**
131 * Find an application ARN by namespace via list enumeration. The
132 * namespace is the account-unique key; the name is a generated
133 * physical name whose instance-id suffix changes across lost-state
134 * re-runs, so matching on it would miss the existing application and
135 * `createApplication` would then fail with "Namespace already in use".
136 */
137 const findByNamespace = (namespace: string) =>
138 appintegrations.listApplications.items({}).pipe(
139 Stream.filter((item) => item.Namespace === namespace),
140 Stream.take(1),
141 Stream.runCollect,
142 Effect.map((chunk) => Array.from(chunk)[0]?.Arn),
143 );
144
145 const toAttrs = Effect.fn(function* (
146 live: appintegrations.GetApplicationResponse,
147 ) {
148 if (
149 live.Arn === undefined ||
150 live.Id === undefined ||
151 live.Name === undefined ||
152 live.Namespace === undefined
153 ) {
154 return yield* new ApplicationIncomplete({
155 message: `application '${live.Name}' is missing Arn, Id, Name, or Namespace`,
156 });
157 }
158 return {
159 applicationId: live.Id,
160 applicationArn: live.Arn,
161 applicationName: live.Name,
162 namespace: live.Namespace,
163 };
164 });
165
166 return Application.Provider.of({
167 stables: ["applicationId", "applicationArn", "namespace"],
168
169 list: () =>
170 appintegrations.listApplications.items({}).pipe(
171 Stream.runCollect,
172 Effect.map((chunk) =>
173 Array.from(chunk).flatMap((item) =>
174 item.Arn !== undefined &&
175 item.Id !== undefined &&
176 item.Name !== undefined &&
177 item.Namespace !== undefined
178 ? [
179 {
180 applicationId: item.Id,
181 applicationArn: item.Arn,
182 applicationName: item.Name,
183 namespace: item.Namespace,
184 },
185 ]
186 : [],
187 ),
188 ),
189 ),
190
191 read: Effect.fn(function* ({ id, olds, output }) {
192 let arn = output?.applicationArn;
193 if (arn === undefined) {
194 if (olds === undefined) return undefined;
195 arn = yield* findByNamespace(olds.namespace);
196 }
197 if (arn === undefined) return undefined;
198 const live = yield* observe(arn);
199 if (live === undefined) return undefined;
200 const attrs = yield* toAttrs(live);
201 const tags = definedTags(live.Tags);
202 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
203 }),
204
205 diff: Effect.fn(function* ({ news, olds }) {
206 if (!isResolved(news)) return undefined;
207 if (olds === undefined) return undefined;
208 if (olds.namespace !== news.namespace) {
209 return { action: "replace" } as const;
210 }
211 // fall through: default update path (name, description, source
212 // config, permissions, tags are all mutable in place)
213 }),
214
215 reconcile: Effect.fn(function* ({ id, news, output, session }) {
216 const name = yield* createName(id, news);
217 const internalTags = yield* createInternalTags(id);
218 const desiredTags = { ...news.tags, ...internalTags };
219 const desiredSourceConfig: appintegrations.ApplicationSourceConfig = {
220 ExternalUrlConfig: {
221 AccessUrl: news.accessUrl,
222 ...(news.approvedOrigins
223 ? { ApprovedOrigins: news.approvedOrigins }
224 : {}),
225 },
226 };
227
228 // 1. Observe — prefer the cached ARN; fall back to enumerating by
229 // the unique namespace so a lost-state re-run converges even
230 // though the generated physical name changed.
231 let arn = output?.applicationArn;
232 let live = arn === undefined ? undefined : yield* observe(arn);
233 if (live === undefined) {
234 arn = yield* findByNamespace(news.namespace);
235 live = arn === undefined ? undefined : yield* observe(arn);
236 }
237
238 // 2. Ensure — create if missing. A concurrent create can win the
239 // unique namespace between observe and create ("Namespace
240 // already in use" InvalidRequestException) — treat it as a race
241 // and re-observe; rethrow if the namespace holder still can't
242 // be found.
243 if (live === undefined) {
244 const created = yield* appintegrations
245 .createApplication({
246 Name: name,
247 Namespace: news.namespace,
248 Description: news.description,
249 ApplicationSourceConfig: desiredSourceConfig,
250 Permissions: news.permissions,
251 Tags: desiredTags,
252 })
253 .pipe(
254 Effect.catchTag("InvalidRequestException", (error) =>
255 Effect.gen(function* () {
256 const existing = yield* findByNamespace(news.namespace);
257 if (existing === undefined) {
258 return yield* Effect.fail(error);
259 }
260 return { Arn: existing };
261 }),
262 ),
263 );
264 if (created.Arn === undefined) {
265 return yield* new ApplicationIncomplete({
266 message: `createApplication for '${name}' returned no Arn`,
267 });
268 }
269 arn = created.Arn;
270 live = yield* appintegrations.getApplication({ Arn: arn });
271 }
272 const attrs = yield* toAttrs(live);
273
274 // 3. Sync mutable aspects — compare observed cloud state against
275 // desired and push a single update with only the changed fields.
276 const update: Omit<appintegrations.UpdateApplicationRequest, "Arn"> =
277 {};
278 if (live.Name !== name) {
279 update.Name = name;
280 }
281 if (
282 news.description !== undefined &&
283 news.description !== live.Description
284 ) {
285 update.Description = news.description;
286 }
287 const observedUrlConfig =
288 live.ApplicationSourceConfig?.ExternalUrlConfig;
289 if (
290 observedUrlConfig?.AccessUrl !== news.accessUrl ||
291 JSON.stringify(observedUrlConfig?.ApprovedOrigins ?? []) !==
292 JSON.stringify(news.approvedOrigins ?? [])
293 ) {
294 update.ApplicationSourceConfig = desiredSourceConfig;
295 }
296 if (
297 news.permissions !== undefined &&
298 JSON.stringify([...(live.Permissions ?? [])].sort()) !==
299 JSON.stringify([...news.permissions].sort())
300 ) {
301 update.Permissions = news.permissions;
302 }
303 if (Object.keys(update).length > 0) {
304 yield* appintegrations.updateApplication({
305 Arn: attrs.applicationArn,
306 ...update,
307 });
308 }
309
310 // 4. Sync tags — diff against OBSERVED cloud tags so adoption
311 // converges.
312 const observedTags = definedTags(live.Tags);
313 const { upsert, removed } = diffTags(observedTags, desiredTags);
314 if (upsert.length > 0) {
315 yield* appintegrations.tagResource({
316 resourceArn: attrs.applicationArn,
317 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
318 });
319 }
320 if (removed.length > 0) {
321 yield* appintegrations.untagResource({
322 resourceArn: attrs.applicationArn,
323 tagKeys: removed,
324 });
325 }
326
327 yield* session.note(name);
328 return { ...attrs, applicationName: name };
329 }),
330
331 delete: Effect.fn(function* ({ output }) {
332 yield* appintegrations
333 .deleteApplication({ Arn: output.applicationArn })
334 .pipe(
335 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
336 );
337 }),
338 });
339 }),
340 );