feat(auth): email verification login and personal access tokens

* feat(auth): add email verification login flow with 401 auto-redirect

Replace the old OAuth-based login with email verification codes:
- Backend: send-code / verify-code endpoints, verification_codes table (migration 009), rate limiting, Resend email service
- Frontend: two-step login UI (email → 6-digit OTP), auth store with sendCode/verifyCode
- SDK: ApiClient gains onUnauthorized callback; 401 responses auto-clear token and redirect to /login
- Fix login button staying disabled due to global isLoading state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): add brute-force protection, redirect loop guard, and expired code cleanup

- VerifyCode: increment attempts on wrong code, reject after 5 failed tries (migration 010)
- onUnauthorized: skip redirect if already on /login to prevent infinite loops
- SendCode: best-effort cleanup of expired verification codes older than 1 hour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add master verification code for non-production environments

Allow code "888888" to bypass email verification in non-production
environments to simplify development and testing workflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add personal access tokens for CLI and API authentication

Add full-stack PAT support: users create tokens in Settings, CLI authenticates
via `multica auth login`. Server stores SHA-256 hashes only. Auth middleware
extended to accept both JWTs and PATs (distinguished by `mul_` prefix).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinYushen 2026-03-26 14:32:30 +08:00 committed by GitHub
parent a997bcfec0
commit 5c9c2f69fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1889 additions and 311 deletions

View file

@ -21,6 +21,10 @@ MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

View file

@ -16,7 +16,7 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router)
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
- `packages/` — Shared TypeScript packages (ui, types, sdk, utils)
### Web App Structure (`apps/web/`)
@ -40,6 +40,8 @@ apps/web/
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
| `features/inbox/` | Inbox notification state | `useInboxStore` |
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `features/modals/` | Modal registry and state | Modal store and components |
| `features/skills/` | Skill management | Skill components |
**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
@ -88,6 +90,7 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/``pkg/db/generated/`. Migrations in `migrations/`.
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
@ -96,6 +99,7 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe
- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here.
- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*).
- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown.
- **`@multica/utils`**: Shared utility functions used across apps and packages.
### Multi-tenancy
@ -145,7 +149,8 @@ make db-down # Stop shared PostgreSQL
### Worktree Support
For isolated feature testing with a separate database:
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree

View file

@ -10,11 +10,13 @@ vi.mock("next/navigation", () => ({
}));
// Mock auth store
const mockLogin = vi.fn();
const mockSendCode = vi.fn();
const mockVerifyCode = vi.fn();
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
login: mockLogin,
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
isLoading: false,
}),
}));
@ -42,78 +44,82 @@ describe("LoginPage", () => {
vi.clearAllMocks();
});
it("renders login form with heading, inputs, and button", () => {
it("renders email form with heading and button", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Name")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /continue/i })
).toBeInTheDocument();
});
it("does not call login when email is empty", async () => {
it("does not call sendCode when email is empty", async () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(mockLogin).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(mockSendCode).not.toHaveBeenCalled();
});
it("calls login with correct args on submit", async () => {
mockLogin.mockResolvedValueOnce({ id: "u1", name: "Test User" });
mockHydrateWorkspace.mockResolvedValueOnce(null);
it("calls sendCode on submit and shows code step", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.type(screen.getByLabelText("Name"), "Test User");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User");
expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai");
});
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
});
it("calls login with email only when name is empty", async () => {
mockLogin.mockResolvedValueOnce({ id: "u1", name: "" });
mockHydrateWorkspace.mockResolvedValueOnce(null);
it("shows 'Sending code...' while submitting", async () => {
mockSendCode.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined);
expect(screen.getByText("Sending code...")).toBeInTheDocument();
});
});
it("shows 'Signing in...' while submitting", async () => {
mockLogin.mockReturnValueOnce(new Promise(() => {}));
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText("Signing in...")).toBeInTheDocument();
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
it("shows error when login fails", async () => {
mockLogin.mockRejectedValueOnce(new Error("Network error"));
it("shows back button on code step", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(
screen.getByText("Login failed. Make sure the server is running."),
).toBeInTheDocument();
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: /back/i })
).toBeInTheDocument();
});
});

View file

@ -1,10 +1,9 @@
"use client";
import { Suspense, useState } from "react";
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useNavigationStore } from "@/features/navigation";
import { api } from "@/shared/api";
import {
Card,
@ -17,20 +16,34 @@ import {
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
function LoginPageContent() {
const router = useRouter();
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const sendCode = useAuthStore((s) => s.sendCode);
const verifyCode = useAuthStore((s) => s.verifyCode);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [cooldown, setCooldown] = useState(0);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
const handleSendCode = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!email) {
setError("Email is required");
return;
@ -38,17 +51,115 @@ function LoginPageContent() {
setError("");
setSubmitting(true);
try {
await login(email, name || undefined);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
const fallback = useNavigationStore.getState().lastPath;
router.push(searchParams.get("next") || fallback);
await sendCode(email);
setStep("code");
setCode("");
setCooldown(60);
} catch (err) {
setError("Login failed. Make sure the server is running.");
setError(
err instanceof Error ? err.message : "Failed to send code. Make sure the server is running."
);
} finally {
setSubmitting(false);
}
};
const handleVerifyCode = useCallback(
async (value: string) => {
if (value.length !== 6) return;
setError("");
setSubmitting(true);
try {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(
err instanceof Error ? err.message : "Invalid or expired code"
);
setCode("");
setSubmitting(false);
}
},
[email, verifyCode, hydrateWorkspace, router, searchParams]
);
const handleResend = async () => {
if (cooldown > 0) return;
setError("");
try {
await sendCode(email);
setCooldown(60);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to resend code"
);
}
};
if (step === "code") {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Check your email</CardTitle>
<CardDescription>
We sent a verification code to{" "}
<span className="font-medium text-foreground">{email}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<InputOTP
maxLength={6}
value={code}
onChange={(value) => {
setCode(value);
if (value.length === 6) handleVerifyCode(value);
}}
disabled={submitting}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button
type="button"
onClick={handleResend}
disabled={cooldown > 0}
className="text-primary underline-offset-4 hover:underline disabled:text-muted-foreground disabled:no-underline disabled:cursor-not-allowed"
>
{cooldown > 0 ? `Resend in ${cooldown}s` : "Resend code"}
</button>
</div>
</CardContent>
<CardFooter>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setStep("email");
setCode("");
setError("");
}}
>
Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
@ -57,17 +168,7 @@ function LoginPageContent() {
<CardDescription>AI-native task management</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
@ -88,11 +189,11 @@ function LoginPageContent() {
<Button
type="submit"
form="login-form"
disabled={submitting || isLoading}
disabled={submitting}
className="w-full"
size="lg"
>
{submitting ? "Signing in..." : "Sign in"}
{submitting ? "Sending code..." : "Continue"}
</Button>
</CardFooter>
</Card>

View file

@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@multica/types";
import { useEffect, useState, useCallback } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut, Key, Copy, Check } from "lucide-react";
import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@multica/types";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
@ -24,6 +24,14 @@ import {
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
@ -120,6 +128,61 @@ export default function SettingsPage() {
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [saving, setSaving] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
const [tokenName, setTokenName] = useState("");
const [tokenExpiry, setTokenExpiry] = useState("90");
const [tokenCreating, setTokenCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch {
// ignore — tokens section simply stays empty
}
}, []);
useEffect(() => { loadTokens(); }, [loadTokens]);
const handleCreateToken = async () => {
setTokenCreating(true);
try {
const expiresInDays = tokenExpiry === "never" ? undefined : Number(tokenExpiry);
const result = await api.createPersonalAccessToken({ name: tokenName, expires_in_days: expiresInDays });
setNewToken(result.token);
setTokenName("");
setTokenExpiry("90");
await loadTokens();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create token");
} finally {
setTokenCreating(false);
}
};
const handleRevokeToken = async (id: string) => {
setTokenRevoking(id);
try {
await api.revokePersonalAccessToken(id);
await loadTokens();
toast.success("Token revoked");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to revoke token");
} finally {
setTokenRevoking(null);
}
};
const handleCopyToken = async () => {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
};
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [inviteLoading, setInviteLoading] = useState(false);
@ -325,6 +388,87 @@ export default function SettingsPage() {
</div>
</section>
{/* API Tokens */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">API Tokens</h2>
</div>
<div className="rounded-lg border p-4 space-y-3">
<p className="text-xs text-muted-foreground">
Personal access tokens allow the CLI and external integrations to authenticate with your account.
</p>
<div className="grid gap-3 sm:grid-cols-[1fr_120px_auto]">
<Input
type="text"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="Token name (e.g. My CLI)"
/>
<Select value={tokenExpiry} onValueChange={(v) => { if (v) setTokenExpiry(v); }}>
<SelectTrigger size="sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="365">1 year</SelectItem>
<SelectItem value="never">No expiry</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreateToken} disabled={tokenCreating || !tokenName.trim()}>
{tokenCreating ? "Creating..." : "Create"}
</Button>
</div>
</div>
{tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<div key={t.id} className="flex items-center gap-3 rounded-lg border px-4 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">
{t.token_prefix}... · Created {new Date(t.created_at).toLocaleDateString()} · {t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : "Never used"}
{t.expires_at && ` · Expires ${new Date(t.expires_at).toLocaleDateString()}`}
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevokeToken(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</section>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
Copy your personal access token now. You won&apos;t be able to see it again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-sm break-all select-all">
{newToken}
</code>
<Button variant="outline" size="icon" onClick={handleCopyToken}>
{tokenCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<DialogFooter>
<Button onClick={() => { setNewToken(null); setTokenCopied(false); }}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Workspace info */}
<section className="space-y-4">
<div className="flex items-center gap-2">

View file

@ -9,7 +9,8 @@ interface AuthState {
isLoading: boolean;
initialize: () => Promise<void>;
login: (email: string, name?: string) => Promise<User>;
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
@ -39,8 +40,12 @@ export const useAuthStore = create<AuthState>((set) => ({
}
},
login: async (email: string, name?: string) => {
const { token, user } = await api.login(email, name);
sendCode: async (email: string) => {
await api.sendCode(email);
},
verifyCode: async (email: string, code: string) => {
const { token, user } = await api.verifyCode(email, code);
localStorage.setItem("multica_token", token);
api.setToken(token);
set({ user });

View file

@ -15,4 +15,14 @@ if (typeof window !== "undefined") {
if (wsId) {
api.setWorkspaceId(wsId);
}
api.setOnUnauthorized(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
});
}

View file

@ -1,6 +1,16 @@
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
// jsdom doesn't provide ResizeObserver; stub it so components that rely on it
// (e.g. input-otp) can render in tests.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
// Create a proper localStorage mock if methods are missing.
if (

View file

@ -5,7 +5,10 @@
* have zero build-time coupling to monorepo packages.
*/
import pg from "pg";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
interface TestWorkspace {
id: string;
@ -19,14 +22,53 @@ export class TestApiClient {
private createdIssueIds: string[] = [];
async login(email: string, name: string) {
const res = await fetch(`${API_BASE}/auth/login`, {
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, name }),
body: JSON.stringify({ email }),
});
const data = await res.json();
this.token = data.token;
return data;
if (!sendRes.ok) {
// Rate limited — code already sent recently, read it from DB
if (sendRes.status !== 429) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
}
// Step 2: Read code from database
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
const result = await client.query(
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
[email]
);
if (result.rows.length === 0) {
throw new Error(`No verification code found for ${email}`);
}
const code = result.rows[0].code;
// Step 3: Verify code to get JWT
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
});
const data = await verifyRes.json();
this.token = data.token;
// Update user name if needed
if (name && data.user?.name !== name) {
await this.authedFetch("/api/me", {
method: "PATCH",
body: JSON.stringify({ name }),
});
}
return data;
} finally {
await client.end();
}
}
async getWorkspaces(): Promise<TestWorkspace[]> {
@ -92,6 +134,10 @@ export class TestApiClient {
this.createdIssueIds = [];
}
getToken() {
return this.token;
}
private async authedFetch(path: string, init?: RequestInit) {
const headers: Record<string, string> = {
"Content-Type": "application/json",

View file

@ -7,16 +7,20 @@ const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
/**
* Log in as the default E2E user and ensure the workspace exists first.
* Authenticates via API (send-code DB read verify-code), then injects
* the token into localStorage so the browser session is authenticated.
*/
export async function loginAsDefault(page: Page) {
const api = new TestApiClient();
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
const token = api.getToken();
await page.goto("/login");
await page.fill('input[placeholder="Name"]', DEFAULT_E2E_NAME);
await page.fill('input[placeholder="Email"]', DEFAULT_E2E_EMAIL);
await page.click('button[type="submit"]');
await page.evaluate((t) => {
localStorage.setItem("multica_token", t);
}, token);
await page.goto("/issues");
await page.waitForURL("**/issues", { timeout: 10000 });
}

View file

@ -24,6 +24,8 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "catalog:",
"@types/pg": "^8.20.0",
"pg": "^8.20.0",
"turbo": "^2.5.0",
"typescript": "catalog:"
}

View file

@ -23,6 +23,9 @@ import type {
CreateSkillRequest,
UpdateSkillRequest,
SetAgentSkillsRequest,
PersonalAccessToken,
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
} from "@multica/types";
import { type SDKLogger, noopLogger } from "./logger";
@ -36,6 +39,7 @@ export class ApiClient {
private token: string | null = null;
private workspaceId: string | null = null;
private logger: SDKLogger;
private onUnauthorized: (() => void) | null = null;
constructor(baseUrl: string, options?: { logger?: SDKLogger }) {
this.baseUrl = baseUrl;
@ -50,6 +54,10 @@ export class ApiClient {
this.workspaceId = id;
}
setOnUnauthorized(callback: (() => void) | null) {
this.onUnauthorized = callback;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = crypto.randomUUID().slice(0, 8);
const start = Date.now();
@ -75,6 +83,9 @@ export class ApiClient {
});
if (!res.ok) {
if (res.status === 401 && this.onUnauthorized) {
this.onUnauthorized();
}
let message = `API error: ${res.status} ${res.statusText}`;
try {
const data = await res.json() as { error?: string };
@ -99,10 +110,17 @@ export class ApiClient {
}
// Auth
async login(email: string, name?: string): Promise<LoginResponse> {
return this.fetch("/auth/login", {
async sendCode(email: string): Promise<{ message: string }> {
return this.fetch("/auth/send-code", {
method: "POST",
body: JSON.stringify({ email, name }),
body: JSON.stringify({ email }),
});
}
async verifyCode(email: string, code: string): Promise<LoginResponse> {
return this.fetch("/auth/verify-code", {
method: "POST",
body: JSON.stringify({ email, code }),
});
}
@ -362,4 +380,20 @@ export class ApiClient {
body: JSON.stringify(data),
});
}
// Personal Access Tokens
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
return this.fetch("/api/tokens");
}
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
return this.fetch("/api/tokens", {
method: "POST",
body: JSON.stringify(data),
});
}
async revokePersonalAccessToken(id: string): Promise<void> {
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
}
}

View file

@ -55,6 +55,25 @@ export interface UpdateMemberRequest {
role: MemberRole;
}
// Personal Access Tokens
export interface PersonalAccessToken {
id: string;
name: string;
token_prefix: string;
expires_at: string | null;
last_used_at: string | null;
created_at: string;
}
export interface CreatePersonalAccessTokenRequest {
name: string;
expires_in_days?: number;
}
export interface CreatePersonalAccessTokenResponse extends PersonalAccessToken {
token: string;
}
// Pagination
export interface PaginationParams {
limit?: number;

122
pnpm-lock.yaml generated
View file

@ -54,6 +54,12 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 25.5.0
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
pg:
specifier: ^8.20.0
version: 8.20.0
turbo:
specifier: ^2.5.0
version: 2.8.20
@ -1451,6 +1457,9 @@ packages:
'@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@ -2864,6 +2873,40 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -2901,6 +2944,22 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
@ -3200,6 +3259,10 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@ -3613,6 +3676,10 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -4716,6 +4783,12 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/pg@8.20.0':
dependencies:
'@types/node': 25.5.0
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@ -6272,6 +6345,41 @@ snapshots:
pathe@2.0.3: {}
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {}
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
pg-protocol@1.13.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
pg-pool: 3.13.0(pg@8.20.0)
pg-protocol: 1.13.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@ -6305,6 +6413,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
powershell-utils@0.1.0: {}
pretty-format@27.5.1:
@ -6744,6 +6862,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
split2@4.2.0: {}
stackback@0.0.2: {}
statuses@2.0.2: {}
@ -7108,6 +7228,8 @@ snapshots:
xmlchars@2.2.0: {}
xtend@4.0.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}

View file

@ -57,12 +57,13 @@ func init() {
func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
serverURL := resolveServerURL(cmd)
workspaceID := resolveWorkspaceID(cmd)
token := resolveToken()
if serverURL == "" {
return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url <url>'")
}
return cli.NewAPIClient(serverURL, workspaceID), nil
return cli.NewAPIClient(serverURL, workspaceID, token), nil
}
func resolveServerURL(cmd *cobra.Command) string {

View file

@ -0,0 +1,140 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage authentication",
}
var authLoginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate with a personal access token",
RunE: runAuthLogin,
}
var authStatusCmd = &cobra.Command{
Use: "status",
Short: "Show current authentication status",
RunE: runAuthStatus,
}
var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove stored authentication token",
RunE: runAuthLogout,
}
func init() {
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authStatusCmd)
authCmd.AddCommand(authLogoutCmd)
}
func resolveToken() string {
if v := strings.TrimSpace(os.Getenv("MULTICA_TOKEN")); v != "" {
return v
}
cfg, _ := cli.LoadCLIConfig()
return cfg.Token
}
func runAuthLogin(cmd *cobra.Command, _ []string) error {
fmt.Print("Enter your personal access token: ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return fmt.Errorf("no input")
}
token := strings.TrimSpace(scanner.Text())
if token == "" {
return fmt.Errorf("token is required")
}
if !strings.HasPrefix(token, "mul_") {
return fmt.Errorf("invalid token format: must start with mul_")
}
serverURL := resolveServerURL(cmd)
client := cli.NewAPIClient(serverURL, "", token)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var me struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := client.GetJSON(ctx, "/api/me", &me); err != nil {
return fmt.Errorf("invalid token: %w", err)
}
cfg, _ := cli.LoadCLIConfig()
cfg.Token = token
if cfg.ServerURL == "" {
cfg.ServerURL = serverURL
}
if err := cli.SaveCLIConfig(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Fprintf(os.Stderr, "Authenticated as %s (%s)\nToken saved to config.\n", me.Name, me.Email)
return nil
}
func runAuthStatus(cmd *cobra.Command, _ []string) error {
token := resolveToken()
serverURL := resolveServerURL(cmd)
if token == "" {
fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica auth login' to authenticate.")
return nil
}
client := cli.NewAPIClient(serverURL, "", token)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var me struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := client.GetJSON(ctx, "/api/me", &me); err != nil {
fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica auth login' to re-authenticate.\n", err)
return nil
}
prefix := token
if len(prefix) > 12 {
prefix = prefix[:12] + "..."
}
fmt.Fprintf(os.Stderr, "Server: %s\nUser: %s (%s)\nToken: %s\n", serverURL, me.Name, me.Email, prefix)
return nil
}
func runAuthLogout(_ *cobra.Command, _ []string) error {
cfg, _ := cli.LoadCLIConfig()
if cfg.Token == "" {
fmt.Fprintln(os.Stderr, "Not authenticated.")
return nil
}
cfg.Token = ""
if err := cli.SaveCLIConfig(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Fprintln(os.Stderr, "Token removed. You are now logged out.")
return nil
}

View file

@ -24,6 +24,7 @@ func init() {
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(daemonCmd)
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(runtimeCmd)

View file

@ -71,28 +71,14 @@ func TestMain(m *testing.M) {
router := NewRouter(pool, hub, bus)
testServer = httptest.NewServer(router)
// Login to get a real JWT token
loginBody, _ := json.Marshal(map[string]string{
"email": integrationTestEmail,
"name": integrationTestName,
})
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(loginBody))
// Generate a JWT token directly for the test user
testToken, err = generateTestJWT(testUserID, integrationTestEmail, integrationTestName)
if err != nil {
fmt.Printf("Skipping: login failed: %v\n", err)
fmt.Printf("Failed to generate test JWT: %v\n", err)
testServer.Close()
pool.Close()
os.Exit(0)
os.Exit(1)
}
defer resp.Body.Close()
var loginResp struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
} `json:"user"`
}
json.NewDecoder(resp.Body).Decode(&loginResp)
testToken = loginResp.Token
code := m.Run()
@ -202,6 +188,17 @@ func readJSON(t *testing.T, resp *http.Response, v any) {
}
}
func generateTestJWT(userID, email, name string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"email": email,
"name": name,
"exp": time.Now().Add(72 * time.Hour).Unix(),
"iat": time.Now().Unix(),
})
return token.SignedString(jwtSecret)
}
// ---- Health ----
func TestHealth(t *testing.T) {
@ -224,27 +221,65 @@ func TestHealth(t *testing.T) {
// ---- Auth ----
func TestLoginAndGetMe(t *testing.T) {
// Login
body, _ := json.Marshal(map[string]string{
"email": "integration-test@multica.ai",
"name": "Integration Tester",
func TestSendCodeAndVerify(t *testing.T) {
const email = "integration-sendcode@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
var userID string
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
if err == nil {
rows, queryErr := testPool.Query(ctx, `
SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1
`, userID)
if queryErr == nil {
defer rows.Close()
for rows.Next() {
var wsID string
if rows.Scan(&wsID) == nil {
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID)
}
}
}
}
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
// Step 1: Send code
body, _ := json.Marshal(map[string]string{"email": email})
resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login failed: %v", err)
t.Fatalf("send-code failed: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("send-code: expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Read code from DB
var code string
err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code)
if err != nil {
t.Fatalf("failed to read code from DB: %v", err)
}
// Step 2: Verify code
body, _ = json.Marshal(map[string]string{"email": email, "code": code})
resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("verify-code failed: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("verify-code: expected 200, got %d: %s", resp.StatusCode, respBody)
}
var loginResp struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
} `json:"user"`
}
readJSON(t, resp, &loginResp)
@ -252,83 +287,81 @@ func TestLoginAndGetMe(t *testing.T) {
if loginResp.Token == "" {
t.Fatal("expected non-empty token")
}
if loginResp.User.Email != "integration-test@multica.ai" {
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", loginResp.User.Email)
if loginResp.User.Email != email {
t.Fatalf("expected email '%s', got '%s'", email, loginResp.User.Email)
}
// Use token to call /api/me
// Verify the token works with /api/me
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
meResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("getMe failed: %v", err)
}
if meResp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", meResp.StatusCode)
}
var me struct {
Email string `json:"email"`
Name string `json:"name"`
}
readJSON(t, meResp, &me)
if me.Email != "integration-test@multica.ai" {
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", me.Email)
t.Fatalf("getMe: expected 200, got %d", meResp.StatusCode)
}
meResp.Body.Close()
}
func TestLoginCreatesWorkspaceForNewUser(t *testing.T) {
const email = "new-integration-login@multica.ai"
func TestVerifyCodeCreatesWorkspaceForNewUser(t *testing.T) {
const email = "new-integration-verify@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
var userID string
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
if err == nil {
rows, queryErr := testPool.Query(ctx, `
SELECT w.id
FROM workspace w
JOIN member m ON m.workspace_id = w.id
WHERE m.user_id = $1
SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1
`, userID)
if queryErr == nil {
defer rows.Close()
for rows.Next() {
var workspaceID string
if scanErr := rows.Scan(&workspaceID); scanErr == nil {
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, workspaceID)
var wsID string
if rows.Scan(&wsID) == nil {
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID)
}
}
}
}
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
body, _ := json.Marshal(map[string]string{
"email": email,
"name": "Jiayuan",
})
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
// Send code
body, _ := json.Marshal(map[string]string{"email": email})
resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login failed: %v", err)
t.Fatalf("send-code failed: %v", err)
}
defer resp.Body.Close()
resp.Body.Close()
// Read code from DB
var code string
err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code)
if err != nil {
t.Fatalf("failed to read code from DB: %v", err)
}
// Verify code
body, _ = json.Marshal(map[string]string{"email": email, "code": code})
resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("verify-code failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
t.Fatalf("verify-code: expected 200, got %d", resp.StatusCode)
}
var loginResp struct {
Token string `json:"token"`
}
readJSON(t, resp, &loginResp)
if loginResp.Token == "" {
t.Fatal("expected non-empty token")
}
// Check workspace was created
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
workspacesResp, err := http.DefaultClient.Do(req)
@ -350,11 +383,8 @@ func TestLoginCreatesWorkspaceForNewUser(t *testing.T) {
if len(workspaces) != 1 {
t.Fatalf("expected 1 workspace, got %d", len(workspaces))
}
if workspaces[0].Name != "Jiayuan's Workspace" {
t.Fatalf("expected default workspace name %q, got %q", "Jiayuan's Workspace", workspaces[0].Name)
}
if workspaces[0].Slug == "" {
t.Fatal("expected non-empty workspace slug")
if !strings.Contains(workspaces[0].Name, "Workspace") {
t.Fatalf("expected workspace name containing 'Workspace', got %q", workspaces[0].Name)
}
}

View file

@ -16,6 +16,7 @@ import (
"github.com/multica-ai/multica/server/internal/handler"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@ -45,7 +46,8 @@ func allowedOrigins() []string {
// NewRouter creates the fully-configured Chi router with all middleware and routes.
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
queries := db.New(pool)
h := handler.New(queries, pool, hub, bus)
emailSvc := service.NewEmailService()
h := handler.New(queries, pool, hub, bus, emailSvc)
r := chi.NewRouter()
@ -74,7 +76,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
})
// Auth (public)
r.Post("/auth/login", h.Login)
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)
// Daemon API routes (no user auth; daemon auth deferred to later)
r.Route("/api/daemon", func(r chi.Router) {
@ -96,7 +99,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
// Protected API routes
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
r.Use(middleware.Auth(queries))
// Auth
r.Get("/api/me", h.GetMe)
@ -155,6 +158,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession)
// Personal Access Tokens
r.Route("/api/tokens", func(r chi.Router) {
r.Get("/", h.ListPersonalAccessTokens)
r.Post("/", h.CreatePersonalAccessToken)
r.Delete("/{id}", h.RevokePersonalAccessToken)
})
// Inbox
r.Route("/api/inbox", func(r chi.Router) {
r.Get("/", h.ListInbox)

View file

@ -8,6 +8,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.8.0
github.com/resend/resend-go/v2 v2.28.0
github.com/spf13/cobra v1.10.2
)

View file

@ -24,6 +24,8 @@ github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4=
github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=

View file

@ -1,6 +1,10 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"sync"
)
@ -23,3 +27,18 @@ func JWTSecret() []byte {
return jwtSecret
}
// GeneratePATToken creates a new personal access token: "mul_" + 40 random hex chars.
func GeneratePATToken() (string, error) {
b := make([]byte, 20) // 20 bytes = 40 hex chars
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate PAT token: %w", err)
}
return "mul_" + hex.EncodeToString(b), nil
}
// HashToken returns the hex-encoded SHA-256 hash of a token string.
func HashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}

View file

@ -16,27 +16,36 @@ import (
type APIClient struct {
BaseURL string
WorkspaceID string
Token string
HTTPClient *http.Client
}
// NewAPIClient creates a new API client for ctrl commands.
func NewAPIClient(baseURL, workspaceID string) *APIClient {
func NewAPIClient(baseURL, workspaceID, token string) *APIClient {
return &APIClient{
BaseURL: strings.TrimRight(baseURL, "/"),
WorkspaceID: workspaceID,
Token: token,
HTTPClient: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *APIClient) setHeaders(req *http.Request) {
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
}
// GetJSON performs a GET request and decodes the JSON response.
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return err
}
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
@ -60,9 +69,7 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
if err != nil {
return err
}
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
@ -89,9 +96,7 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any)
return err
}
req.Header.Set("Content-Type", "application/json")
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {

View file

@ -14,6 +14,7 @@ const defaultCLIConfigPath = ".multica/config.json"
type CLIConfig struct {
ServerURL string `json:"server_url,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
Token string `json:"token,omitempty"`
}
// CLIConfigPath returns the default path for the CLI config file.

View file

@ -2,9 +2,14 @@ package handler
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/binary"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
@ -35,16 +40,20 @@ func userToResponse(u db.User) UserResponse {
}
}
type LoginRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
type SendCodeRequest struct {
Email string `json:"email"`
}
type VerifyCodeRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
func defaultWorkspaceName(user db.User) string {
name := strings.TrimSpace(user.Name)
if name == "" {
@ -150,63 +159,16 @@ func (h *Handler) ensureUserWorkspace(ctx context.Context, user db.User) error {
return tx.Commit(ctx)
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
func generateCode() (string, error) {
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
n := binary.BigEndian.Uint32(buf[:]) % 1000000
return fmt.Sprintf("%06d", n), nil
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Name = strings.TrimSpace(req.Name)
if req.Email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
// Try to find existing user
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
if err != nil {
if !isNotFound(err) {
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
// Create new user
name := req.Name
if name == "" {
name = req.Email
}
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
Name: name,
Email: req.Email,
})
if err != nil {
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
slog.Info("new user created", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
} else if req.Name != "" && req.Name != user.Name {
user, err = h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
ID: user.ID,
Name: req.Name,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update user")
return
}
}
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
return
}
// Generate JWT
func (h *Handler) issueJWT(user db.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": uuidToString(user.ID),
"email": user.Email,
@ -214,8 +176,122 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
"exp": time.Now().Add(72 * time.Hour).Unix(),
"iat": time.Now().Unix(),
})
return token.SignedString(auth.JWTSecret())
}
tokenString, err := token.SignedString(auth.JWTSecret())
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
user, err := h.Queries.GetUserByEmail(ctx, email)
if err != nil {
if !isNotFound(err) {
return db.User{}, err
}
name := email
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
}
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
Name: name,
Email: email,
})
if err != nil {
return db.User{}, err
}
}
return user, nil
}
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
var req SendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
// Rate limit: max 1 code per 60 seconds per email
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
return
}
code, err := generateCode()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate code")
return
}
_, err = h.Queries.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
Email: email,
Code: code,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(10 * time.Minute), Valid: true},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to store verification code")
return
}
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
writeError(w, http.StatusInternalServerError, "failed to send verification code")
return
}
// Best-effort cleanup of expired codes
_ = h.Queries.DeleteExpiredVerificationCodes(r.Context())
writeJSON(w, http.StatusOK, map[string]string{"message": "Verification code sent"})
}
func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
var req VerifyCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
code := strings.TrimSpace(req.Code)
if email == "" || code == "" {
writeError(w, http.StatusBadRequest, "email and code are required")
return
}
dbCode, err := h.Queries.GetLatestVerificationCode(r.Context(), email)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid or expired code")
return
}
isMasterCode := code == "888888" && os.Getenv("APP_ENV") != "production"
if !isMasterCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 {
_ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID)
writeError(w, http.StatusBadRequest, "invalid or expired code")
return
}
if err := h.Queries.MarkVerificationCodeUsed(r.Context(), dbCode.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to verify code")
return
}
user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
return
}
tokenString, err := h.issueJWT(user)
if err != nil {
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
writeError(w, http.StatusInternalServerError, "failed to generate token")

View file

@ -27,27 +27,29 @@ type dbExecutor interface {
}
type Handler struct {
Queries *db.Queries
DB dbExecutor
TxStarter txStarter
Hub *realtime.Hub
Bus *events.Bus
TaskService *service.TaskService
Queries *db.Queries
DB dbExecutor
TxStarter txStarter
Hub *realtime.Hub
Bus *events.Bus
TaskService *service.TaskService
EmailService *service.EmailService
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus) *Handler {
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler {
var executor dbExecutor
if candidate, ok := txStarter.(dbExecutor); ok {
executor = candidate
}
return &Handler{
Queries: queries,
DB: executor,
TxStarter: txStarter,
Hub: hub,
Bus: bus,
TaskService: service.NewTaskService(queries, hub, bus),
Queries: queries,
DB: executor,
TxStarter: txStarter,
Hub: hub,
Bus: bus,
TaskService: service.NewTaskService(queries, hub, bus),
EmailService: emailService,
}
}

View file

@ -15,6 +15,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@ -51,7 +52,8 @@ func TestMain(m *testing.M) {
hub := realtime.NewHub()
go hub.Run()
bus := events.New()
testHandler = New(queries, pool, hub, bus)
emailSvc := service.NewEmailService()
testHandler = New(queries, pool, hub, bus, emailSvc)
testPool = pool
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
@ -360,33 +362,65 @@ func TestWorkspaceCRUD(t *testing.T) {
}
}
func TestAuthLogin(t *testing.T) {
func TestSendCode(t *testing.T) {
w := httptest.NewRecorder()
body := map[string]string{"email": "test-handler@multica.ai", "name": "Test User"}
body := map[string]string{"email": "sendcode-test@multica.ai"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/login", &buf)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.Login(w, req)
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp LoginResponse
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp.Token == "" {
t.Fatal("Login: expected non-empty token")
if resp["message"] == "" {
t.Fatal("SendCode: expected non-empty message")
}
if resp.User.Email != "test-handler@multica.ai" {
t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, "sendcode-test@multica.ai")
})
}
func TestSendCodeRateLimit(t *testing.T) {
const email = "ratelimit-test@multica.ai"
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, email)
})
// First request should succeed
w := httptest.NewRecorder()
body := map[string]string{"email": email}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode (first): expected 200, got %d: %s", w.Code, w.Body.String())
}
// Second request within 60s should be rate limited
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(body)
req = httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("SendCode (second): expected 429, got %d: %s", w.Code, w.Body.String())
}
}
func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
const email = "new-handler-login@multica.ai"
func TestVerifyCode(t *testing.T) {
const email = "verify-test@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err == nil {
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
@ -396,21 +430,166 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
}
}
}
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
// Send code first
w := httptest.NewRecorder()
body := map[string]string{"email": email, "name": "Workspace Owner"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/login", &buf)
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.Login(w, req)
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Read code from DB
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Verify with correct code
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp LoginResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Token == "" {
t.Fatal("VerifyCode: expected non-empty token")
}
if resp.User.Email != email {
t.Fatalf("VerifyCode: expected email '%s', got '%s'", email, resp.User.Email)
}
}
func TestVerifyCodeWrongCode(t *testing.T) {
const email = "wrong-code-test@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
// Verify with wrong code
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("VerifyCode (wrong code): expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeBruteForceProtection(t *testing.T) {
const email = "bruteforce-test@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Read actual code so we can try it after lockout
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Exhaust all 5 attempts with wrong codes
for i := 0; i < 5; i++ {
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("attempt %d: expected 400, got %d", i+1, w.Code)
}
}
// Now even the correct code should be rejected (code is locked out)
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("after lockout: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeCreatesWorkspace(t *testing.T) {
const email = "workspace-verify-test@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err == nil {
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if listErr == nil {
for _, workspace := range workspaces {
_ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID)
}
}
}
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
// Read code from DB
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Verify
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
@ -428,9 +607,6 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
if !strings.Contains(workspaces[0].Name, "Workspace") {
t.Fatalf("expected auto-created workspace name, got %q", workspaces[0].Name)
}
if workspaces[0].Slug == "" {
t.Fatal("expected auto-created workspace slug")
}
}
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {

View file

@ -0,0 +1,132 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/auth"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type PersonalAccessTokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"token_prefix"`
ExpiresAt *string `json:"expires_at"`
LastUsedAt *string `json:"last_used_at"`
CreatedAt string `json:"created_at"`
}
type CreatePATResponse struct {
PersonalAccessTokenResponse
Token string `json:"token"`
}
func patToResponse(pat db.PersonalAccessToken) PersonalAccessTokenResponse {
return PersonalAccessTokenResponse{
ID: uuidToString(pat.ID),
Name: pat.Name,
Prefix: pat.TokenPrefix,
ExpiresAt: timestampToPtr(pat.ExpiresAt),
LastUsedAt: timestampToPtr(pat.LastUsedAt),
CreatedAt: timestampToString(pat.CreatedAt),
}
}
type CreatePATRequest struct {
Name string `json:"name"`
ExpiresInDays *int `json:"expires_in_days"`
}
func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreatePATRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
rawToken, err := auth.GeneratePATToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
var expiresAt pgtype.Timestamptz
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
expiresAt = pgtype.Timestamptz{
Time: time.Now().Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour),
Valid: true,
}
}
prefix := rawToken
if len(prefix) > 12 {
prefix = prefix[:12]
}
pat, err := h.Queries.CreatePersonalAccessToken(r.Context(), db.CreatePersonalAccessTokenParams{
UserID: parseUUID(userID),
Name: req.Name,
TokenHash: auth.HashToken(rawToken),
TokenPrefix: prefix,
ExpiresAt: expiresAt,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create token")
return
}
writeJSON(w, http.StatusCreated, CreatePATResponse{
PersonalAccessTokenResponse: patToResponse(pat),
Token: rawToken,
})
}
func (h *Handler) ListPersonalAccessTokens(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
pats, err := h.Queries.ListPersonalAccessTokensByUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list tokens")
return
}
resp := make([]PersonalAccessTokenResponse, len(pats))
for i, pat := range pats {
resp[i] = patToResponse(pat)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) RevokePersonalAccessToken(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
id := chi.URLParam(r, "id")
if err := h.Queries.RevokePersonalAccessToken(r.Context(), db.RevokePersonalAccessTokenParams{
ID: parseUUID(id),
UserID: parseUUID(userID),
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke token")
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -1,62 +1,94 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Auth middleware validates JWT tokens from the Authorization header.
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
// Auth middleware validates JWT tokens or Personal Access Tokens from the Authorization header.
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
slog.Debug("auth: invalid format", "path", r.URL.Path)
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
func Auth(queries *db.Queries) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
return auth.JWTSecret(), nil
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
slog.Debug("auth: invalid format", "path", r.URL.Path)
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
// PAT: tokens starting with "mul_"
if strings.HasPrefix(tokenString, "mul_") {
if queries == nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
hash := auth.HashToken(tokenString)
pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash)
if err != nil {
slog.Warn("auth: invalid PAT", "path", r.URL.Path, "error", err)
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
r.Header.Set("X-User-ID", uuidToString(pat.UserID))
// Best-effort: update last_used_at
go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
next.ServeHTTP(w, r)
return
}
// JWT
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return auth.JWTSecret(), nil
})
if err != nil || !token.Valid {
slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err)
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
slog.Warn("auth: invalid claims", "path", r.URL.Path)
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
return
}
sub, ok := claims["sub"].(string)
if !ok || strings.TrimSpace(sub) == "" {
slog.Warn("auth: invalid claims", "path", r.URL.Path)
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
return
}
r.Header.Set("X-User-ID", sub)
if email, ok := claims["email"].(string); ok {
r.Header.Set("X-User-Email", email)
}
next.ServeHTTP(w, r)
})
if err != nil || !token.Valid {
slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err)
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
slog.Warn("auth: invalid claims", "path", r.URL.Path)
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
return
}
sub, ok := claims["sub"].(string)
if !ok || strings.TrimSpace(sub) == "" {
slog.Warn("auth: invalid claims", "path", r.URL.Path)
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
return
}
r.Header.Set("X-User-ID", sub)
if email, ok := claims["email"].(string); ok {
r.Header.Set("X-User-Email", email)
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -24,8 +24,13 @@ func validClaims() jwt.MapClaims {
}
}
// authMiddleware returns the Auth middleware with nil queries (JWT-only tests).
func authMiddleware(next http.Handler) http.Handler {
return Auth(nil)(next)
}
func TestAuth_MissingHeader(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
@ -42,7 +47,7 @@ func TestAuth_MissingHeader(t *testing.T) {
}
func TestAuth_NoBearerPrefix(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
@ -60,7 +65,7 @@ func TestAuth_NoBearerPrefix(t *testing.T) {
}
func TestAuth_InvalidToken(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
@ -75,7 +80,7 @@ func TestAuth_InvalidToken(t *testing.T) {
}
func TestAuth_ExpiredToken(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
@ -94,7 +99,7 @@ func TestAuth_ExpiredToken(t *testing.T) {
}
func TestAuth_WrongSecret(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
@ -111,7 +116,7 @@ func TestAuth_WrongSecret(t *testing.T) {
}
func TestAuth_WrongSigningMethod(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
@ -131,7 +136,7 @@ func TestAuth_WrongSigningMethod(t *testing.T) {
func TestAuth_ValidToken(t *testing.T) {
var gotUserID, gotEmail string
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID = r.Header.Get("X-User-ID")
gotEmail = r.Header.Get("X-User-Email")
w.WriteHeader(http.StatusOK)
@ -156,7 +161,7 @@ func TestAuth_ValidToken(t *testing.T) {
}
func TestAuth_MissingClaims(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
@ -175,3 +180,18 @@ func TestAuth_MissingClaims(t *testing.T) {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestAuth_InvalidPAT(t *testing.T) {
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer mul_invalid_token_here")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}

View file

@ -0,0 +1,54 @@
package service
import (
"fmt"
"os"
"github.com/resend/resend-go/v2"
)
type EmailService struct {
client *resend.Client
fromEmail string
}
func NewEmailService() *EmailService {
apiKey := os.Getenv("RESEND_API_KEY")
from := os.Getenv("RESEND_FROM_EMAIL")
if from == "" {
from = "noreply@multica.ai"
}
var client *resend.Client
if apiKey != "" {
client = resend.NewClient(apiKey)
}
return &EmailService{
client: client,
fromEmail: from,
}
}
func (s *EmailService) SendVerificationCode(to, code string) error {
if s.client == nil {
fmt.Printf("[DEV] Verification code for %s: %s\n", to, code)
return nil
}
params := &resend.SendEmailRequest{
From: s.fromEmail,
To: []string{to},
Subject: "Your Multica verification code",
Html: fmt.Sprintf(
`<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
<h2>Your verification code</h2>
<p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; margin: 24px 0;">%s</p>
<p>This code expires in 10 minutes.</p>
<p style="color: #666; font-size: 14px;">If you didn't request this code, you can safely ignore this email.</p>
</div>`, code),
}
_, err := s.client.Emails.Send(params)
return err
}

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS verification_code;

View file

@ -0,0 +1,10 @@
CREATE TABLE verification_code (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_verification_code_email ON verification_code(email, used, expires_at);

View file

@ -0,0 +1 @@
ALTER TABLE verification_code DROP COLUMN attempts;

View file

@ -0,0 +1 @@
ALTER TABLE verification_code ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS personal_access_token;

View file

@ -0,0 +1,14 @@
CREATE TABLE personal_access_token (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_pat_user ON personal_access_token(user_id, revoked);
CREATE UNIQUE INDEX idx_pat_token_hash ON personal_access_token(token_hash);

View file

@ -179,6 +179,18 @@ type Member struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type PersonalAccessToken struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
TokenHash string `json:"token_hash"`
TokenPrefix string `json:"token_prefix"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
Revoked bool `json:"revoked"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Skill struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
@ -209,6 +221,16 @@ type User struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type VerificationCode struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
Code string `json:"code"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Used bool `json:"used"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Attempts int32 `json:"attempts"`
}
type Workspace struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`

View file

@ -0,0 +1,137 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: personal_access_token.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createPersonalAccessToken = `-- name: CreatePersonalAccessToken :one
INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at
`
type CreatePersonalAccessTokenParams struct {
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
TokenHash string `json:"token_hash"`
TokenPrefix string `json:"token_prefix"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreatePersonalAccessToken(ctx context.Context, arg CreatePersonalAccessTokenParams) (PersonalAccessToken, error) {
row := q.db.QueryRow(ctx, createPersonalAccessToken,
arg.UserID,
arg.Name,
arg.TokenHash,
arg.TokenPrefix,
arg.ExpiresAt,
)
var i PersonalAccessToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.TokenHash,
&i.TokenPrefix,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
&i.CreatedAt,
)
return i, err
}
const getPersonalAccessTokenByHash = `-- name: GetPersonalAccessTokenByHash :one
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
WHERE token_hash = $1
AND revoked = FALSE
AND (expires_at IS NULL OR expires_at > now())
`
func (q *Queries) GetPersonalAccessTokenByHash(ctx context.Context, tokenHash string) (PersonalAccessToken, error) {
row := q.db.QueryRow(ctx, getPersonalAccessTokenByHash, tokenHash)
var i PersonalAccessToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.TokenHash,
&i.TokenPrefix,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
&i.CreatedAt,
)
return i, err
}
const listPersonalAccessTokensByUser = `-- name: ListPersonalAccessTokensByUser :many
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
WHERE user_id = $1
AND revoked = FALSE
ORDER BY created_at DESC
`
func (q *Queries) ListPersonalAccessTokensByUser(ctx context.Context, userID pgtype.UUID) ([]PersonalAccessToken, error) {
rows, err := q.db.Query(ctx, listPersonalAccessTokensByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PersonalAccessToken{}
for rows.Next() {
var i PersonalAccessToken
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.TokenHash,
&i.TokenPrefix,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokePersonalAccessToken = `-- name: RevokePersonalAccessToken :exec
UPDATE personal_access_token
SET revoked = TRUE
WHERE id = $1 AND user_id = $2
`
type RevokePersonalAccessTokenParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) RevokePersonalAccessToken(ctx context.Context, arg RevokePersonalAccessTokenParams) error {
_, err := q.db.Exec(ctx, revokePersonalAccessToken, arg.ID, arg.UserID)
return err
}
const updatePersonalAccessTokenLastUsed = `-- name: UpdatePersonalAccessTokenLastUsed :exec
UPDATE personal_access_token
SET last_used_at = now()
WHERE id = $1
`
func (q *Queries) UpdatePersonalAccessTokenLastUsed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updatePersonalAccessTokenLastUsed, id)
return err
}

View file

@ -0,0 +1,118 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: verification_code.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createVerificationCode = `-- name: CreateVerificationCode :one
INSERT INTO verification_code (email, code, expires_at)
VALUES ($1, $2, $3)
RETURNING id, email, code, expires_at, used, created_at, attempts
`
type CreateVerificationCodeParams struct {
Email string `json:"email"`
Code string `json:"code"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreateVerificationCode(ctx context.Context, arg CreateVerificationCodeParams) (VerificationCode, error) {
row := q.db.QueryRow(ctx, createVerificationCode, arg.Email, arg.Code, arg.ExpiresAt)
var i VerificationCode
err := row.Scan(
&i.ID,
&i.Email,
&i.Code,
&i.ExpiresAt,
&i.Used,
&i.CreatedAt,
&i.Attempts,
)
return i, err
}
const deleteExpiredVerificationCodes = `-- name: DeleteExpiredVerificationCodes :exec
DELETE FROM verification_code
WHERE expires_at < now() - interval '1 hour'
`
func (q *Queries) DeleteExpiredVerificationCodes(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteExpiredVerificationCodes)
return err
}
const getLatestCodeByEmail = `-- name: GetLatestCodeByEmail :one
SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code
WHERE email = $1
ORDER BY created_at DESC
LIMIT 1
`
func (q *Queries) GetLatestCodeByEmail(ctx context.Context, email string) (VerificationCode, error) {
row := q.db.QueryRow(ctx, getLatestCodeByEmail, email)
var i VerificationCode
err := row.Scan(
&i.ID,
&i.Email,
&i.Code,
&i.ExpiresAt,
&i.Used,
&i.CreatedAt,
&i.Attempts,
)
return i, err
}
const getLatestVerificationCode = `-- name: GetLatestVerificationCode :one
SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code
WHERE email = $1
AND used = FALSE
AND expires_at > now()
AND attempts < 5
ORDER BY created_at DESC
LIMIT 1
`
func (q *Queries) GetLatestVerificationCode(ctx context.Context, email string) (VerificationCode, error) {
row := q.db.QueryRow(ctx, getLatestVerificationCode, email)
var i VerificationCode
err := row.Scan(
&i.ID,
&i.Email,
&i.Code,
&i.ExpiresAt,
&i.Used,
&i.CreatedAt,
&i.Attempts,
)
return i, err
}
const incrementVerificationCodeAttempts = `-- name: IncrementVerificationCodeAttempts :exec
UPDATE verification_code
SET attempts = attempts + 1
WHERE id = $1
`
func (q *Queries) IncrementVerificationCodeAttempts(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, incrementVerificationCodeAttempts, id)
return err
}
const markVerificationCodeUsed = `-- name: MarkVerificationCodeUsed :exec
UPDATE verification_code
SET used = TRUE
WHERE id = $1
`
func (q *Queries) MarkVerificationCodeUsed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markVerificationCodeUsed, id)
return err
}

View file

@ -0,0 +1,26 @@
-- name: CreatePersonalAccessToken :one
INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetPersonalAccessTokenByHash :one
SELECT * FROM personal_access_token
WHERE token_hash = $1
AND revoked = FALSE
AND (expires_at IS NULL OR expires_at > now());
-- name: ListPersonalAccessTokensByUser :many
SELECT * FROM personal_access_token
WHERE user_id = $1
AND revoked = FALSE
ORDER BY created_at DESC;
-- name: RevokePersonalAccessToken :exec
UPDATE personal_access_token
SET revoked = TRUE
WHERE id = $1 AND user_id = $2;
-- name: UpdatePersonalAccessTokenLastUsed :exec
UPDATE personal_access_token
SET last_used_at = now()
WHERE id = $1;

View file

@ -0,0 +1,33 @@
-- name: CreateVerificationCode :one
INSERT INTO verification_code (email, code, expires_at)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetLatestVerificationCode :one
SELECT * FROM verification_code
WHERE email = $1
AND used = FALSE
AND expires_at > now()
AND attempts < 5
ORDER BY created_at DESC
LIMIT 1;
-- name: MarkVerificationCodeUsed :exec
UPDATE verification_code
SET used = TRUE
WHERE id = $1;
-- name: IncrementVerificationCodeAttempts :exec
UPDATE verification_code
SET attempts = attempts + 1
WHERE id = $1;
-- name: GetLatestCodeByEmail :one
SELECT * FROM verification_code
WHERE email = $1
ORDER BY created_at DESC
LIMIT 1;
-- name: DeleteExpiredVerificationCodes :exec
DELETE FROM verification_code
WHERE expires_at < now() - interval '1 hour';