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 Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as FetchHttpClient from "effect/http/FetchHttpClient";
8import type * as HttpClient from "effect/http/HttpClient";
9import * as Binding from "../Binding.ts";
10import type { Resource } from "../Resource.ts";
11import type { RuntimeContext } from "../RuntimeContext.ts";
12import type { Bucket } from "./Bucket.ts";
13import { TigrisCredentialsMissing } from "./Errors.ts";
14import type { ServiceBinding } from "./MountVolume.ts";
15
16/**
17 * Shared scaffolding for Tigris S3 bindings.
18 *
19 * Tigris speaks the S3 API. Each `{Op}Http.ts` is a thin
20 * `Layer.effect(Cap, makeTigrisS3Binding({ operation }))` that:
21 * - registers the bucket on the host so Service reconcile can wait for
22 * it and attach Tigris App secrets
23 * - `yield*`s credential Outputs so RuntimeContext.set runs
24 * - calls `@distilled.cloud/aws/s3` with those credentials and endpoint
25 *
26 * NOT exported from `index.ts`.
27 */
28export interface TigrisS3Scope {
29 bucketName: string;
30 accessKeyId: string;
31 secretAccessKey: string;
32 endpoint: string;
33 region: RegionName;
34}
35
36const isFlyHost = (
37 value: unknown,
38): value is Resource<string, any, any, ServiceBinding> =>
39 typeof value === "object" &&
40 value !== null &&
41 ((value as { Type?: string }).Type === "Fly.Service" ||
42 (value as { Type?: string }).Type === "Fly.Machine");
43
44const asPlain = (value: unknown): string | undefined => {
45 if (typeof value === "string" && value.length > 0) return value;
46 if (Redacted.isRedacted(value)) return asPlain(Redacted.value(value));
47 return undefined;
48};
49
50const authorizeS3 = <A, E>(
51 scope: TigrisS3Scope,
52 operation: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
53): Effect.Effect<A, E, RuntimeContext> =>
54 operation.pipe(
55 Effect.provide(
56 Layer.mergeAll(
57 fromCredentials(
58 {
59 accessKeyId: scope.accessKeyId,
60 secretAccessKey: scope.secretAccessKey,
61 },
62 scope.region,
63 ),
64 AwsEndpoint.of(scope.endpoint),
65 FetchHttpClient.layer,
66 ),
67 ),
68 ) as Effect.Effect<A, E, RuntimeContext>;
69
70export const makeTigrisS3Binding = <
71 I extends { Bucket?: string },
72 A,
73 E,
74>(options: {
75 tag: string;
76 operation: (
77 input: I,
78 ) => Effect.Effect<A, E, Credentials | HttpClient.HttpClient>;
79}) =>
80 Effect.succeed(
81 Effect.fn(function* (bucket: Bucket) {
82 if (!globalThis.__ALCHEMY_RUNTIME__) {
83 const host = yield* Binding.Host;
84 if (isFlyHost(host)) {
85 // Pass the Bucket resource itself so waitForDeps waits for it
86 // and apply evaluates to attributes (including create-only
87 // Tigris credentials). PropExprs of `name` alone can resolve
88 // from the deterministic physical name without waiting.
89 yield* host.bind`${bucket}`({
90 bucket: bucket as unknown as { name: string; id?: string },
91 });
92 }
93 }
94
95 const bucketName = yield* bucket.bucketName;
96 const name = yield* bucket.name;
97 const accessKeyId = yield* bucket.accessKeyId;
98 const secretAccessKey = yield* bucket.secretAccessKey;
99 const endpoint = yield* bucket.endpoint;
100 const region = yield* bucket.region;
101
102 return Effect.fn(`${options.tag}(${bucket.LogicalId})`)(function* (
103 request?: Omit<I, "Bucket">,
104 ) {
105 const resolvedName = asPlain(yield* bucketName) ?? asPlain(yield* name);
106 const resolvedKey = asPlain(yield* accessKeyId);
107 const resolvedSecret = asPlain(yield* secretAccessKey);
108 const resolvedEndpoint = asPlain(yield* endpoint);
109 const resolvedRegion = asPlain(yield* region) ?? "auto";
110 if (
111 resolvedName === undefined ||
112 resolvedKey === undefined ||
113 resolvedSecret === undefined ||
114 resolvedEndpoint === undefined
115 ) {
116 return yield* new TigrisCredentialsMissing({
117 name: resolvedName ?? bucket.LogicalId,
118 });
119 }
120 const scope = {
121 bucketName: resolvedName,
122 accessKeyId: resolvedKey,
123 secretAccessKey: resolvedSecret,
124 endpoint: resolvedEndpoint,
125 region: resolvedRegion as RegionName,
126 } satisfies TigrisS3Scope;
127 return yield* authorizeS3(
128 scope,
129 options.operation({
130 ...request,
131 Bucket: scope.bucketName,
132 } as I),
133 );
134 });
135 }),
136 );