1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import { isResolved } from "../Diff.ts";
6import { createPhysicalName } from "../PhysicalName.ts";
7import * as Provider from "../Provider.ts";
8import { Resource } from "../Resource.ts";
9import type { Providers } from "./Providers.ts";
10
11export interface OrganizationApiKeyProps {
12 /** Organization that owns the key. Changes replace the key. */
13 orgId: string;
14 /** Restrict access to this project. Omission grants organization-wide access. Changes replace the key. */
15 projectId?: string;
16 /** Immutable key label. Defaults to an instance-qualified physical name. Adding, changing, or removing an explicit label replaces the key. */
17 name?: string;
18}
19
20export interface OrganizationApiKeyAttributes {
21 /** Organization that owns the key. */
22 orgId: string;
23 /** Project restriction, or undefined for organization-wide access. */
24 projectId: string | undefined;
25 /** Stable numeric API key identifier, not the secret token. */
26 keyId: number;
27 /** Observed key label. A matching label is never ownership evidence. */
28 name: string;
29 /** Reveal-once secret stored in Alchemy state. Never the deployment API key. */
30 key: Redacted.Redacted<string>;
31 /** Issuance timestamp. */
32 createdAt: string;
33}
34
35export interface OrganizationApiKey extends Resource<
36 "Neon.OrganizationApiKey",
37 OrganizationApiKeyProps,
38 OrganizationApiKeyAttributes,
39 never,
40 Providers
41> {}
42
43/**
44 * A reveal-once Neon management API key owned by an organization, optionally
45 * restricted to one project. This is not a branch service credential; use
46 * `Neon.Credential` for branch-scoped storage and backend permissions.
47 *
48 * Keep Alchemy state secure and backed up. Neon cannot reveal an existing key.
49 * Missing secrets, revoked keys, and same-name foreign keys fail with
50 * `OrganizationApiKeyRecoveryError` instead of being adopted or silently rotated.
51 * Restore the original state or explicitly revoke the recorded key and replace
52 * the resource. Changing the organization, project restriction, or name replaces
53 * the key. Explicit names may require deleting the recorded old key first.
54 *
55 * ### Project-restricted access
56 * **Example:** Provision a key for a test-owned project
57 * ```typescript
58 * const project = yield* Neon.Project("Application", { orgId: "org-example" });
59 * const key = yield* Neon.OrganizationApiKey("Automation", {
60 * orgId: "org-example",
61 * projectId: project.projectId,
62 * });
63 * ```
64 *
65 * ### Organization-wide access
66 * **Example:** Explicitly choose organization-wide administration
67 * ```typescript
68 * const key = yield* Neon.OrganizationApiKey("OrganizationAdministration", {
69 * orgId: "org-example",
70 * });
71 * ```
72 * Omitting `projectId` grants broad organization access; prefer a project
73 * restriction whenever possible. The secret output is `key.key`, a Redacted value.
74 *
75 * @resource
76 * @product Organization
77 */
78export const OrganizationApiKey = Resource<OrganizationApiKey>(
79 "Neon.OrganizationApiKey",
80);
81
82export class OrganizationApiKeyRecoveryError extends Data.TaggedError(
83 "OrganizationApiKeyRecoveryError",
84)<{
85 message: string;
86}> {}
87
88type KeyScope = Pick<OrganizationApiKeyProps, "orgId" | "projectId">;
89type KeyMetadata = Pick<
90 Neon.OrgApiKeysListResponseItem,
91 "id" | "name" | "project_id" | "created_at"
92>;
93
94const recoveryError = () =>
95 new OrganizationApiKeyRecoveryError({
96 message:
97 "Organization API key identity or reveal-once secret cannot be recovered. Restore the original Alchemy state, or explicitly revoke the recorded key and replace the resource; names do not authorize adoption or recreation.",
98 });
99
100const sameScope = (a: KeyScope, b: KeyScope) =>
101 a.orgId === b.orgId && a.projectId === b.projectId;
102
103const validScope = (scope: KeyScope) =>
104 typeof scope.orgId === "string" &&
105 scope.orgId.trim().length > 0 &&
106 (scope.projectId === undefined ||
107 (typeof scope.projectId === "string" && scope.projectId.trim().length > 0));
108
109const validateScope = (scope: KeyScope) =>
110 validScope(scope) ? Effect.void : Effect.fail(recoveryError());
111
112/** Preserve a cached secret only when the observed ID and complete scope match. */
113export const recoverOrganizationApiKey = (
114 scope: KeyScope,
115 observed: KeyMetadata | undefined,
116 cached: OrganizationApiKeyAttributes | undefined,
117): Effect.Effect<
118 OrganizationApiKeyAttributes,
119 OrganizationApiKeyRecoveryError
120> => {
121 if (
122 !validScope(scope) ||
123 !observed ||
124 !cached ||
125 !Number.isSafeInteger(observed.id) ||
126 observed.id <= 0 ||
127 cached.keyId !== observed.id ||
128 !sameScope(scope, cached) ||
129 observed.project_id !== scope.projectId ||
130 !Redacted.isRedacted(cached.key) ||
131 typeof Redacted.value(cached.key) !== "string" ||
132 Redacted.value(cached.key).length === 0
133 ) {
134 return Effect.fail(recoveryError());
135 }
136 return Effect.succeed({
137 ...scope,
138 projectId: observed.project_id,
139 keyId: observed.id,
140 name: observed.name,
141 key: cached.key,
142 createdAt: observed.created_at,
143 });
144};
145
146const observeKey = Effect.fn(function* (
147 scope: KeyScope,
148 name: string,
149 cached?: OrganizationApiKeyAttributes,
150) {
151 yield* validateScope(scope);
152 // This endpoint returns one complete array, with no pagination or cursor.
153 const keys = yield* Neon.listOrgApiKeys({ org_id: scope.orgId }).pipe(
154 Neon.Retry.none,
155 );
156 const matches = keys.filter((key) =>
157 cached ? key.id === cached.keyId : key.name === name,
158 );
159 if (matches.length > 1) return yield* recoveryError();
160 return matches[0];
161});
162
163export const OrganizationApiKeyProvider = () =>
164 Provider.succeed(OrganizationApiKey, {
165 stables: ["orgId", "projectId", "keyId", "name", "key", "createdAt"],
166 diff: Effect.fn(function* ({ olds, news, output }) {
167 if (!isResolved(news)) return { action: "replace" };
168 if (news.name !== olds.name || (output && !sameScope(news, output))) {
169 return {
170 action: "replace",
171 deleteFirst:
172 output !== undefined &&
173 news.orgId === output.orgId &&
174 news.name === output.name,
175 };
176 }
177 }),
178 read: Effect.fn(function* ({ id, olds, output }) {
179 if (!output && !olds) return undefined;
180 const scope = output ?? olds!;
181 const name =
182 output?.name ??
183 olds?.name ??
184 (yield* createPhysicalName({ id, maxLength: 100 }));
185 const observed = yield* observeKey(scope, name, output);
186 if (!observed && !output) return undefined;
187 return yield* recoverOrganizationApiKey(scope, observed, output);
188 }),
189 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
190 yield* validateScope(news);
191 if (
192 (output &&
193 (!sameScope(news, output) ||
194 (news.name !== undefined && news.name !== output.name))) ||
195 (olds && news.name !== olds.name) ||
196 (!output && olds)
197 ) {
198 return yield* recoveryError();
199 }
200 const name =
201 news.name ??
202 output?.name ??
203 (yield* createPhysicalName({ id, maxLength: 100 }));
204 if (!name.trim()) return yield* recoveryError();
205 const observed = yield* observeKey(news, name, output);
206 if (observed || output) {
207 const recovered = yield* recoverOrganizationApiKey(
208 news,
209 observed,
210 output,
211 );
212 if (recovered.name !== name) return yield* recoveryError();
213 return recovered;
214 }
215 // A retried reveal-once POST could issue an untracked second key.
216 const created = yield* Neon.createOrgApiKey({
217 org_id: news.orgId,
218 project_id: news.projectId,
219 key_name: name,
220 }).pipe(
221 Neon.Retry.none,
222 Effect.catchTag("Conflict", () => Effect.fail(recoveryError())),
223 );
224 return yield* recoverOrganizationApiKey(news, created, {
225 orgId: news.orgId,
226 projectId: news.projectId,
227 keyId: created.id,
228 name: created.name,
229 key: Redacted.isRedacted(created.key)
230 ? created.key
231 : Redacted.make(created.key),
232 createdAt: created.created_at,
233 });
234 }),
235 delete: Effect.fn(function* ({ output }) {
236 const observed = yield* observeKey(output, output.name, output).pipe(
237 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
238 );
239 if (!observed) return;
240 if (observed.project_id !== output.projectId)
241 return yield* recoveryError();
242 yield* Neon.revokeOrgApiKey({
243 org_id: output.orgId,
244 key_id: output.keyId,
245 }).pipe(
246 Neon.Retry.none,
247 Effect.catchTag("NotFound", () => Effect.void),
248 );
249 }),
250 });