feat: add board-claim flow for local_trusted -> authenticated migration

One-time high-entropy claim URL printed at startup when the only
instance admin is local-board. Signed-in user claims ownership,
gets promoted to instance_admin, and receives active memberships
across all existing companies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 16:25:31 -06:00
parent 083ffaa71e
commit 390e4dd839
7 changed files with 358 additions and 3 deletions

View File

@@ -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") {