Files
PaperClipAI/server/src/middleware/auth.ts
T
Devin FoleyandPaperclip 9f3a5456ac perf(server): bound run reads in authentication and conversation recovery (#17663)
Read compact run identity during authentication and compact turn state during conversation recovery. Preserve fresh checks, durable steering and reply evidence, locking, and complete stored payloads.

Validate 184 targeted PostgreSQL and lifecycle tests, workspace typecheck, server build, full CI, and Apex 5/5.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 15:53:18 -07:00

1045 lines
37 KiB
TypeScript

import { createHash, timingSafeEqual } from "node:crypto";
import type { Request, RequestHandler } from "express";
import { and, eq, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agentApiKeys,
agents,
authUsers,
companies,
companyMemberships,
heartbeatRuns,
instanceUserRoles,
} from "@paperclipai/db";
import {
MAX_ISSUE_PREFIX_ATTEMPTS,
deriveIssuePrefixBase,
isIssuePrefixConflict,
issuePrefixSuffixForAttempt,
pickAvailableIssuePrefix,
rekeyCompanyIssueIdentifiers,
} from "../services/issue-prefix.js";
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
import { agentRunWritesRevoked } from "../agent-run-cancellation.js";
import { loadAgentRunAuthentication } from "./agent-run-authentication.js";
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "./logger.js";
import { captureRunIdentity } from "../services/run-identity.js";
import { boardAuthService } from "../services/board-auth.js";
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
import { beginIdleTrackedWork } from "../services/task-admission.js";
export {
isTransientDbConnectionError,
retryIdempotentDatabaseOperation as retryOnTransientDbConnectionError,
} from "../database-retry.js";
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
const CLOUD_TENANT_WRITE_DEBOUNCE_MAX = 1_000;
const cloudTenantWriteDebounces = new WeakMap<Db, Map<string, { fingerprint: string; syncedAt: number }>>();
function cloudTenantWriteDebounceFor(db: Db) {
let debounce = cloudTenantWriteDebounces.get(db);
if (!debounce) {
debounce = new Map();
cloudTenantWriteDebounces.set(db, debounce);
}
return debounce;
}
function pruneCloudTenantWriteDebounce(
debounce: Map<string, { fingerprint: string; syncedAt: number }>,
nowMs: number,
) {
for (const [subject, entry] of debounce) {
if (entry.syncedAt <= nowMs - CLOUD_TENANT_WRITE_DEBOUNCE_MS) debounce.delete(subject);
}
while (debounce.size > CLOUD_TENANT_WRITE_DEBOUNCE_MAX) {
const oldestSubject = debounce.keys().next().value;
if (!oldestSubject) break;
debounce.delete(oldestSubject);
}
}
import { resolveOwnerInstanceAdmin } from "../services/cloud-tenant-authority.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import { forbidden, unauthorized, unprocessable } from "../errors.js";
export { isCloudManagedInstance } from "../services/cloud-instance.js";
import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function normalizeOptionalString(value: string | null | undefined) {
return value?.trim() || null;
}
function invalidAgentTokenMessage(token: string) {
try {
const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")) as {
exp?: unknown;
};
if (typeof payload.exp === "number" && payload.exp <= Math.floor(Date.now() / 1000)) {
return "Expired agent token; obtain fresh credentials and retry";
}
} catch {
// Malformed and incorrectly signed tokens share the generic failure below.
}
return "Agent token did not verify; obtain fresh credentials and retry";
}
async function resolveLegacyRunResponsibleUserId(
db: Db,
input: { companyId: string; agentId: string; runId: string },
) {
if (!isUuidLike(input.runId)) return null;
const run = await db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId),
),
)
.then((rows) => rows[0] ?? null);
return normalizeOptionalString(run?.responsibleUserId);
}
async function loadResponsibleUserMemberships(
db: Db,
input: { companyId: string; userId: string | null },
) {
if (!input.userId) return [];
const [user, memberships] = await Promise.all([
db
.select({ id: authUsers.id })
.from(authUsers)
.where(eq(authUsers.id, input.userId))
.then((rows) => rows[0] ?? null),
db
.select({
companyId: companyMemberships.companyId,
membershipRole: companyMemberships.membershipRole,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, input.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, input.userId),
eq(companyMemberships.status, "active"),
),
),
]);
return user ? memberships : [];
}
/**
* The user's own active company memberships — the exact company scope a
* locally authenticated session actor carries. Shared by the session path
* and the Cloud trusted-header path so both resolve the same access set.
*/
async function loadActiveUserCompanyMemberships(db: Db, userId: string) {
return db
.select({
companyId: companyMemberships.companyId,
membershipRole: companyMemberships.membershipRole,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
);
}
async function auditAgentJwtRunHeaderMismatch(
db: Db,
input: { companyId: string; agentId: string; claimRunId: string; headerRunId: string; method: string; url: string },
) {
try {
// The claimed run id is untrusted input from a rejected token. activity_log
// .run_id is a real FK to heartbeat_runs, so writing it unconditionally
// makes the insert fail — and the catch swallows it — exactly when the
// claim is bogus. Keep the claim in entityId/details and populate the FK
// column only when the row actually exists.
const claimedRunExists = isUuidLike(input.claimRunId)
? await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.claimRunId))
.then((rows) => rows.length > 0)
: false;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: "agent",
actorId: input.agentId,
action: "auth.agent_jwt_run_header_mismatch",
entityType: "heartbeat_run",
entityId: input.claimRunId,
...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
...(claimedRunExists ? { runId: input.claimRunId } : {}),
details: {
claimRunId: input.claimRunId,
headerRunId: input.headerRunId,
method: input.method,
url: input.url,
},
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId, agentId: input.agentId, claimRunId: input.claimRunId },
"Failed to audit rejected agent JWT run header mismatch",
);
}
}
async function auditAgentKeyMissingResponsibleUser(
db: Db,
input: { companyId: string; agentId: string; keyId: string; method: string; url: string },
) {
try {
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: "agent",
actorId: input.agentId,
action: "auth.agent_key_missing_responsible_user",
entityType: "agent_api_key",
entityId: input.keyId,
...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
details: {
method: input.method,
url: input.url,
},
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId, agentId: input.agentId, keyId: input.keyId },
"Failed to audit rejected agent key without responsible user binding",
);
}
}
interface ActorMiddlewareOptions {
deploymentMode: DeploymentMode;
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
}
const publicRoutineWebhookPath = /^\/api\/routine-triggers\/public\/[a-f0-9]{24}\/fire\/?$/i;
const publicMcpGatewayProtocolPath = /^\/mcp\/gateways\/gw_[a-f0-9]{32}\/?$/i;
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
const boardAuth = boardAuthService(db);
const authenticate: RequestHandler = async (req, _res, next) => {
req.actor =
opts.deploymentMode === "local_trusted"
? {
type: "board",
userId: "local-board",
userName: "Local Board",
userEmail: null,
isInstanceAdmin: true,
source: "local_implicit",
}
: { type: "none", source: "none" };
// Routine ingress authenticates its own bearer/signature. Never interpret
// webhook credentials as agent keys or attach an ambient browser session.
if (req.method === "POST" && publicRoutineWebhookPath.test(req.path)) {
req.actor = { type: "none", source: "none" };
next();
return;
}
const runIdHeader = req.header("x-paperclip-run-id");
const authHeader = req.header("authorization");
const hasBearerCredentials = /^bearer(?:\s|$)/i.test(authHeader ?? "");
// Public MCP gateway protocol requests carry a pcgw_* bearer that is
// validated by the gateway service itself. Do not interpret that bearer as
// a board key or agent JWT here: doing so rejects the MCP handshake before
// the protocol route can verify its run-scoped credential. Keep this bypass
// restricted to the unguessable public gateway path; all /api routes retain
// the normal actor authentication path below.
if (hasBearerCredentials && publicMcpGatewayProtocolPath.test(req.path)) {
if (runIdHeader) req.actor.runId = runIdHeader;
next();
return;
}
if (!hasBearerCredentials) {
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
const cloudTenantActor = await resolveCloudTenantActor(db, req);
if (cloudTenantActor) {
req.actor = {
...cloudTenantActor,
runId: runIdHeader ?? undefined,
};
next();
return;
}
let session: BetterAuthSessionResult | null = null;
try {
session = await opts.resolveSession(req);
} catch (err) {
logger.warn(
{ err, method: req.method, url: req.originalUrl },
"Failed to resolve auth session from request headers",
);
}
if (session?.user?.id && session.session?.id) {
const userId = session.user.id;
const [roleRow, memberships] = await Promise.all([
db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null),
loadActiveUserCompanyMemberships(db, userId),
]);
req.actor = {
type: "board",
userId,
sessionId: session.session.id,
userName: session.user.name ?? null,
userEmail: session.user.email ?? null,
companyIds: memberships.map((row) => row.companyId),
memberships,
isInstanceAdmin: Boolean(roleRow),
runId: runIdHeader ?? undefined,
source: "session",
};
next();
return;
}
}
if (runIdHeader) req.actor.runId = runIdHeader;
next();
return;
}
const token = authHeader!.slice("bearer".length).trim();
if (!token) {
next(unauthorized("Empty bearer token; provide valid agent credentials and retry"));
return;
}
const boardKey = await boardAuth.findBoardApiKeyByToken(token);
if (boardKey) {
const access = await boardAuth.resolveBoardAccess(boardKey.userId);
if (access.user) {
await boardAuth.touchBoardApiKey(boardKey.id);
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user?.name ?? null,
userEmail: access.user?.email ?? null,
companyIds: access.companyIds,
memberships: access.memberships,
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
runId: runIdHeader || undefined,
source: "board_key",
};
next();
return;
}
}
const tokenHash = hashToken(token);
const key = await db
.select()
.from(agentApiKeys)
.where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt)))
.then((rows) => rows[0] ?? null);
if (!key) {
const claims = verifyLocalAgentJwt(token);
if (!claims) {
next(unauthorized(invalidAgentTokenMessage(token)));
return;
}
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, claims.sub))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
next(unauthorized("Agent record is missing or belongs to another company; obtain fresh credentials and retry"));
return;
}
if (agentRecord.lifecycleHolds?.includes("setup")) {
next(unauthorized("Agent setup is incomplete and cannot authenticate"));
return;
}
if (agentRecord.status === "terminated") {
next(unauthorized("Agent is terminated and cannot authenticate"));
return;
}
if (agentRecord.status === "pending_approval") {
next(unauthorized("Agent is pending approval and cannot authenticate"));
return;
}
const normalizedRunIdHeader = normalizeOptionalString(runIdHeader);
if (normalizedRunIdHeader && normalizedRunIdHeader !== claims.run_id) {
await auditAgentJwtRunHeaderMismatch(db, {
companyId: claims.company_id,
agentId: claims.sub,
claimRunId: claims.run_id,
headerRunId: normalizedRunIdHeader,
method: req.method,
url: req.originalUrl,
});
next(
unprocessable("X-Paperclip-Run-Id does not match signed agent JWT run_id", {
code: "agent_jwt_run_id_mismatch",
claimRunId: claims.run_id,
headerRunId: normalizedRunIdHeader,
}),
);
return;
}
const [identityRun] = await loadAgentRunAuthentication(db, {
runId: claims.run_id,
companyId: claims.company_id,
agentId: claims.sub,
});
if (agentRunWritesRevoked(identityRun)
&& !["GET", "HEAD", "OPTIONS"].includes(req.method)) {
const conversation = identityRun?.contextSnapshot?.conversationMode === true;
_res.status(403).json({ error: conversation ? "This conversation turn was cancelled" : "This run was cancelled",
code: conversation ? "conversation_turn_cancelled" : "agent_run_cancelled" });
return;
}
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id },
{ readScope: "request_authentication" });
identityRun.activeIdentityContextId = captured.context?.id ?? null;
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
}
if (claims.authorizing_user_id) {
// This signed projection exists only for internal native tools. Recheck
// the admitted run and current immutable binding on every API call.
try {
const { parseNativeExecutionInput } = await import("../vendor/paperclip-runner/index.js");
const { museRunnerBroker } = await import("../services/muse-runner-broker.js");
const [nativeRun] = await db.select({ status: heartbeatRuns.status, runtimeMode: heartbeatRuns.runtimeMode,
nativeExecutionInput: sql<unknown>`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'`,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
));
const execution = parseNativeExecutionInput(nativeRun?.nativeExecutionInput);
if (nativeRun?.status !== "running" || nativeRun.runtimeMode !== "native" || execution.schema !== "paperclip.native-execution-input.v8"
|| execution.binding.runId !== claims.run_id || execution.binding.agentId !== claims.sub || execution.binding.companyId !== claims.company_id
|| await museRunnerBroker(db).authorizingUserIdForBinding(execution.provider.binding) !== claims.authorizing_user_id) {
throw new Error("Muse internal tool authority mismatch");
}
await museRunnerBroker(db).assertRunAuthority(execution);
} catch {
_res.status(403).json({ error: "Muse native tool authority is unavailable" });
return;
}
}
const onBehalfOfUserId = identityRun?.activeIdentityContextId
? identityRun.responsibleUserId
: claims.responsible_user_id !== undefined
? normalizeOptionalString(claims.responsible_user_id)
: await resolveLegacyRunResponsibleUserId(db, {
companyId: claims.company_id,
agentId: claims.sub,
runId: claims.run_id,
});
const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {
companyId: claims.company_id,
userId: onBehalfOfUserId,
});
req.actor = {
type: "agent",
agentId: claims.sub,
companyId: claims.company_id,
keyId: undefined,
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
runId: claims.run_id,
onBehalfOfUserId,
...(claims.authorizing_user_id ? { authorizingUserId: claims.authorizing_user_id } : {}),
identityContextId: identityRun?.activeIdentityContextId ?? null,
onBehalfOfMemberships,
source: "agent_jwt",
};
next();
return;
}
await db
.update(agentApiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(agentApiKeys.id, key.id));
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, key.agentId))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.companyId !== key.companyId) {
next(unauthorized("Agent record is missing or belongs to another company; obtain fresh credentials and retry"));
return;
}
if (agentRecord.lifecycleHolds?.includes("setup")) {
next(unauthorized("Agent setup is incomplete and cannot authenticate"));
return;
}
if (agentRecord.status === "terminated") {
next(unauthorized("Agent is terminated and cannot authenticate"));
return;
}
if (agentRecord.status === "pending_approval") {
next(unauthorized("Agent is pending approval and cannot authenticate"));
return;
}
const responsibleUserId = normalizeOptionalString(key.responsibleUserId);
if (!responsibleUserId) {
await auditAgentKeyMissingResponsibleUser(db, {
companyId: key.companyId,
agentId: key.agentId,
keyId: key.id,
method: req.method,
url: req.originalUrl,
});
next(forbidden("Responsible user is unavailable for this agent key", {
code: "RESPONSIBLE_USER_UNAVAILABLE",
}));
return;
}
req.actor = {
type: "agent",
agentId: key.agentId,
companyId: key.companyId,
keyId: key.id,
keyScope: normalizeAgentApiKeyScope(key.scopeConfig),
onBehalfOfUserId: responsibleUserId,
onBehalfOfMemberships: await loadResponsibleUserMemberships(db, {
companyId: key.companyId,
userId: responsibleUserId,
}),
runId: runIdHeader || undefined,
source: "agent_key",
};
next();
};
return async (req, res, next) => {
// Health and task-drain requests bypass tenant admission, but resolving
// their actor can still write users, companies, memberships and key usage.
// Finish all authentication before entering the next handler: the report
// must count concurrent authentication, without counting its own auth.
const finish = beginIdleTrackedWork();
let continueRequest: (() => void) | undefined;
try {
await authenticate(req, res, (error?: unknown) => {
continueRequest = () => next(error);
});
} finally {
finish();
}
continueRequest?.();
};
}
/**
* Minimal header accessor `resolveCloudTenantActor` needs. Express `Request`
* satisfies it directly; websocket upgrade paths adapt a raw
* `IncomingMessage` with {@link cloudActorHeaderSourceFromHeaders} since
* trusted-header authentication must work identically for upgrades — a
* cloud-proxied browser has no local Better Auth session to fall back on.
*/
export interface CloudActorHeaderSource {
header(name: string): string | undefined;
}
/** Adapts a raw header map (e.g. `IncomingMessage.headers`) to {@link CloudActorHeaderSource}. */
export function cloudActorHeaderSourceFromHeaders(
headers: Record<string, string | string[] | undefined>,
): CloudActorHeaderSource {
return {
header(name: string) {
const value = headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
},
};
}
/**
* Trusted-header actor resolution with bounded transient-connection retries.
* The tenant sync inside is idempotent end to end — every write is an
* upsert/on-conflict/delete and the write debounce records only after the
* whole sync succeeds — so replaying it after a dropped connection is safe,
* and turns a golden-path authentication 500 into a served request.
*/
export async function resolveCloudTenantActor(
db: Db,
req: CloudActorHeaderSource,
): Promise<Express.Request["actor"] | null> {
return retryIdempotentDatabaseOperation(() => resolveCloudTenantActorOnce(db, req));
}
async function resolveCloudTenantActorOnce(
db: Db,
req: CloudActorHeaderSource,
): Promise<Express.Request["actor"] | null> {
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
if (!expectedToken) return null;
const token = req.header("x-paperclip-cloud-tenant-token")?.trim();
if (!token || !constantTimeStringEqual(token, expectedToken)) return null;
const userId = requiredCloudHeader(req, "x-paperclip-cloud-user-id");
const userEmail = requiredCloudHeader(req, "x-paperclip-cloud-user-email").toLowerCase();
const stackId = requiredCloudHeader(req, "x-paperclip-cloud-stack-id");
const stackRole = stackMembershipRole(req.header("x-paperclip-cloud-stack-role"));
const userName = req.header("x-paperclip-cloud-user-name")?.trim() || userEmail;
const paperclipCompanyId = req.header("x-paperclip-cloud-paperclip-company-id")?.trim();
const paperclipCompanyName = req
.header("x-paperclip-cloud-paperclip-company-name")
?.trim();
const companyId = cloudTenantCompanyId(stackId);
const companyName = paperclipCompanyName || humanizeCloudStackSlug(stackId);
const now = new Date();
const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
const syncFingerprint = [userEmail, userName, stackId, stackRole, paperclipCompanyId ?? ""].join(":");
const cloudTenantWriteDebounce = cloudTenantWriteDebounceFor(db);
pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, now.getTime());
const previousSync = cloudTenantWriteDebounce.get(userId);
const shouldSync = previousSync?.fingerprint !== syncFingerprint
|| previousSync.syncedAt <= now.getTime() - CLOUD_TENANT_WRITE_DEBOUNCE_MS;
let effectiveMembership: { companyId: string; membershipRole: string | null; status: string } = {
companyId,
membershipRole,
status: "active",
};
if (shouldSync) await db
.insert(authUsers)
.values({
id: userId,
name: userName,
email: userEmail,
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: authUsers.id,
set: {
name: userName,
email: userEmail,
emailVerified: true,
updatedAt: now,
},
});
// Earlier cloud_tenant builds granted every tenant user `instance_admin`.
// Stale rows from those deployments would still elevate this user through
// the BetterAuth session path, board API keys, and the authorization
// service's own instanceUserRoles lookup — so actively purge them on every
// trusted-header authentication instead of merely no longer inserting them.
await db
.delete(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")));
if (shouldSync) await insertCloudTenantCompany(db, { companyId, companyName, now });
if (shouldSync && paperclipCompanyName) {
await repairCloudTenantCompanyName(db, {
companyId,
paperclipCompanyId,
paperclipCompanyName,
now,
});
}
// Runs after the name repair so the prefix derives from the repaired name.
// The helper self-gates on the legacy markers, so it is a no-op once the
// company has been repaired or was claimed by a current build.
if (shouldSync) {
await repairCloudTenantCompanyProvisionDefaults(db, { companyId, stackId, now });
}
effectiveMembership = shouldSync ? await db
.insert(companyMemberships)
.values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
companyMemberships.companyId,
companyMemberships.principalType,
companyMemberships.principalId,
],
set: {
status: "active",
membershipRole,
updatedAt: now,
},
})
.returning()
.then((rows) => rows[0] ?? {
companyId,
membershipRole,
status: "active",
}) : { companyId, membershipRole, status: "active" as const };
// Without instance-admin elevation, cloud tenant users are authorized purely
// through company-scoped permission grants — seed the same role defaults the
// regular membership flows create.
if (shouldSync) await ensureHumanRoleDefaultGrants(db, {
companyId,
principalId: userId,
membershipRole: effectiveMembership.membershipRole ?? membershipRole,
grantedByUserId: null,
});
if (shouldSync) {
cloudTenantWriteDebounce.delete(userId);
cloudTenantWriteDebounce.set(userId, { fingerprint: syncFingerprint, syncedAt: Date.now() });
pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, Date.now());
}
// The stack's seeded company is only where Cloud provisioned this user.
// Companies created afterwards on the instance (imports, in-app company
// creation) attach real membership rows for the user, so union those with
// the pinned primary — the same active-membership scope a locally
// authenticated session actor carries. Strictly this user's own rows; the
// membership-creating flows seed their own permission grants, so nothing
// needs seeding per request here. A read failure degrades to the pinned
// primary instead of blocking authentication, mirroring the fail-closed
// owner-elevation resolution below.
let additionalMemberships: { companyId: string; membershipRole: string | null; status: string }[] =
[];
try {
additionalMemberships = (await loadActiveUserCompanyMemberships(db, userId)).filter(
(row) => row.companyId !== companyId,
);
} catch (err) {
logger.warn(
{ err, userId, stackId },
"Failed to load cloud tenant user's company memberships; scoping actor to the stack's primary company",
);
}
return {
type: "board",
userId,
userName,
userEmail,
companyIds: [companyId, ...additionalMemberships.map((row) => row.companyId)],
memberships: [
{
companyId,
membershipRole: effectiveMembership.membershipRole ?? membershipRole,
status: effectiveMembership.status,
},
...additionalMemberships,
],
// Computed per request, never persisted: the stack owner is elevated to
// instance admin of their own dedicated instance only while the
// `enableOwnerInstanceAdmin` flag is on. Non-owner stack roles stay
// company-scoped. Turning the flag off de-elevates on the next request —
// there is no role row to clean up.
isInstanceAdmin: await resolveOwnerInstanceAdmin(db, stackRole),
canManageExperimentalSettings: stackRole === "admin",
source: "cloud_tenant",
};
}
function requiredCloudHeader(req: CloudActorHeaderSource, name: string): string {
const value = req.header(name)?.trim();
if (!value) {
throw new Error(`Missing trusted Cloud tenant header ${name}`);
}
return value;
}
function stackMembershipRole(value: string | undefined): "owner" | "admin" | "member" | "support" {
if (value === "owner" || value === "admin" || value === "member" || value === "support") {
return value;
}
throw new Error("Invalid trusted Cloud tenant stack role");
}
function constantTimeStringEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function cloudTenantCompanyId(stackId: string): string {
return cloudTenantPrimaryCompanyId(stackId);
}
export function humanizeCloudStackSlug(stackId: string): string {
const slug = stackId
.trim()
.replace(/^paperclip-stack-/i, "")
.replace(/^stack-/i, "");
const displayName = slug
.split(/[-_]+/)
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join(" ");
return displayName || "Workspace";
}
export function isKnownBadCloudCompanyName(
name: string,
ids: { companyId: string; paperclipCompanyId?: string },
): boolean {
const normalized = name.trim();
return (
/^paperclip-stack-.+/i.test(normalized) ||
/^stack-.+\s+paperclip$/i.test(normalized) ||
normalized === ids.companyId ||
(ids.paperclipCompanyId !== undefined &&
normalized === ids.paperclipCompanyId)
);
}
async function repairCloudTenantCompanyName(
db: Db,
input: {
companyId: string;
paperclipCompanyId?: string;
paperclipCompanyName: string;
now: Date;
},
): Promise<void> {
try {
const existing = await db
.select({ name: companies.name })
.from(companies)
.where(eq(companies.id, input.companyId))
.then((rows) => rows[0]);
if (
!existing ||
!isKnownBadCloudCompanyName(existing.name, {
companyId: input.companyId,
paperclipCompanyId: input.paperclipCompanyId,
})
) {
return;
}
await db.transaction(async (tx) => {
const [updated] = await tx
.update(companies)
.set({ name: input.paperclipCompanyName, updatedAt: input.now })
.where(
and(
eq(companies.id, input.companyId),
// A user may rename the company between the read above and this
// repair. Match the exact observed machine name so that concurrent
// genuine renames always win.
eq(companies.name, existing.name),
),
)
.returning({ id: companies.id });
if (!updated) return;
await tx.insert(activityLog).values({
companyId: input.companyId,
actorType: "system",
actorId: "cloud-tenant-auth",
action: "company.updated",
entityType: "company",
entityId: input.companyId,
details: {
source: "cloud_tenant_auth",
reason: "legacy_machine_name_repair",
previousName: existing.name,
name: input.paperclipCompanyName,
},
});
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId },
"Failed to repair legacy Cloud tenant company name",
);
}
}
/**
* Claims the tenant company row for this stack.
*
* The prefix derives from the company name, exactly as it does for a
* self-hosted company. Each attempt is a standalone INSERT, so a failed
* attempt is its own implicit transaction and cannot poison a surrounding
* one. `onConflictDoNothing` only absorbs the `companies.id` conflict — a
* prefix that another company already holds still raises `23505`, so the loop
* moves on to the next suffix.
*/
async function insertCloudTenantCompany(
db: Db,
input: { companyId: string; companyName: string; now: Date },
): Promise<void> {
const base = deriveIssuePrefixBase(input.companyName);
for (let attempt = 1; attempt <= MAX_ISSUE_PREFIX_ATTEMPTS; attempt += 1) {
try {
await db
.insert(companies)
.values({
id: input.companyId,
name: input.companyName,
description: null,
status: "active",
issuePrefix: `${base}${issuePrefixSuffixForAttempt(attempt)}`,
updatedAt: input.now,
})
.onConflictDoNothing({
target: companies.id,
});
return;
} catch (error) {
if (!isIssuePrefixConflict(error)) throw error;
}
}
throw new Error("Unable to allocate a unique issue prefix for the tenant company");
}
/**
* The issue prefix that pre-name-derivation builds gave a tenant company.
*
* This derivation survives only as the detector for the one-time repair
* below. Nothing mints a prefix this way any more.
*/
function legacyProvisionedIssuePrefix(stackId: string): string {
const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase();
return `PC${hash}`;
}
/** The placeholder description that pre-name-derivation builds wrote. */
const LEGACY_PROVISIONED_DESCRIPTION_PREFIX = "Provisioned by Paperclip Cloud for stack ";
/**
* One-time repair for companies claimed by a pre-name-derivation build.
*
* Those companies carry an opaque hash prefix and a placeholder description
* that the operator never chose. Re-derive the prefix from the company's
* current name, re-key the stored issue and case identifiers onto it, and drop
* the placeholder. Both guards stop matching once the repair lands, so a later
* pass is a no-op.
*/
async function repairCloudTenantCompanyProvisionDefaults(
db: Db,
input: { companyId: string; stackId: string; now: Date },
): Promise<void> {
try {
const existing = await db
.select({
name: companies.name,
issuePrefix: companies.issuePrefix,
description: companies.description,
})
.from(companies)
.where(eq(companies.id, input.companyId))
.then((rows) => rows[0]);
if (!existing) return;
const legacyPrefix = legacyProvisionedIssuePrefix(input.stackId);
const legacyDescription = existing.description?.startsWith(LEGACY_PROVISIONED_DESCRIPTION_PREFIX)
? existing.description
: null;
if (existing.issuePrefix !== legacyPrefix) {
if (!legacyDescription) return;
// The prefix was already re-derived, so only the placeholder is left.
await db
.update(companies)
.set({ description: null, updatedAt: input.now })
.where(and(
eq(companies.id, input.companyId),
eq(companies.description, legacyDescription),
));
return;
}
await db.transaction(async (tx) => {
const candidate = await pickAvailableIssuePrefix(tx, deriveIssuePrefixBase(existing.name));
if (!candidate || candidate === legacyPrefix) return;
const [updated] = await tx
.update(companies)
.set({
issuePrefix: candidate,
...(legacyDescription ? { description: null } : {}),
updatedAt: input.now,
})
.where(and(
eq(companies.id, input.companyId),
// A user may rename the company between the read above and this
// repair. Match the exact observed legacy prefix so that concurrent
// genuine renames always win.
eq(companies.issuePrefix, legacyPrefix),
))
.returning({ id: companies.id });
if (!updated) return;
const rekeyed = await rekeyCompanyIssueIdentifiers(tx, {
companyId: input.companyId,
fromPrefix: legacyPrefix,
toPrefix: candidate,
});
await tx.insert(activityLog).values({
companyId: input.companyId,
actorType: "system",
actorId: "cloud-tenant-auth",
action: "company.updated",
entityType: "company",
entityId: input.companyId,
details: {
source: "cloud_tenant_auth",
reason: "legacy_provision_defaults_repair",
previousIssuePrefix: legacyPrefix,
issuePrefix: candidate,
descriptionCleared: legacyDescription !== null,
issuesRekeyed: rekeyed.issues,
casesRekeyed: rekeyed.cases,
},
});
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId },
"Failed to repair legacy tenant company provisioning defaults",
);
}
}
export function requireBoard(req: Express.Request) {
return req.actor.type === "board";
}