1import * as Context from "effect/Context";
2import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Predicate from "effect/Predicate";
9import * as PlatformError from "effect/PlatformError";
10import * as Schema from "effect/Schema";
11
12import * as ServerConfig from "../config.ts";
13
14const secretStoreErrorContext = {
15 resource: Schema.String,
16 cause: Schema.Defect(),
17};
18
19export class SecretStoreSecureError extends Schema.TaggedError<SecretStoreSecureError>()(
20 "SecretStoreSecureError",
21 {
22 ...secretStoreErrorContext,
23 },
24) {
25 override get message(): string {
26 return `Failed to secure ${this.resource}.`;
27 }
28}
29
30export class SecretStoreReadError extends Schema.TaggedError<SecretStoreReadError>()(
31 "SecretStoreReadError",
32 {
33 ...secretStoreErrorContext,
34 },
35) {
36 override get message(): string {
37 return `Failed to read ${this.resource}.`;
38 }
39}
40
41export class SecretStoreTemporaryPathError extends Schema.TaggedError<SecretStoreTemporaryPathError>()(
42 "SecretStoreTemporaryPathError",
43 {
44 ...secretStoreErrorContext,
45 },
46) {
47 override get message(): string {
48 return `Failed to create temporary path for ${this.resource}.`;
49 }
50}
51
52export class SecretStorePersistError extends Schema.TaggedError<SecretStorePersistError>()(
53 "SecretStorePersistError",
54 {
55 ...secretStoreErrorContext,
56 },
57) {
58 override get message(): string {
59 return `Failed to persist ${this.resource}.`;
60 }
61}
62
63export class SecretStoreRandomGenerationError extends Schema.TaggedError<SecretStoreRandomGenerationError>()(
64 "SecretStoreRandomGenerationError",
65 {
66 ...secretStoreErrorContext,
67 },
68) {
69 override get message(): string {
70 return `Failed to generate random bytes for ${this.resource}.`;
71 }
72}
73
74export class SecretStoreConcurrentReadError extends Schema.TaggedError<SecretStoreConcurrentReadError>()(
75 "SecretStoreConcurrentReadError",
76 {
77 resource: Schema.String,
78 },
79) {
80 override get message(): string {
81 return `Failed to read ${this.resource} after concurrent creation.`;
82 }
83}
84
85export class SecretStoreRemoveError extends Schema.TaggedError<SecretStoreRemoveError>()(
86 "SecretStoreRemoveError",
87 {
88 ...secretStoreErrorContext,
89 },
90) {
91 override get message(): string {
92 return `Failed to remove ${this.resource}.`;
93 }
94}
95
96export class SecretStoreDecodeError extends Schema.TaggedError<SecretStoreDecodeError>()(
97 "SecretStoreDecodeError",
98 {
99 ...secretStoreErrorContext,
100 },
101) {
102 override get message(): string {
103 return `Failed to decode ${this.resource}.`;
104 }
105}
106
107export class SecretStoreEncodeError extends Schema.TaggedError<SecretStoreEncodeError>()(
108 "SecretStoreEncodeError",
109 {
110 ...secretStoreErrorContext,
111 },
112) {
113 override get message(): string {
114 return `Failed to encode ${this.resource}.`;
115 }
116}
117
118export const SecretStoreError = Schema.Union([
119 SecretStoreSecureError,
120 SecretStoreReadError,
121 SecretStoreTemporaryPathError,
122 SecretStorePersistError,
123 SecretStoreRandomGenerationError,
124 SecretStoreConcurrentReadError,
125 SecretStoreRemoveError,
126 SecretStoreDecodeError,
127 SecretStoreEncodeError,
128]);
129export type SecretStoreError = typeof SecretStoreError.Type;
130export const isSecretStoreError = Schema.is(SecretStoreError);
131
132const isPlatformError = (value: unknown): value is PlatformError.PlatformError =>
133 Predicate.isTagged(value, "PlatformError");
134
135export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
136 "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
137
138export class ServerSecretStore extends Context.Service<
139 ServerSecretStore,
140 {
141 /** File-backed stores expose their directory for cross-process credential leases. */
142 readonly directory?: string;
143 readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;
144 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
145 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
146 readonly getOrCreateRandom: (
147 name: string,
148 bytes: number,
149 ) => Effect.Effect<Uint8Array, SecretStoreError>;
150 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
151 }
152>()("t3/auth/ServerSecretStore") {}
153
154/** @public Service construction is part of the canonical Effect module API. */
155export const make = Effect.gen(function* () {
156 const crypto = yield* Crypto.Crypto;
157 const fileSystem = yield* FileSystem.FileSystem;
158 const path = yield* Path.Path;
159 const serverConfig = yield* ServerConfig.ServerConfig;
160
161 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
162 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
163 Effect.mapError(
164 (cause) =>
165 new SecretStoreSecureError({
166 resource: `secrets directory ${serverConfig.secretsDir}`,
167 cause,
168 }),
169 ),
170 );
171
172 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
173
174 const get: ServerSecretStore["Service"]["get"] = (name) =>
175 fileSystem.readFile(resolveSecretPath(name)).pipe(
176 Effect.map((bytes) => Option.some(Uint8Array.from(bytes))),
177 Effect.catch((cause) =>
178 cause.reason._tag === "NotFound"
179 ? Effect.succeedNone
180 : Effect.fail(
181 new SecretStoreReadError({
182 resource: `secret ${name}`,
183 cause,
184 }),
185 ),
186 ),
187 Effect.withSpan("ServerSecretStore.get"),
188 );
189
190 const set: ServerSecretStore["Service"]["set"] = (name, value) => {
191 const secretPath = resolveSecretPath(name);
192 return crypto.randomUUIDv4().pipe(
193 Effect.mapError(
194 (cause) =>
195 new SecretStoreTemporaryPathError({
196 resource: `secret ${name}`,
197 cause,
198 }),
199 ),
200 Effect.flatMap((uuid) => {
201 const tempPath = `${secretPath}.${uuid}.tmp`;
202 return Effect.gen(function* () {
203 yield* fileSystem.writeFile(tempPath, value);
204 yield* fileSystem.chmod(tempPath, 0o600);
205 yield* fileSystem.rename(tempPath, secretPath);
206 yield* fileSystem.chmod(secretPath, 0o600);
207 }).pipe(
208 Effect.catch((cause) =>
209 fileSystem.remove(tempPath).pipe(
210 Effect.ignore,
211 Effect.flatMap(() =>
212 Effect.fail(
213 new SecretStorePersistError({
214 resource: `secret ${name}`,
215 cause,
216 }),
217 ),
218 ),
219 ),
220 ),
221 );
222 }),
223 Effect.withSpan("ServerSecretStore.set"),
224 );
225 };
226
227 const create: ServerSecretStore["Service"]["create"] = (name, value) => {
228 const secretPath = resolveSecretPath(name);
229 return Effect.scoped(
230 Effect.gen(function* () {
231 const file = yield* fileSystem.open(secretPath, {
232 flag: "wx",
233 mode: 0o600,
234 });
235 yield* file.writeAll(value);
236 yield* file.sync;
237 yield* fileSystem.chmod(secretPath, 0o600);
238 }),
239 ).pipe(
240 Effect.mapError(
241 (cause) =>
242 new SecretStorePersistError({
243 resource: `secret ${name}`,
244 cause,
245 }),
246 ),
247 );
248 };
249
250 const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) =>
251 get(name).pipe(
252 Effect.flatMap(
253 Option.match({
254 onSome: Effect.succeed,
255 onNone: () =>
256 crypto.randomBytes(bytes).pipe(
257 Effect.mapError(
258 (cause) =>
259 new SecretStoreRandomGenerationError({
260 resource: `secret ${name}`,
261 cause,
262 }),
263 ),
264 Effect.flatMap((generated) =>
265 create(name, generated).pipe(
266 Effect.as(Uint8Array.from(generated)),
267 Effect.catchIf(isSecretStoreError, (error) =>
268 isSecretAlreadyExistsError(error)
269 ? get(name).pipe(
270 Effect.flatMap(
271 Option.match({
272 onSome: Effect.succeed,
273 onNone: () =>
274 Effect.fail(
275 new SecretStoreConcurrentReadError({
276 resource: `secret ${name}`,
277 }),
278 ),
279 }),
280 ),
281 )
282 : Effect.fail(error),
283 ),
284 ),
285 ),
286 ),
287 }),
288 ),
289 Effect.withSpan("ServerSecretStore.getOrCreateRandom"),
290 );
291
292 const remove: ServerSecretStore["Service"]["remove"] = (name) =>
293 fileSystem.remove(resolveSecretPath(name)).pipe(
294 Effect.catch((cause) =>
295 cause.reason._tag === "NotFound"
296 ? Effect.void
297 : Effect.fail(
298 new SecretStoreRemoveError({
299 resource: `secret ${name}`,
300 cause,
301 }),
302 ),
303 ),
304 Effect.withSpan("ServerSecretStore.remove"),
305 );
306
307 return ServerSecretStore.of({
308 directory: serverConfig.secretsDir,
309 get,
310 set,
311 create,
312 getOrCreateRandom,
313 remove,
314 });
315});
316
317export const layer = Layer.effect(ServerSecretStore, make);