1import type { SdkCredentialStore, SdkLoginOptions, SdkLoginResult } from "@cursor/sdk";
2import {
3 ProviderSetupError,
4 type ProviderAuthState,
5 type ProviderInstanceId,
6} from "@t3tools/contracts";
7import * as Clock from "effect/Clock";
8import * as Crypto from "effect/Crypto";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as Queue from "effect/Queue";
14import * as Scope from "effect/Scope";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18
19import { Cursor, InMemoryCredentialStore } from "./sdk.ts";
20import type { ProviderAuthController } from "@t3tools/provider-core/server/auth";
21
22const AUTH_TIMEOUT_MS = 300_000;
23
24interface AuthFlow {
25 readonly id: string;
26 readonly owner: string;
27 fiber?: Fiber.Fiber<void>;
28}
29
30export interface CursorAuth {
31 readonly controller: ProviderAuthController;
32 readonly readApiKey: Effect.Effect<string | undefined, ProviderSetupError>;
33 readonly requireApiKey: Effect.Effect<string, ProviderSetupError>;
34 readonly usesApiKey: boolean;
35 readonly withAccess: <A, E, R>(
36 task: Effect.Effect<A, E, R>,
37 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
38}
39
40export interface CursorAuthOptions {
41 readonly instanceId: ProviderInstanceId;
42 readonly displayName: string;
43 readonly enabled: boolean;
44 readonly apiKey?: string;
45 readonly store: SdkCredentialStore;
46 readonly credentialBinding?: NonNullable<ProviderAuthController["credentialBinding"]>;
47 readonly onChanged: (signedIn: boolean) => Effect.Effect<void, ProviderSetupError>;
48 readonly login?: (options: SdkLoginOptions) => Promise<SdkLoginResult>;
49}
50
51/** Each instance owns its browser credential; explicit API keys always take precedence. */
52export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) {
53 const scope = yield* Scope.Scope;
54 const crypto = yield* Crypto.Crypto;
55 const lock = yield* Semaphore.make(1);
56 const configuredKey = options.apiKey?.trim() || undefined;
57 const emptyState: ProviderAuthState = {
58 instanceId: options.instanceId,
59 phase: "idle",
60 flowId: null,
61 authorizationUrl: null,
62 expiresAt: null,
63 message: null,
64 };
65 const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: emptyState });
66 let active: AuthFlow | undefined;
67 let operation: "idle" | "login" | "stopping" | "closed" = "idle";
68 const sessions = new Set<Scope.Closeable>();
69
70 const publish = (flow: AuthFlow, patch: Partial<ProviderAuthState>) =>
71 SubscriptionRef.update(snapshot, (current) => ({
72 owner: flow.owner,
73 state: { ...current.state, ...patch },
74 }));
75
76 const readApiKey = Effect.gen(function* () {
77 if (configuredKey) return configuredKey;
78 const credentials = yield* Effect.tryPromise({
79 try: () => options.store.load(),
80 catch: (cause) =>
81 new ProviderSetupError({
82 instanceId: options.instanceId,
83 operation: "credentials",
84 detail: "Could not read the Cursor sign-in. Try signing in again.",
85 cause,
86 }),
87 });
88 const now = yield* Clock.currentTimeMillis;
89 return credentials &&
90 (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now)
91 ? credentials.apiKey
92 : undefined;
93 });
94
95 const requireApiKey = Effect.gen(function* () {
96 if (operation !== "idle") {
97 return yield* new ProviderSetupError({
98 instanceId: options.instanceId,
99 operation: "credentials",
100 detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.",
101 });
102 }
103 const apiKey = yield* readApiKey;
104 if (!apiKey) {
105 return yield* new ProviderSetupError({
106 instanceId: options.instanceId,
107 operation: "credentials",
108 detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
109 });
110 }
111 return apiKey;
112 });
113
114 // Keep SDK sessions and one-shot text generation inside instance-owned scopes.
115 // Closing admission before draining these scopes prevents cached credentials surviving sign-out.
116 const withAccess: CursorAuth["withAccess"] = (task) =>
117 Effect.uninterruptibleMask((restore) =>
118 Effect.gen(function* () {
119 const parent = yield* Scope.Scope;
120 const child = yield* lock.withPermits(1)(
121 Effect.gen(function* () {
122 if (operation !== "idle")
123 return yield* new ProviderSetupError({
124 instanceId: options.instanceId,
125 operation: "session",
126 detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.",
127 });
128 const child = yield* Scope.make();
129 sessions.add(child);
130 yield* Scope.addFinalizer(
131 child,
132 Effect.sync(() => {
133 sessions.delete(child);
134 }),
135 );
136 yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void));
137 return child;
138 }),
139 );
140 const fiber = yield* restore(task).pipe(
141 Effect.provideService(Scope.Scope, child),
142 Effect.forkIn(child),
143 );
144 return yield* restore(Fiber.await(fiber)).pipe(
145 Effect.flatMap((result) => result),
146 Effect.onExit((result) =>
147 Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void,
148 ),
149 );
150 }),
151 );
152 const stopSessionsWithCredentials = Effect.suspend(() =>
153 Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), {
154 discard: true,
155 concurrency: "unbounded",
156 }),
157 );
158
159 const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) =>
160 Effect.gen(function* () {
161 yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials));
162 // The SDK first writes into memory. Cancellation must not save a late login result.
163 const pendingStore = new InMemoryCredentialStore();
164 const urls = yield* Queue.unbounded<string>();
165 yield* Queue.take(urls).pipe(
166 Effect.flatMap((authorizationUrl) =>
167 lock.withPermits(1)(
168 Effect.gen(function* () {
169 if (active !== flow) return;
170 yield* publish(flow, {
171 phase: "waiting",
172 authorizationUrl,
173 message:
174 "Open the Cursor sign-in page and finish signing in. This page updates automatically.",
175 });
176 }),
177 ),
178 ),
179 Effect.forever,
180 Effect.forkScoped,
181 );
182 yield* Effect.tryPromise({
183 try: (signal) =>
184 (options.login ?? Cursor.auth.login)({
185 openBrowser: false,
186 store: pendingStore,
187 signal,
188 apiKeyName: `T3 Code - ${options.displayName}`,
189 onLoginUrl: (authorizationUrl) => {
190 if (active === flow) Queue.offerUnsafe(urls, authorizationUrl);
191 },
192 }),
193 catch: (cause) =>
194 new ProviderSetupError({
195 instanceId: options.instanceId,
196 operation: "start",
197 detail: "Cursor sign-in failed. Start sign-in again.",
198 cause,
199 }),
200 });
201 // Commit and publish together; interrupting a store Promise cannot stop a late write.
202 yield* lock.withPermits(1)(
203 Effect.gen(function* () {
204 if (active !== flow) return;
205 yield* publish(flow, {
206 phase: "verifying",
207 authorizationUrl: null,
208 message: "Checking Cursor sign-in.",
209 });
210 yield* Effect.tryPromise({
211 try: async () => {
212 const credentials = await pendingStore.load();
213 if (!credentials) throw new Error("Cursor login did not return credentials");
214 await options.store.save(credentials);
215 },
216 catch: (cause) =>
217 new ProviderSetupError({
218 instanceId: options.instanceId,
219 operation: "start",
220 detail: "Could not save the Cursor sign-in. Try again.",
221 cause,
222 }),
223 });
224 const verified = yield* options.onChanged(true).pipe(Effect.exit);
225 if (Exit.isFailure(verified)) {
226 yield* Effect.tryPromise({
227 try: () => options.store.clear(),
228 catch: (cause) =>
229 new ProviderSetupError({
230 instanceId: options.instanceId,
231 operation: "start",
232 detail: "Could not clear the rejected Cursor sign-in. Try signing out.",
233 cause,
234 }),
235 });
236 return yield* Effect.failCause(verified.cause);
237 }
238 active = undefined;
239 operation = "idle";
240 yield* publish(flow, {
241 phase: "succeeded",
242 authorizationUrl: null,
243 expiresAt: null,
244 message: "Signed in with Cursor.",
245 });
246 }).pipe(Effect.uninterruptible),
247 );
248 }).pipe(
249 Effect.scoped,
250 Effect.timeoutOrElse({
251 duration: AUTH_TIMEOUT_MS,
252 orElse: () =>
253 Effect.fail(
254 new ProviderSetupError({
255 instanceId: options.instanceId,
256 operation: "start",
257 detail: "Cursor sign-in expired. Start sign-in again.",
258 }),
259 ),
260 }),
261 Effect.exit,
262 Effect.flatMap((result) =>
263 lock.withPermits(1)(
264 Effect.gen(function* () {
265 if (active !== flow) return;
266 active = undefined;
267 operation = "idle";
268 yield* publish(flow, {
269 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
270 authorizationUrl: null,
271 expiresAt: null,
272 message: Exit.isSuccess(result)
273 ? "Signed in with Cursor."
274 : "Cursor sign-in failed or expired. Start sign-in again.",
275 });
276 }),
277 ),
278 ),
279 );
280
281 const requireFlow = (owner: string, id: string, name: string) =>
282 Effect.gen(function* () {
283 if (!active || active.owner !== owner || active.id !== id) {
284 return yield* new ProviderSetupError({
285 instanceId: options.instanceId,
286 operation: name,
287 detail: "This sign-in is no longer active in this client.",
288 });
289 }
290 return active;
291 });
292
293 const controller: ProviderAuthController = {
294 credentialBinding: options.credentialBinding ?? {
295 owner: "t3",
296 key: `cursor:${options.instanceId}`,
297 },
298 isChangingCredentials: Effect.sync(() => operation !== "idle"),
299 start: (owner, stopSessions = Effect.void) =>
300 lock.withPermits(1)(
301 Effect.uninterruptible(
302 Effect.gen(function* () {
303 if (!options.enabled)
304 return yield* new ProviderSetupError({
305 instanceId: options.instanceId,
306 operation: "start",
307 detail: "Enable Cursor before signing in.",
308 });
309 if (configuredKey)
310 return yield* new ProviderSetupError({
311 instanceId: options.instanceId,
312 operation: "start",
313 detail:
314 "Remove CURSOR_API_KEY from this provider's environment before using browser sign-in.",
315 });
316 if (active?.owner === owner && operation === "login") return snapshot.value.state;
317 if (operation !== "idle")
318 return yield* new ProviderSetupError({
319 instanceId: options.instanceId,
320 operation: "start",
321 detail: "Cursor setup is already in progress.",
322 });
323 const id = yield* crypto.randomUUIDv4().pipe(
324 Effect.mapError(
325 (cause) =>
326 new ProviderSetupError({
327 instanceId: options.instanceId,
328 operation: "start",
329 detail: "Could not start Cursor sign-in. Try again.",
330 cause,
331 }),
332 ),
333 );
334 const expiresAt = DateTime.formatIso(
335 DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS),
336 );
337 const flow: AuthFlow = { id, owner };
338 active = flow;
339 operation = "login";
340 const state: ProviderAuthState = {
341 ...emptyState,
342 phase: "starting",
343 flowId: id,
344 expiresAt,
345 message: "Starting Cursor sign-in.",
346 };
347 yield* SubscriptionRef.set(snapshot, { owner, state });
348 flow.fiber = yield* runLogin(flow, stopSessions).pipe(
349 Effect.interruptible,
350 Effect.forkIn(scope),
351 );
352 return state;
353 }),
354 ),
355 ),
356 complete: () =>
357 Effect.fail(
358 new ProviderSetupError({
359 instanceId: options.instanceId,
360 operation: "complete",
361 detail: "Finish signing in on the Cursor website. No redirect URL is needed.",
362 }),
363 ),
364 cancel: (owner, id) =>
365 Effect.uninterruptible(
366 Effect.gen(function* () {
367 const flow = yield* lock.withPermits(1)(
368 Effect.gen(function* () {
369 const flow = yield* requireFlow(owner, id, "cancel");
370 active = undefined;
371 operation = "stopping";
372 yield* publish(flow, {
373 phase: "cancelled",
374 authorizationUrl: null,
375 expiresAt: null,
376 message: "Cursor sign-in was cancelled.",
377 });
378 return flow;
379 }),
380 );
381 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
382 operation = "idle";
383 return snapshot.value.state;
384 }),
385 ),
386 logout: (stopSessions) =>
387 Effect.uninterruptible(
388 Effect.gen(function* () {
389 const flow = yield* lock.withPermits(1)(
390 Effect.gen(function* () {
391 if (configuredKey)
392 return yield* new ProviderSetupError({
393 instanceId: options.instanceId,
394 operation: "logout",
395 detail:
396 "Remove CURSOR_API_KEY from this provider's environment to disconnect it.",
397 });
398 if (operation !== "idle" && operation !== "login")
399 return yield* new ProviderSetupError({
400 instanceId: options.instanceId,
401 operation: "logout",
402 detail: "Cursor setup is already stopping.",
403 });
404 operation = "stopping";
405 const flow = active;
406 active = undefined;
407 return flow;
408 }),
409 );
410 const result = yield* Effect.gen(function* () {
411 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
412 yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials));
413 yield* Effect.tryPromise({
414 try: () => options.store.clear(),
415 catch: (cause) =>
416 new ProviderSetupError({
417 instanceId: options.instanceId,
418 operation: "logout",
419 detail: "Could not clear the Cursor sign-in. Try again.",
420 cause,
421 }),
422 });
423 yield* options.onChanged(false);
424 }).pipe(Effect.exit);
425 operation = "idle";
426 const state: ProviderAuthState = {
427 ...emptyState,
428 phase: Exit.isSuccess(result) ? "idle" : "failed",
429 message: Exit.isSuccess(result)
430 ? "Signed out of Cursor."
431 : "Cursor sign-out failed. Try again.",
432 };
433 yield* SubscriptionRef.set(snapshot, { owner: null, state });
434 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
435 return state;
436 }),
437 ),
438 subscribe: (owner) =>
439 SubscriptionRef.changes(snapshot).pipe(
440 Stream.map((current) => {
441 const state = {
442 ...current.state,
443 credentialOwner: "t3" as const,
444 methods: [
445 {
446 id: "browser",
447 name: "Sign in with Cursor",
448 description: null,
449 type: "agent" as const,
450 },
451 ],
452 interaction:
453 current.state.phase === "waiting" &&
454 current.state.authorizationUrl &&
455 current.state.flowId
456 ? {
457 type: "browser" as const,
458 id: current.state.flowId,
459 url: current.state.authorizationUrl,
460 requiresConsent: false,
461 }
462 : null,
463 };
464 if (current.owner === null || current.owner === owner) return state;
465 return {
466 ...state,
467 interaction: null,
468 flowId: null,
469 authorizationUrl: null,
470 expiresAt: null,
471 message: active ? "Sign-in is in progress in another client." : current.state.message,
472 };
473 }),
474 ),
475 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
476 };
477
478 yield* Effect.addFinalizer(() =>
479 Effect.gen(function* () {
480 operation = "closed";
481 const flow = active;
482 active = undefined;
483 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
484 yield* stopSessionsWithCredentials;
485 }),
486 );
487
488 return {
489 controller,
490 readApiKey,
491 requireApiKey,
492 withAccess,
493 usesApiKey: configuredKey !== undefined,
494 } satisfies CursorAuth;
495});