| 1 | import * as eventbridge from "@distilled.cloud/aws/eventbridge"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as Redacted from "effect/Redacted"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import type { AccountID } from "../Environment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | import type { RegionID } from "../Region.ts"; |
| 12 | |
| 13 | export type { |
| 14 | ConnectionAuthorizationType, |
| 15 | ConnectionHttpParameters, |
| 16 | ConnectionOAuthHttpMethod, |
| 17 | ConnectionState, |
| 18 | } from "@distilled.cloud/aws/eventbridge"; |
| 19 | |
| 20 | export type ConnectionName = string; |
| 21 | export type ConnectionArn = |
| 22 | `arn:aws:events:${RegionID}:${AccountID}:connection/${ConnectionName}/${string}`; |
| 23 | |
| 24 | export interface ConnectionApiKeyAuthParameters { |
| 25 | /** Header name the API key is sent under (e.g. `x-api-key`). */ |
| 26 | apiKeyName: string; |
| 27 | /** The API key value. Wrap with `Redacted.make(...)` — stored by EventBridge in Secrets Manager. */ |
| 28 | apiKeyValue: Redacted.Redacted<string>; |
| 29 | } |
| 30 | |
| 31 | export interface ConnectionBasicAuthParameters { |
| 32 | /** Username for HTTP Basic authorization. */ |
| 33 | username: string; |
| 34 | /** Password for HTTP Basic authorization. Wrap with `Redacted.make(...)`. */ |
| 35 | password: Redacted.Redacted<string>; |
| 36 | } |
| 37 | |
| 38 | export interface ConnectionOAuthClientParameters { |
| 39 | /** OAuth client ID. */ |
| 40 | clientId: string; |
| 41 | /** OAuth client secret. Wrap with `Redacted.make(...)`. */ |
| 42 | clientSecret: Redacted.Redacted<string>; |
| 43 | } |
| 44 | |
| 45 | export interface ConnectionOAuthParameters { |
| 46 | /** The client credentials exchanged for a token. */ |
| 47 | clientParameters: ConnectionOAuthClientParameters; |
| 48 | /** URL of the OAuth authorization (token) endpoint. */ |
| 49 | authorizationEndpoint: string; |
| 50 | /** HTTP method used against the authorization endpoint. */ |
| 51 | httpMethod: eventbridge.ConnectionOAuthHttpMethod; |
| 52 | /** Additional parameters included in the token request. */ |
| 53 | oauthHttpParameters?: eventbridge.ConnectionHttpParameters; |
| 54 | } |
| 55 | |
| 56 | export interface ConnectionAuthParameters { |
| 57 | /** API-key authorization (`authorizationType: "API_KEY"`). */ |
| 58 | apiKeyAuthParameters?: ConnectionApiKeyAuthParameters; |
| 59 | /** Basic authorization (`authorizationType: "BASIC"`). */ |
| 60 | basicAuthParameters?: ConnectionBasicAuthParameters; |
| 61 | /** OAuth client-credentials authorization (`authorizationType: "OAUTH_CLIENT_CREDENTIALS"`). */ |
| 62 | oauthParameters?: ConnectionOAuthParameters; |
| 63 | /** Additional header/query/body parameters included in every invocation. */ |
| 64 | invocationHttpParameters?: eventbridge.ConnectionHttpParameters; |
| 65 | } |
| 66 | |
| 67 | export interface ConnectionProps { |
| 68 | /** |
| 69 | * Name of the connection. Must match [\.\-_A-Za-z0-9]+, 1-64 characters. |
| 70 | * If omitted, a unique name will be generated. |
| 71 | */ |
| 72 | name?: ConnectionName; |
| 73 | |
| 74 | /** |
| 75 | * Description of the connection. Max 512 characters. |
| 76 | */ |
| 77 | description?: string; |
| 78 | |
| 79 | /** |
| 80 | * The type of authorization to use for the connection. |
| 81 | */ |
| 82 | authorizationType: eventbridge.ConnectionAuthorizationType; |
| 83 | |
| 84 | /** |
| 85 | * The authorization parameters matching `authorizationType`. Secret values |
| 86 | * (`apiKeyValue`, `password`, `clientSecret`) are `Redacted` end-to-end and |
| 87 | * stored by EventBridge in Secrets Manager. |
| 88 | */ |
| 89 | authParameters: ConnectionAuthParameters; |
| 90 | |
| 91 | /** |
| 92 | * The identifier of the KMS customer managed key to encrypt the connection |
| 93 | * secret. |
| 94 | */ |
| 95 | kmsKeyIdentifier?: string; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * An Amazon EventBridge connection holding the authorization used to invoke |
| 100 | * an HTTP endpoint through an {@link ApiDestination}. EventBridge stores the |
| 101 | * secret half of the connection in Secrets Manager on your behalf. |
| 102 | * |
| 103 | * Connections do not support tags, so ownership is tracked by the |
| 104 | * deterministic physical name. |
| 105 | * ### Connecting to APIs |
| 106 | * **Example:** API-Key Connection |
| 107 | * ```typescript |
| 108 | * import * as Redacted from "effect/Redacted"; |
| 109 | * |
| 110 | * const connection = yield* AWS.EventBridge.Connection("PartnerApi", { |
| 111 | * authorizationType: "API_KEY", |
| 112 | * authParameters: { |
| 113 | * apiKeyAuthParameters: { |
| 114 | * apiKeyName: "x-api-key", |
| 115 | * apiKeyValue: Redacted.make(process.env.PARTNER_API_KEY!), |
| 116 | * }, |
| 117 | * }, |
| 118 | * }); |
| 119 | * ``` |
| 120 | * |
| 121 | * **Example:** OAuth Client-Credentials Connection |
| 122 | * ```typescript |
| 123 | * const connection = yield* AWS.EventBridge.Connection("OAuthApi", { |
| 124 | * authorizationType: "OAUTH_CLIENT_CREDENTIALS", |
| 125 | * authParameters: { |
| 126 | * oauthParameters: { |
| 127 | * clientParameters: { |
| 128 | * clientId: "my-client", |
| 129 | * clientSecret: Redacted.make(process.env.OAUTH_SECRET!), |
| 130 | * }, |
| 131 | * authorizationEndpoint: "https://auth.example.com/oauth/token", |
| 132 | * httpMethod: "POST", |
| 133 | * }, |
| 134 | * }, |
| 135 | * }); |
| 136 | * ``` |
| 137 | * |
| 138 | * @resource |
| 139 | */ |
| 140 | export interface Connection extends Resource< |
| 141 | "AWS.EventBridge.Connection", |
| 142 | ConnectionProps, |
| 143 | { |
| 144 | /** The name of the connection. */ |
| 145 | connectionName: ConnectionName; |
| 146 | /** The ARN of the connection. */ |
| 147 | connectionArn: ConnectionArn; |
| 148 | /** The state of the connection (e.g. `AUTHORIZED`). */ |
| 149 | connectionState: eventbridge.ConnectionState; |
| 150 | /** ARN of the Secrets Manager secret EventBridge created for the connection. */ |
| 151 | secretArn?: string; |
| 152 | }, |
| 153 | never, |
| 154 | Providers |
| 155 | > {} |
| 156 | export const Connection = Resource<Connection>("AWS.EventBridge.Connection"); |
| 157 | |
| 158 | const toCreateAuthParameters = ( |
| 159 | auth: ConnectionAuthParameters, |
| 160 | ): eventbridge.CreateConnectionAuthRequestParameters => ({ |
| 161 | ApiKeyAuthParameters: auth.apiKeyAuthParameters |
| 162 | ? { |
| 163 | ApiKeyName: auth.apiKeyAuthParameters.apiKeyName, |
| 164 | ApiKeyValue: auth.apiKeyAuthParameters.apiKeyValue, |
| 165 | } |
| 166 | : undefined, |
| 167 | BasicAuthParameters: auth.basicAuthParameters |
| 168 | ? { |
| 169 | Username: auth.basicAuthParameters.username, |
| 170 | Password: auth.basicAuthParameters.password, |
| 171 | } |
| 172 | : undefined, |
| 173 | OAuthParameters: auth.oauthParameters |
| 174 | ? { |
| 175 | ClientParameters: { |
| 176 | ClientID: auth.oauthParameters.clientParameters.clientId, |
| 177 | ClientSecret: auth.oauthParameters.clientParameters.clientSecret, |
| 178 | }, |
| 179 | AuthorizationEndpoint: auth.oauthParameters.authorizationEndpoint, |
| 180 | HttpMethod: auth.oauthParameters.httpMethod, |
| 181 | OAuthHttpParameters: auth.oauthParameters.oauthHttpParameters, |
| 182 | } |
| 183 | : undefined, |
| 184 | InvocationHttpParameters: auth.invocationHttpParameters, |
| 185 | }); |
| 186 | |
| 187 | export const ConnectionProvider = () => |
| 188 | Provider.effect( |
| 189 | Connection, |
| 190 | Effect.gen(function* () { |
| 191 | const createConnectionName = ( |
| 192 | id: string, |
| 193 | props: { name?: string } = {}, |
| 194 | ) => |
| 195 | props.name |
| 196 | ? Effect.succeed(props.name) |
| 197 | : createPhysicalName({ |
| 198 | id, |
| 199 | maxLength: 64, |
| 200 | }); |
| 201 | |
| 202 | /** |
| 203 | * Poll until the connection settles out of its transitional state |
| 204 | * (CREATING/UPDATING/AUTHORIZING). API-key and basic connections |
| 205 | * authorize in seconds; the wait is bounded so a slow OAuth handshake |
| 206 | * surfaces the last observed state instead of hanging. |
| 207 | */ |
| 208 | const awaitSettled = (name: string) => |
| 209 | eventbridge.describeConnection({ Name: name }).pipe( |
| 210 | // A describe fired immediately after create can be eventually |
| 211 | // consistent — absorb NotFound briefly before polling the state. |
| 212 | Effect.retry({ |
| 213 | while: (e): boolean => e._tag === "ResourceNotFoundException", |
| 214 | schedule: Schedule.spaced("1 second"), |
| 215 | times: 5, |
| 216 | }), |
| 217 | Effect.repeat({ |
| 218 | schedule: Schedule.spaced("2 seconds"), |
| 219 | until: (r): boolean => |
| 220 | r.ConnectionState !== "CREATING" && |
| 221 | r.ConnectionState !== "UPDATING" && |
| 222 | r.ConnectionState !== "AUTHORIZING", |
| 223 | times: 15, |
| 224 | }), |
| 225 | ); |
| 226 | |
| 227 | return { |
| 228 | stables: ["connectionName", "connectionArn"], |
| 229 | diff: Effect.fn(function* ({ id, news, olds }) { |
| 230 | if (!isResolved(news)) return; |
| 231 | const oldName = yield* createConnectionName(id, olds); |
| 232 | const newName = yield* createConnectionName(id, news); |
| 233 | if (oldName !== newName) { |
| 234 | return { action: "replace" } as const; |
| 235 | } |
| 236 | }), |
| 237 | read: Effect.fn(function* ({ id, olds, output }) { |
| 238 | // Connections don't support tags; the deterministic physical name |
| 239 | // is the ownership signal (it embeds app/stage/logical id). |
| 240 | const connectionName = |
| 241 | output?.connectionName ?? |
| 242 | (yield* createConnectionName(id, olds ?? {})); |
| 243 | const described = yield* eventbridge |
| 244 | .describeConnection({ Name: connectionName }) |
| 245 | .pipe( |
| 246 | Effect.catchTag("ResourceNotFoundException", () => |
| 247 | Effect.succeed(undefined), |
| 248 | ), |
| 249 | ); |
| 250 | if (!described?.Name || !described.ConnectionArn) { |
| 251 | return undefined; |
| 252 | } |
| 253 | return { |
| 254 | connectionName: described.Name, |
| 255 | connectionArn: described.ConnectionArn as ConnectionArn, |
| 256 | connectionState: described.ConnectionState ?? "AUTHORIZED", |
| 257 | secretArn: described.SecretArn, |
| 258 | }; |
| 259 | }), |
| 260 | list: () => |
| 261 | Effect.gen(function* () { |
| 262 | const attrs: { |
| 263 | connectionName: ConnectionName; |
| 264 | connectionArn: ConnectionArn; |
| 265 | connectionState: eventbridge.ConnectionState; |
| 266 | secretArn?: string; |
| 267 | }[] = []; |
| 268 | let nextToken: string | undefined; |
| 269 | do { |
| 270 | const page = yield* eventbridge.listConnections({ |
| 271 | NextToken: nextToken, |
| 272 | }); |
| 273 | for (const connection of page.Connections ?? []) { |
| 274 | if (!connection.Name || !connection.ConnectionArn) { |
| 275 | continue; |
| 276 | } |
| 277 | attrs.push({ |
| 278 | connectionName: connection.Name, |
| 279 | connectionArn: connection.ConnectionArn as ConnectionArn, |
| 280 | connectionState: connection.ConnectionState ?? "AUTHORIZED", |
| 281 | }); |
| 282 | } |
| 283 | nextToken = page.NextToken; |
| 284 | } while (nextToken); |
| 285 | return attrs; |
| 286 | }), |
| 287 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 288 | const connectionName = |
| 289 | output?.connectionName ?? (yield* createConnectionName(id, news)); |
| 290 | |
| 291 | // Observe — live cloud state is authoritative; a vanished |
| 292 | // connection falls through to create. |
| 293 | const observed = yield* eventbridge |
| 294 | .describeConnection({ Name: connectionName }) |
| 295 | .pipe( |
| 296 | Effect.catchTag("ResourceNotFoundException", () => |
| 297 | Effect.succeed(undefined), |
| 298 | ), |
| 299 | ); |
| 300 | |
| 301 | if (!observed?.ConnectionArn) { |
| 302 | // Ensure — create the connection; tolerate an AlreadyExists race |
| 303 | // with a peer reconciler and converge via the update below. |
| 304 | yield* eventbridge |
| 305 | .createConnection({ |
| 306 | Name: connectionName, |
| 307 | Description: news.description, |
| 308 | AuthorizationType: news.authorizationType, |
| 309 | AuthParameters: toCreateAuthParameters(news.authParameters), |
| 310 | KmsKeyIdentifier: news.kmsKeyIdentifier, |
| 311 | }) |
| 312 | .pipe( |
| 313 | Effect.catchTag( |
| 314 | "ResourceAlreadyExistsException", |
| 315 | () => Effect.void, |
| 316 | ), |
| 317 | ); |
| 318 | } else { |
| 319 | // Sync — updateConnection overwrites description, authorization |
| 320 | // type/parameters, and KMS key in one shot. The update-parameter |
| 321 | // shape is structurally compatible with the create shape (all |
| 322 | // fields optional). |
| 323 | yield* eventbridge.updateConnection({ |
| 324 | Name: connectionName, |
| 325 | Description: news.description, |
| 326 | AuthorizationType: news.authorizationType, |
| 327 | AuthParameters: toCreateAuthParameters(news.authParameters), |
| 328 | KmsKeyIdentifier: news.kmsKeyIdentifier, |
| 329 | }); |
| 330 | } |
| 331 | |
| 332 | const settled = yield* awaitSettled(connectionName); |
| 333 | const connectionArn = (settled.ConnectionArn ?? |
| 334 | observed?.ConnectionArn) as ConnectionArn; |
| 335 | |
| 336 | yield* session.note(connectionArn); |
| 337 | return { |
| 338 | connectionName, |
| 339 | connectionArn, |
| 340 | connectionState: settled.ConnectionState ?? "AUTHORIZED", |
| 341 | secretArn: settled.SecretArn, |
| 342 | }; |
| 343 | }), |
| 344 | delete: Effect.fn(function* ({ output }) { |
| 345 | // Deleting a connection that ApiDestinations still reference fails |
| 346 | // until the destinations are gone; the engine deletes dependents |
| 347 | // first, so a bounded retry absorbs eventual consistency. |
| 348 | yield* eventbridge |
| 349 | .deleteConnection({ Name: output.connectionName }) |
| 350 | .pipe( |
| 351 | Effect.retry({ |
| 352 | while: (e): boolean => |
| 353 | e._tag === "ConcurrentModificationException", |
| 354 | schedule: Schedule.spaced("2 seconds"), |
| 355 | times: 8, |
| 356 | }), |
| 357 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 358 | ); |
| 359 | }), |
| 360 | }; |
| 361 | }), |
| 362 | ); |