1import { createHash } from "node:crypto";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import * as HttpClient from "effect/http/HttpClient";
8import * as FetchHttpClient from "effect/http/FetchHttpClient";
9import { defaultProviderMode } from "../ProviderMode.ts";
10import * as Binding from "../Binding.ts";
11import * as Output from "../Output.ts";
12import {
13 CurrentRuntimeContext,
14 type RuntimeContext,
15} from "../RuntimeContext.ts";
16import { bindBackendEnvironment } from "./BackendConnection.ts";
17import { scopeIdentity, usesInjectedCredentials } from "./CredentialScope.ts";
18import type { Bucket } from "./Bucket.ts";
19import { Credential, validateCredential } from "./Credential.ts";
20import { FunctionEnvironment } from "./FunctionEnvironment.ts";
21import {
22 makeStorageClient,
23 type StorageClient,
24 type StorageConfig,
25} from "./Storage.ts";
26
27export interface StorageBindingOptions {
28 /** Explicit sharing; validated against the requested scope and branch lineage. */
29 credential?: Credential;
30}
31export class StorageBindingError extends Data.TaggedError(
32 "StorageBindingError",
33)<{ message: string }> {}
34
35export 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. */
46export 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
222export const storageHttpLayer = FetchHttpClient.layer;