1import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9/** An OAuth scope defined by a resource server. */
10export interface ResourceServerScope {
11 /** Scope name; clients request it as `<identifier>/<scopeName>`. */
12 scopeName: string;
13 /** Human-readable description of the scope. */
14 scopeDescription: string;
15}
16
17export interface ResourceServerProps {
18 /**
19 * The ID of the user pool the resource server belongs to. Changing this
20 * triggers a replacement.
21 */
22 userPoolId: string;
23 /**
24 * Unique identifier of the resource server — conventionally an API URL
25 * like `https://api.example.com`. If omitted, a deterministic identifier
26 * is generated from the app, stage, and logical ID. Changing this
27 * triggers a replacement.
28 */
29 identifier?: string;
30 /**
31 * Friendly name of the resource server. Defaults to the identifier.
32 */
33 name?: string;
34 /**
35 * OAuth scopes (up to 100) exposed by this resource server. Clients
36 * reference them as `<identifier>/<scopeName>` in `allowedOAuthScopes`.
37 */
38 scopes?: ResourceServerScope[];
39}
40
41export interface ResourceServer extends Resource<
42 "AWS.Cognito.ResourceServer",
43 ResourceServerProps,
44 {
45 /** The unique identifier of the resource server. */
46 identifier: string;
47 /** The ID of the user pool the resource server belongs to. */
48 userPoolId: string;
49 /** The friendly name of the resource server. */
50 name: string;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * An OAuth 2.0 resource server for an Amazon Cognito user pool. Resource
58 * servers declare custom scopes that app clients can request in
59 * `client_credentials` and authorization-code flows.
60 * ### Creating a Resource Server
61 * **Example:** API with Custom Scopes
62 * ```typescript
63 * import * as Cognito from "alchemy/AWS/Cognito";
64 *
65 * const pool = yield* Cognito.UserPool("Users", {});
66 * const api = yield* Cognito.ResourceServer("Api", {
67 * userPoolId: pool.userPoolId,
68 * identifier: "https://api.example.com",
69 * scopes: [
70 * { scopeName: "read", scopeDescription: "Read access" },
71 * { scopeName: "write", scopeDescription: "Write access" },
72 * ],
73 * });
74 * ```
75 *
76 * **Example:** Client Requesting Resource-Server Scopes
77 * ```typescript
78 * const client = yield* Cognito.UserPoolClient("Machine", {
79 * userPoolId: pool.userPoolId,
80 * generateSecret: true,
81 * allowedOAuthFlowsUserPoolClient: true,
82 * allowedOAuthFlows: ["client_credentials"],
83 * allowedOAuthScopes: ["https://api.example.com/read"],
84 * });
85 * ```
86 *
87 * @resource
88 */
89export const ResourceServer = Resource<ResourceServer>(
90 "AWS.Cognito.ResourceServer",
91);
92
93const toWireScopes = (scopes: ResourceServerScope[] | undefined) =>
94 scopes?.map((scope) => ({
95 ScopeName: scope.scopeName,
96 ScopeDescription: scope.scopeDescription,
97 }));
98
99const canonicalScopes = (
100 scopes: { ScopeName?: string; ScopeDescription?: string }[] | undefined,
101) =>
102 (scopes ?? [])
103 .map((scope) => `${scope.ScopeName}:${scope.ScopeDescription}`)
104 .sort()
105 .join(",");
106
107export const ResourceServerProvider = () =>
108 Provider.effect(
109 ResourceServer,
110 Effect.gen(function* () {
111 const createIdentifier = Effect.fn(function* (
112 id: string,
113 props: Pick<ResourceServerProps, "identifier">,
114 ) {
115 return (
116 props.identifier ??
117 (yield* createPhysicalName({ id, maxLength: 256 }))
118 );
119 });
120
121 const describeServer = Effect.fn(function* (
122 userPoolId: string,
123 identifier: string,
124 ) {
125 return yield* cip
126 .describeResourceServer({
127 UserPoolId: userPoolId,
128 Identifier: identifier,
129 })
130 .pipe(
131 Effect.map((r) => r.ResourceServer),
132 Effect.catchTag("ResourceNotFoundException", () =>
133 Effect.succeed(undefined),
134 ),
135 );
136 });
137
138 return ResourceServer.Provider.of({
139 stables: ["identifier", "userPoolId"],
140
141 // Sub-resource keyed entirely by its user pool (userPoolId) with no global
142 // enumeration API of its own — nuke reaches it through the parent's
143 // deletion, so enumeration returns empty per the ProviderService
144 // doctrine.
145 list: () => Effect.succeed([]),
146
147 read: Effect.fn(function* ({ id, olds, output }) {
148 const userPoolId = output?.userPoolId ?? olds?.userPoolId;
149 if (userPoolId === undefined) return undefined;
150 const identifier =
151 output?.identifier ?? (yield* createIdentifier(id, olds ?? {}));
152 const observed = yield* describeServer(userPoolId, identifier);
153 return observed === undefined
154 ? undefined
155 : {
156 identifier,
157 userPoolId,
158 name: observed.Name ?? identifier,
159 };
160 }),
161
162 diff: Effect.fn(function* ({ id, news, olds }) {
163 if (!isResolved(news)) return undefined;
164 const oldIdentifier = yield* createIdentifier(id, olds ?? {});
165 const newIdentifier = yield* createIdentifier(id, news ?? {});
166 if (
167 oldIdentifier !== newIdentifier ||
168 olds?.userPoolId !== news?.userPoolId
169 ) {
170 return { action: "replace" } as const;
171 }
172 }),
173
174 reconcile: Effect.fn(function* ({ id, news, output, session }) {
175 const identifier =
176 output?.identifier ?? (yield* createIdentifier(id, news));
177 const userPoolId = news.userPoolId;
178 const name = news.name ?? identifier;
179
180 // 1. OBSERVE
181 let observed = yield* describeServer(userPoolId, identifier);
182
183 // 2. ENSURE
184 if (observed === undefined) {
185 observed = yield* cip
186 .createResourceServer({
187 UserPoolId: userPoolId,
188 Identifier: identifier,
189 Name: name,
190 Scopes: toWireScopes(news.scopes),
191 })
192 .pipe(Effect.map((r) => r.ResourceServer));
193 } else {
194 // 3. SYNC — name and scopes are mutable in place.
195 const drift =
196 observed.Name !== name ||
197 canonicalScopes(observed.Scopes) !==
198 canonicalScopes(toWireScopes(news.scopes));
199 if (drift) {
200 observed = yield* cip
201 .updateResourceServer({
202 UserPoolId: userPoolId,
203 Identifier: identifier,
204 Name: name,
205 Scopes: toWireScopes(news.scopes),
206 })
207 .pipe(Effect.map((r) => r.ResourceServer));
208 }
209 }
210
211 yield* session.note(identifier);
212 return { identifier, userPoolId, name };
213 }),
214
215 delete: Effect.fn(function* ({ output }) {
216 yield* cip
217 .deleteResourceServer({
218 UserPoolId: output.userPoolId,
219 Identifier: output.identifier,
220 })
221 .pipe(
222 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
223 );
224 }),
225 });
226 }),
227 );