1import * as location from "@distilled.cloud/aws/location";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as EffectStream 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 {
11 createInternalTags,
12 diffTags,
13 hasAlchemyTags,
14 type Tags,
15} from "../../Tags.ts";
16import type { Providers } from "../Providers.ts";
17import { 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 */
23export 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
42export 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
73export 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 */
146export const ApiKey = Resource<ApiKey>("AWS.Location.ApiKey");
147
148const 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
154const unredact = (value: string | Redacted.Redacted<string>): string =>
155 Redacted.isRedacted(value) ? Redacted.value(value) : value;
156
157const 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. */
171const 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
181const 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
201export 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 );