mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Routines let external services start agent tasks through a normal webhook. > - A repair routine must recognize failures from work that it created. > - The receiver added in #18714 put Sentry payload parsing and repair policy in core routine intake. > - This pull request replaces that receiver with verified creation provenance on normal routine calls. > - Sentry can filter the resulting tags before it sends a normal routine webhook. > - The benefit is reusable provenance and loop prevention without a provider-specific route. ## Linked Issues or Issue Description **Problem or motivation** A Sentry repair alert can start another repair when its own work fails. The dedicated receiver in #18714 solved this through a Sentry-specific payload contract. Ordinary routine webhooks also need a trusted way to retain the automation that created their tasks. **Proposed solution** Use the existing public `/fire` route and its current authentication. An optional source run credential proves a live Paperclip caller. Store the caller separately from webhook JSON. Follow recorded task creation links and export bounded ancestry tags on run-failure events. Let Sentry alert filters exclude repair-origin work. **Alternatives considered** Keep the dedicated Sentry receiver, accept sender-supplied origin IDs, or filter only the immediate routine. The first couples core intake to one provider. The second accepts unverified claims. The third misses child tasks, standalone follow-ups, and calls through another routine. **Roadmap alignment** This improves the shipped Scheduled Routines and Activity log capabilities. It does not add a new scheduler or incident system. Related work: #18714 is the implementation replaced here; #9727 adds source-task navigation and UI attribution; #10206 proposes exception-led routine dispatch. This PR uses existing task creation records and does not implement either of those broader proposals. ## What Changed - Remove the Sentry-only route, parser, authentication exception, and payload-specific tests and documentation from #18714. - Add optional `X-Paperclip-Source-Run-Token` support to normal routine webhooks. Validate the standard run JWT, company, instance, agent, live run, and cancellation state. Authenticated routine API calls also retain their validated caller. - Add nullable `routine_runs.created_by_run_id`. Keep the reference after source-run deletion so missing history becomes unknown. Separate callers during coalescing, including legacy open tasks. - Follow company-scoped routine receipts, creator runs, task parents, and legacy creation activity. Mark missing, foreign, cyclic, private, or excessive ancestry for safe alert filtering. - Add event-local `automation_origin_status`, `automation_routine_ids`, and `task_shareable` tags through the existing Sentry SDK hook. Cap ancestry at 32 reads and share a 750 ms deadline across metadata and ancestry lookup. Keep the SDK one-second wait and eight-lookup concurrency bound. Redact the source credential from HTTP logs and Sentry request headers. - Preserve unexpected database failures as server errors without rejection bookkeeping in an aborted transaction. Document 409, 415, and 422 responses for the normal webhook. - Keep `routineScopedRunId` as generic opt-in `X-Paperclip-Idempotency-Scope: routine` support. Default trigger receipts and timestamped HMAC replay protection retain their current scope. - Add [the Sentry repair tutorial](https://github.com/paperclipai/paperclip/blob/codex/routine-creation-provenance/docs/guides/board-operator/sentry-run-failure-repairs.md), API documentation, OpenAPI headers, and capability-map updates. ## Verification At commit `0f4a0051528edd1367a921a76a8050c3fd087872`: - `pnpm -r typecheck` and `pnpm build` passed for the full workspace. Migration numbering and safety checks passed. - The focused service, route, Sentry, redaction, and OpenAPI suites passed 228 tests in five files. The database Sentry resolver and OpenAPI invocation passed 33 tests in two files. These are separate invocations with some overlap. - The full routine HTTP suite passed all 15 tests. The authenticated API case failed before the caller-wiring fix and passed after it. The fixture loads the real route-owned services. No test deadline was raised. - The real `@sentry/node@10.71.0` suite passed all 12 tests. It uses an in-memory transport and sends no events to Sentry. It covers terminal failures, saved-message interruptions, event isolation, and source-token redaction. - Regression tests also cover forged, expired, foreign, and cancelled caller credentials; payload spoofing; inherited routine ancestry; legacy coalescing; deleted history; private work; cycles; query and time limits; database error causes; and routine-wide receipts. The new error and budget cases failed before their fixes. - `node scripts/check-maps.mjs` and `git diff --check` passed. The map check covers 35 areas and 192 capabilities. - The full local Vitest command was stopped for a required master merge after four chat shards passed 1,278 tests. No full local pass is claimed. An earlier combined HTTP run hit an existing malformed-ID timeout whose original cause is not established; the final full HTTP suite passes. An extra local run of the unchanged heartbeat scheduling file passed 12 of 13 tests and failed a readiness-status assertion. Its logs also reported a missing fixture user membership. The same 13-test file now passes in current-head CI. - All GitHub Actions checks pass on this commit. Three jobs initially lost their self-hosted runners. One server shard initially failed while deleting a company in an unchanged heartbeat test because of a `company_skills` foreign key. One retry passed all affected jobs without code changes. That server shard passed all 499 tests, and all eight browser shards passed. - Fresh Greptile review is 5/5 on this commit. All six review threads are resolved. The separate Paperclip Review check remains queued. Its bot reports that work is paused. A maintainer must resume the assigned review work to complete that check. No full green check state is claimed while this external gate is pending. - Live Sentry configuration and deployment are outside this PR's acceptance evidence. The tutorial describes setup, a controlled failure, and a repair-origin exclusion check for operators to run after deployment. ## Risks - Normal app webhooks retain the sender's full JSON in company-readable routine history and the execution task. Alert filters and task visibility are not content scrubbers. Operators must scrub sensitive event data in Sentry before forwarding it and keep connection tests in setup mode. - Existing users of `/sentry-run-failures` must move to `/fire` and add the documented Sentry filters before enabling repair intake. The old route is removed. Stored tasks and receipts remain. - The additive migration uses `ADD COLUMN IF NOT EXISTS`. It does not rewrite historical tasks or infer missing provenance. - Unknown or unshareable ancestry is excluded by the tutorial's alert rule. Missing history, an enrichment timeout, or more than five routine IDs can therefore suppress a repair webhook while retaining the Sentry error. - An external webhook without a verified source credential starts a new receiving-routine origin. Its JSON cannot establish upstream Paperclip ancestry. Sentry must not store a Paperclip run credential. - Routine-wide receipt reuse needs a stable sender key. A static Sentry custom header is not a per-event key. The tutorial keeps delivery deduplication separate from loop prevention. > 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, GPT-6 model family, with reasoning, repository access, code editing, and test execution. The session does not expose the exact model variant or 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 - [ ] 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 { notifyChatCompanyAuthorityChange } 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 notifyChatCompanyAuthorityChange(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";
|
|
}
|