Add initial API documentation for Linsa API endpoints to docs/api.md

This commit is contained in:
Nikita
2025-12-28 11:45:07 -08:00
parent 3a2c78198a
commit c073fe6ee0
32 changed files with 4291 additions and 57 deletions

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 5613 --strictPort",
"dev": "vite dev --port 5625 --strictPort",
"build": "vite build",
"serve": "vite preview",
"test": "vitest run",

View File

@@ -299,6 +299,31 @@ async function seed() {
ADD COLUMN IF NOT EXISTS "cloudflare_customer_code" text
`)
// Create API keys table
await appDb.execute(sql`
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"key_hash" text NOT NULL UNIQUE,
"name" text NOT NULL DEFAULT 'Default',
"last_used_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now()
);
`)
// Create bookmarks table
await appDb.execute(sql`
CREATE TABLE IF NOT EXISTS "bookmarks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"url" text NOT NULL,
"title" text,
"description" text,
"tags" text,
"created_at" timestamptz NOT NULL DEFAULT now()
);
`)
// ========== Seed nikiv user ==========
const nikivUserId = "nikiv"
const nikivEmail = "nikita.voloboev@gmail.com"

View File

@@ -20,6 +20,7 @@ interface SettingsPanelProps {
activeSection: SettingsSection
onSelect: (section: SettingsSection) => void
profile?: UserProfile | null | undefined
showBilling?: boolean
}
type NavItem = {
@@ -66,7 +67,12 @@ export default function SettingsPanel({
activeSection,
onSelect,
profile,
showBilling = false,
}: SettingsPanelProps) {
const filteredNavItems = showBilling
? navItems
: navItems.filter((item) => item.id !== "billing")
return (
<aside className="shrink-0 bg-transparent border border-white/5 rounded-2xl h-[calc(100vh-6em)] sticky top-6 px-2 py-4 items-start flex flex-col gap-6">
<div className="flex flex-col gap-2 items-start w-full">
@@ -78,7 +84,7 @@ export default function SettingsPanel({
<ArrowLeft className="w-4 h-4" />
<span>Back to app</span>
</a>
{navItems.map(({ id, label, icon: Icon }) => {
{filteredNavItems.map(({ id, label, icon: Icon }) => {
const isActive = activeSection === id
return (
<button

View File

@@ -572,3 +572,44 @@ export type CreatorTier = z.infer<typeof selectCreatorTierSchema>
export type CreatorSubscription = z.infer<typeof selectCreatorSubscriptionSchema>
export type CreatorProduct = z.infer<typeof selectCreatorProductSchema>
export type CreatorPurchase = z.infer<typeof selectCreatorPurchaseSchema>
// =============================================================================
// API Keys
// =============================================================================
export const api_keys = pgTable("api_keys", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
key_hash: text("key_hash").notNull().unique(), // SHA-256 hash of the key
name: text("name").notNull().default("Default"), // User-friendly name
last_used_at: timestamp("last_used_at", { withTimezone: true }),
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const selectApiKeySchema = createSelectSchema(api_keys)
export type ApiKey = z.infer<typeof selectApiKeySchema>
// =============================================================================
// Bookmarks
// =============================================================================
export const bookmarks = pgTable("bookmarks", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
url: text("url").notNull(),
title: text("title"),
description: text("description"),
tags: text("tags"), // Comma-separated tags
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const selectBookmarkSchema = createSelectSchema(bookmarks)
export type Bookmark = z.infer<typeof selectBookmarkSchema>

View File

@@ -84,7 +84,7 @@ export const getAuth = () => {
usePlural: true,
schema,
}),
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"],
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:5625"],
plugins: [
tanstackStartCookies(),
emailOTP({

View File

@@ -14,7 +14,7 @@ export const usersCollection = createCollection(
"/api/users",
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000",
: "http://localhost:5625",
).toString(),
parser: {
timestamptz: (date: string) => new Date(date),
@@ -28,7 +28,7 @@ export const usersCollection = createCollection(
const baseUrl =
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000"
: "http://localhost:5625"
// Create collections lazily to avoid fetching before authentication
// Using a factory pattern so each call gets the same collection instance

View File

@@ -0,0 +1,133 @@
import { createAPIFileRoute } from "@tanstack/react-start/api"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { api_keys } from "@/db/schema"
import { auth } from "@/lib/auth"
import { headers } from "@tanstack/react-start/server"
// Generate a random API key
function generateApiKey(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let key = "lk_" // linsa key prefix
for (let i = 0; i < 32; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length))
}
return key
}
// Hash function for API key storage
async function hashApiKey(key: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(key)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
export const APIRoute = createAPIFileRoute("/api/api-keys")({
// GET - List user's API keys (without the actual key, just metadata)
GET: async () => {
try {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const db = getDb(process.env.DATABASE_URL!)
const keys = await db
.select({
id: api_keys.id,
name: api_keys.name,
last_used_at: api_keys.last_used_at,
created_at: api_keys.created_at,
})
.from(api_keys)
.where(eq(api_keys.user_id, session.user.id))
.orderBy(api_keys.created_at)
return Response.json({ keys })
} catch (error) {
console.error("Error fetching API keys:", error)
return Response.json({ error: "Failed to fetch API keys" }, { status: 500 })
}
},
// POST - Create a new API key
POST: async ({ request }) => {
try {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json().catch(() => ({}))
const name = body.name || "Default"
const db = getDb(process.env.DATABASE_URL!)
// Generate new key
const plainKey = generateApiKey()
const keyHash = await hashApiKey(plainKey)
// Insert key record
const [keyRecord] = await db
.insert(api_keys)
.values({
user_id: session.user.id,
key_hash: keyHash,
name,
})
.returning({
id: api_keys.id,
name: api_keys.name,
created_at: api_keys.created_at,
})
// Return the plain key ONLY on creation (it won't be retrievable later)
return Response.json({
key: plainKey,
id: keyRecord.id,
name: keyRecord.name,
created_at: keyRecord.created_at,
})
} catch (error) {
console.error("Error creating API key:", error)
return Response.json({ error: "Failed to create API key" }, { status: 500 })
}
},
// DELETE - Revoke an API key
DELETE: async ({ request }) => {
try {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const url = new URL(request.url)
const keyId = url.searchParams.get("id")
if (!keyId) {
return Response.json({ error: "Key ID is required" }, { status: 400 })
}
const db = getDb(process.env.DATABASE_URL!)
// Delete key (only if it belongs to the user)
const [deleted] = await db
.delete(api_keys)
.where(eq(api_keys.id, keyId))
.returning()
if (!deleted) {
return Response.json({ error: "Key not found" }, { status: 404 })
}
return Response.json({ success: true })
} catch (error) {
console.error("Error deleting API key:", error)
return Response.json({ error: "Failed to delete API key" }, { status: 500 })
}
},
})

View File

@@ -0,0 +1,124 @@
import { createAPIFileRoute } from "@tanstack/react-start/api"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { api_keys, bookmarks, users } from "@/db/schema"
// Hash function for API key verification
async function hashApiKey(key: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(key)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
// Get user from API key
async function getUserFromApiKey(apiKey: string) {
const db = getDb(process.env.DATABASE_URL!)
const keyHash = await hashApiKey(apiKey)
const [keyRecord] = await db
.select({
userId: api_keys.user_id,
keyId: api_keys.id,
})
.from(api_keys)
.where(eq(api_keys.key_hash, keyHash))
.limit(1)
if (!keyRecord) return null
// Update last_used_at
await db
.update(api_keys)
.set({ last_used_at: new Date() })
.where(eq(api_keys.id, keyRecord.keyId))
// Get user
const [user] = await db
.select()
.from(users)
.where(eq(users.id, keyRecord.userId))
.limit(1)
return user || null
}
export const APIRoute = createAPIFileRoute("/api/bookmarks")({
// POST - Add a bookmark
POST: async ({ request }) => {
try {
const body = await request.json()
const { url, title, description, tags, api_key } = body
if (!url) {
return Response.json({ error: "URL is required" }, { status: 400 })
}
if (!api_key) {
return Response.json({ error: "API key is required" }, { status: 401 })
}
const user = await getUserFromApiKey(api_key)
if (!user) {
return Response.json({ error: "Invalid API key" }, { status: 401 })
}
const db = getDb(process.env.DATABASE_URL!)
// Insert bookmark
const [bookmark] = await db
.insert(bookmarks)
.values({
user_id: user.id,
url,
title: title || null,
description: description || null,
tags: tags || null,
})
.returning()
return Response.json({
success: true,
bookmark: {
id: bookmark.id,
url: bookmark.url,
title: bookmark.title,
created_at: bookmark.created_at,
},
})
} catch (error) {
console.error("Error adding bookmark:", error)
return Response.json({ error: "Failed to add bookmark" }, { status: 500 })
}
},
// GET - List bookmarks (requires API key in header)
GET: async ({ request }) => {
try {
const apiKey = request.headers.get("x-api-key")
if (!apiKey) {
return Response.json({ error: "API key is required" }, { status: 401 })
}
const user = await getUserFromApiKey(apiKey)
if (!user) {
return Response.json({ error: "Invalid API key" }, { status: 401 })
}
const db = getDb(process.env.DATABASE_URL!)
const userBookmarks = await db
.select()
.from(bookmarks)
.where(eq(bookmarks.user_id, user.id))
.orderBy(bookmarks.created_at)
return Response.json({ bookmarks: userBookmarks })
} catch (error) {
console.error("Error fetching bookmarks:", error)
return Response.json({ error: "Failed to fetch bookmarks" }, { status: 500 })
}
},
})

View File

@@ -24,6 +24,9 @@ export const Route = createFileRoute("/settings")({
ssr: false,
})
// Feature flag: enable billing section
const BILLING_ENABLED = false
type Option = { value: string; label: string }
function InlineSelect({
@@ -641,6 +644,12 @@ function StreamingSection({ username }: { username: string | null | undefined })
title="Streaming"
description="Configure your live stream settings."
/>
<div className="mb-5 p-4 bg-purple-500/10 border border-purple-500/20 rounded-xl flex items-center gap-3">
<span className="px-2 py-0.5 text-xs font-bold uppercase bg-purple-500 text-white rounded">Beta</span>
<p className="text-sm text-purple-200">
Streaming is currently in beta. Features may change and some functionality is still being developed.
</p>
</div>
<div className="space-y-5">
{loading ? (
<div className="h-32 bg-white/5 rounded-2xl animate-pulse" />
@@ -953,7 +962,11 @@ function BillingSection() {
disabled={subscribing}
className="w-full py-3 rounded-xl text-sm font-semibold bg-teal-500 hover:bg-teal-400 text-white transition-colors disabled:opacity-50"
>
{subscribing ? "Loading..." : "Subscribe Now"}
{subscribing ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mx-auto" />
) : (
"Subscribe Now"
)}
</button>
)}
</div>
@@ -992,7 +1005,7 @@ function SettingsPage() {
if (isPending) {
return (
<div className="min-h-screen text-white grid place-items-center">
<p className="text-slate-400">Loading settings</p>
<div className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
</div>
)
}
@@ -1005,6 +1018,7 @@ function SettingsPage() {
activeSection={activeSection}
onSelect={setActiveSection}
profile={session?.user}
showBilling={BILLING_ENABLED}
/>
<div className="flex-1 space-y-12 overflow-auto pr-1 pb-12">
{activeSection === "preferences" ? (
@@ -1017,7 +1031,7 @@ function SettingsPage() {
/>
) : activeSection === "streaming" ? (
<StreamingSection username={session?.user?.username} />
) : activeSection === "billing" ? (
) : activeSection === "billing" && BILLING_ENABLED ? (
<BillingSection />
) : null}
</div>