1import * as repostspace from "@distilled.cloud/aws/repostspace";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Predicate from "effect/Predicate";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15
16/** Unwrap a distilled sensitive value into its plain string. */
17const unwrapSensitive = (
18 value: string | Redacted.Redacted<string> | undefined,
19): string | undefined =>
20 value === undefined
21 ? undefined
22 : Redacted.isRedacted(value)
23 ? Redacted.value(value)
24 : value;
25
26export interface SpaceSupportedEmailDomains {
27 /**
28 * Whether the supported-email-domains feature is enabled for the
29 * private re:Post.
30 */
31 enabled?: "ENABLED" | "DISABLED";
32 /**
33 * Email domains allowed to join the private re:Post.
34 */
35 allowedDomains?: string[];
36}
37
38export interface SpaceProps {
39 /**
40 * Display name of the private re:Post. Must be unique within the account.
41 * Changing the name replaces the space.
42 * @default a deterministic physical name derived from app, stage, and id
43 */
44 name?: string;
45 /**
46 * Subdomain the private re:Post is served from
47 * (`https://{subdomain}-{random}.private.repost.aws`). Must be globally
48 * unique across AWS re:Post Private. Changing the subdomain replaces the
49 * space.
50 * @default a deterministic lowercase physical name derived from app, stage, and id
51 */
52 subdomain?: string;
53 /**
54 * Pricing tier of the private re:Post — `"BASIC"` or `"STANDARD"`.
55 * Updated in place.
56 * @default "BASIC"
57 */
58 tier?: repostspace.TierLevel;
59 /**
60 * Human-readable description of the private re:Post.
61 */
62 description?: string;
63 /**
64 * ARN of a customer-managed KMS key used to encrypt the space's data.
65 * Changing the key replaces the space.
66 * @default an AWS-owned key
67 */
68 userKMSKey?: string;
69 /**
70 * ARN of the IAM role that grants AWS re:Post Private access to
71 * resources in your account (e.g. for IAM Identity Center integration).
72 */
73 roleArn?: string;
74 /**
75 * Email domains allowed to join the private re:Post without an explicit
76 * invite.
77 */
78 supportedEmailDomains?: SpaceSupportedEmailDomains;
79 /**
80 * User-defined tags for the space. Merged with internal Alchemy tags.
81 */
82 tags?: Record<string, string>;
83}
84
85export interface Space extends Resource<
86 "AWS.RePostSpace.Space",
87 SpaceProps,
88 {
89 /**
90 * Unique ID of the private re:Post space.
91 */
92 spaceId: string;
93 /**
94 * ARN of the space.
95 */
96 spaceArn: string;
97 /**
98 * Display name of the space.
99 */
100 name: string;
101 /**
102 * Current space status (e.g. `"CREATE_COMPLETED"`).
103 */
104 status: string;
105 /**
106 * IAM Identity Center configuration status of the space
107 * (e.g. `"CONFIGURED"` / `"UNCONFIGURED"`).
108 */
109 configurationStatus: string;
110 /**
111 * Client ID of the space's IAM Identity Center application.
112 */
113 clientId: string;
114 /**
115 * ID of the IAM Identity Center identity store backing the space.
116 */
117 identityStoreId: string | undefined;
118 /**
119 * ARN of the space's IAM Identity Center application.
120 */
121 applicationArn: string | undefined;
122 /**
123 * Description of the space.
124 */
125 description: string | undefined;
126 /**
127 * Subdomain the space is served from.
128 */
129 vanityDomain: string;
130 /**
131 * Approval status of the vanity domain (e.g. `"PENDING"`, `"APPROVED"`).
132 */
133 vanityDomainStatus: string;
134 /**
135 * AWS-generated domain the space is reachable on.
136 */
137 randomDomain: string;
138 /**
139 * ARN of the customer-provided IAM role the space uses for account
140 * integrations (mirrors the `roleArn` prop).
141 */
142 customerRoleArn: string | undefined;
143 /**
144 * Pricing tier of the space (`"BASIC"` or `"STANDARD"`).
145 */
146 tier: string;
147 /**
148 * Storage limit of the space, in bytes.
149 */
150 storageLimit: number;
151 /**
152 * Customer-managed KMS key encrypting the space's data, if any.
153 */
154 userKMSKey: string | undefined;
155 /**
156 * Tags on the space (including internal Alchemy tags).
157 */
158 tags: Record<string, string>;
159 },
160 never,
161 Providers
162> {}
163
164/**
165 * An AWS re:Post Private space — a private, organization-scoped version of
166 * AWS re:Post with curated Q&A, articles, and selected public content.
167 *
168 * re:Post Private is a paid feature (Basic or Standard tier) that requires
169 * AWS IAM Identity Center to be enabled in the account. Space provisioning
170 * is asynchronous and can take tens of minutes; the provider waits for the
171 * space to reach `CREATE_COMPLETED` before returning.
172 * ### Creating a Space
173 * **Example:** Basic Space
174 * ```typescript
175 * import * as RePostSpace from "alchemy/AWS/RePostSpace";
176 *
177 * const space = yield* RePostSpace.Space("Support", {
178 * subdomain: "my-org-support",
179 * tier: "BASIC",
180 * });
181 * ```
182 *
183 * **Example:** Space with Description and Tags
184 * ```typescript
185 * const space = yield* RePostSpace.Space("Engineering", {
186 * name: "Engineering Knowledge Base",
187 * subdomain: "my-org-engineering",
188 * tier: "STANDARD",
189 * description: "Internal Q&A for the engineering org",
190 * tags: { team: "platform" },
191 * });
192 * ```
193 *
194 * ### Encryption
195 * **Example:** Space with a Customer-Managed KMS Key
196 * ```typescript
197 * const space = yield* RePostSpace.Space("Secure", {
198 * subdomain: "my-org-secure",
199 * userKMSKey: key.keyArn,
200 * });
201 * ```
202 *
203 * @resource
204 */
205export const Space = Resource<Space>("AWS.RePostSpace.Space");
206
207const DEFAULT_TIER = "BASIC";
208
209/**
210 * Raised when a re:Post Private space enters a terminal failure state
211 * (`CREATE_FAILED`) or starts deleting while the provider is waiting for
212 * provisioning to complete.
213 */
214export class RePostSpaceProvisioningFailed extends Data.TaggedError(
215 "RePostSpaceProvisioningFailed",
216)<{ spaceId: string; status: string }> {}
217
218/** Internal marker error driving the bounded readiness retry loop. */
219class RePostSpaceNotReady extends Data.TaggedError("RePostSpaceNotReady")<{
220 spaceId: string;
221 status: string;
222}> {}
223
224const sameStringSet = (
225 a: readonly string[] | undefined,
226 b: readonly string[] | undefined,
227): boolean => {
228 const left = [...new Set(a ?? [])].sort();
229 const right = [...new Set(b ?? [])].sort();
230 return left.length === right.length && left.every((v, i) => v === right[i]);
231};
232
233export const SpaceProvider = () =>
234 Provider.effect(
235 Space,
236 Effect.gen(function* () {
237 const toName = (id: string, props: Partial<SpaceProps>) =>
238 props.name
239 ? Effect.succeed(props.name)
240 : createPhysicalName({ id, maxLength: 30 });
241
242 const toSubdomain = (id: string, props: Partial<SpaceProps>) =>
243 props.subdomain
244 ? Effect.succeed(props.subdomain)
245 : createPhysicalName({ id, maxLength: 63, lowercase: true });
246
247 const getSpaceOrUndefined = Effect.fn(function* (spaceId: string) {
248 return yield* repostspace
249 .getSpace({ spaceId })
250 .pipe(
251 Effect.catchTag("ResourceNotFoundException", () =>
252 Effect.succeed(undefined),
253 ),
254 );
255 });
256
257 // Locate a live (not deleting/deleted) space by display name. Space
258 // IDs are auto-assigned, so this is the fallback when the state
259 // output cache is absent (e.g. after a past persistence failure).
260 const findByName = Effect.fn(function* (name: string) {
261 const chunk = yield* repostspace.listSpaces
262 .items({})
263 .pipe(Stream.runCollect);
264 return Array.from(chunk).find(
265 (space) =>
266 unwrapSensitive(space.name) === name &&
267 !space.status.startsWith("DELETE"),
268 );
269 });
270
271 const readSpaceTags = Effect.fn(function* (arn: string) {
272 const response = yield* repostspace
273 .listTagsForResource({ resourceArn: arn })
274 .pipe(Effect.catch(() => Effect.succeed(undefined)));
275 const tags: Record<string, string> = {};
276 for (const [key, value] of Object.entries(response?.tags ?? {})) {
277 if (value !== undefined) tags[key] = value;
278 }
279 return tags;
280 });
281
282 // Explicitly-typed pipeable retry helper. Inlining `Effect.retry` in a
283 // provider lifecycle op leaks `Retry.Return`'s conditional into
284 // declaration emit and widens the provider layer to `unknown` R for
285 // every consumer of `AWS.providers()`.
286 const retryWhileSpaceNotReady = <
287 A,
288 E extends { readonly _tag: string },
289 R,
290 >(
291 self: Effect.Effect<A, E, R>,
292 ): Effect.Effect<A, E, R> =>
293 Effect.retry(self, {
294 while: (e) => e._tag === "RePostSpaceNotReady",
295 schedule: Schedule.max([
296 Schedule.fixed("30 seconds"),
297 Schedule.recurs(80),
298 ]),
299 });
300
301 // Bounded readiness wait. Space provisioning is asynchronous and can
302 // take ~30 minutes; budget 40 min (80 * 30s). CREATE_FAILED and any
303 // DELETE* status are terminal failures and stop the retry loop.
304 const waitForCreated = Effect.fn(function* (spaceId: string) {
305 return yield* retryWhileSpaceNotReady(
306 Effect.gen(function* () {
307 const space = yield* repostspace.getSpace({ spaceId });
308 if (
309 space.status === "CREATE_FAILED" ||
310 space.status.startsWith("DELETE")
311 ) {
312 return yield* Effect.fail(
313 new RePostSpaceProvisioningFailed({
314 spaceId,
315 status: space.status,
316 }),
317 );
318 }
319 if (space.status !== "CREATE_COMPLETED") {
320 return yield* Effect.fail(
321 new RePostSpaceNotReady({ spaceId, status: space.status }),
322 );
323 }
324 return space;
325 }),
326 );
327 });
328
329 const toAttrs = Effect.fn(function* (space: repostspace.GetSpaceOutput) {
330 return {
331 spaceId: space.spaceId,
332 spaceArn: space.arn,
333 name: unwrapSensitive(space.name) ?? "",
334 status: space.status,
335 configurationStatus: space.configurationStatus,
336 clientId: space.clientId,
337 identityStoreId: space.identityStoreId,
338 applicationArn: space.applicationArn,
339 description: unwrapSensitive(space.description),
340 vanityDomain: space.vanityDomain,
341 vanityDomainStatus: space.vanityDomainStatus,
342 randomDomain: space.randomDomain,
343 customerRoleArn: space.customerRoleArn,
344 tier: space.tier,
345 storageLimit: space.storageLimit,
346 userKMSKey: space.userKMSKey,
347 tags: yield* readSpaceTags(space.arn),
348 };
349 });
350
351 return Space.Provider.of({
352 stables: ["spaceId", "spaceArn", "clientId"],
353
354 list: () =>
355 Effect.gen(function* () {
356 const chunk = yield* repostspace.listSpaces
357 .items({})
358 .pipe(Stream.runCollect);
359 const items = yield* Effect.forEach(
360 Array.from(chunk),
361 (space) =>
362 repostspace.getSpace({ spaceId: space.spaceId }).pipe(
363 Effect.flatMap((full) => toAttrs(full)),
364 // A space can vanish between enumeration and hydration.
365 Effect.catchTag("ResourceNotFoundException", () =>
366 Effect.succeed(undefined),
367 ),
368 ),
369 { concurrency: 4 },
370 );
371 return items.filter(Predicate.isNotUndefined);
372 }),
373
374 read: Effect.fn(function* ({ id, olds, output }) {
375 let spaceId = output?.spaceId;
376 if (spaceId === undefined) {
377 const name = yield* toName(id, olds ?? {});
378 const found = yield* findByName(name);
379 spaceId = found?.spaceId;
380 }
381 if (spaceId === undefined) return undefined;
382 const space = yield* getSpaceOrUndefined(spaceId);
383 if (space === undefined || space.status.startsWith("DELETE")) {
384 return undefined;
385 }
386 const attrs = yield* toAttrs(space);
387 return (yield* hasAlchemyTags(id, attrs.tags))
388 ? attrs
389 : Unowned(attrs);
390 }),
391
392 diff: Effect.fn(function* ({ id, olds, news }) {
393 if (!isResolved(news)) return undefined;
394 const n = news ?? {};
395 const o = olds ?? {};
396 // Create-only properties force a replacement.
397 if ((yield* toName(id, o)) !== (yield* toName(id, n))) {
398 return { action: "replace" } as const;
399 }
400 if ((yield* toSubdomain(id, o)) !== (yield* toSubdomain(id, n))) {
401 return { action: "replace" } as const;
402 }
403 if (n.userKMSKey !== o.userKMSKey) {
404 return { action: "replace" } as const;
405 }
406 // tier, description, roleArn, supportedEmailDomains update in place.
407 }),
408
409 reconcile: Effect.fn(function* ({ id, news, output, session }) {
410 const props = news ?? {};
411 const name = output?.name ?? (yield* toName(id, props));
412 const internalTags = yield* createInternalTags(id);
413 const desiredTags = { ...internalTags, ...props.tags };
414 const desiredTier = props.tier ?? DEFAULT_TIER;
415
416 // 1. Observe — cloud state is authoritative; output is only an
417 // id cache. Fall through to a by-name lookup when the cached
418 // space no longer exists.
419 let spaceId = output?.spaceId;
420 if (spaceId !== undefined) {
421 const cached = yield* getSpaceOrUndefined(spaceId);
422 if (cached === undefined || cached.status.startsWith("DELETE")) {
423 spaceId = undefined;
424 }
425 }
426 if (spaceId === undefined) {
427 const found = yield* findByName(name);
428 spaceId = found?.spaceId;
429 }
430
431 // 2. Ensure — create if missing; a ConflictException means a peer
432 // created it concurrently (or the name/subdomain is taken), so
433 // fall back to the by-name lookup.
434 if (spaceId === undefined) {
435 const subdomain = yield* toSubdomain(id, props);
436 spaceId = yield* repostspace
437 .createSpace({
438 name,
439 subdomain,
440 tier: desiredTier,
441 description: props.description,
442 userKMSKey: props.userKMSKey,
443 roleArn: props.roleArn,
444 supportedEmailDomains: props.supportedEmailDomains,
445 tags: desiredTags,
446 })
447 .pipe(
448 Effect.map((r) => r.spaceId),
449 Effect.catchTag("ConflictException", (error) =>
450 findByName(name).pipe(
451 Effect.flatMap((existing) =>
452 existing !== undefined
453 ? Effect.succeed(existing.spaceId)
454 : Effect.fail(error),
455 ),
456 ),
457 ),
458 );
459 yield* session.note(`created space ${spaceId}, provisioning...`);
460 }
461
462 // Provisioning is asynchronous — wait (bounded) for the space to
463 // become usable before syncing mutable aspects.
464 let observed = yield* waitForCreated(spaceId);
465
466 // 3. Sync — compute the update delta from OBSERVED state and call
467 // updateSpace only when something actually changed.
468 const update: {
469 description?: string;
470 tier?: repostspace.TierLevel;
471 roleArn?: string;
472 supportedEmailDomains?: repostspace.SupportedEmailDomainsParameters;
473 } = {};
474 let mutated = false;
475 if (
476 props.description !== undefined &&
477 props.description !== unwrapSensitive(observed.description)
478 ) {
479 update.description = props.description;
480 mutated = true;
481 }
482 if (props.tier !== undefined && props.tier !== observed.tier) {
483 update.tier = props.tier;
484 mutated = true;
485 }
486 if (
487 props.roleArn !== undefined &&
488 props.roleArn !== observed.customerRoleArn
489 ) {
490 update.roleArn = props.roleArn;
491 mutated = true;
492 }
493 if (props.supportedEmailDomains !== undefined) {
494 const observedDomains = (
495 observed.supportedEmailDomains?.allowedDomains ?? []
496 ).map(
497 (domain: string | Redacted.Redacted<string>) =>
498 unwrapSensitive(domain) ?? "",
499 );
500 if (
501 props.supportedEmailDomains.enabled !==
502 observed.supportedEmailDomains?.enabled ||
503 !sameStringSet(
504 props.supportedEmailDomains.allowedDomains,
505 observedDomains,
506 )
507 ) {
508 update.supportedEmailDomains = props.supportedEmailDomains;
509 mutated = true;
510 }
511 }
512 if (mutated) {
513 yield* repostspace.updateSpace({ spaceId, ...update });
514 const refreshed = yield* getSpaceOrUndefined(spaceId);
515 if (refreshed !== undefined) observed = refreshed;
516 }
517
518 // 3b. Sync tags — diff against OBSERVED cloud tags so adoption
519 // converges.
520 const observedTags = yield* readSpaceTags(observed.arn);
521 const { removed, upsert } = diffTags(observedTags, desiredTags);
522 if (upsert.length > 0) {
523 yield* repostspace.tagResource({
524 resourceArn: observed.arn,
525 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
526 });
527 }
528 if (removed.length > 0) {
529 yield* repostspace.untagResource({
530 resourceArn: observed.arn,
531 tagKeys: removed,
532 });
533 }
534
535 yield* session.note(spaceId);
536 return yield* toAttrs(observed);
537 }),
538
539 delete: Effect.fn(function* ({ output }) {
540 yield* repostspace
541 .deleteSpace({ spaceId: output.spaceId })
542 .pipe(
543 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
544 );
545 }),
546 });
547 }),
548 );