1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as SqlClient from "effect/sql/SqlClient";
7import * as SqlSchema from "effect/sql/SqlSchema";
8
9import { AuthEnvironmentScopes } from "@t3tools/contracts";
10
11import {
12 type AuthPairingLinkRepositoryError,
13 PersistenceDecodeError,
14 type PersistenceErrorCorrelation,
15 PersistenceSqlError,
16} from "./Errors.ts";
17
18export const AuthPairingLinkRecord = Schema.Struct({
19 id: Schema.String,
20 credential: Schema.String,
21 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),
22 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
23 subject: Schema.String,
24 label: Schema.NullOr(Schema.String),
25 proofKeyThumbprint: Schema.NullOr(Schema.String),
26 createdAt: Schema.DateTimeUtcFromString,
27 expiresAt: Schema.DateTimeUtcFromString,
28 consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
29 revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
30});
31export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type;
32
33export const CreateAuthPairingLinkInput = Schema.Struct({
34 id: Schema.String,
35 credential: Schema.String,
36 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),
37 scopes: AuthEnvironmentScopes,
38 subject: Schema.String,
39 label: Schema.NullOr(Schema.String),
40 proofKeyThumbprint: Schema.NullOr(Schema.String),
41 createdAt: Schema.DateTimeUtcFromString,
42 expiresAt: Schema.DateTimeUtcFromString,
43});
44export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type;
45
46export const ConsumeAuthPairingLinkInput = Schema.Struct({
47 credential: Schema.String,
48 proofKeyThumbprint: Schema.NullOr(Schema.String),
49 requestedScopes: Schema.optionalKey(AuthEnvironmentScopes),
50 consumedAt: Schema.DateTimeUtcFromString,
51 now: Schema.DateTimeUtcFromString,
52});
53export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type;
54
55export const ListActiveAuthPairingLinksInput = Schema.Struct({
56 now: Schema.DateTimeUtcFromString,
57});
58export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type;
59
60export const RevokeAuthPairingLinkInput = Schema.Struct({
61 id: Schema.String,
62 revokedAt: Schema.DateTimeUtcFromString,
63});
64export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type;
65
66export const GetAuthPairingLinkByCredentialInput = Schema.Struct({
67 credential: Schema.String,
68});
69export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type;
70
71const AuthPairingLinkRawDbRow = Schema.Struct({
72 id: Schema.String,
73 credential: Schema.Unknown,
74 method: Schema.Unknown,
75 scopes: Schema.Unknown,
76 subject: Schema.Unknown,
77 label: Schema.Unknown,
78 proofKeyThumbprint: Schema.Unknown,
79 createdAt: Schema.Unknown,
80 expiresAt: Schema.Unknown,
81 consumedAt: Schema.Unknown,
82 revokedAt: Schema.Unknown,
83});
84
85const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord);
86
87export class AuthPairingLinkRepository extends Context.Service<
88 AuthPairingLinkRepository,
89 {
90 readonly create: (
91 input: CreateAuthPairingLinkInput,
92 ) => Effect.Effect<void, AuthPairingLinkRepositoryError>;
93 readonly consumeAvailable: (
94 input: ConsumeAuthPairingLinkInput,
95 ) => Effect.Effect<Option.Option<AuthPairingLinkRecord>, AuthPairingLinkRepositoryError>;
96 readonly listActive: (
97 input: ListActiveAuthPairingLinksInput,
98 ) => Effect.Effect<ReadonlyArray<AuthPairingLinkRecord>, AuthPairingLinkRepositoryError>;
99 readonly revoke: (
100 input: RevokeAuthPairingLinkInput,
101 ) => Effect.Effect<boolean, AuthPairingLinkRepositoryError>;
102 readonly getByCredential: (
103 input: GetAuthPairingLinkByCredentialInput,
104 ) => Effect.Effect<Option.Option<AuthPairingLinkRecord>, AuthPairingLinkRepositoryError>;
105 }
106>()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {}
107
108function toPersistenceSqlOrDecodeError(
109 sqlOperation: string,
110 decodeOperation: string,
111 correlation?: PersistenceErrorCorrelation,
112) {
113 return (cause: unknown): AuthPairingLinkRepositoryError =>
114 Schema.isSchemaError(cause)
115 ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation)
116 : new PersistenceSqlError({
117 operation: sqlOperation,
118 ...(correlation === undefined ? {} : { correlation }),
119 cause,
120 });
121}
122
123/** @public Service construction is part of the canonical Effect module API. */
124export const make = Effect.gen(function* () {
125 const sql = yield* SqlClient.SqlClient;
126
127 const createPairingLinkRow = SqlSchema.void({
128 Request: CreateAuthPairingLinkInput,
129 execute: (input) =>
130 sql`
131 INSERT INTO auth_pairing_links (
132 id,
133 credential,
134 method,
135 scopes,
136 subject,
137 label,
138 proof_key_thumbprint,
139 created_at,
140 expires_at,
141 consumed_at,
142 revoked_at
143 )
144 VALUES (
145 ${input.id},
146 ${input.credential},
147 ${input.method},
148 ${JSON.stringify(input.scopes)},
149 ${input.subject},
150 ${input.label},
151 ${input.proofKeyThumbprint},
152 ${input.createdAt},
153 ${input.expiresAt},
154 NULL,
155 NULL
156 )
157 `,
158 });
159
160 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({
161 Request: ConsumeAuthPairingLinkInput,
162 Result: AuthPairingLinkRawDbRow,
163 execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) =>
164 sql`
165 UPDATE auth_pairing_links
166 SET consumed_at = ${consumedAt}
167 WHERE credential = ${credential}
168 AND revoked_at IS NULL
169 AND consumed_at IS NULL
170 AND expires_at > ${now}
171 AND (
172 proof_key_thumbprint IS NULL
173 OR proof_key_thumbprint = ${proofKeyThumbprint}
174 )
175 AND (${requestedScopes === undefined ? 1 : 0} OR EXISTS (
176 SELECT 1
177 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested
178 WHERE requested.value IN (
179 SELECT value FROM json_each(auth_pairing_links.scopes)
180 )
181 ))
182 RETURNING
183 id AS "id",
184 credential AS "credential",
185 method AS "method",
186 scopes AS "scopes",
187 subject AS "subject",
188 label AS "label",
189 proof_key_thumbprint AS "proofKeyThumbprint",
190 created_at AS "createdAt",
191 expires_at AS "expiresAt",
192 consumed_at AS "consumedAt",
193 revoked_at AS "revokedAt"
194 `,
195 });
196
197 const listActivePairingLinkRows = SqlSchema.findAll({
198 Request: ListActiveAuthPairingLinksInput,
199 Result: AuthPairingLinkRawDbRow,
200 execute: ({ now }) =>
201 sql`
202 SELECT
203 id AS "id",
204 credential AS "credential",
205 method AS "method",
206 scopes AS "scopes",
207 subject AS "subject",
208 label AS "label",
209 proof_key_thumbprint AS "proofKeyThumbprint",
210 created_at AS "createdAt",
211 expires_at AS "expiresAt",
212 consumed_at AS "consumedAt",
213 revoked_at AS "revokedAt"
214 FROM auth_pairing_links
215 WHERE revoked_at IS NULL
216 AND consumed_at IS NULL
217 AND expires_at > ${now}
218 ORDER BY created_at DESC, id DESC
219 `,
220 });
221
222 const revokePairingLinkRow = SqlSchema.findAll({
223 Request: RevokeAuthPairingLinkInput,
224 Result: Schema.Struct({ id: Schema.String }),
225 execute: ({ id, revokedAt }) =>
226 sql`
227 UPDATE auth_pairing_links
228 SET revoked_at = ${revokedAt}
229 WHERE id = ${id}
230 AND revoked_at IS NULL
231 AND consumed_at IS NULL
232 RETURNING id AS "id"
233 `,
234 });
235
236 const getPairingLinkRowByCredential = SqlSchema.findOneOption({
237 Request: GetAuthPairingLinkByCredentialInput,
238 Result: AuthPairingLinkRawDbRow,
239 execute: ({ credential }) =>
240 sql`
241 SELECT
242 id AS "id",
243 credential AS "credential",
244 method AS "method",
245 scopes AS "scopes",
246 subject AS "subject",
247 label AS "label",
248 proof_key_thumbprint AS "proofKeyThumbprint",
249 created_at AS "createdAt",
250 expires_at AS "expiresAt",
251 consumed_at AS "consumedAt",
252 revoked_at AS "revokedAt"
253 FROM auth_pairing_links
254 WHERE credential = ${credential}
255 `,
256 });
257
258 const create: AuthPairingLinkRepository["Service"]["create"] = (input) =>
259 createPairingLinkRow(input).pipe(
260 Effect.mapError(
261 toPersistenceSqlOrDecodeError(
262 "AuthPairingLinkRepository.create:query",
263 "AuthPairingLinkRepository.create:encodeRequest",
264 { pairingLinkId: input.id },
265 ),
266 ),
267 );
268
269 const consumeAvailable: AuthPairingLinkRepository["Service"]["consumeAvailable"] = (input) =>
270 consumeAvailablePairingLinkRow(input).pipe(
271 Effect.mapError(
272 toPersistenceSqlOrDecodeError(
273 "AuthPairingLinkRepository.consumeAvailable:query",
274 "AuthPairingLinkRepository.consumeAvailable:decodeRow",
275 ),
276 ),
277 Effect.flatMap((rowOption) =>
278 Option.match(rowOption, {
279 onNone: () => Effect.succeedNone,
280 onSome: (row) =>
281 decodeAuthPairingLinkDbRow(row).pipe(
282 Effect.mapError((cause) =>
283 PersistenceDecodeError.fromSchemaError(
284 "AuthPairingLinkRepository.consumeAvailable:decodeRow",
285 cause,
286 { pairingLinkId: row.id },
287 ),
288 ),
289 Effect.asSome,
290 ),
291 }),
292 ),
293 );
294
295 const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) =>
296 listActivePairingLinkRows(input).pipe(
297 Effect.mapError(
298 toPersistenceSqlOrDecodeError(
299 "AuthPairingLinkRepository.listActive:query",
300 "AuthPairingLinkRepository.listActive:decodeRows",
301 ),
302 ),
303 Effect.flatMap((rows) =>
304 Effect.forEach(rows, (row) =>
305 decodeAuthPairingLinkDbRow(row).pipe(
306 Effect.mapError((cause) =>
307 PersistenceDecodeError.fromSchemaError(
308 "AuthPairingLinkRepository.listActive:decodeRows",
309 cause,
310 { pairingLinkId: row.id },
311 ),
312 ),
313 ),
314 ),
315 ),
316 );
317
318 const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) =>
319 revokePairingLinkRow(input).pipe(
320 Effect.mapError(
321 toPersistenceSqlOrDecodeError(
322 "AuthPairingLinkRepository.revoke:query",
323 "AuthPairingLinkRepository.revoke:decodeRows",
324 { pairingLinkId: input.id },
325 ),
326 ),
327 Effect.map((rows) => rows.length > 0),
328 );
329
330 const getByCredential: AuthPairingLinkRepository["Service"]["getByCredential"] = (input) =>
331 getPairingLinkRowByCredential(input).pipe(
332 Effect.mapError(
333 toPersistenceSqlOrDecodeError(
334 "AuthPairingLinkRepository.getByCredential:query",
335 "AuthPairingLinkRepository.getByCredential:decodeRow",
336 ),
337 ),
338 Effect.flatMap((rowOption) =>
339 Option.match(rowOption, {
340 onNone: () => Effect.succeedNone,
341 onSome: (row) =>
342 decodeAuthPairingLinkDbRow(row).pipe(
343 Effect.mapError((cause) =>
344 PersistenceDecodeError.fromSchemaError(
345 "AuthPairingLinkRepository.getByCredential:decodeRow",
346 cause,
347 { pairingLinkId: row.id },
348 ),
349 ),
350 Effect.asSome,
351 ),
352 }),
353 ),
354 );
355
356 return {
357 create,
358 consumeAvailable,
359 listActive,
360 revoke,
361 getByCredential,
362 } satisfies AuthPairingLinkRepository["Service"];
363});
364
365export const layer = Layer.effect(AuthPairingLinkRepository, make);