Skip to content
Source
Sign inSign up

WriterRegistry.ts

130 lines5.1 KB

1import * as Config from "effect/Config";
2import * as Context from "effect/Context";
3import * as Digest from "effect/crypto/Digest";
4import * as SecureRandom from "effect/crypto/SecureRandom";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import type * as PlatformError from "effect/PlatformError";
8import * as Layer from "effect/Layer";
9import * as Schema from "effect/Schema";
10​
11const Writers = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String));
12const decodeWriters = Schema.decodeEffect(Writers);
13const encodeWriters = Schema.encodeEffect(Writers);
14​
15const stateDirectory = Config.String("HOME").pipe(
16 Effect.map((home) => `${home}/.config/t3-task-cache`),
17);
18​
19const Credential = Schema.fromJsonString(
20 Schema.Struct({ endpoint: Schema.String, name: Schema.String, token: Schema.String }),
21);
22class ManagementError extends Schema.TaggedError<ManagementError>()("ManagementError", {
23 message: Schema.String,
24}) {}
25export const isWriterName = (name: string) =>
26 /^(?!writers$|credentials$|readers$|ci-reader$)[a-z0-9-]{1,64}$/.test(name);
27​
28const encodeCredential = Schema.encodeEffect(Credential);
29​
30export class WriterRegistry extends Context.Service<
31 WriterRegistry,
32 {
33 readonly read: () => Effect.Effect<
34 Record<string, string>,
35 PlatformError.PlatformError | Schema.SchemaError
36 >;
37 readonly update: (
38 name: string,
39 revoke: boolean,
40 endpoint?: string,
41 initialize?: boolean,
42 ) => Effect.Effect<
43 string | undefined,
44 PlatformError.PlatformError | Schema.SchemaError | ManagementError
45 >;
46 }
47>()("@t3tools/task-cache/WriterRegistry") {}
48​
49const make = Effect.gen(function* () {
50 const fs = yield* FileSystem.FileSystem;
51 const random = yield* SecureRandom.SecureRandom;
52 const digest = yield* Digest.Digest;
53 const state = yield* stateDirectory;
54 const readWriters = Effect.fn("TaskCache.readWriters")(function* () {
55 return yield* decodeWriters(yield* fs.readFileString(`${state}/writers.json`));
56 });
57​
58 const save = Effect.fn("TaskCache.save")(function* (
59 entries: ReadonlyArray<readonly [string, string]>,
60 ) {
61 yield* fs.makeDirectory(state, { recursive: true, mode: 0o700 });
62 yield* Effect.scoped(
63 Effect.gen(function* () {
64 const directory = yield* fs.makeTempDirectoryScoped({
65 directory: state,
66 prefix: ".writer-",
67 });
68 for (const [name, value] of entries)
69 yield* fs.writeFileString(`${directory}/${name}.json`, value + "\n", { mode: 0o600 });
70 // The registry is the authorization commit point and must publish last.
71 for (const [name] of entries)
72 yield* fs.rename(`${directory}/${name}.json`, `${state}/${name}.json`);
73 }),
74 );
75 });
76​
77 const updateWriter = Effect.fn("TaskCache.updateWriter")(function* (
78 name: string,
79 revoke: boolean,
80 endpoint?: string,
81 initialize = false,
82 ) {
83 if (!isWriterName(name)) return yield* new ManagementError({ message: "Invalid writer name." });
84 if (!revoke && !endpoint)
85 return yield* new ManagementError({ message: "A deployed cache endpoint is required." });
86 return yield* Effect.scoped(
87 Effect.gen(function* () {
88 yield* fs.makeDirectory(state, { recursive: true, mode: 0o700 });
89 // Exclusive creation coordinates separate management processes as well as fibers.
90 yield* Effect.acquireRelease(
91 fs.makeDirectory(`${state}/writers.lock`).pipe(
92 Effect.mapError((error) =>
93 error.reason._tag === "AlreadyExists"
94 ? new ManagementError({
95 message:
96 "Writer registry is locked by another update. Retry after it completes; remove writers.lock only if that process has stopped.",
97 })
98 : error,
99 ),
100 ),
101 () => fs.remove(`${state}/writers.lock`, { recursive: true }).pipe(Effect.orDie),
102 );
103 const exists = yield* fs.exists(`${state}/writers.json`);
104 if (!exists && !initialize)
105 return yield* new ManagementError({
106 message:
107 "Writer registry is missing. Copy the existing private registry from its administrator; use --new-registry only when creating a new cache.",
108 });
109 const writers = { ...(exists || revoke ? yield* readWriters() : {}) };
110 const token = hex(yield* random.randomBytes(32));
111 if (revoke) delete writers[name];
112 else {
113 writers[name] = hex(yield* digest.digest(Digest.sha256, new TextEncoder().encode(token)));
114 }
115 const entries: Array<readonly [string, string]> = [];
116 if (!revoke && endpoint)
117 entries.push([name, yield* encodeCredential({ endpoint, name, token })]);
118 entries.push(["writers", yield* encodeWriters(writers)]);
119 yield* save(entries);
120 return revoke ? undefined : `${state}/${name}.json`;
121 }),
122 );
123 });
124 return WriterRegistry.of({ read: readWriters, update: updateWriter });
125});
126​
127export const layer = Layer.effect(WriterRegistry, make);
128​
129const hex = (bytes: Uint8Array) =>
130 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");