Files
PaperClipAI/server/src/middleware/auth.ts
T
DottaandPaperclip 1cc45086d3 feat: use the responsible person's GitHub for shared agent operations (#13005)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Several people can send instructions to the same agent and task.
> - A fixed GitHub token in the provider process can keep the first
person's access after another person's message is accepted.
> - Task ownership cannot select credentials for each accepted
instruction or preserve the identity of an operation already in
progress.
> - This pull request records ordered execution identity contexts and
resolves credentials when managed Git, gh, or GitHub tools start.
> - The benefit is automatic personal GitHub access for shared agents,
with durable continuation rules and no teammate credential fallback.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: orchestration, connection grants, database, runtime
adapters, native runners, and run details.

**Problem or motivation**

A shared agent must use the person whose instructions it has accepted. A
queued message must retain its author. A retry or approval without new
instructions must retain the originating identity. GitHub must remain
optional for ordinary work.

**Proposed solution**

Persist execution identity separately from task ownership. Give new
processes a run-scoped broker capability and token-free managed
launchers. Capture identity at operation start. Keep an explicit
dedicated-agent grant as an override. Show redacted diagnostics in run
details.

**Alternatives considered**

Per-task ownership, fixed provider tokens, and mutable repository author
configuration do not handle accepted steering or concurrent operations.
A manual account-selection action would add unnecessary setup to each
turn.

**Roadmap alignment**

This completes the existing Multiple Human Users, MCP Tool Gateway &
Apps, Secrets Manager, and Self-healing Runs capabilities. The
implementation follows the maintainer-approved plan.

Related work: Refs #12843, Refs #12907. Existing proposals #4618 and
#8945 cover per-agent or per-worktree author configuration. This change
instead follows the accepted human instruction across runtime types.
Refs #11831 for governed personal connection delegation; this change
preserves connection audience checks and does not use standing
delegation as a personal credential fallback.

## What Changed

- Add durable, ordered identity contexts and active run references.
Preserve message authors through consolidation, steering, retries,
delegation, approvals, routines, and restart.
- Add an authenticated operation-time GitHub credential broker and
local/remote managed git and gh launchers. Keep personal tokens out of
the long-lived provider process.
- Resolve GitHub gateway and server-side Git operations through the same
responsible-person or dedicated-grant selection rules.
- Make absent and unavailable GitHub credentials non-blocking at generic
startup. Clear host and prior-person credentials. Keep anonymous Git
access where supported.
- Add run-detail identity history and the dedicated-account warning.
Keep task ownership and queue-versus-steer decisions unchanged.
- Preserve personal OAuth declarations through connection edits. Retain
exact selected grants in the gateway.
- Fix continuation races found during real acceptance: verify a warm
owner before credential rotation, and wait for bounded durable runner
suspension before the next run starts.
- Make migrations replay-safe. Retain identity through agent/run
deletion, remove it with its company, and clean terminal launcher
directories before releasing execution environments. Document
coordinated release and rollback.

## Verification

- Full workspace typecheck, build, and token gates passed. The complete
local suite passed in its normal test groups: 17,120 passing tests,
including all 143 serialized server suites. After integrating the newly
merged runner API work, full local typecheck and build passed again,
along with 890 focused integration tests. All 31 checks on the
integrated revision passed, including build, browser E2E, release
registry, canary dry run, typecheck, security and all test suites.
Greptile is 5/5 with all review threads resolved.
- Current focused checks passed: 142 native executor tests, 67 runtime
lifecycle tests, 9 durable identity tests, 75 credential/routine tests,
19 low-trust/resumption tests, and the executable migration replay test.
- Authenticated browser acceptance with two Paperclip users and two
GitHub accounts on one shared native agent passed. Real commits and
pushes followed A → B accepted steering → queued A continuation in the
same saved conversation. GitHub commit author and committer identities
matched all three operations. Both runs succeeded and task ownership
stayed unchanged.
- Real GitHub MCP calls switched from A to B after accepted steering. A
delegated subtask retained its originating identity across a server
restart.
- Disabling B's GitHub connection left ordinary work successful. Managed
gh was unauthenticated and the provider had no inherited GH_TOKEN or
GITHUB_TOKEN.
- The browser displayed run-detail diagnostics and the exact
dedicated-account warning. A final controller-restart check followed by
another-person continuation retained the conversation, selected the
correct GitHub login and Git author, and removed each terminal launcher
directory.
- Company-lifetime migration and all five previously failing CI suites
passed locally (167 tests). Same-token gateway A → B → A and six
broker/launcher boundary tests passed.
- Remote callback, launcher, sandbox, and runtime contract tests passed.
Both native and legacy Codex completed actual Daytona executions on the
integrated revision ([campaign
results](https://github.com/paperclipai/paperclip/actions/runs/34155056509)).
The remote package-manager shim staging regression also passed locally.

## Risks

- Deploy the migrations, server broker, launchers, and runner artifacts
together. Existing processes finish with their original contract. New
managed processes need the broker endpoint for GitHub operations.
- Finish or stop new managed executions before rolling application code
back. Keep the additive schema and identity history during rollback.
- Scripts that require a persistent raw GH_TOKEN must use managed git,
gh, or GitHub gateway tools. Run capabilities authorize code executing
within that run to acquire its current identity; this is not
hostile-code isolation within one execution principal. Managed commands
prevent automatic credential carryover; arbitrary code deliberately
copying a credential is outside that boundary.
- Uncertain steering acknowledgement deliberately holds new credential
acquisition until reconciliation. Already-started operations retain
their captured identity.
- GitHub private access and provider outages can still fail the specific
operation that needs them. Dedicated grant failure does not fall back to
personal access.

## Model Used

OpenAI GPT-6 through Codex assisted implementation, review, shell
execution, and browser acceptance. The exact model variant and
context-window size are not exposed in this session. Tool use included
TypeScript and Rust tests, database integration tests, GitHub CLI, and
authenticated browser control.

## 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-09-07 14:32:20 -05:00

1023 lines
35 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 {
MAX_ISSUE_PREFIX_ATTEMPTS,
deriveIssuePrefixBase,
isIssuePrefixConflict,
issuePrefixSuffixForAttempt,
pickAvailableIssuePrefix,
rekeyCompanyIssueIdentifiers,
} from "../services/issue-prefix.js";
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 { captureRunIdentity } from "../services/run-identity.js";
import { boardAuthService } from "../services/board-auth.js";
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
const CLOUD_TENANT_WRITE_DEBOUNCE_MAX = 1_000;
const cloudTenantWriteDebounces = new WeakMap<Db, Map<string, { fingerprint: string; syncedAt: number }>>();
function cloudTenantWriteDebounceFor(db: Db) {
let debounce = cloudTenantWriteDebounces.get(db);
if (!debounce) {
debounce = new Map();
cloudTenantWriteDebounces.set(db, debounce);
}
return debounce;
}
function pruneCloudTenantWriteDebounce(
debounce: Map<string, { fingerprint: string; syncedAt: number }>,
nowMs: number,
) {
for (const [subject, entry] of debounce) {
if (entry.syncedAt <= nowMs - CLOUD_TENANT_WRITE_DEBOUNCE_MS) debounce.delete(subject);
}
while (debounce.size > CLOUD_TENANT_WRITE_DEBOUNCE_MAX) {
const oldestSubject = debounce.keys().next().value;
if (!oldestSubject) break;
debounce.delete(oldestSubject);
}
}
import { instanceSettingsService } from "../services/instance-settings.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import { forbidden, unauthorized, 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;
}
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 {
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>;
}
const publicMcpGatewayProtocolPath = /^\/mcp\/gateways\/gw_[a-f0-9]{32}\/?$/i;
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");
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.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 db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
));
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });
identityRun.activeIdentityContextId = captured.context?.id ?? null;
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
}
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,
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.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();
};
}
/**
* 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;
}
}
/**
* 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;
},
};
}
/**
* postgres.js codes for a connection the server side closed out from under
* an in-flight query — a pooled Postgres endpoint recycling or suspending
* (observed 2026-09-03 with a managed pooler closing the socket mid-INSERT).
* The driver reconnects transparently on the next query; only the statement
* that was on the wire is lost.
*/
const transientDbConnectionCodes = new Set([
"CONNECTION_CLOSED",
"CONNECTION_ENDED",
"CONNECTION_DESTROYED",
]);
/**
* True when the error chain (drizzle wraps the driver error as `cause`)
* carries a postgres.js closed-connection code. Exported for tests.
*/
export function isTransientDbConnectionError(error: unknown): boolean {
for (let current: unknown = error; current instanceof Error; current = current.cause) {
const code = (current as { code?: unknown }).code;
if (typeof code === "string" && transientDbConnectionCodes.has(code)) return true;
}
return false;
}
/**
* Runs `run` and retries it exactly once when it fails on a transient
* closed-connection error. Callers must pass an idempotent operation.
* Exported for tests.
*/
export async function retryOnTransientDbConnectionError<T>(run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (error) {
if (!isTransientDbConnectionError(error)) throw error;
return run();
}
}
/**
* Trusted-header actor resolution with a single transient-connection retry.
* 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 retryOnTransientDbConnectionError(() => resolveCloudTenantActorOnce(db, req));
}
async function resolveCloudTenantActorOnce(
db: Db,
req: CloudActorHeaderSource,
): Promise<Express.Request["actor"] | null> {
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
if (!expectedToken) return null;
const token = req.header("x-paperclip-cloud-tenant-token")?.trim();
if (!token || !constantTimeStringEqual(token, expectedToken)) return null;
const userId = requiredCloudHeader(req, "x-paperclip-cloud-user-id");
const userEmail = requiredCloudHeader(req, "x-paperclip-cloud-user-email").toLowerCase();
const stackId = requiredCloudHeader(req, "x-paperclip-cloud-stack-id");
const stackRole = stackMembershipRole(req.header("x-paperclip-cloud-stack-role"));
const userName = req.header("x-paperclip-cloud-user-name")?.trim() || userEmail;
const paperclipCompanyId = req.header("x-paperclip-cloud-paperclip-company-id")?.trim();
const paperclipCompanyName = req
.header("x-paperclip-cloud-paperclip-company-name")
?.trim();
const companyId = cloudTenantCompanyId(stackId);
const companyName = paperclipCompanyName || humanizeCloudStackSlug(stackId);
const now = new Date();
const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
const syncFingerprint = [userEmail, userName, stackId, stackRole, paperclipCompanyId ?? ""].join(":");
const cloudTenantWriteDebounce = cloudTenantWriteDebounceFor(db);
pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, now.getTime());
const previousSync = cloudTenantWriteDebounce.get(userId);
const shouldSync = previousSync?.fingerprint !== syncFingerprint
|| previousSync.syncedAt <= now.getTime() - CLOUD_TENANT_WRITE_DEBOUNCE_MS;
let effectiveMembership: { companyId: string; membershipRole: string | null; status: string } = {
companyId,
membershipRole,
status: "active",
};
if (shouldSync) await db
.insert(authUsers)
.values({
id: userId,
name: userName,
email: userEmail,
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: authUsers.id,
set: {
name: userName,
email: userEmail,
emailVerified: true,
updatedAt: now,
},
});
// Earlier cloud_tenant builds granted every tenant user `instance_admin`.
// Stale rows from those deployments would still elevate this user through
// the BetterAuth session path, board API keys, and the authorization
// service's own instanceUserRoles lookup — so actively purge them on every
// trusted-header authentication instead of merely no longer inserting them.
await db
.delete(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")));
if (shouldSync) await insertCloudTenantCompany(db, { companyId, companyName, now });
if (shouldSync && paperclipCompanyName) {
await repairCloudTenantCompanyName(db, {
companyId,
paperclipCompanyId,
paperclipCompanyName,
now,
});
}
// Runs after the name repair so the prefix derives from the repaired name.
// The helper self-gates on the legacy markers, so it is a no-op once the
// company has been repaired or was claimed by a current build.
if (shouldSync) {
await repairCloudTenantCompanyProvisionDefaults(db, { companyId, stackId, now });
}
effectiveMembership = shouldSync ? await db
.insert(companyMemberships)
.values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
companyMemberships.companyId,
companyMemberships.principalType,
companyMemberships.principalId,
],
set: {
status: "active",
membershipRole,
updatedAt: now,
},
})
.returning()
.then((rows) => rows[0] ?? {
companyId,
membershipRole,
status: "active",
}) : { companyId, membershipRole, status: "active" as const };
// Without instance-admin elevation, cloud tenant users are authorized purely
// through company-scoped permission grants — seed the same role defaults the
// regular membership flows create.
if (shouldSync) await ensureHumanRoleDefaultGrants(db, {
companyId,
principalId: userId,
membershipRole: effectiveMembership.membershipRole ?? membershipRole,
grantedByUserId: null,
});
if (shouldSync) {
cloudTenantWriteDebounce.delete(userId);
cloudTenantWriteDebounce.set(userId, { fingerprint: syncFingerprint, syncedAt: Date.now() });
pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, Date.now());
}
// The stack's seeded company is only where Cloud provisioned this user.
// Companies created afterwards on the instance (imports, in-app company
// creation) attach real membership rows for the user, so union those with
// the pinned primary — the same active-membership scope a locally
// authenticated session actor carries. Strictly this user's own rows; the
// membership-creating flows seed their own permission grants, so nothing
// needs seeding per request here. A read failure degrades to the pinned
// primary instead of blocking authentication, mirroring the fail-closed
// owner-elevation resolution below.
let additionalMemberships: { companyId: string; membershipRole: string | null; status: string }[] =
[];
try {
additionalMemberships = (await loadActiveUserCompanyMemberships(db, userId)).filter(
(row) => row.companyId !== companyId,
);
} catch (err) {
logger.warn(
{ err, userId, stackId },
"Failed to load cloud tenant user's company memberships; scoping actor to the stack's primary company",
);
}
return {
type: "board",
userId,
userName,
userEmail,
companyIds: [companyId, ...additionalMemberships.map((row) => row.companyId)],
memberships: [
{
companyId,
membershipRole: effectiveMembership.membershipRole ?? membershipRole,
status: effectiveMembership.status,
},
...additionalMemberships,
],
// Computed per request, never persisted: the stack owner is elevated to
// instance admin of their own dedicated instance only while the
// `enableOwnerInstanceAdmin` flag is on. Non-owner stack roles stay
// company-scoped. Turning the flag off de-elevates on the next request —
// there is no role row to clean up.
isInstanceAdmin: await resolveOwnerInstanceAdmin(db, stackRole),
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 {
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",
);
}
}
/**
* 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";
}