From b5674869ed368afbfc60fb95d134b4680b98febb Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 31 Mar 2026 16:13:58 +0800 Subject: [PATCH] fix(auth): enforce auth on daemon API routes (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): enforce auth middleware and workspace membership on daemon API routes Daemon routes were registered without the Auth middleware, meaning the server accepted unauthenticated requests to register runtimes, claim tasks, etc. The daemon client already sends a Bearer token — the server just wasn't validating it. - Split /api/daemon routes: pairing-session endpoints stay public (used before the daemon has a token), all others now require Auth middleware - Add workspace membership check in DaemonRegister so only workspace members can register runtimes - Update test to include X-User-ID header matching the new auth requirement Closes MUL-90 Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(daemon): remove dead pairing-session feature The daemon pairing flow was never completed — the daemon authenticates via CLI config token, not pairing sessions. Remove all related code: - Delete daemon_pairing.go handler (4 unused handlers) - Remove pairing routes from router.go (3 public + 1 protected) - Delete /pair/local page + test from frontend - Remove DaemonPairingSession types and API client methods - Add migration 029 to drop daemon_pairing_session table - Update LOCAL_DEVELOPMENT.md to reflect actual auth flow Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- LOCAL_DEVELOPMENT.md | 14 +- apps/web/app/pair/local/page.test.tsx | 107 ----- apps/web/app/pair/local/page.tsx | 181 -------- apps/web/shared/api/client.ts | 16 - apps/web/shared/types/daemon.ts | 22 - apps/web/shared/types/index.ts | 1 - server/cmd/server/router.go | 8 +- server/internal/handler/daemon.go | 6 + server/internal/handler/daemon_pairing.go | 386 ------------------ server/internal/handler/handler_test.go | 1 + .../029_drop_daemon_pairing.down.sql | 21 + .../migrations/029_drop_daemon_pairing.up.sql | 1 + 12 files changed, 33 insertions(+), 731 deletions(-) delete mode 100644 apps/web/app/pair/local/page.test.tsx delete mode 100644 apps/web/app/pair/local/page.tsx delete mode 100644 apps/web/shared/types/daemon.ts delete mode 100644 server/internal/handler/daemon_pairing.go create mode 100644 server/migrations/029_drop_daemon_pairing.down.sql create mode 100644 server/migrations/029_drop_daemon_pairing.up.sql diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index 397d8f88..50b9f80e 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -314,18 +314,8 @@ Run the local daemon: make daemon ``` -Normal flow: - -1. start the daemon -2. open the pairing link it prints -3. choose the workspace in the browser -4. let the daemon register its local runtime - -Debug shortcut: - -- you can set `MULTICA_WORKSPACE_ID` in your env file -- this skips normal pairing -- treat it as a local shortcut, not the default workflow +The daemon authenticates using the CLI's stored token (`multica login`). +It registers runtimes for all watched workspaces from the CLI config. ## Troubleshooting diff --git a/apps/web/app/pair/local/page.test.tsx b/apps/web/app/pair/local/page.test.tsx deleted file mode 100644 index 38ec775b..00000000 --- a/apps/web/app/pair/local/page.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; - -const { - mockGetDaemonPairingSession, - mockApproveDaemonPairingSession, - mockWorkspace, - mockAuthValue, -} = vi.hoisted(() => ({ - mockGetDaemonPairingSession: vi.fn(), - mockApproveDaemonPairingSession: vi.fn(), - mockWorkspace: { - id: "05ce77f1-7c45-4735-b1f7-619347f7f76c", - name: "Jiayuan's Workspace", - slug: "jiayuan-05ce77f1", - description: null, - settings: {}, - created_at: "2026-03-24T00:00:00Z", - updated_at: "2026-03-24T00:00:00Z", - }, - mockAuthValue: { - user: { - id: "user-1", - name: "Jiayuan", - email: "jiayuan@example.com", - avatar_url: null, - created_at: "2026-03-24T00:00:00Z", - updated_at: "2026-03-24T00:00:00Z", - }, - workspaces: [] as Array<{ - id: string; - name: string; - slug: string; - description: null; - settings: Record; - created_at: string; - updated_at: string; - }>, - workspace: null as null | { - id: string; - name: string; - slug: string; - description: null; - settings: Record; - created_at: string; - updated_at: string; - }, - isLoading: false, - }, -})); - -mockAuthValue.workspaces = [mockWorkspace]; -mockAuthValue.workspace = mockWorkspace; - -vi.mock("next/navigation", () => ({ - useSearchParams: () => new URLSearchParams("token=test-token"), -})); - -vi.mock("@/shared/api", () => ({ - api: { - getDaemonPairingSession: mockGetDaemonPairingSession, - approveDaemonPairingSession: mockApproveDaemonPairingSession, - }, -})); - -vi.mock("@/features/auth", () => ({ - useAuthStore: (selector: (s: any) => any) => - selector(mockAuthValue), -})); - -vi.mock("@/features/workspace", () => ({ - useWorkspaceStore: (selector: (s: any) => any) => - selector(mockAuthValue), -})); - -import LocalDaemonPairPage from "./page"; - -describe("LocalDaemonPairPage", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockGetDaemonPairingSession.mockResolvedValue({ - token: "test-token", - daemon_id: "local-daemon", - device_name: "Jiayuans-MacBook-Pro.local", - runtime_name: "Local Codex", - runtime_type: "codex", - runtime_version: "codex-cli 0.116.0", - workspace_id: mockWorkspace.id, - status: "pending", - approved_at: null, - claimed_at: null, - expires_at: "2026-03-24T07:20:00Z", - link_url: null, - }); - }); - - it("shows the selected workspace name instead of the raw id", async () => { - render(); - - await waitFor(() => { - expect(mockGetDaemonPairingSession).toHaveBeenCalledWith("test-token"); - }); - - expect(await screen.findByText("Jiayuan's Workspace")).toBeInTheDocument(); - expect(screen.queryByText(mockWorkspace.id)).not.toBeInTheDocument(); - }); -}); diff --git a/apps/web/app/pair/local/page.tsx b/apps/web/app/pair/local/page.tsx deleted file mode 100644 index 6f94653d..00000000 --- a/apps/web/app/pair/local/page.tsx +++ /dev/null @@ -1,181 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { Suspense, useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import type { DaemonPairingSession } from "@/shared/types"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, -} from "@/components/ui/select"; -import { api } from "@/shared/api"; -import { useAuthStore } from "@/features/auth"; -import { useWorkspaceStore } from "@/features/workspace"; - -function formatExpiresAt(value: string) { - return new Date(value).toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - -function LocalDaemonPairPageContent() { - const searchParams = useSearchParams(); - const token = searchParams.get("token") ?? ""; - const user = useAuthStore((s) => s.user); - const isLoading = useAuthStore((s) => s.isLoading); - const workspace = useWorkspaceStore((s) => s.workspace); - const workspaces = useWorkspaceStore((s) => s.workspaces); - const [session, setSession] = useState(null); - const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(""); - const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(""); - - const nextLoginURL = useMemo(() => { - const next = `/pair/local?token=${encodeURIComponent(token)}`; - return `/login?next=${encodeURIComponent(next)}`; - }, [token]); - const selectedWorkspace = useMemo( - () => workspaces.find((item) => item.id === selectedWorkspaceId) ?? null, - [selectedWorkspaceId, workspaces], - ); - - useEffect(() => { - if (!token) { - setError("Missing pairing token."); - setLoading(false); - return; - } - - setLoading(true); - api.getDaemonPairingSession(token) - .then((value) => { - setSession(value); - setSelectedWorkspaceId(value.workspace_id || workspace?.id || workspaces[0]?.id || ""); - }) - .catch((err) => setError(err instanceof Error ? err.message : "Failed to load pairing session.")) - .finally(() => setLoading(false)); - }, [token, workspace?.id, workspaces]); - - const approve = async () => { - if (!token || !selectedWorkspaceId) return; - setSubmitting(true); - setError(""); - try { - const approved = await api.approveDaemonPairingSession(token, { - workspace_id: selectedWorkspaceId, - }); - setSession(approved); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to approve pairing session."); - } finally { - setSubmitting(false); - } - }; - - return ( -
-
-
-

Connect Local Codex Runtime

-

- Approve this pairing request to register your local Codex runtime with a workspace. -

-
- - {loading || isLoading ? ( -
Loading pairing session...
- ) : error ? ( -
- {error} -
- ) : session ? ( - <> -
-
{session.runtime_name}
-
- {session.device_name} - {session.runtime_version ? ` · ${session.runtime_version}` : ""} -
-
- {session.runtime_type} -
-
- Expires {formatExpiresAt(session.expires_at)} -
-
- - {!user ? ( -
-

- Sign in first, then choose which workspace should own this local runtime. -

- - Sign in to continue - -
- ) : session.status === "approved" || session.status === "claimed" ? ( -
- This runtime is linked to a workspace. Return to the daemon window to finish setup. -
- ) : session.status === "expired" ? ( -
- This pairing link expired. Restart the daemon to generate a new link. -
- ) : workspaces.length === 0 ? ( -
- You do not have a workspace yet. Create one first, then reopen this pairing link. -
- ) : ( -
-
- - -
- - -
- )} - - ) : null} -
-
- ); -} - -export default function LocalDaemonPairPage() { - return ( - - - - ); -} diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index f6a080b0..9b41a9bd 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -12,8 +12,6 @@ import type { UpdateAgentRequest, AgentTask, AgentRuntime, - DaemonPairingSession, - ApproveDaemonPairingSessionRequest, InboxItem, IssueSubscriber, Comment, @@ -360,20 +358,6 @@ export class ApiClient { return this.fetch(`/api/issues/${issueId}/task-runs`); } - async getDaemonPairingSession(token: string): Promise { - return this.fetch(`/api/daemon/pairing-sessions/${token}`); - } - - async approveDaemonPairingSession( - token: string, - data: ApproveDaemonPairingSessionRequest, - ): Promise { - return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, { - method: "POST", - body: JSON.stringify(data), - }); - } - // Inbox async listInbox(): Promise { return this.fetch("/api/inbox"); diff --git a/apps/web/shared/types/daemon.ts b/apps/web/shared/types/daemon.ts deleted file mode 100644 index 459a67a5..00000000 --- a/apps/web/shared/types/daemon.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type DaemonPairingSessionStatus = "pending" | "approved" | "claimed" | "expired"; - -export interface DaemonPairingSession { - token: string; - daemon_id: string; - device_name: string; - runtime_name: string; - runtime_type: string; - runtime_version: string; - workspace_id: string | null; - status: DaemonPairingSessionStatus; - approved_at: string | null; - claimed_at: string | null; - expires_at: string; - created_at: string; - updated_at: string; - link_url?: string | null; -} - -export interface ApproveDaemonPairingSessionRequest { - workspace_id: string; -} diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 4c105ff5..709c7f18 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -27,7 +27,6 @@ export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox"; export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment"; export type { TimelineEntry } from "./activity"; export type { IssueSubscriber } from "./subscriber"; -export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; export type * from "./events"; export type * from "./api"; export type { Attachment } from "./attachment"; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 8cc84c1c..400d3c40 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -83,11 +83,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route 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) + // Daemon API routes (all require a valid token) r.Route("/api/daemon", func(r chi.Router) { - r.Post("/pairing-sessions", h.CreateDaemonPairingSession) - r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession) - r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession) + r.Use(middleware.Auth(queries)) r.Post("/register", h.DaemonRegister) r.Post("/deregister", h.DaemonDeregister) @@ -150,8 +148,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Delete("/{id}", h.RevokePersonalAccessToken) }) - r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession) - // --- Workspace-scoped routes (all require workspace membership) --- r.Group(func(r chi.Router) { r.Use(middleware.RequireWorkspaceMember(queries)) diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index d91b9376..ae7803c4 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -53,6 +53,12 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "at least one runtime is required") return } + + // Verify the caller is a member of the target workspace. + if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok { + return + } + ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID)) if err != nil { writeError(w, http.StatusNotFound, "workspace not found") diff --git a/server/internal/handler/daemon_pairing.go b/server/internal/handler/daemon_pairing.go deleted file mode 100644 index 9cf7747f..00000000 --- a/server/internal/handler/daemon_pairing.go +++ /dev/null @@ -1,386 +0,0 @@ -package handler - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -const daemonPairingTTL = 10 * time.Minute - -type daemonPairingSessionRecord struct { - Token string - DaemonID string - DeviceName string - RuntimeName string - RuntimeType string - RuntimeVersion string - WorkspaceID pgtype.UUID - ApprovedBy pgtype.UUID - Status string - ApprovedAt pgtype.Timestamptz - ClaimedAt pgtype.Timestamptz - ExpiresAt pgtype.Timestamptz - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz -} - -type DaemonPairingSessionResponse struct { - Token string `json:"token"` - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - RuntimeName string `json:"runtime_name"` - RuntimeType string `json:"runtime_type"` - RuntimeVersion string `json:"runtime_version"` - WorkspaceID *string `json:"workspace_id"` - Status string `json:"status"` - ApprovedAt *string `json:"approved_at"` - ClaimedAt *string `json:"claimed_at"` - ExpiresAt string `json:"expires_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - LinkURL *string `json:"link_url,omitempty"` -} - -type CreateDaemonPairingSessionRequest struct { - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - RuntimeName string `json:"runtime_name"` - RuntimeType string `json:"runtime_type"` - RuntimeVersion string `json:"runtime_version"` -} - -type ApproveDaemonPairingSessionRequest struct { - WorkspaceID string `json:"workspace_id"` -} - -func daemonAppBaseURL() string { - for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return strings.TrimRight(value, "/") - } - } - return "http://localhost:3000" -} - -func daemonPairingLinkURL(token string) string { - base := daemonAppBaseURL() - return base + "/pair/local?token=" + url.QueryEscape(token) -} - -func daemonPairingSessionToResponse(rec daemonPairingSessionRecord, includeLink bool) DaemonPairingSessionResponse { - resp := DaemonPairingSessionResponse{ - Token: rec.Token, - DaemonID: rec.DaemonID, - DeviceName: rec.DeviceName, - RuntimeName: rec.RuntimeName, - RuntimeType: rec.RuntimeType, - RuntimeVersion: rec.RuntimeVersion, - WorkspaceID: uuidToPtr(rec.WorkspaceID), - Status: rec.Status, - ApprovedAt: timestampToPtr(rec.ApprovedAt), - ClaimedAt: timestampToPtr(rec.ClaimedAt), - ExpiresAt: timestampToString(rec.ExpiresAt), - CreatedAt: timestampToString(rec.CreatedAt), - UpdatedAt: timestampToString(rec.UpdatedAt), - } - if includeLink { - link := daemonPairingLinkURL(rec.Token) - resp.LinkURL = &link - } - return resp -} - -func randomDaemonPairingToken() (string, error) { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} - -func (h *Handler) getDaemonPairingSession(ctx context.Context, token string) (daemonPairingSessionRecord, error) { - if h.DB == nil { - return daemonPairingSessionRecord{}, fmt.Errorf("database executor is not configured") - } - - var rec daemonPairingSessionRecord - err := h.DB.QueryRow(ctx, ` - SELECT - token, - daemon_id, - device_name, - runtime_name, - runtime_type, - runtime_version, - workspace_id, - approved_by, - status, - approved_at, - claimed_at, - expires_at, - created_at, - updated_at - FROM daemon_pairing_session - WHERE token = $1 - `, token).Scan( - &rec.Token, - &rec.DaemonID, - &rec.DeviceName, - &rec.RuntimeName, - &rec.RuntimeType, - &rec.RuntimeVersion, - &rec.WorkspaceID, - &rec.ApprovedBy, - &rec.Status, - &rec.ApprovedAt, - &rec.ClaimedAt, - &rec.ExpiresAt, - &rec.CreatedAt, - &rec.UpdatedAt, - ) - if err != nil { - return daemonPairingSessionRecord{}, err - } - - if rec.Status == "pending" && rec.ExpiresAt.Valid && rec.ExpiresAt.Time.Before(time.Now()) { - if _, err := h.DB.Exec(ctx, ` - UPDATE daemon_pairing_session - SET status = 'expired', updated_at = now() - WHERE token = $1 AND status = 'pending' - `, token); err == nil { - rec.Status = "expired" - rec.UpdatedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true} - } - } - - return rec, nil -} - -func (h *Handler) CreateDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - if h.DB == nil { - writeError(w, http.StatusInternalServerError, "database executor is not configured") - return - } - - var req CreateDaemonPairingSessionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - req.DaemonID = strings.TrimSpace(req.DaemonID) - req.DeviceName = strings.TrimSpace(req.DeviceName) - req.RuntimeName = strings.TrimSpace(req.RuntimeName) - req.RuntimeType = strings.TrimSpace(req.RuntimeType) - req.RuntimeVersion = strings.TrimSpace(req.RuntimeVersion) - - if req.DaemonID == "" { - writeError(w, http.StatusBadRequest, "daemon_id is required") - return - } - if req.DeviceName == "" { - writeError(w, http.StatusBadRequest, "device_name is required") - return - } - if req.RuntimeName == "" { - writeError(w, http.StatusBadRequest, "runtime_name is required") - return - } - if req.RuntimeType == "" { - writeError(w, http.StatusBadRequest, "runtime_type is required") - return - } - - token, err := randomDaemonPairingToken() - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create pairing token") - return - } - - expiresAt := time.Now().Add(daemonPairingTTL) - var rec daemonPairingSessionRecord - err = h.DB.QueryRow(r.Context(), ` - INSERT INTO daemon_pairing_session ( - token, - daemon_id, - device_name, - runtime_name, - runtime_type, - runtime_version, - expires_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING - token, - daemon_id, - device_name, - runtime_name, - runtime_type, - runtime_version, - workspace_id, - approved_by, - status, - approved_at, - claimed_at, - expires_at, - created_at, - updated_at - `, - token, - req.DaemonID, - req.DeviceName, - req.RuntimeName, - req.RuntimeType, - req.RuntimeVersion, - expiresAt, - ).Scan( - &rec.Token, - &rec.DaemonID, - &rec.DeviceName, - &rec.RuntimeName, - &rec.RuntimeType, - &rec.RuntimeVersion, - &rec.WorkspaceID, - &rec.ApprovedBy, - &rec.Status, - &rec.ApprovedAt, - &rec.ClaimedAt, - &rec.ExpiresAt, - &rec.CreatedAt, - &rec.UpdatedAt, - ) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create pairing session") - return - } - - writeJSON(w, http.StatusCreated, daemonPairingSessionToResponse(rec, true)) -} - -func (h *Handler) GetDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - rec, err := h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusNotFound, "pairing session not found") - return - } - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) -} - -func (h *Handler) ApproveDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - rec, err := h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusNotFound, "pairing session not found") - return - } - if rec.Status == "expired" { - writeError(w, http.StatusBadRequest, "pairing session expired") - return - } - if rec.Status == "claimed" { - writeError(w, http.StatusBadRequest, "pairing session already claimed") - return - } - if rec.Status == "approved" { - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) - return - } - - var req ApproveDaemonPairingSessionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - if req.WorkspaceID == "" { - writeError(w, http.StatusBadRequest, "workspace_id is required") - return - } - - userID, ok := requireUserID(w, r) - if !ok { - return - } - if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok { - return - } - - if h.DB == nil { - writeError(w, http.StatusInternalServerError, "database executor is not configured") - return - } - - if _, err := h.DB.Exec(r.Context(), ` - UPDATE daemon_pairing_session - SET - workspace_id = $2, - approved_by = $3, - status = 'approved', - approved_at = now(), - updated_at = now() - WHERE token = $1 AND status = 'pending' - `, token, parseUUID(req.WorkspaceID), parseUUID(userID)); err != nil { - writeError(w, http.StatusInternalServerError, "failed to approve pairing session") - return - } - - rec, err = h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to reload pairing session") - return - } - - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) -} - -func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - rec, err := h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusNotFound, "pairing session not found") - return - } - if rec.Status == "claimed" { - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) - return - } - if rec.Status != "approved" { - writeError(w, http.StatusBadRequest, "pairing session is not approved") - return - } - - if h.DB == nil { - writeError(w, http.StatusInternalServerError, "database executor is not configured") - return - } - - if _, err := h.DB.Exec(r.Context(), ` - UPDATE daemon_pairing_session - SET - status = 'claimed', - claimed_at = now(), - updated_at = now() - WHERE token = $1 AND status = 'approved' - `, token); err != nil { - writeError(w, http.StatusInternalServerError, "failed to claim pairing session") - return - } - - rec, err = h.getDaemonPairingSession(r.Context(), token) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to reload pairing session") - return - } - - writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true)) -} diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 15e1769b..9a5ec3c8 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -729,6 +729,7 @@ func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) { "runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}] }`)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", testUserID) testHandler.DaemonRegister(w, req) if w.Code != http.StatusNotFound { diff --git a/server/migrations/029_drop_daemon_pairing.down.sql b/server/migrations/029_drop_daemon_pairing.down.sql new file mode 100644 index 00000000..c39431e7 --- /dev/null +++ b/server/migrations/029_drop_daemon_pairing.down.sql @@ -0,0 +1,21 @@ +-- Re-create the daemon_pairing_session table (from migration 005). +CREATE TABLE IF NOT EXISTS daemon_pairing_session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT NOT NULL UNIQUE, + daemon_id TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', + runtime_name TEXT NOT NULL DEFAULT '', + runtime_type TEXT NOT NULL DEFAULT '', + runtime_version TEXT NOT NULL DEFAULT '', + workspace_id UUID REFERENCES workspace(id), + approved_by UUID REFERENCES "user"(id), + status TEXT NOT NULL DEFAULT 'pending', + approved_at TIMESTAMPTZ, + claimed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_token ON daemon_pairing_session(token); +CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_status ON daemon_pairing_session(status, expires_at); diff --git a/server/migrations/029_drop_daemon_pairing.up.sql b/server/migrations/029_drop_daemon_pairing.up.sql new file mode 100644 index 00000000..25e28eb7 --- /dev/null +++ b/server/migrations/029_drop_daemon_pairing.up.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS daemon_pairing_session;