2613 lines
82 KiB
TypeScript
2613 lines
82 KiB
TypeScript
import {
|
|
createHash,
|
|
generateKeyPairSync,
|
|
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,
|
|
createOpenClawInvitePromptSchema,
|
|
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,
|
|
deduplicateAgentName,
|
|
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" &&
|
|
normalized !== "para-memory-files"
|
|
)
|
|
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 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 normalizeHeaderValue(
|
|
value: unknown,
|
|
depth: number = 0
|
|
): string | null {
|
|
const direct = nonEmptyTrimmedString(value);
|
|
if (direct) return direct;
|
|
if (!isPlainObject(value) || depth >= 3) return null;
|
|
|
|
const candidateKeys = [
|
|
"value",
|
|
"token",
|
|
"secret",
|
|
"apiKey",
|
|
"api_key",
|
|
"auth",
|
|
"authToken",
|
|
"auth_token",
|
|
"accessToken",
|
|
"access_token",
|
|
"authorization",
|
|
"bearer",
|
|
"header",
|
|
"raw",
|
|
"text",
|
|
"string"
|
|
];
|
|
for (const key of candidateKeys) {
|
|
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
const normalized = normalizeHeaderValue(
|
|
(value as Record<string, unknown>)[key],
|
|
depth + 1
|
|
);
|
|
if (normalized) return normalized;
|
|
}
|
|
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
if (entries.length === 1) {
|
|
const [singleKey, singleValue] = entries[0];
|
|
const normalizedKey = singleKey.trim().toLowerCase();
|
|
if (
|
|
normalizedKey !== "type" &&
|
|
normalizedKey !== "version" &&
|
|
normalizedKey !== "secretid" &&
|
|
normalizedKey !== "secret_id"
|
|
) {
|
|
const normalized = normalizeHeaderValue(singleValue, depth + 1);
|
|
if (normalized) return normalized;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractHeaderEntries(input: unknown): Array<[string, unknown]> {
|
|
if (isPlainObject(input)) {
|
|
return Object.entries(input);
|
|
}
|
|
if (!Array.isArray(input)) {
|
|
return [];
|
|
}
|
|
|
|
const entries: Array<[string, unknown]> = [];
|
|
for (const item of input) {
|
|
if (Array.isArray(item)) {
|
|
const key = nonEmptyTrimmedString(item[0]);
|
|
if (!key) continue;
|
|
entries.push([key, item[1]]);
|
|
continue;
|
|
}
|
|
if (!isPlainObject(item)) continue;
|
|
|
|
const mapped = item as Record<string, unknown>;
|
|
const explicitKey =
|
|
nonEmptyTrimmedString(mapped.key) ??
|
|
nonEmptyTrimmedString(mapped.name) ??
|
|
nonEmptyTrimmedString(mapped.header);
|
|
if (explicitKey) {
|
|
const explicitValue = Object.prototype.hasOwnProperty.call(
|
|
mapped,
|
|
"value"
|
|
)
|
|
? mapped.value
|
|
: Object.prototype.hasOwnProperty.call(mapped, "token")
|
|
? mapped.token
|
|
: Object.prototype.hasOwnProperty.call(mapped, "secret")
|
|
? mapped.secret
|
|
: mapped;
|
|
entries.push([explicitKey, explicitValue]);
|
|
continue;
|
|
}
|
|
|
|
const singleEntry = Object.entries(mapped);
|
|
if (singleEntry.length === 1) {
|
|
entries.push(singleEntry[0] as [string, unknown]);
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function normalizeHeaderMap(
|
|
input: unknown
|
|
): Record<string, string> | undefined {
|
|
const entries = extractHeaderEntries(input);
|
|
if (entries.length === 0) return undefined;
|
|
|
|
const out: Record<string, string> = {};
|
|
for (const [key, value] of entries) {
|
|
const normalizedValue = normalizeHeaderValue(value);
|
|
if (!normalizedValue) continue;
|
|
const trimmedKey = key.trim();
|
|
const trimmedValue = normalizedValue.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 tokenFromAuthorizationHeader(rawHeader: string | null): string | null {
|
|
const trimmed = nonEmptyTrimmedString(rawHeader);
|
|
if (!trimmed) return null;
|
|
const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i);
|
|
if (bearerMatch?.[1]) {
|
|
return nonEmptyTrimmedString(bearerMatch[1]);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function parseBooleanLike(value: unknown): boolean | null {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return true;
|
|
if (normalized === "false" || normalized === "0") return false;
|
|
return null;
|
|
}
|
|
|
|
function generateEd25519PrivateKeyPem(): string {
|
|
const generated = generateKeyPairSync("ed25519");
|
|
return generated.privateKey
|
|
.export({ type: "pkcs8", format: "pem" })
|
|
.toString();
|
|
}
|
|
|
|
export function buildJoinDefaultsPayloadForAccept(input: {
|
|
adapterType: string | null;
|
|
defaultsPayload: unknown;
|
|
paperclipApiUrl?: unknown;
|
|
inboundOpenClawAuthHeader?: string | null;
|
|
inboundOpenClawTokenHeader?: string | null;
|
|
}): unknown {
|
|
if (input.adapterType !== "openclaw_gateway") {
|
|
return input.defaultsPayload;
|
|
}
|
|
|
|
const merged = isPlainObject(input.defaultsPayload)
|
|
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
|
: ({} as Record<string, unknown>);
|
|
|
|
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
|
|
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
|
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
|
}
|
|
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
|
|
|
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
|
|
input.inboundOpenClawAuthHeader
|
|
);
|
|
const inboundOpenClawTokenHeader = nonEmptyTrimmedString(
|
|
input.inboundOpenClawTokenHeader
|
|
);
|
|
if (
|
|
inboundOpenClawTokenHeader &&
|
|
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
|
|
) {
|
|
mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader;
|
|
}
|
|
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 discoveredToken =
|
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
|
|
tokenFromAuthorizationHeader(
|
|
headerMapGetIgnoreCase(mergedHeaders, "authorization")
|
|
);
|
|
if (
|
|
discoveredToken &&
|
|
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
|
|
) {
|
|
mergedHeaders["x-openclaw-token"] = discoveredToken;
|
|
}
|
|
|
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
}
|
|
|
|
export function mergeJoinDefaultsPayloadForReplay(
|
|
existingDefaultsPayload: unknown,
|
|
nextDefaultsPayload: unknown
|
|
): unknown {
|
|
if (
|
|
!isPlainObject(existingDefaultsPayload) &&
|
|
!isPlainObject(nextDefaultsPayload)
|
|
) {
|
|
return nextDefaultsPayload ?? existingDefaultsPayload;
|
|
}
|
|
if (!isPlainObject(existingDefaultsPayload)) {
|
|
return nextDefaultsPayload;
|
|
}
|
|
if (!isPlainObject(nextDefaultsPayload)) {
|
|
return existingDefaultsPayload;
|
|
}
|
|
|
|
const merged: Record<string, unknown> = {
|
|
...(existingDefaultsPayload as Record<string, unknown>),
|
|
...(nextDefaultsPayload as Record<string, unknown>)
|
|
};
|
|
|
|
const existingHeaders = normalizeHeaderMap(
|
|
(existingDefaultsPayload as Record<string, unknown>).headers
|
|
);
|
|
const nextHeaders = normalizeHeaderMap(
|
|
(nextDefaultsPayload as Record<string, unknown>).headers
|
|
);
|
|
if (existingHeaders || nextHeaders) {
|
|
merged.headers = {
|
|
...(existingHeaders ?? {}),
|
|
...(nextHeaders ?? {})
|
|
};
|
|
} else if (Object.prototype.hasOwnProperty.call(merged, "headers")) {
|
|
delete merged.headers;
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
export function canReplayOpenClawGatewayInviteAccept(input: {
|
|
requestType: "human" | "agent";
|
|
adapterType: string | null;
|
|
existingJoinRequest: Pick<
|
|
typeof joinRequests.$inferSelect,
|
|
"requestType" | "adapterType" | "status"
|
|
> | null;
|
|
}): boolean {
|
|
if (
|
|
input.requestType !== "agent" ||
|
|
input.adapterType !== "openclaw_gateway"
|
|
) {
|
|
return false;
|
|
}
|
|
if (!input.existingJoinRequest) {
|
|
return false;
|
|
}
|
|
if (
|
|
input.existingJoinRequest.requestType !== "agent" ||
|
|
input.existingJoinRequest.adapterType !== "openclaw_gateway"
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
input.existingJoinRequest.status === "pending_approval" ||
|
|
input.existingJoinRequest.status === "approved"
|
|
);
|
|
}
|
|
|
|
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 summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
|
const defaults = isPlainObject(defaultsPayload)
|
|
? (defaultsPayload as Record<string, unknown>)
|
|
: null;
|
|
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
|
|
const gatewayTokenValue = headers
|
|
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
|
tokenFromAuthorizationHeader(
|
|
headerMapGetIgnoreCase(headers, "authorization")
|
|
)
|
|
: null;
|
|
return {
|
|
present: Boolean(defaults),
|
|
keys: defaults ? Object.keys(defaults).sort() : [],
|
|
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
|
|
paperclipApiUrl: defaults
|
|
? nonEmptyTrimmedString(defaults.paperclipApiUrl)
|
|
: null,
|
|
headerKeys: headers ? Object.keys(headers).sort() : [],
|
|
sessionKeyStrategy: defaults
|
|
? nonEmptyTrimmedString(defaults.sessionKeyStrategy)
|
|
: null,
|
|
disableDeviceAuth: defaults
|
|
? parseBooleanLike(defaults.disableDeviceAuth)
|
|
: null,
|
|
waitTimeoutMs:
|
|
defaults && typeof defaults.waitTimeoutMs === "number"
|
|
? defaults.waitTimeoutMs
|
|
: null,
|
|
devicePrivateKeyPem: defaults
|
|
? summarizeSecretForLog(defaults.devicePrivateKeyPem)
|
|
: null,
|
|
gatewayToken: summarizeSecretForLog(gatewayTokenValue)
|
|
};
|
|
}
|
|
|
|
export function normalizeAgentDefaultsForJoin(input: {
|
|
adapterType: string | null;
|
|
defaultsPayload: unknown;
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bindHost: string;
|
|
allowedHostnames: string[];
|
|
}) {
|
|
const fatalErrors: string[] = [];
|
|
const diagnostics: JoinDiagnostic[] = [];
|
|
if (input.adapterType !== "openclaw_gateway") {
|
|
const normalized = isPlainObject(input.defaultsPayload)
|
|
? (input.defaultsPayload as Record<string, unknown>)
|
|
: null;
|
|
return { normalized, diagnostics, fatalErrors };
|
|
}
|
|
|
|
if (!isPlainObject(input.defaultsPayload)) {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_defaults_missing",
|
|
level: "warn",
|
|
message:
|
|
"No OpenClaw gateway config was provided in agentDefaultsPayload.",
|
|
hint:
|
|
"Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins."
|
|
});
|
|
fatalErrors.push(
|
|
"agentDefaultsPayload is required for adapterType=openclaw_gateway"
|
|
);
|
|
return {
|
|
normalized: null as Record<string, unknown> | null,
|
|
diagnostics,
|
|
fatalErrors
|
|
};
|
|
}
|
|
|
|
const defaults = input.defaultsPayload as Record<string, unknown>;
|
|
const normalized: Record<string, unknown> = {};
|
|
|
|
let gatewayUrl: URL | null = null;
|
|
const rawGatewayUrl = nonEmptyTrimmedString(defaults.url);
|
|
if (!rawGatewayUrl) {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_url_missing",
|
|
level: "warn",
|
|
message: "OpenClaw gateway URL is missing.",
|
|
hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL."
|
|
});
|
|
fatalErrors.push("agentDefaultsPayload.url is required");
|
|
} else {
|
|
try {
|
|
gatewayUrl = new URL(rawGatewayUrl);
|
|
if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_url_protocol",
|
|
level: "warn",
|
|
message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).`
|
|
});
|
|
fatalErrors.push(
|
|
"agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway"
|
|
);
|
|
} else {
|
|
normalized.url = gatewayUrl.toString();
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_url_configured",
|
|
level: "info",
|
|
message: `Gateway endpoint set to ${gatewayUrl.toString()}`
|
|
});
|
|
}
|
|
} catch {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_url_invalid",
|
|
level: "warn",
|
|
message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}`
|
|
});
|
|
fatalErrors.push("agentDefaultsPayload.url is not a valid URL");
|
|
}
|
|
}
|
|
|
|
const headers = normalizeHeaderMap(defaults.headers) ?? {};
|
|
const gatewayToken =
|
|
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
|
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
|
|
if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) {
|
|
headers["x-openclaw-token"] = gatewayToken;
|
|
}
|
|
if (Object.keys(headers).length > 0) {
|
|
normalized.headers = headers;
|
|
}
|
|
|
|
if (!gatewayToken) {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_auth_header_missing",
|
|
level: "warn",
|
|
message: "Gateway auth token is missing from agent defaults.",
|
|
hint:
|
|
"Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)."
|
|
});
|
|
fatalErrors.push(
|
|
"agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required"
|
|
);
|
|
} else if (gatewayToken.trim().length < 16) {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_auth_header_too_short",
|
|
level: "warn",
|
|
message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`,
|
|
hint:
|
|
"Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)."
|
|
});
|
|
fatalErrors.push(
|
|
"agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token"
|
|
);
|
|
} else {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_auth_header_configured",
|
|
level: "info",
|
|
message: "Gateway auth token configured."
|
|
});
|
|
}
|
|
|
|
if (isPlainObject(defaults.payloadTemplate)) {
|
|
normalized.payloadTemplate = defaults.payloadTemplate;
|
|
}
|
|
|
|
const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth);
|
|
const disableDeviceAuth = parsedDisableDeviceAuth === true;
|
|
if (parsedDisableDeviceAuth !== null) {
|
|
normalized.disableDeviceAuth = parsedDisableDeviceAuth;
|
|
}
|
|
|
|
const configuredDevicePrivateKeyPem = nonEmptyTrimmedString(
|
|
defaults.devicePrivateKeyPem
|
|
);
|
|
if (configuredDevicePrivateKeyPem) {
|
|
normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem;
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_device_key_configured",
|
|
level: "info",
|
|
message:
|
|
"Gateway device key configured. Pairing approvals should persist for this agent."
|
|
});
|
|
} else if (!disableDeviceAuth) {
|
|
try {
|
|
normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem();
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_device_key_generated",
|
|
level: "info",
|
|
message:
|
|
"Generated persistent gateway device key for this join. Pairing approvals should persist for this agent."
|
|
});
|
|
} catch (err) {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_device_key_generate_failed",
|
|
level: "warn",
|
|
message: `Failed to generate gateway device key: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
hint:
|
|
"Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true."
|
|
});
|
|
fatalErrors.push(
|
|
"Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true."
|
|
);
|
|
}
|
|
}
|
|
|
|
const waitTimeoutMs =
|
|
typeof defaults.waitTimeoutMs === "number" &&
|
|
Number.isFinite(defaults.waitTimeoutMs)
|
|
? Math.floor(defaults.waitTimeoutMs)
|
|
: typeof defaults.waitTimeoutMs === "string"
|
|
? Number.parseInt(defaults.waitTimeoutMs.trim(), 10)
|
|
: NaN;
|
|
if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) {
|
|
normalized.waitTimeoutMs = waitTimeoutMs;
|
|
}
|
|
|
|
const timeoutSec =
|
|
typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)
|
|
? Math.floor(defaults.timeoutSec)
|
|
: typeof defaults.timeoutSec === "string"
|
|
? Number.parseInt(defaults.timeoutSec.trim(), 10)
|
|
: NaN;
|
|
if (Number.isFinite(timeoutSec) && timeoutSec > 0) {
|
|
normalized.timeoutSec = timeoutSec;
|
|
}
|
|
|
|
const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy);
|
|
if (
|
|
sessionKeyStrategy === "fixed" ||
|
|
sessionKeyStrategy === "issue" ||
|
|
sessionKeyStrategy === "run"
|
|
) {
|
|
normalized.sessionKeyStrategy = sessionKeyStrategy;
|
|
}
|
|
|
|
const sessionKey = nonEmptyTrimmedString(defaults.sessionKey);
|
|
if (sessionKey) {
|
|
normalized.sessionKey = sessionKey;
|
|
}
|
|
|
|
const role = nonEmptyTrimmedString(defaults.role);
|
|
if (role) {
|
|
normalized.role = role;
|
|
}
|
|
|
|
if (Array.isArray(defaults.scopes)) {
|
|
const scopes = defaults.scopes
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
if (scopes.length > 0) {
|
|
normalized.scopes = scopes;
|
|
}
|
|
}
|
|
|
|
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_gateway_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_gateway_paperclip_api_url_configured",
|
|
level: "info",
|
|
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`
|
|
});
|
|
}
|
|
} catch {
|
|
diagnostics.push({
|
|
code: "openclaw_gateway_paperclip_api_url_invalid",
|
|
level: "warn",
|
|
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`
|
|
});
|
|
}
|
|
}
|
|
|
|
return { normalized, diagnostics, fatalErrors };
|
|
}
|
|
|
|
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 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 Gateway 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 submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
|
|
inviteMessage: extractInviteMessage(invite),
|
|
recommendedAdapterType: "openclaw_gateway",
|
|
requiredFields: {
|
|
requestType: "agent",
|
|
agentName: "Display name for this agent",
|
|
adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents",
|
|
capabilities: "Optional capability summary",
|
|
agentDefaultsPayload:
|
|
"Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem."
|
|
},
|
|
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,
|
|
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 invite, claim, and skill bootstrap calls."
|
|
},
|
|
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: string[] = [];
|
|
const appendBlock = (block: string) => {
|
|
const trimmed = block.replace(/^\n/, "").replace(/\n\s*$/, "");
|
|
const lineIndentation = trimmed
|
|
.split("\n")
|
|
.filter((line) => line.trim().length > 0)
|
|
.map((line) => line.match(/^(\s*)/)?.[0].length ?? 0);
|
|
const minIndent =
|
|
lineIndentation.length > 0 ? Math.min(...lineIndentation) : 0;
|
|
for (const line of trimmed.split("\n")) {
|
|
lines.push(line.slice(minIndent));
|
|
}
|
|
};
|
|
|
|
appendBlock(`
|
|
# Paperclip OpenClaw Gateway 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) {
|
|
appendBlock(`
|
|
## Message from inviter
|
|
${onboarding.inviteMessage}
|
|
`);
|
|
}
|
|
|
|
appendBlock(`
|
|
## Step 0
|
|
|
|
Get the OpenClaw gateway auth token (THIS MUST BE SENT)
|
|
Token lives in:
|
|
|
|
~/.openclaw/openclaw.json -> gateway.auth.token
|
|
Extract:
|
|
|
|
TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
|
|
test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
|
|
test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1)
|
|
|
|
3) IMPORTANT: Don't accidentally drop the token when generating JSON
|
|
If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var.
|
|
|
|
Safe payload build looks sort of like this (substitute where necessary):
|
|
|
|
BODY="$(node -e '
|
|
const token = process.argv[1];
|
|
if (!token) process.exit(2);
|
|
const body = {
|
|
requestType: "agent",
|
|
agentName: "OpenClaw",
|
|
adapterType: "openclaw_gateway",
|
|
capabilities: "OpenClaw agent adapter",
|
|
agentDefaultsPayload: {
|
|
url: "ws://127.0.0.1:18789",
|
|
paperclipApiUrl: "http://host.docker.internal:3100",
|
|
headers: { "x-openclaw-token": token },
|
|
waitTimeoutMs: 120000,
|
|
sessionKeyStrategy: "issue",
|
|
role: "operator",
|
|
scopes: ["operator.admin"]
|
|
}
|
|
};
|
|
process.stdout.write(JSON.stringify(body));
|
|
' "$TOKEN")"
|
|
|
|
## Step 1: Submit agent join request
|
|
${onboarding.registrationEndpoint.method} ${
|
|
onboarding.registrationEndpoint.url
|
|
}
|
|
|
|
IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token.
|
|
Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred.
|
|
Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL.
|
|
Pairing mode requirement:
|
|
- Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable.
|
|
- You may set disableDeviceAuth=true only for special environments that cannot support pairing.
|
|
- First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry.
|
|
Do NOT use /v1/responses or /hooks/* in this gateway join flow.
|
|
|
|
Body (JSON):
|
|
{
|
|
"requestType": "agent",
|
|
"agentName": "My OpenClaw Agent",
|
|
"adapterType": "openclaw_gateway",
|
|
"capabilities": "Optional summary",
|
|
"agentDefaultsPayload": {
|
|
"url": "wss://your-openclaw-gateway.example",
|
|
"paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",
|
|
"headers": { "x-openclaw-token": "replace-me" },
|
|
"waitTimeoutMs": 120000,
|
|
"sessionKeyStrategy": "issue",
|
|
"role": "operator",
|
|
"scopes": ["operator.admin"]
|
|
}
|
|
}
|
|
|
|
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
|
|
chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json
|
|
|
|
And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here:
|
|
https://docs.openclaw.ai/help/environment
|
|
|
|
e.g.
|
|
|
|
{
|
|
env: {
|
|
PAPERCLIP_API_KEY: "...",
|
|
PAPERCLIP_API_URL: "...",
|
|
},
|
|
}
|
|
|
|
Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL 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}
|
|
|
|
Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL
|
|
|
|
## Text onboarding URL
|
|
${onboarding.textInstructions.url}
|
|
|
|
## Connectivity guidance
|
|
${
|
|
onboarding.connectivity?.guidance ??
|
|
"Ensure Paperclip is reachable from your OpenClaw runtime."
|
|
}
|
|
`);
|
|
|
|
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}`);
|
|
}
|
|
appendBlock(`
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
appendBlock(`
|
|
|
|
## Helpful endpoints
|
|
${onboarding.registrationEndpoint.path}
|
|
${onboarding.claimEndpointTemplate.path}
|
|
${onboarding.skill.path}
|
|
${manifest.invite.onboardingPath}
|
|
`);
|
|
|
|
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");
|
|
}
|
|
|
|
async function assertCanGenerateOpenClawInvitePrompt(
|
|
req: Request,
|
|
companyId: string
|
|
) {
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type === "agent") {
|
|
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
|
const actorAgent = await agents.getById(req.actor.agentId);
|
|
if (!actorAgent || actorAgent.companyId !== companyId) {
|
|
throw forbidden("Agent key cannot access another company");
|
|
}
|
|
if (actorAgent.role !== "ceo") {
|
|
throw forbidden("Only CEO agents can generate OpenClaw invite prompts");
|
|
}
|
|
return;
|
|
}
|
|
if (req.actor.type !== "board") throw unauthorized();
|
|
if (isLocalImplicit(req)) return;
|
|
const allowed = await access.canUser(companyId, req.actor.userId, "users:invite");
|
|
if (!allowed) throw forbidden("Permission denied");
|
|
}
|
|
|
|
async function createCompanyInviteForCompany(input: {
|
|
req: Request;
|
|
companyId: string;
|
|
allowedJoinTypes: "human" | "agent" | "both";
|
|
defaultsPayload?: Record<string, unknown> | null;
|
|
agentMessage?: string | null;
|
|
}) {
|
|
const normalizedAgentMessage =
|
|
typeof input.agentMessage === "string"
|
|
? input.agentMessage.trim() || null
|
|
: null;
|
|
const insertValues = {
|
|
companyId: input.companyId,
|
|
inviteType: "company_join" as const,
|
|
allowedJoinTypes: input.allowedJoinTypes,
|
|
defaultsPayload: mergeInviteDefaults(
|
|
input.defaultsPayload ?? null,
|
|
normalizedAgentMessage
|
|
),
|
|
expiresAt: companyInviteExpiresAt(),
|
|
invitedByUserId: input.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.");
|
|
}
|
|
|
|
return { token, created, normalizedAgentMessage };
|
|
}
|
|
|
|
router.get("/skills/index", (_req, res) => {
|
|
res.json({
|
|
skills: [
|
|
{ name: "paperclip", path: "/api/skills/paperclip" },
|
|
{
|
|
name: "para-memory-files",
|
|
path: "/api/skills/para-memory-files"
|
|
},
|
|
{
|
|
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 { token, created, normalizedAgentMessage } =
|
|
await createCompanyInviteForCompany({
|
|
req,
|
|
companyId,
|
|
allowedJoinTypes: req.body.allowedJoinTypes,
|
|
defaultsPayload: req.body.defaultsPayload ?? null,
|
|
agentMessage: req.body.agentMessage ?? null
|
|
});
|
|
|
|
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.post(
|
|
"/companies/:companyId/openclaw/invite-prompt",
|
|
validate(createOpenClawInvitePromptSchema),
|
|
async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertCanGenerateOpenClawInvitePrompt(req, companyId);
|
|
const { token, created, normalizedAgentMessage } =
|
|
await createCompanyInviteForCompany({
|
|
req,
|
|
companyId,
|
|
allowedJoinTypes: "agent",
|
|
defaultsPayload: null,
|
|
agentMessage: req.body.agentMessage ?? null
|
|
});
|
|
|
|
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.openclaw_prompt_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 || inviteExpired(invite)) {
|
|
throw notFound("Invite not found");
|
|
}
|
|
const inviteAlreadyAccepted = Boolean(invite.acceptedAt);
|
|
const existingJoinRequestForInvite = inviteAlreadyAccepted
|
|
? await db
|
|
.select()
|
|
.from(joinRequests)
|
|
.where(eq(joinRequests.inviteId, invite.id))
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
|
|
if (invite.inviteType === "bootstrap_ceo") {
|
|
if (inviteAlreadyAccepted) throw notFound("Invite not found");
|
|
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) {
|
|
if (
|
|
!inviteAlreadyAccepted ||
|
|
!existingJoinRequestForInvite?.agentName
|
|
) {
|
|
throw badRequest("agentName is required for agent join requests");
|
|
}
|
|
}
|
|
|
|
const adapterType = req.body.adapterType ?? null;
|
|
if (
|
|
inviteAlreadyAccepted &&
|
|
!canReplayOpenClawGatewayInviteAccept({
|
|
requestType,
|
|
adapterType,
|
|
existingJoinRequest: existingJoinRequestForInvite
|
|
})
|
|
) {
|
|
throw notFound("Invite not found");
|
|
}
|
|
const replayJoinRequestId = inviteAlreadyAccepted
|
|
? existingJoinRequestForInvite?.id ?? null
|
|
: null;
|
|
if (inviteAlreadyAccepted && !replayJoinRequestId) {
|
|
throw conflict("Join request not found");
|
|
}
|
|
|
|
const replayMergedDefaults = inviteAlreadyAccepted
|
|
? mergeJoinDefaultsPayloadForReplay(
|
|
existingJoinRequestForInvite?.agentDefaultsPayload ?? null,
|
|
req.body.agentDefaultsPayload ?? null
|
|
)
|
|
: req.body.agentDefaultsPayload ?? null;
|
|
|
|
const gatewayDefaultsPayload =
|
|
requestType === "agent"
|
|
? buildJoinDefaultsPayloadForAccept({
|
|
adapterType,
|
|
defaultsPayload: replayMergedDefaults,
|
|
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
|
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
|
inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
|
|
})
|
|
: null;
|
|
|
|
const joinDefaults =
|
|
requestType === "agent"
|
|
? normalizeAgentDefaultsForJoin({
|
|
adapterType,
|
|
defaultsPayload: gatewayDefaultsPayload,
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
bindHost: opts.bindHost,
|
|
allowedHostnames: opts.allowedHostnames
|
|
})
|
|
: {
|
|
normalized: null as Record<string, unknown> | null,
|
|
diagnostics: [] as JoinDiagnostic[],
|
|
fatalErrors: [] as string[]
|
|
};
|
|
|
|
if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) {
|
|
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
|
}
|
|
|
|
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
|
logger.info(
|
|
{
|
|
inviteId: invite.id,
|
|
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
|
|
code: diag.code,
|
|
level: diag.level
|
|
})),
|
|
normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog(
|
|
joinDefaults.normalized
|
|
)
|
|
},
|
|
"invite accept normalized OpenClaw gateway defaults"
|
|
);
|
|
}
|
|
|
|
const claimSecret =
|
|
requestType === "agent" && !inviteAlreadyAccepted
|
|
? 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 = !inviteAlreadyAccepted
|
|
? 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" ? adapterType : null,
|
|
capabilities:
|
|
requestType === "agent"
|
|
? req.body.capabilities ?? null
|
|
: null,
|
|
agentDefaultsPayload:
|
|
requestType === "agent" ? joinDefaults.normalized : null,
|
|
claimSecretHash,
|
|
claimSecretExpiresAt
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
return row;
|
|
})
|
|
: await db
|
|
.update(joinRequests)
|
|
.set({
|
|
requestIp: requestIp(req),
|
|
agentName:
|
|
requestType === "agent"
|
|
? req.body.agentName ??
|
|
existingJoinRequestForInvite?.agentName ??
|
|
null
|
|
: null,
|
|
capabilities:
|
|
requestType === "agent"
|
|
? req.body.capabilities ??
|
|
existingJoinRequestForInvite?.capabilities ??
|
|
null
|
|
: null,
|
|
adapterType: requestType === "agent" ? adapterType : null,
|
|
agentDefaultsPayload:
|
|
requestType === "agent" ? joinDefaults.normalized : null,
|
|
updatedAt: new Date()
|
|
})
|
|
.where(eq(joinRequests.id, replayJoinRequestId as string))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
if (!created) {
|
|
throw conflict("Join request not found");
|
|
}
|
|
|
|
if (
|
|
inviteAlreadyAccepted &&
|
|
requestType === "agent" &&
|
|
adapterType === "openclaw_gateway" &&
|
|
created.status === "approved" &&
|
|
created.createdAgentId
|
|
) {
|
|
const existingAgent = await agents.getById(created.createdAgentId);
|
|
if (!existingAgent) {
|
|
throw conflict("Approved join request agent not found");
|
|
}
|
|
const existingAdapterConfig = isPlainObject(existingAgent.adapterConfig)
|
|
? (existingAgent.adapterConfig as Record<string, unknown>)
|
|
: {};
|
|
const nextAdapterConfig = {
|
|
...existingAdapterConfig,
|
|
...(joinDefaults.normalized ?? {})
|
|
};
|
|
const updatedAgent = await agents.update(created.createdAgentId, {
|
|
adapterType,
|
|
adapterConfig: nextAdapterConfig
|
|
});
|
|
if (!updatedAgent) {
|
|
throw conflict("Approved join request agent not found");
|
|
}
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
actorId:
|
|
req.actor.type === "agent"
|
|
? req.actor.agentId ?? "invite-agent"
|
|
: req.actor.userId ?? "board",
|
|
action: "agent.updated_from_join_replay",
|
|
entityType: "agent",
|
|
entityId: updatedAgent.id,
|
|
details: { inviteId: invite.id, joinRequestId: created.id }
|
|
});
|
|
}
|
|
|
|
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
|
const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog(
|
|
joinDefaults.normalized
|
|
);
|
|
const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog(
|
|
created.agentDefaultsPayload
|
|
);
|
|
const missingPersistedFields: string[] = [];
|
|
|
|
if (expectedDefaults.url && !persistedDefaults.url)
|
|
missingPersistedFields.push("url");
|
|
if (
|
|
expectedDefaults.paperclipApiUrl &&
|
|
!persistedDefaults.paperclipApiUrl
|
|
) {
|
|
missingPersistedFields.push("paperclipApiUrl");
|
|
}
|
|
if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) {
|
|
missingPersistedFields.push("headers.x-openclaw-token");
|
|
}
|
|
if (
|
|
expectedDefaults.devicePrivateKeyPem &&
|
|
!persistedDefaults.devicePrivateKeyPem
|
|
) {
|
|
missingPersistedFields.push("devicePrivateKeyPem");
|
|
}
|
|
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 gateway join request"
|
|
);
|
|
|
|
if (missingPersistedFields.length > 0) {
|
|
logger.warn(
|
|
{
|
|
inviteId: invite.id,
|
|
joinRequestId: created.id,
|
|
missingPersistedFields
|
|
},
|
|
"invite accept detected missing persisted OpenClaw gateway 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: inviteAlreadyAccepted
|
|
? "join.request_replayed"
|
|
: "join.requested",
|
|
entityType: "join_request",
|
|
entityId: created.id,
|
|
details: {
|
|
requestType,
|
|
requestIp: created.requestIp,
|
|
inviteReplay: inviteAlreadyAccepted
|
|
}
|
|
});
|
|
|
|
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 existingAgents = await agents.list(companyId);
|
|
const managerId = resolveJoinRequestAgentManagerId(existingAgents);
|
|
if (!managerId) {
|
|
throw conflict(
|
|
"Join request cannot be approved because this company has no active CEO"
|
|
);
|
|
}
|
|
|
|
const agentName = deduplicateAgentName(
|
|
existing.agentName ?? "New Agent",
|
|
existingAgents.map((a) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
status: a.status
|
|
}))
|
|
);
|
|
|
|
const created = await agents.create(companyId, {
|
|
name: agentName,
|
|
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;
|
|
}
|