mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Add browser-based board CLI auth flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
1376fc8f44
commit
37c2c4acc4
31 changed files with 13299 additions and 19 deletions
|
|
@ -19,10 +19,12 @@ import {
|
|||
} from "@paperclipai/db";
|
||||
import {
|
||||
acceptInviteSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
PERMISSION_KEYS
|
||||
|
|
@ -40,6 +42,7 @@ import { validate } from "../middleware/validate.js";
|
|||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
boardAuthService,
|
||||
deduplicateAgentName,
|
||||
logActivity,
|
||||
notifyHireApproved
|
||||
|
|
@ -95,6 +98,10 @@ function requestBaseUrl(req: Request) {
|
|||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
function buildCliAuthApprovalPath(challengeId: string, token: string) {
|
||||
return `/cli-auth/${challengeId}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function readSkillMarkdown(skillName: string): string | null {
|
||||
const normalized = skillName.trim().toLowerCase();
|
||||
if (
|
||||
|
|
@ -1537,6 +1544,7 @@ export function accessRoutes(
|
|||
) {
|
||||
const router = Router();
|
||||
const access = accessService(db);
|
||||
const boardAuth = boardAuthService(db);
|
||||
const agents = agentService(db);
|
||||
|
||||
async function assertInstanceAdmin(req: Request) {
|
||||
|
|
@ -1594,6 +1602,159 @@ export function accessRoutes(
|
|||
throw conflict("Board claim challenge is no longer available");
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/cli-auth/challenges",
|
||||
validate(createCliAuthChallengeSchema),
|
||||
async (req, res) => {
|
||||
const created = await boardAuth.createCliAuthChallenge(req.body);
|
||||
const approvalPath = buildCliAuthApprovalPath(
|
||||
created.challenge.id,
|
||||
created.challengeSecret,
|
||||
);
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
res.status(201).json({
|
||||
id: created.challenge.id,
|
||||
token: created.challengeSecret,
|
||||
boardApiToken: created.pendingBoardToken,
|
||||
approvalPath,
|
||||
approvalUrl: baseUrl ? `${baseUrl}${approvalPath}` : null,
|
||||
pollPath: `/cli-auth/challenges/${created.challenge.id}`,
|
||||
expiresAt: created.challenge.expiresAt.toISOString(),
|
||||
suggestedPollIntervalMs: 1000,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/cli-auth/challenges/:id", async (req, res) => {
|
||||
const id = (req.params.id as string).trim();
|
||||
const token =
|
||||
typeof req.query.token === "string" ? req.query.token.trim() : "";
|
||||
if (!id || !token) throw notFound("CLI auth challenge not found");
|
||||
const challenge = await boardAuth.describeCliAuthChallenge(id, token);
|
||||
if (!challenge) throw notFound("CLI auth challenge not found");
|
||||
|
||||
const isSignedInBoardUser =
|
||||
req.actor.type === "board" &&
|
||||
(req.actor.source === "session" || isLocalImplicit(req)) &&
|
||||
Boolean(req.actor.userId);
|
||||
const canApprove =
|
||||
isSignedInBoardUser &&
|
||||
(challenge.requestedAccess !== "instance_admin_required" ||
|
||||
isLocalImplicit(req) ||
|
||||
Boolean(req.actor.isInstanceAdmin));
|
||||
|
||||
res.json({
|
||||
...challenge,
|
||||
requiresSignIn: !isSignedInBoardUser,
|
||||
canApprove,
|
||||
currentUserId: req.actor.type === "board" ? req.actor.userId ?? null : null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/cli-auth/challenges/:id/approve",
|
||||
validate(resolveCliAuthChallengeSchema),
|
||||
async (req, res) => {
|
||||
const id = (req.params.id as string).trim();
|
||||
if (
|
||||
req.actor.type !== "board" ||
|
||||
(!req.actor.userId && !isLocalImplicit(req))
|
||||
) {
|
||||
throw unauthorized("Sign in before approving CLI access");
|
||||
}
|
||||
|
||||
const userId = req.actor.userId ?? "local-board";
|
||||
const approved = await boardAuth.approveCliAuthChallenge(
|
||||
id,
|
||||
req.body.token,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (approved.status === "approved") {
|
||||
const accessSnapshot = await boardAuth.resolveBoardAccess(userId);
|
||||
for (const companyId of accessSnapshot.companyIds) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "board_api_key.created",
|
||||
entityType: "user",
|
||||
entityId: userId,
|
||||
details: {
|
||||
boardApiKeyId: approved.challenge.boardApiKeyId,
|
||||
requestedAccess: approved.challenge.requestedAccess,
|
||||
requestedCompanyId: approved.challenge.requestedCompanyId,
|
||||
challengeId: approved.challenge.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
approved: approved.status === "approved",
|
||||
status: approved.status,
|
||||
userId,
|
||||
keyId: approved.challenge.boardApiKeyId ?? null,
|
||||
expiresAt: approved.challenge.expiresAt.toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/cli-auth/challenges/:id/cancel",
|
||||
validate(resolveCliAuthChallengeSchema),
|
||||
async (req, res) => {
|
||||
const id = (req.params.id as string).trim();
|
||||
const cancelled = await boardAuth.cancelCliAuthChallenge(id, req.body.token);
|
||||
res.json({
|
||||
status: cancelled.status,
|
||||
cancelled: cancelled.status === "cancelled",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/cli-auth/me", async (req, res) => {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
throw unauthorized("Board authentication required");
|
||||
}
|
||||
const accessSnapshot = await boardAuth.resolveBoardAccess(req.actor.userId);
|
||||
res.json({
|
||||
user: accessSnapshot.user,
|
||||
userId: req.actor.userId,
|
||||
isInstanceAdmin: accessSnapshot.isInstanceAdmin,
|
||||
companyIds: accessSnapshot.companyIds,
|
||||
source: req.actor.source ?? "none",
|
||||
keyId: req.actor.source === "board_key" ? req.actor.keyId ?? null : null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/cli-auth/revoke-current", async (req, res) => {
|
||||
if (req.actor.type !== "board" || req.actor.source !== "board_key") {
|
||||
throw badRequest("Current board API key context is required");
|
||||
}
|
||||
const key = await boardAuth.assertCurrentBoardKey(
|
||||
req.actor.keyId,
|
||||
req.actor.userId,
|
||||
);
|
||||
await boardAuth.revokeBoardApiKey(key.id);
|
||||
const accessSnapshot = await boardAuth.resolveBoardAccess(key.userId);
|
||||
for (const companyId of accessSnapshot.companyIds) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: key.userId,
|
||||
action: "board_api_key.revoked",
|
||||
entityType: "user",
|
||||
entityId: key.userId,
|
||||
details: {
|
||||
boardApiKeyId: key.id,
|
||||
revokedVia: "cli_auth_logout",
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ revoked: true, keyId: key.id });
|
||||
});
|
||||
|
||||
async function assertCompanyPermission(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue