History

Structure cloud CLI token errors

Julius Marminge committed 4 months ago

Commit dfc32cd1 parent 6c12fe13 files · +347 −53Browse files

Unified · Split

3 files
  1. apps/server/src/cloud/CliTokenManager.test.ts +121 −0
  2. apps/server/src/cloud/CliTokenManager.ts +226 −47
  3. apps/server/src/cloud/http.ts +0 −6
apps/server/src/cloud/CliTokenManager.test.ts+121−0
@@ -0,0 +1,121 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+import * as PlatformError from "effect/PlatformError";
7+import * as HttpClient from "effect/unstable/http/HttpClient";
8+
9+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10+import * as CliTokenManager from "./CliTokenManager.ts";
11+
12+const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
13+
14+function makeSecretStore(
15+ overrides: Partial<ServerSecretStore.ServerSecretStore["Service"]>,
16+): ServerSecretStore.ServerSecretStore["Service"] {
17+ return {
18+ get: unusedSecretStoreOperation,
19+ set: unusedSecretStoreOperation,
20+ create: unusedSecretStoreOperation,
21+ getOrCreateRandom: unusedSecretStoreOperation,
22+ remove: unusedSecretStoreOperation,
23+ ...overrides,
24+ };
25+}
26+
27+function makeTokenManager(secretStore: ServerSecretStore.ServerSecretStore["Service"]) {
28+ return CliTokenManager.make.pipe(
29+ Effect.provide(
30+ Layer.mergeAll(
31+ NodeServices.layer,
32+ Layer.succeed(ServerSecretStore.ServerSecretStore, secretStore),
33+ Layer.succeed(
34+ HttpClient.HttpClient,
35+ HttpClient.make(() => Effect.die("unused HTTP client")),
36+ ),
37+ ),
38+ ),
39+ );
40+}
41+
42+describe("CloudCliTokenManager", () => {
43+ it.effect("retains secret context and cause when credential removal fails", () => {
44+ const failure = new ServerSecretStore.SecretStoreRemoveError({
45+ secretName: "cloud-cli-oauth-token",
46+ secretPath: "/tmp/secrets/cloud-cli-oauth-token.bin",
47+ cause: PlatformError.systemError({
48+ _tag: "PermissionDenied",
49+ module: "FileSystem",
50+ method: "remove",
51+ pathOrDescriptor: "/tmp/secrets/cloud-cli-oauth-token.bin",
52+ }),
53+ });
54+
55+ return Effect.gen(function* () {
56+ const tokens = yield* makeTokenManager(
57+ makeSecretStore({ remove: () => Effect.fail(failure) }),
58+ );
59+ const error = yield* Effect.flip(tokens.clear);
60+
61+ expect(error).toMatchObject({
62+ _tag: "CloudCliCredentialRemovalError",
63+ secretName: "cloud-cli-oauth-token",
64+ cause: failure,
65+ });
66+ expect(error.message).toBe(
67+ "Could not remove the stored T3 Connect CLI credential cloud-cli-oauth-token.",
68+ );
69+ });
70+ });
71+
72+ it.effect("classifies credential read failures without replacing the cause", () => {
73+ const failure = new ServerSecretStore.SecretStoreReadError({
74+ secretName: "cloud-cli-oauth-token",
75+ secretPath: "/tmp/secrets/cloud-cli-oauth-token.bin",
76+ cause: PlatformError.systemError({
77+ _tag: "PermissionDenied",
78+ module: "FileSystem",
79+ method: "readFile",
80+ pathOrDescriptor: "/tmp/secrets/cloud-cli-oauth-token.bin",
81+ }),
82+ });
83+
84+ return Effect.gen(function* () {
85+ const tokens = yield* makeTokenManager(makeSecretStore({ get: () => Effect.fail(failure) }));
86+ const error = yield* Effect.flip(tokens.hasCredential);
87+
88+ expect(error).toMatchObject({
89+ _tag: "CloudCliCredentialReadError",
90+ stage: "read-credential",
91+ secretName: "cloud-cli-oauth-token",
92+ cause: failure,
93+ });
94+ expect(error.message).toBe(
95+ "Could not inspect the stored T3 Connect CLI credential cloud-cli-oauth-token during read-credential.",
96+ );
97+ });
98+ });
99+
100+ it.effect("classifies malformed persisted credentials as refresh decode failures", () =>
101+ Effect.gen(function* () {
102+ const tokens = yield* makeTokenManager(
103+ makeSecretStore({
104+ get: () =>
105+ Effect.succeed(Option.some(new TextEncoder().encode("not valid credential JSON"))),
106+ }),
107+ );
108+ const error = yield* Effect.flip(tokens.getExisting);
109+
110+ expect(error).toMatchObject({
111+ _tag: "CloudCliCredentialRefreshError",
112+ stage: "decode-credential",
113+ secretName: "cloud-cli-oauth-token",
114+ cause: { _tag: "SchemaError" },
115+ });
116+ expect(error.message).toBe(
117+ "Could not refresh the T3 Connect CLI credential cloud-cli-oauth-token during decode-credential.",
118+ );
119+ }),
120+ );
121+});
apps/server/src/cloud/CliTokenManager.ts+226−47
@@ -27,6 +27,8 @@ import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts · 26 unmodified lines
2727 const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
2828 const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
2929 const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
30+const CLOUD_CLI_OAUTH_CALLBACK_HOST = "127.0.0.1";
31+const CLOUD_CLI_OAUTH_CALLBACK_PORT = 34338;
3032
3133 const PersistedToken = Schema.Struct({
3234 accessToken: Schema.String,
@@ -46,48 +48,136 @@ const OAuthTokenResponse = Schema.Struct({ · 13 unmodified lines
4648 token_type: Schema.String,
4749 });
4850
51+type CredentialReadFailure = ServerSecretStore.SecretStoreReadError | Schema.SchemaError;
52+
53+type CredentialPersistFailure =
54+ | Schema.SchemaError
55+ | ServerSecretStore.SecretStoreTemporaryPathGenerationError
56+ | ServerSecretStore.SecretStorePersistError;
57+
58+const CloudCliCredentialRefreshStage = Schema.Literals([
59+ "read-credential",
60+ "decode-credential",
61+ "load-oauth-config",
62+ "exchange-token",
63+ "encode-credential",
64+ "persist-credential",
65+]);
66+
67+const CloudCliAuthorizationStage = Schema.Literals([
68+ "load-oauth-config",
69+ "prepare-pkce",
70+ "start-callback-server",
71+ "exchange-token",
72+ "encode-credential",
73+ "persist-credential",
74+]);
75+
4976 export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()(
5077 "CloudCliCredentialRemovalError",
51− { cause: Schema.Defect() },
78+ {
79+ secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),
80+ cause: Schema.Defect(),
81+ },
5282 ) {
5383 override get message(): string {
54− return "Could not remove the stored T3 Connect CLI credential.";
84+ return `Could not remove the stored T3 Connect CLI credential ${this.secretName}.`;
5585 }
5686 }
5787
5888 export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass<CloudCliCredentialRefreshError>()(
5989 "CloudCliCredentialRefreshError",
60− { cause: Schema.Defect() },
90+ {
91+ stage: CloudCliCredentialRefreshStage,
92+ secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),
93+ tokenEndpoint: Schema.optional(Schema.String),
94+ cause: Schema.Defect(),
95+ },
6196 ) {
97+ static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialRefreshError {
98+ return new CloudCliCredentialRefreshError({
99+ stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential",
100+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
101+ cause,
102+ });
103+ }
104+
105+ static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliCredentialRefreshError {
106+ return new CloudCliCredentialRefreshError({
107+ stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential",
108+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
109+ cause,
110+ });
111+ }
112+
62113 override get message(): string {
63− return "Could not refresh the T3 Connect CLI credential.";
114+ const tokenEndpoint = this.tokenEndpoint ? ` using ${this.tokenEndpoint}` : "";
115+ return `Could not refresh the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}.`;
64116 }
65117 }
66118
67119 export class CloudCliCredentialReadError extends Schema.TaggedErrorClass<CloudCliCredentialReadError>()(
68120 "CloudCliCredentialReadError",
69− { cause: Schema.Defect() },
121+ {
122+ stage: Schema.Literals(["read-credential", "decode-credential"]),
123+ secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),
124+ cause: Schema.Defect(),
125+ },
70126 ) {
127+ static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialReadError {
128+ return new CloudCliCredentialReadError({
129+ stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential",
130+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
131+ cause,
132+ });
133+ }
134+
71135 override get message(): string {
72− return "Could not read the stored T3 Connect CLI credential.";
136+ return `Could not inspect the stored T3 Connect CLI credential ${this.secretName} during ${this.stage}.`;
73137 }
74138 }
75139
76140 export class CloudCliAuthorizationError extends Schema.TaggedErrorClass<CloudCliAuthorizationError>()(
77141 "CloudCliAuthorizationError",
78− { cause: Schema.Defect() },
142+ {
143+ stage: CloudCliAuthorizationStage,
144+ secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET),
145+ tokenEndpoint: Schema.optional(Schema.String),
146+ redirectUri: Schema.optional(Schema.String),
147+ callbackHost: Schema.optional(Schema.String),
148+ callbackPort: Schema.optional(Schema.Number),
149+ cause: Schema.Defect(),
150+ },
79151 ) {
152+ static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliAuthorizationError {
153+ return new CloudCliAuthorizationError({
154+ stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential",
155+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
156+ cause,
157+ });
158+ }
159+
80160 override get message(): string {
81− return "Could not authorize the T3 Connect CLI.";
161+ const tokenEndpoint = this.tokenEndpoint ? ` using ${this.tokenEndpoint}` : "";
162+ const redirectUri = this.redirectUri ? ` with callback ${this.redirectUri}` : "";
163+ const callbackAddress =
164+ this.callbackHost && this.callbackPort !== undefined
165+ ? ` on ${this.callbackHost}:${this.callbackPort}`
166+ : "";
167+ return `Could not authorize the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}${redirectUri}${callbackAddress}.`;
82168 }
83169 }
84170
85171 export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass<CloudCliAuthorizationTimeoutError>()(
86172 "CloudCliAuthorizationTimeoutError",
87− { cause: Schema.Defect() },
173+ {
174+ redirectUri: Schema.String,
175+ timeoutMillis: Schema.Number,
176+ cause: Schema.Defect(),
177+ },
88178 ) {
89179 override get message(): string {
90− return "Timed out waiting for T3 Connect authorization.";
180+ return `Timed out after ${this.timeoutMillis}ms waiting for T3 Connect authorization at ${this.redirectUri}.`;
91181 }
92182 }
93183
@@ -103,18 +193,21 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; · 9 unmodified lines
103193 export class CloudCliTokenManager extends Context.Service<
104194 CloudCliTokenManager,
105195 {
106− readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>;
107− readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>;
108− readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>;
109− readonly clear: Effect.Effect<void, CloudCliTokenManagerError>;
196+ readonly get: Effect.Effect<
197+ PersistedToken,
198+ | CloudCliCredentialRefreshError
199+ | CloudCliAuthorizationError
200+ | CloudCliAuthorizationTimeoutError
201+ >;
202+ readonly getExisting: Effect.Effect<
203+ Option.Option<PersistedToken>,
204+ CloudCliCredentialRefreshError
205+ >;
206+ readonly hasCredential: Effect.Effect<boolean, CloudCliCredentialReadError>;
207+ readonly clear: Effect.Effect<void, CloudCliCredentialRemovalError>;
110208 }
111209 >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {}
112210
113−const wrapError =
114− <WrappedError extends CloudCliTokenManagerError>(makeError: (cause: unknown) => WrappedError) =>
115− <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, WrappedError, R> =>
116− effect.pipe(Effect.mapError(makeError));
117−
118211 function stringToBytes(value: string): Uint8Array {
119212 return new TextEncoder().encode(value);
120213 }
@@ -134,9 +227,15 @@ export const make = Effect.gen(function* () { · 13 unmodified lines
134227 return token;
135228 });
136229
137− const clear = secrets
138− .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET)
139− .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause })));
230+ const clear = secrets.remove(CLOUD_CLI_OAUTH_TOKEN_SECRET).pipe(
231+ Effect.mapError(
232+ (cause) =>
233+ new CloudCliCredentialRemovalError({
234+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
235+ cause,
236+ }),
237+ ),
238+ );
140239
141240 const read = Effect.fn("cloud.cli_token.read")(function* () {
142241 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);
@@ -162,21 +261,62 @@ export const make = Effect.gen(function* () { · 19 unmodified lines
162261 });
163262
164263 const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) {
165− const metadata = yield* cloudCliOAuthConfig;
264+ const metadata = yield* cloudCliOAuthConfig.pipe(
265+ Effect.mapError(
266+ (cause) =>
267+ new CloudCliCredentialRefreshError({
268+ stage: "load-oauth-config",
269+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
270+ cause,
271+ }),
272+ ),
273+ );
166274 return yield* exchangeToken(metadata, {
167275 grant_type: "refresh_token",
168276 refresh_token: token.refreshToken,
169277 client_id: metadata.clientId,
170− });
278+ }).pipe(
279+ Effect.mapError(
280+ (cause) =>
281+ new CloudCliCredentialRefreshError({
282+ stage: "exchange-token",
283+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
284+ tokenEndpoint: metadata.tokenEndpoint,
285+ cause,
286+ }),
287+ ),
288+ );
171289 });
172290
173291 const login = Effect.fn("cloud.cli_token.login")(function* () {
174− const metadata = yield* cloudCliOAuthConfig;
175− const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
176− const challenge = Encoding.encodeBase64Url(
177− yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),
292+ const metadata = yield* cloudCliOAuthConfig.pipe(
293+ Effect.mapError(
294+ (cause) =>
295+ new CloudCliAuthorizationError({
296+ stage: "load-oauth-config",
297+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
298+ cause,
299+ }),
300+ ),
301+ );
302+ const { challenge, state, verifier } = yield* Effect.gen(function* () {
303+ const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
304+ const challenge = Encoding.encodeBase64Url(
305+ yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),
306+ );
307+ const state = yield* crypto.randomUUIDv4;
308+ return { challenge, state, verifier };
309+ }).pipe(
310+ Effect.mapError(
311+ (cause) =>
312+ new CloudCliAuthorizationError({
313+ stage: "prepare-pkce",
314+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
315+ redirectUri: metadata.redirectUri,
316+ cause,
317+ }),
318+ ),
178319 );
179− const state = yield* crypto.randomUUIDv4;
180320 const callback = yield* Deferred.make<string>();
181321 const callbackRoute = HttpRouter.add(
182322 "GET",
@@ -207,12 +347,23 @@ export const make = Effect.gen(function* () { · 24 unmodified lines
207347 }).pipe(
208348 Layer.provide(
209349 NodeHttpServer.layer(NodeHttp.createServer, {
210− host: "127.0.0.1",
211− port: 34338,
350+ host: CLOUD_CLI_OAUTH_CALLBACK_HOST,
351+ port: CLOUD_CLI_OAUTH_CALLBACK_PORT,
212352 disablePreemptiveShutdown: true,
213353 }),
214354 ),
215355 Layer.build,
356+ Effect.mapError(
357+ (cause) =>
358+ new CloudCliAuthorizationError({
359+ stage: "start-callback-server",
360+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
361+ redirectUri: metadata.redirectUri,
362+ callbackHost: CLOUD_CLI_OAUTH_CALLBACK_HOST,
363+ callbackPort: CLOUD_CLI_OAUTH_CALLBACK_PORT,
364+ cause,
365+ }),
366+ ),
216367 );
217368 const authorizationUrl = new URL(metadata.authorizationEndpoint);
218369 authorizationUrl.searchParams.set("client_id", metadata.clientId);
@@ -225,13 +376,16 @@ export const make = Effect.gen(function* () { · 6 unmodified lines
225376 yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`);
226377 const code = yield* Deferred.await(callback).pipe(
227378 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
228− Effect.catchTag("TimeoutError", (cause) =>
229− Effect.fail(
230− new CloudCliAuthorizationTimeoutError({
231− cause,
232− }),
233− ),
234− ),
379+ Effect.catchTags({
380+ TimeoutError: (cause) =>
381+ Effect.fail(
382+ new CloudCliAuthorizationTimeoutError({
383+ redirectUri: metadata.redirectUri,
384+ timeoutMillis: Duration.toMillis(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
385+ cause,
386+ }),
387+ ),
388+ }),
235389 );
236390 return yield* exchangeToken(metadata, {
237391 grant_type: "authorization_code",
@@ -239,26 +393,45 @@ export const make = Effect.gen(function* () { · 1 unmodified line
239393 redirect_uri: metadata.redirectUri,
240394 client_id: metadata.clientId,
241395 code_verifier: verifier,
242− });
396+ }).pipe(
397+ Effect.mapError(
398+ (cause) =>
399+ new CloudCliAuthorizationError({
400+ stage: "exchange-token",
401+ secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET,
402+ tokenEndpoint: metadata.tokenEndpoint,
403+ redirectUri: metadata.redirectUri,
404+ cause,
405+ }),
406+ ),
407+ );
243408 });
244409
245410 const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () {
246− const token = yield* read();
411+ const token = yield* read().pipe(
412+ Effect.mapError(CloudCliCredentialRefreshError.fromCredentialRead),
413+ );
247414 if (Option.isNone(token)) return token;
248415 const now = yield* Clock.currentTimeMillis;
249416 if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) {
250417 return token;
251418 }
252− return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist)));
419+ return Option.some(
420+ yield* refresh(token.value).pipe(
421+ Effect.flatMap((refreshed) =>
422+ persist(refreshed).pipe(
423+ Effect.mapError(CloudCliCredentialRefreshError.fromCredentialPersist),
424+ ),
425+ ),
426+ ),
427+ );
253428 });
254429
255− const getExisting = semaphore.withPermits(1)(
256− getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))),
257− );
430+ const getExisting = semaphore.withPermits(1)(getExistingNoLock());
258431 const hasCredential = semaphore.withPermits(1)(
259432 read().pipe(
260433 Effect.map(Option.isSome),
261− wrapError((cause) => new CloudCliCredentialReadError({ cause })),
434+ Effect.mapError(CloudCliCredentialReadError.fromCredentialRead),
262435 ),
263436 );
264437 const get = semaphore.withPermits(1)(
@@ -266,8 +439,14 @@ export const make = Effect.gen(function* () { · 1 unmodified line
266439 const token = yield* getExistingNoLock();
267440 return Option.isSome(token)
268441 ? token.value
269− : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist));
270− }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))),
442+ : yield* Effect.scoped(login()).pipe(
443+ Effect.flatMap((authorized) =>
444+ persist(authorized).pipe(
445+ Effect.mapError(CloudCliAuthorizationError.fromCredentialPersist),
446+ ),
447+ ),
448+ );
449+ }),
271450 );
272451
273452 return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear });
apps/server/src/cloud/http.ts+0−6
@@ -770,14 +770,8 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi · 769 unmodified lines
770770 CloudRelayConfigurationError: (error) =>
771771 failEnvironmentCloudInternalError(error.message)(error),
772772 CloudRelayRequestError: (error) => failEnvironmentCloudInternalError(error.message)(error),
773− CloudCliCredentialRemovalError: (error) =>
774− failEnvironmentCloudInternalError(error.message)(error),
775773 CloudCliCredentialRefreshError: (error) =>
776774 failEnvironmentCloudInternalError(error.message)(error),
777− CloudCliCredentialReadError: (error) => failEnvironmentCloudInternalError(error.message)(error),
778− CloudCliAuthorizationError: (error) => failEnvironmentCloudInternalError(error.message)(error),
779− CloudCliAuthorizationTimeoutError: (error) =>
780− failEnvironmentCloudInternalError(error.message)(error),
781775 }),
782776 );
783777

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t