@lotargo/memory_plugin 1.6.8 → 1.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,645 +1,682 @@
1
- import http from "node:http";
2
- import crypto from "node:crypto";
3
- import { spawn } from "node:child_process";
4
- import { saveSecrets, deleteSecrets, loadSecrets, resolveEnvSecrets, getSecretsSource, invalidateAuthCache, onSecretsChanged } from "../config/auth_store.js";
5
- import { getConfig, updateConfig } from "../config/config_manager.js";
6
-
7
- // Register callback so resolveCloudSecrets() cache is cleared when secrets change
8
- onSecretsChanged(() => { _cachedResolvedSecrets = undefined; _cachedResolvedAt = 0; });
9
-
10
- export const TURSO_API_BASE = () => process.env.TURSO_API_BASE || "https://api.turso.tech";
11
-
12
- function openBrowser(url) {
13
- const platform = process.platform;
14
- try {
15
- if (platform === "win32") {
16
- spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
17
- } else if (platform === "darwin") {
18
- spawn("open", [url], { stdio: "ignore", detached: true }).unref();
19
- } else {
20
- spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
21
- }
22
- } catch (err) {
23
- // Browser auto-open is best-effort; the printed URL can be opened manually.
24
- }
25
- }
26
-
27
- // Starts a temporary loopback HTTP server to receive the OAuth callback.
28
- // Turso redirects the browser back to the root path: /?jwt=<JWT>&username=<USERNAME>
29
- // `expectedState` protects against OAuth CSRF: the callback is only accepted
30
- // when it echoes the random `state` value that was embedded in the login URL.
31
- export function startAuthLoopbackServer(port = 48900, expectedState = null) {
32
- return new Promise((resolve, reject) => {
33
- const server = http.createServer((req, res) => {
34
- const url = new URL(req.url, `http://${req.headers.host}`);
35
- const token = url.searchParams.get("jwt") || url.searchParams.get("token");
36
- const username = url.searchParams.get("username");
37
- const error = url.searchParams.get("error");
38
- const receivedState = url.searchParams.get("state");
39
-
40
- if (token) {
41
- if (expectedState && receivedState !== expectedState) {
42
- res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
43
- res.end("Invalid state parameter");
44
- server.close(() => reject(new Error("OAuth callback rejected: state parameter mismatch (possible CSRF attempt).")));
45
- return;
46
- }
47
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
48
- res.end(`
49
- <!DOCTYPE html>
50
- <html lang="en">
51
- <head>
52
- <meta charset="utf-8" />
53
- <meta name="viewport" content="width=device-width, initial-scale=1" />
54
- <title>Authorization Successful</title>
55
- <style>
56
- * { margin: 0; padding: 0; box-sizing: border-box; }
57
- body {
58
- min-height: 100vh;
59
- display: flex;
60
- align-items: center;
61
- justify-content: center;
62
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
63
- -webkit-font-smoothing: antialiased;
64
- background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
65
- color: #e8ebf2;
66
- padding: 24px;
67
- }
68
- .card {
69
- max-width: 420px;
70
- width: 100%;
71
- background: #161a21;
72
- border: 1px solid rgba(255, 255, 255, 0.07);
73
- border-radius: 20px;
74
- padding: 46px 38px;
75
- text-align: center;
76
- box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
77
- }
78
- .badge {
79
- width: 76px;
80
- height: 76px;
81
- margin: 0 auto 26px;
82
- border-radius: 50%;
83
- display: flex;
84
- align-items: center;
85
- justify-content: center;
86
- background: rgba(94, 224, 154, 0.10);
87
- border: 1px solid rgba(94, 224, 154, 0.28);
88
- }
89
- .badge svg { width: 36px; height: 36px; }
90
- h1 { font-size: 22px; font-weight: 600; letter-spacing: 0.2px; color: #f2f4f8; margin-bottom: 12px; }
91
- p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
92
- .hint { margin-top: 24px; font-size: 12.5px; color: #6f7887; }
93
- </style>
94
- </head>
95
- <body>
96
- <div class="card">
97
- <div class="badge">
98
- <svg viewBox="0 0 24 24" fill="none" stroke="#5ee09a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
99
- <path d="M20 6 9 17l-5-5" />
100
- </svg>
101
- </div>
102
- <h1>Authorization successful</h1>
103
- <p>Your credentials were received and stored securely on this device.</p>
104
- <div class="hint">You can now close this tab and return to the terminal.</div>
105
- </div>
106
- </body>
107
- </html>
108
- `);
109
-
110
- server.close(() => {
111
- resolve({ token, username: username || "" });
112
- });
113
- } else if (error) {
114
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
115
- res.end(`
116
- <!DOCTYPE html>
117
- <html lang="en">
118
- <head>
119
- <meta charset="utf-8" />
120
- <meta name="viewport" content="width=device-width, initial-scale=1" />
121
- <title>Authorization Failed</title>
122
- <style>
123
- * { margin: 0; padding: 0; box-sizing: border-box; }
124
- body {
125
- min-height: 100vh;
126
- display: flex;
127
- align-items: center;
128
- justify-content: center;
129
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
130
- -webkit-font-smoothing: antialiased;
131
- background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
132
- color: #e8ebf2;
133
- padding: 24px;
134
- }
135
- .card {
136
- max-width: 400px;
137
- width: 100%;
138
- background: #161a21;
139
- border: 1px solid rgba(255, 255, 255, 0.07);
140
- border-radius: 20px;
141
- padding: 40px 34px;
142
- text-align: center;
143
- box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
144
- }
145
- h1 { font-size: 20px; font-weight: 600; color: #f2f4f8; margin-bottom: 12px; }
146
- p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
147
- </style>
148
- </head>
149
- <body>
150
- <div class="card">
151
- <h1>Authorization failed</h1>
152
- <p>An error occurred during the login flow. Close this tab, return to the terminal, and try again.</p>
153
- </div>
154
- </body>
155
- </html>
156
- `);
157
- server.close(() => reject(new Error(`Authentication error: ${error}`)));
158
- } else {
159
- res.writeHead(404, { "Content-Type": "text/plain" });
160
- res.end("Not Found");
161
- }
162
- });
163
-
164
- server.on("error", (err) => {
165
- reject(err);
166
- });
167
-
168
- server.listen(port, "127.0.0.1", () => {
169
- console.log(`\n [*] Waiting for authorization on local port http://localhost:${port}/...`);
170
- });
171
- });
172
- }
173
-
174
- async function apiRequest(token, pathname, { method = "GET", body } = {}) {
175
- const res = await fetch(`${TURSO_API_BASE()}${pathname}`, {
176
- method,
177
- headers: {
178
- Authorization: `Bearer ${token}`,
179
- "Content-Type": "application/json",
180
- Accept: "application/json",
181
- },
182
- body: body ? JSON.stringify(body) : undefined,
183
- });
184
-
185
- const text = await res.text();
186
- let data = null;
187
- try {
188
- data = JSON.parse(text);
189
- } catch {}
190
-
191
- if (!res.ok) {
192
- const err = new Error(data?.error || `Turso API ${res.status}: ${text}`);
193
- err.status = res.status;
194
- throw err;
195
- }
196
- return data;
197
- }
198
-
199
- // Validate the account JWT obtained from OAuth and return current-user info.
200
- export async function validateTursoToken(token) {
201
- return apiRequest(token, "/v1/current-user");
202
- }
203
-
204
- export async function listOrganizations(token) {
205
- const data = await apiRequest(token, "/v1/organizations");
206
- const orgs = data?.organizations || [];
207
- return orgs.map((o) => ({
208
- slug: o.slug || o.Slug || o.id || o.Id || null,
209
- name: o.name || o.Name || null,
210
- id: o.id || o.Id || null,
211
- }));
212
- }
213
-
214
- export async function listDatabases(token, org) {
215
- const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`);
216
- const dbs = data?.databases || [];
217
- return dbs.map((d) => ({
218
- name: d.name || d.Name,
219
- hostname: d.hostname || d.Hostname,
220
- id: d.id || d.Id,
221
- }));
222
- }
223
-
224
- export async function createDatabase(token, org, name) {
225
- try {
226
- const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
227
- method: "POST",
228
- body: { name },
229
- });
230
- const d = data?.database || data;
231
- return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
232
- } catch (err) {
233
- // Fresh accounts have no default group; create one, then retry.
234
- if (!String(err.message || "").toLowerCase().includes("group")) {
235
- throw err;
236
- }
237
- console.log(` [CLOUD] No group found. Creating group "default"...`);
238
- await createGroup(token, org, "default");
239
- const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
240
- method: "POST",
241
- body: { name, group: "default" },
242
- });
243
- const d = data?.database || data;
244
- return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
245
- }
246
- }
247
-
248
- // Turso's public closest-region endpoint (no auth required).
249
- // Returns e.g. { server: "aws-eu-west-1", client: "ams" }.
250
- async function getClosestLocation() {
251
- if (process.env.TURSO_LOCATION) return process.env.TURSO_LOCATION;
252
- const fallback = "ams";
253
- try {
254
- const res = await fetch("https://region.turso.io/", { signal: AbortSignal.timeout(8000) });
255
- const data = await res.json().catch(() => null);
256
- const loc = data?.server || data?.client || null;
257
- if (loc && /^[a-z0-9-]+$/i.test(loc)) return loc;
258
- } catch {
259
- // ignore
260
- }
261
- return fallback;
262
- }
263
-
264
- export async function createGroup(token, org, name) {
265
- // Always provide an explicit location: Turso's internal auto-lookup fails
266
- // with "invalid location: Host not found" when the group has no location.
267
- const location = await getClosestLocation();
268
- const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/groups`, {
269
- method: "POST",
270
- body: { name, location },
271
- });
272
- return data?.group || data;
273
- }
274
-
275
- export async function createDatabaseToken(token, org, db, { expiration = "never", authorization = "full-access" } = {}) {
276
- const data = await apiRequest(
277
- token,
278
- `/v1/organizations/${encodeURIComponent(org)}/databases/${encodeURIComponent(db)}/auth/tokens?expiration=${encodeURIComponent(expiration)}&authorization=${encodeURIComponent(authorization)}`,
279
- { method: "POST", body: {} }
280
- );
281
- return data?.jwt || null;
282
- }
283
-
284
- function dbHostname(org, dbName) {
285
- return `${dbName}-${org}.turso.io`;
286
- }
287
-
288
- // Shared post-auth resolution: validate happens in the caller. Steps:
289
- // 1. Resolve an organization (explicit, first available, or username fallback).
290
- // 2. Pick or create a database.
291
- // 3. Mint a full-access token for that database.
292
- // 4. Persist the encrypted token + dbUrl and mark the session as authorized.
293
- async function finalizeCloudLogin({ token, username, org = null, databaseName = null, autoCreate = true, persist = true, apiToken = null }) {
294
- const accountUsername = username || "user";
295
-
296
- // Step 1: resolve organization + database namespace
297
- const orgs = await listOrganizations(token);
298
- let orgSlug;
299
- let orgName;
300
- if (orgs && orgs.length > 0) {
301
- const requested = org ? orgs.find((o) => (o.slug || o.name || o.id) === org) : null;
302
- const chosen = requested || orgs[0];
303
- orgSlug = chosen.slug || chosen.name || chosen.id || String(chosen);
304
- orgName = chosen.name || orgSlug;
305
- } else {
306
- // Personal accounts are not listed in /v1/organizations, but their own
307
- // username acts as the organization namespace in the Platform API.
308
- orgSlug = accountUsername;
309
- orgName = accountUsername;
310
- console.log(` [CLOUD] No organizations found. Using personal account "${orgSlug}" as the database namespace.`);
311
- }
312
-
313
- const dbs = await listDatabases(token, orgSlug);
314
- if (dbs.length > 0) {
315
- console.log(`\n [CLOUD] Databases in organization "${orgName}":`);
316
- dbs.forEach((d, i) => console.log(` ${i + 1}. ${d.name}`));
317
- }
318
-
319
- let dbName = databaseName;
320
- if (!dbName) {
321
- if (dbs.length > 0) {
322
- dbName = dbs[0].name;
323
- console.log(`\n [CLOUD] Using existing database: "${dbName}"`);
324
- } else if (autoCreate) {
325
- dbName = `memory-${accountUsername}`;
326
- console.log(`\n [CLOUD] No database found. Creating "${dbName}"...`);
327
- await createDatabase(token, orgSlug, dbName);
328
- console.log(` [OK] Database "${dbName}" created.`);
329
- } else {
330
- throw new Error("No databases found and autoCreate is disabled.");
331
- }
332
- }
333
-
334
- // Step 2: mint a full-access token for the database
335
- console.log(" [CLOUD] Issuing database access token...");
336
- const dbJwt = await createDatabaseToken(token, orgSlug, dbName);
337
- if (!dbJwt) {
338
- throw new Error("Failed to create database auth token.");
339
- }
340
-
341
- const dbUrl = `libsql://${dbHostname(orgSlug, dbName)}`;
342
-
343
- // Step 3: persist secrets and mark authorized
344
- if (persist) {
345
- saveSecrets({
346
- token: dbJwt,
347
- dbUrl,
348
- username: accountUsername,
349
- org: orgSlug,
350
- db: dbName,
351
- authorized: true,
352
- ...(apiToken ? { apiToken } : {}),
353
- });
354
- }
355
- updateConfig({ tursoUrl: dbUrl, authorized: true, username: accountUsername });
356
-
357
- return {
358
- token: dbJwt,
359
- dbUrl,
360
- username: accountUsername,
361
- org: orgSlug,
362
- db: dbName,
363
- authorized: true,
364
- ...(apiToken ? { apiToken } : {}),
365
- };
366
- }
367
-
368
- // Perform the full cloud login flow:
369
- // 1. OAuth browser flow against Turso (api.turso.tech).
370
- // 2. Validate the received account JWT.
371
- // 3. Resolve an organization and pick/create a database.
372
- // 4. Mint a full-access token for that database.
373
- // 5. Persist the encrypted token + dbUrl and mark the session as authorized.
374
- export async function loginToCloud({
375
- customPort = 48900,
376
- simulated = false,
377
- simulatedParams = null,
378
- autoCreate = true,
379
- databaseName = null,
380
- org = null,
381
- } = {}) {
382
- const state = crypto.randomBytes(16).toString("hex");
383
- const loginUrl = `${TURSO_API_BASE()}/?port=${customPort}&redirect=true&state=${state}&type=cli`;
384
-
385
- console.log(`\n [CLOUD] Please open your system browser to authorize:`);
386
- console.log(` \x1b[36m${loginUrl}\x1b[0m\n`);
387
-
388
- let received;
389
- if (simulated && simulatedParams) {
390
- received = await new Promise((resolve, reject) => {
391
- const serverPromise = startAuthLoopbackServer(customPort, state);
392
- const req = http.request(
393
- `http://127.0.0.1:${customPort}/?jwt=${encodeURIComponent(simulatedParams.jwt)}&username=${encodeURIComponent(simulatedParams.username)}&state=${encodeURIComponent(state)}`,
394
- { method: "GET" },
395
- (res) => {
396
- res.resume();
397
- }
398
- );
399
- req.on("error", (e) => reject(e));
400
- req.end();
401
- serverPromise.then(resolve).catch(reject);
402
- });
403
- } else {
404
- openBrowser(loginUrl);
405
- received = await startAuthLoopbackServer(customPort, state);
406
- }
407
-
408
- const { token, username } = received;
409
-
410
- // Step 2: validate the account token
411
- let userInfo = null;
412
- try {
413
- userInfo = await validateTursoToken(token);
414
- } catch (err) {
415
- throw new Error(`Token validation failed: ${err.message}`);
416
- }
417
- const accountUsername = username || userInfo?.username || userInfo?.name || "user";
418
- console.log(` [OK] Token is valid. User: ${accountUsername}`);
419
-
420
- const secrets = await finalizeCloudLogin({ token, username: accountUsername, org, databaseName, autoCreate });
421
-
422
- console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${secrets.dbUrl}\x1b[0m`);
423
- return secrets;
424
- }
425
-
426
- // Headless login with a Turso account API token (no browser, no loopback).
427
- // Create one at https://console.turso.tech or via `turso auth api-tokens create`.
428
- // The same Platform API is used to resolve the organization + database and to
429
- // mint a per-database token, exactly like the browser flow.
430
- export async function loginWithApiToken({
431
- token,
432
- org = null,
433
- databaseName = null,
434
- autoCreate = true,
435
- username = null,
436
- persist = true,
437
- } = {}) {
438
- if (!token) throw new Error("An account API token is required.");
439
- console.log("\n [CLOUD] Validating account API token...");
440
- let userInfo = null;
441
- try {
442
- userInfo = await validateTursoToken(token);
443
- } catch (err) {
444
- throw new Error(`Token validation failed: ${err.message}`);
445
- }
446
- const accountUsername = username || userInfo?.username || userInfo?.name || "user";
447
- console.log(` [OK] API token is valid. User: ${accountUsername}`);
448
-
449
- const secrets = await finalizeCloudLogin({
450
- token,
451
- username: accountUsername,
452
- org,
453
- databaseName,
454
- autoCreate,
455
- persist,
456
- apiToken: persist ? token : null,
457
- });
458
-
459
- console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${secrets.dbUrl}\x1b[0m`);
460
- return secrets;
461
- }
462
-
463
- // Direct headless login with an existing database URL + auth token.
464
- // No Platform API calls are made; org/db are derived from the endpoint.
465
- export async function loginWithDatabaseToken({
466
- token,
467
- dbUrl,
468
- username = "",
469
- org = "",
470
- db = "",
471
- validate = true,
472
- } = {}) {
473
- if (!token || !dbUrl) throw new Error("Both a database auth token and a libsql:// URL are required.");
474
-
475
- // Derive org + database name from the endpoint (libsql://<db>-<org>.turso.io)
476
- let resolvedOrg = org;
477
- let resolvedDb = db;
478
- const m = String(dbUrl).match(/^libsql:\/\/(.+)\.turso\.io$/);
479
- if (m) {
480
- const host = m[1];
481
- const sep = host.lastIndexOf("-");
482
- if (sep > 0) {
483
- resolvedDb = resolvedDb || host.slice(0, sep);
484
- resolvedOrg = resolvedOrg || host.slice(sep + 1);
485
- }
486
- }
487
-
488
- if (validate) {
489
- console.log(" [CLOUD] Validating database token against the endpoint...");
490
- try {
491
- const { createClient } = await import("@libsql/client");
492
- const client = createClient({ url: dbUrl, authToken: token });
493
- await client.execute("SELECT 1");
494
- client.close();
495
- console.log(" [OK] Database token validated.");
496
- } catch (err) {
497
- throw new Error(`Database token validation failed: ${err.message}`);
498
- }
499
- }
500
-
501
- saveSecrets({ token, dbUrl, username, org: resolvedOrg, db: resolvedDb, authorized: true });
502
- updateConfig({ tursoUrl: dbUrl, authorized: true, username });
503
-
504
- console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${dbUrl}\x1b[0m`);
505
- return { token, dbUrl, username, org: resolvedOrg, db: resolvedDb, authorized: true };
506
- }
507
-
508
- // Pick up credentials from the environment or MEMORY_DIR/.env.
509
- // - TURSO_DB_URL + TURSO_DB_TOKEN: direct endpoint login (preferred, no API calls).
510
- // - TURSO_API_TOKEN: account API-token flow resolving org/db via the Platform API.
511
- // When persist is true the resolved secrets are also written to the encrypted store.
512
- export async function loginFromEnv({ persist = false } = {}) {
513
- const env = resolveEnvSecrets();
514
- if (!env) {
515
- return { ok: false, reason: "No cloud secrets found in the environment or MEMORY_DIR/.env." };
516
- }
517
-
518
- if (env.dbUrl && env.token) {
519
- const secrets = {
520
- token: env.token,
521
- dbUrl: env.dbUrl,
522
- username: env.username || "",
523
- org: env.org || "",
524
- db: env.database || "",
525
- authorized: true,
526
- };
527
- if (persist) saveSecrets(secrets);
528
- updateConfig({ tursoUrl: env.dbUrl, authorized: true, username: secrets.username });
529
- console.log(`\n \x1b[32m[OK] Cloud credentials imported from the environment! Endpoint: ${env.dbUrl}\x1b[0m`);
530
- return { ok: true, secrets, source: "env" };
531
- }
532
-
533
- if (env.apiToken) {
534
- // Resolve lazily; the raw API token stays in the environment and is NOT
535
- // persisted to the encrypted store (explicit `login --api-token` does that).
536
- const secrets = await loginWithApiToken({
537
- token: env.apiToken,
538
- org: env.org || null,
539
- databaseName: env.database || null,
540
- username: env.username || null,
541
- persist: false,
542
- });
543
- return { ok: true, secrets: { ...secrets, apiToken: env.apiToken }, source: "env" };
544
- }
545
-
546
- return {
547
- ok: false,
548
- reason: "Incomplete cloud secrets. Set TURSO_DB_URL + TURSO_DB_TOKEN (preferred) or TURSO_API_TOKEN (with optional TURSO_ORG / TURSO_DATABASE).",
549
- };
550
- }
551
-
552
- // Async resolution of the working cloud credentials (a DB URL + auth token).
553
- // Used by database.js at startup so a raw TURSO_API_TOKEN environment token
554
- // (which can only call the Platform API, not libsql) gets minted into a
555
- // per-database JWT without any interactive step.
556
- let _cachedResolvedSecrets = undefined;
557
- let _cachedResolvedAt = 0;
558
- const RESOLVED_CACHE_TTL_MS = 60_000; // 60s — avoids re-minting JWT on every getDatabase()
559
-
560
- export function invalidateResolvedCache() {
561
- _cachedResolvedSecrets = undefined;
562
- _cachedResolvedAt = 0;
563
- }
564
-
565
- export async function resolveCloudSecrets() {
566
- const now = Date.now();
567
- if (_cachedResolvedSecrets !== undefined && (now - _cachedResolvedAt) < RESOLVED_CACHE_TTL_MS) {
568
- return _cachedResolvedSecrets;
569
- }
570
-
571
- const secrets = loadSecrets();
572
- if (!secrets) { _cachedResolvedSecrets = null; return null; }
573
- if (secrets.apiToken && secrets.needsResolution) {
574
- const resolved = await loginWithApiToken({
575
- token: secrets.apiToken,
576
- org: secrets.org || null,
577
- databaseName: secrets.db || null,
578
- username: secrets.username || null,
579
- persist: false,
580
- });
581
- _cachedResolvedSecrets = { ...resolved, source: "env" };
582
- _cachedResolvedAt = Date.now();
583
- return _cachedResolvedSecrets;
584
- }
585
- _cachedResolvedSecrets = secrets;
586
- _cachedResolvedAt = Date.now();
587
- return _cachedResolvedSecrets;
588
- }
589
-
590
- // Store/replace a Turso account API token. Alias of the headless login flow:
591
- // the token is validated, an org/database is resolved and a per-database JWT
592
- // is minted, then both the API token and the resolved session are persisted.
593
- export async function setApiKey(token, { org = null, databaseName = null } = {}) {
594
- if (!token || typeof token !== "string" || !token.trim()) {
595
- throw new Error("An account API token is required.");
596
- }
597
- const secrets = await loginWithApiToken({ token: token.trim(), org, databaseName });
598
- return { ok: true, secrets };
599
- }
600
-
601
- // Remove a stored API token. The resolved database session is kept as a plain
602
- // browser/database session so an already-synced deployment keeps working.
603
- export function clearApiKey() {
604
- const existing = loadSecrets();
605
- if (!existing || !existing.apiToken) return { removed: false };
606
- const rest = { ...existing };
607
- delete rest.apiToken;
608
- if (rest.token && rest.dbUrl) {
609
- saveSecrets({ ...rest, authorized: true });
610
- invalidateAuthCache();
611
- invalidateResolvedCache();
612
- return { removed: true, keptDbSession: true };
613
- }
614
- deleteSecrets();
615
- invalidateAuthCache();
616
- invalidateResolvedCache();
617
- return { removed: true, keptDbSession: false };
618
- }
619
-
620
- // Non-throwing status report used by `auth-status` and the TUI.
621
- export function getAuthStatus() {
622
- const secrets = loadSecrets();
623
- const config = getConfig();
624
- const source = getSecretsSource();
625
- return {
626
- source: source || "none",
627
- configured: !!(secrets?.dbUrl || config.tursoUrl),
628
- authorized: !!config.authorized || source === "env" || source === "api-key",
629
- hasApiKey: !!secrets?.apiToken,
630
- dbUrl: secrets?.dbUrl || config.tursoUrl || "",
631
- username: secrets?.username || config.username || "",
632
- org: secrets?.org || "",
633
- database: secrets?.db || "",
634
- mode: config.mode || "only-local",
635
- };
636
- }
637
-
638
- // Logout and reset configurations
639
- export function logoutFromCloud() {
640
- const deleted = deleteSecrets();
641
- invalidateAuthCache();
642
- invalidateResolvedCache();
643
- updateConfig({ tursoUrl: "", mode: "only-local", authorized: false, username: "" });
644
- return deleted;
645
- }
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { saveSecrets, deleteSecrets, loadSecrets, resolveEnvSecrets, getSecretsSource, invalidateAuthCache, onSecretsChanged } from "../config/auth_store.js";
5
+ import { getConfig, updateConfig } from "../config/config_manager.js";
6
+
7
+ // Register callback so resolveCloudSecrets() cache is cleared when secrets change
8
+ onSecretsChanged(() => { _cachedResolvedSecrets = undefined; _cachedResolvedAt = 0; });
9
+
10
+ export const TURSO_API_BASE = () => process.env.TURSO_API_BASE || "https://api.turso.tech";
11
+
12
+ export function browserLaunchSpec(url, platform = process.platform) {
13
+ if (platform === "win32") {
14
+ // Do not route URLs through cmd.exe. '&' is a command separator there and
15
+ // can truncate Turso's query string before redirect/state/type parameters.
16
+ return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
17
+ }
18
+ if (platform === "darwin") return { command: "open", args: [url] };
19
+ return { command: "xdg-open", args: [url] };
20
+ }
21
+
22
+ function openBrowser(url) {
23
+ try {
24
+ const { command, args } = browserLaunchSpec(url);
25
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
26
+ child.on("error", () => {});
27
+ child.unref();
28
+ } catch {
29
+ // Browser auto-open is best-effort; the printed URL can be opened manually.
30
+ }
31
+ }
32
+
33
+ // Starts a temporary loopback HTTP server to receive the OAuth callback.
34
+ // Turso redirects the browser back to the root path: /?jwt=<JWT>&username=<USERNAME>
35
+ // `expectedState` protects against OAuth CSRF: the callback is only accepted
36
+ // when it echoes the random `state` value that was embedded in the login URL.
37
+ export async function createAuthLoopbackServer(port = 0, expectedState = null) {
38
+ let resolveResult;
39
+ let rejectResult;
40
+ const result = new Promise((resolve, reject) => {
41
+ resolveResult = resolve;
42
+ rejectResult = reject;
43
+ });
44
+
45
+ const server = http.createServer((req, res) => {
46
+ const url = new URL(req.url, `http://${req.headers.host}`);
47
+ const token = url.searchParams.get("jwt") || url.searchParams.get("token");
48
+ const username = url.searchParams.get("username");
49
+ const error = url.searchParams.get("error");
50
+ const receivedState = url.searchParams.get("state");
51
+
52
+ if (token) {
53
+ if (expectedState && receivedState && receivedState !== expectedState) {
54
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
55
+ res.end("Invalid state parameter");
56
+ server.close(() => rejectResult(new Error("OAuth callback rejected: state parameter mismatch (possible CSRF attempt).")));
57
+ return;
58
+ }
59
+ if (expectedState && !receivedState) {
60
+ // Turso's CLI callback redirects to /?jwt=<JWT>&username=<USERNAME>
61
+ // without echoing our `state` (see tursodatabase/turso-cli#634).
62
+ // Rejecting here would break every real login, so accept the token
63
+ // (it is still validated against the Turso API afterwards) and warn.
64
+ console.warn(" [!] OAuth callback arrived without a state parameter; accepting it (Turso does not echo state).");
65
+ }
66
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
67
+ res.end(`
68
+ <!DOCTYPE html>
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="utf-8" />
72
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
73
+ <title>Authorization Successful</title>
74
+ <style>
75
+ * { margin: 0; padding: 0; box-sizing: border-box; }
76
+ body {
77
+ min-height: 100vh;
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
82
+ -webkit-font-smoothing: antialiased;
83
+ background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
84
+ color: #e8ebf2;
85
+ padding: 24px;
86
+ }
87
+ .card {
88
+ max-width: 420px;
89
+ width: 100%;
90
+ background: #161a21;
91
+ border: 1px solid rgba(255, 255, 255, 0.07);
92
+ border-radius: 20px;
93
+ padding: 46px 38px;
94
+ text-align: center;
95
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
96
+ }
97
+ .badge {
98
+ width: 76px;
99
+ height: 76px;
100
+ margin: 0 auto 26px;
101
+ border-radius: 50%;
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ background: rgba(94, 224, 154, 0.10);
106
+ border: 1px solid rgba(94, 224, 154, 0.28);
107
+ }
108
+ .badge svg { width: 36px; height: 36px; }
109
+ h1 { font-size: 22px; font-weight: 600; letter-spacing: 0.2px; color: #f2f4f8; margin-bottom: 12px; }
110
+ p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
111
+ .hint { margin-top: 24px; font-size: 12.5px; color: #6f7887; }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <div class="card">
116
+ <div class="badge">
117
+ <svg viewBox="0 0 24 24" fill="none" stroke="#5ee09a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
118
+ <path d="M20 6 9 17l-5-5" />
119
+ </svg>
120
+ </div>
121
+ <h1>Authorization successful</h1>
122
+ <p>Your credentials were received and stored securely on this device.</p>
123
+ <div class="hint">You can now close this tab and return to the terminal.</div>
124
+ </div>
125
+ </body>
126
+ </html>
127
+ `);
128
+
129
+ server.close(() => {
130
+ resolveResult({ token, username: username || "" });
131
+ });
132
+ } else if (error) {
133
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
134
+ res.end(`
135
+ <!DOCTYPE html>
136
+ <html lang="en">
137
+ <head>
138
+ <meta charset="utf-8" />
139
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
140
+ <title>Authorization Failed</title>
141
+ <style>
142
+ * { margin: 0; padding: 0; box-sizing: border-box; }
143
+ body {
144
+ min-height: 100vh;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
149
+ -webkit-font-smoothing: antialiased;
150
+ background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
151
+ color: #e8ebf2;
152
+ padding: 24px;
153
+ }
154
+ .card {
155
+ max-width: 400px;
156
+ width: 100%;
157
+ background: #161a21;
158
+ border: 1px solid rgba(255, 255, 255, 0.07);
159
+ border-radius: 20px;
160
+ padding: 40px 34px;
161
+ text-align: center;
162
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
163
+ }
164
+ h1 { font-size: 20px; font-weight: 600; color: #f2f4f8; margin-bottom: 12px; }
165
+ p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div class="card">
170
+ <h1>Authorization failed</h1>
171
+ <p>An error occurred during the login flow. Close this tab, return to the terminal, and try again.</p>
172
+ </div>
173
+ </body>
174
+ </html>
175
+ `);
176
+ server.close(() => rejectResult(new Error(`Authentication error: ${error}`)));
177
+ } else {
178
+ res.writeHead(404, { "Content-Type": "text/plain" });
179
+ res.end("Not Found");
180
+ }
181
+ });
182
+
183
+ await new Promise((resolve, reject) => {
184
+ const onError = (err) => {
185
+ server.off("listening", onListening);
186
+ reject(err);
187
+ };
188
+ const onListening = () => {
189
+ server.off("error", onError);
190
+ resolve();
191
+ };
192
+ server.once("error", onError);
193
+ server.once("listening", onListening);
194
+ server.listen(port, "127.0.0.1");
195
+ });
196
+
197
+ const address = server.address();
198
+ const boundPort = typeof address === "object" && address ? address.port : port;
199
+ console.log(`\n [*] Waiting for authorization on local port http://localhost:${boundPort}/...`);
200
+
201
+ return {
202
+ port: boundPort,
203
+ result,
204
+ close: () => new Promise((resolve) => server.close(() => resolve())),
205
+ };
206
+ }
207
+
208
+ // Backward-compatible helper used by tests/older internal callers.
209
+ export function startAuthLoopbackServer(port = 48900, expectedState = null) {
210
+ return createAuthLoopbackServer(port, expectedState).then(({ result }) => result);
211
+ }
212
+
213
+ async function apiRequest(token, pathname, { method = "GET", body } = {}) {
214
+ const res = await fetch(`${TURSO_API_BASE()}${pathname}`, {
215
+ method,
216
+ headers: {
217
+ Authorization: `Bearer ${token}`,
218
+ "Content-Type": "application/json",
219
+ Accept: "application/json",
220
+ },
221
+ body: body ? JSON.stringify(body) : undefined,
222
+ });
223
+
224
+ const text = await res.text();
225
+ let data = null;
226
+ try {
227
+ data = JSON.parse(text);
228
+ } catch {}
229
+
230
+ if (!res.ok) {
231
+ const err = new Error(data?.error || `Turso API ${res.status}: ${text}`);
232
+ err.status = res.status;
233
+ throw err;
234
+ }
235
+ return data;
236
+ }
237
+
238
+ // Validate the account JWT obtained from OAuth and return current-user info.
239
+ export async function validateTursoToken(token) {
240
+ return apiRequest(token, "/v1/current-user");
241
+ }
242
+
243
+ export async function listOrganizations(token) {
244
+ const data = await apiRequest(token, "/v1/organizations");
245
+ const orgs = data?.organizations || [];
246
+ return orgs.map((o) => ({
247
+ slug: o.slug || o.Slug || o.id || o.Id || null,
248
+ name: o.name || o.Name || null,
249
+ id: o.id || o.Id || null,
250
+ }));
251
+ }
252
+
253
+ export async function listDatabases(token, org) {
254
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`);
255
+ const dbs = data?.databases || [];
256
+ return dbs.map((d) => ({
257
+ name: d.name || d.Name,
258
+ hostname: d.hostname || d.Hostname,
259
+ id: d.id || d.Id,
260
+ }));
261
+ }
262
+
263
+ export async function createDatabase(token, org, name) {
264
+ try {
265
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
266
+ method: "POST",
267
+ body: { name },
268
+ });
269
+ const d = data?.database || data;
270
+ return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
271
+ } catch (err) {
272
+ // Fresh accounts have no default group; create one, then retry.
273
+ if (!String(err.message || "").toLowerCase().includes("group")) {
274
+ throw err;
275
+ }
276
+ console.log(` [CLOUD] No group found. Creating group "default"...`);
277
+ await createGroup(token, org, "default");
278
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
279
+ method: "POST",
280
+ body: { name, group: "default" },
281
+ });
282
+ const d = data?.database || data;
283
+ return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
284
+ }
285
+ }
286
+
287
+ // Turso's public closest-region endpoint (no auth required).
288
+ // Returns e.g. { server: "aws-eu-west-1", client: "ams" }.
289
+ async function getClosestLocation() {
290
+ if (process.env.TURSO_LOCATION) return process.env.TURSO_LOCATION;
291
+ const fallback = "ams";
292
+ try {
293
+ const res = await fetch("https://region.turso.io/", { signal: AbortSignal.timeout(8000) });
294
+ const data = await res.json().catch(() => null);
295
+ const loc = data?.server || data?.client || null;
296
+ if (loc && /^[a-z0-9-]+$/i.test(loc)) return loc;
297
+ } catch {
298
+ // ignore
299
+ }
300
+ return fallback;
301
+ }
302
+
303
+ export async function createGroup(token, org, name) {
304
+ // Always provide an explicit location: Turso's internal auto-lookup fails
305
+ // with "invalid location: Host not found" when the group has no location.
306
+ const location = await getClosestLocation();
307
+ const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/groups`, {
308
+ method: "POST",
309
+ body: { name, location },
310
+ });
311
+ return data?.group || data;
312
+ }
313
+
314
+ export async function createDatabaseToken(token, org, db, { expiration = "never", authorization = "full-access" } = {}) {
315
+ const data = await apiRequest(
316
+ token,
317
+ `/v1/organizations/${encodeURIComponent(org)}/databases/${encodeURIComponent(db)}/auth/tokens?expiration=${encodeURIComponent(expiration)}&authorization=${encodeURIComponent(authorization)}`,
318
+ { method: "POST", body: {} }
319
+ );
320
+ return data?.jwt || null;
321
+ }
322
+
323
+ function dbHostname(org, dbName) {
324
+ return `${dbName}-${org}.turso.io`;
325
+ }
326
+
327
+ // Shared post-auth resolution: validate happens in the caller. Steps:
328
+ // 1. Resolve an organization (explicit, first available, or username fallback).
329
+ // 2. Pick or create a database.
330
+ // 3. Mint a full-access token for that database.
331
+ // 4. Persist the encrypted token + dbUrl and mark the session as authorized.
332
+ async function finalizeCloudLogin({ token, username, org = null, databaseName = null, autoCreate = true, persist = true, apiToken = null }) {
333
+ const accountUsername = username || "user";
334
+
335
+ // Step 1: resolve organization + database namespace
336
+ const orgs = await listOrganizations(token);
337
+ let orgSlug;
338
+ let orgName;
339
+ if (orgs && orgs.length > 0) {
340
+ const requested = org ? orgs.find((o) => (o.slug || o.name || o.id) === org) : null;
341
+ const chosen = requested || orgs[0];
342
+ orgSlug = chosen.slug || chosen.name || chosen.id || String(chosen);
343
+ orgName = chosen.name || orgSlug;
344
+ } else {
345
+ // Personal accounts are not listed in /v1/organizations, but their own
346
+ // username acts as the organization namespace in the Platform API.
347
+ orgSlug = accountUsername;
348
+ orgName = accountUsername;
349
+ console.log(` [CLOUD] No organizations found. Using personal account "${orgSlug}" as the database namespace.`);
350
+ }
351
+
352
+ const dbs = await listDatabases(token, orgSlug);
353
+ if (dbs.length > 0) {
354
+ console.log(`\n [CLOUD] Databases in organization "${orgName}":`);
355
+ dbs.forEach((d, i) => console.log(` ${i + 1}. ${d.name}`));
356
+ }
357
+
358
+ let dbName = databaseName;
359
+ if (!dbName) {
360
+ if (dbs.length > 0) {
361
+ dbName = dbs[0].name;
362
+ console.log(`\n [CLOUD] Using existing database: "${dbName}"`);
363
+ } else if (autoCreate) {
364
+ dbName = `memory-${accountUsername}`;
365
+ console.log(`\n [CLOUD] No database found. Creating "${dbName}"...`);
366
+ await createDatabase(token, orgSlug, dbName);
367
+ console.log(` [OK] Database "${dbName}" created.`);
368
+ } else {
369
+ throw new Error("No databases found and autoCreate is disabled.");
370
+ }
371
+ }
372
+
373
+ // Step 2: mint a full-access token for the database
374
+ console.log(" [CLOUD] Issuing database access token...");
375
+ const dbJwt = await createDatabaseToken(token, orgSlug, dbName);
376
+ if (!dbJwt) {
377
+ throw new Error("Failed to create database auth token.");
378
+ }
379
+
380
+ const dbUrl = `libsql://${dbHostname(orgSlug, dbName)}`;
381
+
382
+ // Step 3: persist secrets and mark authorized
383
+ if (persist) {
384
+ saveSecrets({
385
+ token: dbJwt,
386
+ dbUrl,
387
+ username: accountUsername,
388
+ org: orgSlug,
389
+ db: dbName,
390
+ authorized: true,
391
+ ...(apiToken ? { apiToken } : {}),
392
+ });
393
+ }
394
+ updateConfig({ tursoUrl: dbUrl, authorized: true, username: accountUsername });
395
+
396
+ return {
397
+ token: dbJwt,
398
+ dbUrl,
399
+ username: accountUsername,
400
+ org: orgSlug,
401
+ db: dbName,
402
+ authorized: true,
403
+ ...(apiToken ? { apiToken } : {}),
404
+ };
405
+ }
406
+
407
+ // Perform the full cloud login flow:
408
+ // 1. OAuth browser flow against Turso (api.turso.tech).
409
+ // 2. Validate the received account JWT.
410
+ // 3. Resolve an organization and pick/create a database.
411
+ // 4. Mint a full-access token for that database.
412
+ // 5. Persist the encrypted token + dbUrl and mark the session as authorized.
413
+ export async function loginToCloud({
414
+ customPort = 0,
415
+ simulated = false,
416
+ simulatedParams = null,
417
+ autoCreate = true,
418
+ databaseName = null,
419
+ org = null,
420
+ } = {}) {
421
+ const state = crypto.randomBytes(16).toString("hex");
422
+ const loopback = await createAuthLoopbackServer(customPort, state);
423
+ const loginUrl = `${TURSO_API_BASE()}/?port=${loopback.port}&redirect=true&state=${state}&type=cli`;
424
+
425
+ console.log(`\n [CLOUD] Please open your system browser to authorize:`);
426
+ console.log(` \x1b[36m${loginUrl}\x1b[0m\n`);
427
+
428
+ let received;
429
+ if (simulated && simulatedParams) {
430
+ const req = http.request(
431
+ `http://127.0.0.1:${loopback.port}/?jwt=${encodeURIComponent(simulatedParams.jwt)}&username=${encodeURIComponent(simulatedParams.username)}&state=${encodeURIComponent(state)}`,
432
+ { method: "GET" },
433
+ (res) => {
434
+ res.resume();
435
+ }
436
+ );
437
+ req.on("error", () => {});
438
+ req.end();
439
+ received = await loopback.result;
440
+ } else {
441
+ openBrowser(loginUrl);
442
+ received = await loopback.result;
443
+ }
444
+
445
+ const { token, username } = received;
446
+
447
+ // Step 2: validate the account token
448
+ let userInfo = null;
449
+ try {
450
+ userInfo = await validateTursoToken(token);
451
+ } catch (err) {
452
+ throw new Error(`Token validation failed: ${err.message}`);
453
+ }
454
+ const accountUsername = username || userInfo?.username || userInfo?.name || "user";
455
+ console.log(` [OK] Token is valid. User: ${accountUsername}`);
456
+
457
+ const secrets = await finalizeCloudLogin({ token, username: accountUsername, org, databaseName, autoCreate });
458
+
459
+ console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${secrets.dbUrl}\x1b[0m`);
460
+ return secrets;
461
+ }
462
+
463
+ // Headless login with a Turso account API token (no browser, no loopback).
464
+ // Create one at https://console.turso.tech or via `turso auth api-tokens create`.
465
+ // The same Platform API is used to resolve the organization + database and to
466
+ // mint a per-database token, exactly like the browser flow.
467
+ export async function loginWithApiToken({
468
+ token,
469
+ org = null,
470
+ databaseName = null,
471
+ autoCreate = true,
472
+ username = null,
473
+ persist = true,
474
+ } = {}) {
475
+ if (!token) throw new Error("An account API token is required.");
476
+ console.log("\n [CLOUD] Validating account API token...");
477
+ let userInfo = null;
478
+ try {
479
+ userInfo = await validateTursoToken(token);
480
+ } catch (err) {
481
+ throw new Error(`Token validation failed: ${err.message}`);
482
+ }
483
+ const accountUsername = username || userInfo?.username || userInfo?.name || "user";
484
+ console.log(` [OK] API token is valid. User: ${accountUsername}`);
485
+
486
+ const secrets = await finalizeCloudLogin({
487
+ token,
488
+ username: accountUsername,
489
+ org,
490
+ databaseName,
491
+ autoCreate,
492
+ persist,
493
+ apiToken: persist ? token : null,
494
+ });
495
+
496
+ console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${secrets.dbUrl}\x1b[0m`);
497
+ return secrets;
498
+ }
499
+
500
+ // Direct headless login with an existing database URL + auth token.
501
+ // No Platform API calls are made; org/db are derived from the endpoint.
502
+ export async function loginWithDatabaseToken({
503
+ token,
504
+ dbUrl,
505
+ username = "",
506
+ org = "",
507
+ db = "",
508
+ validate = true,
509
+ } = {}) {
510
+ if (!token || !dbUrl) throw new Error("Both a database auth token and a libsql:// URL are required.");
511
+
512
+ // Derive org + database name from the endpoint (libsql://<db>-<org>.turso.io)
513
+ let resolvedOrg = org;
514
+ let resolvedDb = db;
515
+ const m = String(dbUrl).match(/^libsql:\/\/(.+)\.turso\.io$/);
516
+ if (m) {
517
+ const host = m[1];
518
+ const sep = host.lastIndexOf("-");
519
+ if (sep > 0) {
520
+ resolvedDb = resolvedDb || host.slice(0, sep);
521
+ resolvedOrg = resolvedOrg || host.slice(sep + 1);
522
+ }
523
+ }
524
+
525
+ if (validate) {
526
+ console.log(" [CLOUD] Validating database token against the endpoint...");
527
+ try {
528
+ const { createClient } = await import("@libsql/client");
529
+ const client = createClient({ url: dbUrl, authToken: token });
530
+ await client.execute("SELECT 1");
531
+ client.close();
532
+ console.log(" [OK] Database token validated.");
533
+ } catch (err) {
534
+ throw new Error(`Database token validation failed: ${err.message}`);
535
+ }
536
+ }
537
+
538
+ saveSecrets({ token, dbUrl, username, org: resolvedOrg, db: resolvedDb, authorized: true });
539
+ updateConfig({ tursoUrl: dbUrl, authorized: true, username });
540
+
541
+ console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${dbUrl}\x1b[0m`);
542
+ return { token, dbUrl, username, org: resolvedOrg, db: resolvedDb, authorized: true };
543
+ }
544
+
545
+ // Pick up credentials from the environment or MEMORY_DIR/.env.
546
+ // - TURSO_DB_URL + TURSO_DB_TOKEN: direct endpoint login (preferred, no API calls).
547
+ // - TURSO_API_TOKEN: account API-token flow resolving org/db via the Platform API.
548
+ // When persist is true the resolved secrets are also written to the encrypted store.
549
+ export async function loginFromEnv({ persist = false } = {}) {
550
+ const env = resolveEnvSecrets();
551
+ if (!env) {
552
+ return { ok: false, reason: "No cloud secrets found in the environment or MEMORY_DIR/.env." };
553
+ }
554
+
555
+ if (env.dbUrl && env.token) {
556
+ const secrets = {
557
+ token: env.token,
558
+ dbUrl: env.dbUrl,
559
+ username: env.username || "",
560
+ org: env.org || "",
561
+ db: env.database || "",
562
+ authorized: true,
563
+ };
564
+ if (persist) saveSecrets(secrets);
565
+ updateConfig({ tursoUrl: env.dbUrl, authorized: true, username: secrets.username });
566
+ console.log(`\n \x1b[32m[OK] Cloud credentials imported from the environment! Endpoint: ${env.dbUrl}\x1b[0m`);
567
+ return { ok: true, secrets, source: "env" };
568
+ }
569
+
570
+ if (env.apiToken) {
571
+ // Resolve lazily; the raw API token stays in the environment and is NOT
572
+ // persisted to the encrypted store (explicit `login --api-token` does that).
573
+ const secrets = await loginWithApiToken({
574
+ token: env.apiToken,
575
+ org: env.org || null,
576
+ databaseName: env.database || null,
577
+ username: env.username || null,
578
+ persist: false,
579
+ });
580
+ return { ok: true, secrets: { ...secrets, apiToken: env.apiToken }, source: "env" };
581
+ }
582
+
583
+ return {
584
+ ok: false,
585
+ reason: "Incomplete cloud secrets. Set TURSO_DB_URL + TURSO_DB_TOKEN (preferred) or TURSO_API_TOKEN (with optional TURSO_ORG / TURSO_DATABASE).",
586
+ };
587
+ }
588
+
589
+ // Async resolution of the working cloud credentials (a DB URL + auth token).
590
+ // Used by database.js at startup so a raw TURSO_API_TOKEN environment token
591
+ // (which can only call the Platform API, not libsql) gets minted into a
592
+ // per-database JWT without any interactive step.
593
+ let _cachedResolvedSecrets = undefined;
594
+ let _cachedResolvedAt = 0;
595
+ const RESOLVED_CACHE_TTL_MS = 60_000; // 60s avoids re-minting JWT on every getDatabase()
596
+
597
+ export function invalidateResolvedCache() {
598
+ _cachedResolvedSecrets = undefined;
599
+ _cachedResolvedAt = 0;
600
+ }
601
+
602
+ export async function resolveCloudSecrets() {
603
+ const now = Date.now();
604
+ if (_cachedResolvedSecrets !== undefined && (now - _cachedResolvedAt) < RESOLVED_CACHE_TTL_MS) {
605
+ return _cachedResolvedSecrets;
606
+ }
607
+
608
+ const secrets = loadSecrets();
609
+ if (!secrets) { _cachedResolvedSecrets = null; return null; }
610
+ if (secrets.apiToken && secrets.needsResolution) {
611
+ const resolved = await loginWithApiToken({
612
+ token: secrets.apiToken,
613
+ org: secrets.org || null,
614
+ databaseName: secrets.db || null,
615
+ username: secrets.username || null,
616
+ persist: false,
617
+ });
618
+ _cachedResolvedSecrets = { ...resolved, source: "env" };
619
+ _cachedResolvedAt = Date.now();
620
+ return _cachedResolvedSecrets;
621
+ }
622
+ _cachedResolvedSecrets = secrets;
623
+ _cachedResolvedAt = Date.now();
624
+ return _cachedResolvedSecrets;
625
+ }
626
+
627
+ // Store/replace a Turso account API token. Alias of the headless login flow:
628
+ // the token is validated, an org/database is resolved and a per-database JWT
629
+ // is minted, then both the API token and the resolved session are persisted.
630
+ export async function setApiKey(token, { org = null, databaseName = null } = {}) {
631
+ if (!token || typeof token !== "string" || !token.trim()) {
632
+ throw new Error("An account API token is required.");
633
+ }
634
+ const secrets = await loginWithApiToken({ token: token.trim(), org, databaseName });
635
+ return { ok: true, secrets };
636
+ }
637
+
638
+ // Remove a stored API token. The resolved database session is kept as a plain
639
+ // browser/database session so an already-synced deployment keeps working.
640
+ export function clearApiKey() {
641
+ const existing = loadSecrets();
642
+ if (!existing || !existing.apiToken) return { removed: false };
643
+ const rest = { ...existing };
644
+ delete rest.apiToken;
645
+ if (rest.token && rest.dbUrl) {
646
+ saveSecrets({ ...rest, authorized: true });
647
+ invalidateAuthCache();
648
+ invalidateResolvedCache();
649
+ return { removed: true, keptDbSession: true };
650
+ }
651
+ deleteSecrets();
652
+ invalidateAuthCache();
653
+ invalidateResolvedCache();
654
+ return { removed: true, keptDbSession: false };
655
+ }
656
+
657
+ // Non-throwing status report used by `auth-status` and the TUI.
658
+ export function getAuthStatus() {
659
+ const secrets = loadSecrets();
660
+ const config = getConfig();
661
+ const source = getSecretsSource();
662
+ return {
663
+ source: source || "none",
664
+ configured: !!(secrets?.dbUrl || config.tursoUrl),
665
+ authorized: !!config.authorized || source === "env" || source === "api-key",
666
+ hasApiKey: !!secrets?.apiToken,
667
+ dbUrl: secrets?.dbUrl || config.tursoUrl || "",
668
+ username: secrets?.username || config.username || "",
669
+ org: secrets?.org || "",
670
+ database: secrets?.db || "",
671
+ mode: config.mode || "only-local",
672
+ };
673
+ }
674
+
675
+ // Logout and reset configurations
676
+ export function logoutFromCloud() {
677
+ const deleted = deleteSecrets();
678
+ invalidateAuthCache();
679
+ invalidateResolvedCache();
680
+ updateConfig({ tursoUrl: "", mode: "only-local", authorized: false, username: "" });
681
+ return deleted;
682
+ }