1import * as Context from "effect/Context";
2import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Base64Url from "effect/encoding/Base64Url";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import { and, eq, exists, isNull, ne, notExists } from "drizzle-orm";
10import { QueryBuilder } from "drizzle-orm/pg-core";
11
12import * as RelayDb from "../db.ts";
13import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts";
14
15export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedError<EnvironmentCredentialCreatePersistenceError>()(
16 "EnvironmentCredentialCreatePersistenceError",
17 {
18 stage: Schema.Literals([
19 "generate-credential",
20 "hash-token",
21 "insert-credential",
22 "revoke-previous-credentials",
23 ]),
24 environmentId: Schema.String,
25 credentialId: Schema.optionalKey(Schema.String),
26 cause: Schema.Defect(),
27 },
28) {
29 override get message(): string {
30 return `Environment credential creation failed during '${this.stage}' for environment '${this.environmentId}'${this.credentialId === undefined ? "" : `, credential '${this.credentialId}'`}`;
31 }
32}
33
34export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedError<EnvironmentCredentialAuthenticatePersistenceError>()(
35 "EnvironmentCredentialAuthenticatePersistenceError",
36 {
37 stage: Schema.Literals(["hash-token", "lookup-credential"]),
38 cause: Schema.Defect(),
39 },
40) {
41 override get message(): string {
42 return `Environment credential authentication failed during '${this.stage}'`;
43 }
44}
45
46export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedError<EnvironmentCredentialRevokePersistenceError>()(
47 "EnvironmentCredentialRevokePersistenceError",
48 {
49 environmentId: Schema.String,
50 cause: Schema.Defect(),
51 },
52) {
53 override get message(): string {
54 return `Failed to revoke credentials for environment '${this.environmentId}'`;
55 }
56}
57
58export interface EnvironmentCredentialPrincipal {
59 readonly credentialId: string;
60 readonly environmentId: string;
61 readonly environmentPublicKey: string;
62}
63
64export class EnvironmentCredentials extends Context.Service<
65 EnvironmentCredentials,
66 {
67 readonly create: (input: {
68 readonly environmentId: string;
69 readonly environmentPublicKey: string;
70 }) => Effect.Effect<string, EnvironmentCredentialCreatePersistenceError>;
71 readonly authenticate: (
72 token: string,
73 ) => Effect.Effect<
74 Option.Option<EnvironmentCredentialPrincipal>,
75 EnvironmentCredentialAuthenticatePersistenceError
76 >;
77 readonly revokeForEnvironmentPublicKey: (input: {
78 readonly environmentId: string;
79 readonly environmentPublicKey: string;
80 }) => Effect.Effect<boolean, EnvironmentCredentialRevokePersistenceError>;
81 }
82>()("t3code-relay/environments/EnvironmentCredentials") {}
83
84const make = Effect.gen(function* () {
85 const db = yield* RelayDb.RelayDb;
86 const crypto = yield* Crypto.Crypto;
87 const hashToken = (token: string) =>
88 crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe(Effect.map(Base64Url.encode));
89 const randomTokenPart = (segments: number) =>
90 Effect.map(
91 Effect.all(Array.from({ length: segments }, () => crypto.randomUUIDv4())),
92 (values) => values.join("").replaceAll("-", ""),
93 );
94 const makeCredential = Effect.fnUntraced(function* () {
95 const credentialId = yield* randomTokenPart(2);
96 const secret = yield* randomTokenPart(3);
97 return {
98 credentialId,
99 token: `t3env_${credentialId}_${secret}`,
100 };
101 });
102
103 return EnvironmentCredentials.of({
104 create: Effect.fn("relay.environment_credentials.create")(function* (input) {
105 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
106 const credential = yield* makeCredential().pipe(
107 Effect.mapError(
108 (cause) =>
109 new EnvironmentCredentialCreatePersistenceError({
110 stage: "generate-credential",
111 environmentId: input.environmentId,
112 cause,
113 }),
114 ),
115 );
116 const credentialHash = yield* hashToken(credential.token).pipe(
117 Effect.mapError(
118 (cause) =>
119 new EnvironmentCredentialCreatePersistenceError({
120 stage: "hash-token",
121 environmentId: input.environmentId,
122 credentialId: credential.credentialId,
123 cause,
124 }),
125 ),
126 );
127 const now = DateTime.formatIso(yield* DateTime.now);
128 yield* db
129 .insert(relayEnvironmentCredentials)
130 .values({
131 credentialId: credential.credentialId,
132 environmentId: input.environmentId,
133 environmentPublicKey: input.environmentPublicKey,
134 credentialHash,
135 revokedAt: null,
136 createdAt: now,
137 updatedAt: now,
138 })
139 .pipe(
140 Effect.mapError(
141 (cause) =>
142 new EnvironmentCredentialCreatePersistenceError({
143 stage: "insert-credential",
144 environmentId: input.environmentId,
145 credentialId: credential.credentialId,
146 cause,
147 }),
148 ),
149 );
150 yield* db
151 .update(relayEnvironmentCredentials)
152 .set({
153 revokedAt: now,
154 updatedAt: now,
155 })
156 .where(
157 and(
158 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
159 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
160 ne(relayEnvironmentCredentials.credentialId, credential.credentialId),
161 isNull(relayEnvironmentCredentials.revokedAt),
162 ),
163 )
164 .pipe(
165 Effect.mapError(
166 (cause) =>
167 new EnvironmentCredentialCreatePersistenceError({
168 stage: "revoke-previous-credentials",
169 environmentId: input.environmentId,
170 credentialId: credential.credentialId,
171 cause,
172 }),
173 ),
174 );
175 return credential.token;
176 }),
177
178 authenticate: Effect.fn("relay.environment_credentials.authenticate")(function* (token) {
179 const credentialHash = yield* hashToken(token).pipe(
180 Effect.mapError(
181 (cause) =>
182 new EnvironmentCredentialAuthenticatePersistenceError({
183 stage: "hash-token",
184 cause,
185 }),
186 ),
187 );
188 const rows = yield* db
189 .select({
190 credentialId: relayEnvironmentCredentials.credentialId,
191 environmentId: relayEnvironmentCredentials.environmentId,
192 environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey,
193 })
194 .from(relayEnvironmentCredentials)
195 .where(
196 and(
197 eq(relayEnvironmentCredentials.credentialHash, credentialHash),
198 isNull(relayEnvironmentCredentials.revokedAt),
199 exists(
200 new QueryBuilder()
201 .select({ userId: relayEnvironmentLinks.userId })
202 .from(relayEnvironmentLinks)
203 .where(
204 and(
205 eq(
206 relayEnvironmentLinks.environmentId,
207 relayEnvironmentCredentials.environmentId,
208 ),
209 eq(
210 relayEnvironmentLinks.environmentPublicKey,
211 relayEnvironmentCredentials.environmentPublicKey,
212 ),
213 isNull(relayEnvironmentLinks.revokedAt),
214 ),
215 ),
216 ),
217 ),
218 )
219 .limit(1)
220 .pipe(
221 Effect.mapError(
222 (cause) =>
223 new EnvironmentCredentialAuthenticatePersistenceError({
224 stage: "lookup-credential",
225 cause,
226 }),
227 ),
228 );
229 const row = rows[0];
230 if (row) {
231 yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId });
232 }
233 return row
234 ? Option.some({
235 credentialId: row.credentialId,
236 environmentId: row.environmentId,
237 environmentPublicKey: row.environmentPublicKey,
238 })
239 : Option.none();
240 }),
241
242 revokeForEnvironmentPublicKey: Effect.fn(
243 "relay.environment_credentials.revoke_for_environment_public_key",
244 )(function* (input) {
245 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
246 const revokedAt = DateTime.formatIso(yield* DateTime.now);
247 const rows = yield* db
248 .update(relayEnvironmentCredentials)
249 .set({
250 revokedAt,
251 updatedAt: revokedAt,
252 })
253 .where(
254 and(
255 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
256 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
257 isNull(relayEnvironmentCredentials.revokedAt),
258 notExists(
259 new QueryBuilder()
260 .select({ userId: relayEnvironmentLinks.userId })
261 .from(relayEnvironmentLinks)
262 .where(
263 and(
264 eq(relayEnvironmentLinks.environmentId, input.environmentId),
265 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
266 isNull(relayEnvironmentLinks.revokedAt),
267 ),
268 ),
269 ),
270 ),
271 )
272 .returning({
273 credentialId: relayEnvironmentCredentials.credentialId,
274 })
275 .pipe(
276 Effect.mapError(
277 (cause) =>
278 new EnvironmentCredentialRevokePersistenceError({
279 environmentId: input.environmentId,
280 cause,
281 }),
282 ),
283 );
284 return rows.length > 0;
285 }),
286 });
287});
288
289export const layer = Layer.effect(EnvironmentCredentials, make);