feat: ship enterprise control and desktop governance
This commit is contained in:
614
src/app/api/v1/admin/access/route.ts
Normal file
614
src/app/api/v1/admin/access/route.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { buildRequestAuditMeta } from "@/lib/boss-audit";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
|
||||
import {
|
||||
appendPermissionAuditLog,
|
||||
assignAccountCompanyByAdmin,
|
||||
assignDeviceCompanyByAdmin,
|
||||
bulkImportAuthAccountsByAdmin,
|
||||
previewBulkImportAuthAccountsByAdmin,
|
||||
readState,
|
||||
reclaimAuthAccountByAdmin,
|
||||
resetAuthAccountPasswordByAdmin,
|
||||
revokeAccessGrant,
|
||||
saveAccountDeviceGrant,
|
||||
saveAccountProjectGrant,
|
||||
saveAccountSkillGrant,
|
||||
setAdminCompanyStatusByAdmin,
|
||||
setAuthAccountMfaRequiredByAdmin,
|
||||
setAuthAccountStatusByAdmin,
|
||||
upsertAdminCompanyByAdmin,
|
||||
upsertAuthAccountByAdmin,
|
||||
} from "@/lib/boss-data";
|
||||
import type { AuthAccount, AuthAccountStatus, AuthRole, BossPermission, Device, Project } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
const validRoles = new Set<AuthRole>(["member", "admin", "highest_admin"]);
|
||||
const validImportRoles = new Set<AuthRole>(["member", "admin"]);
|
||||
const validAccountStatuses = new Set<AuthAccountStatus>(["active", "disabled"]);
|
||||
const validCompanyStatuses = new Set(["active", "disabled"]);
|
||||
const validCompanyPlanTiers = new Set(["trial", "standard", "enterprise"]);
|
||||
const validPermissions = new Set<BossPermission>([
|
||||
"device.view",
|
||||
"device.manage",
|
||||
"project.view",
|
||||
"thread.chat",
|
||||
"master_agent.ask",
|
||||
"master_agent.takeover",
|
||||
"computer.control",
|
||||
"skill.view",
|
||||
"skill.use",
|
||||
"skill.manage",
|
||||
"account.manage",
|
||||
"audit.view",
|
||||
]);
|
||||
|
||||
function publicAuthAccount(account: AuthAccount) {
|
||||
const { passwordHash, mfaSecret, ...safeAccount } = account;
|
||||
void passwordHash;
|
||||
void mfaSecret;
|
||||
return safeAccount;
|
||||
}
|
||||
|
||||
function publicAdminDevice(device: Device) {
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
avatar: device.avatar,
|
||||
account: device.account,
|
||||
status: device.status,
|
||||
companyId: device.companyId,
|
||||
source: device.source,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
preferredExecutionMode: device.preferredExecutionMode,
|
||||
capabilities: device.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
function publicAdminProject(project: Project) {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
deviceIds: project.deviceIds,
|
||||
threadMeta: project.threadMeta,
|
||||
isGroup: project.isGroup,
|
||||
updatedAt: project.updatedAt,
|
||||
lastMessageAt: project.lastMessageAt,
|
||||
collaborationMode: project.collaborationMode,
|
||||
approvalState: project.approvalState,
|
||||
riskLevel: project.riskLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkillCatalog(skills: Array<{
|
||||
skillId: string;
|
||||
deviceId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
invocation: string;
|
||||
category: string;
|
||||
updatedAt: string;
|
||||
}>) {
|
||||
const catalog = new Map<string, {
|
||||
name: string;
|
||||
invocation: string;
|
||||
description: string;
|
||||
deviceCount: number;
|
||||
devices: Array<{ skillId: string; deviceId: string; path: string; category: string; updatedAt: string }>;
|
||||
}>();
|
||||
|
||||
for (const skill of skills) {
|
||||
const key = skill.name.trim() || skill.skillId;
|
||||
const existing = catalog.get(key) ?? {
|
||||
name: key,
|
||||
invocation: skill.invocation,
|
||||
description: skill.description,
|
||||
deviceCount: 0,
|
||||
devices: [],
|
||||
};
|
||||
existing.devices.push({
|
||||
skillId: skill.skillId,
|
||||
deviceId: skill.deviceId,
|
||||
path: skill.path,
|
||||
category: skill.category,
|
||||
updatedAt: skill.updatedAt,
|
||||
});
|
||||
existing.deviceCount = existing.devices.length;
|
||||
catalog.set(key, existing);
|
||||
}
|
||||
|
||||
return [...catalog.values()].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
async function requireHighestAdmin(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return { ok: false as const, response: jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }) };
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return { ok: false as const, response: forbidden() };
|
||||
}
|
||||
return { ok: true as const, session };
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function permissionValues(value: unknown): BossPermission[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is BossPermission =>
|
||||
typeof item === "string" && validPermissions.has(item as BossPermission),
|
||||
);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map(stringValue).filter(Boolean);
|
||||
}
|
||||
|
||||
function accountImportValues(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
|
||||
.map((item) => ({
|
||||
account: stringValue(item.account),
|
||||
displayName: stringValue(item.displayName),
|
||||
role: validImportRoles.has(stringValue(item.role) as AuthRole) ? (stringValue(item.role) as AuthRole) : "member",
|
||||
password: stringValue(item.password),
|
||||
verificationEmail: stringValue(item.verificationEmail),
|
||||
}))
|
||||
.filter((item) => Boolean(item.account));
|
||||
}
|
||||
|
||||
function idSegment(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item";
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
companies: state.adminCompanies,
|
||||
accounts: state.authAccounts.map(publicAuthAccount),
|
||||
devices: state.devices.map(publicAdminDevice),
|
||||
projects: state.projects.map(publicAdminProject),
|
||||
skills: state.deviceSkills,
|
||||
skillCatalog: buildSkillCatalog(state.deviceSkills),
|
||||
permissionTemplates: BOSS_PERMISSION_TEMPLATES,
|
||||
grants: {
|
||||
devices: state.accountDeviceGrants,
|
||||
projects: state.accountProjectGrants,
|
||||
skills: state.accountSkillGrants,
|
||||
},
|
||||
auditLogs: state.permissionAuditLogs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const action = stringValue(body.action);
|
||||
const auditMeta = buildRequestAuditMeta(request);
|
||||
|
||||
if (action === "upsert_account") {
|
||||
const account = stringValue(body.account);
|
||||
const role = stringValue(body.role) as AuthRole;
|
||||
if (!account || !validRoles.has(role)) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_OR_ROLE_INVALID" }, { status: 400 });
|
||||
}
|
||||
const saved = await upsertAuthAccountByAdmin({
|
||||
account,
|
||||
displayName: stringValue(body.displayName),
|
||||
role,
|
||||
password: stringValue(body.password),
|
||||
verificationEmail: stringValue(body.verificationEmail),
|
||||
companyId: stringValue(body.companyId) || undefined,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
}
|
||||
|
||||
if (action === "set_account_status") {
|
||||
const account = stringValue(body.account);
|
||||
const status = stringValue(body.status) as AuthAccountStatus;
|
||||
if (!account || !validAccountStatuses.has(status)) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_STATUS_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await setAuthAccountStatusByAdmin({
|
||||
account,
|
||||
status,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore(
|
||||
{ ok: false, message },
|
||||
{ status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "set_account_mfa_required") {
|
||||
const account = stringValue(body.account);
|
||||
if (!account) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await setAuthAccountMfaRequiredByAdmin({
|
||||
account,
|
||||
required: body.required === true,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
account: publicAuthAccount(saved),
|
||||
mfaSetupSecret: body.required === true ? saved.mfaSecret : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "reset_account_password") {
|
||||
const account = stringValue(body.account);
|
||||
const password = stringValue(body.password);
|
||||
if (!account || !password) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_OR_PASSWORD_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await resetAuthAccountPasswordByAdmin({
|
||||
account,
|
||||
password,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore(
|
||||
{ ok: false, message },
|
||||
{ status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "upsert_company") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
if (!companyId) {
|
||||
return jsonNoStore({ ok: false, message: "COMPANY_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const company = await upsertAdminCompanyByAdmin({
|
||||
companyId,
|
||||
name: stringValue(body.name),
|
||||
ownerAccount: stringValue(body.ownerAccount),
|
||||
successOwnerAccount: stringValue(body.successOwnerAccount),
|
||||
planTier: validCompanyPlanTiers.has(stringValue(body.planTier))
|
||||
? (stringValue(body.planTier) as "trial" | "standard" | "enterprise")
|
||||
: undefined,
|
||||
contractExpiresAt: stringValue(body.contractExpiresAt),
|
||||
note: stringValue(body.note),
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, company });
|
||||
}
|
||||
|
||||
if (action === "set_company_status") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const status = stringValue(body.status);
|
||||
if (!companyId || !validCompanyStatuses.has(status)) {
|
||||
return jsonNoStore({ ok: false, message: "COMPANY_STATUS_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await setAdminCompanyStatusByAdmin({
|
||||
companyId,
|
||||
status: status as "active" | "disabled",
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "COMPANY_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "assign_account_company") {
|
||||
const account = stringValue(body.account);
|
||||
const companyId = stringValue(body.companyId);
|
||||
if (!account || !companyId) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_OR_COMPANY_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await assignAccountCompanyByAdmin({
|
||||
account,
|
||||
companyId,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message.endsWith("_NOT_FOUND") ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "assign_device_company") {
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
const companyId = stringValue(body.companyId);
|
||||
if (!deviceId || !companyId) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_OR_COMPANY_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const device = await assignDeviceCompanyByAdmin({
|
||||
deviceId,
|
||||
companyId,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, device });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message.endsWith("_NOT_FOUND") ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "bulk_import_accounts") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const accounts = accountImportValues(body.accounts);
|
||||
if (!companyId || accounts.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "BULK_IMPORT_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const imported = await bulkImportAuthAccountsByAdmin({
|
||||
companyId,
|
||||
accounts,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, imported: imported.map(publicAuthAccount) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message.endsWith("_NOT_FOUND") ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "preview_bulk_import_accounts") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const accounts = accountImportValues(body.accounts);
|
||||
if (!companyId || accounts.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "BULK_IMPORT_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const preview = await previewBulkImportAuthAccountsByAdmin({
|
||||
companyId,
|
||||
accounts,
|
||||
});
|
||||
return jsonNoStore({ ok: true, preview });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "COMPANY_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "reclaim_account") {
|
||||
const account = stringValue(body.account);
|
||||
if (!account) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await reclaimAuthAccountByAdmin({
|
||||
account,
|
||||
reason: stringValue(body.reason),
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
account: publicAuthAccount(result.account),
|
||||
removedGrants: result.removedGrants,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "grant_device") {
|
||||
const account = stringValue(body.account);
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
const permissions = permissionValues(body.permissions);
|
||||
if (!account || !deviceId || permissions.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_GRANT_INVALID" }, { status: 400 });
|
||||
}
|
||||
const grant = await saveAccountDeviceGrant({
|
||||
grantId: stringValue(body.grantId) || undefined,
|
||||
account,
|
||||
deviceId,
|
||||
permissions,
|
||||
grantedBy: auth.session.account,
|
||||
expiresAt: stringValue(body.expiresAt) || undefined,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
if (action === "grant_project") {
|
||||
const account = stringValue(body.account);
|
||||
const projectId = stringValue(body.projectId);
|
||||
const permissions = permissionValues(body.permissions);
|
||||
if (!account || !projectId || permissions.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "PROJECT_GRANT_INVALID" }, { status: 400 });
|
||||
}
|
||||
const grant = await saveAccountProjectGrant({
|
||||
grantId: stringValue(body.grantId) || undefined,
|
||||
account,
|
||||
projectId,
|
||||
deviceId: stringValue(body.deviceId) || undefined,
|
||||
permissions,
|
||||
inheritFromDeviceGrant: body.inheritFromDeviceGrant === true,
|
||||
grantedBy: auth.session.account,
|
||||
expiresAt: stringValue(body.expiresAt) || undefined,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
if (action === "grant_skill") {
|
||||
const account = stringValue(body.account);
|
||||
const skillId = stringValue(body.skillId);
|
||||
const permissions = permissionValues(body.permissions);
|
||||
if (!account || !skillId || permissions.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_GRANT_INVALID" }, { status: 400 });
|
||||
}
|
||||
const grant = await saveAccountSkillGrant({
|
||||
grantId: stringValue(body.grantId) || undefined,
|
||||
account,
|
||||
skillId,
|
||||
deviceId: stringValue(body.deviceId) || undefined,
|
||||
projectId: stringValue(body.projectId) || undefined,
|
||||
permissions,
|
||||
grantedBy: auth.session.account,
|
||||
expiresAt: stringValue(body.expiresAt) || undefined,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
if (action === "apply_template") {
|
||||
const account = stringValue(body.account);
|
||||
const templateId = stringValue(body.templateId);
|
||||
const template = BOSS_PERMISSION_TEMPLATES.find((item) => item.templateId === templateId);
|
||||
const deviceIds = stringArray(body.deviceIds);
|
||||
const projectIds = stringArray(body.projectIds);
|
||||
const skillIds = stringArray(body.skillIds);
|
||||
if (!account || !template || deviceIds.length + projectIds.length + skillIds.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "ACCESS_TEMPLATE_INVALID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
if (!state.authAccounts.some((item) => item.account === account)) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const missingDevice = deviceIds.find((deviceId) => !state.devices.some((device) => device.id === deviceId));
|
||||
const missingProject = projectIds.find((projectId) => !state.projects.some((project) => project.id === projectId));
|
||||
const missingSkill = skillIds.find((skillId) => !state.deviceSkills.some((skill) => skill.skillId === skillId));
|
||||
if (missingDevice || missingProject || missingSkill) {
|
||||
return jsonNoStore({
|
||||
ok: false,
|
||||
message: "ACCESS_TEMPLATE_TARGET_NOT_FOUND",
|
||||
missingDevice,
|
||||
missingProject,
|
||||
missingSkill,
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
const deviceGrants = [];
|
||||
for (const deviceId of deviceIds) {
|
||||
deviceGrants.push(await saveAccountDeviceGrant({
|
||||
grantId: `grant-template-${idSegment(templateId)}-device-${idSegment(account)}-${idSegment(deviceId)}`,
|
||||
account,
|
||||
deviceId,
|
||||
permissions: template.devicePermissions,
|
||||
grantedBy: auth.session.account,
|
||||
note: template.name,
|
||||
auditMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
const projectGrants = [];
|
||||
for (const projectId of projectIds) {
|
||||
projectGrants.push(await saveAccountProjectGrant({
|
||||
grantId: `grant-template-${idSegment(templateId)}-project-${idSegment(account)}-${idSegment(projectId)}`,
|
||||
account,
|
||||
projectId,
|
||||
permissions: template.projectPermissions,
|
||||
grantedBy: auth.session.account,
|
||||
note: template.name,
|
||||
auditMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
const skillGrants = [];
|
||||
for (const skillId of skillIds) {
|
||||
const skill = state.deviceSkills.find((item) => item.skillId === skillId);
|
||||
skillGrants.push(await saveAccountSkillGrant({
|
||||
grantId: `grant-template-${idSegment(templateId)}-skill-${idSegment(account)}-${idSegment(skillId)}`,
|
||||
account,
|
||||
skillId,
|
||||
deviceId: skill?.deviceId,
|
||||
permissions: template.skillPermissions,
|
||||
grantedBy: auth.session.account,
|
||||
note: template.name,
|
||||
auditMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
await appendPermissionAuditLog({
|
||||
actorAccount: auth.session.account,
|
||||
action: "grant.updated",
|
||||
targetAccount: account,
|
||||
permissions: [
|
||||
...template.devicePermissions,
|
||||
...template.projectPermissions,
|
||||
...template.skillPermissions,
|
||||
],
|
||||
detail: `template:${template.templateId}`,
|
||||
...auditMeta,
|
||||
});
|
||||
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
template,
|
||||
grants: {
|
||||
devices: deviceGrants,
|
||||
projects: projectGrants,
|
||||
skills: skillGrants,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "revoke_grant") {
|
||||
const grantId = stringValue(body.grantId);
|
||||
if (!grantId) {
|
||||
return jsonNoStore({ ok: false, message: "GRANT_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const grant = await revokeAccessGrant(grantId, auth.session.account, auditMeta);
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
return jsonNoStore({ ok: false, message: "UNKNOWN_ACTION" }, { status: 400 });
|
||||
}
|
||||
219
src/app/api/v1/admin/backoffice/route.ts
Normal file
219
src/app/api/v1/admin/backoffice/route.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
|
||||
import { buildAdminOverview } from "@/lib/boss-admin-overview";
|
||||
import { readState, type BossState } from "@/lib/boss-data";
|
||||
|
||||
const MENU_TREE = [
|
||||
{ key: "workbench", label: "工作台" },
|
||||
{ key: "tenant", label: "租户管理" },
|
||||
{ key: "user", label: "账号管理" },
|
||||
{ key: "role", label: "角色权限" },
|
||||
{
|
||||
key: "resource",
|
||||
label: "资源授权",
|
||||
children: [
|
||||
{ key: "resource.devices", label: "设备资源" },
|
||||
{ key: "resource.projects", label: "项目线程" },
|
||||
{ key: "resource.skills", label: "Skill 资源" },
|
||||
],
|
||||
},
|
||||
{ key: "skills", label: "Skill 中心" },
|
||||
{ key: "risk", label: "风险告警" },
|
||||
{ key: "audit", label: "审计日志" },
|
||||
{ key: "system", label: "系统设置" },
|
||||
] as const;
|
||||
|
||||
function companyNameMap(state: BossState) {
|
||||
return new Map(state.adminCompanies.map((company) => [company.companyId, company.name]));
|
||||
}
|
||||
|
||||
function companyNameFor(state: BossState, companyId?: string) {
|
||||
if (!companyId || companyId === "default") return "默认公司";
|
||||
return companyNameMap(state).get(companyId) ?? companyId;
|
||||
}
|
||||
|
||||
function safeUsers(state: BossState) {
|
||||
return state.authAccounts.map((account) => ({
|
||||
id: account.id,
|
||||
account: account.account,
|
||||
displayName: account.displayName,
|
||||
role: account.role,
|
||||
status: account.status ?? "active",
|
||||
companyId: account.companyId ?? "default",
|
||||
companyName: companyNameFor(state, account.companyId),
|
||||
primaryDeviceId: account.primaryDeviceId,
|
||||
codexNodeId: account.codexNodeId,
|
||||
codexNodeLabel: account.codexNodeLabel,
|
||||
lastLoginAt: account.lastLoginAt,
|
||||
lastLoginMethod: account.lastLoginMethod,
|
||||
mfaRequired: Boolean(account.mfaRequired),
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function skillResources(state: BossState) {
|
||||
const byName = new Map<
|
||||
string,
|
||||
{
|
||||
skillId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
invocation?: string;
|
||||
sourceType: "device" | "catalog";
|
||||
deviceCount: number;
|
||||
devices: Array<{ deviceId: string; updatedAt: string }>;
|
||||
updatedAt: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const skill of state.deviceSkills) {
|
||||
const existing = byName.get(skill.name);
|
||||
if (existing) {
|
||||
existing.deviceCount += existing.devices.some((device) => device.deviceId === skill.deviceId) ? 0 : 1;
|
||||
existing.devices.push({ deviceId: skill.deviceId, updatedAt: skill.updatedAt });
|
||||
if (skill.updatedAt.localeCompare(existing.updatedAt) > 0) {
|
||||
existing.updatedAt = skill.updatedAt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
byName.set(skill.name, {
|
||||
skillId: skill.skillId,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category: skill.category,
|
||||
invocation: skill.invocation,
|
||||
sourceType: "device",
|
||||
deviceCount: 1,
|
||||
devices: [{ deviceId: skill.deviceId, updatedAt: skill.updatedAt }],
|
||||
updatedAt: skill.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const catalogItem of state.skillCatalog) {
|
||||
if (byName.has(catalogItem.name)) continue;
|
||||
byName.set(catalogItem.name, {
|
||||
skillId: catalogItem.skillId,
|
||||
name: catalogItem.name,
|
||||
description: catalogItem.description,
|
||||
category: catalogItem.category,
|
||||
sourceType: "catalog",
|
||||
deviceCount: 0,
|
||||
devices: [],
|
||||
updatedAt: catalogItem.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return [...byName.values()].sort(
|
||||
(left, right) => right.deviceCount - left.deviceCount || left.name.localeCompare(right.name, "zh-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
function projectResources(state: BossState) {
|
||||
return state.projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
deviceIds: project.deviceIds,
|
||||
deviceCount: project.deviceIds.length,
|
||||
folderName: project.threadMeta.folderName,
|
||||
threadId: project.threadMeta.threadId,
|
||||
threadDisplayName: project.threadMeta.threadDisplayName,
|
||||
isGroup: project.isGroup,
|
||||
collaborationMode: project.collaborationMode,
|
||||
unreadCount: project.unreadCount,
|
||||
riskLevel: project.riskLevel,
|
||||
updatedAt: project.updatedAt,
|
||||
lastMessageAt: project.lastMessageAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function rolesContract() {
|
||||
return {
|
||||
builtInRoles: [
|
||||
{
|
||||
role: "highest_admin",
|
||||
label: "超级管理员",
|
||||
description: "平台侧最高权限,可管理全部公司、账号、设备、项目、Skill、风险和审计。",
|
||||
},
|
||||
{
|
||||
role: "admin",
|
||||
label: "企业管理员",
|
||||
description: "企业内管理员,按授权范围管理本公司资源。",
|
||||
},
|
||||
{
|
||||
role: "member",
|
||||
label: "成员账号",
|
||||
description: "企业子账号,只能访问已分配的电脑、项目和 Skill。",
|
||||
},
|
||||
],
|
||||
permissionTemplates: BOSS_PERMISSION_TEMPLATES,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackofficePayload(state: BossState) {
|
||||
const overview = buildAdminOverview(state);
|
||||
const skills = skillResources(state);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
menuTree: MENU_TREE,
|
||||
workbench: {
|
||||
summary: overview.summary,
|
||||
companies: overview.companies.slice(0, 10),
|
||||
devices: overview.devices.slice(0, 20),
|
||||
risks: overview.risks.slice(0, 20),
|
||||
notifications: overview.notifications,
|
||||
grantsSummary: overview.grantsSummary,
|
||||
},
|
||||
tenants: overview.companies.map((company) => ({
|
||||
...company,
|
||||
lifecycleStatus: company.status ?? "active",
|
||||
})),
|
||||
users: safeUsers(state),
|
||||
roles: rolesContract(),
|
||||
resourceGroups: {
|
||||
devices: overview.devices,
|
||||
projects: projectResources(state),
|
||||
skills,
|
||||
grants: {
|
||||
devices: state.accountDeviceGrants,
|
||||
projects: state.accountProjectGrants,
|
||||
skills: state.accountSkillGrants,
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
risks: overview.risks,
|
||||
notifications: overview.notifications,
|
||||
riskTimeline: overview.riskTimeline,
|
||||
permissionLogs: state.permissionAuditLogs
|
||||
.slice()
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
.slice(0, 100),
|
||||
},
|
||||
yudaoMapping: {
|
||||
tenant: "adminCompanies",
|
||||
user: "authAccounts",
|
||||
role: "BOSS_PERMISSION_TEMPLATES",
|
||||
menu: "menuTree",
|
||||
operateLog: "permissionAuditLogs",
|
||||
resource: "devices/projects/deviceSkills",
|
||||
risk: "opsFaults/threadContextAlerts/masterAgentTasks/adminNotifications",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore(buildBackofficePayload(state));
|
||||
}
|
||||
29
src/app/api/v1/admin/notifications/dispatch/route.ts
Normal file
29
src/app/api/v1/admin/notifications/dispatch/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { dispatchAdminRiskNotifications } from "@/lib/boss-data";
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { max?: number };
|
||||
const result = await dispatchAdminRiskNotifications({
|
||||
actorAccount: session.account,
|
||||
max: typeof body.max === "number" && Number.isFinite(body.max) ? body.max : undefined,
|
||||
});
|
||||
return jsonNoStore({ ok: true, ...result });
|
||||
}
|
||||
21
src/app/api/v1/admin/overview/route.ts
Normal file
21
src/app/api/v1/admin/overview/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { buildAdminOverview } from "@/lib/boss-admin-overview";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
...buildAdminOverview(state),
|
||||
});
|
||||
}
|
||||
67
src/app/api/v1/admin/risks/actions/route.ts
Normal file
67
src/app/api/v1/admin/risks/actions/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { handleAdminRiskAction, type AdminRiskAction } from "@/lib/boss-data";
|
||||
|
||||
const validActions = new Set<AdminRiskAction>(["ack", "resolve", "create_repair_ticket", "assign_owner", "set_sla"]);
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function statusForError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
if (message === "RISK_TARGET_NOT_FOUND") return 404;
|
||||
if (
|
||||
message === "RISK_ACTION_UNSUPPORTED" ||
|
||||
message === "RISK_ID_INVALID" ||
|
||||
message === "RISK_OWNER_REQUIRED" ||
|
||||
message === "RISK_SLA_REQUIRED"
|
||||
) return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const riskId = stringValue(body.riskId);
|
||||
const action = stringValue(body.action) as AdminRiskAction;
|
||||
if (!riskId || !validActions.has(action)) {
|
||||
return jsonNoStore({ ok: false, message: "RISK_ACTION_INVALID" }, { status: 400 });
|
||||
}
|
||||
const ownerAccount = stringValue(body.ownerAccount);
|
||||
const slaDueAt = stringValue(body.slaDueAt);
|
||||
const note = stringValue(body.note);
|
||||
if (action === "assign_owner" && !ownerAccount) {
|
||||
return jsonNoStore({ ok: false, message: "RISK_OWNER_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
if (action === "set_sla" && !slaDueAt) {
|
||||
return jsonNoStore({ ok: false, message: "RISK_SLA_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleAdminRiskAction({
|
||||
riskId,
|
||||
action,
|
||||
actorAccount: session.account,
|
||||
ownerAccount,
|
||||
slaDueAt,
|
||||
note,
|
||||
});
|
||||
return jsonNoStore({ ok: true, riskId, action, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: statusForError(error) });
|
||||
}
|
||||
}
|
||||
27
src/app/api/v1/admin/risks/scan/route.ts
Normal file
27
src/app/api/v1/admin/risks/scan/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { scanAdminRiskNotifications } from "@/lib/boss-data";
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const result = await scanAdminRiskNotifications({
|
||||
actorAccount: session.account,
|
||||
});
|
||||
return jsonNoStore({ ok: true, ...result });
|
||||
}
|
||||
110
src/app/api/v1/admin/skills/requests/route.ts
Normal file
110
src/app/api/v1/admin/skills/requests/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { buildRequestAuditMeta } from "@/lib/boss-audit";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import {
|
||||
createSkillLifecycleRequest,
|
||||
readState,
|
||||
type SkillLifecycleAction,
|
||||
} from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
const validActions = new Set<SkillLifecycleAction>([
|
||||
"install",
|
||||
"update",
|
||||
"uninstall",
|
||||
"rollback",
|
||||
"version_lock",
|
||||
]);
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
async function requireHighestAdmin(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return { ok: false as const, response: jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }) };
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return { ok: false as const, response: forbidden() };
|
||||
}
|
||||
return { ok: true as const, session };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
requests: [...state.skillLifecycleRequests].sort((left, right) =>
|
||||
right.requestedAt.localeCompare(left.requestedAt),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const action = stringValue(body.action) as SkillLifecycleAction;
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
const skillId = stringValue(body.skillId);
|
||||
const sourceUrl = stringValue(body.sourceUrl);
|
||||
const trustedSource = stringValue(body.trustedSource);
|
||||
const trustedSourceId = stringValue(body.trustedSourceId);
|
||||
|
||||
if (!validActions.has(action)) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_ACTION_INVALID" }, { status: 400 });
|
||||
}
|
||||
if (!deviceId) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
if (!skillId && !sourceUrl && !trustedSource && !trustedSourceId) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_ID_OR_SOURCE_URL_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
if (!state.devices.some((device) => device.id === deviceId)) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
skillId &&
|
||||
!state.deviceSkills.some((skill) => skill.deviceId === deviceId && skill.skillId === skillId)
|
||||
) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const requestRecord = await createSkillLifecycleRequest({
|
||||
action,
|
||||
deviceId,
|
||||
skillId: skillId || undefined,
|
||||
sourceUrl: sourceUrl || undefined,
|
||||
trustedSource: trustedSource || undefined,
|
||||
trustedSourceId: trustedSourceId || undefined,
|
||||
checksum: stringValue(body.checksum) || undefined,
|
||||
expectedChecksum: stringValue(body.expectedChecksum) || undefined,
|
||||
targetVersion: stringValue(body.targetVersion) || undefined,
|
||||
rollbackToVersion: stringValue(body.rollbackToVersion) || undefined,
|
||||
lockedVersion: stringValue(body.lockedVersion) || undefined,
|
||||
requestedBy: auth.session.account,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta: buildRequestAuditMeta(request),
|
||||
});
|
||||
|
||||
return jsonNoStore({ ok: true, request: requestRecord });
|
||||
}
|
||||
24
src/app/api/v1/audits/permission-logs/route.ts
Normal file
24
src/app/api/v1/audits/permission-logs/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { permissionAuditQueryFromSearchParams, queryPermissionAuditLogs, summarizePermissionAuditRisks } from "@/lib/boss-audit";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const query = permissionAuditQueryFromSearchParams(request.nextUrl.searchParams);
|
||||
const result = queryPermissionAuditLogs(state.permissionAuditLogs, query);
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
...result,
|
||||
riskSummary: summarizePermissionAuditRisks(state),
|
||||
});
|
||||
}
|
||||
75
src/app/api/v1/auth/sessions/route.ts
Normal file
75
src/app/api/v1/auth/sessions/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { AUTH_SESSION_COOKIE, requireRequestSession } from "@/lib/boss-auth";
|
||||
import { readState, revokeAuthSessionById } from "@/lib/boss-data";
|
||||
import type { AuthSession } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
function isActiveSession(session: AuthSession) {
|
||||
return !session.revokedAt && new Date(session.expiresAt).getTime() > Date.now();
|
||||
}
|
||||
|
||||
function publicSession(session: AuthSession, currentToken?: string | null) {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
account: session.account,
|
||||
role: session.role,
|
||||
displayName: session.displayName,
|
||||
loginMethod: session.loginMethod,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastSeenAt: session.lastSeenAt,
|
||||
current: Boolean(currentToken && session.sessionToken === currentToken),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const currentToken = request.cookies.get(AUTH_SESSION_COOKIE)?.value;
|
||||
const state = await readState();
|
||||
const sessions = state.authSessions
|
||||
.filter(isActiveSession)
|
||||
.filter((item) => session.role === "highest_admin" || item.account === session.account)
|
||||
.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
|
||||
.map((item) => publicSession(item, currentToken));
|
||||
|
||||
return jsonNoStore({ ok: true, sessions });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
action?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
if (body.action !== "revoke_session") {
|
||||
return jsonNoStore({ ok: false, message: "UNKNOWN_ACTION" }, { status: 400 });
|
||||
}
|
||||
if (!body.sessionId?.trim()) {
|
||||
return jsonNoStore({ ok: false, message: "SESSION_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const revoked = await revokeAuthSessionById({
|
||||
sessionId: body.sessionId.trim(),
|
||||
actorAccount: session.account,
|
||||
actorRole: session.role,
|
||||
});
|
||||
if (!revoked) {
|
||||
return jsonNoStore({ ok: false, message: "SESSION_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
return jsonNoStore({ ok: true, session: publicSession(revoked) });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "FORBIDDEN_AUTH_SESSION") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getConversationFolderView } from "@/lib/boss-projections";
|
||||
import { getConversationFolderViewForSession } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function GET(
|
||||
}
|
||||
const { folderKey } = await context.params;
|
||||
const state = await readState();
|
||||
const folder = getConversationFolderView(state, decodeURIComponent(folderKey));
|
||||
const folder = getConversationFolderViewForSession(state, session, decodeURIComponent(folderKey));
|
||||
if (!folder) {
|
||||
return jsonNoStore({ ok: false, message: "FOLDER_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getConversationHomeItems } from "@/lib/boss-projections";
|
||||
import { getConversationHomeItemsForSession } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
@@ -12,6 +12,6 @@ export async function GET(request: NextRequest) {
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
conversations: getConversationHomeItems(state),
|
||||
conversations: getConversationHomeItemsForSession(state, session),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getConversationItems } from "@/lib/boss-projections";
|
||||
import { getConversationItemsForSession } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
@@ -12,6 +12,6 @@ export async function GET(request: NextRequest) {
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
conversations: getConversationItems(state),
|
||||
conversations: getConversationItemsForSession(state, session),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { completeSkillLifecycleRequest } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string; requestId: string }> },
|
||||
) {
|
||||
const { deviceId, requestId } = await context.params;
|
||||
const auth = await authorizeDeviceWriteRequest(request, deviceId);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (!auth.device) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
status?: "completed" | "failed";
|
||||
resultSummary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const skillRequest = await completeSkillLifecycleRequest({
|
||||
requestId,
|
||||
deviceId,
|
||||
status: body.status === "failed" ? "failed" : "completed",
|
||||
resultSummary: typeof body.resultSummary === "string" ? body.resultSummary : undefined,
|
||||
error: typeof body.error === "string" ? body.error : undefined,
|
||||
});
|
||||
return NextResponse.json({ ok: true, request: skillRequest });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { claimNextSkillLifecycleRequest } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ deviceId: string }> },
|
||||
) {
|
||||
const { deviceId } = await context.params;
|
||||
const auth = await authorizeDeviceWriteRequest(request, deviceId);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (!auth.device) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const skillRequest = await claimNextSkillLifecycleRequest(deviceId);
|
||||
return NextResponse.json({ ok: true, request: skillRequest });
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { readState, upsertDeviceSkills } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { canAccessDevice, canViewSkill } from "@/lib/boss-permissions";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -18,13 +19,24 @@ export async function GET(
|
||||
if (!device) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const deviceSkills = state.deviceSkills
|
||||
.filter((item) => item.deviceId === deviceId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||||
const visibleSkills = session.role === "highest_admin"
|
||||
? deviceSkills
|
||||
: deviceSkills.filter((skill) => canViewSkill(state, session, skill.skillId, { deviceId }));
|
||||
if (
|
||||
session.role !== "highest_admin" &&
|
||||
!canAccessDevice(state, session, deviceId, "device.view") &&
|
||||
visibleSkills.length === 0
|
||||
) {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
device,
|
||||
skills: state.deviceSkills
|
||||
.filter((item) => item.deviceId === deviceId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")),
|
||||
skills: visibleSkills,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getDeviceWorkspaceView } from "@/lib/boss-projections";
|
||||
import { getDeviceWorkspaceViewForSession } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { filterDevicesForSession } from "@/lib/boss-permissions";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
@@ -12,11 +13,13 @@ export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const deviceId = url.searchParams.get("device");
|
||||
const state = await readState();
|
||||
const devices = filterDevicesForSession(state, session);
|
||||
const visibleDeviceIds = new Set(devices.map((device) => device.id));
|
||||
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
devices: state.devices,
|
||||
enrollments: state.deviceEnrollments,
|
||||
workspace: getDeviceWorkspaceView(state, deviceId ?? undefined),
|
||||
devices,
|
||||
enrollments: state.deviceEnrollments.filter((item) => visibleDeviceIds.has(item.deviceId)),
|
||||
workspace: getDeviceWorkspaceViewForSession(state, session, deviceId ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import {
|
||||
readState,
|
||||
resolveDialogGuardInterventionDecision,
|
||||
type DialogGuardInterventionAction,
|
||||
} from "@/lib/boss-data";
|
||||
import { canAccessDevice, canAccessProject } from "@/lib/boss-permissions";
|
||||
|
||||
const validDecisions = new Set<DialogGuardInterventionAction>([
|
||||
"allow_once",
|
||||
"allow_for_device_dialog",
|
||||
"deny",
|
||||
"handled_on_device",
|
||||
"cancel_task",
|
||||
]);
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function statusForError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
if (message === "DIALOG_GUARD_INTERVENTION_NOT_FOUND") return 404;
|
||||
if (
|
||||
message === "DIALOG_GUARD_INTERVENTION_ALREADY_RESOLVED" ||
|
||||
message === "DIALOG_GUARD_DECISION_NOT_AVAILABLE"
|
||||
) {
|
||||
return 409;
|
||||
}
|
||||
return 400;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ interventionId: string }> },
|
||||
) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { interventionId } = await context.params;
|
||||
const state = await readState();
|
||||
const intervention = state.dialogGuardInterventions.find(
|
||||
(item) => item.interventionId === interventionId,
|
||||
);
|
||||
if (!intervention) {
|
||||
return jsonNoStore({ ok: false, message: "DIALOG_GUARD_INTERVENTION_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const canResolve =
|
||||
session.role === "highest_admin" ||
|
||||
canAccessProject(state, session, intervention.projectId, "computer.control") ||
|
||||
canAccessDevice(state, session, intervention.deviceId, "device.manage");
|
||||
if (!canResolve) {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const decision = stringValue(body.decision) as DialogGuardInterventionAction;
|
||||
if (!validDecisions.has(decision)) {
|
||||
return jsonNoStore({ ok: false, message: "DIALOG_GUARD_DECISION_INVALID" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveDialogGuardInterventionDecision({
|
||||
interventionId,
|
||||
actorAccount: session.account,
|
||||
decision,
|
||||
note: stringValue(body.note),
|
||||
});
|
||||
return jsonNoStore({ ok: true, intervention: resolved });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: statusForError(error) });
|
||||
}
|
||||
}
|
||||
111
src/app/api/v1/integrations/telegram/route.ts
Normal file
111
src/app/api/v1/integrations/telegram/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import {
|
||||
getAuthorizedTelegramConfigSession,
|
||||
getTelegramIntegrationView,
|
||||
probeTelegramBot,
|
||||
saveTelegramIntegrationConfig,
|
||||
syncTelegramWebhookRegistration,
|
||||
} from "@/lib/telegram-gateway";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAuthorizedTelegramConfigSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
return jsonNoStore({ ok: true, telegram: await getTelegramIntegrationView() });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAuthorizedTelegramConfigSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
enabled?: boolean;
|
||||
mode?: "webhook" | "polling";
|
||||
botToken?: string;
|
||||
botUsername?: string;
|
||||
dmPolicy?: "allowlist" | "open" | "disabled";
|
||||
allowFrom?: string[];
|
||||
groupPolicy?: "allowlist" | "open" | "disabled";
|
||||
groups?: string[];
|
||||
requireMentionInGroups?: boolean;
|
||||
defaultProjectId?: string;
|
||||
groupProjectRoutes?: Array<{ chatId: string; threadId?: number; projectId: string; label?: string }>;
|
||||
webhookSecret?: string;
|
||||
webhookUrl?: string;
|
||||
testConnection?: boolean;
|
||||
};
|
||||
|
||||
let telegram = await saveTelegramIntegrationConfig({
|
||||
enabled: body.enabled === true,
|
||||
mode: body.mode,
|
||||
botToken: body.botToken,
|
||||
botUsername: body.botUsername,
|
||||
dmPolicy: body.dmPolicy,
|
||||
allowFrom: body.allowFrom,
|
||||
groupPolicy: body.groupPolicy,
|
||||
groups: body.groups,
|
||||
requireMentionInGroups: body.requireMentionInGroups,
|
||||
defaultProjectId: body.defaultProjectId,
|
||||
groupProjectRoutes: body.groupProjectRoutes,
|
||||
webhookSecret: body.webhookSecret,
|
||||
webhookUrl: body.webhookUrl,
|
||||
configuredBy: session.account,
|
||||
});
|
||||
let webhookSync:
|
||||
| { ok: true; action: "set_webhook" | "delete_webhook" | "skipped"; reason?: string }
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
webhookSync = await syncTelegramWebhookRegistration();
|
||||
} catch (error) {
|
||||
return jsonNoStore(
|
||||
{
|
||||
ok: false,
|
||||
telegram,
|
||||
message: error instanceof Error ? error.message : "TELEGRAM_WEBHOOK_SYNC_FAILED",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.testConnection) {
|
||||
return jsonNoStore({ ok: true, telegram, webhookSync });
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await probeTelegramBot();
|
||||
if (probe.username && probe.username !== telegram.botUsername) {
|
||||
telegram = await saveTelegramIntegrationConfig({
|
||||
enabled: body.enabled === true,
|
||||
mode: body.mode,
|
||||
botToken: body.botToken,
|
||||
botUsername: probe.username,
|
||||
dmPolicy: body.dmPolicy,
|
||||
allowFrom: body.allowFrom,
|
||||
groupPolicy: body.groupPolicy,
|
||||
groups: body.groups,
|
||||
requireMentionInGroups: body.requireMentionInGroups,
|
||||
defaultProjectId: body.defaultProjectId,
|
||||
groupProjectRoutes: body.groupProjectRoutes,
|
||||
webhookSecret: body.webhookSecret,
|
||||
webhookUrl: body.webhookUrl,
|
||||
configuredBy: session.account,
|
||||
});
|
||||
}
|
||||
return jsonNoStore({ ok: true, telegram, probe, webhookSync });
|
||||
} catch (error) {
|
||||
return jsonNoStore(
|
||||
{
|
||||
ok: false,
|
||||
telegram,
|
||||
webhookSync,
|
||||
message: error instanceof Error ? error.message : "TELEGRAM_PROBE_FAILED",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
6
src/app/api/v1/integrations/telegram/webhook/route.ts
Normal file
6
src/app/api/v1/integrations/telegram/webhook/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { handleTelegramWebhookRequest } from "@/lib/telegram-gateway";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleTelegramWebhookRequest({ request });
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import type { ExecutionProgressInput } from "@/lib/boss-data";
|
||||
import { completeMasterAgentTask } from "@/lib/boss-data";
|
||||
import { normalizeRemoteExecutionResult } from "@/lib/execution/remote-runtime-adapter";
|
||||
import { deliverTelegramReplyForCompletedTask } from "@/lib/telegram-gateway";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@@ -9,14 +11,25 @@ export async function POST(
|
||||
) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
deviceId?: string;
|
||||
status?: "completed" | "failed";
|
||||
status?: "completed" | "failed" | "needs_user_action";
|
||||
kind?: string;
|
||||
replyBody?: string;
|
||||
errorMessage?: string;
|
||||
requestId?: string;
|
||||
dialogId?: string;
|
||||
appName?: string;
|
||||
platform?: string;
|
||||
risk?: "low" | "medium" | "high";
|
||||
summary?: string;
|
||||
recommendedAction?: "allow_once" | "allow_for_device_dialog" | "deny" | "handled_on_device" | "cancel_task";
|
||||
availableActions?: Array<"allow_once" | "allow_for_device_dialog" | "deny" | "handled_on_device" | "cancel_task">;
|
||||
dispatchExecutionId?: string;
|
||||
targetProjectId?: string;
|
||||
targetThreadId?: string;
|
||||
targetUrl?: string;
|
||||
targetApp?: string;
|
||||
rawThreadReply?: string;
|
||||
executionProgress?: ExecutionProgressInput;
|
||||
};
|
||||
|
||||
if (!body.deviceId?.trim()) {
|
||||
@@ -31,6 +44,24 @@ export async function POST(
|
||||
const { taskId } = await context.params;
|
||||
|
||||
try {
|
||||
if (body.status === "needs_user_action") {
|
||||
const task = await completeMasterAgentTask({
|
||||
taskId,
|
||||
deviceId: body.deviceId.trim(),
|
||||
status: "needs_user_action",
|
||||
kind: body.kind,
|
||||
requestId: body.requestId,
|
||||
dialogId: body.dialogId,
|
||||
appName: body.appName,
|
||||
platform: body.platform,
|
||||
risk: body.risk,
|
||||
summary: body.summary,
|
||||
recommendedAction: body.recommendedAction,
|
||||
availableActions: Array.isArray(body.availableActions) ? body.availableActions : undefined,
|
||||
});
|
||||
return NextResponse.json({ ok: true, task });
|
||||
}
|
||||
|
||||
const normalized = normalizeRemoteExecutionResult({
|
||||
...body,
|
||||
status: body.status === "failed" ? "failed" : "completed",
|
||||
@@ -45,8 +76,12 @@ export async function POST(
|
||||
dispatchExecutionId: normalized.dispatchExecutionId,
|
||||
targetProjectId: normalized.targetProjectId,
|
||||
targetThreadId: normalized.targetThreadId,
|
||||
targetUrl: normalized.targetUrl,
|
||||
targetApp: normalized.targetApp,
|
||||
rawThreadReply: normalized.rawThreadReply,
|
||||
executionProgress: normalized.executionProgress,
|
||||
});
|
||||
await deliverTelegramReplyForCompletedTask(task.taskId).catch(() => null);
|
||||
return NextResponse.json({ ok: true, task });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -4,22 +4,30 @@ import {
|
||||
appendProjectMessage,
|
||||
appendProjectMessages,
|
||||
buildCollaborationGate,
|
||||
deleteProjectMessage,
|
||||
getProjectAgentControls,
|
||||
hasProjectSummarySyncNotifyPreferenceInState,
|
||||
rememberProjectSummarySyncNotifyPreference,
|
||||
readState,
|
||||
requestProjectUnderstandingSyncForProject,
|
||||
updateProjectAgentControls,
|
||||
} from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { buildProjectMessagesRealtimePayload } from "@/lib/boss-projections";
|
||||
import { buildProjectMessagesRealtimePayloadForSession } from "@/lib/boss-projections";
|
||||
import { canAccessProject } from "@/lib/boss-permissions";
|
||||
import {
|
||||
buildMasterAgentProjectSummarySyncAck,
|
||||
getThreadConversationExecutionConflict,
|
||||
queueGroupDispatchPlan,
|
||||
queueThreadConversationReplyTask,
|
||||
replyToMasterAgentUserMessage,
|
||||
resolveMasterAgentProjectSummarySyncTarget,
|
||||
shouldRecommendMasterAgentDispatchPlan,
|
||||
ThreadConversationExecutionConflictError,
|
||||
tryBuildLocalMasterAgentFastReply,
|
||||
} from "@/lib/boss-master-agent";
|
||||
import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy";
|
||||
import type { Message } from "@/lib/boss-data";
|
||||
|
||||
function dispatchFailureNotice(error?: string) {
|
||||
switch (error) {
|
||||
@@ -45,6 +53,10 @@ function threadConversationFailureMessage(error?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function forbiddenResponse(message = "FORBIDDEN") {
|
||||
return NextResponse.json({ ok: false, message }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
@@ -56,7 +68,14 @@ export async function GET(
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const payload = buildProjectMessagesRealtimePayload(state, projectId);
|
||||
const projectExists = state.projects.some((project) => project.id === projectId);
|
||||
if (!canAccessProject(state, session, projectId, "project.view")) {
|
||||
return jsonNoStore(
|
||||
{ ok: false, message: projectExists ? "FORBIDDEN" : "PROJECT_NOT_FOUND" },
|
||||
{ status: projectExists ? 403 : 404 },
|
||||
);
|
||||
}
|
||||
const payload = buildProjectMessagesRealtimePayloadForSession(state, session, projectId);
|
||||
if (!payload) {
|
||||
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
@@ -81,13 +100,38 @@ export async function POST(
|
||||
try {
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const requestKind = body.kind ?? "text";
|
||||
const requestText = (body.body ?? "").trim();
|
||||
const masterAgentMention =
|
||||
project && projectId !== "master-agent" && requestKind === "text"
|
||||
? parseMasterAgentMention(requestText)
|
||||
: null;
|
||||
if (!canAccessProject(state, session, projectId, "project.view")) {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
if (masterAgentMention || projectId === "master-agent") {
|
||||
if (!canAccessProject(state, session, projectId, "master_agent.ask")) {
|
||||
return forbiddenResponse("MASTER_AGENT_FORBIDDEN");
|
||||
}
|
||||
} else if (!canAccessProject(state, session, projectId, "thread.chat")) {
|
||||
return forbiddenResponse("THREAD_CHAT_FORBIDDEN");
|
||||
}
|
||||
const masterAgentProjectSummarySyncTarget =
|
||||
projectId === "master-agent" && requestKind === "text"
|
||||
? resolveMasterAgentProjectSummarySyncTarget(state, requestText)
|
||||
: null;
|
||||
const shouldCreateDispatchPlan =
|
||||
!masterAgentMention &&
|
||||
!masterAgentProjectSummarySyncTarget &&
|
||||
Boolean(project) &&
|
||||
((project?.isGroup && project.id !== "master-agent") ||
|
||||
(project?.id === "master-agent" &&
|
||||
shouldRecommendMasterAgentDispatchPlan(state, (body.body ?? "").trim()))) &&
|
||||
(body.kind ?? "text") === "text" &&
|
||||
(body.body ?? "").trim().length > 0;
|
||||
shouldRecommendMasterAgentDispatchPlan(state, requestText))) &&
|
||||
requestKind === "text" &&
|
||||
requestText.length > 0;
|
||||
|
||||
const pendingPlan = shouldCreateDispatchPlan
|
||||
? [...state.dispatchPlans]
|
||||
@@ -101,7 +145,7 @@ export async function POST(
|
||||
hasPendingDispatchPlan: Boolean(pendingPlan),
|
||||
});
|
||||
|
||||
if (!permission.allowed) {
|
||||
if (!permission.allowed && !masterAgentMention) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
@@ -114,11 +158,12 @@ export async function POST(
|
||||
}
|
||||
|
||||
const isSingleThreadTextMessage =
|
||||
!masterAgentMention &&
|
||||
Boolean(project) &&
|
||||
projectId !== "master-agent" &&
|
||||
!project?.isGroup &&
|
||||
(body.kind ?? "text") === "text" &&
|
||||
(body.body ?? "").trim().length > 0;
|
||||
requestKind === "text" &&
|
||||
requestText.length > 0;
|
||||
const singleThreadAgentControls = isSingleThreadTextMessage
|
||||
? await getProjectAgentControls(projectId, session.account)
|
||||
: null;
|
||||
@@ -139,9 +184,198 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
if (projectId === "master-agent" && (body.kind ?? "text") === "text" && (body.body ?? "").trim()) {
|
||||
if (masterAgentMention && project) {
|
||||
const message = await appendProjectMessage({
|
||||
projectId,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: requestKind,
|
||||
});
|
||||
|
||||
if (shouldDisableCurrentProjectTakeoverFromMasterMention(masterAgentMention.requestText)) {
|
||||
await updateProjectAgentControls(projectId, { takeoverEnabled: false }, session.account);
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverDisabledReply(nextProject),
|
||||
kind: "text",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message,
|
||||
replyMessage,
|
||||
masterReply: {
|
||||
ok: true,
|
||||
masterReplyState: "completed",
|
||||
replyMessage,
|
||||
},
|
||||
task: null,
|
||||
replyPresenter: "master",
|
||||
masterReplyState: "completed",
|
||||
dispatchPlan: null,
|
||||
dispatchRecommendation: {
|
||||
ok: false,
|
||||
status: "skipped",
|
||||
},
|
||||
collaborationGate: buildCollaborationGate(nextProject),
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldEnableCurrentProjectTakeoverFromMasterMention(masterAgentMention.requestText)) {
|
||||
await updateProjectAgentControls(projectId, { takeoverEnabled: true }, session.account);
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverEnabledReply(nextProject),
|
||||
kind: "text",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message,
|
||||
replyMessage,
|
||||
masterReply: {
|
||||
ok: true,
|
||||
masterReplyState: "completed",
|
||||
replyMessage,
|
||||
},
|
||||
task: null,
|
||||
replyPresenter: "master",
|
||||
masterReplyState: "completed",
|
||||
dispatchPlan: null,
|
||||
dispatchRecommendation: {
|
||||
ok: false,
|
||||
status: "skipped",
|
||||
},
|
||||
collaborationGate: buildCollaborationGate(nextProject),
|
||||
});
|
||||
}
|
||||
|
||||
if (!project.isGroup && shouldRequestVerifiedProjectSummarySync(masterAgentMention.requestText)) {
|
||||
const explicitNotify = shouldNotifyAfterProjectSummarySync(masterAgentMention.requestText);
|
||||
const rememberPreference =
|
||||
explicitNotify && shouldRememberProjectSummarySyncNotice(masterAgentMention.requestText);
|
||||
const rememberedPreference =
|
||||
rememberPreference || hasProjectSummarySyncNotifyPreferenceInState(state, session.account);
|
||||
if (rememberPreference) {
|
||||
await rememberProjectSummarySyncNotifyPreference({
|
||||
account: session.account,
|
||||
sourceMessageId: message.id,
|
||||
});
|
||||
}
|
||||
const syncTask = await requestProjectUnderstandingSyncForProject({
|
||||
projectId,
|
||||
observedActivityAt: message.sentAt,
|
||||
reason: "thread_reply",
|
||||
replyProjectId: projectId,
|
||||
notifyOnCompletion: explicitNotify || rememberedPreference,
|
||||
requestedByAccount: session.account,
|
||||
});
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(project, {
|
||||
notifyOnCompletion: explicitNotify || rememberedPreference,
|
||||
rememberedPreference: rememberPreference,
|
||||
}),
|
||||
kind: "system_notice",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message,
|
||||
replyMessage,
|
||||
masterReply: {
|
||||
ok: true,
|
||||
taskId: syncTask?.taskId,
|
||||
masterReplyState: syncTask ? "queued" : "completed",
|
||||
task: syncTask
|
||||
? {
|
||||
taskId: syncTask.taskId,
|
||||
taskType: "conversation_reply" as const,
|
||||
status: "queued" as const,
|
||||
}
|
||||
: undefined,
|
||||
replyMessage,
|
||||
},
|
||||
task: syncTask
|
||||
? {
|
||||
taskId: syncTask.taskId,
|
||||
taskType: "conversation_reply" as const,
|
||||
status: "queued" as const,
|
||||
}
|
||||
: null,
|
||||
replyPresenter: "master",
|
||||
masterReplyState: syncTask ? "queued" : "completed",
|
||||
dispatchPlan: null,
|
||||
dispatchRecommendation: {
|
||||
ok: false,
|
||||
status: "skipped",
|
||||
},
|
||||
collaborationGate: buildCollaborationGate(project),
|
||||
});
|
||||
}
|
||||
|
||||
const masterReply = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: message.id,
|
||||
requestText: masterAgentMention.requestText,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
projectId,
|
||||
interactionMode: "direct",
|
||||
mode: "smart",
|
||||
});
|
||||
const mentionMasterReply = masterReply as {
|
||||
ok?: boolean;
|
||||
taskId?: string;
|
||||
masterReplyState?: "queued" | "running" | "completed";
|
||||
task?: {
|
||||
taskId: string;
|
||||
taskType: "conversation_reply";
|
||||
status: "queued" | "running" | "completed";
|
||||
};
|
||||
replyMessage?: Awaited<ReturnType<typeof appendProjectMessage>>;
|
||||
};
|
||||
const task = mentionMasterReply.taskId
|
||||
? mentionMasterReply.task ?? {
|
||||
taskId: mentionMasterReply.taskId,
|
||||
taskType: "conversation_reply" as const,
|
||||
status: mentionMasterReply.masterReplyState ?? "queued",
|
||||
}
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message,
|
||||
replyMessage: mentionMasterReply.replyMessage,
|
||||
masterReply,
|
||||
task,
|
||||
replyPresenter: "master",
|
||||
masterReplyState: mentionMasterReply.masterReplyState ?? (mentionMasterReply.ok ? "completed" : null),
|
||||
executionMode: (mentionMasterReply as { executionMode?: "discussion" | "thread" | "development" | "browser" | "desktop" }).executionMode,
|
||||
riskLevel: (mentionMasterReply as { riskLevel?: "low" | "medium" | "high" }).riskLevel,
|
||||
requiresConfirmation: (mentionMasterReply as { requiresConfirmation?: boolean }).requiresConfirmation,
|
||||
dispatchPlan: null,
|
||||
dispatchRecommendation: {
|
||||
ok: false,
|
||||
status: "skipped",
|
||||
},
|
||||
collaborationGate: buildCollaborationGate(project),
|
||||
});
|
||||
}
|
||||
|
||||
if (projectId === "master-agent" && requestKind === "text" && requestText) {
|
||||
const localMasterReply = await tryBuildLocalMasterAgentFastReply({
|
||||
requestText: (body.body ?? "").trim(),
|
||||
requestText,
|
||||
requestedByAccount: session.account,
|
||||
projectId,
|
||||
state,
|
||||
@@ -153,7 +387,7 @@ export async function POST(
|
||||
{
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: body.kind ?? "text",
|
||||
kind: requestKind,
|
||||
},
|
||||
{
|
||||
sender: "master",
|
||||
@@ -186,7 +420,7 @@ export async function POST(
|
||||
projectId,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: body.kind ?? "text",
|
||||
kind: requestKind,
|
||||
});
|
||||
let dispatchPlan = null;
|
||||
let dispatchRecommendation:
|
||||
@@ -208,17 +442,20 @@ export async function POST(
|
||||
masterReplyState?: "queued" | "running" | "completed";
|
||||
task?: {
|
||||
taskId: string;
|
||||
taskType: "conversation_reply";
|
||||
taskType: "conversation_reply" | "browser_control" | "desktop_control";
|
||||
status: "queued" | "running" | "completed";
|
||||
};
|
||||
replyMessage?: Awaited<ReturnType<typeof appendProjectMessage>>;
|
||||
replyMessage?: Message;
|
||||
executionMode?: "discussion" | "thread" | "development" | "browser" | "desktop";
|
||||
riskLevel?: "low" | "medium" | "high";
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
let replyMessage: Awaited<ReturnType<typeof appendProjectMessage>> | undefined;
|
||||
let task:
|
||||
| {
|
||||
taskId: string;
|
||||
taskType: "conversation_reply";
|
||||
taskType: "conversation_reply" | "browser_control" | "desktop_control";
|
||||
status: "queued" | "running" | "completed";
|
||||
}
|
||||
| null = null;
|
||||
@@ -228,8 +465,72 @@ export async function POST(
|
||||
| "completed"
|
||||
| null = null;
|
||||
let replyPresenter: "thread" | "master" | undefined;
|
||||
let executionMode:
|
||||
| "discussion"
|
||||
| "thread"
|
||||
| "development"
|
||||
| "browser"
|
||||
| "desktop"
|
||||
| undefined;
|
||||
let riskLevel: "low" | "medium" | "high" | undefined;
|
||||
let requiresConfirmation: boolean | undefined;
|
||||
|
||||
if (shouldCreateDispatchPlan) {
|
||||
if (masterAgentProjectSummarySyncTarget && message.body.trim().length > 0) {
|
||||
const explicitNotify = shouldNotifyAfterProjectSummarySync(message.body);
|
||||
const rememberPreference = explicitNotify && shouldRememberProjectSummarySyncNotice(message.body);
|
||||
const rememberedPreference =
|
||||
rememberPreference || hasProjectSummarySyncNotifyPreferenceInState(state, session.account);
|
||||
if (rememberPreference) {
|
||||
await rememberProjectSummarySyncNotifyPreference({
|
||||
account: session.account,
|
||||
sourceMessageId: message.id,
|
||||
});
|
||||
}
|
||||
const syncTask = await requestProjectUnderstandingSyncForProject({
|
||||
projectId: masterAgentProjectSummarySyncTarget.id,
|
||||
observedActivityAt: message.sentAt,
|
||||
reason: "thread_reply",
|
||||
replyProjectId: projectId,
|
||||
notifyOnCompletion: explicitNotify || rememberedPreference,
|
||||
requestedByAccount: session.account,
|
||||
});
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(masterAgentProjectSummarySyncTarget, {
|
||||
notifyOnCompletion: explicitNotify || rememberedPreference,
|
||||
rememberedPreference: rememberPreference,
|
||||
}),
|
||||
kind: "system_notice",
|
||||
});
|
||||
masterReply = {
|
||||
ok: true,
|
||||
taskId: syncTask?.taskId,
|
||||
masterReplyState: syncTask ? "queued" : "completed",
|
||||
task: syncTask
|
||||
? {
|
||||
taskId: syncTask.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: "queued",
|
||||
}
|
||||
: undefined,
|
||||
replyMessage,
|
||||
};
|
||||
task = syncTask
|
||||
? {
|
||||
taskId: syncTask.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: "queued",
|
||||
}
|
||||
: null;
|
||||
masterReplyState = syncTask ? "queued" : "completed";
|
||||
replyPresenter = "master";
|
||||
dispatchRecommendation = {
|
||||
ok: false,
|
||||
status: "skipped",
|
||||
};
|
||||
} else if (shouldCreateDispatchPlan) {
|
||||
try {
|
||||
const recommendation = await queueGroupDispatchPlan({
|
||||
groupProjectId: projectId,
|
||||
@@ -265,40 +566,140 @@ export async function POST(
|
||||
} else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) {
|
||||
const relayViaMasterAgent = singleThreadTakeoverEnabled;
|
||||
if (relayViaMasterAgent) {
|
||||
if (shouldRequestVerifiedProjectSummarySync(message.body)) {
|
||||
await requestProjectUnderstandingSyncForProject({
|
||||
if (shouldDisableCurrentProjectTakeoverFromMasterMention(message.body)) {
|
||||
await updateProjectAgentControls(projectId, { takeoverEnabled: false }, session.account);
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverDisabledReply(nextProject),
|
||||
kind: "text",
|
||||
});
|
||||
masterReply = {
|
||||
ok: true,
|
||||
masterReplyState: "completed",
|
||||
replyMessage,
|
||||
};
|
||||
task = null;
|
||||
masterReplyState = "completed";
|
||||
} else if (shouldRequestVerifiedProjectSummarySync(message.body)) {
|
||||
const explicitNotify = shouldNotifyAfterProjectSummarySync(message.body);
|
||||
const rememberPreference = explicitNotify && shouldRememberProjectSummarySyncNotice(message.body);
|
||||
const rememberedPreference =
|
||||
rememberPreference || hasProjectSummarySyncNotifyPreferenceInState(state, session.account);
|
||||
if (rememberPreference) {
|
||||
await rememberProjectSummarySyncNotifyPreference({
|
||||
account: session.account,
|
||||
sourceMessageId: message.id,
|
||||
});
|
||||
}
|
||||
const syncTask = await requestProjectUnderstandingSyncForProject({
|
||||
projectId,
|
||||
observedActivityAt: message.sentAt,
|
||||
reason: "thread_reply",
|
||||
});
|
||||
}
|
||||
masterReply = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName || session.account,
|
||||
replyProjectId: projectId,
|
||||
notifyOnCompletion: explicitNotify || rememberedPreference,
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
});
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
interactionMode: "takeover_single_thread",
|
||||
mode: "enqueue",
|
||||
})
|
||||
if (masterReply?.taskId) {
|
||||
task = masterReply.task ?? {
|
||||
taskId: masterReply.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: masterReply.masterReplyState ?? "queued",
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(project, {
|
||||
notifyOnCompletion: explicitNotify || rememberedPreference,
|
||||
rememberedPreference: rememberPreference,
|
||||
}),
|
||||
kind: "system_notice",
|
||||
});
|
||||
masterReply = {
|
||||
ok: true,
|
||||
taskId: syncTask?.taskId,
|
||||
masterReplyState: syncTask ? "queued" : "completed",
|
||||
task: syncTask
|
||||
? {
|
||||
taskId: syncTask.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: "queued" as const,
|
||||
}
|
||||
: undefined,
|
||||
replyMessage,
|
||||
};
|
||||
masterReplyState = masterReply.masterReplyState ?? null;
|
||||
task = syncTask
|
||||
? {
|
||||
taskId: syncTask.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: "queued",
|
||||
}
|
||||
: null;
|
||||
masterReplyState = syncTask ? "queued" : "completed";
|
||||
} else {
|
||||
try {
|
||||
const queuedTask = await queueThreadConversationReplyTask({
|
||||
projectId,
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
sourceMessageId: message.id,
|
||||
sourceMessageBody: message.body,
|
||||
sourceMessageSentAt: message.sentAt,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
relayViaMasterAgent: true,
|
||||
});
|
||||
task = {
|
||||
taskId: queuedTask.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: "queued",
|
||||
};
|
||||
masterReply = {
|
||||
ok: true,
|
||||
taskId: queuedTask.taskId,
|
||||
masterReplyState: "queued",
|
||||
task,
|
||||
};
|
||||
masterReplyState = "queued";
|
||||
} catch (error) {
|
||||
if (!(error instanceof ThreadConversationExecutionConflictError)) {
|
||||
throw error;
|
||||
}
|
||||
masterReply = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
projectId,
|
||||
interactionMode: "takeover_single_thread",
|
||||
mode: "enqueue",
|
||||
});
|
||||
if (masterReply?.taskId) {
|
||||
task = masterReply.task ?? {
|
||||
taskId: masterReply.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: masterReply.masterReplyState ?? "queued",
|
||||
};
|
||||
masterReplyState = masterReply.masterReplyState ?? null;
|
||||
}
|
||||
replyMessage = masterReply?.replyMessage;
|
||||
executionMode = (masterReply as { executionMode?: typeof executionMode }).executionMode;
|
||||
riskLevel = (masterReply as { riskLevel?: typeof riskLevel }).riskLevel;
|
||||
requiresConfirmation = (
|
||||
masterReply as { requiresConfirmation?: typeof requiresConfirmation }
|
||||
).requiresConfirmation;
|
||||
}
|
||||
}
|
||||
replyMessage = masterReply?.replyMessage;
|
||||
} else {
|
||||
const queuedTask = await queueThreadConversationReplyTask({
|
||||
projectId,
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
});
|
||||
projectId,
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
sourceMessageId: message.id,
|
||||
sourceMessageBody: message.body,
|
||||
sourceMessageSentAt: message.sentAt,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
});
|
||||
task = {
|
||||
taskId: queuedTask.taskId,
|
||||
taskType: "conversation_reply",
|
||||
@@ -313,7 +714,12 @@ export async function POST(
|
||||
};
|
||||
}
|
||||
|
||||
if (projectId === "master-agent" && (body.kind ?? "text") === "text" && message.body.trim()) {
|
||||
if (
|
||||
projectId === "master-agent" &&
|
||||
!masterAgentProjectSummarySyncTarget &&
|
||||
(body.kind ?? "text") === "text" &&
|
||||
message.body.trim()
|
||||
) {
|
||||
masterReply = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
@@ -333,6 +739,11 @@ export async function POST(
|
||||
masterReplyState = masterReply.masterReplyState ?? (masterReply.taskId ? null : "completed");
|
||||
replyPresenter = "master";
|
||||
replyMessage = masterReply.replyMessage;
|
||||
executionMode = (masterReply as { executionMode?: typeof executionMode }).executionMode;
|
||||
riskLevel = (masterReply as { riskLevel?: typeof riskLevel }).riskLevel;
|
||||
requiresConfirmation = (
|
||||
masterReply as { requiresConfirmation?: typeof requiresConfirmation }
|
||||
).requiresConfirmation;
|
||||
} else {
|
||||
masterReplyState = null;
|
||||
}
|
||||
@@ -350,6 +761,9 @@ export async function POST(
|
||||
task,
|
||||
replyPresenter,
|
||||
masterReplyState,
|
||||
executionMode,
|
||||
riskLevel,
|
||||
requiresConfirmation,
|
||||
dispatchPlan,
|
||||
dispatchRecommendation,
|
||||
collaborationGate,
|
||||
@@ -378,6 +792,49 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const messageIdFromQuery = request.nextUrl.searchParams.get("messageId")?.trim();
|
||||
const body = messageIdFromQuery
|
||||
? {}
|
||||
: ((await request.json().catch(() => ({}))) as { messageId?: string });
|
||||
const messageId = messageIdFromQuery || body.messageId?.trim();
|
||||
if (!messageId) {
|
||||
return NextResponse.json({ ok: false, message: "MESSAGE_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await readState();
|
||||
const projectExists = state.projects.some((project) => project.id === projectId);
|
||||
if (!canAccessProject(state, session, projectId, "thread.chat")) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: projectExists ? "THREAD_CHAT_FORBIDDEN" : "PROJECT_NOT_FOUND" },
|
||||
{ status: projectExists ? 403 : 404 },
|
||||
);
|
||||
}
|
||||
const result = await deleteProjectMessage({ projectId, messageId });
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
deletedMessage: result.deletedMessage,
|
||||
remainingCount: result.remainingCount,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return NextResponse.json(
|
||||
{ ok: false, message },
|
||||
{ status: message === "PROJECT_NOT_FOUND" || message === "MESSAGE_NOT_FOUND" ? 404 : 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRequestVerifiedProjectSummarySync(text: string) {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
@@ -385,6 +842,110 @@ function shouldRequestVerifiedProjectSummarySync(text: string) {
|
||||
}
|
||||
const mentionsGoal = /项目目标|目标/.test(normalized);
|
||||
const mentionsVersion = /版本记录|版本迭代|版本/.test(normalized);
|
||||
const mentionsReviewOrSync = /核对|确认|同步|更新|刷新|整理|汇总/.test(normalized);
|
||||
const mentionsReviewOrSync = /核对|确认|同步|更新|刷新|整理|汇总|总结|梳理|概括|回写/.test(normalized);
|
||||
return mentionsReviewOrSync && (mentionsGoal || mentionsVersion);
|
||||
}
|
||||
|
||||
function shouldNotifyAfterProjectSummarySync(text: string) {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /同步完成.*(告诉我|提醒我|回我|和我说|通知我)|完成后.*(告诉我|提醒我|回我|和我说|通知我)|记得和我说|记得告诉我|同步完.*说一声/.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRememberProjectSummarySyncNotice(text: string) {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /以后|后续|默认|都这样|也这样|记住|下次也这样/.test(normalized);
|
||||
}
|
||||
|
||||
const INLINE_MASTER_AGENT_MENTION_REGEX =
|
||||
/@+\s*(主\s*a(?:[\s"'`’‘._-]*)g(?:[\s"'`’‘._-]*)e(?:[\s"'`’‘._-]*)n(?:[\s"'`’‘._-]*)t|主\s*gpt|主控|master\s*agent|agent)(?=$|[\s::,,、。.!?!?;;])/i;
|
||||
|
||||
function parseMasterAgentMention(text: string) {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlineMentionMatch = normalized.match(INLINE_MASTER_AGENT_MENTION_REGEX);
|
||||
if (inlineMentionMatch) {
|
||||
const requestText = normalized
|
||||
.replace(INLINE_MASTER_AGENT_MENTION_REGEX, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^[\s::,,、-]+/, "")
|
||||
.trim();
|
||||
return {
|
||||
requestText: requestText || "你好",
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalized.startsWith("@")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripped = normalized.slice(1).trim();
|
||||
if (!stripped) {
|
||||
return { requestText: "你好" };
|
||||
}
|
||||
const labelMatch = stripped.match(
|
||||
/^(主\s*a(?:[\s"'`’‘._-]*)g(?:[\s"'`’‘._-]*)e(?:[\s"'`’‘._-]*)n(?:[\s"'`’‘._-]*)t|主\s*gpt|主控|master\s*agent|agent)/i,
|
||||
);
|
||||
const requestText = labelMatch
|
||||
? stripped.slice(labelMatch[0].length).replace(/^[\s::,,、-]+/, "").trim()
|
||||
: stripped;
|
||||
return {
|
||||
requestText: requestText || "你好",
|
||||
};
|
||||
}
|
||||
|
||||
function shouldEnableCurrentProjectTakeoverFromMasterMention(text: string) {
|
||||
const normalized = text.trim().replace(/\s+/g, "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (!/(托管|接管|协同接管)/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (/(关闭|取消|不要|别|停止|禁用|撤销|解除)/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /(开启|打开|启用|开始|进入|切到|切换到|交给|接手|让.+(托管|接管)|请.+(托管|接管)|帮我.+(托管|接管))/.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDisableCurrentProjectTakeoverFromMasterMention(text: string) {
|
||||
const normalized = text.trim().replace(/\s+/g, "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (!/(托管|接管|协同接管)/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (!/(关闭|取消|退出|停止|禁用|撤销|解除)/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /(当前|本线程|这个线程|当前线程|接管模式|托管模式)/.test(normalized) || normalized.includes("退出当前的接管模式");
|
||||
}
|
||||
|
||||
function buildMasterMentionTakeoverEnabledReply(project: { name?: string | null }) {
|
||||
const projectName = project.name?.trim() || "当前会话";
|
||||
return [
|
||||
`已为《${projectName}》开启主 Agent 协同接管。`,
|
||||
"后续你直接发普通消息时,我会先确认意图,再协调对应线程推进;如果只是单独和我说话,继续用 @ 开头即可。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildMasterMentionTakeoverDisabledReply(project: { name?: string | null }) {
|
||||
const projectName = project.name?.trim() || "当前会话";
|
||||
return [
|
||||
`已为《${projectName}》关闭主 Agent 协同接管。`,
|
||||
"后续这个线程会恢复为你直接和对应 Codex 线程对话;如果还想单独找我,继续用 @ 开头即可。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getProjectDetailView } from "@/lib/boss-projections";
|
||||
import { getProjectDetailViewForSession } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
@@ -14,10 +14,14 @@ export async function GET(
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const detail = getProjectDetailView(state, projectId, session.account);
|
||||
const projectExists = state.projects.some((project) => project.id === projectId);
|
||||
const detail = getProjectDetailViewForSession(state, projectId, session);
|
||||
|
||||
if (!detail) {
|
||||
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
return jsonNoStore(
|
||||
{ ok: false, message: projectExists ? "FORBIDDEN" : "PROJECT_NOT_FOUND" },
|
||||
{ status: projectExists ? 403 : 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return jsonNoStore({
|
||||
|
||||
Reference in New Issue
Block a user