mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators use the same board application in self-hosted and Paperclip Cloud deployments. > - A Cloud tenant contains one company, so an in-app company switch does not change the active Cloud stack. > - Cloud operators need the sidebar and company surfaces to use the signed-in user's stack portfolio. > - The server must derive Cloud identity and links from trusted instance context instead of client input. > - This pull request adds canonical Cloud context, a trusted stack portfolio proxy, and Cloud-aware navigation. > - The benefit is consistent stack switching on Cloud while self-hosted company behavior stays unchanged. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: server REST routes and the React board UI. **Problem or motivation** A Cloud-managed instance contains one company. The existing company switcher could only switch records inside that tenant. It could not move the operator to another Cloud stack. The existing header also gave long organization names too little width. **Proposed solution** Expose a canonical public Cloud context in health data. Add a trusted server proxy for the current user's stack portfolio. Use that data in the board UI to switch stacks with top-level navigation. Keep the existing company behavior on self-hosted instances. Move search into the navigation and keep long organization names inside the sidebar panel. **Alternatives considered** An in-app `/stacks` route was rejected because Cloud tenant hosts reserve that path and stack selection must wake or authenticate another tenant. Client-supplied user identity was rejected because the server can derive the trusted Cloud actor. **Roadmap alignment** This change advances the Cloud deployments milestone. It keeps the product local-first and Cloud-ready without changing the self-hosted mental model. ## What Changed - Added canonical Cloud instance context and public health metadata. - Added a Cloud-only stack portfolio proxy with trusted actor forwarding and per-user caching. - Prevented normal company creation on Cloud-managed instances. - Switched the sidebar and Companies page from company actions to stack actions on Cloud. - Added full-page stack navigation and Cloud create-stack links. - Moved search into the sidebar navigation so the organization name keeps more width. - Added truncation and hover recovery for long organization and stack names. - Added server and UI regression coverage for Cloud and self-hosted behavior. - Updated the implementation specification for the Cloud contracts. ## Verification - `node scripts/check-token-gates.mjs` passed. All three token gates are clean. - `pnpm --dir server exec vitest run src/__tests__/health.test.ts src/__tests__/cloud-instance.test.ts src/__tests__/cloud-routes.test.ts src/__tests__/company-cloud-floor.test.ts src/__tests__/company-portability-routes.test.ts` passed: 5 files and 66 tests. - `pnpm --dir ui exec vitest run src/components/SidebarCompanyMenu.test.tsx` passed: 1 file and 11 tests. - Pre-PR QA report `7da87ca7` passed all 8 acceptance criteria with real HTTP route factories and real Chromium screenshots in Cloud and self-hosted modes. - Security reviews passed for the canonical Cloud context and stack portfolio proxy. ## Risks - Cloud stack switching depends on the configured Cloud application and tenant portfolio URLs. - The new health `cloud` block is public by design, but it contains only canonical public instance metadata. - The stack proxy fails closed on self-hosted instances and derives the user identity from the trusted actor. - Self-hosted navigation and company creation retain their existing paths and behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, model `gpt-5`. The run used reasoning, repository tools, shell execution, and GitHub integration. The deployment did not expose its context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
701 lines
22 KiB
TypeScript
701 lines
22 KiB
TypeScript
import { createHash, timingSafeEqual } from "node:crypto";
|
|
import type { Request, RequestHandler } from "express";
|
|
import { and, eq, isNull } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
activityLog,
|
|
agentApiKeys,
|
|
agents,
|
|
authUsers,
|
|
companies,
|
|
companyMemberships,
|
|
heartbeatRuns,
|
|
instanceUserRoles,
|
|
} from "@paperclipai/db";
|
|
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
|
|
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
|
import { logger } from "./logger.js";
|
|
import { boardAuthService } from "../services/board-auth.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
|
|
import { forbidden, unprocessable } from "../errors.js";
|
|
|
|
export { isCloudManagedInstance } 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;
|
|
}
|
|
|
|
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 {
|
|
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 } : {}),
|
|
...(isUuidLike(input.claimRunId) ? { 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>;
|
|
}
|
|
|
|
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
|
|
const boardAuth = boardAuthService(db);
|
|
return 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" };
|
|
|
|
const runIdHeader = req.header("x-paperclip-run-id");
|
|
|
|
const authHeader = req.header("authorization");
|
|
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
|
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();
|
|
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();
|
|
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();
|
|
return;
|
|
}
|
|
|
|
if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
|
|
next();
|
|
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 onBehalfOfUserId = 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,
|
|
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.status === "terminated" || agentRecord.status === "pending_approval") {
|
|
next();
|
|
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();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether the trusted-header actor being resolved should carry computed
|
|
* instance-admin elevation: only the stack `owner` role elevates, and only
|
|
* while `enableOwnerInstanceAdmin` is enabled. The flag is resolved through
|
|
* the instance-settings service so the cloud managed-config overlay applies
|
|
* (the harness can turn elevation off fleet-wide without touching tenant
|
|
* DBs). Fails closed: a settings read error means no elevation.
|
|
*/
|
|
async function resolveOwnerInstanceAdmin(
|
|
db: Db,
|
|
stackRole: "owner" | "admin" | "member" | "support",
|
|
): Promise<boolean> {
|
|
if (stackRole !== "owner") return false;
|
|
try {
|
|
const experimental = await instanceSettingsService(db).getExperimental();
|
|
return experimental.enableOwnerInstanceAdmin === true;
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err },
|
|
"Failed to resolve enableOwnerInstanceAdmin for cloud tenant owner; treating elevation as disabled",
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function resolveCloudTenantActor(db: Db, req: Request): 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();
|
|
|
|
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")));
|
|
|
|
await db
|
|
.insert(companies)
|
|
.values({
|
|
id: companyId,
|
|
name: companyName,
|
|
description: `Provisioned by Paperclip Cloud for stack ${stackId}.`,
|
|
status: "active",
|
|
issuePrefix: issuePrefixForCloudStack(stackId),
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoNothing({
|
|
target: companies.id,
|
|
});
|
|
|
|
if (paperclipCompanyName) {
|
|
await repairCloudTenantCompanyName(db, {
|
|
companyId,
|
|
paperclipCompanyId,
|
|
paperclipCompanyName,
|
|
now,
|
|
});
|
|
}
|
|
|
|
const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
|
|
const membership = 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",
|
|
});
|
|
|
|
// 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.
|
|
await ensureHumanRoleDefaultGrants(db, {
|
|
companyId,
|
|
principalId: userId,
|
|
membershipRole: membership.membershipRole,
|
|
grantedByUserId: null,
|
|
});
|
|
|
|
// 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: membership.membershipRole,
|
|
status: membership.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),
|
|
source: "cloud_tenant",
|
|
};
|
|
}
|
|
|
|
function requiredCloudHeader(req: Request, 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 {
|
|
const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest();
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
const hex = bytes.subarray(0, 16).toString("hex");
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
}
|
|
|
|
function issuePrefixForCloudStack(stackId: string): string {
|
|
const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase();
|
|
return `PC${hash}`;
|
|
}
|
|
|
|
export function requireBoard(req: Express.Request) {
|
|
return req.actor.type === "board";
|
|
}
|