| 1 | import * as location from "@distilled.cloud/aws/location"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as EffectStream from "effect/Stream"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { |
| 11 | createInternalTags, |
| 12 | diffTags, |
| 13 | hasAlchemyTags, |
| 14 | type Tags, |
| 15 | } from "../../Tags.ts"; |
| 16 | import type { Providers } from "../Providers.ts"; |
| 17 | import { toTagRecord } from "./internal.ts"; |
| 18 | |
| 19 | /** |
| 20 | * What a Location API key is allowed to call. Keys authorize *unsigned* |
| 21 | * requests, so restrictions should be as narrow as possible. |
| 22 | */ |
| 23 | export interface ApiKeyRestrictions { |
| 24 | /** |
| 25 | * Allowed API actions, e.g. `["geo:GetMap*"]` or |
| 26 | * `["geo:SearchPlaceIndexForText"]`. Wildcards are allowed at the end of |
| 27 | * an action name. |
| 28 | */ |
| 29 | allowActions: string[]; |
| 30 | /** |
| 31 | * Allowed resource ARNs, e.g. a specific map's ARN or a wildcard like |
| 32 | * `arn:aws:geo:*:*:map/*`. |
| 33 | */ |
| 34 | allowResources: string[]; |
| 35 | /** |
| 36 | * Optional HTTP referer patterns the key may be used from, e.g. |
| 37 | * `["https://example.com/*"]`. |
| 38 | */ |
| 39 | allowReferers?: string[]; |
| 40 | } |
| 41 | |
| 42 | export interface ApiKeyProps { |
| 43 | /** |
| 44 | * Name of the API key. Immutable — changing it replaces the key. |
| 45 | * @default ${app}-${stage}-${id} |
| 46 | */ |
| 47 | keyName?: string; |
| 48 | /** |
| 49 | * The actions, resources, and referers the key is allowed to be used |
| 50 | * with. Updatable in place. |
| 51 | */ |
| 52 | restrictions: ApiKeyRestrictions; |
| 53 | /** |
| 54 | * Optional description of the API key. |
| 55 | */ |
| 56 | description?: string; |
| 57 | /** |
| 58 | * Expiry as an ISO-8601 timestamp, e.g. `"2027-01-01T00:00:00Z"`. |
| 59 | * Mutually exclusive with `noExpiry`. |
| 60 | */ |
| 61 | expireTime?: string; |
| 62 | /** |
| 63 | * Create the key without an expiry. |
| 64 | * @default true when `expireTime` is omitted |
| 65 | */ |
| 66 | noExpiry?: boolean; |
| 67 | /** |
| 68 | * Tags to associate with the API key. |
| 69 | */ |
| 70 | tags?: Record<string, string>; |
| 71 | } |
| 72 | |
| 73 | export interface ApiKey extends Resource< |
| 74 | "AWS.Location.ApiKey", |
| 75 | ApiKeyProps, |
| 76 | { |
| 77 | /** Physical name of the API key. */ |
| 78 | keyName: string; |
| 79 | /** ARN of the API key resource. */ |
| 80 | keyArn: string; |
| 81 | /** |
| 82 | * The key value clients send (`v1.public.…`). Wrapped in `Redacted` — |
| 83 | * unwrap with `Redacted.value(apiKey.key)` where the raw value is |
| 84 | * needed. |
| 85 | */ |
| 86 | key: Redacted.Redacted<string>; |
| 87 | /** Restrictions currently applied to the key. */ |
| 88 | restrictions: ApiKeyRestrictions; |
| 89 | /** Expiry timestamp (ISO-8601), if any. */ |
| 90 | expireTime: string | undefined; |
| 91 | /** Description of the API key. */ |
| 92 | description: string | undefined; |
| 93 | /** Tags currently associated with the key. */ |
| 94 | tags: Record<string, string>; |
| 95 | }, |
| 96 | never, |
| 97 | Providers |
| 98 | > {} |
| 99 | |
| 100 | /** |
| 101 | * An Amazon Location Service API key. API keys authorize **unsigned** |
| 102 | * requests (e.g. map tiles rendered directly in a browser) to a restricted |
| 103 | * set of Location actions and resources. The key name is immutable; |
| 104 | * restrictions, description, and expiry can be updated in place. |
| 105 | * |
| 106 | * The `key` attribute is the secret key value (`v1.public.…`) clients pass |
| 107 | * as the `key` query parameter; it is wrapped in `Redacted`. |
| 108 | * |
| 109 | * Availability: Amazon Location classic (V1) is closed to newer AWS |
| 110 | * accounts — `geo:CreateKey` is rejected service-side with an |
| 111 | * `AccessDeniedException` regardless of IAM policy. Accounts onboarded to |
| 112 | * Location before the V2 split can create keys normally. |
| 113 | * |
| 114 | * ### Creating API Keys |
| 115 | * **Example:** Map-Rendering Key for Browsers |
| 116 | * ```typescript |
| 117 | * import * as Location from "alchemy/AWS/Location"; |
| 118 | * |
| 119 | * const map = yield* Location.Map("SiteMap", { |
| 120 | * configuration: { style: "VectorEsriStreets" }, |
| 121 | * }); |
| 122 | * |
| 123 | * const key = yield* Location.ApiKey("SiteMapKey", { |
| 124 | * restrictions: { |
| 125 | * allowActions: ["geo:GetMap*"], |
| 126 | * allowResources: [map.mapArn], |
| 127 | * }, |
| 128 | * }); |
| 129 | * // Redacted.value(key.key) → "v1.public.…" — append as ?key=… to tile URLs |
| 130 | * ``` |
| 131 | * |
| 132 | * **Example:** Key with Referer Restrictions and Expiry |
| 133 | * ```typescript |
| 134 | * const key = yield* Location.ApiKey("WebKey", { |
| 135 | * restrictions: { |
| 136 | * allowActions: ["geo:GetMap*"], |
| 137 | * allowResources: ["arn:aws:geo:*:*:map/*"], |
| 138 | * allowReferers: ["https://example.com/*"], |
| 139 | * }, |
| 140 | * expireTime: "2027-01-01T00:00:00Z", |
| 141 | * }); |
| 142 | * ``` |
| 143 | * |
| 144 | * @resource |
| 145 | */ |
| 146 | export const ApiKey = Resource<ApiKey>("AWS.Location.ApiKey"); |
| 147 | |
| 148 | const createKeyName = (id: string, props: { keyName?: string | undefined }) => |
| 149 | Effect.gen(function* () { |
| 150 | if (props.keyName) return props.keyName; |
| 151 | return yield* createPhysicalName({ id, maxLength: 100 }); |
| 152 | }); |
| 153 | |
| 154 | const unredact = (value: string | Redacted.Redacted<string>): string => |
| 155 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 156 | |
| 157 | const toRestrictions = ( |
| 158 | restrictions: location.ApiKeyRestrictions, |
| 159 | ): ApiKeyRestrictions => ({ |
| 160 | allowActions: [...restrictions.AllowActions], |
| 161 | allowResources: [...restrictions.AllowResources], |
| 162 | allowReferers: |
| 163 | restrictions.AllowReferers === undefined |
| 164 | ? undefined |
| 165 | : ( |
| 166 | restrictions.AllowReferers as (string | Redacted.Redacted<string>)[] |
| 167 | ).map(unredact), |
| 168 | }); |
| 169 | |
| 170 | /** Order-insensitive structural comparison of key restrictions. */ |
| 171 | const sameRestrictions = (a: ApiKeyRestrictions, b: ApiKeyRestrictions) => { |
| 172 | const norm = (r: ApiKeyRestrictions) => |
| 173 | JSON.stringify({ |
| 174 | actions: [...r.allowActions].sort(), |
| 175 | resources: [...r.allowResources].sort(), |
| 176 | referers: [...(r.allowReferers ?? [])].sort(), |
| 177 | }); |
| 178 | return norm(a) === norm(b); |
| 179 | }; |
| 180 | |
| 181 | const readKey = Effect.fn(function* (keyName: string) { |
| 182 | const found = yield* location |
| 183 | .describeKey({ KeyName: keyName }) |
| 184 | .pipe( |
| 185 | Effect.catchTag("ResourceNotFoundException", () => |
| 186 | Effect.succeed(undefined), |
| 187 | ), |
| 188 | ); |
| 189 | if (!found) return undefined; |
| 190 | return { |
| 191 | keyName: found.KeyName, |
| 192 | keyArn: found.KeyArn, |
| 193 | key: Redacted.make(unredact(found.Key)), |
| 194 | restrictions: toRestrictions(found.Restrictions), |
| 195 | expireTime: found.ExpireTime ? found.ExpireTime.toISOString() : undefined, |
| 196 | description: found.Description ? found.Description : undefined, |
| 197 | tags: toTagRecord(found.Tags), |
| 198 | } satisfies ApiKey["Attributes"]; |
| 199 | }); |
| 200 | |
| 201 | export const ApiKeyProvider = () => |
| 202 | Provider.effect( |
| 203 | ApiKey, |
| 204 | Effect.gen(function* () { |
| 205 | return { |
| 206 | stables: ["keyName", "keyArn", "key"], |
| 207 | list: () => |
| 208 | Effect.gen(function* () { |
| 209 | const names = yield* location.listKeys.pages({}).pipe( |
| 210 | EffectStream.runCollect, |
| 211 | Effect.map((chunk) => |
| 212 | Array.from(chunk).flatMap((page) => |
| 213 | (page.Entries ?? []).map((entry) => entry.KeyName), |
| 214 | ), |
| 215 | ), |
| 216 | ); |
| 217 | const hydrated = yield* Effect.forEach( |
| 218 | names, |
| 219 | (name) => readKey(name), |
| 220 | { concurrency: 10 }, |
| 221 | ); |
| 222 | return hydrated.filter( |
| 223 | (attrs): attrs is ApiKey["Attributes"] => attrs !== undefined, |
| 224 | ); |
| 225 | }), |
| 226 | read: Effect.fn(function* ({ id, olds, output }) { |
| 227 | const keyName = |
| 228 | output?.keyName ?? (yield* createKeyName(id, olds ?? {})); |
| 229 | const state = yield* readKey(keyName); |
| 230 | if (!state) return undefined; |
| 231 | return (yield* hasAlchemyTags(id, state.tags as Tags)) |
| 232 | ? state |
| 233 | : Unowned(state); |
| 234 | }), |
| 235 | diff: Effect.fn(function* ({ id, news, olds }) { |
| 236 | if (!isResolved(news)) return; |
| 237 | const oldName = yield* createKeyName(id, olds); |
| 238 | const newName = yield* createKeyName(id, news); |
| 239 | if (oldName !== newName) { |
| 240 | return { action: "replace" } as const; |
| 241 | } |
| 242 | }), |
| 243 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 244 | const keyName = output?.keyName ?? (yield* createKeyName(id, news)); |
| 245 | const internalTags = yield* createInternalTags(id); |
| 246 | const desiredTags = { ...internalTags, ...news.tags }; |
| 247 | const desiredRestrictions: location.ApiKeyRestrictions = { |
| 248 | AllowActions: news.restrictions.allowActions, |
| 249 | AllowResources: news.restrictions.allowResources, |
| 250 | AllowReferers: news.restrictions.allowReferers, |
| 251 | }; |
| 252 | |
| 253 | let state = yield* readKey(keyName); |
| 254 | |
| 255 | if (state === undefined) { |
| 256 | yield* location |
| 257 | .createKey({ |
| 258 | KeyName: keyName, |
| 259 | Restrictions: desiredRestrictions, |
| 260 | Description: news.description, |
| 261 | ExpireTime: news.expireTime |
| 262 | ? new Date(news.expireTime) |
| 263 | : undefined, |
| 264 | NoExpiry: news.expireTime ? undefined : (news.noExpiry ?? true), |
| 265 | Tags: desiredTags, |
| 266 | }) |
| 267 | .pipe(Effect.catchTag("ConflictException", () => Effect.void)); |
| 268 | state = yield* readKey(keyName); |
| 269 | if (state === undefined) { |
| 270 | return yield* Effect.fail( |
| 271 | new Error(`failed to read created API key ${keyName}`), |
| 272 | ); |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | const desiredExpireTime = news.expireTime |
| 277 | ? new Date(news.expireTime).toISOString() |
| 278 | : undefined; |
| 279 | if ( |
| 280 | state.description !== (news.description ?? undefined) || |
| 281 | !sameRestrictions(state.restrictions, news.restrictions) || |
| 282 | (desiredExpireTime !== undefined && |
| 283 | state.expireTime !== desiredExpireTime) |
| 284 | ) { |
| 285 | yield* location.updateKey({ |
| 286 | KeyName: keyName, |
| 287 | Description: news.description, |
| 288 | Restrictions: desiredRestrictions, |
| 289 | ExpireTime: desiredExpireTime |
| 290 | ? new Date(desiredExpireTime) |
| 291 | : undefined, |
| 292 | NoExpiry: desiredExpireTime ? undefined : true, |
| 293 | // Required to update a key that has been used recently. |
| 294 | ForceUpdate: true, |
| 295 | }); |
| 296 | } |
| 297 | |
| 298 | const { removed, upsert } = diffTags(state.tags, desiredTags); |
| 299 | if (removed.length > 0) { |
| 300 | yield* location.untagResource({ |
| 301 | ResourceArn: state.keyArn, |
| 302 | TagKeys: removed, |
| 303 | }); |
| 304 | } |
| 305 | if (upsert.length > 0) { |
| 306 | yield* location.tagResource({ |
| 307 | ResourceArn: state.keyArn, |
| 308 | Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])), |
| 309 | }); |
| 310 | } |
| 311 | |
| 312 | yield* session.note(state.keyArn); |
| 313 | |
| 314 | const final = yield* readKey(keyName); |
| 315 | if (!final) { |
| 316 | return yield* Effect.fail( |
| 317 | new Error(`failed to read reconciled API key ${keyName}`), |
| 318 | ); |
| 319 | } |
| 320 | return final; |
| 321 | }), |
| 322 | delete: Effect.fn(function* ({ output }) { |
| 323 | yield* location |
| 324 | .deleteKey({ KeyName: output.keyName, ForceDelete: true }) |
| 325 | .pipe( |
| 326 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 327 | ); |
| 328 | }), |
| 329 | }; |
| 330 | }), |
| 331 | ); |