Files
PaperClipAI/server/src/middleware/auth.ts
T
DottaandPaperclip 5858ccb981 feat: make in-app features cloud-aware (#10850)
## 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>
2026-08-04 23:00:14 -05:00

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";
}