| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import { isResolved } from "../Diff.ts"; |
| 5 | import * as Provider from "../Provider.ts"; |
| 6 | import { |
| 7 | attrOrRedactedString, |
| 8 | attrOrString, |
| 9 | devId, |
| 10 | devProvider, |
| 11 | } from "./Internal/DevStub.ts"; |
| 12 | import * as ProviderLayer from "../Local/ProviderLayer.ts"; |
| 13 | import { Resource } from "../Resource.ts"; |
| 14 | import type { Bucket } from "./Bucket.ts"; |
| 15 | import { |
| 16 | type GetBucketKeysResponse, |
| 17 | deleteBucketKey, |
| 18 | getBucketKeys, |
| 19 | createBucketKey, |
| 20 | } from "@distilled.cloud/prisma/management"; |
| 21 | import { Retry } from "@distilled.cloud/prisma"; |
| 22 | import { physicalInstanceName } from "./Internal/EnvName.ts"; |
| 23 | import type { Providers } from "./Providers.ts"; |
| 24 | import { |
| 25 | concreteIdsChanged, |
| 26 | isInputObject, |
| 27 | isPrismaDevId, |
| 28 | resolveBucketId, |
| 29 | unresolvedBucketIdOf, |
| 30 | } from "./Refs.ts"; |
| 31 | import { |
| 32 | type ObservedBucketKey, |
| 33 | requiredSecretValue, |
| 34 | } from "./Internal/Observed.ts"; |
| 35 | import type { BucketKeyRole } from "./Types.ts"; |
| 36 | import { PrismaPaginationError } from "./Internal/Pagination.ts"; |
| 37 | |
| 38 | export 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 | |
| 57 | export 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 | */ |
| 130 | export const BucketAccessKey = Resource<BucketAccessKey>( |
| 131 | "Prisma.BucketAccessKey", |
| 132 | ); |
| 133 | |
| 134 | const 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 | */ |
| 147 | export 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`). |
| 158 | const 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 | |
| 184 | const 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 | |
| 203 | const 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 | |
| 321 | const 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 | |
| 339 | export const BucketAccessKeyProvider = () => |
| 340 | ProviderLayer.dual(BucketAccessKey, { |
| 341 | local: () => ProviderLocal(), |
| 342 | live: () => ProviderLive(), |
| 343 | }); |