mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connected chats show safe notices when a run starts, waits, finishes, or fails. > - A recurring scan projected these notices even when no eligible work existed. > - Eligibility depends on committed run state, conversation lineage, and current authority. > - Existing database rows can reconstruct that work after restart. > - This PR wakes a scoped worker from those writes and removes lifecycle polling. > - Native progress events keep their separate existing owner until the next migration. ## Linked Issues or Issue Description **What happened?** The chat maintenance timer repeatedly scanned run lifecycle state. Empty and settled instances still queried the database. A run-status event alone cannot replace the scan: prompt receipts, continuation receipts, identity changes, and restored authority can make a run eligible later. **What did you expect?** Committed changes wake projection. Startup reconstructs eligible work. A completed pass stops until another relevant change arrives. Failed work retains recovery without scanning settled history forever. **Steps to reproduce** Configure an automatic chat endpoint. Finish its work, or leave it unused. Observe recurring lifecycle projection queries from the chat maintenance callback. Stacked on #17592. Related foundation: #15625. This PR owns lifecycle milestones only. ## What Changed - Preserve upstream coalesced Cloud membership writes together with locked membership restoration and its committed chat notification. - Signal milestone eligibility from the real Slack account-link transaction. - Register the lifecycle worker with the existing delivery coordinator. Remove lifecycle projection from the periodic chat callback. Keep native progress in a separately named lane and preserve the explicit combined API. - Reconstruct company/issue scopes from the existing selector. Drain bounded batches with company fairness. Retry failures, retain writes through drain/resume, and disarm settled work. - Signal from supported run, presentation, interaction, receipt, lineage, conversation, recovery, and authority writers. Preserve their transaction ownership, including nested savepoints and uncertain settlement. No new table is added. - Preserve provider authorization, authored-final suppression, responsible-user continuity, deduplication, the 24-hour selection window, and raw-output restrictions. Keep provider interpretation in its owner. - Preserve PostgreSQL timestamp precision in lifecycle pagination. Rejected submillisecond candidates must not repeat the same page and starve later notices. - Document producer coverage and alternate-writer dispositions in `doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md`. ## Verification Published head: `1d056f68c5b1005933b9e12de8745bf1c4cbeeca`, targeting master after #17592 merged at `7d75ae0569e41786640cf09647ae8849411d2f22`. A normal merge integrates the actual landed parent. The entire 77-path milestone patch is binary-identical to the locally verified preparation across that ancestry reconciliation; both unrelated master paths are preserved. The four earlier producer/test integration conflicts retain scoped milestone and voice notices, current onboarding completion admission, and expanded identity query/read-scope coverage. - **330 distinct selected cases pass on the verified unchanged patch:** 305 across 14 complete suites (all five milestone worker suites, run identity, board claim, Cloud provisioning, auth session routes, Cloud actor authentication, stale-lock recovery, GitHub credentials, project Git authorization, and app admission wiring), plus 25 selected real chat producer cases. The remaining 1,590 chat integration cases were skipped, not passed. - The real producer cases cover saved milestone discovery, GitHub and Slack identity changes, account-link rollback/reconfirmation, ordinary ingress reactivation, post-close action/delivery receipt settlement, all ten authority-restoration paths including Cloud synchronization, and changed native review metadata. - Worker coverage verifies outer commit, rollback, uncertain-write recovery, restart, bounded batching/fairness, timestamp precision, retries, drain/resume, and settled SQL quiet. Authorization and existing publication deduplication remain with their owning services. - Server production typechecking, build, and module-boundary checks pass. The tests above were executed separately. Production typechecking excludes `src/__tests__` and does not replace those runs. Each database owner uses its own newly created and app-migrated PostgreSQL fixture. - [Current-parent acceptance and preserved failure ledger](https://github.com/paperclipai/paperclip/blob/1d056f68c5b1005933b9e12de8745bf1c4cbeeca/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md#current-parent-integration-and-acceptance-october-28) records commands, counts, producer scope, conflict dispositions, and limits. Earlier combined-stack counts and failures keep their original revision scope; local acceptance does not erase old CI failures. - No new migration, queue table, dependency, workflow, or shared scheduler contract is introduced. Parent migration 0397, bounded consent recovery, voice cleanup/retention repairs, and runner checkpoint restoration remain present. - **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No Ponytail review is present, which is an explicitly authorized nonblocker; no failing review is waived. Older reviews and parent checks are not current passes. Actual failing Ponytail findings must be fixed; an absent/stopped/paused turn without a verdict is an authorized nonblocker. Active unfinished Ponytail uses its original request/start timer, with a 25-minute monitor cutoff respecting the user's 30-minute maximum. - No complete local repository matrix, live provider tenant, external alarm, physical host/database suspension, or fleet savings claim is made. Native progress remains a separate following slice. ## Risks - A future eligibility writer must notify the existing projection boundary. Independent SQL writers or another active server process are outside this process-local signal contract. - Authority and lineage remain sensitive. The worker uses the existing selectors and publication policy; it does not grant execution or disclosure authority. - Native progress and other publication maintenance still have recurring work. This PR removes one reason, not the entire chat maintenance timer. - Worker quiet does not prove database suspension, host sleep, or external alarm coverage. Cloud sleep blockers remain unchanged. - Merge eligibility requires passing current-head CI and Greptile 5/5 after this actual-master reconciliation. Subsequent stack slices retain their own gates. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context-window size are not exposed. Used repository inspection, reasoning, shell execution, PostgreSQL tests, browser E2Es and GitHub tools. ## 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 available in this session) - [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 the documented focused 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>
1007 lines
37 KiB
TypeScript
1007 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";
|
|
import { notifyChatRunMilestones } from "../services/chat-run-milestone-notifications.js";
|
|
|
|
export {
|
|
isTransientDbConnectionError,
|
|
retryIdempotentDatabaseOperation as retryOnTransientDbConnectionError,
|
|
} from "../database-retry.js";
|
|
|
|
import { syncCloudTenantWrites } from "./cloud-tenant-sync.js";
|
|
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 membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
|
|
const syncFingerprint = JSON.stringify([
|
|
userEmail, userName, stackId, stackRole, paperclipCompanyId ?? "", paperclipCompanyName ?? "",
|
|
]);
|
|
let effectiveMembership: { companyId: string; membershipRole: string | null; status: string } = {
|
|
companyId,
|
|
membershipRole,
|
|
status: "active",
|
|
};
|
|
|
|
// 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 syncCloudTenantWrites(db, userId, syncFingerprint, async () => {
|
|
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 },
|
|
setWhere: sql`${authUsers.name} is distinct from ${userName}
|
|
or ${authUsers.email} is distinct from ${userEmail}
|
|
or ${authUsers.emailVerified} is distinct from true`,
|
|
});
|
|
|
|
await insertCloudTenantCompany(db, { companyId, companyName, now });
|
|
if (paperclipCompanyName) {
|
|
await repairCloudTenantCompanyName(db, {
|
|
companyId,
|
|
paperclipCompanyId,
|
|
paperclipCompanyName,
|
|
now,
|
|
});
|
|
}
|
|
// Derive any legacy prefix from the repaired name, preserving the helper's
|
|
// exact legacy-marker guards and the operator's own edits.
|
|
await repairCloudTenantCompanyProvisionDefaults(db, { companyId, stackId, now });
|
|
|
|
effectiveMembership = await db.transaction(async tx => {
|
|
const [existing] = await tx.select({
|
|
status: companyMemberships.status,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
}).from(companyMemberships).where(and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
)).for("update");
|
|
// Serialize authority restoration and its notice while retaining the
|
|
// guarded upsert that leaves unchanged memberships untouched.
|
|
if (!existing || existing.status !== "active" || existing.membershipRole === "viewer") {
|
|
await notifyChatRunMilestones(tx, { companyId });
|
|
}
|
|
return tx
|
|
.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 },
|
|
setWhere: sql`${companyMemberships.status} is distinct from 'active'
|
|
or ${companyMemberships.membershipRole} is distinct from ${membershipRole}`,
|
|
})
|
|
.returning({ companyId: companyMemberships.companyId, membershipRole: companyMemberships.membershipRole, status: companyMemberships.status })
|
|
// PostgreSQL returns no row for an unchanged ON CONFLICT ... WHERE.
|
|
// That condition itself proves the existing role/status match the input.
|
|
.then((rows) => rows[0] ?? { companyId, membershipRole, status: "active" });
|
|
});
|
|
|
|
// An unchanged membership can still lack default grants after a partial
|
|
// sync or an older deployment; keep this repair inside every actual sync.
|
|
await ensureHumanRoleDefaultGrants(db, {
|
|
companyId,
|
|
principalId: userId,
|
|
membershipRole: effectiveMembership.membershipRole ?? 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: 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";
|
|
}
|