1import { Credentials, fromCredentials } from "@distilled.cloud/aws/Credentials";
2import * as AwsEndpoint from "@distilled.cloud/aws/Endpoint";
3import type { RegionName } from "@distilled.cloud/aws/Region";
4import * as Config from "effect/Config";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as FetchHttpClient from "effect/http/FetchHttpClient";
10import type * as HttpClient from "effect/http/HttpClient";
11import * as Binding from "../Binding.ts";
12import type { RuntimeContext } from "../RuntimeContext.ts";
13import type { Bucket } from "./Bucket.ts";
14import { isRailwayHost } from "./MountVolume.ts";
15
16/**
17 * Shared scaffolding for Railway S3 bindings.
18 *
19 * Railway buckets speak the S3 API. Each `{Op}Http.ts` is a thin
20 * `Layer.effect(Cap, makeRailwayS3Binding({ operation }))` that:
21 * - registers the bucket on the host so Service reconcile can write
22 * `AWS_*` / `BUCKET_NAME` variables
23 * - calls `@distilled.cloud/aws/s3` with those credentials and endpoint
24 *
25 * NOT exported from `index.ts`.
26 */
27export class RailwayS3CredentialsMissing extends Data.TaggedError(
28 "Railway.S3CredentialsMissing",
29)<{
30 name: string;
31}> {}
32
33export interface RailwayS3Scope {
34 bucketName: string;
35 accessKeyId: string;
36 secretAccessKey: string;
37 endpoint: string;
38 region: RegionName;
39}
40
41const asPlain = (value: unknown): string | undefined => {
42 if (typeof value === "string" && value.length > 0) return value;
43 if (Redacted.isRedacted(value)) return asPlain(Redacted.value(value));
44 return undefined;
45};
46
47const readValue = (value: unknown): Effect.Effect<string | undefined> =>
48 Effect.gen(function* () {
49 const direct = asPlain(value);
50 if (direct !== undefined) return direct;
51 if (Effect.isEffect(value)) {
52 return asPlain(yield* value as Effect.Effect<unknown>);
53 }
54 return undefined;
55 });
56
57const scopeFromResource = (bucket: Bucket) =>
58 Effect.gen(function* () {
59 const bucketName =
60 (yield* readValue(bucket.s3BucketName)) ??
61 (yield* readValue(bucket.name));
62 const accessKeyId = yield* readValue(bucket.accessKeyId);
63 const secretAccessKey = yield* readValue(bucket.secretAccessKey);
64 const endpoint = yield* readValue(bucket.endpoint);
65 const region =
66 (yield* readValue(bucket.s3Region)) ??
67 (yield* readValue(bucket.region)) ??
68 "auto";
69 if (
70 bucketName === undefined ||
71 accessKeyId === undefined ||
72 secretAccessKey === undefined ||
73 endpoint === undefined
74 ) {
75 return yield* new RailwayS3CredentialsMissing({
76 name: bucketName ?? bucket.LogicalId,
77 });
78 }
79 return {
80 bucketName,
81 accessKeyId,
82 secretAccessKey,
83 endpoint,
84 region: region as RegionName,
85 } satisfies RailwayS3Scope;
86 });
87
88const scopeFromEnv = Effect.gen(function* () {
89 const bucketName = yield* Config.String("BUCKET_NAME").pipe(
90 Config.orElse(() => Config.String("AWS_S3_BUCKET_NAME")),
91 );
92 const accessKeyId = yield* Config.String("AWS_ACCESS_KEY_ID");
93 const secretAccessKey = yield* Config.Redacted("AWS_SECRET_ACCESS_KEY");
94 const endpoint = yield* Config.String("AWS_ENDPOINT_URL_S3").pipe(
95 Config.orElse(() => Config.String("AWS_ENDPOINT_URL")),
96 );
97 const region = yield* Config.String("AWS_REGION").pipe(
98 Config.withDefault("auto"),
99 );
100 return {
101 bucketName,
102 accessKeyId,
103 secretAccessKey: Redacted.value(secretAccessKey),
104 endpoint,
105 region: region as RegionName,
106 } satisfies RailwayS3Scope;
107});
108
109const authorizeS3 = <A, E>(
110 scope: RailwayS3Scope,
111 operation: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
112): Effect.Effect<A, E, RuntimeContext> =>
113 operation.pipe(
114 Effect.provide(
115 Layer.mergeAll(
116 fromCredentials(
117 {
118 accessKeyId: scope.accessKeyId,
119 secretAccessKey: scope.secretAccessKey,
120 },
121 scope.region,
122 ),
123 AwsEndpoint.of(scope.endpoint),
124 FetchHttpClient.layer,
125 ),
126 ),
127 ) as Effect.Effect<A, E, RuntimeContext>;
128
129export const makeRailwayS3Binding = <
130 I extends { Bucket?: string },
131 A,
132 E,
133>(options: {
134 tag: string;
135 operation: (
136 input: I,
137 ) => Effect.Effect<A, E, Credentials | HttpClient.HttpClient>;
138}) =>
139 Effect.succeed(
140 Effect.fn(function* (bucket: Bucket) {
141 if (!globalThis.__ALCHEMY_RUNTIME__) {
142 const host = yield* Binding.Host;
143 if (isRailwayHost(host)) {
144 yield* host.bind`${bucket}`({
145 env: {
146 AWS_ACCESS_KEY_ID: bucket.accessKeyId,
147 AWS_SECRET_ACCESS_KEY: bucket.secretAccessKey,
148 AWS_ENDPOINT_URL_S3: bucket.endpoint,
149 AWS_ENDPOINT_URL: bucket.endpoint,
150 AWS_REGION: bucket.s3Region,
151 BUCKET_NAME: bucket.s3BucketName,
152 AWS_S3_BUCKET_NAME: bucket.s3BucketName,
153 AWS_S3_URL_STYLE: bucket.urlStyle,
154 },
155 });
156 }
157 }
158
159 return Effect.fn(`${options.tag}(${bucket.LogicalId})`)(function* (
160 request?: Omit<I, "Bucket">,
161 ) {
162 const scope = globalThis.__ALCHEMY_RUNTIME__
163 ? yield* scopeFromEnv
164 : yield* scopeFromResource(bucket);
165 return yield* authorizeS3(
166 scope,
167 options.operation({
168 ...request,
169 Bucket: scope.bucketName,
170 } as I),
171 );
172 });
173 }),
174 );