1import type {
2 CredentialsError,
3 ResolvedCredentials,
4} from "@distilled.cloud/aws/Credentials";
5import * as Config from "effect/Config";
6import * as Context from "effect/Context";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import {
11 deferUntilFirstUse,
12 orDieCredentialsUnavailable,
13 resolveProviderConfig,
14} from "../Auth/Resolve.ts";
15import {
16 AWS_AUTH_PROVIDER_NAME,
17 LOCAL_ACCOUNT_ID,
18 type AwsAuthConfig,
19 type AwsResolvedCredentials,
20} from "./AuthProvider.ts";
21
22export const AWS_PROFILE = Config.String("AWS_PROFILE").pipe(
23 Config.withDefault("default"),
24);
25
26export const AWS_REGION = Config.String("AWS_REGION");
27export const AWS_ACCOUNT_ID = Config.String("AWS_ACCOUNT_ID");
28export const AWS_ACCESS_KEY_ID = Config.String("AWS_ACCESS_KEY_ID");
29export const AWS_SECRET_ACCESS_KEY = Config.Redacted("AWS_SECRET_ACCESS_KEY");
30export const AWS_SESSION_TOKEN = Config.Redacted("AWS_SESSION_TOKEN");
31
32export type AccountID = string;
33export type RegionID = string;
34
35export class FailedToGetAccount extends Data.TaggedError(
36 "AWS::Environment::FailedToGetAccount",
37)<{
38 message: string;
39 cause: Error;
40}> {}
41
42/**
43 * Fully-resolved AWS environment for a stack. Mirrors `CloudflareEnvironment`:
44 * one Context.Service that holds account, region, credentials, endpoint, and
45 * (optionally) the SSO profile name.
46 *
47 * `credentials` is held as an Effect so callers can refresh on each access
48 * (SSO sessions expire). The Effect itself is constructed once when this
49 * service is built; resolving it lazily preserves @distilled.cloud/aws's
50 * existing `Credentials` semantics.
51 */
52export interface AWSEnvironmentShape {
53 accountId: AccountID;
54 region: RegionID;
55 credentials: Effect.Effect<ResolvedCredentials, CredentialsError>;
56 endpoint?: string;
57 profile?: string;
58}
59
60export class AWSEnvironment extends Context.Service<
61 AWSEnvironment,
62 Effect.Effect<AWSEnvironmentShape>
63>()("AWS::Environment") {
64 static current = AWSEnvironment.use((env) => env);
65 /**
66 * Whether this environment is the floci emulator
67 * (dummy account {@link LOCAL_ACCOUNT_ID}). A set `endpoint` is not
68 * enough: `AWS_ENDPOINT_URL` and explicit endpoint overrides also
69 * populate it on real-account credentials.
70 */
71 static isLocalEmulator = Effect.map(
72 AWSEnvironment.current,
73 (env) => env.accountId === LOCAL_ACCOUNT_ID,
74 );
75 readonly kind = "Environment" as const;
76}
77
78/** @see {@link AWSEnvironment.isLocalEmulator} */
79export const isLocalEmulator = AWSEnvironment.isLocalEmulator;
80
81export const Default = Layer.effect(
82 AWSEnvironment,
83 Effect.gen(function* () {
84 // Provider layers are built on every run — before any profile may be
85 // configured — so nothing may resolve at construction. A dev run with
86 // zero AWS credentials builds this layer too (the ambient environment
87 // is the emulator; only `Alchemy.remote()` rows ever need it), so the
88 // profile/CI precedence is captured here and evaluated on first use,
89 // exactly once.
90 const resolve = yield* resolveProviderConfig<
91 AwsAuthConfig,
92 AwsResolvedCredentials
93 >(AWS_AUTH_PROVIDER_NAME).pipe(
94 Effect.flatMap(({ resolve }) => resolve),
95 deferUntilFirstUse,
96 );
97 return yield* resolve.pipe(
98 orDieCredentialsUnavailable(AWS_AUTH_PROVIDER_NAME),
99 Effect.cached,
100 );
101 }),
102).pipe(Layer.orDie);