Files
PaperClipAI/server/src/services/chat-channels/credentials.ts
T
DottaandPaperclip 5f0da562db fix(server): wake chat lifecycle milestones from committed changes (#17606)
## 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>
2026-10-28 11:07:54 -05:00

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