From 6e7675679acef135f334769e9d44a9a8894331bf Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 07:25:32 +0200 Subject: [PATCH 01/23] refactor(auth, storage): consolidate user storage and auth logic, update schemas --- .env.example | 25 +- README.md | 15 +- api/package.json | 2 +- api/src/auth.ts | 19 + api/src/index.ts | 2 + api/src/middleware/auth.ts | 120 ++--- api/src/routes/api-keys.ts | 254 ++++----- api/src/routes/auth.ts | 170 ++---- api/src/routes/storage.ts | 486 +++++++++--------- api/src/routes/sync-credentials.ts | 45 +- api/src/types.ts | 1 - bun.lock | 48 +- .../migrations/0003_better_auth_migration.sql | 195 +++++++ db/src/migrations/meta/_journal.json | 7 + db/src/schema.ts | 149 ++++-- docker-compose.yml | 4 +- web/package.json | 1 + web/src/App.tsx | 20 +- 18 files changed, 917 insertions(+), 646 deletions(-) create mode 100644 api/src/auth.ts create mode 100644 db/src/migrations/0003_better_auth_migration.sql diff --git a/.env.example b/.env.example index 20b3fce..1c11b0c 100644 --- a/.env.example +++ b/.env.example @@ -21,20 +21,29 @@ TOKEN_ENCRYPTION_KEY= # ── REST API ───────────────────────────────────────────────────────────── +# Better Auth secret — used to sign sessions (replaces JWT_SECRET) +# Generate: openssl rand -hex 32 +BETTER_AUTH_SECRET= + +# Public base URL of the API server (used by Better Auth to construct OAuth callback URLs) +# Local dev (Vite proxies /v1 → port 3000): http://localhost:3000 +# Prod: https://api.your-domain.com +BETTER_AUTH_URL=http://localhost:3000 + # Google OAuth2 app credentials (console.cloud.google.com → APIs & Services → Credentials) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= + # Must exactly match an authorized redirect URI in your Google OAuth app -# Vite dev server (port 5173) proxies /v1/* → API (port 3000), so OAuth cookies land on the right origin -# Add both URIs to Google Cloud Console -GOOGLE_REDIRECT_URI=http://localhost:5173/v1/auth/google/callback +# Better Auth callback (for user login): {BETTER_AUTH_URL}/api/auth/callback/google +# Add this to Google Cloud Console authorized redirect URIs: +# http://localhost:3000/api/auth/callback/google (local dev) +# https://api.your-domain.com/api/auth/callback/google (prod) + # Must exactly match an authorized redirect URI in your Google OAuth app +# For Google Drive storage connection (separate OAuth flow): GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/gdrive/callback # Frontend URL — API callbacks redirect here after OAuth flows complete -# Local (Vite dev server proxies /v1 → port 3000): http://localhost:5173 Prod: https://your-domain.com +# Local (Vite dev server): http://localhost:5173 Prod: https://your-domain.com FRONTEND_URL=http://localhost:5173 - -# JWT signing secret — 32-byte hex -# Generate: openssl rand -hex 32 -JWT_SECRET= diff --git a/README.md b/README.md index d88c80c..81639df 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ No database or cloud storage required. Users defined via `SYNC_USER1` in `.env`. Good for local development and testing the REST API without GDrive setup. ```bash -cp .env.example .env # set SIDECAR_TOKEN, JWT_SECRET, SYNC_USER1 at minimum +cp .env.example .env # set SIDECAR_TOKEN, BETTER_AUTH_SECRET, SYNC_USER1 at minimum # published image (fast) docker compose -f docker-compose.yml -f docker-compose.standalone.yml up @@ -34,8 +34,15 @@ docker compose --build -f docker-compose.yml -f docker-compose.standalone.yml -f ### Cloud mode -Full production-like stack. Users authenticate via Google OAuth; deck data stored in their -Google Drive. Requires all OAuth credentials in `.env`. +Full production-like stack. Users authenticate via Google OAuth (via Better Auth); deck data +stored in their Google Drive. Requires all OAuth credentials in `.env`. + +Before running, add the Better Auth callback URI to your Google OAuth app in +[Google Cloud Console](https://console.cloud.google.com) → APIs & Services → Credentials: + +``` +{BETTER_AUTH_URL}/api/auth/callback/google +``` ```bash cp .env.example .env # fill in all credentials @@ -69,7 +76,7 @@ Authorization: Bearer ak_ Generate a key in the web UI under **Account → API Keys**, or via `POST /v1/me/api-keys`. -Account management endpoints (`/v1/me/*`) use the session cookie set by Google OAuth login. +Account management endpoints (`/v1/me/*`) use the session cookie set by [Better Auth](https://better-auth.com) after Google OAuth login. The auth handler is mounted at `/api/auth/*`. --- diff --git a/api/package.json b/api/package.json index b273254..541ff7e 100644 --- a/api/package.json +++ b/api/package.json @@ -12,9 +12,9 @@ "@anki-cloud/db": "workspace:*", "@hono/zod-openapi": "^0.19.6", "arctic": "^3.7.0", + "better-auth": "^1.0.0", "drizzle-orm": "^0.43.1", "hono": "^4.7.7", - "jose": "^6.2.2", "zod": "^3.24.2" }, "devDependencies": { diff --git a/api/src/auth.ts b/api/src/auth.ts new file mode 100644 index 0000000..32403bb --- /dev/null +++ b/api/src/auth.ts @@ -0,0 +1,19 @@ +// Copyright 2026 Archont Soft Daniel Klimuntowski +// Licensed under the Elastic License 2.0 — see LICENSE in the repository root. +import {betterAuth} from "better-auth"; +import {drizzleAdapter} from "better-auth/adapters/drizzle"; +import {db} from "@anki-cloud/db"; + +export const auth = betterAuth({ + baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", + secret: process.env.BETTER_AUTH_SECRET!, + database: drizzleAdapter(db, { + provider: "sqlite", + }), + socialProviders: { + google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }, + }, +}); diff --git a/api/src/index.ts b/api/src/index.ts index c140540..d4596ee 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,6 +1,7 @@ // Copyright 2026 Archont Soft Daniel Klimuntowski // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. import { OpenAPIHono } from "@hono/zod-openapi"; +import { auth } from "@/auth"; import { authRouter } from "@/routes/auth"; import { storageRouter } from "@/routes/storage"; import { apiKeysRouter } from "@/routes/api-keys"; @@ -45,6 +46,7 @@ publicApi.get("/docs", (c) => const app = new OpenAPIHono(); app.get("/health", (c) => c.json({ status: "ok" })); +app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw)); app.route("/", publicApi); app.route("/v1", authRouter); app.route("/v1", storageRouter); diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts index 17b5f56..412e255 100644 --- a/api/src/middleware/auth.ts +++ b/api/src/middleware/auth.ts @@ -1,80 +1,56 @@ // Copyright 2026 Archont Soft Daniel Klimuntowski // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. -import { getCookie } from "hono/cookie"; -import { createMiddleware } from "hono/factory"; -import { jwtVerify } from "jose"; -import { and, eq, isNull } from "drizzle-orm"; -import { db, users, usersApiKeys } from "@anki-cloud/db"; -import type { Env } from "@/types"; - -const secret = new Uint8Array(Buffer.from(process.env.JWT_SECRET!, "hex")); +import {createMiddleware} from "hono/factory"; +import {and, eq, isNull} from "drizzle-orm"; +import {db, user, userApiKey} from "@anki-cloud/db"; +import {auth} from "@/auth"; +import type {Env} from "@/types"; export const authWebMiddleware = createMiddleware(async (c, next) => { - const token = getCookie(c, "session"); - if (!token) { - return c.json({ error: "Unauthenticated", code: "MISSING_SESSION" }, 401); - } - - let sub: string; - try { - const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"] }); - if (typeof payload.sub !== "string") throw new Error("missing sub"); - sub = payload.sub; - } catch { - return c.json({ error: "Invalid or expired session", code: "INVALID_SESSION" }, 401); - } - - const [user] = await db.select().from(users).where(eq(users.id, sub)).limit(1); - if (!user) { - return c.json({ error: "User not found", code: "USER_NOT_FOUND" }, 401); - } - - c.set("user", { id: user.id, googleSub: user.googleSub, email: user.email, name: user.name }); - await next(); + const session = await auth.api.getSession({headers: c.req.raw.headers}); + if (!session) { + return c.json({error: "Unauthenticated", code: "MISSING_SESSION"}, 401); + } + c.set("user", {id: session.user.id, email: session.user.email, name: session.user.name}); + await next(); }); export const authApiMiddleware = createMiddleware(async (c, next) => { - const authHeader = c.req.header("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return c.json({ error: "Missing API key", code: "MISSING_API_KEY" }, 401); - } - const rawKey = authHeader.slice(7); - if (!rawKey.startsWith("ak_")) { - return c.json({ error: "Invalid API key", code: "INVALID_API_KEY" }, 401); - } - - const keyHash = Buffer.from( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawKey)) - ).toString("hex"); - - const [row] = await db - .select({ - keyId: usersApiKeys.id, - userId: usersApiKeys.userId, - userEmail: users.email, - userName: users.name, - userGoogleSub: users.googleSub, - }) - .from(usersApiKeys) - .innerJoin(users, eq(usersApiKeys.userId, users.id)) - .where(and(eq(usersApiKeys.keyHash, keyHash), isNull(usersApiKeys.revokedAt))) - .limit(1); - - if (!row) { - return c.json({ error: "Invalid or revoked API key", code: "INVALID_API_KEY" }, 401); - } - - db.update(usersApiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(usersApiKeys.id, row.keyId)) - .execute() - .catch(() => {}); - - c.set("user", { - id: row.userId, - googleSub: row.userGoogleSub, - email: row.userEmail, - name: row.userName, - }); - await next(); + const authHeader = c.req.header("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return c.json({error: "Missing API key", code: "MISSING_API_KEY"}, 401); + } + const rawKey = authHeader.slice(7); + if (!rawKey.startsWith("ak_")) { + return c.json({error: "Invalid API key", code: "INVALID_API_KEY"}, 401); + } + + const keyHash = Buffer.from( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawKey)) + ).toString("hex"); + + const [row] = await db + .select({ + keyId: userApiKey.id, + userId: userApiKey.userId, + userEmail: user.email, + userName: user.name, + }) + .from(userApiKey) + .innerJoin(user, eq(userApiKey.userId, user.id)) + .where(and(eq(userApiKey.keyHash, keyHash), isNull(userApiKey.revokedAt))) + .limit(1); + + if (!row) { + return c.json({error: "Invalid or revoked API key", code: "INVALID_API_KEY"}, 401); + } + + db.update(userApiKey) + .set({lastUsedAt: new Date()}) + .where(eq(userApiKey.id, row.keyId)) + .execute() + .catch(() => {}); + + c.set("user", {id: row.userId, email: row.userEmail, name: row.userName}); + await next(); }); diff --git a/api/src/routes/api-keys.ts b/api/src/routes/api-keys.ts index 198766d..51883b9 100644 --- a/api/src/routes/api-keys.ts +++ b/api/src/routes/api-keys.ts @@ -1,167 +1,167 @@ // Copyright 2026 Archont Soft Daniel Klimuntowski // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. -import { and, eq, isNull } from "drizzle-orm"; -import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; -import { db, usersApiKeys } from "@anki-cloud/db"; -import { authWebMiddleware } from "@/middleware/auth"; -import type { Env } from "@/types"; +import {and, eq, isNull} from "drizzle-orm"; +import {OpenAPIHono, createRoute, z} from "@hono/zod-openapi"; +import {db, userApiKey} from "@anki-cloud/db"; +import {authWebMiddleware} from "@/middleware/auth"; +import type {Env} from "@/types"; -const ErrorSchema = z.object({ error: z.string(), code: z.string() }); +const ErrorSchema = z.object({error: z.string(), code: z.string()}); const ApiKeySchema = z.object({ - id: z.string().uuid(), - label: z.string(), - lastUsedAt: z.string().datetime().nullable(), - createdAt: z.string().datetime(), + id: z.string().uuid(), + label: z.string(), + lastUsedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), }); const ApiKeyListResponseSchema = z.object({ - apiKeys: z.array(ApiKeySchema), + apiKeys: z.array(ApiKeySchema), }); const CreateApiKeyRequestSchema = z.object({ - label: z.string().min(1).max(100), + label: z.string().min(1).max(100), }); const CreateApiKeyResponseSchema = z.object({ - id: z.string().uuid(), - label: z.string(), - key: z.string(), - createdAt: z.string().datetime(), + id: z.string().uuid(), + label: z.string(), + key: z.string(), + createdAt: z.string().datetime(), }); export const apiKeysRouter = new OpenAPIHono(); const listApiKeysRoute = createRoute({ - method: "get", - path: "/me/api-keys", - middleware: [authWebMiddleware] as const, - responses: { - 200: { - content: { "application/json": { schema: ApiKeyListResponseSchema } }, - description: "Active API keys", + method: "get", + path: "/me/api-keys", + middleware: [authWebMiddleware] as const, + responses: { + 200: { + content: {"application/json": {schema: ApiKeyListResponseSchema}}, + description: "Active API keys", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", - }, - }, }); apiKeysRouter.openapi(listApiKeysRoute, async (c) => { - const { id: userId } = c.get("user"); - - const keys = await db - .select({ - id: usersApiKeys.id, - label: usersApiKeys.label, - lastUsedAt: usersApiKeys.lastUsedAt, - createdAt: usersApiKeys.createdAt, - }) - .from(usersApiKeys) - .where(and(eq(usersApiKeys.userId, userId), isNull(usersApiKeys.revokedAt))); - - return c.json( - { - apiKeys: keys.map((k) => ({ - ...k, - lastUsedAt: k.lastUsedAt?.toISOString() ?? null, - createdAt: k.createdAt.toISOString(), - })), - }, - 200 - ); + const {id: userId} = c.get("user"); + + const keys = await db + .select({ + id: userApiKey.id, + label: userApiKey.label, + lastUsedAt: userApiKey.lastUsedAt, + createdAt: userApiKey.createdAt, + }) + .from(userApiKey) + .where(and(eq(userApiKey.userId, userId), isNull(userApiKey.revokedAt))); + + return c.json( + { + apiKeys: keys.map((k) => ({ + ...k, + lastUsedAt: k.lastUsedAt?.toISOString() ?? null, + createdAt: k.createdAt.toISOString(), + })), + }, + 200 + ); }); const createApiKeyRoute = createRoute({ - method: "post", - path: "/me/api-keys", - middleware: [authWebMiddleware] as const, - request: { - body: { - content: { "application/json": { schema: CreateApiKeyRequestSchema } }, - required: true, - }, - }, - responses: { - 201: { - content: { "application/json": { schema: CreateApiKeyResponseSchema } }, - description: "Created API key (plaintext shown once)", + method: "post", + path: "/me/api-keys", + middleware: [authWebMiddleware] as const, + request: { + body: { + content: {"application/json": {schema: CreateApiKeyRequestSchema}}, + required: true, + }, }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", + responses: { + 201: { + content: {"application/json": {schema: CreateApiKeyResponseSchema}}, + description: "Created API key (plaintext shown once)", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - }, }); apiKeysRouter.openapi(createApiKeyRoute, async (c) => { - const { id: userId } = c.get("user"); - const { label } = c.req.valid("json"); - - const rawKey = `ak_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`; - const keyHash = Buffer.from( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawKey)) - ).toString("hex"); - - const [created] = await db - .insert(usersApiKeys) - .values({ userId, label, keyHash }) - .returning(); - - return c.json( - { - id: created!.id, - label: created!.label, - key: rawKey, - createdAt: created!.createdAt.toISOString(), - }, - 201 - ); + const {id: userId} = c.get("user"); + const {label} = c.req.valid("json"); + + const rawKey = `ak_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`; + const keyHash = Buffer.from( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawKey)) + ).toString("hex"); + + const [created] = await db + .insert(userApiKey) + .values({userId, label, keyHash}) + .returning(); + + return c.json( + { + id: created!.id, + label: created!.label, + key: rawKey, + createdAt: created!.createdAt.toISOString(), + }, + 201 + ); }); const revokeApiKeyRoute = createRoute({ - method: "delete", - path: "/me/api-keys/{id}", - middleware: [authWebMiddleware] as const, - request: { - params: z.object({ id: z.string().uuid() }), - }, - responses: { - 200: { - content: { "application/json": { schema: z.object({ ok: z.boolean() }) } }, - description: "API key revoked", - }, - 404: { - content: { "application/json": { schema: ErrorSchema } }, - description: "API key not found", + method: "delete", + path: "/me/api-keys/{id}", + middleware: [authWebMiddleware] as const, + request: { + params: z.object({id: z.string().uuid()}), }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", + responses: { + 200: { + content: {"application/json": {schema: z.object({ok: z.boolean()})}}, + description: "API key revoked", + }, + 404: { + content: {"application/json": {schema: ErrorSchema}}, + description: "API key not found", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - }, }); apiKeysRouter.openapi(revokeApiKeyRoute, async (c) => { - const { id: userId } = c.get("user"); - const { id } = c.req.valid("param"); - - const updated = await db - .update(usersApiKeys) - .set({ revokedAt: new Date() }) - .where( - and( - eq(usersApiKeys.id, id), - eq(usersApiKeys.userId, userId), - isNull(usersApiKeys.revokedAt) - ) - ) - .returning({ id: usersApiKeys.id }); - - if (updated.length === 0) { - return c.json({ error: "API key not found", code: "NOT_FOUND" }, 404); - } - - return c.json({ ok: true }, 200); + const {id: userId} = c.get("user"); + const {id} = c.req.valid("param"); + + const updated = await db + .update(userApiKey) + .set({revokedAt: new Date()}) + .where( + and( + eq(userApiKey.id, id), + eq(userApiKey.userId, userId), + isNull(userApiKey.revokedAt) + ) + ) + .returning({id: userApiKey.id}); + + if (updated.length === 0) { + return c.json({error: "API key not found", code: "NOT_FOUND"}, 404); + } + + return c.json({ok: true}, 200); }); diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 15498e8..4f83abf 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -1,152 +1,48 @@ // Copyright 2026 Archont Soft Daniel Klimuntowski // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. -import { - ArcticFetchError, - Google, - OAuth2RequestError, - decodeIdToken, - generateCodeVerifier, - generateState, -} from "arctic"; -import { eq } from "drizzle-orm"; -import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; -import { SignJWT } from "jose"; -import { db, users } from "@anki-cloud/db"; -import { authWebMiddleware } from "@/middleware/auth"; -import type { Env } from "@/types"; +import {OpenAPIHono, createRoute, z} from "@hono/zod-openapi"; +import {eq} from "drizzle-orm"; +import {db, user} from "@anki-cloud/db"; +import {authWebMiddleware} from "@/middleware/auth"; +import type {Env} from "@/types"; -const google = new Google( - process.env.GOOGLE_CLIENT_ID!, - process.env.GOOGLE_CLIENT_SECRET!, - process.env.GOOGLE_REDIRECT_URI! -); - -const secret = new Uint8Array(Buffer.from(process.env.JWT_SECRET!, "hex")); - -const SESSION_MAX_AGE = 60 * 60 * 24 * 30; // 30 days -const OAUTH_STATE_MAX_AGE = 600; // 10 minutes - -const ErrorSchema = z.object({ error: z.string(), code: z.string() }); +const ErrorSchema = z.object({error: z.string(), code: z.string()}); const MeResponseSchema = z.object({ - id: z.string().uuid(), - email: z.string().email().nullable(), - name: z.string().nullable(), - createdAt: z.string().datetime(), + id: z.string(), + email: z.string().email().nullable(), + name: z.string().nullable(), + createdAt: z.string().datetime(), }); -const FRONTEND_URL = process.env.FRONTEND_URL ?? "/"; - export const authRouter = new OpenAPIHono(); -authRouter.get("/auth/logout", (c) => { - deleteCookie(c, "session", { path: "/" }); - return c.redirect(FRONTEND_URL, 302); -}); - -authRouter.get("/auth/google", async (c) => { - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "email", "profile"]); - - setCookie(c, "oauth_state", state, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: OAUTH_STATE_MAX_AGE, - }); - setCookie(c, "oauth_code_verifier", codeVerifier, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: OAUTH_STATE_MAX_AGE, - }); - - return c.redirect(url.toString(), 302); -}); - -authRouter.get("/auth/google/callback", async (c) => { - const { code, state } = c.req.query(); - const storedState = getCookie(c, "oauth_state"); - const codeVerifier = getCookie(c, "oauth_code_verifier"); - - deleteCookie(c, "oauth_state"); - deleteCookie(c, "oauth_code_verifier"); - - if (!code || !state || !storedState || !codeVerifier || state !== storedState) { - return c.json({ error: "Invalid state", code: "INVALID_OAUTH_STATE" }, 400); - } - - let tokens; - try { - tokens = await google.validateAuthorizationCode(code, codeVerifier); - } catch (e) { - if (e instanceof OAuth2RequestError) { - return c.json({ error: e.message, code: "OAUTH_ERROR" }, 400); - } - if (e instanceof ArcticFetchError) { - return c.json({ error: "OAuth provider unreachable", code: "OAUTH_FETCH_ERROR" }, 502); - } - throw e; - } - - const claims = decodeIdToken(tokens.idToken()) as { - sub: string; - email?: string; - name?: string; - }; - const { sub, email = null, name = null } = claims; - - const [user] = await db - .insert(users) - .values({ googleSub: sub, email, name }) - .onConflictDoUpdate({ target: users.googleSub, set: { email, name } }) - .returning(); - - const token = await new SignJWT({ sub: user!.id, googleSub: sub, email }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime("30d") - .sign(secret); - - setCookie(c, "session", token, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: SESSION_MAX_AGE, - secure: process.env.NODE_ENV === "production", - }); - - return c.redirect(FRONTEND_URL, 302); -}); - const meRoute = createRoute({ - method: "get", - path: "/me", - middleware: [authWebMiddleware] as const, - responses: { - 200: { - content: { "application/json": { schema: MeResponseSchema } }, - description: "Current user", + method: "get", + path: "/me", + middleware: [authWebMiddleware] as const, + responses: { + 200: { + content: {"application/json": {schema: MeResponseSchema}}, + description: "Current user", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", - }, - }, }); authRouter.openapi(meRoute, async (c) => { - const { id } = c.get("user"); - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - return c.json( - { - id: user!.id, - email: user!.email, - name: user!.name, - createdAt: user!.createdAt.toISOString(), - }, - 200 - ); + const {id} = c.get("user"); + const [u] = await db.select().from(user).where(eq(user.id, id)).limit(1); + return c.json( + { + id: u!.id, + email: u!.email, + name: u!.name, + createdAt: u!.createdAt.toISOString(), + }, + 200 + ); }); diff --git a/api/src/routes/storage.ts b/api/src/routes/storage.ts index 8f819db..9350a5b 100644 --- a/api/src/routes/storage.ts +++ b/api/src/routes/storage.ts @@ -1,311 +1,321 @@ // Copyright 2026 Archont Soft Daniel Klimuntowski // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. import { - ArcticFetchError, - Google, - OAuth2RequestError, - generateCodeVerifier, - generateState, + ArcticFetchError, + Google, + OAuth2RequestError, + generateCodeVerifier, + generateState, } from "arctic"; -import { and, eq } from "drizzle-orm"; -import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; -import { deleteCookie, getCookie, setCookie } from "hono/cookie"; -import { db, storageConnections } from "@anki-cloud/db"; -import { encrypt } from "@anki-cloud/db/encrypt"; -import { authWebMiddleware } from "@/middleware/auth"; -import type { Env } from "@/types"; +import {and, eq} from "drizzle-orm"; +import {OpenAPIHono, createRoute, z} from "@hono/zod-openapi"; +import {deleteCookie, getCookie, setCookie} from "hono/cookie"; +import {db, userStorageConnection} from "@anki-cloud/db"; +import {encrypt} from "@anki-cloud/db/encrypt"; +import {authWebMiddleware} from "@/middleware/auth"; +import type {Env} from "@/types"; const googleDrive = new Google( - process.env.GOOGLE_CLIENT_ID!, - process.env.GOOGLE_CLIENT_SECRET!, - process.env.GOOGLE_DRIVE_REDIRECT_URI! + process.env.GOOGLE_CLIENT_ID!, + process.env.GOOGLE_CLIENT_SECRET!, + process.env.GOOGLE_DRIVE_REDIRECT_URI! ); const OAUTH_STATE_MAX_AGE = 600; const FRONTEND_URL = process.env.FRONTEND_URL ?? "/"; -const ErrorSchema = z.object({ error: z.string(), code: z.string() }); +const ErrorSchema = z.object({error: z.string(), code: z.string()}); const ProviderSchema = z.enum(["gdrive", "dropbox", "s3"]); const StorageConnectionSchema = z.object({ - id: z.string().uuid(), - provider: ProviderSchema, - folderPath: z.string(), - connectedAt: z.string().datetime(), + id: z.string().uuid(), + provider: ProviderSchema, + folderPath: z.string(), + connectedAt: z.string().datetime(), }); const StorageListResponseSchema = z.object({ - connections: z.array(StorageConnectionSchema), + connections: z.array(StorageConnectionSchema), }); const StorageConnectRequestSchema = z.object({ - provider: ProviderSchema, + provider: ProviderSchema, }); const StorageConnectResponseSchema = z.object({ - redirectUrl: z.string().url(), + redirectUrl: z.string().url(), }); export const storageRouter = new OpenAPIHono(); const storageConnectRoute = createRoute({ - method: "post", - path: "/me/storage/connect", - middleware: [authWebMiddleware] as const, - request: { - body: { - content: { "application/json": { schema: StorageConnectRequestSchema } }, - required: true, - }, - }, - responses: { - 200: { - content: { "application/json": { schema: StorageConnectResponseSchema } }, - description: "OAuth redirect URL to initiate storage connection", - }, - 400: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unsupported provider", + method: "post", + path: "/me/storage/connect", + middleware: [authWebMiddleware] as const, + request: { + body: { + content: {"application/json": {schema: StorageConnectRequestSchema}}, + required: true, + }, }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", + responses: { + 200: { + content: {"application/json": {schema: StorageConnectResponseSchema}}, + description: "OAuth redirect URL to initiate storage connection", + }, + 400: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unsupported provider", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - }, }); storageRouter.openapi(storageConnectRoute, async (c) => { - const { provider } = c.req.valid("json"); - - if (provider !== "gdrive") { - return c.json({ error: "Provider not yet supported", code: "UNSUPPORTED_PROVIDER" }, 400); - } - - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const url = googleDrive.createAuthorizationURL(state, codeVerifier, [ - "https://www.googleapis.com/auth/drive.file", - ]); - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - - setCookie(c, "gdrive_oauth_state", state, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: OAUTH_STATE_MAX_AGE, - }); - setCookie(c, "gdrive_code_verifier", codeVerifier, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: OAUTH_STATE_MAX_AGE, - }); - - return c.json({ redirectUrl: url.toString() }, 200); + const {provider} = c.req.valid("json"); + + if (provider !== "gdrive") { + return c.json({error: "Provider not yet supported", code: "UNSUPPORTED_PROVIDER"}, 400); + } + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = googleDrive.createAuthorizationURL(state, codeVerifier, [ + "https://www.googleapis.com/auth/drive.file", + ]); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + + setCookie(c, "gdrive_oauth_state", state, { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: OAUTH_STATE_MAX_AGE, + }); + setCookie(c, "gdrive_code_verifier", codeVerifier, { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: OAUTH_STATE_MAX_AGE, + }); + + return c.json({redirectUrl: url.toString()}, 200); }); const storageDisconnectRoute = createRoute({ - method: "delete", - path: "/me/storage/{provider}", - middleware: [authWebMiddleware] as const, - request: { - params: z.object({ provider: ProviderSchema }), - }, - responses: { - 200: { - content: { "application/json": { schema: z.object({ ok: z.boolean() }) } }, - description: "Storage disconnected", - }, - 404: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Storage connection not found", + method: "delete", + path: "/me/storage/{provider}", + middleware: [authWebMiddleware] as const, + request: { + params: z.object({provider: ProviderSchema}), }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", + responses: { + 200: { + content: {"application/json": {schema: z.object({ok: z.boolean()})}}, + description: "Storage disconnected", + }, + 404: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Storage connection not found", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - }, }); storageRouter.openapi(storageDisconnectRoute, async (c) => { - const { id: userId } = c.get("user"); - const { provider } = c.req.valid("param"); - - const deleted = await db - .delete(storageConnections) - .where(and(eq(storageConnections.userId, userId), eq(storageConnections.provider, provider))) - .returning({ id: storageConnections.id }); - - if (deleted.length === 0) { - return c.json({ error: "Storage connection not found", code: "NOT_FOUND" }, 404); - } + const {id: userId} = c.get("user"); + const {provider} = c.req.valid("param"); + + const deleted = await db + .delete(userStorageConnection) + .where( + and( + eq(userStorageConnection.userId, userId), + eq(userStorageConnection.provider, provider) + ) + ) + .returning({id: userStorageConnection.id}); + + if (deleted.length === 0) { + return c.json({error: "Storage connection not found", code: "NOT_FOUND"}, 404); + } - return c.json({ ok: true }, 200); + return c.json({ok: true}, 200); }); storageRouter.get("/me/storage/connect/gdrive", authWebMiddleware, async (c) => { - const state = generateState(); - const codeVerifier = generateCodeVerifier(); - const url = googleDrive.createAuthorizationURL(state, codeVerifier, [ - "https://www.googleapis.com/auth/drive.file", - ]); - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - - setCookie(c, "gdrive_oauth_state", state, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: OAUTH_STATE_MAX_AGE, - }); - setCookie(c, "gdrive_code_verifier", codeVerifier, { - httpOnly: true, - sameSite: "Lax", - path: "/", - maxAge: OAUTH_STATE_MAX_AGE, - }); - - return c.redirect(url.toString(), 302); + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = googleDrive.createAuthorizationURL(state, codeVerifier, [ + "https://www.googleapis.com/auth/drive.file", + ]); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + + setCookie(c, "gdrive_oauth_state", state, { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: OAUTH_STATE_MAX_AGE, + }); + setCookie(c, "gdrive_code_verifier", codeVerifier, { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: OAUTH_STATE_MAX_AGE, + }); + + return c.redirect(url.toString(), 302); }); storageRouter.get("/me/storage/connect/gdrive/callback", authWebMiddleware, async (c) => { - const { code, state } = c.req.query(); - const storedState = getCookie(c, "gdrive_oauth_state"); - const codeVerifier = getCookie(c, "gdrive_code_verifier"); + const {code, state} = c.req.query(); + const storedState = getCookie(c, "gdrive_oauth_state"); + const codeVerifier = getCookie(c, "gdrive_code_verifier"); - deleteCookie(c, "gdrive_oauth_state"); - deleteCookie(c, "gdrive_code_verifier"); + deleteCookie(c, "gdrive_oauth_state"); + deleteCookie(c, "gdrive_code_verifier"); - if (!code || !state || !storedState || !codeVerifier || state !== storedState) { - return c.redirect(`${FRONTEND_URL}?storage=error`, 302); - } + if (!code || !state || !storedState || !codeVerifier || state !== storedState) { + return c.redirect(`${FRONTEND_URL}?storage=error`, 302); + } - const { id: userId } = c.get("user"); + const {id: userId} = c.get("user"); - let tokens; - try { - tokens = await googleDrive.validateAuthorizationCode(code, codeVerifier); - } catch (e) { - if (e instanceof OAuth2RequestError || e instanceof ArcticFetchError) { - return c.redirect(`${FRONTEND_URL}?storage=error`, 302); + let tokens; + try { + tokens = await googleDrive.validateAuthorizationCode(code, codeVerifier); + } catch (e) { + if (e instanceof OAuth2RequestError || e instanceof ArcticFetchError) { + return c.redirect(`${FRONTEND_URL}?storage=error`, 302); + } + throw e; } - throw e; - } - - const accessToken = tokens.accessToken(); - const refreshToken = tokens.refreshToken(); - - const [encryptedAccess, encryptedRefresh] = await Promise.all([ - encrypt(accessToken), - encrypt(refreshToken), - ]); - - await db - .insert(storageConnections) - .values({ - userId, - provider: "gdrive", - oauthToken: encryptedAccess, - oauthRefreshToken: encryptedRefresh, - }) - .onConflictDoUpdate({ - target: [storageConnections.userId, storageConnections.provider], - set: { - oauthToken: encryptedAccess, - oauthRefreshToken: encryptedRefresh, - connectedAt: new Date(), - }, - }); - return c.redirect(`${FRONTEND_URL}?storage=connected`, 302); + const accessToken = tokens.accessToken(); + const refreshToken = tokens.refreshToken(); + + const [encryptedAccess, encryptedRefresh] = await Promise.all([ + encrypt(accessToken), + encrypt(refreshToken), + ]); + + await db + .insert(userStorageConnection) + .values({ + userId, + provider: "gdrive", + oauthToken: encryptedAccess, + oauthRefreshToken: encryptedRefresh, + }) + .onConflictDoUpdate({ + target: [userStorageConnection.userId, userStorageConnection.provider], + set: { + oauthToken: encryptedAccess, + oauthRefreshToken: encryptedRefresh, + connectedAt: new Date(), + }, + }); + + return c.redirect(`${FRONTEND_URL}?storage=connected`, 302); }); const storageListRoute = createRoute({ - method: "get", - path: "/me/storage", - middleware: [authWebMiddleware] as const, - responses: { - 200: { - content: { "application/json": { schema: StorageListResponseSchema } }, - description: "Storage connections", - }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", + method: "get", + path: "/me/storage", + middleware: [authWebMiddleware] as const, + responses: { + 200: { + content: {"application/json": {schema: StorageListResponseSchema}}, + description: "Storage connections", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - }, }); const storageUpdateRoute = createRoute({ - method: "put", - path: "/me/storage/{provider}", - middleware: [authWebMiddleware] as const, - request: { - params: z.object({ provider: ProviderSchema }), - body: { - content: { - "application/json": { - schema: z.object({ folderPath: z.string().min(1).startsWith("/") }), + method: "put", + path: "/me/storage/{provider}", + middleware: [authWebMiddleware] as const, + request: { + params: z.object({provider: ProviderSchema}), + body: { + content: { + "application/json": { + schema: z.object({folderPath: z.string().min(1).startsWith("/")}), + }, + }, + required: true, }, - }, - required: true, - }, - }, - responses: { - 200: { - content: { "application/json": { schema: z.object({ ok: z.boolean() }) } }, - description: "Folder path updated", }, - 404: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Storage connection not found", - }, - 401: { - content: { "application/json": { schema: ErrorSchema } }, - description: "Unauthenticated", + responses: { + 200: { + content: {"application/json": {schema: z.object({ok: z.boolean()})}}, + description: "Folder path updated", + }, + 404: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Storage connection not found", + }, + 401: { + content: {"application/json": {schema: ErrorSchema}}, + description: "Unauthenticated", + }, }, - }, }); storageRouter.openapi(storageUpdateRoute, async (c) => { - const { id: userId } = c.get("user"); - const { provider } = c.req.valid("param"); - const { folderPath } = c.req.valid("json"); - - const updated = await db - .update(storageConnections) - .set({ folderPath }) - .where(and(eq(storageConnections.userId, userId), eq(storageConnections.provider, provider))) - .returning({ id: storageConnections.id }); - - if (updated.length === 0) { - return c.json({ error: "Storage connection not found", code: "NOT_FOUND" }, 404); - } + const {id: userId} = c.get("user"); + const {provider} = c.req.valid("param"); + const {folderPath} = c.req.valid("json"); + + const updated = await db + .update(userStorageConnection) + .set({folderPath}) + .where( + and( + eq(userStorageConnection.userId, userId), + eq(userStorageConnection.provider, provider) + ) + ) + .returning({id: userStorageConnection.id}); + + if (updated.length === 0) { + return c.json({error: "Storage connection not found", code: "NOT_FOUND"}, 404); + } - return c.json({ ok: true }, 200); + return c.json({ok: true}, 200); }); storageRouter.openapi(storageListRoute, async (c) => { - const { id: userId } = c.get("user"); - const connections = await db - .select({ - id: storageConnections.id, - provider: storageConnections.provider, - folderPath: storageConnections.folderPath, - connectedAt: storageConnections.connectedAt, - }) - .from(storageConnections) - .where(eq(storageConnections.userId, userId)); - - return c.json( - { - connections: connections.map((conn) => ({ - ...conn, - connectedAt: conn.connectedAt.toISOString(), - })), - }, - 200 - ); + const {id: userId} = c.get("user"); + const connections = await db + .select({ + id: userStorageConnection.id, + provider: userStorageConnection.provider, + folderPath: userStorageConnection.folderPath, + connectedAt: userStorageConnection.connectedAt, + }) + .from(userStorageConnection) + .where(eq(userStorageConnection.userId, userId)); + + return c.json( + { + connections: connections.map((conn) => ({ + ...conn, + connectedAt: conn.connectedAt.toISOString(), + })), + }, + 200 + ); }); diff --git a/api/src/routes/sync-credentials.ts b/api/src/routes/sync-credentials.ts index 2c421af..89277ee 100644 --- a/api/src/routes/sync-credentials.ts +++ b/api/src/routes/sync-credentials.ts @@ -2,7 +2,7 @@ // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. import {eq} from "drizzle-orm"; import {OpenAPIHono, createRoute, z} from "@hono/zod-openapi"; -import {db, users, usersSyncState} from "@anki-cloud/db"; +import {db, userSyncConfig, userSyncState} from "@anki-cloud/db"; import {authWebMiddleware} from "@/middleware/auth"; import type {Env} from "@/types"; @@ -56,31 +56,48 @@ const resetSyncPasswordRoute = createRoute({ export const syncCredentialsRouter = new OpenAPIHono(); syncCredentialsRouter.openapi(getSyncPasswordRoute, async (c) => { - const {id} = c.get("user"); - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - if (!user) return c.json({error: "User not found", code: "USER_NOT_FOUND"}, 401); + const {id, email} = c.get("user"); - if (user.syncPasswordHash !== null) { - return c.json({username: user.email, password: null}, 200); + const [config] = await db + .select() + .from(userSyncConfig) + .where(eq(userSyncConfig.userId, id)) + .limit(1); + + if (config?.syncPasswordHash !== null && config?.syncPasswordHash !== undefined) { + return c.json({username: email, password: null}, 200); } const password = generatePassword(); const hash = await Bun.password.hash(password, {algorithm: "bcrypt", cost: 10}); - await db.update(users).set({syncPasswordHash: hash}).where(eq(users.id, id)); - return c.json({username: user.email, password}, 200); + await db + .insert(userSyncConfig) + .values({userId: id, syncPasswordHash: hash}) + .onConflictDoUpdate({ + target: userSyncConfig.userId, + set: {syncPasswordHash: hash}, + }); + + return c.json({username: email, password}, 200); }); syncCredentialsRouter.openapi(resetSyncPasswordRoute, async (c) => { - const {id} = c.get("user"); - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - if (!user) return c.json({error: "User not found", code: "USER_NOT_FOUND"}, 401); + const {id, email} = c.get("user"); const password = generatePassword(); const hash = await Bun.password.hash(password, {algorithm: "bcrypt", cost: 10}); - await db.update(users).set({syncPasswordHash: hash}).where(eq(users.id, id)); + + await db + .insert(userSyncConfig) + .values({userId: id, syncPasswordHash: hash}) + .onConflictDoUpdate({ + target: userSyncConfig.userId, + set: {syncPasswordHash: hash}, + }); + // Invalidate any existing hkey so the sync server rejects the old session. - await db.update(usersSyncState).set({syncKey: null}).where(eq(usersSyncState.userId, id)); + await db.update(userSyncState).set({syncKey: null}).where(eq(userSyncState.userId, id)); - return c.json({username: user.email, password}, 200); + return c.json({username: email, password}, 200); }); diff --git a/api/src/types.ts b/api/src/types.ts index 017b5ba..f9ef342 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -2,7 +2,6 @@ // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. export interface AuthUser { id: string - googleSub: string email: string | null name: string | null } diff --git a/bun.lock b/bun.lock index ae3e0d1..84a74df 100644 --- a/bun.lock +++ b/bun.lock @@ -16,9 +16,9 @@ "@anki-cloud/db": "workspace:*", "@hono/zod-openapi": "^0.19.6", "arctic": "^3.7.0", + "better-auth": "^1.0.0", "drizzle-orm": "^0.43.1", "hono": "^4.7.7", - "jose": "^6.2.2", "zod": "^3.24.2", }, "devDependencies": { @@ -50,6 +50,24 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@better-auth/core": ["@better-auth/core@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/core/-/core-1.6.5.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-T3u4rVsJcMWShG2qfQUlU1HdkQGLYX0+lcR48QV2Cp2kpBOLOTYdt+p6zZtGm2Omx/ReEouRQyKy7pYtahRQuA=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-9YjPW35+h66D+QA+YqEJ9pFP97ClLFR+QrTPZojkeP0PTYqpW0ErBK3p1pwRTJG88yK+o3Y4yOwoacMTBxz0jQ=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/kysely-adapter/-/kysely-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-kbevd70qzKNR3ZHF7q6/e0XXYRCXanLB2rvmTd3T8WbNEd9kYMqKjgTGNxL1ri5N+PEDUK6zfHx/HrvaEOfoHw=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/memory-adapter/-/memory-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0" } }, "sha512-5qFUpSdQi+RwHSmNyHMSsJIrFjed8d/ASS61L2xyW7sjBLTIuR7JcgS6hif5cQbtPeq+Qz+Wct5q8oKw33qyqQ=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/mongo-adapter/-/mongo-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-HvOUFTiSEFSGTzL/vE3FntTwQiZ79O/V+QcsCimR+65Bj3tOqdFaC1G2Yd1dQ9l2YHNXA9SNBrGekbk66RzJMw=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/prisma-adapter/-/prisma-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-d7PUO5XoimYYDEG/DoYVbOSbyVYJBDuZgvY9pjf8INccBTCD1BzcyEJ9NQil4huXWj4fcNaGOt2FG0OI8NtWOA=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/telemetry/-/telemetry-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-Ag3CjAP+tLretKPq+pYdU/gU4pFIcey/AoNQzw671wV5JQZXrMitS65INi8j8QuYfol2xgQrht5KVlcxGrkhHQ=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/utils/-/utils-0.4.0.tgz", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-fetch/fetch/-/fetch-1.1.21.tgz", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@commitlint/cli": ["@commitlint/cli@19.8.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@commitlint/cli/-/cli-19.8.1.tgz", { "dependencies": { "@commitlint/format": "^19.8.1", "@commitlint/lint": "^19.8.1", "@commitlint/load": "^19.8.1", "@commitlint/read": "^19.8.1", "@commitlint/types": "^19.8.1", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA=="], "@commitlint/config-conventional": ["@commitlint/config-conventional@19.8.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", { "dependencies": { "@commitlint/types": "^19.8.1", "conventional-changelog-conventionalcommits": "^7.0.2" } }, "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ=="], @@ -146,6 +164,14 @@ "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@hono/zod-validator/-/zod-validator-0.7.6.tgz", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="], + "@noble/ciphers": ["@noble/ciphers@2.2.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@noble/ciphers/-/ciphers-2.2.0.tgz", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], + + "@noble/hashes": ["@noble/hashes@2.2.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@noble/hashes/-/hashes-2.2.0.tgz", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@oslojs/asn1/-/asn1-1.0.0.tgz", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/binary": ["@oslojs/binary@1.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@oslojs/binary/-/binary-1.0.0.tgz", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], @@ -156,6 +182,8 @@ "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@oslojs/jwt/-/jwt-0.2.0.tgz", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bun": ["@types/bun@1.3.12", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/bun/-/bun-1.3.12.tgz", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="], @@ -176,6 +204,10 @@ "array-ify": ["array-ify@1.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/array-ify/-/array-ify-1.0.0.tgz", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], + "better-auth": ["better-auth@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/better-auth/-/better-auth-1.6.5.tgz", { "dependencies": { "@better-auth/core": "1.6.5", "@better-auth/drizzle-adapter": "1.6.5", "@better-auth/kysely-adapter": "1.6.5", "@better-auth/memory-adapter": "1.6.5", "@better-auth/mongo-adapter": "1.6.5", "@better-auth/prisma-adapter": "1.6.5", "@better-auth/telemetry": "1.6.5", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-rSt8JtJOJK0MqPShXINCmM6DV30GsDvnCTlIxQIzP9OpUx/umA40nUc4ALZHQyqAPbw1ib/a549kIWw/WyxxKA=="], + + "better-call": ["better-call@1.3.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/better-call/-/better-call-1.3.5.tgz", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + "buffer-from": ["buffer-from@1.1.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.12", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/bun-types/-/bun-types-1.3.12.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], @@ -204,6 +236,8 @@ "dargs": ["dargs@8.1.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/dargs/-/dargs-8.1.0.tgz", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], + "defu": ["defu@6.1.7", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/defu/-/defu-6.1.7.tgz", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "dot-prop": ["dot-prop@5.3.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/dot-prop/-/dot-prop-5.3.0.tgz", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "drizzle-kit": ["drizzle-kit@0.31.10", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/drizzle-kit/-/drizzle-kit-0.31.10.tgz", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], @@ -266,6 +300,8 @@ "jsonparse": ["jsonparse@1.3.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/jsonparse/-/jsonparse-1.3.1.tgz", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + "kysely": ["kysely@0.28.16", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/kysely/-/kysely-0.28.16.tgz", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "locate-path": ["locate-path@7.2.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/locate-path/-/locate-path-7.2.0.tgz", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], @@ -292,6 +328,8 @@ "minimist": ["minimist@1.2.8", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "nanostores": ["nanostores@1.3.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/nanostores/-/nanostores-1.3.0.tgz", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], + "openapi3-ts": ["openapi3-ts@4.5.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/openapi3-ts/-/openapi3-ts-4.5.0.tgz", { "dependencies": { "yaml": "^2.8.0" } }, "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ=="], "p-limit": ["p-limit@4.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/p-limit/-/p-limit-4.0.0.tgz", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], @@ -314,8 +352,12 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "rou3": ["rou3@0.7.12", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/rou3/-/rou3-0.7.12.tgz", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "semver": ["semver@7.7.4", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "set-cookie-parser": ["set-cookie-parser@3.1.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "source-map": ["source-map@0.6.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-support": ["source-map-support@0.5.21", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/source-map-support/-/source-map-support-0.5.21.tgz", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], @@ -354,10 +396,14 @@ "zod": ["zod@3.25.76", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@better-auth/core/zod": ["zod@4.3.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/esbuild/-/esbuild-0.18.20.tgz", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@oslojs/encoding/-/encoding-0.4.1.tgz", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "better-auth/zod": ["zod@4.3.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "tsx/esbuild": ["esbuild@0.27.7", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/esbuild/-/esbuild-0.27.7.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], diff --git a/db/src/migrations/0003_better_auth_migration.sql b/db/src/migrations/0003_better_auth_migration.sql new file mode 100644 index 0000000..afee527 --- /dev/null +++ b/db/src/migrations/0003_better_auth_migration.sql @@ -0,0 +1,195 @@ +-- Migration: Better Auth + table rename to singular convention +-- Better Auth tables (user, session, account, verification) replace hand-rolled auth. +-- App tables renamed to user_* prefix convention. +-- sync_password_hash extracted from users → user_sync_config. +-- storage_connections → user_storage_connection +-- users_api_keys → user_api_key +-- users_sync_state → user_sync_state + +PRAGMA foreign_keys = OFF; +--> statement-breakpoint + +-- ── Better Auth tables ──────────────────────────────────────────────────────── + +CREATE TABLE `user` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer NOT NULL, + `image` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); +--> statement-breakpoint + +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `token` text NOT NULL, + `expires_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`); +--> statement-breakpoint + +CREATE TABLE `account` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `id_token` text, + `password` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); +--> statement-breakpoint + +CREATE TABLE `verification` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint + +-- ── Migrate users → user + account ─────────────────────────────────────────── + +INSERT INTO `user` (`id`, `name`, `email`, `email_verified`, `image`, `created_at`, `updated_at`) +SELECT + `id`, + COALESCE(`name`, ''), + COALESCE(`email`, ''), + 1, + NULL, + `created_at`, + `created_at` +FROM `users`; +--> statement-breakpoint + +-- googleSub migrates to the account table as accountId with providerId='google' +INSERT INTO `account` (`id`, `user_id`, `account_id`, `provider_id`, `created_at`, `updated_at`) +SELECT + lower(hex(randomblob(16))), + `id`, + `google_sub`, + 'google', + `created_at`, + `created_at` +FROM `users`; +--> statement-breakpoint + +-- ── user_sync_config (sync_password_hash extracted from users) ──────────────── + +CREATE TABLE `user_sync_config` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `sync_password_hash` text, + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_sync_config_user_id_unique` ON `user_sync_config` (`user_id`); +--> statement-breakpoint + +INSERT INTO `user_sync_config` (`id`, `user_id`, `sync_password_hash`) +SELECT lower(hex(randomblob(16))), `id`, `sync_password_hash` +FROM `users` +WHERE `sync_password_hash` IS NOT NULL; +--> statement-breakpoint + +-- ── user_storage_connection (renamed from storage_connections) ──────────────── + +CREATE TABLE `user_storage_connection` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `provider` text NOT NULL, + `oauth_token` text NOT NULL, + `oauth_refresh_token` text NOT NULL, + `folder_path` text DEFAULT '/AnkiCloudSync' NOT NULL, + `connected_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_storage_user_provider` ON `user_storage_connection` (`user_id`, `provider`); +--> statement-breakpoint +CREATE INDEX `idx_storage_user_id` ON `user_storage_connection` (`user_id`); +--> statement-breakpoint + +INSERT INTO `user_storage_connection` + (`id`, `user_id`, `provider`, `oauth_token`, `oauth_refresh_token`, `folder_path`, `connected_at`) +SELECT `id`, `user_id`, `provider`, `oauth_token`, `oauth_refresh_token`, `folder_path`, `connected_at` +FROM `storage_connections`; +--> statement-breakpoint + +-- ── user_api_key (renamed from users_api_keys) ─────────────────────────────── + +CREATE TABLE `user_api_key` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `key_hash` text NOT NULL, + `label` text NOT NULL, + `last_used_at` integer, + `created_at` integer NOT NULL, + `revoked_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX `idx_api_key_user_id` ON `user_api_key` (`user_id`); +--> statement-breakpoint +CREATE INDEX `idx_api_key_hash` ON `user_api_key` (`key_hash`); +--> statement-breakpoint + +INSERT INTO `user_api_key` + (`id`, `user_id`, `key_hash`, `label`, `last_used_at`, `created_at`, `revoked_at`) +SELECT `id`, `user_id`, `key_hash`, `label`, `last_used_at`, `created_at`, `revoked_at` +FROM `users_api_keys`; +--> statement-breakpoint + +-- ── user_sync_state (renamed from users_sync_state) ────────────────────────── + +CREATE TABLE `user_sync_state` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `last_sync_at` integer, + `client_version` text, + `sync_key` text, + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_sync_state_user_id_unique` ON `user_sync_state` (`user_id`); +--> statement-breakpoint +CREATE INDEX `idx_sync_state_user_id` ON `user_sync_state` (`user_id`); +--> statement-breakpoint + +INSERT INTO `user_sync_state` + (`id`, `user_id`, `last_sync_at`, `client_version`, `sync_key`) +SELECT `id`, `user_id`, `last_sync_at`, `client_version`, `sync_key` +FROM `users_sync_state`; +--> statement-breakpoint + +-- ── Drop old tables ─────────────────────────────────────────────────────────── + +DROP TABLE `users_sync_state`; +--> statement-breakpoint +DROP TABLE `users_api_keys`; +--> statement-breakpoint +DROP TABLE `storage_connections`; +--> statement-breakpoint +DROP TABLE `users`; +--> statement-breakpoint + +PRAGMA foreign_keys = ON; diff --git a/db/src/migrations/meta/_journal.json b/db/src/migrations/meta/_journal.json index 9e32f40..8b40400 100644 --- a/db/src/migrations/meta/_journal.json +++ b/db/src/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776627594043, "tag": "0002_eager_charles_xavier", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1745107200000, + "tag": "0003_better_auth_migration", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/src/schema.ts b/db/src/schema.ts index a25eeb4..df805ea 100644 --- a/db/src/schema.ts +++ b/db/src/schema.ts @@ -3,24 +3,76 @@ import {relations} from "drizzle-orm"; import {index, integer, sqliteTable, text, unique} from "drizzle-orm/sqlite-core"; -export const users = sqliteTable("users", { +// ── Better Auth tables (owned by Better Auth, do not add app columns here) ──── + +export const user = sqliteTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: integer("email_verified", {mode: "boolean"}).notNull(), + image: text("image"), + createdAt: integer("created_at", {mode: "timestamp"}).notNull(), + updatedAt: integer("updated_at", {mode: "timestamp"}).notNull(), +}); + +export const session = sqliteTable("session", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, {onDelete: "cascade"}), + token: text("token").notNull().unique(), + expiresAt: integer("expires_at", {mode: "timestamp"}).notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + createdAt: integer("created_at", {mode: "timestamp"}).notNull(), + updatedAt: integer("updated_at", {mode: "timestamp"}).notNull(), +}); + +export const account = sqliteTable("account", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, {onDelete: "cascade"}), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + accessTokenExpiresAt: integer("access_token_expires_at", {mode: "timestamp"}), + refreshTokenExpiresAt: integer("refresh_token_expires_at", {mode: "timestamp"}), + scope: text("scope"), + idToken: text("id_token"), + password: text("password"), + createdAt: integer("created_at", {mode: "timestamp"}).notNull(), + updatedAt: integer("updated_at", {mode: "timestamp"}).notNull(), +}); + +export const verification = sqliteTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: integer("expires_at", {mode: "timestamp"}).notNull(), + createdAt: integer("created_at", {mode: "timestamp"}), + updatedAt: integer("updated_at", {mode: "timestamp"}), +}); + +// ── App tables ──────────────────────────────────────────────────────────────── + +export const userSyncConfig = sqliteTable("user_sync_config", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), - googleSub: text("google_sub").notNull().unique(), - email: text("email"), - name: text("name"), - createdAt: integer("created_at", {mode: "timestamp"}) + userId: text("user_id") .notNull() - .$defaultFn(() => new Date()), + .unique() + .references(() => user.id, {onDelete: "cascade"}), syncPasswordHash: text("sync_password_hash"), }); -export const storageConnections = sqliteTable( - "storage_connections", +export const userStorageConnection = sqliteTable( + "user_storage_connection", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), userId: text("user_id") .notNull() - .references(() => users.id, {onDelete: "cascade"}), + .references(() => user.id, {onDelete: "cascade"}), provider: text("provider", {enum: ["gdrive", "dropbox", "s3"]}).notNull(), oauthToken: text("oauth_token").notNull(), oauthRefreshToken: text("oauth_refresh_token").notNull(), @@ -35,13 +87,13 @@ export const storageConnections = sqliteTable( ] ); -export const usersApiKeys = sqliteTable( - "users_api_keys", +export const userApiKey = sqliteTable( + "user_api_key", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), userId: text("user_id") .notNull() - .references(() => users.id, {onDelete: "cascade"}), + .references(() => user.id, {onDelete: "cascade"}), keyHash: text("key_hash").notNull(), label: text("label").notNull(), lastUsedAt: integer("last_used_at", {mode: "timestamp"}), @@ -51,19 +103,19 @@ export const usersApiKeys = sqliteTable( revokedAt: integer("revoked_at", {mode: "timestamp"}), }, (t) => [ - index("idx_api_keys_user_id").on(t.userId), - index("idx_api_keys_hash").on(t.keyHash), + index("idx_api_key_user_id").on(t.userId), + index("idx_api_key_hash").on(t.keyHash), ] ); -export const usersSyncState = sqliteTable( - "users_sync_state", +export const userSyncState = sqliteTable( + "user_sync_state", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), userId: text("user_id") .notNull() .unique() - .references(() => users.id, {onDelete: "cascade"}), + .references(() => user.id, {onDelete: "cascade"}), lastSyncAt: integer("last_sync_at", {mode: "timestamp"}), clientVersion: text("client_version"), syncKey: text("sync_key"), @@ -71,32 +123,55 @@ export const usersSyncState = sqliteTable( (t) => [index("idx_sync_state_user_id").on(t.userId)] ); -export const usersRelations = relations(users, ({many, one}) => ({ - storageConnections: many(storageConnections), - apiKeys: many(usersApiKeys), - syncState: one(usersSyncState, { - fields: [users.id], - references: [usersSyncState.userId], +// ── Relations ───────────────────────────────────────────────────────────────── + +export const userRelations = relations(user, ({many, one}) => ({ + sessions: many(session), + accounts: many(account), + syncConfig: one(userSyncConfig, { + fields: [user.id], + references: [userSyncConfig.userId], + }), + storageConnections: many(userStorageConnection), + apiKeys: many(userApiKey), + syncState: one(userSyncState, { + fields: [user.id], + references: [userSyncState.userId], }), })); -export const storageConnectionsRelations = relations(storageConnections, ({one}) => ({ - user: one(users, {fields: [storageConnections.userId], references: [users.id]}), +export const sessionRelations = relations(session, ({one}) => ({ + user: one(user, {fields: [session.userId], references: [user.id]}), +})); + +export const accountRelations = relations(account, ({one}) => ({ + user: one(user, {fields: [account.userId], references: [user.id]}), })); -export const usersApiKeysRelations = relations(usersApiKeys, ({one}) => ({ - user: one(users, {fields: [usersApiKeys.userId], references: [users.id]}), +export const userSyncConfigRelations = relations(userSyncConfig, ({one}) => ({ + user: one(user, {fields: [userSyncConfig.userId], references: [user.id]}), })); -export const usersSyncStateRelations = relations(usersSyncState, ({one}) => ({ - user: one(users, {fields: [usersSyncState.userId], references: [users.id]}), +export const userStorageConnectionRelations = relations(userStorageConnection, ({one}) => ({ + user: one(user, {fields: [userStorageConnection.userId], references: [user.id]}), })); -export type User = typeof users.$inferSelect; -export type NewUser = typeof users.$inferInsert; -export type StorageConnection = typeof storageConnections.$inferSelect; -export type NewStorageConnection = typeof storageConnections.$inferInsert; -export type UsersApiKey = typeof usersApiKeys.$inferSelect; -export type NewUsersApiKey = typeof usersApiKeys.$inferInsert; -export type UsersSyncState = typeof usersSyncState.$inferSelect; -export type NewUsersSyncState = typeof usersSyncState.$inferInsert; +export const userApiKeyRelations = relations(userApiKey, ({one}) => ({ + user: one(user, {fields: [userApiKey.userId], references: [user.id]}), +})); + +export const userSyncStateRelations = relations(userSyncState, ({one}) => ({ + user: one(user, {fields: [userSyncState.userId], references: [user.id]}), +})); + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type User = typeof user.$inferSelect; +export type UserSyncConfig = typeof userSyncConfig.$inferSelect; +export type NewUserSyncConfig = typeof userSyncConfig.$inferInsert; +export type UserStorageConnection = typeof userStorageConnection.$inferSelect; +export type NewUserStorageConnection = typeof userStorageConnection.$inferInsert; +export type UserApiKey = typeof userApiKey.$inferSelect; +export type NewUserApiKey = typeof userApiKey.$inferInsert; +export type UserSyncState = typeof userSyncState.$inferSelect; +export type NewUserSyncState = typeof userSyncState.$inferInsert; diff --git a/docker-compose.yml b/docker-compose.yml index faf9329..78ac936 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,10 +24,10 @@ services: DATABASE_URL: file:/data/anki-cloud.db SIDECAR_URL: http://anki-sync-server:8081 SIDECAR_TOKEN: ${SIDECAR_TOKEN} - JWT_SECRET: ${JWT_SECRET} + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + BETTER_AUTH_URL: ${BETTER_AUTH_URL} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} - GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI} GOOGLE_DRIVE_REDIRECT_URI: ${GOOGLE_DRIVE_REDIRECT_URI} FRONTEND_URL: ${FRONTEND_URL} TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY} diff --git a/web/package.json b/web/package.json index 201e37b..728f7ed 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "better-auth": "^1.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index a076650..4cac8cf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,9 @@ // Copyright 2026 Archont Soft Daniel Klimuntowski // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. import {useEffect, useState} from "react"; +import {createAuthClient} from "better-auth/client"; + +const authClient = createAuthClient({baseURL: window.location.origin}); import type {ApiKey, NewApiKey, StorageConnection, SyncCredentials, User} from "./api"; import * as api from "./api"; @@ -108,14 +111,19 @@ export default function App() { // ── Header ─────────────────────────────────────────────────────────────────── function Header({user}: { user: User }) { + const handleSignOut = async () => { + await authClient.signOut(); + window.location.href = "/"; + }; + return (
Account Settings
{user.email ?? user.name ?? "Account"} - +
); @@ -124,15 +132,19 @@ function Header({user}: { user: User }) { // ── Login Page ─────────────────────────────────────────────────────────────── function LoginPage() { + const handleGoogleLogin = async () => { + await authClient.signIn.social({provider: "google", callbackURL: "/"}); + }; + return (

Account Settings

Sign in to manage your account

- +
); From 7c9f05cea1b7dc74a380ed1b690e6c49d0459a61 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 07:25:38 +0200 Subject: [PATCH 02/23] docs: clean up TODO.md, remove outdated items on GDrive configuration and REST API extensions --- TODO.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 2c7b110..b69bc22 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,3 @@ -- default GDrive directory should be /AnkiCloudSync -- extend REST API with note types fetching; possibly note type creation? maybe in future -- extend REST API with note tags fetching; - we have e2e module and ./scripts/smoke-test.sh - not sure if we should keep both or combine them somehow -- allow users to configure GDrive directory, so it's not hardcoded to /AnkiSync +- extend REST API with note tags fetching; - \ No newline at end of file From c8f89694a7f350f95417850802cf76c5bcf9b43d Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 07:32:42 +0200 Subject: [PATCH 03/23] refactor: update routing to use `/v1/auth` path, adjust related configurations and documentation --- .env.example | 6 +++--- README.md | 2 +- api/src/auth.ts | 1 + api/src/index.ts | 2 +- web/src/App.tsx | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 1c11b0c..531e55b 100644 --- a/.env.example +++ b/.env.example @@ -35,10 +35,10 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= # Must exactly match an authorized redirect URI in your Google OAuth app -# Better Auth callback (for user login): {BETTER_AUTH_URL}/api/auth/callback/google +# Better Auth callback (for user login): {BETTER_AUTH_URL}/v1/auth/callback/google # Add this to Google Cloud Console authorized redirect URIs: -# http://localhost:3000/api/auth/callback/google (local dev) -# https://api.your-domain.com/api/auth/callback/google (prod) +# http://localhost:3000/v1/auth/callback/google (local dev) +# https://api.your-domain.com/v1/auth/callback/google (prod) # Must exactly match an authorized redirect URI in your Google OAuth app # For Google Drive storage connection (separate OAuth flow): diff --git a/README.md b/README.md index 81639df..ad8e06d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Before running, add the Better Auth callback URI to your Google OAuth app in [Google Cloud Console](https://console.cloud.google.com) → APIs & Services → Credentials: ``` -{BETTER_AUTH_URL}/api/auth/callback/google +{BETTER_AUTH_URL}/v1/auth/callback/google ``` ```bash diff --git a/api/src/auth.ts b/api/src/auth.ts index 32403bb..e6d71e7 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -6,6 +6,7 @@ import {db} from "@anki-cloud/db"; export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", + basePath: "/v1/auth", secret: process.env.BETTER_AUTH_SECRET!, database: drizzleAdapter(db, { provider: "sqlite", diff --git a/api/src/index.ts b/api/src/index.ts index d4596ee..511a791 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -46,7 +46,7 @@ publicApi.get("/docs", (c) => const app = new OpenAPIHono(); app.get("/health", (c) => c.json({ status: "ok" })); -app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw)); +app.on(["POST", "GET"], "/v1/auth/**", (c) => auth.handler(c.req.raw)); app.route("/", publicApi); app.route("/v1", authRouter); app.route("/v1", storageRouter); diff --git a/web/src/App.tsx b/web/src/App.tsx index 4cac8cf..23b7add 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,7 +3,7 @@ import {useEffect, useState} from "react"; import {createAuthClient} from "better-auth/client"; -const authClient = createAuthClient({baseURL: window.location.origin}); +const authClient = createAuthClient({baseURL: window.location.origin, basePath: "/v1/auth"}); import type {ApiKey, NewApiKey, StorageConnection, SyncCredentials, User} from "./api"; import * as api from "./api"; From 02a0913d5785988cca67712c25fa66e918f8c621 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 07:38:56 +0200 Subject: [PATCH 04/23] refactor(storage): rename GDrive references to Google, update schemas, routes, and migrations --- CLAUDE.md | 2 +- TODO.md | 5 +++- api/src/routes/storage.ts | 26 +++++++++---------- .../migrations/0003_better_auth_migration.sql | 5 +++- db/src/schema.ts | 2 +- e2e/src/helpers/auth.ts | 2 +- web/src/App.tsx | 5 ++-- web/src/api.ts | 2 +- 8 files changed, 28 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be5d582..2c22492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ users storage_connections ( id, user_id, - provider, -- 'gdrive' | 'dropbox' | 's3' | 'local' + provider, -- 'google' | 'dropbox' | 's3' | 'local' oauth_token, -- encrypted at rest (AES-256-GCM) oauth_refresh_token, -- encrypted at rest (AES-256-GCM); null for 'local' provider folder_path, diff --git a/TODO.md b/TODO.md index b69bc22..b763e73 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,6 @@ - we have e2e module and ./scripts/smoke-test.sh - not sure if we should keep both or combine them somehow +- make code more extensible following the Open-Closed principle; the below isn't extensible: + - if (provider !== "google") { + return c.json({error: "Provider not yet supported", code: "UNSUPPORTED_PROVIDER"}, 400); + } - extend REST API with note tags fetching; -- \ No newline at end of file diff --git a/api/src/routes/storage.ts b/api/src/routes/storage.ts index 9350a5b..4d581fd 100644 --- a/api/src/routes/storage.ts +++ b/api/src/routes/storage.ts @@ -26,7 +26,7 @@ const FRONTEND_URL = process.env.FRONTEND_URL ?? "/"; const ErrorSchema = z.object({error: z.string(), code: z.string()}); -const ProviderSchema = z.enum(["gdrive", "dropbox", "s3"]); +const ProviderSchema = z.enum(["google", "dropbox", "s3"]); const StorageConnectionSchema = z.object({ id: z.string().uuid(), @@ -78,7 +78,7 @@ const storageConnectRoute = createRoute({ storageRouter.openapi(storageConnectRoute, async (c) => { const {provider} = c.req.valid("json"); - if (provider !== "gdrive") { + if (provider !== "google") { return c.json({error: "Provider not yet supported", code: "UNSUPPORTED_PROVIDER"}, 400); } @@ -90,13 +90,13 @@ storageRouter.openapi(storageConnectRoute, async (c) => { url.searchParams.set("access_type", "offline"); url.searchParams.set("prompt", "consent"); - setCookie(c, "gdrive_oauth_state", state, { + setCookie(c, "google_storage_state", state, { httpOnly: true, sameSite: "Lax", path: "/", maxAge: OAUTH_STATE_MAX_AGE, }); - setCookie(c, "gdrive_code_verifier", codeVerifier, { + setCookie(c, "google_storage_verifier", codeVerifier, { httpOnly: true, sameSite: "Lax", path: "/", @@ -150,7 +150,7 @@ storageRouter.openapi(storageDisconnectRoute, async (c) => { return c.json({ok: true}, 200); }); -storageRouter.get("/me/storage/connect/gdrive", authWebMiddleware, async (c) => { +storageRouter.get("/me/storage/connect/google", authWebMiddleware, async (c) => { const state = generateState(); const codeVerifier = generateCodeVerifier(); const url = googleDrive.createAuthorizationURL(state, codeVerifier, [ @@ -159,13 +159,13 @@ storageRouter.get("/me/storage/connect/gdrive", authWebMiddleware, async (c) => url.searchParams.set("access_type", "offline"); url.searchParams.set("prompt", "consent"); - setCookie(c, "gdrive_oauth_state", state, { + setCookie(c, "google_storage_state", state, { httpOnly: true, sameSite: "Lax", path: "/", maxAge: OAUTH_STATE_MAX_AGE, }); - setCookie(c, "gdrive_code_verifier", codeVerifier, { + setCookie(c, "google_storage_verifier", codeVerifier, { httpOnly: true, sameSite: "Lax", path: "/", @@ -175,13 +175,13 @@ storageRouter.get("/me/storage/connect/gdrive", authWebMiddleware, async (c) => return c.redirect(url.toString(), 302); }); -storageRouter.get("/me/storage/connect/gdrive/callback", authWebMiddleware, async (c) => { +storageRouter.get("/me/storage/connect/google/callback", authWebMiddleware, async (c) => { const {code, state} = c.req.query(); - const storedState = getCookie(c, "gdrive_oauth_state"); - const codeVerifier = getCookie(c, "gdrive_code_verifier"); + const storedState = getCookie(c, "google_storage_state"); + const codeVerifier = getCookie(c, "google_storage_verifier"); - deleteCookie(c, "gdrive_oauth_state"); - deleteCookie(c, "gdrive_code_verifier"); + deleteCookie(c, "google_storage_state"); + deleteCookie(c, "google_storage_verifier"); if (!code || !state || !storedState || !codeVerifier || state !== storedState) { return c.redirect(`${FRONTEND_URL}?storage=error`, 302); @@ -211,7 +211,7 @@ storageRouter.get("/me/storage/connect/gdrive/callback", authWebMiddleware, asyn .insert(userStorageConnection) .values({ userId, - provider: "gdrive", + provider: "google", oauthToken: encryptedAccess, oauthRefreshToken: encryptedRefresh, }) diff --git a/db/src/migrations/0003_better_auth_migration.sql b/db/src/migrations/0003_better_auth_migration.sql index afee527..f2e7939 100644 --- a/db/src/migrations/0003_better_auth_migration.sql +++ b/db/src/migrations/0003_better_auth_migration.sql @@ -131,7 +131,10 @@ CREATE INDEX `idx_storage_user_id` ON `user_storage_connection` (`user_id`); INSERT INTO `user_storage_connection` (`id`, `user_id`, `provider`, `oauth_token`, `oauth_refresh_token`, `folder_path`, `connected_at`) -SELECT `id`, `user_id`, `provider`, `oauth_token`, `oauth_refresh_token`, `folder_path`, `connected_at` +SELECT + `id`, `user_id`, + CASE `provider` WHEN 'gdrive' THEN 'google' ELSE `provider` END, + `oauth_token`, `oauth_refresh_token`, `folder_path`, `connected_at` FROM `storage_connections`; --> statement-breakpoint diff --git a/db/src/schema.ts b/db/src/schema.ts index df805ea..d306430 100644 --- a/db/src/schema.ts +++ b/db/src/schema.ts @@ -73,7 +73,7 @@ export const userStorageConnection = sqliteTable( userId: text("user_id") .notNull() .references(() => user.id, {onDelete: "cascade"}), - provider: text("provider", {enum: ["gdrive", "dropbox", "s3"]}).notNull(), + provider: text("provider", {enum: ["google", "dropbox", "s3"]}).notNull(), oauthToken: text("oauth_token").notNull(), oauthRefreshToken: text("oauth_refresh_token").notNull(), folderPath: text("folder_path").notNull().default("/AnkiCloudSync"), diff --git a/e2e/src/helpers/auth.ts b/e2e/src/helpers/auth.ts index 5e2e39f..63ff298 100644 --- a/e2e/src/helpers/auth.ts +++ b/e2e/src/helpers/auth.ts @@ -49,7 +49,7 @@ export async function seedLocalStorage(dbPath: string, userId: string): Promise< await db.insert(storageConnections).values({ id: crypto.randomUUID(), userId, - provider: "local" as "gdrive", // "local" accepted by Rust but not in TS schema enum + provider: "local" as "google", // "local" accepted by Rust but not in TS schema enum oauthToken: "", oauthRefreshToken: "", folderPath: "/AnkiSync", diff --git a/web/src/App.tsx b/web/src/App.tsx index 23b7add..62ca421 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -220,7 +220,8 @@ function StorageSection({ const [folderPathInput, setFolderPathInput] = useState(""); const [folderPathError, setFolderPathError] = useState(null); const [saving, setSaving] = useState(false); - const gdrive = connections.find((c) => c.provider === "gdrive"); + + const gdrive = connections.find((c) => c.provider === "google"); const handleDisconnect = async () => { if (!confirm("Disconnect Google Drive? Your data in Drive will not be deleted.")) return; @@ -274,7 +275,7 @@ function StorageSection({ {busy ? "Disconnecting…" : "Disconnect"} ) : ( - + Connect )} diff --git a/web/src/api.ts b/web/src/api.ts index f477d5f..8e03058 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -24,7 +24,7 @@ export type User = { export type StorageConnection = { id: string; - provider: "gdrive" | "dropbox" | "s3"; + provider: "google" | "dropbox" | "s3"; folderPath: string; connectedAt: string; }; From 900f760bd1ce326895562da6e6230725cbecea89 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 08:05:48 +0200 Subject: [PATCH 05/23] docs(todo): add note to implement unit tests for api module --- TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TODO.md b/TODO.md index b763e73..3171d09 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,4 @@ +- implement comprehensive unit tests for api module - we have e2e module and ./scripts/smoke-test.sh - not sure if we should keep both or combine them somehow - make code more extensible following the Open-Closed principle; the below isn't extensible: - if (provider !== "google") { From 7ca8d3681826b78674c68170aa46e03c11725631 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 08:07:00 +0200 Subject: [PATCH 06/23] refactor(auth, e2e, test): replace JWT-based session management with Better Auth sessions --- e2e/README.md | 2 +- e2e/package.json | 3 +- e2e/src/helpers/api.ts | 20 +++---- e2e/src/helpers/auth.ts | 51 +++++++++++------ e2e/src/setup.ts | 5 +- e2e/src/tests/02-sync-credentials.test.ts | 16 +++--- e2e/src/tests/03-sync-server.test.ts | 10 ++-- .../tests/04-sync-password-rotation.test.ts | 12 ++-- scripts/smoke-test.sh | 57 ++++++++++--------- 9 files changed, 98 insertions(+), 78 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 23ac491..b868e87 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -37,6 +37,6 @@ so they can run without any external services. ## Architecture notes - Each test suite calls `startStack()` which spawns fresh API + sync server processes with a temp DB. -- JWTs are minted directly (skipping Google OAuth) via `mintSessionJwt()`. +- Sessions are seeded directly into the DB (skipping Google OAuth) via `createTestSession()`. - Storage connections use `provider = "local"` so no cloud credentials are needed. - Sync requests use Anki sync protocol v11: `anki-sync` header + zstd-compressed JSON body. diff --git a/e2e/package.json b/e2e/package.json index 0e3e139..0f0a001 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,8 +9,7 @@ "dependencies": { "@anki-cloud/db": "workspace:*", "@mongodb-js/zstd": "^1.2.2", - "drizzle-orm": "^0.43.1", - "jose": "^6.2.2" + "drizzle-orm": "^0.43.1" }, "devDependencies": { "@types/bun": "^1.2.10", diff --git a/e2e/src/helpers/api.ts b/e2e/src/helpers/api.ts index 34be6a6..48922ce 100644 --- a/e2e/src/helpers/api.ts +++ b/e2e/src/helpers/api.ts @@ -3,9 +3,9 @@ /** Typed REST API client for e2e tests. */ export interface ApiClient { - getSyncPassword(sessionCookie: string): Promise; - resetSyncPassword(sessionCookie: string): Promise; - getMe(sessionCookie: string): Promise; + getSyncPassword(sessionToken: string): Promise; + resetSyncPassword(sessionToken: string): Promise; + getMe(sessionToken: string): Promise; health(): Promise; } @@ -21,19 +21,19 @@ export interface MeResponse { } export function makeApiClient(baseUrl: string): ApiClient { - async function get(path: string, cookie: string): Promise { + async function get(path: string, sessionToken: string): Promise { const res = await fetch(`${baseUrl}${path}`, { - headers: { cookie: `session=${cookie}` }, + headers: { cookie: `better-auth.session_token=${sessionToken}` }, }); if (!res.ok) throw new Error(`GET ${path} failed ${res.status}`); return res.json() as Promise; } - async function post(path: string, cookie: string, body?: unknown): Promise { + async function post(path: string, sessionToken: string, body?: unknown): Promise { const res = await fetch(`${baseUrl}${path}`, { method: "POST", headers: { - cookie: `session=${cookie}`, + cookie: `better-auth.session_token=${sessionToken}`, "content-type": "application/json", }, body: body ? JSON.stringify(body) : undefined, @@ -43,9 +43,9 @@ export function makeApiClient(baseUrl: string): ApiClient { } return { - getSyncPassword: (cookie) => get("/v1/me/sync-password", cookie), - resetSyncPassword: (cookie) => post("/v1/me/sync-password/reset", cookie), - getMe: (cookie) => get("/v1/me", cookie), + getSyncPassword: (token) => get("/v1/me/sync-password", token), + resetSyncPassword: (token) => post("/v1/me/sync-password/reset", token), + getMe: (token) => get("/v1/me", token), async health() { try { const res = await fetch(`${baseUrl}/health`); diff --git a/e2e/src/helpers/auth.ts b/e2e/src/helpers/auth.ts index 63ff298..2e5ceb4 100644 --- a/e2e/src/helpers/auth.ts +++ b/e2e/src/helpers/auth.ts @@ -1,10 +1,8 @@ // Copyright 2026 Archont Soft Daniel Klimuntowski // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. -import { SignJWT } from "jose"; import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; -import { users, storageConnections } from "@anki-cloud/db/schema"; -import { TEST_JWT_SECRET } from "@/setup"; +import { user, session, userSyncConfig, userStorageConnection } from "@anki-cloud/db/schema"; export interface SeedUser { id: string; @@ -19,7 +17,7 @@ export async function seedUser( ): Promise { const sqlite = new Database(dbPath, { readwrite: true }); sqlite.run("PRAGMA foreign_keys = ON"); - const db = drizzle(sqlite, { schema: { users, storageConnections } }); + const db = drizzle(sqlite, { schema: { user, userSyncConfig } }); const u: SeedUser = { id: crypto.randomUUID(), @@ -29,14 +27,23 @@ export async function seedUser( ...overrides, }; - await db.insert(users).values({ + const now = new Date(); + await db.insert(user).values({ id: u.id, - googleSub: `google-sub-${u.id}`, email: u.email, name: u.name, - syncPasswordHash: u.syncPasswordHash ?? null, + emailVerified: true, + createdAt: now, + updatedAt: now, }); + if (u.syncPasswordHash) { + await db.insert(userSyncConfig).values({ + userId: u.id, + syncPasswordHash: u.syncPasswordHash, + }); + } + sqlite.close(); return u; } @@ -44,9 +51,9 @@ export async function seedUser( export async function seedLocalStorage(dbPath: string, userId: string): Promise { const sqlite = new Database(dbPath, { readwrite: true }); sqlite.run("PRAGMA foreign_keys = ON"); - const db = drizzle(sqlite, { schema: { storageConnections } }); + const db = drizzle(sqlite, { schema: { userStorageConnection } }); - await db.insert(storageConnections).values({ + await db.insert(userStorageConnection).values({ id: crypto.randomUUID(), userId, provider: "local" as "google", // "local" accepted by Rust but not in TS schema enum @@ -58,11 +65,23 @@ export async function seedLocalStorage(dbPath: string, userId: string): Promise< sqlite.close(); } -export async function mintSessionJwt(userId: string): Promise { - const secret = new Uint8Array(Buffer.from(TEST_JWT_SECRET, "hex")); - return new SignJWT({ sub: userId }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime("1h") - .sign(secret); +/** Insert a Better Auth session row and return the token (used as the session cookie value). */ +export async function createTestSession(dbPath: string, userId: string): Promise { + const sqlite = new Database(dbPath, { readwrite: true }); + sqlite.run("PRAGMA foreign_keys = ON"); + const db = drizzle(sqlite, { schema: { session } }); + + const token = crypto.randomUUID(); + const now = new Date(); + await db.insert(session).values({ + id: crypto.randomUUID(), + userId, + token, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour + createdAt: now, + updatedAt: now, + }); + + sqlite.close(); + return token; } diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts index ec9e5e0..6aabac9 100644 --- a/e2e/src/setup.ts +++ b/e2e/src/setup.ts @@ -13,7 +13,7 @@ const SYNC_BIN = join(import.meta.dir, "../../anki-sync-server/target/debug/anki // 32-byte all-zeros key (hex) — test only export const TEST_ENCRYPTION_KEY = "0".repeat(64); -export const TEST_JWT_SECRET = "0".repeat(64); +export const TEST_BETTER_AUTH_SECRET = "0".repeat(64); export const TEST_GOOGLE_CLIENT_ID = "test-client-id"; export const TEST_GOOGLE_CLIENT_SECRET = "test-client-secret"; @@ -44,7 +44,8 @@ export async function startStack(): Promise { const env: Record = { DATABASE_URL: `file:${dbPath}`, TOKEN_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, - JWT_SECRET: TEST_JWT_SECRET, + BETTER_AUTH_SECRET: TEST_BETTER_AUTH_SECRET, + BETTER_AUTH_URL: `http://localhost:${apiPort}`, GOOGLE_CLIENT_ID: TEST_GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET: TEST_GOOGLE_CLIENT_SECRET, PORT: String(apiPort), diff --git a/e2e/src/tests/02-sync-credentials.test.ts b/e2e/src/tests/02-sync-credentials.test.ts index 5b3173d..254ac34 100644 --- a/e2e/src/tests/02-sync-credentials.test.ts +++ b/e2e/src/tests/02-sync-credentials.test.ts @@ -2,23 +2,23 @@ // Licensed under the Elastic License 2.0 — see LICENSE in the repository root. /** * Tests for sync password generation/reset via REST API. - * Users authenticate via JWT session cookie (minted directly, skipping Google OAuth). + * Users authenticate via Better Auth session (seeded directly into DB, skipping Google OAuth). */ import { describe, it, expect, beforeAll, afterAll } from "bun:test"; import { startStack, type TestStack } from "@/setup"; -import { seedUser, mintSessionJwt } from "@/helpers/auth"; +import { seedUser, createTestSession } from "@/helpers/auth"; import { makeApiClient } from "@/helpers/api"; describe("Sync credentials", () => { let stack: TestStack; let userId: string; - let sessionJwt: string; + let sessionToken: string; beforeAll(async () => { stack = await startStack(); const user = await seedUser(stack.dbPath, { email: "sync-test@example.com" }); userId = user.id; - sessionJwt = await mintSessionJwt(userId); + sessionToken = await createTestSession(stack.dbPath, userId); }); afterAll(async () => { @@ -27,7 +27,7 @@ describe("Sync credentials", () => { it("GET /v1/me/sync-password — first call generates and returns password", async () => { const api = makeApiClient(`http://localhost:${stack.apiPort}`); - const creds = await api.getSyncPassword(sessionJwt); + const creds = await api.getSyncPassword(sessionToken); expect(creds.username).toBe("sync-test@example.com"); expect(typeof creds.password).toBe("string"); @@ -36,7 +36,7 @@ describe("Sync credentials", () => { it("GET /v1/me/sync-password — second call returns null password (already set)", async () => { const api = makeApiClient(`http://localhost:${stack.apiPort}`); - const creds = await api.getSyncPassword(sessionJwt); + const creds = await api.getSyncPassword(sessionToken); expect(creds.username).toBe("sync-test@example.com"); expect(creds.password).toBeNull(); @@ -44,7 +44,7 @@ describe("Sync credentials", () => { it("POST /v1/me/sync-password/reset — returns new plaintext password", async () => { const api = makeApiClient(`http://localhost:${stack.apiPort}`); - const creds = await api.resetSyncPassword(sessionJwt); + const creds = await api.resetSyncPassword(sessionToken); expect(creds.username).toBe("sync-test@example.com"); expect(typeof creds.password).toBe("string"); @@ -53,7 +53,7 @@ describe("Sync credentials", () => { it("GET /v1/me/sync-password — after reset, still returns null (hash is set)", async () => { const api = makeApiClient(`http://localhost:${stack.apiPort}`); - const creds = await api.getSyncPassword(sessionJwt); + const creds = await api.getSyncPassword(sessionToken); expect(creds.password).toBeNull(); }); diff --git a/e2e/src/tests/03-sync-server.test.ts b/e2e/src/tests/03-sync-server.test.ts index e66a36f..0678d8e 100644 --- a/e2e/src/tests/03-sync-server.test.ts +++ b/e2e/src/tests/03-sync-server.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, beforeAll, afterAll } from "bun:test"; import { compress } from "@mongodb-js/zstd"; import { startStack, type TestStack } from "@/setup"; -import { seedUser, seedLocalStorage, mintSessionJwt } from "@/helpers/auth"; +import { seedUser, seedLocalStorage, createTestSession } from "@/helpers/auth"; import { makeApiClient } from "@/helpers/api"; import { makeSyncClient } from "@/helpers/sync"; @@ -25,9 +25,9 @@ describe("Sync server — authentication", () => { // Create user via API (generates sync password, returns it once) const user = await seedUser(stack.dbPath, { email }); await seedLocalStorage(stack.dbPath, user.id); - const jwt = await mintSessionJwt(user.id); + const sessionToken = await createTestSession(stack.dbPath, user.id); const api = makeApiClient(`http://localhost:${stack.apiPort}`); - const creds = await api.getSyncPassword(jwt); + const creds = await api.getSyncPassword(sessionToken); // Store for tests (stack as TestStack & { syncPassword: string }).syncPassword = creds.password as string; }); @@ -86,9 +86,9 @@ describe("Sync server — stateless re-hydration", () => { const user = await seedUser(stack.dbPath, { email }); await seedLocalStorage(stack.dbPath, user.id); - const jwt = await mintSessionJwt(user.id); + const sessionToken = await createTestSession(stack.dbPath, user.id); const api = makeApiClient(`http://localhost:${stack.apiPort}`); - const creds = await api.getSyncPassword(jwt); + const creds = await api.getSyncPassword(sessionToken); const sync = makeSyncClient(`http://localhost:${stack.syncPort}`); hkey = await sync.hostKey(email, creds.password as string); diff --git a/e2e/src/tests/04-sync-password-rotation.test.ts b/e2e/src/tests/04-sync-password-rotation.test.ts index 0a522fd..292e4c5 100644 --- a/e2e/src/tests/04-sync-password-rotation.test.ts +++ b/e2e/src/tests/04-sync-password-rotation.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeAll, afterAll } from "bun:test"; import { compress } from "@mongodb-js/zstd"; import { startStack, type TestStack } from "@/setup"; -import { seedUser, seedLocalStorage, mintSessionJwt } from "@/helpers/auth"; +import { seedUser, seedLocalStorage, createTestSession } from "@/helpers/auth"; import { makeApiClient } from "@/helpers/api"; import { makeSyncClient } from "@/helpers/sync"; @@ -22,9 +22,9 @@ describe("Sync password rotation", () => { const user = await seedUser(stack.dbPath, { email }); await seedLocalStorage(stack.dbPath, user.id); - const jwt = await mintSessionJwt(user.id); + const sessionToken = await createTestSession(stack.dbPath, user.id); const api = makeApiClient(`http://localhost:${stack.apiPort}`); - const creds = await api.getSyncPassword(jwt); + const creds = await api.getSyncPassword(sessionToken); oldPassword = creds.password as string; }); @@ -46,10 +46,10 @@ describe("Sync password rotation", () => { const freshEmail = `rotate-fresh-${crypto.randomUUID()}@example.com`; const freshUser = await seedUser(dbPath, { email: freshEmail }); await seedLocalStorage(dbPath, freshUser.id); - const freshJwt = await mintSessionJwt(freshUser.id); + const freshToken = await createTestSession(dbPath, freshUser.id); const freshApi = makeApiClient(`http://localhost:${apiPort}`); - const initialCreds = await freshApi.getSyncPassword(freshJwt); + const initialCreds = await freshApi.getSyncPassword(freshToken); const initialPassword = initialCreds.password as string; // Verify initial password works @@ -58,7 +58,7 @@ describe("Sync password rotation", () => { expect(hkeyBefore.length).toBeGreaterThan(0); // Reset - const newCreds = await freshApi.resetSyncPassword(freshJwt); + const newCreds = await freshApi.resetSyncPassword(freshToken); const newPassword = newCreds.password as string; expect(newPassword).not.toBe(initialPassword); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 7d7b668..e275af7 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -9,7 +9,7 @@ set -euo pipefail # ---- Config --------------------------------------------------------------- -TEST_JWT_SECRET="0000000000000000000000000000000000000000000000000000000000000000" +TEST_BETTER_AUTH_SECRET="0000000000000000000000000000000000000000000000000000000000000000" TEST_ENCRYPTION_KEY="0000000000000000000000000000000000000000000000000000000000000000" SIDECAR_TOKEN="smoke-sidecar-token" TEST_EMAIL="smoke@example.com" @@ -27,7 +27,7 @@ BASE_URL="http://127.0.0.1:${API_PORT}/v1" API_PID="" SYNC_PID="" WORK_DIR="" -SESSION_JWT="" +SESSION_TOKEN="" # ---- Helpers --------------------------------------------------------------- PASS_COUNT=0 @@ -71,14 +71,14 @@ api_with_body() { api "$method" "$path" -d "$body" } -# curl wrapper for /me/* endpoints: attaches session cookie +# curl wrapper for /me/* endpoints: attaches Better Auth session cookie api_me() { local method="$1" path="$2"; shift 2 local extra_args=("$@") curl -s -o /tmp/smoke_api_body -w "%{http_code}" \ -X "$method" \ -H "Content-Type: application/json" \ - -H "Cookie: session=${SESSION_JWT}" \ + -H "Cookie: better-auth.session_token=${SESSION_TOKEN}" \ ${extra_args[@]+"${extra_args[@]}"} \ "${BASE_URL}${path}" } @@ -133,13 +133,12 @@ DB_PATH="${WORK_DIR}/test.db" SYNC_BASE="${WORK_DIR}/sync" mkdir -p "$SYNC_BASE" -# Apply migrations, seed user + local storage connection + API key, mint JWT — all via Python -SESSION_JWT=$(python3 - < Starting API (port :${API_PORT})..." DATABASE_URL="file:${DB_PATH}" \ -JWT_SECRET="$TEST_JWT_SECRET" \ +BETTER_AUTH_SECRET="$TEST_BETTER_AUTH_SECRET" \ +BETTER_AUTH_URL="http://127.0.0.1:${API_PORT}" \ TOKEN_ENCRYPTION_KEY="$TEST_ENCRYPTION_KEY" \ SIDECAR_URL="http://127.0.0.1:${INTERNAL_PORT}" \ SIDECAR_TOKEN="$SIDECAR_TOKEN" \ From 61cfe498bc82ca88daee99d3eefee5fb6f94057d Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:15:48 +0200 Subject: [PATCH 07/23] fix: drop old indexes, add better-auth dependencies to lockfile - Remove outdated indexes from SQLite schema before recreating with consistent names on new tables. - Add `better-auth` dependencies to lockfile. --- .../migrations/0003_better_auth_migration.sql | 10 + web/bun.lock | 296 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 web/bun.lock diff --git a/db/src/migrations/0003_better_auth_migration.sql b/db/src/migrations/0003_better_auth_migration.sql index f2e7939..79d4112 100644 --- a/db/src/migrations/0003_better_auth_migration.sql +++ b/db/src/migrations/0003_better_auth_migration.sql @@ -138,6 +138,12 @@ SELECT FROM `storage_connections`; --> statement-breakpoint +-- Drop old indexes before recreating with same names on new tables (SQLite index names are DB-scoped) +DROP INDEX IF EXISTS `idx_storage_user_id`; +--> statement-breakpoint +DROP INDEX IF EXISTS `uq_storage_user_provider`; +--> statement-breakpoint + -- ── user_api_key (renamed from users_api_keys) ─────────────────────────────── CREATE TABLE `user_api_key` ( @@ -184,6 +190,10 @@ SELECT `id`, `user_id`, `last_sync_at`, `client_version`, `sync_key` FROM `users_sync_state`; --> statement-breakpoint +-- Drop old index before recreating with same name on new table +DROP INDEX IF EXISTS `idx_sync_state_user_id`; +--> statement-breakpoint + -- ── Drop old tables ─────────────────────────────────────────────────────────── DROP TABLE `users_sync_state`; diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000..3fbc4b6 --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,296 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@anki-cloud/web", + "dependencies": { + "better-auth": "^1.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.2", + "vite": "^5.4.10", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@better-auth/core": ["@better-auth/core@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/core/-/core-1.6.5.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-T3u4rVsJcMWShG2qfQUlU1HdkQGLYX0+lcR48QV2Cp2kpBOLOTYdt+p6zZtGm2Omx/ReEouRQyKy7pYtahRQuA=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-9YjPW35+h66D+QA+YqEJ9pFP97ClLFR+QrTPZojkeP0PTYqpW0ErBK3p1pwRTJG88yK+o3Y4yOwoacMTBxz0jQ=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/kysely-adapter/-/kysely-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-kbevd70qzKNR3ZHF7q6/e0XXYRCXanLB2rvmTd3T8WbNEd9kYMqKjgTGNxL1ri5N+PEDUK6zfHx/HrvaEOfoHw=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/memory-adapter/-/memory-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0" } }, "sha512-5qFUpSdQi+RwHSmNyHMSsJIrFjed8d/ASS61L2xyW7sjBLTIuR7JcgS6hif5cQbtPeq+Qz+Wct5q8oKw33qyqQ=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/mongo-adapter/-/mongo-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-HvOUFTiSEFSGTzL/vE3FntTwQiZ79O/V+QcsCimR+65Bj3tOqdFaC1G2Yd1dQ9l2YHNXA9SNBrGekbk66RzJMw=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/prisma-adapter/-/prisma-adapter-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-d7PUO5XoimYYDEG/DoYVbOSbyVYJBDuZgvY9pjf8INccBTCD1BzcyEJ9NQil4huXWj4fcNaGOt2FG0OI8NtWOA=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/telemetry/-/telemetry-1.6.5.tgz", { "peerDependencies": { "@better-auth/core": "^1.6.5", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-Ag3CjAP+tLretKPq+pYdU/gU4pFIcey/AoNQzw671wV5JQZXrMitS65INi8j8QuYfol2xgQrht5KVlcxGrkhHQ=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-auth/utils/-/utils-0.4.0.tgz", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@better-fetch/fetch/-/fetch-1.1.21.tgz", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/android-arm/-/android-arm-0.21.5.tgz", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/android-x64/-/android-x64-0.21.5.tgz", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@noble/ciphers": ["@noble/ciphers@2.2.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@noble/ciphers/-/ciphers-2.2.0.tgz", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], + + "@noble/hashes": ["@noble/hashes@2.2.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@noble/hashes/-/hashes-2.2.0.tgz", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/babel__core/-/babel__core-7.20.5.tgz", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/babel__generator/-/babel__generator-7.27.0.tgz", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/babel__template/-/babel__template-7.4.4.tgz", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/prop-types/-/prop-types-15.7.15.tgz", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.28", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/react/-/react-18.3.28.tgz", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@types/react-dom/-/react-dom-18.3.7.tgz", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.20", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="], + + "better-auth": ["better-auth@1.6.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/better-auth/-/better-auth-1.6.5.tgz", { "dependencies": { "@better-auth/core": "1.6.5", "@better-auth/drizzle-adapter": "1.6.5", "@better-auth/kysely-adapter": "1.6.5", "@better-auth/memory-adapter": "1.6.5", "@better-auth/mongo-adapter": "1.6.5", "@better-auth/prisma-adapter": "1.6.5", "@better-auth/telemetry": "1.6.5", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-rSt8JtJOJK0MqPShXINCmM6DV30GsDvnCTlIxQIzP9OpUx/umA40nUc4ALZHQyqAPbw1ib/a549kIWw/WyxxKA=="], + + "better-call": ["better-call@1.3.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/better-call/-/better-call-1.3.5.tgz", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + + "browserslist": ["browserslist@4.28.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001788", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "defu": ["defu@6.1.7", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/defu/-/defu-6.1.7.tgz", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.340", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], + + "esbuild": ["esbuild@0.21.5", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/esbuild/-/esbuild-0.21.5.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escalade": ["escalade@3.2.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fsevents": ["fsevents@2.3.3", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "jose": ["jose@6.2.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/jose/-/jose-6.2.2.tgz", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "js-tokens": ["js-tokens@4.0.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kysely": ["kysely@0.28.16", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/kysely/-/kysely-0.28.16.tgz", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="], + + "loose-envify": ["loose-envify@1.4.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "nanostores": ["nanostores@1.3.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/nanostores/-/nanostores-1.3.0.tgz", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], + + "node-releases": ["node-releases@2.0.37", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/node-releases/-/node-releases-2.0.37.tgz", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "picocolors": ["picocolors@1.1.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.10", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/postcss/-/postcss-8.5.10.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + + "react": ["react@18.3.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/react/-/react-18.3.1.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/react-dom/-/react-dom-18.3.1.tgz", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-refresh": ["react-refresh@0.17.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/react-refresh/-/react-refresh-0.17.0.tgz", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "rollup": ["rollup@4.60.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/rollup/-/rollup-4.60.2.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], + + "rou3": ["rou3@0.7.12", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/rou3/-/rou3-0.7.12.tgz", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + + "scheduler": ["scheduler@0.23.2", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/scheduler/-/scheduler-0.23.2.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.0", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "source-map-js": ["source-map-js@1.2.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "typescript": ["typescript@5.6.3", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@5.4.21", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/vite/-/vite-5.4.21.tgz", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "yallist": ["yallist@3.1.1", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zod": ["zod@4.3.6", "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} From 6c641d7978e9faca917275d1b776811ded88986d Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:21:43 +0200 Subject: [PATCH 08/23] fix(db): recreate dropped indexes with consistent names in migrations --- .../migrations/0003_better_auth_migration.sql | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/db/src/migrations/0003_better_auth_migration.sql b/db/src/migrations/0003_better_auth_migration.sql index 79d4112..724c665 100644 --- a/db/src/migrations/0003_better_auth_migration.sql +++ b/db/src/migrations/0003_better_auth_migration.sql @@ -124,10 +124,6 @@ CREATE TABLE `user_storage_connection` ( FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ); --> statement-breakpoint -CREATE UNIQUE INDEX `uq_storage_user_provider` ON `user_storage_connection` (`user_id`, `provider`); ---> statement-breakpoint -CREATE INDEX `idx_storage_user_id` ON `user_storage_connection` (`user_id`); ---> statement-breakpoint INSERT INTO `user_storage_connection` (`id`, `user_id`, `provider`, `oauth_token`, `oauth_refresh_token`, `folder_path`, `connected_at`) @@ -138,10 +134,14 @@ SELECT FROM `storage_connections`; --> statement-breakpoint --- Drop old indexes before recreating with same names on new tables (SQLite index names are DB-scoped) +-- Drop old indexes before recreating with same names on new table (SQLite index names are DB-scoped) +DROP INDEX IF EXISTS `uq_storage_user_provider`; +--> statement-breakpoint DROP INDEX IF EXISTS `idx_storage_user_id`; --> statement-breakpoint -DROP INDEX IF EXISTS `uq_storage_user_provider`; +CREATE UNIQUE INDEX `uq_storage_user_provider` ON `user_storage_connection` (`user_id`, `provider`); +--> statement-breakpoint +CREATE INDEX `idx_storage_user_id` ON `user_storage_connection` (`user_id`); --> statement-breakpoint -- ── user_api_key (renamed from users_api_keys) ─────────────────────────────── @@ -179,10 +179,6 @@ CREATE TABLE `user_sync_state` ( FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ); --> statement-breakpoint -CREATE UNIQUE INDEX `user_sync_state_user_id_unique` ON `user_sync_state` (`user_id`); ---> statement-breakpoint -CREATE INDEX `idx_sync_state_user_id` ON `user_sync_state` (`user_id`); ---> statement-breakpoint INSERT INTO `user_sync_state` (`id`, `user_id`, `last_sync_at`, `client_version`, `sync_key`) @@ -190,9 +186,13 @@ SELECT `id`, `user_id`, `last_sync_at`, `client_version`, `sync_key` FROM `users_sync_state`; --> statement-breakpoint --- Drop old index before recreating with same name on new table +-- Drop old index before recreating with same name on new table (SQLite index names are DB-scoped) DROP INDEX IF EXISTS `idx_sync_state_user_id`; --> statement-breakpoint +CREATE UNIQUE INDEX `user_sync_state_user_id_unique` ON `user_sync_state` (`user_id`); +--> statement-breakpoint +CREATE INDEX `idx_sync_state_user_id` ON `user_sync_state` (`user_id`); +--> statement-breakpoint -- ── Drop old tables ─────────────────────────────────────────────────────────── From 904e22328dfefbad8014a72233b9ebba4da30dca Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:32:30 +0200 Subject: [PATCH 09/23] fix(auth): replace `??` with `||` in `baseURL` fallback to ensure broader compatibility --- api/src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/auth.ts b/api/src/auth.ts index e6d71e7..b94aae4 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -5,7 +5,7 @@ import {drizzleAdapter} from "better-auth/adapters/drizzle"; import {db} from "@anki-cloud/db"; export const auth = betterAuth({ - baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", + baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", basePath: "/v1/auth", secret: process.env.BETTER_AUTH_SECRET!, database: drizzleAdapter(db, { From 0bff41c16a10a0cafa70dac8a9839436fdbacc61 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:32:37 +0200 Subject: [PATCH 10/23] docs(self-hosting): update OAuth redirect URIs and `.env` configuration for Better Auth integration --- docs/SELF_HOSTING.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index ea4d705..7ba1240 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -29,7 +29,7 @@ You need a Google OAuth 2.0 app to handle sign-in and Google Drive access. - Application type: **Web application** - Add these **Authorized redirect URIs**: ``` - http://localhost:5173/v1/auth/google/callback + http://localhost:3000/v1/auth/callback/google http://localhost:5173/v1/me/storage/connect/gdrive/callback ``` 5. Copy the **Client ID** and **Client Secret** — you'll need them in the next step. @@ -47,18 +47,20 @@ cp .env.example .env Open `.env` and fill in the required values: ```bash -# Generate two random secrets (run these commands, paste the output): +# Generate random secrets (run these commands, paste the output): # openssl rand -hex 32 SIDECAR_TOKEN= -JWT_SECRET= +BETTER_AUTH_SECRET= TOKEN_ENCRYPTION_KEY= +# Public base URL of the API server — used by Better Auth for OAuth callbacks +BETTER_AUTH_URL=http://localhost:3000 + # From Step 1 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# Must match the redirect URIs you registered in Google Cloud Console -GOOGLE_REDIRECT_URI=http://localhost:5173/v1/auth/google/callback +# Must match the redirect URI you registered in Google Cloud Console GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/gdrive/callback # Where to redirect after OAuth flows complete @@ -83,7 +85,7 @@ Once running: |------------------------------|---------------------------| | `http://localhost:5173` | Account management web UI | | `http://localhost:8080` | Anki sync server endpoint | -| `http://localhost:5173/docs` | Interactive API reference | +| `http://localhost:3000/docs` | Interactive API reference | --- @@ -134,7 +136,8 @@ docker compose -f docker-compose.yml -f docker-compose.cloud.yml up ## Troubleshooting **OAuth redirect mismatch error** -Verify the redirect URIs in Google Cloud Console exactly match `GOOGLE_REDIRECT_URI` and `GOOGLE_DRIVE_REDIRECT_URI` in your `.env`. Trailing slashes and `http` vs `https` matter. +Verify the redirect URIs in Google Cloud Console exactly match those derived from `BETTER_AUTH_URL` and `GOOGLE_DRIVE_REDIRECT_URI` in your `.env`. For local dev: `http://localhost:3000/v1/auth/callback/google` (sign-in) +and `http://localhost:5173/v1/me/storage/connect/gdrive/callback` (Drive). Trailing slashes and `http` vs `https` matter. **Anki says "sync server not configured"** Ensure the sync URL in Anki is `http://localhost:8080` (no trailing slash) and the stack is running. From 0cbc75c0ceb11c36ac66f353b7805dba7bc15f09 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:41:29 +0200 Subject: [PATCH 11/23] fix(auth): update wildcard route matching for `/v1/auth/*` endpoints - Hono's wildcard is *, not ** --- api/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/index.ts b/api/src/index.ts index 511a791..0aa36f0 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -46,7 +46,7 @@ publicApi.get("/docs", (c) => const app = new OpenAPIHono(); app.get("/health", (c) => c.json({ status: "ok" })); -app.on(["POST", "GET"], "/v1/auth/**", (c) => auth.handler(c.req.raw)); +app.on(["POST", "GET"], "/v1/auth/*", (c) => auth.handler(c.req.raw)); app.route("/", publicApi); app.route("/v1", authRouter); app.route("/v1", storageRouter); From 8e7e30929a12921432e37fd1d032df79b6bb0951 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:50:16 +0200 Subject: [PATCH 12/23] feat(auth): add support for configurable trusted origins via `.env` --- api/src/auth.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/src/auth.ts b/api/src/auth.ts index b94aae4..d05cd24 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -4,10 +4,15 @@ import {betterAuth} from "better-auth"; import {drizzleAdapter} from "better-auth/adapters/drizzle"; import {db} from "@anki-cloud/db"; +const trustedOrigins = process.env.TRUSTED_ORIGINS + ? process.env.TRUSTED_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean) + : [process.env.FRONTEND_URL ?? "http://localhost:5173"]; + export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", basePath: "/v1/auth", secret: process.env.BETTER_AUTH_SECRET!, + trustedOrigins, database: drizzleAdapter(db, { provider: "sqlite", }), From c32885b2c5fe37f2c9049a5519eda1425b08da3f Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:50:37 +0200 Subject: [PATCH 13/23] feat(auth,ci): add `TRUSTED_ORIGINS` support to `.env` and Docker configuration --- .env.example | 5 +++++ docker-compose.yml | 1 + 2 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 531e55b..7d46950 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,8 @@ GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/gdrive/cal # Frontend URL — API callbacks redirect here after OAuth flows complete # Local (Vite dev server): http://localhost:5173 Prod: https://your-domain.com FRONTEND_URL=http://localhost:5173 + +# Comma-separated trusted origins for Better Auth CSRF protection. +# If unset, falls back to FRONTEND_URL. +# Local dev: http://localhost:5173 Prod: https://app.your-domain.com,https://staging.your-domain.com +TRUSTED_ORIGINS= diff --git a/docker-compose.yml b/docker-compose.yml index 78ac936..e851f05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} GOOGLE_DRIVE_REDIRECT_URI: ${GOOGLE_DRIVE_REDIRECT_URI} FRONTEND_URL: ${FRONTEND_URL} + TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-} TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY} volumes: - app-data:/data From db2b40fea7bfb8c3eadccad92473020a38385d58 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:50:58 +0200 Subject: [PATCH 14/23] docs: update OAuth redirect URIs and add `TRUSTED_ORIGINS` guidance for Better Auth integration --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ad8e06d..702988e 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,17 @@ docker compose --build -f docker-compose.yml -f docker-compose.standalone.yml -f Full production-like stack. Users authenticate via Google OAuth (via Better Auth); deck data stored in their Google Drive. Requires all OAuth credentials in `.env`. -Before running, add the Better Auth callback URI to your Google OAuth app in +Before running, add these URIs to your Google OAuth app in [Google Cloud Console](https://console.cloud.google.com) → APIs & Services → Credentials: ``` -{BETTER_AUTH_URL}/v1/auth/callback/google +{BETTER_AUTH_URL}/v1/auth/callback/google # sign-in callback +{FRONTEND_URL}/v1/me/storage/connect/gdrive/callback # Google Drive callback ``` +Set `TRUSTED_ORIGINS` in `.env` to your frontend URL(s) (comma-separated) so Better Auth accepts +requests from the web UI. Defaults to `FRONTEND_URL` if unset. + ```bash cp .env.example .env # fill in all credentials @@ -63,10 +67,10 @@ of `../anki-cloud-sync`. First build takes ~2–3 min; subsequent starts are ins Once the stack is running, two endpoints are available: -| URL | Purpose | -|-----|---------| +| URL | Purpose | +|--------------------------------------|--------------------------------------------------------------| | `http://localhost:3000/openapi.json` | OpenAPI 3.1 spec — import into Postman via **Import → Link** | -| `http://localhost:3000/docs` | Scalar interactive UI | +| `http://localhost:3000/docs` | Scalar interactive UI | All data endpoints (`/v1/decks/*`, `/v1/notes/*`, `/v1/cards/*`) require an API key: @@ -76,7 +80,7 @@ Authorization: Bearer ak_ Generate a key in the web UI under **Account → API Keys**, or via `POST /v1/me/api-keys`. -Account management endpoints (`/v1/me/*`) use the session cookie set by [Better Auth](https://better-auth.com) after Google OAuth login. The auth handler is mounted at `/api/auth/*`. +Account management endpoints (`/v1/me/*`) use the session cookie set by [Better Auth](https://better-auth.com) after Google OAuth login. The auth handler is mounted at `/v1/auth/*`. --- @@ -100,11 +104,11 @@ Run the setup script to install all required tools (skips anything already prese ./scripts/setup.zsh ``` -| Tool | Purpose | -|-------------------------------------------------------------------|-----------------------------------------------------| -| [Bun](https://bun.sh) | TypeScript runtime for REST API, MCP server, web UI | +| Tool | Purpose | +|-------------------------------------------------------------------|------------------------------------------------------| +| [Bun](https://bun.sh) | TypeScript runtime for REST API, MCP server, web UI | | [Docker Desktop](https://www.docker.com/products/docker-desktop/) | Full stack via `docker compose` (install separately) | -| [Rust](https://rustup.rs) ≥ 1.80 + `protoc` | Only needed to build anki-cloud-sync from source | +| [Rust](https://rustup.rs) ≥ 1.80 + `protoc` | Only needed to build anki-cloud-sync from source | ### Installing dependencies From 6a05a02833e4fc7a2f82e17255c727edbc27e26b Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 17:53:16 +0200 Subject: [PATCH 15/23] fix(auth): update Google login callback to use `window.location.origin` --- web/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 62ca421..56c2933 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -133,7 +133,7 @@ function Header({user}: { user: User }) { function LoginPage() { const handleGoogleLogin = async () => { - await authClient.signIn.social({provider: "google", callbackURL: "/"}); + await authClient.signIn.social({provider: "google", callbackURL: window.location.origin}); }; return ( From 6052b88605fc95e7263dbe024f90c0bd50abea67 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 18:00:54 +0200 Subject: [PATCH 16/23] fix: rename `gdrive` references to `google` for consistency with provider naming --- .env.example | 2 +- web/src/App.tsx | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 7d46950..5bf064a 100644 --- a/.env.example +++ b/.env.example @@ -42,7 +42,7 @@ GOOGLE_CLIENT_SECRET= # Must exactly match an authorized redirect URI in your Google OAuth app # For Google Drive storage connection (separate OAuth flow): -GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/gdrive/callback +GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/google/callback # Frontend URL — API callbacks redirect here after OAuth flows complete # Local (Vite dev server): http://localhost:5173 Prod: https://your-domain.com diff --git a/web/src/App.tsx b/web/src/App.tsx index 56c2933..ef78134 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -221,20 +221,20 @@ function StorageSection({ const [folderPathError, setFolderPathError] = useState(null); const [saving, setSaving] = useState(false); - const gdrive = connections.find((c) => c.provider === "google"); + const google = connections.find((c) => c.provider === "google"); const handleDisconnect = async () => { if (!confirm("Disconnect Google Drive? Your data in Drive will not be deleted.")) return; setBusy(true); try { - await onDisconnect("gdrive"); + await onDisconnect("google"); } finally { setBusy(false); } }; const startEdit = () => { - setFolderPathInput(gdrive?.folderPath ?? "/AnkiCloudSync"); + setFolderPathInput(google?.folderPath ?? "/AnkiCloudSync"); setFolderPathError(null); setEditing(true); }; @@ -246,7 +246,7 @@ function StorageSection({ } setSaving(true); try { - await onUpdateFolderPath("gdrive", folderPathInput); + await onUpdateFolderPath("google", folderPathInput); setEditing(false); } catch (err) { setFolderPathError(err instanceof Error ? err.message : "Failed to update."); @@ -261,16 +261,16 @@ function StorageSection({

Google Drive

- {gdrive ? ( + {google ? (

Connected · since{" "} - {new Date(gdrive.connectedAt).toLocaleDateString()} + {new Date(google.connectedAt).toLocaleDateString()}

) : (

Not connected

)}
- {gdrive ? ( + {google ? ( @@ -280,7 +280,7 @@ function StorageSection({ )}
- {gdrive && ( + {google && (

Sync folder

{editing ? ( @@ -308,7 +308,7 @@ function StorageSection({
) : (
- {gdrive.folderPath} + {google.folderPath}
)} From 04fa56a0fb7823894afff6dffcfe64fa7dea696e Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 18:01:24 +0200 Subject: [PATCH 17/23] docs: update all references from `GDrive` to `Google Drive` --- CLAUDE.md | 22 +++++++-------- CONTRIBUTING.md | 4 +-- README.md | 10 +++---- docs/SELF_HOSTING.md | 6 ++-- .../0003-fork-rust-ankitects-sync-server.md | 2 +- .../REST-API-over-rust-sync-server.md | 28 +++++++++---------- docs/tests/e2e-test-guide-rest-api.md | 2 +- .../e2e-testing-auth-and-gdrive-connect.md | 28 +++++++++---------- 8 files changed, 50 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c22492..ef58d3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ Hono on Bun. Full CRUD API with OpenAPI spec auto-generated from Zod schemas. MC │ │ │ │ │ ┌──────▼─────────────────▼──────────────────────────────┐ │ │ │ Auth & Storage Adapter Layer │ │ -│ │ Google OAuth (identity) + GDrive OAuth (storage) │ │ +│ │ Google OAuth (identity) + Google Drive OAuth (storage) │ │ │ └──────────────────────────┬────────────────────────────┘ │ │ │ │ │ ┌────────────────┐ ┌────────▼──────┐ │ @@ -168,11 +168,11 @@ users_sync_state ( ``` **What is NOT stored:** deck data, card content, review history, media files. -All of that lives in the user's GDrive. +All of that lives in the user's Google Drive. **Redis (ephemeral):** -- Active sync session state (flushed to GDrive on completion) +- Active sync session state (flushed to Google Drive on completion) - OAuth flow state (PKCE codes, state params — TTL: 10 minutes) - Rate limiting counters - API response cache (TTL: configurable) @@ -186,7 +186,7 @@ All of that lives in the user's GDrive. | MCP Server | TypeScript / Hono on Bun | [ADR-0007](docs/decisions/0007-mcp-server-wraps-rest-api-not-direct-db.md) · [ADR-0008](docs/decisions/0008-use-hono-on-bun-for-rest-api-and-mcp-server.md) | | Persistent DB | SQLite (via Drizzle ORM) | [ADR-0009](docs/decisions/0009-use-sqlite-for-persistent-storage.md) | | Cache / Sessions | Redis | — | -| Storage backends | GDrive API / Dropbox API / S3 SDK | [ADR-0002](docs/decisions/0002-use-user-owned-cloud-storage-for-deck-data.md) · [ADR-0006](docs/decisions/0006-use-google-drive-as-the-primary-storage-backend.md) | +| Storage backends | Google Drive API / Dropbox API / S3 SDK | [ADR-0002](docs/decisions/0002-use-user-owned-cloud-storage-for-deck-data.md) · [ADR-0006](docs/decisions/0006-use-google-drive-as-the-primary-storage-backend.md) | | Containerization | Docker + Docker Compose | — | | CI/CD | GitHub Actions | — | | Docs: API reference | Scalar (from OpenAPI spec) | — | @@ -309,8 +309,8 @@ web UI. Stored as bcrypt hash in `users.sync_password_hash`. Username = email ad 6. Sync server → returns hkey to Anki client (used as session token for all subsequent requests) 7. Anki client → sends requests with hkey in anki-sync header 8. Sync server → looks up hkey in memory map; if missing (restart/failover), re-hydrates from DB -9. Sync server → fetches GDrive OAuth refresh_token from SQLite, exchanges for fresh access_token -10. Sync server → reads/writes collection from/to user's GDrive +9. Sync server → fetches Google Drive OAuth refresh_token from SQLite, exchanges for fresh access_token +10. Sync server → reads/writes collection from/to user's Google Drive 11. Sync server → returns sync response to Anki client ``` @@ -322,7 +322,7 @@ web UI. Stored as bcrypt hash in `users.sync_password_hash`. Username = email ad 3. LLM → calls MCP tool (e.g. create_flashcard) 4. MCP server → validates API key (lookup in SQLite by hash) 5. MCP server → calls REST API with user context -6. REST API → applies change via storage adapter → writes to GDrive +6. REST API → applies change via storage adapter → writes to Google Drive ``` --- @@ -401,7 +401,7 @@ docker compose up ``` No external dependencies beyond Docker and a Google OAuth app (for auth). -Storage backend credentials are per-user (their own GDrive etc.). +Storage backend credentials are per-user (their own Google Drive etc.). --- @@ -415,7 +415,7 @@ Storage backend credentials are per-user (their own GDrive etc.). 5. **OpenAPI first.** The spec is the contract. SDKs and docs generate from it. 6. **Conventional commits.** Enables automated changelog and semantic versioning. 7. **Do not use "Anki" in the product name.** Registered trademark — legal risk. -8. **Prove the sync → GDrive adapter works before building anything else.** +8. **Prove the sync → Google Drive adapter works before building anything else.** It's the riskiest assumption. Validate it first. 9. **AI Agents: Never auto-commit code.** When work is complete, inform the user that changes are ready to commit. Let the user handle git commits themselves. This preserves user agency and prevents accidental commits. @@ -439,8 +439,8 @@ ADRs live in `docs/decisions/`. Use `adr-tools` to manage them. ## 12. Open Questions (OSS-scoped) - [ ] **Conflict resolution** — what happens when two devices sync simultaneously? -- [ ] **Media files** — large audio/image files need special handling in GDrive (size limits, latency) -- [ ] **GDrive API rate limits** — need to understand quotas for sync-heavy users +- [ ] **Media files** — large audio/image files need special handling in Google Drive (size limits, latency) +- [ ] **Google Drive API rate limits** — need to understand quotas for sync-heavy users - [ ] **AnkiMobile compatibility** — verify custom sync URL works with the iOS app --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5e984c..3b3e676 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ cp .env.example .env # fill in SIDECAR_TOKEN and JWT_SECRET at minimum Start the full local stack: ```bash -# Standalone mode — no GDrive or OAuth setup required +# Standalone mode — no Google Drive or OAuth setup required docker compose -f docker-compose.yml -f docker-compose.standalone.yml up ``` @@ -58,7 +58,7 @@ A `BREAKING CHANGE:` footer (or `!` after the type) triggers a major version bum ``` feat(api): add POST /v1/decks endpoint -fix(auth): handle expired refresh token on GDrive callback +fix(auth): handle expired refresh token on Google Drive callback docs: add self-hosting guide chore: bump anki-cloud-sync image to v25.09-r5 feat(sync)!: change hkey derivation algorithm diff --git a/README.md b/README.md index 702988e..99358cf 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Two independent axes: **mode** (standalone vs cloud) and **image source** (publi ### Standalone mode No database or cloud storage required. Users defined via `SYNC_USER1` in `.env`. -Good for local development and testing the REST API without GDrive setup. +Good for local development and testing the REST API without Google Drive setup. ```bash cp .env.example .env # set SIDECAR_TOKEN, BETTER_AUTH_SECRET, SYNC_USER1 at minimum @@ -42,7 +42,7 @@ Before running, add these URIs to your Google OAuth app in ``` {BETTER_AUTH_URL}/v1/auth/callback/google # sign-in callback -{FRONTEND_URL}/v1/me/storage/connect/gdrive/callback # Google Drive callback +{FRONTEND_URL}/v1/me/storage/connect/google/callback # Google Drive callback ``` Set `TRUSTED_ORIGINS` in `.env` to your frontend URL(s) (comma-separated) so Better Auth accepts @@ -152,8 +152,8 @@ docs/ Architecture decisions (ADRs) + narrative docs scripts/ Dev tooling (setup, SDK generation) docker-compose.yml Base stack (api + anki-sync-server) -docker-compose.standalone.yml Standalone mode override (no DB/GDrive) -docker-compose.cloud.yml Cloud mode override (SQLite + GDrive OAuth) +docker-compose.standalone.yml Standalone mode override (no DB/Google Drive) +docker-compose.cloud.yml Cloud mode override (SQLite + Google Drive OAuth) docker-compose.dev.yml Local build of anki-cloud-sync (any mode) ``` @@ -161,7 +161,7 @@ The sync server lives in a separate repository: [github.com/danielpmichalski/anki-cloud-sync](https://github.com/danielpmichalski/anki-cloud-sync) — see its README for all configuration options and environment variables. -Full self-hosting walkthrough (Google OAuth setup, GDrive, Anki Desktop, Claude Desktop): [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md) +Full self-hosting walkthrough (Google OAuth setup, Google Drive, Anki Desktop, Claude Desktop): [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md) Full architecture and design decisions: [CLAUDE.md](CLAUDE.md) diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 7ba1240..76e1092 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -30,7 +30,7 @@ You need a Google OAuth 2.0 app to handle sign-in and Google Drive access. - Add these **Authorized redirect URIs**: ``` http://localhost:3000/v1/auth/callback/google - http://localhost:5173/v1/me/storage/connect/gdrive/callback + http://localhost:5173/v1/me/storage/connect/google/callback ``` 5. Copy the **Client ID** and **Client Secret** — you'll need them in the next step. @@ -61,7 +61,7 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= # Must match the redirect URI you registered in Google Cloud Console -GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/gdrive/callback +GOOGLE_DRIVE_REDIRECT_URI=http://localhost:5173/v1/me/storage/connect/google/callback # Where to redirect after OAuth flows complete FRONTEND_URL=http://localhost:5173 @@ -137,7 +137,7 @@ docker compose -f docker-compose.yml -f docker-compose.cloud.yml up **OAuth redirect mismatch error** Verify the redirect URIs in Google Cloud Console exactly match those derived from `BETTER_AUTH_URL` and `GOOGLE_DRIVE_REDIRECT_URI` in your `.env`. For local dev: `http://localhost:3000/v1/auth/callback/google` (sign-in) -and `http://localhost:5173/v1/me/storage/connect/gdrive/callback` (Drive). Trailing slashes and `http` vs `https` matter. +and `http://localhost:5173/v1/me/storage/connect/google/callback` (Drive). Trailing slashes and `http` vs `https` matter. **Anki says "sync server not configured"** Ensure the sync URL in Anki is `http://localhost:8080` (no trailing slash) and the stack is running. diff --git a/docs/decisions/0003-fork-rust-ankitects-sync-server.md b/docs/decisions/0003-fork-rust-ankitects-sync-server.md index 396b2e1..97d21c2 100644 --- a/docs/decisions/0003-fork-rust-ankitects-sync-server.md +++ b/docs/decisions/0003-fork-rust-ankitects-sync-server.md @@ -37,7 +37,7 @@ The upstream server writes one directory per user under `SYNC_BASE`: collection.media/ ← media files stored by hash ``` -SQLite must reside on local disk during an active sync — it requires random access, WAL, and file locking. GDrive I/O cannot substitute for local SQLite at operation time. +SQLite must reside on local disk during an active sync — it requires random access, WAL, and file locking. Google Drive I/O cannot substitute for local SQLite at operation time. ### Why not abstract storage at the SQLite level diff --git a/docs/research/REST-API-over-rust-sync-server.md b/docs/research/REST-API-over-rust-sync-server.md index 84bc374..472d193 100644 --- a/docs/research/REST-API-over-rust-sync-server.md +++ b/docs/research/REST-API-over-rust-sync-server.md @@ -80,10 +80,10 @@ The forked Rust binary runs two HTTP listeners: ``` Hono REST API never touches `collection.anki2`. All collection mutations go through -the sidecar. Rust owns USN, mtime, graves, GDrive download/upload, and the per-user lock. +the sidecar. Rust owns USN, mtime, graves, Google Drive download/upload, and the per-user lock. ``` -LLM → MCP → Hono REST (:443) → Rust sidecar (:8081) → rslib → GDrive +LLM → MCP → Hono REST (:443) → Rust sidecar (:8081) → rslib → Google Drive ↑ Anki Desktop → Rust sync (:8080) ``` @@ -91,7 +91,7 @@ LLM → MCP → Hono REST (:443) → Rust sidecar (:8081) → rslib → GDrive **Why this is the only correct choice:** - Single write path — USN/mtime/graves handled exclusively by rslib - Per-user lock already exists for sync; sidecar acquires same lock → CRUD and sync are mutually exclusive -- No GDrive race — one process owns the file lifecycle +- No Google Drive race — one process owns the file lifecycle - Hono stays TypeScript ([ADR-0008](../decisions/0008-use-hono-on-bun-for-rest-api-and-mcp-server.md)); no Rust CRUD surface exposed publicly - Internal API is never public — no versioning pressure, no OpenAPI needed for it @@ -104,11 +104,11 @@ LLM → MCP → Hono REST (:443) → Rust sidecar (:8081) → rslib → GDrive ### Option 2 — Both processes coordinate via Redis lock + raw SQLite (rejected) -Hono and Rust both download `collection.anki2` from GDrive, mutate, upload. Redis +Hono and Rust both download `collection.anki2` from Google Drive, mutate, upload. Redis distributed lock per user enforces one writer at a time. **Why rejected:** Hono must reimplement USN management in TypeScript. Any bug permanently -desynchronizes the collection. GDrive upload race still possible on lock expiry. Most of +desynchronizes the collection. Google Drive upload race still possible on lock expiry. Most of Option 1's operational complexity without Option 1's correctness guarantee. --- @@ -119,7 +119,7 @@ Option 1's operational complexity without Option 1's correctness guarantee. **Why rejected:** Adds Python to the stack alongside Rust and TypeScript. Full collection in memory. Schema version between pip release and forked rslib can diverge silently. -GDrive race still exists. Worst of all worlds. +Google Drive race still exists. Worst of all worlds. --- @@ -171,22 +171,22 @@ tokio::join!( ); ``` -### Lock and GDrive lifecycle for sidecar requests +### Lock and Google Drive lifecycle for sidecar requests ``` 1. Receive request on :8081 2. Acquire per-user lock (same Mutex/RwLock used by sync handlers) -3. If collection not in local temp dir → download from GDrive (CollectionStorage::fetch) +3. If collection not in local temp dir → download from Google Drive (CollectionStorage::fetch) 4. Open collection via rslib 5. Execute operation (rslib handles USN/mtime/graves automatically) 6. Close collection (rslib flushes WAL) -7. Upload modified file back to GDrive (CollectionStorage::commit) +7. Upload modified file back to Google Drive (CollectionStorage::commit) 8. Release lock 9. Return JSON response to Hono ``` Warm-cache optimization: if a sync recently finished, the temp dir may still exist. -Compare GDrive file etag before downloading to skip unnecessary round-trips. +Compare Google Drive file etag before downloading to skip unnecessary round-trips. ### Auth on the sidecar @@ -203,7 +203,7 @@ authenticated requests. When a note references media: 1. Hono uploads the file to the sidecar as a multipart field -2. Rust computes the content hash, stores file in `collection.media/` on GDrive +2. Rust computes the content hash, stores file in `collection.media/` on Google Drive 3. Rust updates `collection.media.db` via rslib media APIs 4. File participates in the next `/msync/` cycle automatically @@ -215,11 +215,11 @@ Never write media references into `notes.flds` without updating `collection.medi | Risk | Severity | Mitigation | |---|---|---| -| GDrive concurrent upload (CRUD + sync racing) | Critical | Shared per-user lock; CRUD and sync mutually exclusive | +| Google Drive concurrent upload (CRUD + sync racing) | Critical | Shared per-user lock; CRUD and sync mutually exclusive | | Sidecar port exposed outside container network | High | Bind to 127.0.0.1; no published port; shared secret header | -| Stale temp dir on crash | Medium | Validate GDrive etag on startup; clean orphaned temp dirs | +| Stale temp dir on crash | Medium | Validate Google Drive etag on startup; clean orphaned temp dirs | | Media orphaning (notes ref files not in media.db) | Medium | All media writes through rslib media APIs only | -| GDrive API rate limits under heavy CRUD | Low | Exponential backoff; batch small mutations | +| Google Drive API rate limits under heavy CRUD | Low | Exponential backoff; batch small mutations | --- diff --git a/docs/tests/e2e-test-guide-rest-api.md b/docs/tests/e2e-test-guide-rest-api.md index 272f90a..78d273b 100644 --- a/docs/tests/e2e-test-guide-rest-api.md +++ b/docs/tests/e2e-test-guide-rest-api.md @@ -24,7 +24,7 @@ SESSION= ## Option B: Docker Compose standalone (no Google OAuth) -Fastest for REST API testing — no GDrive or OAuth setup needed. +Fastest for REST API testing — no Google Drive or OAuth setup needed. ```bash cd ~/Projects/anki-cloud diff --git a/docs/tests/e2e-testing-auth-and-gdrive-connect.md b/docs/tests/e2e-testing-auth-and-gdrive-connect.md index a18b785..710e7ef 100644 --- a/docs/tests/e2e-testing-auth-and-gdrive-connect.md +++ b/docs/tests/e2e-testing-auth-and-gdrive-connect.md @@ -1,4 +1,4 @@ -# E2E Testing: Google OAuth2 Login + GDrive Connection +# E2E Testing: Google OAuth2 Login + Google Drive Connection Manual test guide for the M2 auth flows: Google login (identity) and Google Drive connection (storage). @@ -11,7 +11,7 @@ Manual test guide for the M2 auth flows: Google login (identity) and Google Driv - ✅ Authenticated user can connect Google Drive - ✅ OAuth tokens are stored encrypted in `storage_connections` - ✅ `GET /v1/me/storage` returns connection metadata (no tokens) -- ✅ Re-connecting GDrive updates tokens (upsert) +- ✅ Re-connecting Google Drive updates tokens (upsert) - ✅ All protected routes reject unauthenticated requests ## Prerequisites @@ -24,11 +24,11 @@ In [Google Cloud Console](https://console.cloud.google.com/) → APIs & Services - Authorized redirect URIs must include **both**: ``` http://localhost:3000/v1/auth/google/callback - http://localhost:3000/v1/me/storage/connect/gdrive/callback + http://localhost:3000/v1/me/storage/connect/google/callback ``` APIs & Services → Enabled APIs must include: -- Google Drive API (required for GDrive connection flow) +- Google Drive API (required for Google Drive connection flow) ### 2. Environment @@ -38,7 +38,7 @@ APIs & Services → Enabled APIs must include: GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/v1/auth/google/callback -GOOGLE_DRIVE_REDIRECT_URI=http://localhost:3000/v1/me/storage/connect/gdrive/callback +GOOGLE_DRIVE_REDIRECT_URI=http://localhost:3000/v1/me/storage/connect/google/callback JWT_SECRET=<32-byte hex — openssl rand -hex 32> TOKEN_ENCRYPTION_KEY=<32-byte hex — openssl rand -hex 32> DATABASE_URL=file:../data/anki-cloud.db @@ -187,22 +187,20 @@ http://localhost:3000/v1/auth/google/callback?code=fakecode&state=wrongstate In browser (same session): ``` -http://localhost:3000/v1/me/storage/connect/gdrive +http://localhost:3000/v1/me/storage/connect/google ``` **Expected:** - Browser redirects to `accounts.google.com` with `drive.file` scope - Consent screen shows: _"See, edit, create, and delete only the specific Google Drive files you use with this app"_ -- Two new cookies: `gdrive_oauth_state`, `gdrive_code_verifier` ### Step 2: Approve consent Click **Allow** on the Google consent screen. **Expected:** -- Redirects to `http://localhost:3000/v1/me/storage/connect/gdrive/callback` +- Redirects to `http://localhost:3000/v1/me/storage/connect/google/callback` - Response: `{"ok":true}` -- `gdrive_oauth_state` and `gdrive_code_verifier` cookies cleared ### Step 3: Verify in DB @@ -211,7 +209,7 @@ sqlite3 packages/data/anki-cloud.db \ "SELECT user_id, provider, folder_path, connected_at FROM storage_connections;" ``` -**Expected:** One row with `provider=gdrive`, `folder_path=/AnkiSync`. +**Expected:** One row with `provider=google`, `folder_path=/AnkiSync`. Verify tokens are encrypted (not plaintext): @@ -237,7 +235,7 @@ curl http://localhost:3000/v1/me/storage \ "connections": [ { "id": "", - "provider": "gdrive", + "provider": "google", "folderPath": "/AnkiSync", "connectedAt": "" } @@ -249,7 +247,7 @@ Verify `oauthToken` and `oauthRefreshToken` are **absent** from the response. --- -## Test 10: Re-connect GDrive (Upsert) +## Test 10: Re-connect Google Drive (Upsert) Repeat Test 8 (go through Drive OAuth again, same account). @@ -271,7 +269,7 @@ sqlite3 packages/data/anki-cloud.db "SELECT COUNT(*) FROM storage_connections;" Open in browser without a session cookie (use incognito or clear cookies): ``` -http://localhost:3000/v1/me/storage/connect/gdrive +http://localhost:3000/v1/me/storage/connect/google ``` **Expected:** `{"error":"Unauthenticated","code":"MISSING_SESSION"}` with `401` — redirect to Google does NOT happen. @@ -289,7 +287,7 @@ All of the following must be true: - [ ] Logging in twice same account = still 1 user row - [ ] Invalid session token = `401 INVALID_SESSION` - [ ] Invalid OAuth state = `400 INVALID_OAUTH_STATE` -- [ ] GDrive consent shows `drive.file` scope +- [ ] Google Drive consent shows `drive.file` scope - [ ] Drive connect completes, `storage_connections` row in DB - [ ] Token stored as encrypted base64url (not plaintext) - [ ] `GET /v1/me/storage` returns connection without token fields @@ -331,6 +329,6 @@ Browser-based tests are required for steps involving OAuth redirects. Use DevToo ## See Also -- [E2E Testing: GDrive Sync Integration](./e2e-testing-gdrive-sync.md) — Rust sync server + GDrive adapter +- [E2E Testing: Google Drive Sync Integration](./e2e-testing-gdrive-sync.md) — Rust sync server + Google Drive adapter - [ADR-0004: OAuth2 Authentication](../decisions/0004-use-oauth2-for-authentication-no-password-storage.md) - [ADR-0005: Google as OAuth Provider](../decisions/0005-use-google-as-the-sole-oauth-provider-mvp.md) From 73fd04cf9edcbd5d88b7d7177d36a758c720aa24 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 18:13:46 +0200 Subject: [PATCH 18/23] fix(api): add configurable idle timeout for server connections --- api/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/index.ts b/api/src/index.ts index 0aa36f0..8752337 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -63,5 +63,6 @@ app.onError((err, c) => { export default { port: Number.parseInt(process.env.PORT ?? "3000"), + idleTimeout: 120, fetch: app.fetch, }; From 6a069afb6ee01b0932fd219ef077b6805e221664 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 18:43:24 +0200 Subject: [PATCH 19/23] chore: bump anki-cloud-sync image to v25.09-r6 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e851f05..9b09601 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: anki-sync-server: - image: ghcr.io/danielpmichalski/anki-cloud-sync:v25.09-r5 + image: ghcr.io/danielpmichalski/anki-cloud-sync:v25.09-r6 # image: anki-sync-server:local # pull_policy: never ports: From 8644733c708a7ea31c19c86ed6e67b30cb992a0a Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 18:54:12 +0200 Subject: [PATCH 20/23] ci: add Docker Compose smoke test workflow --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 628c6b5..0ee26ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,28 @@ jobs: tags: ghcr.io/danielpmichalski/anki-cloud:ci cache-from: type=gha cache-to: type=gha,mode=max + + docker-compose-smoke: + name: Docker Compose smoke test + runs-on: ubuntu-latest + env: + SIDECAR_TOKEN: ci-dummy-token + BETTER_AUTH_SECRET: ci-dummy-secret-32-chars-minimum!! + BETTER_AUTH_URL: http://localhost:3000 + GOOGLE_CLIENT_ID: dummy-client-id + GOOGLE_CLIENT_SECRET: dummy-client-secret + GOOGLE_DRIVE_REDIRECT_URI: http://localhost:3000/auth/callback/google-drive + FRONTEND_URL: http://localhost:5173 + TOKEN_ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" + steps: + - uses: actions/checkout@v4 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GH_PAT }} + - name: Pull sync server image + run: docker compose pull anki-sync-server + - name: Build API image and create containers + run: docker compose up --no-start --build From ae0170158be91fd303f414636e6b0b89f6e57961 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 19:06:10 +0200 Subject: [PATCH 21/23] chore: bump anki-cloud-sync image to v25.09-r7 --- CONTRIBUTING.md | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b3e676..ae296a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ A `BREAKING CHANGE:` footer (or `!` after the type) triggers a major version bum feat(api): add POST /v1/decks endpoint fix(auth): handle expired refresh token on Google Drive callback docs: add self-hosting guide -chore: bump anki-cloud-sync image to v25.09-r5 +chore: bump anki-cloud-sync image to v25.09-r7 feat(sync)!: change hkey derivation algorithm BREAKING CHANGE: existing sync sessions will be invalidated diff --git a/docker-compose.yml b/docker-compose.yml index 9b09601..003313f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: anki-sync-server: - image: ghcr.io/danielpmichalski/anki-cloud-sync:v25.09-r6 + image: ghcr.io/danielpmichalski/anki-cloud-sync:v25.09-r7 # image: anki-sync-server:local # pull_policy: never ports: From eccf42d51b368a806dfe3e43384513fd2f8460d3 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 19:10:25 +0200 Subject: [PATCH 22/23] ci: remove redundant GitHub Container Registry login step --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ee26ce..55966f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,12 +61,6 @@ jobs: TOKEN_ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" steps: - uses: actions/checkout@v4 - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GH_PAT }} - name: Pull sync server image run: docker compose pull anki-sync-server - name: Build API image and create containers From b0dc7c486aea89223c0082a7343ff63361cba885 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Mon, 20 Apr 2026 19:14:32 +0200 Subject: [PATCH 23/23] ci: enable manual workflow dispatch trigger in CI configuration --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55966f8..3e1e363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: typecheck: