1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import {
7 attrOrRedactedString,
8 attrOrString,
9 devId,
10 devProvider,
11} from "./Internal/DevStub.ts";
12import * as ProviderLayer from "../Local/ProviderLayer.ts";
13import { Resource } from "../Resource.ts";
14import type { Bucket } from "./Bucket.ts";
15import {
16 type GetBucketKeysResponse,
17 deleteBucketKey,
18 getBucketKeys,
19 createBucketKey,
20} from "@distilled.cloud/prisma/management";
21import { Retry } from "@distilled.cloud/prisma";
22import { physicalInstanceName } from "./Internal/EnvName.ts";
23import type { Providers } from "./Providers.ts";
24import {
25 concreteIdsChanged,
26 isInputObject,
27 isPrismaDevId,
28 resolveBucketId,
29 unresolvedBucketIdOf,
30} from "./Refs.ts";
31import {
32 type ObservedBucketKey,
33 requiredSecretValue,
34} from "./Internal/Observed.ts";
35import type { BucketKeyRole } from "./Types.ts";
36import { PrismaPaginationError } from "./Internal/Pagination.ts";
37
38export interface BucketAccessKeyProps {
39 /**
40 * Bucket ID or `bucket.bucketId` output this key grants access to.
41 */
42 bucket: string | Bucket;
43 /**
44 * Human-readable key name prefix. Alchemy appends the resource instance
45 * identity so an interrupted create can be recovered by name instead of
46 * minting a second, unenumerable key.
47 *
48 * @default the resource's logical ID
49 */
50 name?: string;
51 /**
52 * Access role for the key: `"read"` or `"read_write"`.
53 */
54 role: BucketKeyRole;
55}
56
57export interface BucketAccessKey extends Resource<
58 "Prisma.BucketAccessKey",
59 BucketAccessKeyProps,
60 {
61 /**
62 * Prisma bucket key ID.
63 */
64 bucketAccessKeyId: string;
65 /**
66 * Bucket ID the key belongs to, persisted so deletion can address both
67 * path parameters.
68 */
69 bucketId: string;
70 /**
71 * S3 access key ID.
72 */
73 accessKeyId: string;
74 /**
75 * S3 secret access key, redacted in state. Prisma returns it exactly
76 * once at creation and never again, so the persisted state is the
77 * authoritative copy.
78 */
79 secretAccessKey: Redacted.Redacted<string>;
80 /**
81 * S3-compatible endpoint URL for the bucket's region.
82 */
83 endpoint: string;
84 /**
85 * Provider-side S3 bucket name (e.g. `user-<id>`). S3 clients must use
86 * this as the bucket name, not the display name chosen on the bucket.
87 */
88 bucketName: string;
89 },
90 never,
91 Providers
92> {}
93
94/**
95 * An access key for a Prisma Object Store bucket, yielding S3 credentials.
96 *
97 * Prisma returns the secret access key only in the create response, so
98 * Alchemy stores it as a `Redacted` value and treats persisted state as
99 * authoritative: once created, the secret is never re-read from the API.
100 * Changing the bucket, name, or role replaces the key with fresh
101 * credentials.
102 *
103 * ### Creating a Bucket Access Key
104 * **Example:** Read-write credentials for a bucket
105 * ```typescript
106 * const key = yield* Prisma.BucketAccessKey("uploads-key", {
107 * bucket,
108 * role: "read_write",
109 * });
110 * ```
111 *
112 * ### Binding to Platforms
113 * **Example:** Pass S3 credentials to Compute env
114 * ```typescript
115 * const app = yield* Prisma.Compute("api", {
116 * project,
117 * path: "./apps/api",
118 * env: {
119 * S3_ENDPOINT: key.endpoint,
120 * S3_BUCKET: key.bucketName,
121 * S3_ACCESS_KEY_ID: key.accessKeyId,
122 * S3_SECRET_ACCESS_KEY: key.secretAccessKey,
123 * },
124 * });
125 * ```
126 *
127 * @resource
128 * @product Bucket
129 */
130export const BucketAccessKey = Resource<BucketAccessKey>(
131 "Prisma.BucketAccessKey",
132);
133
134const BUCKET_ACCESS_KEY_STABLES = [
135 "bucketAccessKeyId",
136 "bucketId",
137 "accessKeyId",
138 "secretAccessKey",
139 "endpoint",
140 "bucketName",
141] satisfies Extract<keyof BucketAccessKey["Attributes"], string>[];
142
143/**
144 * Raised when more than one key on a bucket carries the deterministic name
145 * of a single resource instance, so no key can be recovered unambiguously.
146 */
147export class AmbiguousBucketAccessKeyError extends Data.TaggedError(
148 "AmbiguousBucketAccessKeyError",
149)<{
150 bucketId: string;
151 name: string;
152 count: number;
153 message: string;
154}> {}
155
156// Distilled emits the cursor-paginated list operations as plain ops, so
157// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
158const listKeys = (bucketId: string) =>
159 Effect.gen(function* () {
160 const keys: GetBucketKeysResponse["data"][number][] = [];
161 let cursor: string | undefined;
162 while (true) {
163 const page = yield* getBucketKeys(
164 cursor === undefined
165 ? { bucketId, limit: 100 }
166 : { bucketId, limit: 100, cursor },
167 );
168 keys.push(...page.data);
169 const nextCursor = page.pagination.nextCursor;
170 if (!page.pagination.hasMore) break;
171 if (nextCursor === null) {
172 return yield* Effect.fail(
173 new PrismaPaginationError({
174 message:
175 "Invalid Prisma Management API pagination response from getBucketKeys: hasMore was true without a non-empty nextCursor",
176 }),
177 );
178 }
179 cursor = nextCursor;
180 }
181 return keys;
182 }).pipe(Effect.catchTag("NotFound", () => Effect.succeed([])));
183
184const uniqueKeyNamed = (bucketId: string, expectedName: string) =>
185 listKeys(bucketId).pipe(
186 Effect.flatMap((keys) => {
187 const matches = keys.filter(
188 (key: ObservedBucketKey) => key.name === expectedName,
189 );
190 return matches.length > 1
191 ? Effect.fail(
192 new AmbiguousBucketAccessKeyError({
193 bucketId,
194 name: expectedName,
195 count: matches.length,
196 message: `Prisma bucket '${bucketId}' has ${matches.length} keys named '${expectedName}'; refusing to select one arbitrarily`,
197 }),
198 )
199 : Effect.succeed(matches[0]);
200 }),
201 );
202
203const ProviderLive = () =>
204 Provider.effect(
205 BucketAccessKey,
206 Effect.gen(function* () {
207 return {
208 stables: BUCKET_ACCESS_KEY_STABLES,
209 // Bucket keys cannot be listed account-wide, and bucket deletion
210 // revokes them server-side, so nuke has nothing to enumerate here.
211 list: () => Effect.succeed([]),
212 diff: Effect.fn(function* ({ olds, news, output }) {
213 if (!isInputObject(news)) return undefined;
214 if (isPrismaDevId(output?.bucketAccessKeyId)) {
215 return { action: "update" } as const;
216 }
217 const oldBucketId =
218 output?.bucketId ?? unresolvedBucketIdOf(olds.bucket);
219 const newBucketId = isResolved(news.bucket)
220 ? unresolvedBucketIdOf(news.bucket)
221 : undefined;
222 if (concreteIdsChanged(oldBucketId, newBucketId)) {
223 return { action: "replace" } as const;
224 }
225 // A key's name and role are fixed at creation, so changes replace
226 // the key and mint fresh credentials.
227 if (isResolved(news.role) && news.role !== olds.role) {
228 return { action: "replace" } as const;
229 }
230 if (
231 isResolved(news.name) &&
232 news.name !== undefined &&
233 news.name !== olds.name
234 ) {
235 return { action: "replace" } as const;
236 }
237 return undefined;
238 }),
239 read: Effect.fn(function* ({ output }) {
240 // The secret access key can never be re-read from the API, so the
241 // persisted state stays authoritative for credentials; the list
242 // endpoint only confirms the key still exists.
243 if (!output || isPrismaDevId(output.bucketAccessKeyId)) return output;
244 const keys = yield* listKeys(output.bucketId);
245 return keys.some(
246 (key: ObservedBucketKey) => key.id === output.bucketAccessKeyId,
247 )
248 ? output
249 : undefined;
250 }),
251 reconcile: Effect.fn(function* ({ id, instanceId, news, output }) {
252 const persisted =
253 output && !isPrismaDevId(output.bucketAccessKeyId)
254 ? output
255 : undefined;
256 if (persisted) {
257 // Prisma returns the secret exactly once, at creation. Persisted
258 // state is authoritative afterwards — but only while the key
259 // still exists; a revoked key falls through to mint fresh
260 // credentials.
261 const keys = yield* listKeys(persisted.bucketId);
262 if (
263 keys.some(
264 (key: ObservedBucketKey) =>
265 key.id === persisted.bucketAccessKeyId,
266 )
267 ) {
268 return persisted;
269 }
270 }
271 const bucketId = yield* resolveBucketId(news.bucket);
272 const expectedName = physicalInstanceName(
273 news.name ?? id,
274 instanceId,
275 );
276 // A crash after create but before state persist leaves a key under
277 // the deterministic name whose secret was never persisted and can
278 // never be recovered. Revoke it and mint a fresh key rather than
279 // leaking an unusable credential.
280 const orphan = yield* uniqueKeyNamed(bucketId, expectedName);
281 if (orphan) {
282 yield* deleteBucketKey({
283 bucketId,
284 keyId: orphan.id,
285 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
286 }
287 const created = yield* createBucketKey({
288 bucketId,
289 name: expectedName,
290 role: news.role,
291 }).pipe(
292 // The secret is revealed exactly once, so a replayed create
293 // would leak an unusable key; opt out of the retry policy.
294 Retry.none,
295 Effect.map((response) => response.data),
296 );
297 return {
298 bucketAccessKeyId: created.id,
299 bucketId,
300 accessKeyId: requiredSecretValue(created.accessKeyId),
301 secretAccessKey: Redacted.make(
302 requiredSecretValue(created.secretAccessKey),
303 ),
304 endpoint: created.endpoint,
305 bucketName: created.bucketName,
306 } satisfies BucketAccessKey["Attributes"];
307 }),
308 delete: Effect.fn(function* ({ output }) {
309 if (isPrismaDevId(output.bucketAccessKeyId)) return;
310 // Bucket deletion revokes remaining keys server-side, so the key
311 // may already be gone when the bucket was destroyed first.
312 yield* deleteBucketKey({
313 bucketId: output.bucketId,
314 keyId: output.bucketAccessKeyId,
315 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
316 }),
317 };
318 }),
319 );
320
321const ProviderLocal = () =>
322 devProvider(
323 BucketAccessKey,
324 BUCKET_ACCESS_KEY_STABLES,
325 ({ id, news, output }) => ({
326 bucketAccessKeyId: devId("bucket-access-key", id),
327 bucketId: attrOrString(news.bucket, "bucketId") ?? devId("bucket", id),
328 accessKeyId: devId("access-key", id),
329 // Keep the fabricated secret stable across dev reconciles, mirroring
330 // the reveal-once live behavior where persisted state is authoritative.
331 secretAccessKey:
332 attrOrRedactedString(output, "secretAccessKey") ??
333 Redacted.make(devId("secret-access-key", id)),
334 endpoint: "http://localhost",
335 bucketName: `dev-${id}`,
336 }),
337 );
338
339export const BucketAccessKeyProvider = () =>
340 ProviderLayer.dual(BucketAccessKey, {
341 local: () => ProviderLocal(),
342 live: () => ProviderLive(),
343 });