1import * as appsync from "@distilled.cloud/aws/appsync";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { retryConcurrentModification } from "./common.ts";
11import type { GraphqlApi } from "./GraphqlApi.ts";
12
13export interface ApiKeyProps {
14 /**
15 * ID of the GraphQL API. Usually derived from `api.apiId` by the
16 * {@link ApiKey} wrapper.
17 */
18 apiId: string;
19 /** Description of the key. */
20 description?: string;
21 /**
22 * Expiry as epoch seconds, between 1 and 365 days from creation. AWS
23 * rounds the value **down to the nearest hour**. When omitted, the key
24 * expires 7 days after creation and expiry is left unmanaged.
25 */
26 expires?: number;
27}
28
29export interface AppSyncApiKey extends Resource<
30 "AWS.AppSync.ApiKey",
31 ApiKeyProps,
32 {
33 /** The API this key belongs to. */
34 apiId: string;
35 /**
36 * The API key ID — this IS the secret key value (`da2-…`) clients send
37 * in the `x-api-key` header. Wrapped in `Redacted` — unwrap with
38 * `Redacted.value(key.id)` where the raw header value is needed.
39 */
40 id: Redacted.Redacted<string>;
41 /** The key's expiry (epoch seconds, rounded down to the hour). */
42 expires: number | undefined;
43 /** The key's description. */
44 description: string | undefined;
45 },
46 never,
47 Providers
48> {}
49
50/**
51 * An AppSync API key for `API_KEY`-authenticated GraphQL APIs.
52 *
53 * The key's `id` attribute is the secret value (`da2-…`) sent in the
54 * `x-api-key` request header. It is wrapped in `Redacted`; unwrap with
55 * `Redacted.value(key.id)` where the raw header value is needed.
56 * ### Creating API Keys
57 * **Example:** Key with the default 7-day expiry
58 * ```typescript
59 * const key = yield* AppSync.ApiKey("Key", { api });
60 * // Redacted.value(key.id) → "da2-…" — send as the x-api-key header
61 * ```
62 *
63 * **Example:** Key with a managed expiry
64 * ```typescript
65 * const key = yield* AppSync.ApiKey("Key", {
66 * api,
67 * description: "mobile clients",
68 * expires: 1893456000, // rounded down to the hour by AWS
69 * });
70 * ```
71 *
72 * @resource
73 */
74export const ApiKeyResource = Resource<AppSyncApiKey>("AWS.AppSync.ApiKey");
75
76export interface ApiKeyInputProps extends Omit<
77 {
78 [K in keyof ApiKeyProps]?: Input<ApiKeyProps[K]>;
79 },
80 "apiId"
81> {
82 /**
83 * The `GraphqlApi` this key belongs to (preferred). Alternatively pass a
84 * raw `apiId`.
85 */
86 api?: GraphqlApi;
87 apiId?: Input<string>;
88}
89
90/**
91 * User-facing wrapper for the ApiKey resource. Accepts `api: GraphqlApi`
92 * as the idiomatic way to mint a key for an API.
93 */
94export const ApiKey = (id: string, props: ApiKeyInputProps = {}) =>
95 Effect.gen(function* () {
96 const { api, ...rest } = props;
97 const apiId = rest.apiId ?? api?.apiId;
98 if (!apiId) {
99 return yield* Effect.die(
100 "ApiKey requires either `api` (preferred) or an explicit `apiId`.",
101 );
102 }
103 return yield* ApiKeyResource(id, { ...rest, apiId } as any);
104 });
105
106/** AWS rounds key expiry down to the nearest hour. */
107const floorToHour = (epochSeconds: number): number =>
108 Math.floor(epochSeconds / 3600) * 3600;
109
110export const ApiKeyProvider = () =>
111 Provider.effect(
112 ApiKeyResource,
113 Effect.gen(function* () {
114 /** Find a key by its ID (keys have no other stable identity). */
115 const findKey = Effect.fn(function* (apiId: string, keyId: string) {
116 const pages = yield* appsync.listApiKeys.pages({ apiId }).pipe(
117 Stream.runCollect,
118 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
119 );
120 return Array.from(pages)
121 .flatMap((page) => page.apiKeys ?? [])
122 .find((key) => key.id === keyId);
123 });
124
125 const toAttributes = (
126 apiId: string,
127 key: appsync.ApiKey,
128 ): AppSyncApiKey["Attributes"] => ({
129 apiId,
130 // The key id doubles as the secret `x-api-key` header value.
131 id: Redacted.make(key.id!),
132 expires: key.expires,
133 description: key.description,
134 });
135
136 return ApiKeyResource.Provider.of({
137 stables: ["apiId", "id"],
138
139 // Sub-resource keyed entirely by its GraphQL API (apiId) with no global
140 // enumeration API of its own — nuke reaches it through the parent's
141 // deletion, so enumeration returns empty per the ProviderService
142 // doctrine.
143 list: () => Effect.succeed([]),
144
145 read: Effect.fn(function* ({ output }) {
146 // Keys carry no deterministic identity — without the cached id
147 // there is nothing to look up (a fresh reconcile will mint one).
148 if (output?.id === undefined) return undefined;
149 const key = yield* findKey(output.apiId, Redacted.value(output.id));
150 if (key?.id == null) return undefined;
151 return toAttributes(output.apiId, key);
152 }),
153
154 diff: Effect.fn(function* ({ news, olds }) {
155 if (!isResolved(news)) return undefined;
156 if (news.apiId !== olds.apiId) {
157 return { action: "replace" } as const;
158 }
159 // description/expires converge via updateApiKey
160 }),
161
162 reconcile: Effect.fn(function* ({ news, output, session }) {
163 const apiId = output?.apiId ?? news.apiId;
164
165 // 1. OBSERVE — the cached key id is the only handle.
166 let observed =
167 output?.id !== undefined
168 ? yield* findKey(apiId, Redacted.value(output.id))
169 : undefined;
170
171 if (observed?.id == null) {
172 // 2. ENSURE
173 const created = yield* retryConcurrentModification(
174 appsync.createApiKey({
175 apiId,
176 description: news.description,
177 expires: news.expires,
178 }),
179 );
180 observed = created.apiKey!;
181 yield* session.note(`Created API key for ${apiId}`);
182 } else {
183 // 3. SYNC — update description/expiry on drift (expiry only
184 // when managed by props; AWS floors it to the hour).
185 const expiresDrifted =
186 news.expires !== undefined &&
187 observed.expires !== floorToHour(news.expires);
188 const descriptionDrifted =
189 news.description !== undefined &&
190 observed.description !== news.description;
191 if (expiresDrifted || descriptionDrifted) {
192 const updated = yield* retryConcurrentModification(
193 appsync.updateApiKey({
194 apiId,
195 id: observed.id,
196 description: news.description,
197 expires: news.expires,
198 }),
199 );
200 observed = updated.apiKey ?? observed;
201 yield* session.note(`Updated API key ${observed.id}`);
202 }
203 }
204
205 return toAttributes(apiId, observed);
206 }),
207
208 delete: Effect.fn(function* ({ output }) {
209 yield* retryConcurrentModification(
210 appsync
211 .deleteApiKey({
212 apiId: output.apiId,
213 id: Redacted.value(output.id),
214 })
215 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
216 );
217 }),
218 });
219 }),
220 );