1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import * as Schema from "effect/Schema";
7import path from "pathe";
8import { writeFileAtomic } from "../Util/AtomicFile.ts";
9import { profileCommandHint } from "../Util/interactive.ts";
10import { AuthError } from "./AuthProvider.ts";
11import { profileCredentialsDirPath } from "./Paths.ts";
12import { validateProfileName } from "./Profile.ts";
13
14export const credentialsFilePath = (profile: string, provider: string) =>
15 path.join(profileCredentialsDirPath(profile), `${provider}.json`);
16
17/**
18 * Service exposing per-profile credential file helpers. All methods have
19 * `R = never` — the {@link FileSystem.FileSystem} requirement is captured
20 * by {@link CredentialsStoreLive} when the layer is built.
21 *
22 * Reads and writes go through the provider's declared credential schema, so
23 * a hand-edited or corrupted secrets file surfaces as a typed `AuthError`
24 * (with a reconfigure hint) instead of propagating malformed data into API
25 * calls.
26 */
27export interface CredentialsStoreService {
28 readonly read: <A, E>(
29 profile: string,
30 provider: string,
31 schema: Schema.Codec<A, E>,
32 ) => Effect.Effect<A | undefined, AuthError>;
33 readonly write: <A, E>(
34 profile: string,
35 provider: string,
36 schema: Schema.Codec<A, E>,
37 credentials: A,
38 ) => Effect.Effect<void, AuthError>;
39 readonly delete: (
40 profile: string,
41 provider: string,
42 ) => Effect.Effect<void, AuthError>;
43 /**
44 * Recursively remove the `~/.alchemy/credentials/{profile}` directory
45 * containing all per-provider secrets for `profile`. No-op if it doesn't exist.
46 */
47 readonly deleteProfile: (profile: string) => Effect.Effect<void, AuthError>;
48}
49
50export class CredentialsStore extends Context.Service<
51 CredentialsStore,
52 CredentialsStoreService
53>()("Alchemy::CredentialsStore") {}
54
55export const CredentialsStoreLive = Layer.effect(
56 CredentialsStore,
57 Effect.gen(function* () {
58 const fs = yield* FileSystem.FileSystem;
59
60 const read = <A, E>(
61 profile: string,
62 provider: string,
63 schema: Schema.Codec<A, E>,
64 ) =>
65 Effect.gen(function* () {
66 const filePath = yield* validateCredentialPath(profile, provider);
67 const data = yield* fs.readFileString(filePath).pipe(
68 Effect.catchReason("PlatformError", "NotFound", () =>
69 Effect.succeed(undefined),
70 ),
71 Effect.mapError(
72 (cause) =>
73 new AuthError({
74 message: `Could not read credentials at '${filePath}'.`,
75 cause,
76 }),
77 ),
78 );
79 if (data === undefined) return undefined;
80 const json = yield* Schema.decodeUnknownEffect(
81 Schema.fromJsonString(Schema.Unknown),
82 )(data).pipe(
83 Effect.mapError(
84 (cause) =>
85 new AuthError({
86 message: `Stored credentials at '${filePath}' contain invalid JSON.`,
87 cause,
88 }),
89 ),
90 );
91 const command = yield* profileCommandHint(
92 `alchemy profile edit --profile ${profile} --reconfigure ${provider}`,
93 );
94 return yield* Schema.decodeUnknownEffect(schema)(json).pipe(
95 Effect.mapError(
96 (cause) =>
97 new AuthError({
98 message:
99 `Stored credentials at '${filePath}' do not match the expected shape. ` +
100 `Run \`${command}\` to replace them.`,
101 cause,
102 }),
103 ),
104 );
105 });
106
107 const write = <A, E>(
108 profile: string,
109 provider: string,
110 schema: Schema.Codec<A, E>,
111 credentials: A,
112 ) =>
113 Effect.gen(function* () {
114 const filePath = yield* validateCredentialPath(profile, provider);
115 const encoded = yield* Schema.encodeEffect(schema)(credentials).pipe(
116 Effect.mapError(
117 (cause) =>
118 new AuthError({
119 message: `Credentials for '${provider}' do not match the declared schema.`,
120 cause,
121 }),
122 ),
123 );
124 yield* Effect.gen(function* () {
125 const directory = path.dirname(filePath);
126 yield* fs.makeDirectory(directory, { recursive: true });
127 yield* fs.chmod(directory, 0o700);
128 yield* writeFileAtomic(
129 fs,
130 filePath,
131 JSON.stringify(encoded, null, 2),
132 0o600,
133 );
134 }).pipe(
135 Effect.mapError(
136 (cause) =>
137 new AuthError({
138 message: `Could not write credentials at '${filePath}'.`,
139 cause,
140 }),
141 ),
142 );
143 });
144
145 const remove_ = (
146 profile: string,
147 provider: string,
148 ): Effect.Effect<void, AuthError> =>
149 validateCredentialPath(profile, provider).pipe(
150 Effect.flatMap((filePath) =>
151 fs.remove(filePath).pipe(
152 Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
153 Effect.mapError(
154 (cause) =>
155 new AuthError({
156 message: `Could not delete credentials at '${filePath}'.`,
157 cause,
158 }),
159 ),
160 ),
161 ),
162 );
163
164 const deleteProfile = (profile: string) =>
165 Effect.gen(function* () {
166 yield* validateProfileName(profile).pipe(
167 Effect.mapError(
168 (cause) => new AuthError({ message: cause.message, cause }),
169 ),
170 );
171 yield* fs
172 .remove(profileCredentialsDirPath(profile), { recursive: true })
173 .pipe(
174 Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
175 Effect.mapError(
176 (cause) =>
177 new AuthError({
178 message: `Could not delete credentials for profile '${profile}'.`,
179 cause,
180 }),
181 ),
182 );
183 });
184
185 return {
186 read,
187 write,
188 delete: remove_,
189 deleteProfile,
190 } satisfies CredentialsStoreService;
191 }),
192);
193
194const CREDENTIAL_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
195
196const validateCredentialPath = (profile: string, provider: string) =>
197 Effect.gen(function* () {
198 yield* validateProfileName(profile).pipe(
199 Effect.mapError(
200 (cause) => new AuthError({ message: cause.message, cause }),
201 ),
202 );
203 if (!CREDENTIAL_KEY_PATTERN.test(provider)) {
204 return yield* new AuthError({
205 message: `Invalid credential key '${provider}'.`,
206 });
207 }
208 return credentialsFilePath(profile, provider);
209 });
210
211export function displayRedacted(
212 r: Redacted.Redacted<string>,
213 visibleChars = 4,
214): string {
215 const raw = Redacted.value(r);
216 if (raw.length <= visibleChars) return "****";
217 return `${raw.slice(0, visibleChars)}****`;
218}