1import * as Clock from "effect/Clock";
2import * as Duration from "effect/Duration";
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 Schedule from "effect/Schedule";
9
10import { CLOUD_REPLAY_MARKER_PREFIXES } from "../cloud/CloudLink.ts";
11import * as ServerConfig from "../config.ts";
12import { forkParked } from "../serverActivation.ts";
13import { DPOP_REPLAY_MARKER_PREFIX } from "./dpop.ts";
14
15const REPLAY_MARKER_PREFIXES = [DPOP_REPLAY_MARKER_PREFIX, ...CLOUD_REPLAY_MARKER_PREFIXES];
16
17/**
18 * How long a replay marker stays on disk. A marker only matters while its proof
19 * can pass the time check (about 5 minutes for DPoP, 7 for cloud proofs). After
20 * that, the time check rejects a replay by itself. The sweep and the time check
21 * both use the wall clock, so a pruned marker can let a replay through only if
22 * the clock moves back by almost a day, or if the filesystem stamps mtimes almost
23 * a day behind. Markers are files, so a restart does not reset them.
24 */
25export const REPLAY_MARKER_MAX_AGE = Duration.days(1);
26
27/**
28 * Deletes replay markers whose mtime is older than `REPLAY_MARKER_MAX_AGE`.
29 * `ServerSecretStore` saves each secret as `<name>.bin`, so only
30 * `<marker prefix>*.bin` names match. Other secrets and the `*.bin.<uuid>.tmp`
31 * files that `set` writes are never touched.
32 */
33export const pruneExpiredReplayMarkers = Effect.fn("replayMarkers.pruneExpired")(function* () {
34 const fileSystem = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const { secretsDir } = yield* ServerConfig.ServerConfig;
37 const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(REPLAY_MARKER_MAX_AGE);
38 const markers = (yield* fileSystem.readDirectory(secretsDir)).filter(
39 (name) =>
40 name.endsWith(".bin") && REPLAY_MARKER_PREFIXES.some((prefix) => name.startsWith(prefix)),
41 );
42 // `partition` visits every marker, so one locked file does not stop the sweep.
43 const [removed, failures] = yield* Effect.partition(markers, (name) => {
44 const markerPath = path.join(secretsDir, name);
45 return fileSystem.stat(markerPath).pipe(
46 Effect.flatMap((info) =>
47 Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff)
48 ? fileSystem.remove(markerPath).pipe(Effect.as(true))
49 : Effect.succeed(false),
50 ),
51 Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
52 );
53 });
54 yield* Effect.annotateCurrentSpan({
55 "replay_markers.matched": markers.length,
56 "replay_markers.removed": removed.filter(Boolean).length,
57 "replay_markers.failed": failures.length,
58 });
59 if (failures.length > 0) {
60 yield* Effect.logWarning("Failed to prune some replay markers", {
61 failed: failures.length,
62 cause: failures[0],
63 });
64 }
65});
66
67/** Prunes expired replay markers after server activation, then every hour. */
68export const layer = Layer.effectDiscard(
69 forkParked(
70 pruneExpiredReplayMarkers().pipe(
71 Effect.catch((cause) =>
72 Effect.logWarning("Failed to prune expired replay markers", { cause }),
73 ),
74 Effect.repeat(Schedule.spaced(Duration.hours(1))),
75 ),
76 ),
77);