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