mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +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>
566 lines
23 KiB
TypeScript
566 lines
23 KiB
TypeScript
import { notifyChatEndpointConfiguration } from "./configuration-work-notifications.js";
|
|
import { prepareSlackInventory } from "./slack/inventory.js";
|
|
import { prepareGitHubInventory } from "./github/inventory.js";
|
|
import { prepareDiscordInventory } from "./discord/inventory.js";
|
|
import type { VerifiedProviderIdentity } from "./runtime-types.js";
|
|
import { verifySlackCredentials } from "./slack/credentials.js";
|
|
import { verifyDiscordCredentials } from "./discord/credentials.js";
|
|
import { verifyTelegramCredentials } from "./telegram/credentials.js";
|
|
import { verifyGitHubCredentials } from "./github/credentials.js";
|
|
import { verifySpekoCredentials } from "./speko/credentials.js";
|
|
import { verifyTeamsCredentials } from "./microsoft-teams/credentials.js";
|
|
import { createPhotonCredentialChecks } from "./imessage-photon/credentials.js";
|
|
|
|
import type { EndpointRow, DbOrTransaction } from "./runtime-types.js";
|
|
|
|
import { type CredentialMutationLeaseGuard } from "../chat-credential-mutation-lease.js";
|
|
|
|
import { photonSharedScope } from "../photon/cloud.js";
|
|
|
|
import { isUuidLike, photonChannelConfigurationSchema } from "@paperclipai/shared";
|
|
import { notifyChatEndpointWork } from "../chat-work-notifications.js";
|
|
|
|
import {
|
|
randomBytes,
|
|
randomUUID,
|
|
} from "node:crypto";
|
|
import {
|
|
and,
|
|
eq,
|
|
inArray,
|
|
ne,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
|
|
import {
|
|
chatEndpoints,
|
|
companySecretBindings,
|
|
toolConnections,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
ChatProvider,
|
|
ToolCredentialSecretRef,
|
|
} from "@paperclipai/shared";
|
|
|
|
import { isUniqueViolation } from "../../db-errors.js";
|
|
|
|
import {
|
|
HttpError,
|
|
conflict,
|
|
notFound,
|
|
unprocessable,
|
|
} from "../../errors.js";
|
|
import { logger } from "../../middleware/logger.js";
|
|
|
|
import { type ChatProviderInventoryResult } from "../chat-provider-inventory.js";
|
|
|
|
import {
|
|
normalizeMicrosoftTeamsCredentialIds,
|
|
} from "../chat-teams-credentials.js";
|
|
|
|
import { secretService } from "../secrets.js";
|
|
import { isSecretProviderClientError } from "../../secrets/types.js";
|
|
|
|
export const CHAT_HUMAN_CREDENTIAL_FIELDS = ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"] as const;
|
|
|
|
/** Opaque generations identify actual protected-value changes, never secret bytes. */
|
|
export function chatCredentialEntryGenerations(setup: object): Record<string, string | null> {
|
|
const generations: unknown = Reflect.get(setup, "credentialEntryGenerations");
|
|
return Object.fromEntries(CHAT_HUMAN_CREDENTIAL_FIELDS.map(field => {
|
|
const value: unknown = generations && typeof generations === "object" ? Reflect.get(generations, field) : null;
|
|
return [field, typeof value === "string" && isUuidLike(value) ? value : null];
|
|
}));
|
|
}
|
|
|
|
type ChatCredentialsOptions = {
|
|
secrets: ReturnType<typeof secretService>;
|
|
PROVIDER_LABELS: Record<ChatProvider, string>;
|
|
REQUIRED_CREDENTIALS: Record<Exclude<ChatProvider, "github">, readonly string[]>;
|
|
PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number;
|
|
fetchImpl: typeof globalThis.fetch;
|
|
redactError: (error: unknown) => string;
|
|
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
|
|
verifyPhotonCredentials: ReturnType<typeof createPhotonCredentialChecks>["verifyPhotonCredentials"];
|
|
preservesWebhookVerification?: (endpoint: EndpointRow, setup: EndpointRow["setup"], credentials: Record<string, string>) => boolean;
|
|
};
|
|
export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
|
|
const { secrets, PROVIDER_LABELS, REQUIRED_CREDENTIALS, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, fetchImpl, redactError, credentialFingerprint, verifyPhotonCredentials } = options;
|
|
|
|
|
|
async function normalizedCredentials(
|
|
endpoint: EndpointRow,
|
|
supplied: Record<string, string> | undefined,
|
|
) {
|
|
const values = await resolveCredentials(endpoint).catch(
|
|
() => ({}) as Record<string, string>,
|
|
);
|
|
for (const [key, rawValue] of Object.entries(supplied ?? {})) {
|
|
if (typeof rawValue === "string" && rawValue.trim())
|
|
values[key] = key === "privateKey" ? rawValue : rawValue.trim();
|
|
}
|
|
if (endpoint.provider === "telegram" && !values.webhookSecret) {
|
|
values.webhookSecret = randomBytes(32).toString("hex");
|
|
}
|
|
const required =
|
|
endpoint.provider === "github"
|
|
? ["appId", "privateKey", "webhookSecret"]
|
|
: REQUIRED_CREDENTIALS[endpoint.provider];
|
|
const missing = required.filter((key) => !values[key]);
|
|
if (missing.length > 0) {
|
|
throw unprocessable(
|
|
`Missing required ${PROVIDER_LABELS[endpoint.provider]} credentials: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
return endpoint.provider === "microsoft-teams"
|
|
? normalizeMicrosoftTeamsCredentialIds(values)
|
|
: values;
|
|
}
|
|
|
|
async function verifyCredentials(
|
|
provider: ChatProvider,
|
|
credentials: Record<string, string>,
|
|
): Promise<VerifiedProviderIdentity> {
|
|
if (provider === "imessage-photon") return verifyPhotonCredentials(credentials);
|
|
if (provider === "slack") return verifySlackCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS);
|
|
if (provider === "discord") return verifyDiscordCredentials(credentials, fetchImpl, redactError);
|
|
if (provider === "telegram") return verifyTelegramCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS);
|
|
if (provider === "github") return verifyGitHubCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS);
|
|
if (provider === "speko") return verifySpekoCredentials(credentials, fetchImpl);
|
|
return verifyTeamsCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS);
|
|
}
|
|
|
|
function nativeBotIdentityKey(
|
|
provider: ChatProvider,
|
|
identity: VerifiedProviderIdentity,
|
|
): string | null {
|
|
const account = identity.providerAccountId?.trim().toLowerCase();
|
|
const bot = identity.botExternalId?.trim().toLowerCase();
|
|
if (provider === "github" || provider === "discord") {
|
|
if (bot) return `${provider}:app:${bot}`;
|
|
return null;
|
|
}
|
|
if (!account || !bot) return null;
|
|
return `${provider}:${account}:${bot}`;
|
|
}
|
|
|
|
function providerUsesGlobalApplicationIdentity(
|
|
provider: ChatProvider,
|
|
): boolean {
|
|
return (
|
|
provider === "github" ||
|
|
provider === "discord" ||
|
|
provider === "microsoft-teams" || provider === "imessage-photon"
|
|
);
|
|
}
|
|
|
|
function nativeBotIdentityMatches(
|
|
provider: ChatProvider,
|
|
current: VerifiedProviderIdentity,
|
|
incoming: VerifiedProviderIdentity,
|
|
): boolean {
|
|
const currentKey = nativeBotIdentityKey(provider, current);
|
|
return (
|
|
currentKey !== null &&
|
|
currentKey === nativeBotIdentityKey(provider, incoming)
|
|
);
|
|
}
|
|
|
|
function legacyGitHubLabelsMatchVerifiedCredentials(
|
|
current: VerifiedProviderIdentity,
|
|
verified: VerifiedProviderIdentity,
|
|
): boolean {
|
|
return (
|
|
!current.botExternalId &&
|
|
Boolean(verified.botExternalId) &&
|
|
current.providerAccountId?.trim().toLowerCase() ===
|
|
verified.providerAccountId?.trim().toLowerCase() &&
|
|
current.botUsername?.trim().toLowerCase() ===
|
|
verified.botUsername?.trim().toLowerCase()
|
|
);
|
|
}
|
|
|
|
async function assertNativeBotIdentityAvailable(
|
|
endpoint: EndpointRow,
|
|
identity: VerifiedProviderIdentity,
|
|
): Promise<void> {
|
|
const key = nativeBotIdentityKey(endpoint.provider, identity);
|
|
if (!key) {
|
|
throw unprocessable(
|
|
`${PROVIDER_LABELS[endpoint.provider]} did not return a stable native bot identity`,
|
|
);
|
|
}
|
|
const candidates = await db
|
|
.select({
|
|
id: chatEndpoints.id,
|
|
companyId: chatEndpoints.companyId,
|
|
assignedAgentId: chatEndpoints.assignedAgentId,
|
|
providerAccountId: chatEndpoints.providerAccountId,
|
|
botExternalId: chatEndpoints.botExternalId,
|
|
botUsername: chatEndpoints.botUsername,
|
|
})
|
|
.from(chatEndpoints)
|
|
.where(
|
|
and(
|
|
providerUsesGlobalApplicationIdentity(endpoint.provider)
|
|
? undefined
|
|
: eq(chatEndpoints.companyId, endpoint.companyId),
|
|
eq(chatEndpoints.provider, endpoint.provider),
|
|
ne(chatEndpoints.id, endpoint.id),
|
|
endpoint.provider === "imessage-photon" ? ne(chatEndpoints.status, "archived") : inArray(chatEndpoints.status, [
|
|
"verifying",
|
|
"active",
|
|
"paused",
|
|
"attention",
|
|
]),
|
|
),
|
|
);
|
|
const conflictEndpoint = candidates.find((candidate) => {
|
|
if (!providerUsesGlobalApplicationIdentity(endpoint.provider)) {
|
|
return nativeBotIdentityKey(endpoint.provider, candidate) === key;
|
|
}
|
|
const candidateAppId = candidate.botExternalId?.trim().toLowerCase();
|
|
const incomingAppId = identity.botExternalId?.trim().toLowerCase();
|
|
if (candidateAppId && incomingAppId)
|
|
return candidateAppId === incomingAppId;
|
|
return (
|
|
candidate.providerAccountId?.trim().toLowerCase() ===
|
|
identity.providerAccountId?.trim().toLowerCase() &&
|
|
candidate.botUsername?.trim().toLowerCase() ===
|
|
identity.botUsername?.trim().toLowerCase()
|
|
);
|
|
});
|
|
if (conflictEndpoint) {
|
|
throw conflict(
|
|
`This ${PROVIDER_LABELS[endpoint.provider]} bot already represents another Paperclip agent connection`,
|
|
conflictEndpoint.companyId === endpoint.companyId
|
|
? {
|
|
code: "chat_bot_identity_in_use",
|
|
endpointId: conflictEndpoint.id,
|
|
assignedAgentId: conflictEndpoint.assignedAgentId,
|
|
}
|
|
: { code: "chat_bot_identity_in_use" },
|
|
);
|
|
}
|
|
}
|
|
|
|
function isNativeBotIdentityUniqueViolation(error: unknown): boolean {
|
|
return (
|
|
isUniqueViolation(error, "chat_endpoints_photon_number_uq") ||
|
|
isUniqueViolation(error, "chat_endpoints_live_bot_external_uq") ||
|
|
isUniqueViolation(error, "chat_endpoints_live_discord_bot_external_uq") ||
|
|
isUniqueViolation(
|
|
error,
|
|
"chat_endpoints_live_global_app_bot_external_uq",
|
|
) ||
|
|
isUniqueViolation(error, "chat_endpoints_live_bot_username_uq")
|
|
);
|
|
}
|
|
|
|
function nativeBotIdentityConflict(provider: ChatProvider) {
|
|
return conflict(
|
|
`This ${PROVIDER_LABELS[provider]} bot already represents another Paperclip agent connection`,
|
|
{ code: "chat_bot_identity_in_use" },
|
|
);
|
|
}
|
|
|
|
async function persistCredentials(
|
|
endpoint: EndpointRow,
|
|
credentials: Record<string, string>,
|
|
credentialLease: CredentialMutationLeaseGuard,
|
|
actorUserId?: string | null,
|
|
onCommitted?: (tx: Db) => Promise<void>,
|
|
) {
|
|
await credentialLease.assertOwned();
|
|
const previousRefs = await db
|
|
.select({ refs: toolConnections.credentialSecretRefs })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, endpoint.companyId),
|
|
eq(toolConnections.id, endpoint.connectionId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0]?.refs ?? []);
|
|
const protectedFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => credentials[field] !== undefined);
|
|
const previousValues: Record<string, string> = {};
|
|
for (const ref of previousRefs.filter(ref => protectedFields.some(field => ref.configPath === `credentials.${field}`))) {
|
|
try {
|
|
Object.assign(previousValues, await resolveCredentialRefs(endpoint, [ref]));
|
|
} catch (error) {
|
|
// A valid replacement can repair unavailable old material. Unexpected
|
|
// database failures and company/scope violations must still abort.
|
|
const code = error instanceof HttpError && error.details && typeof error.details === "object"
|
|
? Reflect.get(error.details, "code") : null;
|
|
const unavailable = isSecretProviderClientError(error) || (error instanceof HttpError && (
|
|
["secret_deleted", "secret_inactive", "version_missing", "version_inactive"].includes(code) ||
|
|
(error.status === 404 && error.message === "Secret not found" && !error.details)
|
|
));
|
|
if (!unavailable) throw error;
|
|
}
|
|
}
|
|
const changedFields = protectedFields.filter(field => credentials[field] !== previousValues[field]);
|
|
const generations = Object.fromEntries(changedFields.map(field => [field, randomUUID()]));
|
|
const refs: ToolCredentialSecretRef[] = [];
|
|
try {
|
|
for (const [key, value] of Object.entries(credentials)) {
|
|
if (endpoint.provider === "imessage-photon" && key !== "projectSecret") continue;
|
|
await credentialLease.assertOwned();
|
|
const suffix = randomUUID();
|
|
const secret = await secrets.create(
|
|
endpoint.companyId,
|
|
{
|
|
name: `chat.${endpoint.provider}.${endpoint.id.slice(0, 8)}.${key}.${suffix.slice(0, 8)}`,
|
|
key: `CHAT_${endpoint.provider.replaceAll("-", "_")}_${endpoint.id.replaceAll("-", "_")}_${key}_${suffix}`.toUpperCase(),
|
|
provider: "local_encrypted",
|
|
managedMode: "paperclip_managed",
|
|
value,
|
|
description: `${PROVIDER_LABELS[endpoint.provider]} channel credential for endpoint ${endpoint.id}`,
|
|
},
|
|
{ userId: actorUserId ?? undefined },
|
|
);
|
|
refs.push({
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: `credentials.${key}`,
|
|
required: true,
|
|
label: key,
|
|
projectionClass: "unclassified",
|
|
});
|
|
await credentialLease.assertOwned();
|
|
}
|
|
await db.transaction(async (tx) => {
|
|
await credentialLease.assertOwned(tx);
|
|
const current = await tx
|
|
.select({ refs: toolConnections.credentialSecretRefs })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, endpoint.companyId),
|
|
eq(toolConnections.id, endpoint.connectionId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current) throw notFound("Chat connection not found");
|
|
if (
|
|
credentialFingerprint(current.refs) !==
|
|
credentialFingerprint(previousRefs)
|
|
) {
|
|
throw conflict("Chat credentials changed during rotation", {
|
|
code: "chat_endpoint_credentials_changed",
|
|
});
|
|
}
|
|
await notifyChatEndpointConfiguration(tx, endpoint);
|
|
await tx
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, endpoint.companyId),
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
eq(companySecretBindings.targetId, endpoint.connectionId),
|
|
),
|
|
);
|
|
if (refs.length > 0) {
|
|
await tx.insert(companySecretBindings).values(
|
|
refs.map((ref) => ({
|
|
companyId: endpoint.companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection",
|
|
targetId: endpoint.connectionId,
|
|
configPath: ref.configPath,
|
|
versionSelector: String(ref.versionSelector ?? "latest"),
|
|
required: ref.required,
|
|
label: ref.label ?? null,
|
|
projectionClass: ref.projectionClass ?? "unclassified",
|
|
projectionAllowlistKey: ref.projectionAllowlistKey ?? null,
|
|
})),
|
|
);
|
|
}
|
|
await tx
|
|
.update(toolConnections)
|
|
.set({ credentialSecretRefs: refs, updatedAt: new Date() })
|
|
.where(eq(toolConnections.id, endpoint.connectionId));
|
|
if (changedFields.length) {
|
|
await notifyChatEndpointWork(tx, endpoint.connectionId, endpoint.companyId);
|
|
const [currentEndpoint] = await tx.select({ setup: chatEndpoints.setup }).from(chatEndpoints)
|
|
.where(and(eq(chatEndpoints.id, endpoint.id), eq(chatEndpoints.companyId, endpoint.companyId))).for("update");
|
|
if (!currentEndpoint) throw notFound("Chat endpoint not found");
|
|
const verification = changedFields.some(field => field === "signingSecret" || field === "webhookSecret")
|
|
&& !options.preservesWebhookVerification?.(endpoint, currentEndpoint.setup, credentials)
|
|
? { webhookVerifiedAt: null } : {};
|
|
await tx.update(chatEndpoints).set({
|
|
// Fence old healthy state in the same transaction as the new refs.
|
|
status: sql`case when ${chatEndpoints.status} = 'active' then 'verifying' else ${chatEndpoints.status} end`,
|
|
setup: sql`jsonb_set(${chatEndpoints.setup}, '{credentialEntryGenerations}', coalesce(${chatEndpoints.setup}->'credentialEntryGenerations', '{}'::jsonb) || ${JSON.stringify(generations)}::jsonb) || ${JSON.stringify(verification)}::jsonb`,
|
|
}).where(and(eq(chatEndpoints.id, endpoint.id), eq(chatEndpoints.companyId, endpoint.companyId)));
|
|
}
|
|
await onCommitted?.(tx as unknown as Db);
|
|
await credentialLease.assertOwned(tx);
|
|
});
|
|
} catch (error) {
|
|
// A lost database acknowledgement can be ambiguous even when the
|
|
// transaction committed. Delete only secrets proven unbound after the
|
|
// failure; when that proof cannot be read, leak safely for later cleanup
|
|
// instead of deleting a possibly-authoritative credential.
|
|
const boundSecretIds =
|
|
refs.length === 0
|
|
? new Set<string>()
|
|
: await db
|
|
.select({ secretId: companySecretBindings.secretId })
|
|
.from(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, endpoint.companyId),
|
|
inArray(
|
|
companySecretBindings.secretId,
|
|
refs.map((ref) => ref.secretId),
|
|
),
|
|
),
|
|
)
|
|
.then((rows) => new Set(rows.map((row) => row.secretId)))
|
|
.catch(() => null);
|
|
if (boundSecretIds) {
|
|
await Promise.allSettled(
|
|
refs
|
|
.filter((ref) => !boundSecretIds.has(ref.secretId))
|
|
.map((ref) => secrets.remove(ref.secretId)),
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
const currentIds = new Set(refs.map((ref) => ref.secretId));
|
|
const retired = previousRefs.filter((ref) => !currentIds.has(ref.secretId));
|
|
const results = await Promise.allSettled(
|
|
retired.map((ref) => secrets.remove(ref.secretId)),
|
|
);
|
|
if (results.some((result) => result.status === "rejected")) {
|
|
logger.warn(
|
|
{ endpointId: endpoint.id },
|
|
"chat credential rotation left an unbound retired secret for later cleanup",
|
|
);
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
async function clearCredentials(
|
|
endpoint: EndpointRow,
|
|
credentialLease: CredentialMutationLeaseGuard,
|
|
) {
|
|
let refs: ToolCredentialSecretRef[] = [];
|
|
await db.transaction(async (tx) => {
|
|
await credentialLease.assertOwned(tx);
|
|
const current = await tx
|
|
.select({ refs: toolConnections.credentialSecretRefs })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, endpoint.companyId),
|
|
eq(toolConnections.id, endpoint.connectionId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current) throw notFound("Chat connection not found");
|
|
refs = current.refs;
|
|
await notifyChatEndpointConfiguration(tx, endpoint);
|
|
await tx
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, endpoint.companyId),
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
eq(companySecretBindings.targetId, endpoint.connectionId),
|
|
),
|
|
);
|
|
await tx
|
|
.update(toolConnections)
|
|
.set({ credentialSecretRefs: [], updatedAt: new Date() })
|
|
.where(eq(toolConnections.id, endpoint.connectionId));
|
|
await credentialLease.assertOwned(tx);
|
|
});
|
|
const results = await Promise.allSettled(
|
|
refs.map((ref) => secrets.remove(ref.secretId)),
|
|
);
|
|
if (results.some((result) => result.status === "rejected")) {
|
|
logger.warn(
|
|
{ endpointId: endpoint.id },
|
|
"chat endpoint removal left an unbound retired secret for later cleanup",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function resolveCredentialRefs(
|
|
endpoint: EndpointRow,
|
|
refs: ToolCredentialSecretRef[],
|
|
database: DbOrTransaction = db,
|
|
): Promise<Record<string, string>> {
|
|
const values: Record<string, string> = {};
|
|
for (const ref of refs) {
|
|
const key = ref.configPath.replace(/^credentials\./, "");
|
|
values[key] = await (database === db ? secrets : secretService(database as Db)).resolveSecretValue(
|
|
endpoint.companyId,
|
|
ref.secretId,
|
|
ref.versionSelector ?? "latest",
|
|
{
|
|
consumerType: "tool_connection",
|
|
consumerId: endpoint.connectionId,
|
|
configPath: ref.configPath,
|
|
actorType: "system",
|
|
actorId: null,
|
|
},
|
|
);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
async function resolveCredentials(
|
|
endpoint: EndpointRow,
|
|
database: DbOrTransaction = db,
|
|
secretKeys?: readonly string[],
|
|
): Promise<Record<string, string>> {
|
|
const connection = await database
|
|
.select({ refs: toolConnections.credentialSecretRefs, config: toolConnections.config })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, endpoint.companyId),
|
|
eq(toolConnections.id, endpoint.connectionId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!connection) throw notFound("Chat connection not found");
|
|
const refs = secretKeys
|
|
? connection.refs.filter(ref => secretKeys.includes(ref.configPath.replace(/^credentials\./, "")))
|
|
: connection.refs;
|
|
const values = await resolveCredentialRefs(endpoint, refs, database);
|
|
if (endpoint.provider === "imessage-photon") {
|
|
const configuration = photonChannelConfigurationSchema.parse(connection.config.photon);
|
|
return { ...values, ...configuration, lineId: configuration.allocation === "shared" ? photonSharedScope(configuration.projectId) : configuration.lineId };
|
|
}
|
|
return values;
|
|
}
|
|
|
|
async function prepareProviderInventory(
|
|
endpoint: EndpointRow,
|
|
credentials: Record<string, string>,
|
|
): Promise<{
|
|
credentials: Record<string, string>;
|
|
inventory: ChatProviderInventoryResult | null;
|
|
managementUrl?: string;
|
|
}> {
|
|
try {
|
|
if (endpoint.provider === "slack") return await prepareSlackInventory(credentials, fetchImpl);
|
|
if (endpoint.provider === "github") return await prepareGitHubInventory(credentials, fetchImpl);
|
|
if (endpoint.provider === "discord") return await prepareDiscordInventory(credentials, fetchImpl);
|
|
return { credentials, inventory: null };
|
|
} catch (error) {
|
|
throw unprocessable(redactError(error), {
|
|
code: "chat_provider_inventory_failed",
|
|
});
|
|
}
|
|
}
|
|
return { normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, legacyGitHubLabelsMatchVerifiedCredentials, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, clearCredentials, resolveCredentialRefs, resolveCredentials, prepareProviderInventory };
|
|
}
|