| 1 | import { createHash } from "node:crypto"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as HttpClient from "effect/http/HttpClient"; |
| 8 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 9 | import { defaultProviderMode } from "../ProviderMode.ts"; |
| 10 | import * as Binding from "../Binding.ts"; |
| 11 | import * as Output from "../Output.ts"; |
| 12 | import { |
| 13 | CurrentRuntimeContext, |
| 14 | type RuntimeContext, |
| 15 | } from "../RuntimeContext.ts"; |
| 16 | import { bindBackendEnvironment } from "./BackendConnection.ts"; |
| 17 | import { scopeIdentity, usesInjectedCredentials } from "./CredentialScope.ts"; |
| 18 | import type { Bucket } from "./Bucket.ts"; |
| 19 | import { Credential, validateCredential } from "./Credential.ts"; |
| 20 | import { FunctionEnvironment } from "./FunctionEnvironment.ts"; |
| 21 | import { |
| 22 | makeStorageClient, |
| 23 | type StorageClient, |
| 24 | type StorageConfig, |
| 25 | } from "./Storage.ts"; |
| 26 | |
| 27 | export interface StorageBindingOptions { |
| 28 | /** Explicit sharing; validated against the requested scope and branch lineage. */ |
| 29 | credential?: Credential; |
| 30 | } |
| 31 | export class StorageBindingError extends Data.TaggedError( |
| 32 | "StorageBindingError", |
| 33 | )<{ message: string }> {} |
| 34 | |
| 35 | export type RuntimeStorageMethods<K extends keyof StorageClient> = { |
| 36 | [P in K]: ( |
| 37 | ...args: Parameters<StorageClient[P]> |
| 38 | ) => Effect.Effect< |
| 39 | Effect.Success<ReturnType<StorageClient[P]>>, |
| 40 | Effect.Error<ReturnType<StorageClient[P]>> | StorageBindingError, |
| 41 | RuntimeContext |
| 42 | >; |
| 43 | }; |
| 44 | |
| 45 | /** Internal host wiring shared by read/write and object-bound capabilities. */ |
| 46 | export const makeStorageBinding = (scope: "storage:read" | "storage:write") => |
| 47 | Effect.gen(function* () { |
| 48 | const http = yield* HttpClient.HttpClient; |
| 49 | const runtime = yield* CurrentRuntimeContext; |
| 50 | if (!runtime) |
| 51 | return yield* Effect.die( |
| 52 | new StorageBindingError({ |
| 53 | message: "Storage bindings require a Platform host", |
| 54 | }), |
| 55 | ); |
| 56 | const environment = yield* Effect.serviceOption(FunctionEnvironment); |
| 57 | const createCredential = yield* Credential; |
| 58 | return Effect.fn(function* ( |
| 59 | bucket: Bucket, |
| 60 | options: StorageBindingOptions = {}, |
| 61 | ) { |
| 62 | const host = yield* Binding.Host; |
| 63 | if (!host) |
| 64 | return yield* Effect.die( |
| 65 | new StorageBindingError({ |
| 66 | message: "Storage binding requires a runtime host", |
| 67 | }), |
| 68 | ); |
| 69 | const digest = yield* Effect.sync(() => |
| 70 | createHash("sha256") |
| 71 | .update( |
| 72 | `${host.FQN}:${scopeIdentity(bucket.Props) ?? bucket.FQN}:${scope}:${options.credential?.FQN ?? "managed"}`, |
| 73 | ) |
| 74 | .digest("hex") |
| 75 | .slice(0, 24), |
| 76 | ); |
| 77 | const prefix = `NEON_STORAGE_${digest.toUpperCase()}`; |
| 78 | const bucketKey = `${prefix}_${yield* Effect.sync(() => createHash("sha256").update(bucket.FQN).digest("hex").slice(0, 12).toUpperCase())}_BUCKET`; |
| 79 | const keys = { |
| 80 | injected: `${prefix}_INJECTED`, |
| 81 | endpoint: `${prefix}_ENDPOINT`, |
| 82 | region: `${prefix}_REGION`, |
| 83 | accessKeyId: `${prefix}_ACCESS_KEY_ID`, |
| 84 | secretAccessKey: `${prefix}_SECRET_ACCESS_KEY`, |
| 85 | }; |
| 86 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 87 | const mode = host.Mode ?? (yield* defaultProviderMode); |
| 88 | const injected = |
| 89 | !options.credential && |
| 90 | usesInjectedCredentials(host, bucket.Props, mode); |
| 91 | const env: Record< |
| 92 | string, |
| 93 | Output.Output<string | Redacted.Redacted<string>> |
| 94 | > = { |
| 95 | [bucketKey]: bucket.bucketName, |
| 96 | [keys.injected]: Output.literal(injected ? "yes" : "no"), |
| 97 | [keys.endpoint]: bucket.endpoint, |
| 98 | [keys.region]: bucket.region, |
| 99 | }; |
| 100 | if (!injected) { |
| 101 | const credential = |
| 102 | options.credential ?? |
| 103 | (yield* createCredential(`Storage${digest}`, { |
| 104 | ...(bucket.Props.branch !== undefined |
| 105 | ? { branch: bucket.Props.branch } |
| 106 | : { project: bucket.Props.project }), |
| 107 | scopes: |
| 108 | scope === "storage:write" |
| 109 | ? ["storage:read", "storage:write"] |
| 110 | : [scope], |
| 111 | })); |
| 112 | const validated = Output.all( |
| 113 | Output.of(credential), |
| 114 | Output.of(bucket), |
| 115 | ).pipe( |
| 116 | Output.mapEffect( |
| 117 | Effect.fn(function* ([credential, target]) { |
| 118 | yield* validateCredential(credential, target, scope).pipe( |
| 119 | Effect.orDie, |
| 120 | ); |
| 121 | if (scope === "storage:write") |
| 122 | yield* validateCredential( |
| 123 | credential, |
| 124 | target, |
| 125 | "storage:read", |
| 126 | ).pipe(Effect.orDie); |
| 127 | return credential; |
| 128 | }), |
| 129 | ), |
| 130 | ); |
| 131 | env[keys.accessKeyId] = validated.tokenId; |
| 132 | env[keys.secretAccessKey] = validated.s3SecretAccessKey; |
| 133 | } |
| 134 | yield* bindBackendEnvironment(`Storage${digest}:${bucket.FQN}`, env); |
| 135 | } |
| 136 | const required = (key: string) => |
| 137 | runtime.get<string | Redacted.Redacted<string>>(key).pipe( |
| 138 | Effect.flatMap((value) => |
| 139 | value === undefined |
| 140 | ? Effect.fail( |
| 141 | new StorageBindingError({ |
| 142 | message: `Missing storage binding variable ${key}`, |
| 143 | }), |
| 144 | ) |
| 145 | : Effect.succeed( |
| 146 | Redacted.isRedacted(value) ? Redacted.value(value) : value, |
| 147 | ), |
| 148 | ), |
| 149 | ); |
| 150 | const config = Effect.gen(function* () { |
| 151 | if ((yield* required(keys.injected)) === "yes") { |
| 152 | const env = Option.getOrUndefined(environment); |
| 153 | if ( |
| 154 | !env?.AWS_ACCESS_KEY_ID || |
| 155 | !env.AWS_SECRET_ACCESS_KEY || |
| 156 | !env.AWS_ENDPOINT_URL_S3 || |
| 157 | !env.AWS_REGION |
| 158 | ) { |
| 159 | return yield* new StorageBindingError({ |
| 160 | message: "Neon Function did not inject complete S3 credentials", |
| 161 | }); |
| 162 | } |
| 163 | return { |
| 164 | endpoint: env.AWS_ENDPOINT_URL_S3, |
| 165 | region: env.AWS_REGION, |
| 166 | accessKeyId: env.AWS_ACCESS_KEY_ID, |
| 167 | secretAccessKey: Redacted.make(env.AWS_SECRET_ACCESS_KEY), |
| 168 | } satisfies StorageConfig; |
| 169 | } |
| 170 | return { |
| 171 | endpoint: yield* required(keys.endpoint), |
| 172 | region: yield* required(keys.region), |
| 173 | accessKeyId: yield* required(keys.accessKeyId), |
| 174 | secretAccessKey: Redacted.make(yield* required(keys.secretAccessKey)), |
| 175 | } satisfies StorageConfig; |
| 176 | }); |
| 177 | const client = Effect.gen(function* () { |
| 178 | return yield* makeStorageClient( |
| 179 | yield* config, |
| 180 | yield* required(bucketKey), |
| 181 | ); |
| 182 | }).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient, http))); |
| 183 | return { |
| 184 | get: (key: string) => client.pipe(Effect.flatMap((c) => c.get(key))), |
| 185 | head: (key: string) => client.pipe(Effect.flatMap((c) => c.head(key))), |
| 186 | list: (options?: Parameters<StorageClient["list"]>[0]) => |
| 187 | client.pipe(Effect.flatMap((c) => c.list(options))), |
| 188 | presign: (...args: Parameters<StorageClient["presign"]>) => |
| 189 | client.pipe(Effect.flatMap((c) => c.presign(...args))), |
| 190 | put: (...args: Parameters<StorageClient["put"]>) => |
| 191 | client.pipe(Effect.flatMap((c) => c.put(...args))), |
| 192 | delete: (key: string) => |
| 193 | client.pipe(Effect.flatMap((c) => c.delete(key))), |
| 194 | deleteMany: (keys: string[]) => |
| 195 | client.pipe(Effect.flatMap((c) => c.deleteMany(keys))), |
| 196 | createMultipartUpload: ( |
| 197 | ...args: Parameters<StorageClient["createMultipartUpload"]> |
| 198 | ) => |
| 199 | client.pipe(Effect.flatMap((c) => c.createMultipartUpload(...args))), |
| 200 | uploadPart: (...args: Parameters<StorageClient["uploadPart"]>) => |
| 201 | client.pipe(Effect.flatMap((c) => c.uploadPart(...args))), |
| 202 | completeMultipartUpload: ( |
| 203 | ...args: Parameters<StorageClient["completeMultipartUpload"]> |
| 204 | ) => |
| 205 | client.pipe( |
| 206 | Effect.flatMap((c) => c.completeMultipartUpload(...args)), |
| 207 | ), |
| 208 | abortMultipartUpload: ( |
| 209 | ...args: Parameters<StorageClient["abortMultipartUpload"]> |
| 210 | ) => |
| 211 | client.pipe(Effect.flatMap((c) => c.abortMultipartUpload(...args))), |
| 212 | listMultipartUploads: ( |
| 213 | ...args: Parameters<StorageClient["listMultipartUploads"]> |
| 214 | ) => |
| 215 | client.pipe(Effect.flatMap((c) => c.listMultipartUploads(...args))), |
| 216 | listParts: (...args: Parameters<StorageClient["listParts"]>) => |
| 217 | client.pipe(Effect.flatMap((c) => c.listParts(...args))), |
| 218 | }; |
| 219 | }); |
| 220 | }); |
| 221 | |
| 222 | export const storageHttpLayer = FetchHttpClient.layer; |