1842 lines
66 KiB
TypeScript
1842 lines
66 KiB
TypeScript
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { Router } from "express";
|
|
import type { Request } from "express";
|
|
import { and, eq, isNull, desc } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agentApiKeys,
|
|
authUsers,
|
|
invites,
|
|
joinRequests,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
acceptInviteSchema,
|
|
claimJoinRequestApiKeySchema,
|
|
createCompanyInviteSchema,
|
|
listJoinRequestsQuerySchema,
|
|
updateMemberPermissionsSchema,
|
|
updateUserCompanyAccessSchema,
|
|
PERMISSION_KEYS,
|
|
} from "@paperclipai/shared";
|
|
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
|
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { accessService, agentService, logActivity, notifyHireApproved } from "../services/index.js";
|
|
import { assertCompanyAccess } from "./authz.js";
|
|
import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js";
|
|
|
|
function hashToken(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
const INVITE_TOKEN_PREFIX = "pcp_invite_";
|
|
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
|
const INVITE_TOKEN_MAX_RETRIES = 5;
|
|
const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000;
|
|
|
|
function createInviteToken() {
|
|
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
|
|
let suffix = "";
|
|
for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
|
|
suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length];
|
|
}
|
|
return `${INVITE_TOKEN_PREFIX}${suffix}`;
|
|
}
|
|
|
|
function createClaimSecret() {
|
|
return `pcp_claim_${randomBytes(24).toString("hex")}`;
|
|
}
|
|
|
|
export function companyInviteExpiresAt(nowMs: number = Date.now()) {
|
|
return new Date(nowMs + COMPANY_INVITE_TTL_MS);
|
|
}
|
|
|
|
function tokenHashesMatch(left: string, right: string) {
|
|
const leftBytes = Buffer.from(left, "utf8");
|
|
const rightBytes = Buffer.from(right, "utf8");
|
|
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
}
|
|
|
|
function requestBaseUrl(req: Request) {
|
|
const forwardedProto = req.header("x-forwarded-proto");
|
|
const proto = forwardedProto?.split(",")[0]?.trim() || req.protocol || "http";
|
|
const host = req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.header("host");
|
|
if (!host) return "";
|
|
return `${proto}://${host}`;
|
|
}
|
|
|
|
function readSkillMarkdown(skillName: string): string | null {
|
|
const normalized = skillName.trim().toLowerCase();
|
|
if (normalized !== "paperclip" && normalized !== "paperclip-create-agent") return null;
|
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const candidates = [
|
|
path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> <pkg>/skills/
|
|
path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root)
|
|
path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md"), // dev: src/routes/ -> repo root/skills/
|
|
];
|
|
for (const skillPath of candidates) {
|
|
try {
|
|
return fs.readFileSync(skillPath, "utf8");
|
|
} catch {
|
|
// Continue to next candidate.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) {
|
|
const { claimSecretHash: _claimSecretHash, ...safe } = row;
|
|
return safe;
|
|
}
|
|
|
|
type JoinDiagnostic = {
|
|
code: string;
|
|
level: "info" | "warn";
|
|
message: string;
|
|
hint?: string;
|
|
};
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isLoopbackHost(hostname: string): boolean {
|
|
const value = hostname.trim().toLowerCase();
|
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
|
}
|
|
|
|
function isWakePath(pathname: string): boolean {
|
|
const value = pathname.trim().toLowerCase();
|
|
return value === "/hooks/wake" || value.endsWith("/hooks/wake");
|
|
}
|
|
|
|
function normalizeHostname(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith("[")) {
|
|
const end = trimmed.indexOf("]");
|
|
return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
|
|
}
|
|
const firstColon = trimmed.indexOf(":");
|
|
if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase();
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
function normalizeHeaderMap(input: unknown): Record<string, string> | undefined {
|
|
if (!isPlainObject(input)) return undefined;
|
|
const out: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(input)) {
|
|
if (typeof value !== "string") continue;
|
|
const trimmedKey = key.trim();
|
|
const trimmedValue = value.trim();
|
|
if (!trimmedKey || !trimmedValue) continue;
|
|
out[trimmedKey] = trimmedValue;
|
|
}
|
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
}
|
|
|
|
function nonEmptyTrimmedString(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function headerMapHasKeyIgnoreCase(headers: Record<string, string>, targetKey: string): boolean {
|
|
const normalizedTarget = targetKey.trim().toLowerCase();
|
|
return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget);
|
|
}
|
|
|
|
function headerMapGetIgnoreCase(headers: Record<string, string>, targetKey: string): string | null {
|
|
const normalizedTarget = targetKey.trim().toLowerCase();
|
|
const key = Object.keys(headers).find((candidate) => candidate.trim().toLowerCase() === normalizedTarget);
|
|
if (!key) return null;
|
|
const value = headers[key];
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function toAuthorizationHeaderValue(rawToken: string): string {
|
|
const trimmed = rawToken.trim();
|
|
if (!trimmed) return trimmed;
|
|
return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`;
|
|
}
|
|
|
|
export function buildJoinDefaultsPayloadForAccept(input: {
|
|
adapterType: string | null;
|
|
defaultsPayload: unknown;
|
|
responsesWebhookUrl?: unknown;
|
|
responsesWebhookMethod?: unknown;
|
|
responsesWebhookHeaders?: unknown;
|
|
paperclipApiUrl?: unknown;
|
|
webhookAuthHeader?: unknown;
|
|
inboundOpenClawAuthHeader?: string | null;
|
|
}): unknown {
|
|
if (input.adapterType !== "openclaw") {
|
|
return input.defaultsPayload;
|
|
}
|
|
|
|
const merged = isPlainObject(input.defaultsPayload)
|
|
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
|
: {} as Record<string, unknown>;
|
|
|
|
if (!nonEmptyTrimmedString(merged.url)) {
|
|
const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl);
|
|
if (legacyUrl) merged.url = legacyUrl;
|
|
}
|
|
|
|
if (!nonEmptyTrimmedString(merged.method)) {
|
|
const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod);
|
|
if (legacyMethod) merged.method = legacyMethod.toUpperCase();
|
|
}
|
|
|
|
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
|
|
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
|
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
|
}
|
|
|
|
if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) {
|
|
const providedWebhookAuthHeader = nonEmptyTrimmedString(input.webhookAuthHeader);
|
|
if (providedWebhookAuthHeader) merged.webhookAuthHeader = providedWebhookAuthHeader;
|
|
}
|
|
|
|
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
|
const compatibilityHeaders = normalizeHeaderMap(input.responsesWebhookHeaders);
|
|
if (compatibilityHeaders) {
|
|
for (const [key, value] of Object.entries(compatibilityHeaders)) {
|
|
if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) {
|
|
mergedHeaders[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(input.inboundOpenClawAuthHeader);
|
|
if (inboundOpenClawAuthHeader && !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")) {
|
|
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
|
}
|
|
|
|
if (Object.keys(mergedHeaders).length > 0) {
|
|
merged.headers = mergedHeaders;
|
|
} else {
|
|
delete merged.headers;
|
|
}
|
|
|
|
const hasAuthorizationHeader = headerMapHasKeyIgnoreCase(mergedHeaders, "authorization");
|
|
const hasWebhookAuthHeader = Boolean(nonEmptyTrimmedString(merged.webhookAuthHeader));
|
|
if (!hasAuthorizationHeader && !hasWebhookAuthHeader) {
|
|
const openClawAuthToken = headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth");
|
|
if (openClawAuthToken) {
|
|
merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken);
|
|
}
|
|
}
|
|
|
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
}
|
|
|
|
function summarizeSecretForLog(value: unknown): { present: true; length: number; sha256Prefix: string } | null {
|
|
const trimmed = nonEmptyTrimmedString(value);
|
|
if (!trimmed) return null;
|
|
return {
|
|
present: true,
|
|
length: trimmed.length,
|
|
sha256Prefix: hashToken(trimmed).slice(0, 12),
|
|
};
|
|
}
|
|
|
|
function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) {
|
|
const defaults = isPlainObject(defaultsPayload) ? (defaultsPayload as Record<string, unknown>) : null;
|
|
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
|
|
const openClawAuthHeaderValue = headers
|
|
? Object.entries(headers).find(([key]) => key.trim().toLowerCase() === "x-openclaw-auth")?.[1] ?? null
|
|
: null;
|
|
|
|
return {
|
|
present: Boolean(defaults),
|
|
keys: defaults ? Object.keys(defaults).sort() : [],
|
|
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
|
|
method: defaults ? nonEmptyTrimmedString(defaults.method) : null,
|
|
paperclipApiUrl: defaults ? nonEmptyTrimmedString(defaults.paperclipApiUrl) : null,
|
|
headerKeys: headers ? Object.keys(headers).sort() : [],
|
|
webhookAuthHeader: defaults ? summarizeSecretForLog(defaults.webhookAuthHeader) : null,
|
|
openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue),
|
|
};
|
|
}
|
|
|
|
function buildJoinConnectivityDiagnostics(input: {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
callbackUrl: URL | null;
|
|
}): JoinDiagnostic[] {
|
|
const diagnostics: JoinDiagnostic[] = [];
|
|
const bindHost = normalizeHostname(input.bindHost);
|
|
const callbackHost = input.callbackUrl ? normalizeHostname(input.callbackUrl.hostname) : null;
|
|
const allowSet = new Set(
|
|
input.allowedHostnames
|
|
.map((entry) => normalizeHostname(entry))
|
|
.filter((entry): entry is string => Boolean(entry)),
|
|
);
|
|
|
|
diagnostics.push({
|
|
code: "openclaw_deployment_context",
|
|
level: "info",
|
|
message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`,
|
|
});
|
|
|
|
if (input.deploymentMode === "authenticated" && input.deploymentExposure === "private") {
|
|
if (!bindHost || isLoopbackHost(bindHost)) {
|
|
diagnostics.push({
|
|
code: "openclaw_private_bind_loopback",
|
|
level: "warn",
|
|
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
|
hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks.",
|
|
});
|
|
}
|
|
if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
|
|
diagnostics.push({
|
|
code: "openclaw_private_bind_not_allowed",
|
|
level: "warn",
|
|
message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`,
|
|
hint: `Run pnpm paperclipai allowed-hostname ${bindHost}`,
|
|
});
|
|
}
|
|
if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) {
|
|
diagnostics.push({
|
|
code: "openclaw_private_allowed_hostnames_empty",
|
|
level: "warn",
|
|
message: "No explicit allowed hostnames are configured for authenticated/private mode.",
|
|
hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs off-host.",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (
|
|
input.deploymentMode === "authenticated" &&
|
|
input.deploymentExposure === "public" &&
|
|
input.callbackUrl &&
|
|
input.callbackUrl.protocol !== "https:"
|
|
) {
|
|
diagnostics.push({
|
|
code: "openclaw_public_http_callback",
|
|
level: "warn",
|
|
message: "OpenClaw callback URL uses HTTP in authenticated/public mode.",
|
|
hint: "Prefer HTTPS for public deployments.",
|
|
});
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
function normalizeAgentDefaultsForJoin(input: {
|
|
adapterType: string | null;
|
|
defaultsPayload: unknown;
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
}) {
|
|
const diagnostics: JoinDiagnostic[] = [];
|
|
if (input.adapterType !== "openclaw") {
|
|
const normalized = isPlainObject(input.defaultsPayload)
|
|
? (input.defaultsPayload as Record<string, unknown>)
|
|
: null;
|
|
return { normalized, diagnostics };
|
|
}
|
|
|
|
if (!isPlainObject(input.defaultsPayload)) {
|
|
diagnostics.push({
|
|
code: "openclaw_callback_config_missing",
|
|
level: "warn",
|
|
message: "No OpenClaw callback config was provided in agentDefaultsPayload.",
|
|
hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw SSE endpoint immediately after approval.",
|
|
});
|
|
return { normalized: null as Record<string, unknown> | null, diagnostics };
|
|
}
|
|
|
|
const defaults = input.defaultsPayload as Record<string, unknown>;
|
|
const normalized: Record<string, unknown> = { streamTransport: "sse" };
|
|
|
|
let callbackUrl: URL | null = null;
|
|
const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : "";
|
|
if (!rawUrl) {
|
|
diagnostics.push({
|
|
code: "openclaw_callback_url_missing",
|
|
level: "warn",
|
|
message: "OpenClaw callback URL is missing.",
|
|
hint: "Set agentDefaultsPayload.url to your OpenClaw SSE endpoint.",
|
|
});
|
|
} else {
|
|
try {
|
|
callbackUrl = new URL(rawUrl);
|
|
if (callbackUrl.protocol !== "http:" && callbackUrl.protocol !== "https:") {
|
|
diagnostics.push({
|
|
code: "openclaw_callback_url_protocol",
|
|
level: "warn",
|
|
message: `Unsupported callback protocol: ${callbackUrl.protocol}`,
|
|
hint: "Use http:// or https://.",
|
|
});
|
|
} else {
|
|
normalized.url = callbackUrl.toString();
|
|
diagnostics.push({
|
|
code: "openclaw_callback_url_configured",
|
|
level: "info",
|
|
message: `Callback endpoint set to ${callbackUrl.toString()}`,
|
|
});
|
|
}
|
|
if (isWakePath(callbackUrl.pathname)) {
|
|
diagnostics.push({
|
|
code: "openclaw_callback_wake_path_incompatible",
|
|
level: "warn",
|
|
message: "Configured callback path targets /hooks/wake, which is not stream-capable for strict SSE mode.",
|
|
hint: "Use an endpoint that returns text/event-stream for the full run duration.",
|
|
});
|
|
}
|
|
if (isLoopbackHost(callbackUrl.hostname)) {
|
|
diagnostics.push({
|
|
code: "openclaw_callback_loopback",
|
|
level: "warn",
|
|
message: "OpenClaw callback endpoint uses loopback hostname.",
|
|
hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.",
|
|
});
|
|
}
|
|
} catch {
|
|
diagnostics.push({
|
|
code: "openclaw_callback_url_invalid",
|
|
level: "warn",
|
|
message: `Invalid callback URL: ${rawUrl}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const rawMethod = typeof defaults.method === "string" ? defaults.method.trim().toUpperCase() : "";
|
|
normalized.method = rawMethod || "POST";
|
|
|
|
if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) {
|
|
normalized.timeoutSec = Math.max(0, Math.min(7200, Math.floor(defaults.timeoutSec)));
|
|
}
|
|
|
|
const headers = normalizeHeaderMap(defaults.headers);
|
|
if (headers) normalized.headers = headers;
|
|
|
|
if (typeof defaults.webhookAuthHeader === "string" && defaults.webhookAuthHeader.trim()) {
|
|
normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim();
|
|
}
|
|
|
|
const openClawAuthHeader = headers ? headerMapGetIgnoreCase(headers, "x-openclaw-auth") : null;
|
|
if (openClawAuthHeader) {
|
|
diagnostics.push({
|
|
code: "openclaw_auth_header_configured",
|
|
level: "info",
|
|
message: "Gateway auth token received via headers.x-openclaw-auth.",
|
|
});
|
|
} else {
|
|
diagnostics.push({
|
|
code: "openclaw_auth_header_missing",
|
|
level: "warn",
|
|
message: "Gateway auth token is missing from agent defaults.",
|
|
hint: "Set agentDefaultsPayload.headers.x-openclaw-auth to the token your OpenClaw /v1/responses endpoint requires.",
|
|
});
|
|
}
|
|
|
|
if (isPlainObject(defaults.payloadTemplate)) {
|
|
normalized.payloadTemplate = defaults.payloadTemplate;
|
|
}
|
|
|
|
const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string"
|
|
? defaults.paperclipApiUrl.trim()
|
|
: "";
|
|
if (rawPaperclipApiUrl) {
|
|
try {
|
|
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
|
|
if (parsedPaperclipApiUrl.protocol !== "http:" && parsedPaperclipApiUrl.protocol !== "https:") {
|
|
diagnostics.push({
|
|
code: "openclaw_paperclip_api_url_protocol",
|
|
level: "warn",
|
|
message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`,
|
|
});
|
|
} else {
|
|
normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
|
|
diagnostics.push({
|
|
code: "openclaw_paperclip_api_url_configured",
|
|
level: "info",
|
|
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`,
|
|
});
|
|
if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) {
|
|
diagnostics.push({
|
|
code: "openclaw_paperclip_api_url_loopback",
|
|
level: "warn",
|
|
message:
|
|
"paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.",
|
|
hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments.",
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
diagnostics.push({
|
|
code: "openclaw_paperclip_api_url_invalid",
|
|
level: "warn",
|
|
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
diagnostics.push(
|
|
...buildJoinConnectivityDiagnostics({
|
|
deploymentMode: input.deploymentMode,
|
|
deploymentExposure: input.deploymentExposure,
|
|
bindHost: input.bindHost,
|
|
allowedHostnames: input.allowedHostnames,
|
|
callbackUrl,
|
|
}),
|
|
);
|
|
|
|
return { normalized, diagnostics };
|
|
}
|
|
|
|
function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) {
|
|
const baseUrl = requestBaseUrl(req);
|
|
const onboardingPath = `/api/invites/${token}/onboarding`;
|
|
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
|
const inviteMessage = extractInviteMessage(invite);
|
|
return {
|
|
id: invite.id,
|
|
companyId: invite.companyId,
|
|
inviteType: invite.inviteType,
|
|
allowedJoinTypes: invite.allowedJoinTypes,
|
|
expiresAt: invite.expiresAt,
|
|
onboardingPath,
|
|
onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
|
|
onboardingTextPath,
|
|
onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath,
|
|
skillIndexPath: "/api/skills/index",
|
|
skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
|
|
inviteMessage,
|
|
};
|
|
}
|
|
|
|
function buildOnboardingDiscoveryDiagnostics(input: {
|
|
apiBaseUrl: string;
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
}): JoinDiagnostic[] {
|
|
const diagnostics: JoinDiagnostic[] = [];
|
|
let apiHost: string | null = null;
|
|
if (input.apiBaseUrl) {
|
|
try {
|
|
apiHost = normalizeHostname(new URL(input.apiBaseUrl).hostname);
|
|
} catch {
|
|
apiHost = null;
|
|
}
|
|
}
|
|
|
|
const bindHost = normalizeHostname(input.bindHost);
|
|
const allowSet = new Set(
|
|
input.allowedHostnames
|
|
.map((entry) => normalizeHostname(entry))
|
|
.filter((entry): entry is string => Boolean(entry)),
|
|
);
|
|
|
|
if (apiHost && isLoopbackHost(apiHost)) {
|
|
diagnostics.push({
|
|
code: "openclaw_onboarding_api_loopback",
|
|
level: "warn",
|
|
message:
|
|
"Onboarding URL resolves to loopback hostname. Remote OpenClaw agents cannot reach localhost on your Paperclip host.",
|
|
hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain).",
|
|
});
|
|
}
|
|
|
|
if (
|
|
input.deploymentMode === "authenticated" &&
|
|
input.deploymentExposure === "private" &&
|
|
(!bindHost || isLoopbackHost(bindHost))
|
|
) {
|
|
diagnostics.push({
|
|
code: "openclaw_onboarding_private_loopback_bind",
|
|
level: "warn",
|
|
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
|
hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding.",
|
|
});
|
|
}
|
|
|
|
if (
|
|
input.deploymentMode === "authenticated" &&
|
|
input.deploymentExposure === "private" &&
|
|
apiHost &&
|
|
!isLoopbackHost(apiHost) &&
|
|
allowSet.size > 0 &&
|
|
!allowSet.has(apiHost)
|
|
) {
|
|
diagnostics.push({
|
|
code: "openclaw_onboarding_private_host_not_allowed",
|
|
level: "warn",
|
|
message: `Onboarding host "${apiHost}" is not in allowed hostnames for authenticated/private mode.`,
|
|
hint: `Run pnpm paperclipai allowed-hostname ${apiHost}`,
|
|
});
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
function buildOnboardingConnectionCandidates(input: {
|
|
apiBaseUrl: string;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
}): string[] {
|
|
let base: URL | null = null;
|
|
try {
|
|
if (input.apiBaseUrl) {
|
|
base = new URL(input.apiBaseUrl);
|
|
}
|
|
} catch {
|
|
base = null;
|
|
}
|
|
|
|
const protocol = base?.protocol ?? "http:";
|
|
const port = base?.port ? `:${base.port}` : "";
|
|
const candidates = new Set<string>();
|
|
|
|
if (base) {
|
|
candidates.add(base.origin);
|
|
}
|
|
|
|
const bindHost = normalizeHostname(input.bindHost);
|
|
if (bindHost && !isLoopbackHost(bindHost)) {
|
|
candidates.add(`${protocol}//${bindHost}${port}`);
|
|
}
|
|
|
|
for (const rawHost of input.allowedHostnames) {
|
|
const host = normalizeHostname(rawHost);
|
|
if (!host) continue;
|
|
candidates.add(`${protocol}//${host}${port}`);
|
|
}
|
|
|
|
if (base && isLoopbackHost(base.hostname)) {
|
|
candidates.add(`${protocol}//host.docker.internal${port}`);
|
|
}
|
|
|
|
return Array.from(candidates);
|
|
}
|
|
|
|
function buildInviteOnboardingManifest(
|
|
req: Request,
|
|
token: string,
|
|
invite: typeof invites.$inferSelect,
|
|
opts: {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
},
|
|
) {
|
|
const baseUrl = requestBaseUrl(req);
|
|
const skillPath = "/api/skills/paperclip";
|
|
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
|
const registrationEndpointPath = `/api/invites/${token}/accept`;
|
|
const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
|
|
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
|
const onboardingTextUrl = baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath;
|
|
const testResolutionPath = `/api/invites/${token}/test-resolution`;
|
|
const testResolutionUrl = baseUrl ? `${baseUrl}${testResolutionPath}` : testResolutionPath;
|
|
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
|
|
apiBaseUrl: baseUrl,
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
bindHost: opts.bindHost,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
});
|
|
const connectionCandidates = buildOnboardingConnectionCandidates({
|
|
apiBaseUrl: baseUrl,
|
|
bindHost: opts.bindHost,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
});
|
|
|
|
return {
|
|
invite: toInviteSummaryResponse(req, token, invite),
|
|
onboarding: {
|
|
instructions:
|
|
"Join as an OpenClaw agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST include agentDefaultsPayload.headers.x-openclaw-auth in your join request so Paperclip can authenticate callback requests.",
|
|
inviteMessage: extractInviteMessage(invite),
|
|
recommendedAdapterType: "openclaw",
|
|
requiredFields: {
|
|
requestType: "agent",
|
|
agentName: "Display name for this agent",
|
|
adapterType: "Use 'openclaw' for OpenClaw streaming agents",
|
|
capabilities: "Optional capability summary",
|
|
agentDefaultsPayload:
|
|
"Adapter config for OpenClaw SSE endpoint. MUST include headers.x-openclaw-auth; also include url/method/paperclipApiUrl (and optional webhookAuthHeader/timeoutSec/payloadTemplate).",
|
|
},
|
|
registrationEndpoint: {
|
|
method: "POST",
|
|
path: registrationEndpointPath,
|
|
url: registrationEndpointUrl,
|
|
},
|
|
claimEndpointTemplate: {
|
|
method: "POST",
|
|
path: "/api/join-requests/{requestId}/claim-api-key",
|
|
body: {
|
|
claimSecret: "one-time claim secret returned when the join request is created",
|
|
},
|
|
},
|
|
connectivity: {
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
bindHost: opts.bindHost,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
connectionCandidates,
|
|
testResolutionEndpoint: {
|
|
method: "GET",
|
|
path: testResolutionPath,
|
|
url: testResolutionUrl,
|
|
query: {
|
|
url: "https://your-openclaw-agent.example/v1/responses",
|
|
timeoutMs: 5000,
|
|
},
|
|
},
|
|
diagnostics: discoveryDiagnostics,
|
|
guidance:
|
|
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
|
|
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
|
|
: "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.",
|
|
},
|
|
textInstructions: {
|
|
path: onboardingTextPath,
|
|
url: onboardingTextUrl,
|
|
contentType: "text/plain",
|
|
},
|
|
skill: {
|
|
name: "paperclip",
|
|
path: skillPath,
|
|
url: skillUrl,
|
|
installPath: "~/.openclaw/skills/paperclip/SKILL.md",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildInviteOnboardingTextDocument(
|
|
req: Request,
|
|
token: string,
|
|
invite: typeof invites.$inferSelect,
|
|
opts: {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
},
|
|
) {
|
|
const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
|
const onboarding = manifest.onboarding as {
|
|
inviteMessage?: string | null;
|
|
registrationEndpoint: { method: string; path: string; url: string };
|
|
claimEndpointTemplate: { method: string; path: string };
|
|
textInstructions: { path: string; url: string };
|
|
skill: { path: string; url: string; installPath: string };
|
|
connectivity: {
|
|
diagnostics?: JoinDiagnostic[];
|
|
guidance?: string;
|
|
connectionCandidates?: string[];
|
|
testResolutionEndpoint?: { method?: string; path?: string; url?: string };
|
|
};
|
|
};
|
|
const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics)
|
|
? onboarding.connectivity.diagnostics
|
|
: [];
|
|
|
|
const lines = [
|
|
"# Paperclip OpenClaw Onboarding",
|
|
"",
|
|
"This document is meant to be readable by both humans and agents.",
|
|
"",
|
|
"## Invite",
|
|
`- inviteType: ${invite.inviteType}`,
|
|
`- allowedJoinTypes: ${invite.allowedJoinTypes}`,
|
|
`- expiresAt: ${invite.expiresAt.toISOString()}`,
|
|
"",
|
|
];
|
|
|
|
if (onboarding.inviteMessage) {
|
|
lines.push("## Message from inviter", onboarding.inviteMessage, "");
|
|
}
|
|
|
|
lines.push(
|
|
"## Step 1: Submit agent join request",
|
|
`${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`,
|
|
"",
|
|
"IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-auth with your gateway token.",
|
|
"Without this token, Paperclip callback requests to your OpenClaw endpoint will fail with 401 Unauthorized.",
|
|
"",
|
|
"Body (JSON):",
|
|
"{",
|
|
' "requestType": "agent",',
|
|
' "agentName": "My OpenClaw Agent",',
|
|
' "adapterType": "openclaw",',
|
|
' "capabilities": "Optional summary",',
|
|
' "agentDefaultsPayload": {',
|
|
' "url": "https://your-openclaw-agent.example/v1/responses",',
|
|
' "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",',
|
|
' "streamTransport": "sse",',
|
|
' "method": "POST",',
|
|
' "headers": { "x-openclaw-auth": "replace-me" },',
|
|
' "timeoutSec": 0',
|
|
" }",
|
|
"}",
|
|
"",
|
|
"Expected response includes:",
|
|
"- request id",
|
|
"- one-time claimSecret",
|
|
"- claimApiKeyPath",
|
|
"",
|
|
"## Step 2: Wait for board approval",
|
|
"The board approves the join request in Paperclip before key claim is allowed.",
|
|
"",
|
|
"## Step 3: Claim API key (one-time)",
|
|
`${onboarding.claimEndpointTemplate.method} /api/join-requests/{requestId}/claim-api-key`,
|
|
"",
|
|
"Body (JSON):",
|
|
"{",
|
|
' "claimSecret": "<one-time-claim-secret>"',
|
|
"}",
|
|
"",
|
|
"On successful claim, save the full JSON response to:",
|
|
"- ~/.openclaw/workspace/paperclip-claimed-api-key.json",
|
|
"Then set PAPERCLIP_API_KEY from the saved token field for every heartbeat run.",
|
|
"",
|
|
"Important:",
|
|
"- claim secrets expire",
|
|
"- claim secrets are single-use",
|
|
"- claim fails before board approval",
|
|
"",
|
|
"## Step 4: Install Paperclip skill in OpenClaw",
|
|
`GET ${onboarding.skill.url}`,
|
|
`Install path: ${onboarding.skill.installPath}`,
|
|
"",
|
|
"## Text onboarding URL",
|
|
`${onboarding.textInstructions.url}`,
|
|
"",
|
|
"## Connectivity guidance",
|
|
onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.",
|
|
);
|
|
|
|
if (onboarding.connectivity?.testResolutionEndpoint?.url) {
|
|
lines.push(
|
|
"",
|
|
"## Optional: test callback resolution from Paperclip",
|
|
`${onboarding.connectivity.testResolutionEndpoint.method ?? "GET"} ${onboarding.connectivity.testResolutionEndpoint.url}?url=https%3A%2F%2Fyour-openclaw-agent.example%2Fv1%2Fresponses`,
|
|
"",
|
|
"This endpoint checks whether Paperclip can reach your OpenClaw endpoint and reports reachable, timeout, or unreachable.",
|
|
);
|
|
}
|
|
|
|
const connectionCandidates = Array.isArray(onboarding.connectivity?.connectionCandidates)
|
|
? onboarding.connectivity.connectionCandidates.filter((entry): entry is string => Boolean(entry))
|
|
: [];
|
|
|
|
if (connectionCandidates.length > 0) {
|
|
lines.push("", "## Suggested Paperclip base URLs to try");
|
|
for (const candidate of connectionCandidates) {
|
|
lines.push(`- ${candidate}`);
|
|
}
|
|
lines.push(
|
|
"",
|
|
"Test each candidate with:",
|
|
"- GET <candidate>/api/health",
|
|
"- set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request",
|
|
"",
|
|
"If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.",
|
|
"For authenticated/private mode, they may need:",
|
|
"- pnpm paperclipai allowed-hostname <host>",
|
|
"- then restart Paperclip and retry onboarding.",
|
|
);
|
|
}
|
|
|
|
if (diagnostics.length > 0) {
|
|
lines.push("", "## Connectivity diagnostics");
|
|
for (const diag of diagnostics) {
|
|
lines.push(`- [${diag.level}] ${diag.message}`);
|
|
if (diag.hint) lines.push(` hint: ${diag.hint}`);
|
|
}
|
|
}
|
|
|
|
lines.push(
|
|
"",
|
|
"## Helpful endpoints",
|
|
`${onboarding.registrationEndpoint.path}`,
|
|
`${onboarding.claimEndpointTemplate.path}`,
|
|
`${onboarding.skill.path}`,
|
|
manifest.invite.onboardingPath,
|
|
);
|
|
if (onboarding.connectivity?.testResolutionEndpoint?.path) {
|
|
lines.push(`${onboarding.connectivity.testResolutionEndpoint.path}`);
|
|
}
|
|
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function extractInviteMessage(invite: typeof invites.$inferSelect): string | null {
|
|
const rawDefaults = invite.defaultsPayload;
|
|
if (!rawDefaults || typeof rawDefaults !== "object" || Array.isArray(rawDefaults)) {
|
|
return null;
|
|
}
|
|
const rawMessage = (rawDefaults as Record<string, unknown>).agentMessage;
|
|
if (typeof rawMessage !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = rawMessage.trim();
|
|
return trimmed.length ? trimmed : null;
|
|
}
|
|
|
|
function mergeInviteDefaults(
|
|
defaultsPayload: Record<string, unknown> | null | undefined,
|
|
agentMessage: string | null,
|
|
): Record<string, unknown> | null {
|
|
const merged = defaultsPayload && typeof defaultsPayload === "object"
|
|
? { ...defaultsPayload }
|
|
: {};
|
|
if (agentMessage) {
|
|
merged.agentMessage = agentMessage;
|
|
}
|
|
return Object.keys(merged).length ? merged : null;
|
|
}
|
|
|
|
function requestIp(req: Request) {
|
|
const forwarded = req.header("x-forwarded-for");
|
|
if (forwarded) {
|
|
const first = forwarded.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
return req.ip || "unknown";
|
|
}
|
|
|
|
function inviteExpired(invite: typeof invites.$inferSelect) {
|
|
return invite.expiresAt.getTime() <= Date.now();
|
|
}
|
|
|
|
function isLocalImplicit(req: Request) {
|
|
return req.actor.type === "board" && req.actor.source === "local_implicit";
|
|
}
|
|
|
|
async function resolveActorEmail(db: Db, req: Request): Promise<string | null> {
|
|
if (isLocalImplicit(req)) return "local@paperclip.local";
|
|
const userId = req.actor.userId;
|
|
if (!userId) return null;
|
|
const user = await db
|
|
.select({ email: authUsers.email })
|
|
.from(authUsers)
|
|
.where(eq(authUsers.id, userId))
|
|
.then((rows) => rows[0] ?? null);
|
|
return user?.email ?? null;
|
|
}
|
|
|
|
function grantsFromDefaults(
|
|
defaultsPayload: Record<string, unknown> | null | undefined,
|
|
key: "human" | "agent",
|
|
): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record<string, unknown> | null }> {
|
|
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
|
|
const scoped = defaultsPayload[key];
|
|
if (!scoped || typeof scoped !== "object") return [];
|
|
const grants = (scoped as Record<string, unknown>).grants;
|
|
if (!Array.isArray(grants)) return [];
|
|
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
|
|
const result: Array<{
|
|
permissionKey: (typeof PERMISSION_KEYS)[number];
|
|
scope: Record<string, unknown> | null;
|
|
}> = [];
|
|
for (const item of grants) {
|
|
if (!item || typeof item !== "object") continue;
|
|
const record = item as Record<string, unknown>;
|
|
if (typeof record.permissionKey !== "string") continue;
|
|
if (!validPermissionKeys.has(record.permissionKey)) continue;
|
|
result.push({
|
|
permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number],
|
|
scope:
|
|
record.scope && typeof record.scope === "object" && !Array.isArray(record.scope)
|
|
? (record.scope as Record<string, unknown>)
|
|
: null,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
type JoinRequestManagerCandidate = {
|
|
id: string;
|
|
role: string;
|
|
reportsTo: string | null;
|
|
};
|
|
|
|
export function resolveJoinRequestAgentManagerId(
|
|
candidates: JoinRequestManagerCandidate[],
|
|
): string | null {
|
|
const ceoCandidates = candidates.filter((candidate) => candidate.role === "ceo");
|
|
if (ceoCandidates.length === 0) return null;
|
|
const rootCeo = ceoCandidates.find((candidate) => candidate.reportsTo === null);
|
|
return (rootCeo ?? ceoCandidates[0] ?? null)?.id ?? null;
|
|
}
|
|
|
|
function isInviteTokenHashCollisionError(error: unknown) {
|
|
const candidates = [
|
|
error,
|
|
(error as { cause?: unknown } | null)?.cause ?? null,
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (!candidate || typeof candidate !== "object") continue;
|
|
const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null;
|
|
const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : "";
|
|
const constraint = "constraint" in candidate && typeof candidate.constraint === "string"
|
|
? candidate.constraint
|
|
: null;
|
|
if (code !== "23505") continue;
|
|
if (constraint === "invites_token_hash_unique_idx") return true;
|
|
if (message.includes("invites_token_hash_unique_idx")) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isAbortError(error: unknown) {
|
|
return error instanceof Error && error.name === "AbortError";
|
|
}
|
|
|
|
type InviteResolutionProbe = {
|
|
status: "reachable" | "timeout" | "unreachable";
|
|
method: "HEAD";
|
|
durationMs: number;
|
|
httpStatus: number | null;
|
|
message: string;
|
|
};
|
|
|
|
async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise<InviteResolutionProbe> {
|
|
const startedAt = Date.now();
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "HEAD",
|
|
redirect: "manual",
|
|
signal: controller.signal,
|
|
});
|
|
const durationMs = Date.now() - startedAt;
|
|
if (
|
|
response.ok ||
|
|
response.status === 401 ||
|
|
response.status === 403 ||
|
|
response.status === 404 ||
|
|
response.status === 405 ||
|
|
response.status === 422 ||
|
|
response.status === 500 ||
|
|
response.status === 501
|
|
) {
|
|
return {
|
|
status: "reachable",
|
|
method: "HEAD",
|
|
durationMs,
|
|
httpStatus: response.status,
|
|
message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`,
|
|
};
|
|
}
|
|
return {
|
|
status: "unreachable",
|
|
method: "HEAD",
|
|
durationMs,
|
|
httpStatus: response.status,
|
|
message: `Webhook endpoint probe returned HTTP ${response.status}.`,
|
|
};
|
|
} catch (error) {
|
|
const durationMs = Date.now() - startedAt;
|
|
if (isAbortError(error)) {
|
|
return {
|
|
status: "timeout",
|
|
method: "HEAD",
|
|
durationMs,
|
|
httpStatus: null,
|
|
message: `Webhook endpoint probe timed out after ${timeoutMs}ms.`,
|
|
};
|
|
}
|
|
return {
|
|
status: "unreachable",
|
|
method: "HEAD",
|
|
durationMs,
|
|
httpStatus: null,
|
|
message: error instanceof Error ? error.message : "Webhook endpoint probe failed.",
|
|
};
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export function accessRoutes(
|
|
db: Db,
|
|
opts: {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
},
|
|
) {
|
|
const router = Router();
|
|
const access = accessService(db);
|
|
const agents = agentService(db);
|
|
|
|
async function assertInstanceAdmin(req: Request) {
|
|
if (req.actor.type !== "board") throw unauthorized();
|
|
if (isLocalImplicit(req)) return;
|
|
const allowed = await access.isInstanceAdmin(req.actor.userId);
|
|
if (!allowed) throw forbidden("Instance admin required");
|
|
}
|
|
|
|
router.get("/board-claim/:token", async (req, res) => {
|
|
const token = (req.params.token as string).trim();
|
|
const code = typeof req.query.code === "string" ? req.query.code.trim() : undefined;
|
|
if (!token) throw notFound("Board claim challenge not found");
|
|
const challenge = inspectBoardClaimChallenge(token, code);
|
|
if (challenge.status === "invalid") throw notFound("Board claim challenge not found");
|
|
res.json(challenge);
|
|
});
|
|
|
|
router.post("/board-claim/:token/claim", async (req, res) => {
|
|
const token = (req.params.token as string).trim();
|
|
const code = typeof req.body?.code === "string" ? req.body.code.trim() : undefined;
|
|
if (!token) throw notFound("Board claim challenge not found");
|
|
if (!code) throw badRequest("Claim code is required");
|
|
if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId) {
|
|
throw unauthorized("Sign in before claiming board ownership");
|
|
}
|
|
|
|
const claimed = await claimBoardOwnership(db, {
|
|
token,
|
|
code,
|
|
userId: req.actor.userId,
|
|
});
|
|
|
|
if (claimed.status === "invalid") throw notFound("Board claim challenge not found");
|
|
if (claimed.status === "expired") throw conflict("Board claim challenge expired. Restart server to generate a new one.");
|
|
if (claimed.status === "claimed") {
|
|
res.json({ claimed: true, userId: claimed.claimedByUserId ?? req.actor.userId });
|
|
return;
|
|
}
|
|
|
|
throw conflict("Board claim challenge is no longer available");
|
|
});
|
|
|
|
async function assertCompanyPermission(req: Request, companyId: string, permissionKey: any) {
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type === "agent") {
|
|
if (!req.actor.agentId) throw forbidden();
|
|
const allowed = await access.hasPermission(companyId, "agent", req.actor.agentId, permissionKey);
|
|
if (!allowed) throw forbidden("Permission denied");
|
|
return;
|
|
}
|
|
if (req.actor.type !== "board") throw unauthorized();
|
|
if (isLocalImplicit(req)) return;
|
|
const allowed = await access.canUser(companyId, req.actor.userId, permissionKey);
|
|
if (!allowed) throw forbidden("Permission denied");
|
|
}
|
|
|
|
router.get("/skills/index", (_req, res) => {
|
|
res.json({
|
|
skills: [
|
|
{ name: "paperclip", path: "/api/skills/paperclip" },
|
|
{ name: "paperclip-create-agent", path: "/api/skills/paperclip-create-agent" },
|
|
],
|
|
});
|
|
});
|
|
|
|
router.get("/skills/:skillName", (req, res) => {
|
|
const skillName = (req.params.skillName as string).trim().toLowerCase();
|
|
const markdown = readSkillMarkdown(skillName);
|
|
if (!markdown) throw notFound("Skill not found");
|
|
res.type("text/markdown").send(markdown);
|
|
});
|
|
|
|
router.post(
|
|
"/companies/:companyId/invites",
|
|
validate(createCompanyInviteSchema),
|
|
async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertCompanyPermission(req, companyId, "users:invite");
|
|
const normalizedAgentMessage = typeof req.body.agentMessage === "string"
|
|
? req.body.agentMessage.trim() || null
|
|
: null;
|
|
const insertValues = {
|
|
companyId,
|
|
inviteType: "company_join" as const,
|
|
allowedJoinTypes: req.body.allowedJoinTypes,
|
|
defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage),
|
|
expiresAt: companyInviteExpiresAt(),
|
|
invitedByUserId: req.actor.userId ?? null,
|
|
};
|
|
|
|
let token: string | null = null;
|
|
let created: typeof invites.$inferSelect | null = null;
|
|
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
|
|
const candidateToken = createInviteToken();
|
|
try {
|
|
const row = await db
|
|
.insert(invites)
|
|
.values({
|
|
...insertValues,
|
|
tokenHash: hashToken(candidateToken),
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
token = candidateToken;
|
|
created = row;
|
|
break;
|
|
} catch (error) {
|
|
if (!isInviteTokenHashCollisionError(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
if (!token || !created) {
|
|
throw conflict("Failed to generate a unique invite token. Please retry.");
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
|
|
action: "invite.created",
|
|
entityType: "invite",
|
|
entityId: created.id,
|
|
details: {
|
|
inviteType: created.inviteType,
|
|
allowedJoinTypes: created.allowedJoinTypes,
|
|
expiresAt: created.expiresAt.toISOString(),
|
|
hasAgentMessage: Boolean(normalizedAgentMessage),
|
|
},
|
|
});
|
|
|
|
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
|
res.status(201).json({
|
|
...created,
|
|
token,
|
|
inviteUrl: `/invite/${token}`,
|
|
onboardingTextPath: inviteSummary.onboardingTextPath,
|
|
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
|
inviteMessage: inviteSummary.inviteMessage,
|
|
});
|
|
},
|
|
);
|
|
|
|
router.get("/invites/:token", async (req, res) => {
|
|
const token = (req.params.token as string).trim();
|
|
if (!token) throw notFound("Invite not found");
|
|
const invite = await db
|
|
.select()
|
|
.from(invites)
|
|
.where(eq(invites.tokenHash, hashToken(token)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
|
|
throw notFound("Invite not found");
|
|
}
|
|
|
|
res.json(toInviteSummaryResponse(req, token, invite));
|
|
});
|
|
|
|
router.get("/invites/:token/onboarding", async (req, res) => {
|
|
const token = (req.params.token as string).trim();
|
|
if (!token) throw notFound("Invite not found");
|
|
const invite = await db
|
|
.select()
|
|
.from(invites)
|
|
.where(eq(invites.tokenHash, hashToken(token)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
|
throw notFound("Invite not found");
|
|
}
|
|
|
|
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
|
|
});
|
|
|
|
router.get("/invites/:token/onboarding.txt", async (req, res) => {
|
|
const token = (req.params.token as string).trim();
|
|
if (!token) throw notFound("Invite not found");
|
|
const invite = await db
|
|
.select()
|
|
.from(invites)
|
|
.where(eq(invites.tokenHash, hashToken(token)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
|
throw notFound("Invite not found");
|
|
}
|
|
|
|
res.type("text/plain; charset=utf-8").send(buildInviteOnboardingTextDocument(req, token, invite, opts));
|
|
});
|
|
|
|
router.get("/invites/:token/test-resolution", async (req, res) => {
|
|
const token = (req.params.token as string).trim();
|
|
if (!token) throw notFound("Invite not found");
|
|
const invite = await db
|
|
.select()
|
|
.from(invites)
|
|
.where(eq(invites.tokenHash, hashToken(token)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
|
throw notFound("Invite not found");
|
|
}
|
|
|
|
const rawUrl = typeof req.query.url === "string" ? req.query.url.trim() : "";
|
|
if (!rawUrl) throw badRequest("url query parameter is required");
|
|
let target: URL;
|
|
try {
|
|
target = new URL(rawUrl);
|
|
} catch {
|
|
throw badRequest("url must be an absolute http(s) URL");
|
|
}
|
|
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
throw badRequest("url must use http or https");
|
|
}
|
|
|
|
const parsedTimeoutMs = typeof req.query.timeoutMs === "string" ? Number(req.query.timeoutMs) : NaN;
|
|
const timeoutMs = Number.isFinite(parsedTimeoutMs)
|
|
? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
|
|
: 5000;
|
|
const probe = await probeInviteResolutionTarget(target, timeoutMs);
|
|
res.json({
|
|
inviteId: invite.id,
|
|
testResolutionPath: `/api/invites/${token}/test-resolution`,
|
|
requestedUrl: target.toString(),
|
|
timeoutMs,
|
|
...probe,
|
|
});
|
|
});
|
|
|
|
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
|
const token = (req.params.token as string).trim();
|
|
if (!token) throw notFound("Invite not found");
|
|
|
|
const invite = await db
|
|
.select()
|
|
.from(invites)
|
|
.where(eq(invites.tokenHash, hashToken(token)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
|
|
throw notFound("Invite not found");
|
|
}
|
|
|
|
if (invite.inviteType === "bootstrap_ceo") {
|
|
if (req.body.requestType !== "human") {
|
|
throw badRequest("Bootstrap invite requires human request type");
|
|
}
|
|
if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) {
|
|
throw unauthorized("Authenticated user required for bootstrap acceptance");
|
|
}
|
|
const userId = req.actor.userId ?? "local-board";
|
|
const existingAdmin = await access.isInstanceAdmin(userId);
|
|
if (!existingAdmin) {
|
|
await access.promoteInstanceAdmin(userId);
|
|
}
|
|
const updatedInvite = await db
|
|
.update(invites)
|
|
.set({ acceptedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(invites.id, invite.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? invite);
|
|
res.status(202).json({
|
|
inviteId: updatedInvite.id,
|
|
inviteType: updatedInvite.inviteType,
|
|
bootstrapAccepted: true,
|
|
userId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const requestType = req.body.requestType as "human" | "agent";
|
|
const companyId = invite.companyId;
|
|
if (!companyId) throw conflict("Invite is missing company scope");
|
|
if (invite.allowedJoinTypes !== "both" && invite.allowedJoinTypes !== requestType) {
|
|
throw badRequest(`Invite does not allow ${requestType} joins`);
|
|
}
|
|
|
|
if (requestType === "human" && req.actor.type !== "board") {
|
|
throw unauthorized("Human invite acceptance requires authenticated user");
|
|
}
|
|
if (requestType === "human" && !req.actor.userId && !isLocalImplicit(req)) {
|
|
throw unauthorized("Authenticated user is required");
|
|
}
|
|
if (requestType === "agent" && !req.body.agentName) {
|
|
throw badRequest("agentName is required for agent join requests");
|
|
}
|
|
|
|
const openClawDefaultsPayload = requestType === "agent"
|
|
? buildJoinDefaultsPayloadForAccept({
|
|
adapterType: req.body.adapterType ?? null,
|
|
defaultsPayload: req.body.agentDefaultsPayload ?? null,
|
|
responsesWebhookUrl: req.body.responsesWebhookUrl ?? null,
|
|
responsesWebhookMethod: req.body.responsesWebhookMethod ?? null,
|
|
responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null,
|
|
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
|
webhookAuthHeader: req.body.webhookAuthHeader ?? null,
|
|
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
|
})
|
|
: null;
|
|
|
|
if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") {
|
|
logger.info(
|
|
{
|
|
inviteId: invite.id,
|
|
requestType,
|
|
adapterType: req.body.adapterType ?? null,
|
|
bodyKeys: isPlainObject(req.body) ? Object.keys(req.body).sort() : [],
|
|
responsesWebhookUrl: nonEmptyTrimmedString(req.body.responsesWebhookUrl),
|
|
paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl),
|
|
webhookAuthHeader: summarizeSecretForLog(req.body.webhookAuthHeader),
|
|
inboundOpenClawAuthHeader: summarizeSecretForLog(req.header("x-openclaw-auth") ?? null),
|
|
rawAgentDefaults: summarizeOpenClawDefaultsForLog(req.body.agentDefaultsPayload ?? null),
|
|
mergedAgentDefaults: summarizeOpenClawDefaultsForLog(openClawDefaultsPayload),
|
|
},
|
|
"invite accept received OpenClaw join payload",
|
|
);
|
|
}
|
|
|
|
const joinDefaults = requestType === "agent"
|
|
? normalizeAgentDefaultsForJoin({
|
|
adapterType: req.body.adapterType ?? null,
|
|
defaultsPayload: openClawDefaultsPayload,
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
bindHost: opts.bindHost,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
})
|
|
: { normalized: null as Record<string, unknown> | null, diagnostics: [] as JoinDiagnostic[] };
|
|
|
|
if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") {
|
|
logger.info(
|
|
{
|
|
inviteId: invite.id,
|
|
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
|
|
code: diag.code,
|
|
level: diag.level,
|
|
})),
|
|
normalizedAgentDefaults: summarizeOpenClawDefaultsForLog(joinDefaults.normalized),
|
|
},
|
|
"invite accept normalized OpenClaw defaults",
|
|
);
|
|
}
|
|
|
|
const claimSecret = requestType === "agent" ? createClaimSecret() : null;
|
|
const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
|
|
const claimSecretExpiresAt = claimSecret
|
|
? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
: null;
|
|
|
|
const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null;
|
|
const created = await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(invites)
|
|
.set({ acceptedAt: new Date(), updatedAt: new Date() })
|
|
.where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt)));
|
|
|
|
const row = await tx
|
|
.insert(joinRequests)
|
|
.values({
|
|
inviteId: invite.id,
|
|
companyId,
|
|
requestType,
|
|
status: "pending_approval",
|
|
requestIp: requestIp(req),
|
|
requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null,
|
|
requestEmailSnapshot: requestType === "human" ? actorEmail : null,
|
|
agentName: requestType === "agent" ? req.body.agentName : null,
|
|
adapterType: requestType === "agent" ? req.body.adapterType ?? null : null,
|
|
capabilities: requestType === "agent" ? req.body.capabilities ?? null : null,
|
|
agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null,
|
|
claimSecretHash,
|
|
claimSecretExpiresAt,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
return row;
|
|
});
|
|
|
|
if (requestType === "agent" && (req.body.adapterType ?? null) === "openclaw") {
|
|
const expectedDefaults = summarizeOpenClawDefaultsForLog(joinDefaults.normalized);
|
|
const persistedDefaults = summarizeOpenClawDefaultsForLog(created.agentDefaultsPayload);
|
|
const missingPersistedFields: string[] = [];
|
|
|
|
if (expectedDefaults.url && !persistedDefaults.url) missingPersistedFields.push("url");
|
|
if (expectedDefaults.paperclipApiUrl && !persistedDefaults.paperclipApiUrl) {
|
|
missingPersistedFields.push("paperclipApiUrl");
|
|
}
|
|
if (expectedDefaults.webhookAuthHeader && !persistedDefaults.webhookAuthHeader) {
|
|
missingPersistedFields.push("webhookAuthHeader");
|
|
}
|
|
if (expectedDefaults.openClawAuthHeader && !persistedDefaults.openClawAuthHeader) {
|
|
missingPersistedFields.push("headers.x-openclaw-auth");
|
|
}
|
|
if (expectedDefaults.headerKeys.length > 0 && persistedDefaults.headerKeys.length === 0) {
|
|
missingPersistedFields.push("headers");
|
|
}
|
|
|
|
logger.info(
|
|
{
|
|
inviteId: invite.id,
|
|
joinRequestId: created.id,
|
|
joinRequestStatus: created.status,
|
|
expectedDefaults,
|
|
persistedDefaults,
|
|
diagnostics: joinDefaults.diagnostics.map((diag) => ({
|
|
code: diag.code,
|
|
level: diag.level,
|
|
message: diag.message,
|
|
hint: diag.hint ?? null,
|
|
})),
|
|
},
|
|
"invite accept persisted OpenClaw join request",
|
|
);
|
|
|
|
if (missingPersistedFields.length > 0) {
|
|
logger.warn(
|
|
{
|
|
inviteId: invite.id,
|
|
joinRequestId: created.id,
|
|
missingPersistedFields,
|
|
},
|
|
"invite accept detected missing persisted OpenClaw defaults",
|
|
);
|
|
}
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
actorId:
|
|
req.actor.type === "agent"
|
|
? req.actor.agentId ?? "invite-agent"
|
|
: req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"),
|
|
action: "join.requested",
|
|
entityType: "join_request",
|
|
entityId: created.id,
|
|
details: { requestType, requestIp: created.requestIp },
|
|
});
|
|
|
|
const response = toJoinRequestResponse(created);
|
|
if (claimSecret) {
|
|
const onboardingManifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
|
res.status(202).json({
|
|
...response,
|
|
claimSecret,
|
|
claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`,
|
|
onboarding: onboardingManifest.onboarding,
|
|
diagnostics: joinDefaults.diagnostics,
|
|
});
|
|
return;
|
|
}
|
|
res.status(202).json({
|
|
...response,
|
|
...(joinDefaults.diagnostics.length > 0 ? { diagnostics: joinDefaults.diagnostics } : {}),
|
|
});
|
|
});
|
|
|
|
router.post("/invites/:inviteId/revoke", async (req, res) => {
|
|
const id = req.params.inviteId as string;
|
|
const invite = await db.select().from(invites).where(eq(invites.id, id)).then((rows) => rows[0] ?? null);
|
|
if (!invite) throw notFound("Invite not found");
|
|
if (invite.inviteType === "bootstrap_ceo") {
|
|
await assertInstanceAdmin(req);
|
|
} else {
|
|
if (!invite.companyId) throw conflict("Invite is missing company scope");
|
|
await assertCompanyPermission(req, invite.companyId, "users:invite");
|
|
}
|
|
if (invite.acceptedAt) throw conflict("Invite already consumed");
|
|
if (invite.revokedAt) return res.json(invite);
|
|
|
|
const revoked = await db
|
|
.update(invites)
|
|
.set({ revokedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(invites.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
if (invite.companyId) {
|
|
await logActivity(db, {
|
|
companyId: invite.companyId,
|
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
|
|
action: "invite.revoked",
|
|
entityType: "invite",
|
|
entityId: id,
|
|
});
|
|
}
|
|
|
|
res.json(revoked);
|
|
});
|
|
|
|
router.get("/companies/:companyId/join-requests", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertCompanyPermission(req, companyId, "joins:approve");
|
|
const query = listJoinRequestsQuerySchema.parse(req.query);
|
|
const all = await db
|
|
.select()
|
|
.from(joinRequests)
|
|
.where(eq(joinRequests.companyId, companyId))
|
|
.orderBy(desc(joinRequests.createdAt));
|
|
const filtered = all.filter((row) => {
|
|
if (query.status && row.status !== query.status) return false;
|
|
if (query.requestType && row.requestType !== query.requestType) return false;
|
|
return true;
|
|
});
|
|
res.json(filtered.map(toJoinRequestResponse));
|
|
});
|
|
|
|
router.post("/companies/:companyId/join-requests/:requestId/approve", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const requestId = req.params.requestId as string;
|
|
await assertCompanyPermission(req, companyId, "joins:approve");
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(joinRequests)
|
|
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) throw notFound("Join request not found");
|
|
if (existing.status !== "pending_approval") throw conflict("Join request is not pending");
|
|
|
|
const invite = await db
|
|
.select()
|
|
.from(invites)
|
|
.where(eq(invites.id, existing.inviteId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!invite) throw notFound("Invite not found");
|
|
|
|
let createdAgentId: string | null = existing.createdAgentId ?? null;
|
|
if (existing.requestType === "human") {
|
|
if (!existing.requestingUserId) throw conflict("Join request missing user identity");
|
|
await access.ensureMembership(companyId, "user", existing.requestingUserId, "member", "active");
|
|
const grants = grantsFromDefaults(invite.defaultsPayload as Record<string, unknown> | null, "human");
|
|
await access.setPrincipalGrants(
|
|
companyId,
|
|
"user",
|
|
existing.requestingUserId,
|
|
grants,
|
|
req.actor.userId ?? null,
|
|
);
|
|
} else {
|
|
const managerId = resolveJoinRequestAgentManagerId(await agents.list(companyId));
|
|
if (!managerId) {
|
|
throw conflict("Join request cannot be approved because this company has no active CEO");
|
|
}
|
|
|
|
const created = await agents.create(companyId, {
|
|
name: existing.agentName ?? "New Agent",
|
|
role: "general",
|
|
title: null,
|
|
status: "idle",
|
|
reportsTo: managerId,
|
|
capabilities: existing.capabilities ?? null,
|
|
adapterType: existing.adapterType ?? "process",
|
|
adapterConfig:
|
|
existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object"
|
|
? (existing.agentDefaultsPayload as Record<string, unknown>)
|
|
: {},
|
|
runtimeConfig: {},
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
permissions: {},
|
|
lastHeartbeatAt: null,
|
|
metadata: null,
|
|
});
|
|
createdAgentId = created.id;
|
|
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
|
|
const grants = grantsFromDefaults(invite.defaultsPayload as Record<string, unknown> | null, "agent");
|
|
await access.setPrincipalGrants(companyId, "agent", created.id, grants, req.actor.userId ?? null);
|
|
}
|
|
|
|
const approved = await db
|
|
.update(joinRequests)
|
|
.set({
|
|
status: "approved",
|
|
approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
|
|
approvedAt: new Date(),
|
|
createdAgentId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(joinRequests.id, requestId))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "join.approved",
|
|
entityType: "join_request",
|
|
entityId: requestId,
|
|
details: { requestType: existing.requestType, createdAgentId },
|
|
});
|
|
|
|
if (createdAgentId) {
|
|
void notifyHireApproved(db, {
|
|
companyId,
|
|
agentId: createdAgentId,
|
|
source: "join_request",
|
|
sourceId: requestId,
|
|
approvedAt: new Date(),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
res.json(toJoinRequestResponse(approved));
|
|
});
|
|
|
|
router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const requestId = req.params.requestId as string;
|
|
await assertCompanyPermission(req, companyId, "joins:approve");
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(joinRequests)
|
|
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) throw notFound("Join request not found");
|
|
if (existing.status !== "pending_approval") throw conflict("Join request is not pending");
|
|
|
|
const rejected = await db
|
|
.update(joinRequests)
|
|
.set({
|
|
status: "rejected",
|
|
rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
|
|
rejectedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(joinRequests.id, requestId))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: req.actor.userId ?? "board",
|
|
action: "join.rejected",
|
|
entityType: "join_request",
|
|
entityId: requestId,
|
|
details: { requestType: existing.requestType },
|
|
});
|
|
|
|
res.json(toJoinRequestResponse(rejected));
|
|
});
|
|
|
|
router.post("/join-requests/:requestId/claim-api-key", validate(claimJoinRequestApiKeySchema), async (req, res) => {
|
|
const requestId = req.params.requestId as string;
|
|
const presentedClaimSecretHash = hashToken(req.body.claimSecret);
|
|
const joinRequest = await db
|
|
.select()
|
|
.from(joinRequests)
|
|
.where(eq(joinRequests.id, requestId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!joinRequest) throw notFound("Join request not found");
|
|
if (joinRequest.requestType !== "agent") throw badRequest("Only agent join requests can claim API keys");
|
|
if (joinRequest.status !== "approved") throw conflict("Join request must be approved before key claim");
|
|
if (!joinRequest.createdAgentId) throw conflict("Join request has no created agent");
|
|
if (!joinRequest.claimSecretHash) throw conflict("Join request is missing claim secret metadata");
|
|
if (!tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash)) {
|
|
throw forbidden("Invalid claim secret");
|
|
}
|
|
if (joinRequest.claimSecretExpiresAt && joinRequest.claimSecretExpiresAt.getTime() <= Date.now()) {
|
|
throw conflict("Claim secret expired");
|
|
}
|
|
if (joinRequest.claimSecretConsumedAt) throw conflict("Claim secret already used");
|
|
|
|
const existingKey = await db
|
|
.select({ id: agentApiKeys.id })
|
|
.from(agentApiKeys)
|
|
.where(eq(agentApiKeys.agentId, joinRequest.createdAgentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (existingKey) throw conflict("API key already claimed");
|
|
|
|
const consumed = await db
|
|
.update(joinRequests)
|
|
.set({ claimSecretConsumedAt: new Date(), updatedAt: new Date() })
|
|
.where(and(eq(joinRequests.id, requestId), isNull(joinRequests.claimSecretConsumedAt)))
|
|
.returning({ id: joinRequests.id })
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!consumed) throw conflict("Claim secret already used");
|
|
|
|
const created = await agents.createApiKey(joinRequest.createdAgentId, "initial-join-key");
|
|
|
|
await logActivity(db, {
|
|
companyId: joinRequest.companyId,
|
|
actorType: "system",
|
|
actorId: "join-claim",
|
|
action: "agent_api_key.claimed",
|
|
entityType: "agent_api_key",
|
|
entityId: created.id,
|
|
details: { agentId: joinRequest.createdAgentId, joinRequestId: requestId },
|
|
});
|
|
|
|
res.status(201).json({
|
|
keyId: created.id,
|
|
token: created.token,
|
|
agentId: joinRequest.createdAgentId,
|
|
createdAt: created.createdAt,
|
|
});
|
|
});
|
|
|
|
router.get("/companies/:companyId/members", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
|
const members = await access.listMembers(companyId);
|
|
res.json(members);
|
|
});
|
|
|
|
router.patch(
|
|
"/companies/:companyId/members/:memberId/permissions",
|
|
validate(updateMemberPermissionsSchema),
|
|
async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const memberId = req.params.memberId as string;
|
|
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
|
const updated = await access.setMemberPermissions(
|
|
companyId,
|
|
memberId,
|
|
req.body.grants ?? [],
|
|
req.actor.userId ?? null,
|
|
);
|
|
if (!updated) throw notFound("Member not found");
|
|
res.json(updated);
|
|
},
|
|
);
|
|
|
|
router.post("/admin/users/:userId/promote-instance-admin", async (req, res) => {
|
|
await assertInstanceAdmin(req);
|
|
const userId = req.params.userId as string;
|
|
const result = await access.promoteInstanceAdmin(userId);
|
|
res.status(201).json(result);
|
|
});
|
|
|
|
router.post("/admin/users/:userId/demote-instance-admin", async (req, res) => {
|
|
await assertInstanceAdmin(req);
|
|
const userId = req.params.userId as string;
|
|
const removed = await access.demoteInstanceAdmin(userId);
|
|
if (!removed) throw notFound("Instance admin role not found");
|
|
res.json(removed);
|
|
});
|
|
|
|
router.get("/admin/users/:userId/company-access", async (req, res) => {
|
|
await assertInstanceAdmin(req);
|
|
const userId = req.params.userId as string;
|
|
const memberships = await access.listUserCompanyAccess(userId);
|
|
res.json(memberships);
|
|
});
|
|
|
|
router.put(
|
|
"/admin/users/:userId/company-access",
|
|
validate(updateUserCompanyAccessSchema),
|
|
async (req, res) => {
|
|
await assertInstanceAdmin(req);
|
|
const userId = req.params.userId as string;
|
|
const memberships = await access.setUserCompanyAccess(userId, req.body.companyIds ?? []);
|
|
res.json(memberships);
|
|
},
|
|
);
|
|
|
|
return router;
|
|
}
|