diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index 83484300..93b4a00e 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -88,19 +88,32 @@ Required integration points: This is required because user assignment paths validate active membership for `assigneeUserId`. -## 7. Current Code Reality (As Of 2026-02-23) +## 7. Local Trusted -> Authenticated Claim Flow + +When running `authenticated` mode, if the only instance admin is `local-board`, Paperclip emits a startup warning with a one-time high-entropy claim URL. + +- URL format: `/board-claim/?code=` +- intended use: signed-in human claims board ownership +- claim action: + - promotes current signed-in user to `instance_admin` + - demotes `local-board` admin role + - ensures active owner membership for the claiming user across existing companies + +This prevents lockout when a user migrates from long-running local trusted usage to authenticated mode. + +## 8. Current Code Reality (As Of 2026-02-23) - runtime values are `local_trusted | authenticated` - `authenticated` uses Better Auth sessions and bootstrap invite flow - `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access - company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent -## 8. Naming and Compatibility Policy +## 9. Naming and Compatibility Policy - canonical naming is `local_trusted` and `authenticated` with `private/public` exposure - no long-term compatibility alias layer for discarded naming variants -## 9. Relationship to Other Docs +## 10. Relationship to Other Docs - implementation plan: `doc/plans/deployment-auth-mode-consolidation.md` - V1 contract: `doc/SPEC-implementation.md` diff --git a/server/src/board-claim.ts b/server/src/board-claim.ts new file mode 100644 index 00000000..886ca1b4 --- /dev/null +++ b/server/src/board-claim.ts @@ -0,0 +1,149 @@ +import { randomBytes } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { companies, companyMemberships, instanceUserRoles } from "@paperclip/db"; +import type { DeploymentMode } from "@paperclip/shared"; + +const LOCAL_BOARD_USER_ID = "local-board"; +const CLAIM_TTL_MS = 1000 * 60 * 60 * 24; + +type ChallengeStatus = "available" | "claimed" | "expired" | "invalid"; + +type ClaimChallenge = { + token: string; + code: string; + createdAt: Date; + expiresAt: Date; + claimedAt: Date | null; + claimedByUserId: string | null; +}; + +let activeChallenge: ClaimChallenge | null = null; + +function createChallenge(now = new Date()): ClaimChallenge { + return { + token: randomBytes(24).toString("hex"), + code: randomBytes(12).toString("hex"), + createdAt: now, + expiresAt: new Date(now.getTime() + CLAIM_TTL_MS), + claimedAt: null, + claimedByUserId: null, + }; +} + +function getChallengeStatus(token: string, code: string | undefined): ChallengeStatus { + if (!activeChallenge) return "invalid"; + if (activeChallenge.token !== token) return "invalid"; + if (activeChallenge.code !== (code ?? "")) return "invalid"; + if (activeChallenge.claimedAt) return "claimed"; + if (activeChallenge.expiresAt.getTime() <= Date.now()) return "expired"; + return "available"; +} + +export async function initializeBoardClaimChallenge( + db: Db, + opts: { deploymentMode: DeploymentMode }, +): Promise { + if (opts.deploymentMode !== "authenticated") { + activeChallenge = null; + return; + } + + const admins = await db + .select({ userId: instanceUserRoles.userId }) + .from(instanceUserRoles) + .where(eq(instanceUserRoles.role, "instance_admin")); + + const onlyLocalBoardAdmin = admins.length === 1 && admins[0]?.userId === LOCAL_BOARD_USER_ID; + if (!onlyLocalBoardAdmin) { + activeChallenge = null; + return; + } + + if (!activeChallenge || activeChallenge.expiresAt.getTime() <= Date.now() || activeChallenge.claimedAt) { + activeChallenge = createChallenge(); + } +} + +export function getBoardClaimWarningUrl(host: string, port: number): string | null { + if (!activeChallenge) return null; + if (activeChallenge.claimedAt || activeChallenge.expiresAt.getTime() <= Date.now()) return null; + const visibleHost = host === "0.0.0.0" ? "localhost" : host; + return `http://${visibleHost}:${port}/board-claim/${activeChallenge.token}?code=${activeChallenge.code}`; +} + +export function inspectBoardClaimChallenge(token: string, code: string | undefined) { + const status = getChallengeStatus(token, code); + return { + status, + requiresSignIn: true, + expiresAt: activeChallenge?.expiresAt?.toISOString() ?? null, + claimedByUserId: activeChallenge?.claimedByUserId ?? null, + }; +} + +export async function claimBoardOwnership( + db: Db, + opts: { token: string; code: string | undefined; userId: string }, +): Promise<{ status: ChallengeStatus; claimedByUserId?: string }> { + const status = getChallengeStatus(opts.token, opts.code); + if (status !== "available") return { status }; + + await db.transaction(async (tx) => { + const existingTargetAdmin = await tx + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, opts.userId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null); + if (!existingTargetAdmin) { + await tx.insert(instanceUserRoles).values({ + userId: opts.userId, + role: "instance_admin", + }); + } + + await tx + .delete(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin"))); + + const allCompanies = await tx.select({ id: companies.id }).from(companies); + for (const company of allCompanies) { + const existing = await tx + .select({ id: companyMemberships.id, status: companyMemberships.status }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, company.id), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, opts.userId), + ), + ) + .then((rows) => rows[0] ?? null); + + if (!existing) { + await tx.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: opts.userId, + status: "active", + membershipRole: "owner", + }); + continue; + } + + if (existing.status !== "active") { + await tx + .update(companyMemberships) + .set({ status: "active", membershipRole: "owner", updatedAt: new Date() }) + .where(eq(companyMemberships.id, existing.id)); + } + } + }); + + if (activeChallenge && activeChallenge.token === opts.token) { + activeChallenge.claimedAt = new Date(); + activeChallenge.claimedByUserId = opts.userId; + } + + return { status: "claimed", claimedByUserId: opts.userId }; +} diff --git a/server/src/index.ts b/server/src/index.ts index 4a3d8bbd..a22fc97c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,6 +24,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { heartbeatService } from "./services/index.js"; import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; +import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; import { createBetterAuthHandler, createBetterAuthInstance, @@ -337,6 +338,7 @@ if (config.deploymentMode === "authenticated") { const auth = createBetterAuthInstance(db as any, config); betterAuthHandler = createBetterAuthHandler(auth); resolveSession = (req) => resolveBetterAuthSession(auth, req); + await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode }); authReady = true; } @@ -404,6 +406,22 @@ server.listen(listenPort, config.host, () => { heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled, heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs, }); + + const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort); + if (boardClaimUrl) { + const red = "\x1b[41m\x1b[30m"; + const yellow = "\x1b[33m"; + const reset = "\x1b[0m"; + console.log( + [ + `${red} BOARD CLAIM REQUIRED ${reset}`, + `${yellow}This instance was previously local_trusted and still has local-board as the only admin.${reset}`, + `${yellow}Sign in with a real user and open this one-time URL to claim ownership:${reset}`, + `${yellow}${boardClaimUrl}${reset}`, + `${yellow}If you are connecting over Tailscale, replace the host in this URL with your Tailscale IP/MagicDNS name.${reset}`, + ].join("\n"), + ); + } }); if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 75581e40..cf06e10a 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -21,6 +21,7 @@ import { forbidden, conflict, notFound, unauthorized, badRequest } from "../erro import { validate } from "../middleware/validate.js"; import { accessService, agentService, logActivity } from "../services/index.js"; import { assertCompanyAccess } from "./authz.js"; +import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -101,6 +102,40 @@ export function accessRoutes(db: Db) { if (!allowed) throw forbidden("Instance admin required"); } + router.get("/board-claim/:token", async (req, res) => { + const token = (req.params.token as string).trim(); + const code = typeof req.query.code === "string" ? req.query.code.trim() : undefined; + if (!token) throw notFound("Board claim challenge not found"); + const challenge = inspectBoardClaimChallenge(token, code); + if (challenge.status === "invalid") throw notFound("Board claim challenge not found"); + res.json(challenge); + }); + + router.post("/board-claim/:token/claim", async (req, res) => { + const token = (req.params.token as string).trim(); + const code = typeof req.body?.code === "string" ? req.body.code.trim() : undefined; + if (!token) throw notFound("Board claim challenge not found"); + if (!code) throw badRequest("Claim code is required"); + if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId) { + throw unauthorized("Sign in before claiming board ownership"); + } + + const claimed = await claimBoardOwnership(db, { + token, + code, + userId: req.actor.userId, + }); + + if (claimed.status === "invalid") throw notFound("Board claim challenge not found"); + if (claimed.status === "expired") throw conflict("Board claim challenge expired. Restart server to generate a new one."); + if (claimed.status === "claimed") { + res.json({ claimed: true, userId: claimed.claimedByUserId ?? req.actor.userId }); + return; + } + + throw conflict("Board claim challenge is no longer available"); + }); + async function assertCompanyPermission(req: Request, companyId: string, permissionKey: any) { assertCompanyAccess(req, companyId); if (req.actor.type === "agent") { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 36633a8e..6002ee61 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -21,6 +21,7 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; import { AuthPage } from "./pages/Auth"; +import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; import { queryKeys } from "./lib/queryKeys"; @@ -85,6 +86,7 @@ export function App() { return ( } /> + } /> } /> }> diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 25bfe138..db660fda 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -19,6 +19,13 @@ type AcceptInviteInput = agentDefaultsPayload?: Record | null; }; +type BoardClaimStatus = { + status: "available" | "claimed" | "expired"; + requiresSignIn: boolean; + expiresAt: string | null; + claimedByUserId: string | null; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, @@ -49,4 +56,10 @@ export const accessApi = { rejectJoinRequest: (companyId: string, requestId: string) => api.post(`/companies/${companyId}/join-requests/${requestId}/reject`, {}), + + getBoardClaimStatus: (token: string, code: string) => + api.get(`/board-claim/${token}?code=${encodeURIComponent(code)}`), + + claimBoard: (token: string, code: string) => + api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }), }; diff --git a/ui/src/pages/BoardClaim.tsx b/ui/src/pages/BoardClaim.tsx new file mode 100644 index 00000000..ab8ab7a8 --- /dev/null +++ b/ui/src/pages/BoardClaim.tsx @@ -0,0 +1,125 @@ +import { useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useParams, useSearchParams } from "react-router-dom"; +import { accessApi } from "../api/access"; +import { authApi } from "../api/auth"; +import { queryKeys } from "../lib/queryKeys"; +import { Button } from "@/components/ui/button"; + +export function BoardClaimPage() { + const queryClient = useQueryClient(); + const params = useParams(); + const [searchParams] = useSearchParams(); + const token = (params.token ?? "").trim(); + const code = (searchParams.get("code") ?? "").trim(); + const currentPath = useMemo( + () => `/board-claim/${encodeURIComponent(token)}${code ? `?code=${encodeURIComponent(code)}` : ""}`, + [token, code], + ); + + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const statusQuery = useQuery({ + queryKey: ["board-claim", token, code], + queryFn: () => accessApi.getBoardClaimStatus(token, code), + enabled: token.length > 0 && code.length > 0, + retry: false, + }); + + const claimMutation = useMutation({ + mutationFn: () => accessApi.claimBoard(token, code), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.health }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }); + await statusQuery.refetch(); + }, + }); + + if (!token || !code) { + return
Invalid board claim URL.
; + } + + if (statusQuery.isLoading || sessionQuery.isLoading) { + return
Loading claim challenge...
; + } + + if (statusQuery.error) { + return ( +
+
+

Claim challenge unavailable

+

+ {statusQuery.error instanceof Error ? statusQuery.error.message : "Challenge is invalid or expired."} +

+
+
+ ); + } + + const status = statusQuery.data; + if (!status) { + return
Claim challenge unavailable.
; + } + + if (status.status === "claimed") { + return ( +
+
+

Board ownership claimed

+

+ This instance is now linked to your authenticated user. +

+ +
+
+ ); + } + + if (!sessionQuery.data) { + return ( +
+
+

Sign in required

+

+ Sign in or create an account, then return to this page to claim Board ownership. +

+ +
+
+ ); + } + + return ( +
+
+

Claim Board ownership

+

+ This will promote your user to instance admin and migrate company ownership access from local trusted mode. +

+ + {claimMutation.error && ( +

+ {claimMutation.error instanceof Error ? claimMutation.error.message : "Failed to claim board ownership"} +

+ )} + + +
+
+ ); +}