feat: add opt-in chat provider and data foundation (#13100)

Add dormant provider contracts, qualified patched adapters, tenant-scoped persistence and lifecycle ownership without activating chat routes. Preserve the experimental integration as dependent PR #13038.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 13:49:12 -05:00 committed by GitHub
parent 7d84b183fb
commit 6abeb67334
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
143 changed files with 789391 additions and 162 deletions

View File

@ -5,6 +5,8 @@ import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createServer } from "node:net";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import {
agents,
@ -13,6 +15,8 @@ import {
companies,
companyMemberships,
createDb,
closeRegisteredClients,
ensurePostgresDatabase,
executionWorkspaces,
inspectMigrations,
issueComments,
@ -1644,17 +1648,32 @@ describe("worktree helpers", () => {
const sourceEnvPath = path.join(sourceConfigDir, ".env");
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
onTestFinished(() => sourceDb.cleanup());
await seedValidWorktreeSource(sourceDb.connectionString);
const sourceCluster = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
const sourceUrl = new URL(sourceCluster.connectionString);
sourceUrl.pathname = "/lagging_source";
const sourceDb = { connectionString: sourceUrl.toString() };
onTestFinished(async () => {
await closeRegisteredClients(sourceDb.connectionString);
await sourceCluster.cleanup();
});
await ensurePostgresDatabase(sourceCluster.connectionString, "lagging_source");
// A lagging source must also have the prior schema. Deleting only the
// newest receipt from a fully migrated schema relied on that particular
// migration being idempotent and breaks when the new migration creates a
// table. Build the actual all-but-last schema before shuffling its history.
const migrationsRoot = new URL("../../../packages/db/src/migrations/", import.meta.url);
const journal = JSON.parse(fs.readFileSync(new URL("meta/_journal.json", migrationsRoot), "utf8"));
const priorEntries = journal.entries.slice(0, -1);
const priorMigrations = path.join(tempRoot, "prior-migrations");
fs.mkdirSync(path.join(priorMigrations, "meta"), { recursive: true });
fs.writeFileSync(path.join(priorMigrations, "meta", "_journal.json"), JSON.stringify({ ...journal, entries: priorEntries }));
for (const entry of priorEntries) {
fs.copyFileSync(new URL(`${entry.tag}.sql`, migrationsRoot), path.join(priorMigrations, `${entry.tag}.sql`));
}
const sourceDbClient = createDb(sourceDb.connectionString);
await migrate(drizzle(sourceDbClient.$client), { migrationsFolder: priorMigrations });
await seedValidWorktreeSource(sourceDb.connectionString);
await sourceDbClient.$client.unsafe(`
DELETE FROM "drizzle"."__drizzle_migrations"
WHERE "id" = (
SELECT max("id") FROM "drizzle"."__drizzle_migrations"
);
WITH pair AS (
SELECT
array_agg("id" ORDER BY "id" DESC) AS ids,

View File

@ -0,0 +1,50 @@
# Experimental chat channel landing plan
The chat integration lands in two dependent changes after the native runner
prerequisites in #13092. The runner change is not one of these two chat changes.
## First change: provider and data foundation
Add the closed channel types, additive database schema, pinned provider adapter
patches, packaged dependencies, and opt-in runtime and transport helpers. Include
their real-parser, synthetic-transport, migration, and package qualification tests.
Keep the existing server routes, application catalog, experimental settings,
heartbeat dispatch, and Board entry points unchanged. This change does not start
provider connections or expose partially implemented channel routes. The existing
production GitHub tool connection keeps its current path.
Validate this change against the existing master consumers independently. Run
repository types, tests, and build, the historical database upgrade checks, and
the release patch-packaging checks. Exact-head CI and code review are required.
The foundation exports the connection-purpose type needed by the schema, but
does not add the channel transport or application kind to existing consumers.
Durable command-registration and Teams transfer stores remain in the second
change alongside their service authorization. Tenant foreign keys bind task,
agent, comment, delivery, and action references to their company. Nullable
deletion uses an ID-only `SET NULL` action plus a tenant `NO ACTION` constraint,
so deleting a parent cannot clear the required company ID. Action references to
conversation and principal retain history instead of silently detaching it.
These corrections use forward migrations; historical SQL remains unchanged.
The runtime registry retires the prior endpoint owner before exposing a
replacement, and fences initialization against removal or shutdown. It does not
initialize providers itself: the service caller must install current guarded
callback context before starting a Gateway connection.
## Second change: gated service and Board integration
Add company-scoped durable admission, identity and reach authorization, leases,
queues, publications, native controls, and the Board user journey. This includes
the provider-specific registration and service wiring for Slack, Discord,
Telegram, Teams, and GitHub. Keep activation behind the experimental channel
setting. A capability is not permission to bypass current source or user checks.
Run joined service and recovery tests, all repository checks, and the deterministic
browser suite on the composed head. Live provider observations supplement these
checks; mocked transport and browser fixtures are not live-provider proof.
Merge the foundation first. Then update the integration onto current master and
repeat exact-head verification and review. Each chat pull request must remain
under 500 changed files. Preserve uncertain delivery outcomes and explicit
unsupported cases rather than claiming complete provider qualification.

View File

@ -104,7 +104,13 @@
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
"@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
"@agentclientprotocol/claude-agent-acp@0.73.0": "patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
"@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch"
"@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch",
"@chat-adapter/telegram@4.39.0": "patches/@chat-adapter__telegram@4.39.0.patch",
"@chat-adapter/teams@4.39.0": "patches/@chat-adapter__teams@4.39.0.patch",
"@chat-adapter/slack@4.39.0": "patches/@chat-adapter__slack@4.39.0.patch",
"@chat-adapter/discord@4.39.0": "patches/@chat-adapter__discord@4.39.0.patch",
"@chat-adapter/github@4.39.0": "patches/@chat-adapter__github@4.39.0.patch",
"@discordjs/ws@1.2.3": "patches/@discordjs__ws@1.2.3.patch"
},
"overrides": {
"@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.153.4",

View File

@ -0,0 +1,833 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import {
applyPendingMigrations,
closeRegisteredClients,
ensurePostgresDatabase,
inspectMigrations,
} from "./client.js";
import {
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
// The deployed chat branch used 02400249 before master added its independent
// 02400245 execution-identity chain. These are the exact original SQL hashes,
// not hashes of regenerated DDL: the interaction migration also repairs data
// and must never run again just because its filename moved.
// The next upstream chain occupies02460254. Chat files now use02550268;
// historical data-repair audit labels remain byte-identical.
const chatMigrations = [
[
"0255_previous_captain_america",
"2cbd1eb88d3bf4c82b72fdfd78dce72ecd7899f85607a76fb7e40b2749fffa00",
1788580015986,
],
[
"0256_married_king_cobra",
"f352a8769496412df3be35050df02110714af3a189d92b3c2ac8d097e2612c7b",
1788581746772,
],
[
"0257_bizarre_the_hunter",
"4e4636a22fb06aac55a998a0c98d043ec70083c198c94a0f5e0a99f18a1debac",
1788582768429,
],
[
"0258_typical_sauron",
"f3cb8b9d3bb3691d98a830c7ba8b4f49bbe9bb01ce2e899583d0b5c9b84d423c",
1788585030341,
],
[
"0259_tan_chat",
"91012c36bfcf66615b537ce808c9cb2f1311bc6efa6aab3ed8afd0d01298af75",
1788673647823,
],
[
"0260_chat_interaction_wakeup_idempotency",
"5e181169a724173d17865d537bd84c385e97e6f78e71aa795cad91734cd37ea0",
1788688205087,
],
[
"0261_faulty_iceman",
"dd7a7571e080471148cff98d1138ddd046e8c1e3c256fa5bc11564d5f4766c28",
1788704871875,
],
[
"0262_lying_avengers",
"59909e4edae56117c7fe0af28aa10fe30a64d151e83fe6e06debcec90658ff09",
1788708784607,
],
[
"0263_nebulous_iron_lad",
"858eb11c0863e361c1ae6995e78ca365f8002e35dcb1e89dbcc8bf65dea879e3",
1788714691806,
],
[
"0264_cynical_hellcat",
"d9aeacc58ae3c52d34bf50f8ea38f55435a6f8f66dc6ee87d3b78e105a86602a",
1788793844054,
],
[
"0265_chat_interaction_wakeup_provenance",
"1547e6e597b50c621691ead1d25624c4bf94ca3259cf24ea4480f3fb915dd849",
1788880065244,
],
[
"0266_brave_living_mummy",
"7c38ccd2fa6a9bde19d62b111f892bcabae9a8eabe5f8bf438f73a407016b56a",
1788930085103,
],
[
"0267_warm_wild_child",
"6902ea71d481a26d6359c6c9b149ff0c8a388e65356b066b2fbaa33622a6c9b8",
1788934048647,
],
[
"0268_lively_runaways",
"20ebd2ac15d9b467abcc5552901ecf592b38499942b3eae7d593c79fd89bd0a8",
1788942847296,
],
] as const;
const identityMigrations = [
"0240_pink_fantastic_four.sql",
"0241_conscious_adam_destine.sql",
"0242_wide_lightspeed.sql",
"0243_sleepy_metal_master.sql",
"0244_organic_meltdown.sql",
"0245_misty_nightshade.sql",
];
const provenanceMigration = "0265_chat_interaction_wakeup_provenance.sql";
const legacyProvenance = "0245_chat_interaction_wakeup_idempotency";
const canonicalProvenance = "0251_chat_interaction_wakeup_idempotency";
async function executeMigration(sql: postgres.Sql, file: string) {
const content = await readFile(
new URL(`./migrations/${file}`, import.meta.url),
"utf8",
);
for (const statement of content.split("--> statement-breakpoint")) {
if (statement.trim()) await sql.unsafe(statement);
}
}
async function migrationHash(file: string) {
const content = await readFile(
new URL(`./migrations/${file}`, import.meta.url),
);
return createHash("sha256").update(content).digest("hex");
}
describe("chat and execution identity migration reconciliation", () => {
it("preserves all fourteen deployed chat SQL hashes after renumbering", async () => {
for (const [tag, hash] of chatMigrations) {
expect(await migrationHash(`${tag}.sql`), tag).toBe(hash);
}
});
it("keeps canonical identity history in every regenerated chat checkpoint", async () => {
const journal = JSON.parse(
await readFile(
new URL("./migrations/meta/_journal.json", import.meta.url),
"utf8",
),
);
expect(
journal.entries
.filter(
(entry: { idx: number }) => entry.idx >= 240 && entry.idx <= 245,
)
.map((entry: { tag: string }) => `${entry.tag}.sql`),
).toEqual(identityMigrations);
expect(
journal.entries
.filter(
(entry: { idx: number }) => entry.idx >= 255 && entry.idx <= 268,
)
.map((entry: { tag: string }) => entry.tag),
).toEqual(chatMigrations.map(([tag]) => tag));
let previous = JSON.parse(
await readFile(
new URL("./migrations/meta/0254_snapshot.json", import.meta.url),
"utf8",
),
);
for (let step = 0; step < chatMigrations.length; step++) {
const index = String(255 + step).padStart(4, "0");
const current = JSON.parse(
await readFile(
new URL(`./migrations/meta/${index}_snapshot.json`, import.meta.url),
"utf8",
),
);
expect(current.prevId, index).toBe(previous.id);
expect(current.tables["public.run_identity_contexts"], index).toEqual(
previous.tables["public.run_identity_contexts"],
);
expect(
current.tables["public.heartbeat_runs"].columns
.active_identity_context_id,
index,
).toBeDefined();
expect(
current.tables["public.issues"].columns.origin_identity_context_id,
index,
).toBeDefined();
expect(
current.tables["public.issues"].columns
.continuation_identity_context_id,
index,
).toBeDefined();
expect(
current.tables["public.issue_thread_interactions"].columns
.source_identity_context_id,
index,
).toBeDefined();
expect(
Boolean(
current.tables["public.chat_conversations"].columns
.session_generation,
),
index,
).toBe(step >= 1);
expect(
Boolean(
current.tables["public.chat_endpoints"].indexes
.chat_endpoints_live_bot_external_uq,
),
index,
).toBe(step >= 2);
expect(
current.tables[
"public.chat_publications"
].checkConstraints.chat_publications_state_check.value.includes(
"delivery_unknown",
),
index,
).toBe(step >= 3);
expect(
current.tables["public.chat_endpoints"].columns.allow_group_chats
.default,
index,
).toBe(step < 4);
expect(
current.tables[
"public.agent_wakeup_requests"
].indexes.agent_wakeup_requests_question_response_delivery_idempotency_uq.where.includes(
"interaction:%",
),
index,
).toBe(step >= 5);
expect(
current.tables[
"public.chat_endpoints"
].checkConstraints.chat_endpoints_provider_check.value.includes(
"discord",
),
index,
).toBe(step >= 6);
expect(
Boolean(
current.tables["public.chat_endpoints"].indexes
.chat_endpoints_live_discord_bot_external_uq,
),
index,
).toBe(step >= 7);
expect(
Boolean(
current.tables["public.chat_endpoints"].indexes
.chat_endpoints_live_global_app_bot_external_uq,
),
index,
).toBe(step >= 8);
expect(
Boolean(
current.tables["public.issue_attachments"].columns.originating_run_id,
),
index,
).toBe(step >= 9);
previous = current;
}
});
});
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)(
"chat wake migration provenance",
() => {
it(
"repairs deployed provenance without replaying historical SQL or changing wake execution state",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-provenance-",
);
const sql = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
try {
const companyId = randomUUID(),
agentId = randomUUID(),
retainedId = randomUUID();
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Provenance upgrade','PRV')`;
await sql`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Upgrade agent')`;
const originalKey = `interaction:${randomUUID()}`;
const oldLine = `Safely retired duplicate by migration 0245; retained wake request ${retainedId}`;
const newLine = `Safely retired duplicate by migration 0251; retained wake request ${retainedId}`;
const retired = {
migration: legacyProvenance,
retainedWakeRequestId: retainedId,
originalIdempotencyKey: originalKey,
previousStatus: "queued",
linkedRunId: null,
resolution: "retired_unstarted_duplicate",
futureAuditField: { preserved: true },
};
const cases: Array<{
name: string;
dedupe: postgres.JSONValue;
key?: string;
status?: string;
error?: string | null;
correct?: boolean;
correctedError?: string | null;
id?: string;
}> = [
{
name: "retired first UUID",
id: "00000000-0000-0000-0000-000000000000",
dedupe: retired,
error: oldLine,
correct: true,
correctedError: newLine,
},
{
name: "preserves unrelated audit prefix",
dedupe: retired,
error: `Unrelated migration 0245 note\n${oldLine}`,
correct: true,
correctedError: `Unrelated migration 0245 note\n${newLine}`,
},
{
name: "preserves non-final generated line",
dedupe: retired,
error: `${oldLine}\nLater operator note`,
correct: true,
},
{
name: "does not replace arbitrary suffix",
dedupe: retired,
error: `Operator quoted: ${oldLine}`,
correct: true,
},
{
name: "does not replace another retained ID",
dedupe: retired,
error: oldLine.replace(retainedId, randomUUID()),
correct: true,
},
{
name: "later terminalized rekeyed wake",
dedupe: {
...retired,
previousStatus: "running",
linkedRunId: randomUUID(),
resolution: "rekeyed_preserving_execution_history",
},
key: "historical",
status: "failed",
error: oldLine,
correct: true,
},
{
name: "already canonical",
dedupe: { ...retired, migration: canonicalProvenance },
error: oldLine,
},
{
name: "unrelated migration",
dedupe: { ...retired, migration: "0245_misty_nightshade" },
error: oldLine,
},
{ name: "array metadata", dedupe: [retired] },
{ name: "null metadata", dedupe: null },
{ name: "string metadata", dedupe: legacyProvenance },
{
name: "unknown resolution",
dedupe: { ...retired, resolution: "unknown" },
},
{
name: "missing linked-run field",
dedupe: { ...retired, linkedRunId: undefined },
},
{
name: "malformed retained ID",
dedupe: { ...retired, retainedWakeRequestId: "not-a-uuid" },
},
{
name: "malformed linked-run ID",
dedupe: {
...retired,
linkedRunId: "not-a-uuid",
resolution: "rekeyed_preserving_execution_history",
},
key: "historical",
},
{
name: "non-interaction source key",
dedupe: { ...retired, originalIdempotencyKey: "timer:unrelated" },
},
{
name: "non-string previous status",
dedupe: { ...retired, previousStatus: ["queued"] },
},
{
name: "inconsistent retired resolution",
dedupe: { ...retired, previousStatus: "running" },
},
{
name: "inconsistent rekeyed resolution",
dedupe: {
...retired,
resolution: "rekeyed_preserving_execution_history",
},
key: "historical",
},
{
name: "unrelated current key",
dedupe: retired,
key: "timer:unrelated",
},
];
// More than one keyset batch, including an unrelated-only middle batch.
await sql`INSERT INTO agent_wakeup_requests (id,company_id,agent_id,source,payload)
SELECT ('00000000-0000-0000-0001-' || lpad(n::text,12,'0'))::uuid, ${companyId}, ${agentId}, 'timer', '{"unrelated":true}'::jsonb
FROM generate_series(1,1001) AS n`;
const expectedChanges = new Map<
string,
{ error: string | null; payload: Record<string, unknown> }
>();
for (const fixture of cases) {
const id = fixture.id ?? randomUUID();
const key =
fixture.key === "historical"
? `historical-interaction-wake-duplicate:${id}`
: (fixture.key ?? originalKey);
const payload = {
unrelated: { preserved: true },
migrationDedupe: fixture.dedupe,
};
const error = fixture.error ?? null;
await sql`INSERT INTO agent_wakeup_requests
(id,company_id,agent_id,source,reason,status,idempotency_key,run_id,payload,error,requested_at,claimed_at,finished_at,created_at,updated_at)
VALUES (${id},${companyId},${agentId},'automation',${fixture.name},${fixture.status ?? "skipped"},${key},${fixture.status === "failed" ? randomUUID() : null},${sql.json(payload)},${error},'2026-09-01T00:00:00Z','2026-09-01T00:00:01Z','2026-09-01T00:00:02Z','2026-09-01T00:00:00Z','2026-09-01T00:00:02Z')`;
if (fixture.correct)
expectedChanges.set(id, {
payload: {
...payload,
migrationDedupe: {
...(fixture.dedupe as object),
migration: canonicalProvenance,
},
},
error: fixture.correctedError ?? error,
});
}
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${await migrationHash(provenanceMigration)}`;
const legacyHash = chatMigrations[5][1];
await sql`UPDATE drizzle.__drizzle_migrations SET created_at = ${chatMigrations[5][2]} WHERE hash = ${legacyHash}`;
const historyBefore =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(
historyBefore.filter((row) => row.hash === legacyHash),
).toHaveLength(1);
const rowsBefore =
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`;
const expected = rowsBefore.map(({ row }) => ({
row: { ...row, ...expectedChanges.get(row.id) },
}));
expect(
await inspectMigrations(database.connectionString),
).toMatchObject({
status: "needsMigrations",
pendingMigrations: [provenanceMigration],
});
await applyPendingMigrations(database.connectionString);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations WHERE id <= ${historyBefore.at(-1)!.id} ORDER BY id`,
).toEqual(historyBefore);
const historyAfter =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(historyAfter).toHaveLength(historyBefore.length + 1);
await executeMigration(sql, provenanceMigration);
await applyPendingMigrations(database.connectionString);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`,
).toEqual(historyAfter);
} finally {
await sql.end();
await database.cleanup();
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
it(
"corrects both original repair resolutions on a fresh migration path",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-provenance-fresh-",
);
const sql = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
try {
const companyId = randomUUID(),
agentId = randomUUID(),
retainedId = randomUUID(),
queuedId = randomUUID(),
activeId = randomUUID();
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Fresh provenance','FPV')`;
await sql`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Fresh agent')`;
await sql`DROP INDEX agent_wakeup_requests_question_response_delivery_idempotency_uq`;
const key = `interaction:${randomUUID()}`;
for (const [id, status, runId] of [
[retainedId, "succeeded", randomUUID()],
[queuedId, "queued", null],
[activeId, "running", randomUUID()],
] as const) {
await sql`INSERT INTO agent_wakeup_requests (id,company_id,agent_id,source,status,run_id,idempotency_key,payload,error)
VALUES (${id},${companyId},${agentId},'automation',${status},${runId},${key},'{"preserved":true}'::jsonb,'Prior audit')`;
}
await executeMigration(sql, `${chatMigrations[5][0]}.sql`);
const before =
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`;
expect(
before.filter(
({ row }) =>
row.payload.migrationDedupe?.migration === legacyProvenance,
),
).toHaveLength(2);
expect(
before.find(({ row }) => row.id === queuedId)!.row.status,
).toBe("skipped");
expect(
before.find(({ row }) => row.id === activeId)!.row.status,
).toBe("running");
await executeMigration(sql, provenanceMigration);
const expected = before.map(({ row }) =>
row.id === retainedId
? { row }
: {
row: {
...row,
payload: {
...row.payload,
migrationDedupe: {
...row.payload.migrationDedupe,
migration: canonicalProvenance,
},
},
error:
row.id === queuedId
? `Prior audit\nSafely retired duplicate by migration 0251; retained wake request ${retainedId}`
: row.error,
},
},
);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
await executeMigration(sql, provenanceMigration);
expect(
await sql`SELECT row_to_json(w) AS row FROM agent_wakeup_requests w WHERE company_id = ${companyId} ORDER BY id`,
).toEqual(expected);
} finally {
await sql.end();
await database.cleanup();
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
},
);
(support.supported ? describe : describe.skip)(
"chat identity migration upgrade",
() => {
it(
"upgrades the actual prior chat schema through the new upstream chain without replaying chat effects",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-upstream-upgrade-",
);
const source = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
const directory = await mkdtemp(
join(tmpdir(), "paperclip-chat-old-migrations-"),
);
const name = `legacy_chat_${randomUUID().replaceAll("-", "")}`;
const legacyUrl = new URL(database.connectionString);
legacyUrl.pathname = `/${name}`;
let legacy: postgres.Sql | null = null;
try {
expect(
await ensurePostgresDatabase(database.connectionString, name),
).toBe("created");
legacy = postgres(legacyUrl.href, { max: 1, onnotice: () => {} });
const journal = JSON.parse(
await readFile(
new URL("./migrations/meta/_journal.json", import.meta.url),
"utf8",
),
);
const entries = journal.entries as Array<{
idx: number;
tag: string;
when: number;
version: string;
breakpoints: boolean;
}>;
const priorEntries = entries
.filter(
(entry) => entry.idx < 246 || (entry.idx >= 255 && entry.idx <= 268),
)
.map((entry, index) => ({
...entry,
idx: index,
// Exact immutable007 chat-history timestamps. Filenames are not
// persisted by Drizzle; the SQL hash and applied time are.
when: entry.idx < 255 ? entry.when : [
1788832469741, 1788832471197, 1788832472637,
1788832474071, 1788832475492, 1788832476957,
1788832478340, 1788832479792, 1788832481237,
1788832482645, 1788880065244, 1788930085103,
1788934048647, 1788942847296,
][entry.idx - 255]!,
}));
expect(priorEntries.every((entry) => Number.isFinite(entry.when))).toBe(true);
await mkdir(join(directory, "meta"));
for (const entry of priorEntries) {
await writeFile(
join(directory, `${entry.tag}.sql`),
await readFile(
new URL(`./migrations/${entry.tag}.sql`, import.meta.url),
),
);
}
await writeFile(
join(directory, "meta/_journal.json"),
JSON.stringify({ ...journal, entries: priorEntries }),
);
// Run the real migration engine over the prior schema, not a current
// schema with fabricated applied-history rows or dropped columns.
await migrate(drizzle(legacy), { migrationsFolder: directory });
const companyId = randomUUID(),
agentId = randomUUID(),
applicationId = randomUUID(),
connectionId = randomUUID(),
endpointId = randomUUID(),
issueId = randomUUID(),
conversationId = randomUUID(),
publicationId = randomUUID();
await legacy`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Prior chat schema','OLD')`;
await legacy`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Prior agent')`;
await legacy`INSERT INTO tool_applications (id,company_id,name,type) VALUES (${applicationId},${companyId},'Slack','rest_api')`;
await legacy`INSERT INTO tool_connections (id,company_id,application_id,name,uid,connection_purpose,transport) VALUES (${connectionId},${companyId},${applicationId},'Slack','old-chat','channel','chat_sdk')`;
await legacy`INSERT INTO chat_endpoints (id,company_id,connection_id,provider,public_id,assigned_agent_id) VALUES (${endpointId},${companyId},${connectionId},'slack',${randomUUID()},${agentId})`;
await legacy`INSERT INTO issues (id,company_id,title) VALUES (${issueId},${companyId},'Retained source')`;
await legacy`INSERT INTO chat_conversations (id,company_id,endpoint_id,issue_id,external_conversation_id,external_thread_id,external_label) VALUES (${conversationId},${companyId},${endpointId},${issueId},'COLD','slack:COLD:1700.1','Retained thread')`;
await legacy`INSERT INTO chat_publications (id,company_id,endpoint_id,conversation_id,issue_id,idempotency_key,payload,state,attempts) VALUES (${publicationId},${companyId},${endpointId},${conversationId},${issueId},'retained-unknown','{"text":"Do not resend"}'::jsonb,'delivery_unknown',1)`;
const before =
await legacy`SELECT row_to_json(p) AS row FROM chat_publications p ORDER BY id`;
const historyBefore =
await legacy`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
const pending = entries
.filter(
(entry) => (entry.idx >= 246 && entry.idx <= 254) || entry.idx > 268,
)
.map((entry) => `${entry.tag}.sql`);
expect(pending).toHaveLength(9 + entries.filter((entry) => entry.idx > 268).length);
expect(await inspectMigrations(legacyUrl.href)).toMatchObject({
status: "needsMigrations",
pendingMigrations: pending,
});
await applyPendingMigrations(legacyUrl.href);
expect((await inspectMigrations(legacyUrl.href)).status).toBe(
"upToDate",
);
expect(
await legacy`SELECT row_to_json(p) AS row FROM chat_publications p ORDER BY id`,
).toEqual(before);
const historyAfter =
await legacy`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(historyAfter.slice(0, historyBefore.length)).toEqual(
historyBefore,
);
expect(historyAfter).toHaveLength(
historyBefore.length + pending.length,
);
for (const [, hash] of chatMigrations)
expect(
historyAfter.filter((row) => row.hash === hash),
).toHaveLength(1);
const schema = async (sql: postgres.Sql) => ({
columns:
await sql`SELECT table_name,column_name,data_type,is_nullable,column_default FROM information_schema.columns WHERE table_schema='public' ORDER BY table_name,column_name`,
indexes:
await sql`SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY tablename,indexname`,
constraints:
await sql`SELECT c.relname,k.conname,pg_get_constraintdef(k.oid) AS definition FROM pg_constraint k JOIN pg_class c ON c.oid=k.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' ORDER BY c.relname,k.conname`,
});
expect(await schema(legacy)).toEqual(await schema(source));
await applyPendingMigrations(legacyUrl.href);
expect(
await legacy`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`,
).toEqual(historyAfter);
expect(
await legacy`SELECT row_to_json(p) AS row FROM chat_publications p ORDER BY id`,
).toEqual(before);
} finally {
try {
await legacy?.end();
} finally {
try {
await source.end();
} finally {
try {
await closeRegisteredClients(legacyUrl.href);
} finally {
try {
await database.cleanup();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
}
}
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
it(
"migrates a fresh database and upgrades deployed chat history without replaying chat SQL",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-identity-migration-",
);
const sql = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
try {
expect(
(await inspectMigrations(database.connectionString)).status,
).toBe("upToDate");
expect(
(
await sql`SELECT to_regclass('public.run_identity_contexts')::text AS identity, to_regclass('public.chat_publications')::text AS publications`
)[0],
).toEqual({
identity: "run_identity_contexts",
publications: "chat_publications",
});
// Restore the schema/history shape of a deployed pre-identity chat DB.
// This disposable database is owned solely by this test; no live fixture
// is modified. Its already-applied chat SQL retains the original hashes
// and timestamps, even though the checkout now uses new filenames.
await sql`ALTER TABLE heartbeat_runs DROP COLUMN active_identity_context_id`;
await sql`ALTER TABLE issues DROP COLUMN origin_identity_context_id, DROP COLUMN continuation_identity_context_id`;
await sql`ALTER TABLE issue_thread_interactions DROP COLUMN source_identity_context_id`;
await sql`DROP TABLE run_identity_contexts`;
for (const file of identityMigrations) {
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${await migrationHash(file)}`;
}
for (const [, hash, timestamp] of chatMigrations) {
await sql`UPDATE drizzle.__drizzle_migrations SET created_at = ${timestamp} WHERE hash = ${hash}`;
}
const companyId = randomUUID(),
agentId = randomUUID(),
applicationId = randomUUID(),
connectionId = randomUUID(),
endpointId = randomUUID(),
issueId = randomUUID(),
conversationId = randomUUID(),
publicationId = randomUUID();
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${companyId},'Chat upgrade','CUP')`;
await sql`INSERT INTO agents (id,company_id,name) VALUES (${agentId},${companyId},'Chat agent')`;
await sql`INSERT INTO tool_applications (id,company_id,name,type) VALUES (${applicationId},${companyId},'Slack','rest_api')`;
await sql`INSERT INTO tool_connections (id,company_id,application_id,name,uid,connection_purpose,transport) VALUES (${connectionId},${companyId},${applicationId},'Slack','slack-upgrade','channel','chat_sdk')`;
await sql`INSERT INTO chat_endpoints (id,company_id,connection_id,provider,public_id,assigned_agent_id) VALUES (${endpointId},${companyId},${connectionId},'slack',${randomUUID()},${agentId})`;
await sql`INSERT INTO issues (id,company_id,title) VALUES (${issueId},${companyId},'Existing chat task')`;
await sql`INSERT INTO chat_conversations (id,company_id,endpoint_id,issue_id,external_conversation_id,external_thread_id,external_label) VALUES (${conversationId},${companyId},${endpointId},${issueId},'CUPGRADE','slack:CUPGRADE:1700.1','Existing Slack thread')`;
await sql`INSERT INTO chat_publications (id,company_id,endpoint_id,conversation_id,issue_id,idempotency_key,payload,state,attempts) VALUES (${publicationId},${companyId},${endpointId},${conversationId},${issueId},'existing-unknown-file','{"attachmentIds":["existing-attachment"]}'::jsonb,'delivery_unknown',1)`;
const rowsBefore =
await sql`SELECT row_to_json(p) AS row FROM chat_publications p WHERE id = ${publicationId}`;
const historyBefore =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
expect(
await inspectMigrations(database.connectionString),
).toMatchObject({
status: "needsMigrations",
pendingMigrations: identityMigrations,
});
await applyPendingMigrations(database.connectionString);
expect(
(await inspectMigrations(database.connectionString)).status,
).toBe("upToDate");
expect(
await sql`SELECT row_to_json(p) AS row FROM chat_publications p WHERE id = ${publicationId}`,
).toEqual(rowsBefore);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations WHERE id <= ${historyBefore.at(-1)!.id} ORDER BY id`,
).toEqual(historyBefore);
expect(
(
await sql`SELECT count(*)::integer AS count FROM drizzle.__drizzle_migrations`
)[0].count,
).toBe(historyBefore.length + identityMigrations.length);
expect(
(
await sql`SELECT origin_identity_context_id,continuation_identity_context_id FROM issues WHERE id = ${issueId}`
)[0],
).toEqual({
origin_identity_context_id: null,
continuation_identity_context_id: null,
});
const historyAfter =
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`;
await applyPendingMigrations(database.connectionString);
expect(
await sql`SELECT id,hash,created_at::text FROM drizzle.__drizzle_migrations ORDER BY id`,
).toEqual(historyAfter);
} finally {
await sql.end();
await database.cleanup();
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
},
);

View File

@ -0,0 +1,95 @@
import postgres from "postgres";
import { afterEach, describe, expect, it } from "vitest";
import {
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const cleanups: Array<() => Promise<void>> = [];
const support = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = support.supported ? describe : describe.skip;
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
describeEmbeddedPostgres("Telegram draft ID migration", () => {
it(
"never reuses draft IDs across rollback, concurrent allocation, or exhaustion",
async () => {
// Exhaustion is deliberately tested only in this disposable cluster, not
// in a shared fixture database or the configured application database.
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-telegram-draft-ids-",
);
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, {
max: 8,
onnotice: () => {},
});
cleanups.push(async () => sql.end());
const [configuration] = await sql`
SELECT seqstart::integer AS start, seqmin::integer AS min,
seqmax::integer AS max, seqincrement::integer AS increment,
seqcache::integer AS cache, seqcycle AS cycle
FROM pg_sequence WHERE seqrelid = 'public.chat_telegram_draft_ids'::regclass
`;
expect(configuration).toEqual({
start: 1,
min: 1,
max: 2_147_483_647,
increment: 1,
cache: 1,
cycle: false,
});
// No table owns this content-free sequence: deleting a company or its
// endpoint cannot cascade away the non-reuse boundary.
const dependencies = await sql`
SELECT 1 FROM pg_depend
WHERE classid = 'pg_class'::regclass
AND objid = 'public.chat_telegram_draft_ids'::regclass
AND deptype IN ('a', 'i')
`;
expect(dependencies).toHaveLength(0);
let rolledBackId = 0;
await expect(
sql.begin(async (transaction) => {
const [row] = await transaction`
SELECT nextval('public.chat_telegram_draft_ids')::integer AS id
`;
rolledBackId = row.id;
throw new Error("deliberate draft ownership transaction rollback");
}),
).rejects.toThrow("deliberate draft ownership transaction rollback");
expect(rolledBackId).toBe(1);
const allocated = await Promise.all(
Array.from({ length: 64 }, async () => {
const [row] = await sql`
SELECT nextval('public.chat_telegram_draft_ids')::integer AS id
`;
return row.id as number;
}),
);
expect(allocated.sort((a, b) => a - b)).toEqual(
Array.from({ length: 64 }, (_, index) => rolledBackId + index + 1),
);
await sql`SELECT setval('public.chat_telegram_draft_ids', 2147483647, false)`;
const [last] =
await sql`SELECT nextval('public.chat_telegram_draft_ids')::integer AS id`;
expect(last.id).toBe(2_147_483_647);
for (let attempt = 0; attempt < 2; attempt += 1) {
await expect(
sql`SELECT nextval('public.chat_telegram_draft_ids')`,
).rejects.toMatchObject({
code: "2200H",
});
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
});

View File

@ -0,0 +1,196 @@
import { randomUUID } from "node:crypto";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import {
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const describeDatabase = support.supported ? describe : describe.skip;
describeDatabase("chat tenant foreign keys", () => {
it(
"rejects cross-company references without breaking nullable-link deletion",
async () => {
const database = await startEmbeddedPostgresTestDatabase(
"paperclip-chat-tenant-fks-",
);
const sql = postgres(database.connectionString, {
max: 1,
onnotice: () => {},
});
try {
const make = async () => {
const ids = Object.fromEntries(
[
"company",
"agent",
"application",
"connection",
"endpoint",
"issue",
"comment",
"conversation",
"delivery",
"publication",
"principal",
"action",
"link",
].map((key) => [key, randomUUID()]),
);
await sql`INSERT INTO companies (id,name,issue_prefix) VALUES (${ids.company},'Tenant fixture',${ids.company})`;
await sql`INSERT INTO agents (id,company_id,name) VALUES (${ids.agent},${ids.company},'Agent')`;
await sql`INSERT INTO tool_applications (id,company_id,name,type) VALUES (${ids.application},${ids.company},'Fixture','mcp_http')`;
await sql`INSERT INTO tool_connections (id,company_id,application_id,uid,name,transport) VALUES (${ids.connection},${ids.company},${ids.application},${randomUUID()},'Fixture','rest_api')`;
await sql`INSERT INTO chat_endpoints (id,company_id,connection_id,provider,public_id,assigned_agent_id) VALUES (${ids.endpoint},${ids.company},${ids.connection},'slack',${randomUUID()},${ids.agent})`;
await sql`INSERT INTO issues (id,company_id,title) VALUES (${ids.issue},${ids.company},'Fixture')`;
await sql`INSERT INTO issue_comments (id,company_id,issue_id,body) VALUES (${ids.comment},${ids.company},${ids.issue},'Fixture')`;
await sql`INSERT INTO chat_conversations (id,company_id,endpoint_id,issue_id,external_conversation_id,external_label) VALUES (${ids.conversation},${ids.company},${ids.endpoint},${ids.issue},'C1','Fixture')`;
await sql`INSERT INTO chat_external_principals (id,company_id,provider,provider_account_id,external_id,kind,display_name) VALUES (${ids.principal},${ids.company},'slack','T1','U1','user','Fixture')`;
await sql`INSERT INTO chat_deliveries (id,company_id,endpoint_id,conversation_id,principal_id,provider_event_id,deduplication_key,event_kind,normalized_event) VALUES (${ids.delivery},${ids.company},${ids.endpoint},${ids.conversation},${ids.principal},'E1','E1','message','{}')`;
await sql`INSERT INTO chat_publications (id,company_id,endpoint_id,conversation_id,issue_id,comment_id,idempotency_key,payload) VALUES (${ids.publication},${ids.company},${ids.endpoint},${ids.conversation},${ids.issue},${ids.comment},'P1','{}')`;
await sql`INSERT INTO chat_actions (id,company_id,endpoint_id,delivery_id,conversation_id,principal_id,kind,provider_action_id) VALUES (${ids.action},${ids.company},${ids.endpoint},${ids.delivery},${ids.conversation},${ids.principal},'fixture','A1')`;
await sql`INSERT INTO chat_message_links (id,company_id,endpoint_id,conversation_id,delivery_id,publication_id,comment_id,provider_message_id,direction) VALUES (${ids.link},${ids.company},${ids.endpoint},${ids.conversation},${ids.delivery},${ids.publication},${ids.comment},'M1','outbound')`;
return ids;
};
const own = await make();
const foreign = await make();
const references = [
["chat_endpoints", "assigned_agent_id", "endpoint", "agent"],
["chat_conversations", "issue_id", "conversation", "issue"],
["chat_publications", "issue_id", "publication", "issue"],
["chat_publications", "comment_id", "publication", "comment"],
["chat_message_links", "endpoint_id", "link", "endpoint"],
["chat_message_links", "delivery_id", "link", "delivery"],
["chat_message_links", "publication_id", "link", "publication"],
["chat_message_links", "comment_id", "link", "comment"],
["chat_actions", "delivery_id", "action", "delivery"],
["chat_actions", "conversation_id", "action", "conversation"],
["chat_actions", "principal_id", "action", "principal"],
];
for (const [table, column, row, target] of references) {
await expect(
sql.unsafe(`UPDATE "${table}" SET "${column}" = $1 WHERE id = $2`, [
foreign[target],
own[row],
]),
`${table}.${column}`,
).rejects.toMatchObject({ code: "23503" });
// A same-company reference remains valid after the rejected statement.
await sql.unsafe(
`UPDATE "${table}" SET "${column}" = $1 WHERE id = $2`,
[own[target], own[row]],
);
}
await sql`DELETE FROM issue_comments WHERE id = ${own.comment}`;
await sql`DELETE FROM chat_publications WHERE id = ${own.publication}`;
await sql`DELETE FROM chat_deliveries WHERE id = ${own.delivery}`;
const [link] =
await sql`SELECT company_id, comment_id, publication_id, delivery_id FROM chat_message_links WHERE id = ${own.link}`;
expect(link).toEqual({
company_id: own.company,
comment_id: null,
publication_id: null,
delivery_id: null,
});
const [action] =
await sql`SELECT company_id, delivery_id FROM chat_actions WHERE id = ${own.action}`;
expect(action).toEqual({ company_id: own.company, delivery_id: null });
// Durable action references retain their original conversation/principal;
// unlike delivery_id these parents had no legacy SET NULL behavior.
await expect(
sql`DELETE FROM chat_conversations WHERE id = ${own.conversation}`,
).rejects.toMatchObject({ code: "23503" });
await expect(
sql`DELETE FROM chat_external_principals WHERE id = ${own.principal}`,
).rejects.toMatchObject({ code: "23503" });
await expect(
sql`DELETE FROM agents WHERE id = ${own.agent}`,
).rejects.toMatchObject({ code: "23001" });
// Existing chat history deliberately restricts deleting its task.
await expect(
sql`DELETE FROM issues WHERE id = ${own.issue}`,
).rejects.toMatchObject({ code: "23001" });
const [conversation] =
await sql`SELECT company_id, issue_id FROM chat_conversations WHERE id = ${own.conversation}`;
expect(conversation).toEqual({
company_id: own.company,
issue_id: own.issue,
});
// Existing company deletion requires its service's explicit child cleanup.
// A direct rejected delete must not partially cascade into chat history.
await expect(
sql`DELETE FROM companies WHERE id = ${foreign.company}`,
).rejects.toMatchObject({ code: "23503" });
expect(
await sql`SELECT id FROM chat_actions WHERE id = ${foreign.action}`,
).toHaveLength(1);
// This tests the endpoint/conversation ownership graph with no resource
// binding; it does not qualify preexisting resource SET NULL constraints.
await sql`DELETE FROM chat_endpoints WHERE id = ${foreign.endpoint}`;
for (const table of [
"chat_endpoints",
"chat_conversations",
"chat_deliveries",
"chat_publications",
"chat_actions",
"chat_message_links",
]) {
expect(
await sql.unsafe(
`SELECT id FROM "${table}" WHERE company_id = $1`,
[foreign.company],
),
table,
).toHaveLength(0);
}
expect(
await sql`SELECT id FROM chat_actions WHERE id = ${own.action}`,
).toHaveLength(1);
const nullable = await make();
await sql`DELETE FROM chat_actions WHERE id = ${nullable.action}`;
const resourceId = randomUUID();
await sql`INSERT INTO chat_endpoint_resources (id,company_id,endpoint_id,type,provider_resource_id,label) VALUES (${resourceId},${nullable.company},${nullable.endpoint},'channel','C1','Fixture')`;
await sql`UPDATE chat_conversations SET resource_id = ${resourceId} WHERE id = ${nullable.conversation}`;
await sql`DELETE FROM chat_endpoint_resources WHERE id = ${resourceId}`;
expect(
(
await sql`SELECT company_id, resource_id FROM chat_conversations WHERE id = ${nullable.conversation}`
)[0],
).toEqual({ company_id: nullable.company, resource_id: null });
await sql`DELETE FROM chat_conversations WHERE id = ${nullable.conversation}`;
expect(
(
await sql`SELECT company_id, conversation_id FROM chat_deliveries WHERE id = ${nullable.delivery}`
)[0],
).toEqual({ company_id: nullable.company, conversation_id: null });
await sql`DELETE FROM chat_external_principals WHERE id = ${nullable.principal}`;
expect(
(
await sql`SELECT company_id, principal_id FROM chat_deliveries WHERE id = ${nullable.delivery}`
)[0],
).toEqual({ company_id: nullable.company, principal_id: null });
const resourceBound = await make();
const boundResourceId = randomUUID();
await sql`INSERT INTO chat_endpoint_resources (id,company_id,endpoint_id,type,provider_resource_id,label) VALUES (${boundResourceId},${resourceBound.company},${resourceBound.endpoint},'channel','C1','Fixture')`;
await sql`UPDATE chat_conversations SET resource_id = ${boundResourceId} WHERE id = ${resourceBound.conversation}`;
await sql`DELETE FROM chat_endpoints WHERE id = ${resourceBound.endpoint}`;
expect(
await sql`SELECT id FROM chat_conversations WHERE company_id = ${resourceBound.company}`,
).toHaveLength(0);
expect(
await sql`SELECT id FROM chat_message_links WHERE company_id = ${resourceBound.company}`,
).toHaveLength(0);
} finally {
try {
await sql.end();
} finally {
await database.cleanup();
}
}
},
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
);
});

View File

@ -1,11 +1,17 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
DEFAULT_DATABASE_APPLICATION_NAME,
applyPendingMigrations,
closeRegisteredClients,
createDb,
ensurePostgresDatabase,
inspectMigrations,
resetPostgresDatabase,
} from "./client.js";
@ -1491,8 +1497,22 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
it(
"preserves legacy runs while adding native persistence and replay-safe status versioning",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const clusterUrl = await createTempDatabase();
await ensurePostgresDatabase(clusterUrl, "native_legacy");
const legacyUrl = new URL(clusterUrl);
legacyUrl.pathname = "/native_legacy";
const connectionString = legacyUrl.href;
cleanups.push(() => closeRegisteredClients(connectionString));
const directory = await fs.promises.mkdtemp(join(tmpdir(), "paperclip-native-prior-migrations-"));
cleanups.push(() => fs.promises.rm(directory, { recursive: true, force: true }));
const migrationsRoot = new URL("./migrations/", import.meta.url);
const journal = JSON.parse(await fs.promises.readFile(new URL("meta/_journal.json", migrationsRoot), "utf8"));
const priorEntries = journal.entries.filter((entry: { idx: number }) => entry.idx < 227);
await fs.promises.mkdir(join(directory, "meta"));
for (const entry of priorEntries) {
await fs.promises.copyFile(new URL(`${entry.tag}.sql`, migrationsRoot), join(directory, `${entry.tag}.sql`));
}
await fs.promises.writeFile(join(directory, "meta/_journal.json"), JSON.stringify({ ...journal, entries: priorEntries }));
const nativePersistenceHash = await migrationHash("0227_modern_pandemic.sql");
const eventSequenceUniquenessHash = await migrationHash(
@ -1517,44 +1537,10 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
const otherDecisionId = "81000000-0000-4000-8000-000000000227";
try {
await sql.unsafe(`
DROP TABLE IF EXISTS status_decision_effects, status_decisions, work_assessments,
native_run_finalizations, native_run_results, completion_contracts CASCADE;
DROP TRIGGER IF EXISTS paperclip_issue_status_version_trigger ON issues;
DROP FUNCTION IF EXISTS paperclip_bump_issue_status_version();
DROP INDEX IF EXISTS issues_company_id_uq;
DROP INDEX IF EXISTS heartbeat_run_events_run_source_event_uq;
DROP INDEX IF EXISTS heartbeat_run_events_run_source_seq_uq;
DROP INDEX IF EXISTS heartbeat_run_events_run_seq_uq;
ALTER TABLE heartbeat_run_events
DROP COLUMN IF EXISTS source_instance_id,
DROP COLUMN IF EXISTS source_event_id,
DROP COLUMN IF EXISTS source_seq,
DROP COLUMN IF EXISTS source_payload_sha256,
DROP COLUMN IF EXISTS protocol_schema_version;
ALTER TABLE heartbeat_run_events ALTER COLUMN seq TYPE integer;
ALTER TABLE heartbeat_runs
DROP COLUMN IF EXISTS runtime_mode,
DROP COLUMN IF EXISTS runtime_mode_resolver_version,
DROP COLUMN IF EXISTS runtime_mode_reason,
DROP COLUMN IF EXISTS runtime_mode_resolved_at,
DROP COLUMN IF EXISTS runner_profile_json,
DROP COLUMN IF EXISTS runner_instance_id,
DROP COLUMN IF EXISTS native_session_id,
DROP COLUMN IF EXISTS native_issue_id,
DROP COLUMN IF EXISTS driver_kind,
DROP COLUMN IF EXISTS driver_version,
DROP COLUMN IF EXISTS completion_contract_id,
DROP COLUMN IF EXISTS completion_contract_sha256,
DROP COLUMN IF EXISTS next_event_seq,
DROP COLUMN IF EXISTS native_phase,
DROP COLUMN IF EXISTS native_phase_updated_at;
ALTER TABLE issues
DROP COLUMN IF EXISTS status_version,
DROP COLUMN IF EXISTS last_status_decision_id;
`);
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${nativePersistenceHash}`;
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${eventSequenceUniquenessHash}`;
// Build the real pre-native schema. Downgrading the latest schema by
// dropping its unique index is invalid once later tenant FKs use it.
await migrate(drizzle(sql), { migrationsFolder: directory });
expect(await sql`SELECT to_regclass('public.native_run_results') AS native_results`).toEqual([{ native_results: null }]);
await sql`
INSERT INTO companies (id, name, issue_prefix)
VALUES (${companyId}, 'Native persistence fixture', 'NPF')

View File

@ -24,6 +24,17 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
const sql = postgres(database.connectionString, { max: 1 });
cleanups.push(async () => sql.end());
// The fixture starts at the latest schema. Rewind the later chat FK before
// exercising migration 0182's composite connection key; never CASCADE away
// unknown dependencies or change the production constraint for this test.
const [chatForeignKey] = await sql<{ definition: string }[]>`
SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint
WHERE conrelid = 'chat_endpoints'::regclass
AND conname = 'chat_endpoints_company_connection_fk'
`;
expect(chatForeignKey?.definition).toBeTruthy();
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP TABLE IF EXISTS "connection_grant_delegations"`;
await sql`DROP TABLE IF EXISTS "connection_grant_members"`;
@ -49,6 +60,9 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
`;
await applyPendingMigrations(database.connectionString);
await sql.unsafe(
`ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_company_connection_fk" ${chatForeignKey!.definition}`,
);
const [connection] = await sql<{ uid: string; ownership: string; transport: string; auth_kind: string }[]>`
SELECT "uid", "ownership", "transport", "auth_kind" FROM "tool_connections" WHERE "id" = ${connectionId}
@ -73,6 +87,7 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
VALUES (${companyId}, ${connectionId}, 'user', 'user-1', true)
`).rejects.toMatchObject({ code: "23514" });
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
await sql`DROP TABLE IF EXISTS "connection_grant_members"`;
await sql`DROP TABLE "connection_grants"`;
await sql`DROP INDEX "tool_connections_company_uid_uq"`;

View File

@ -0,0 +1,290 @@
CREATE TABLE "chat_actions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"delivery_id" uuid,
"conversation_id" uuid,
"principal_id" uuid,
"kind" text NOT NULL,
"provider_action_id" text NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"status" text DEFAULT 'received' NOT NULL,
"result" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "chat_agent_routes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"source_endpoint_id" uuid NOT NULL,
"destination_endpoint_id" uuid NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"trigger_mode" text DEFAULT 'explicit_mention' NOT NULL,
"max_hops" integer DEFAULT 1 NOT NULL,
"created_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_agent_routes_hops_check" CHECK ("chat_agent_routes"."max_hops" between 1 and 8)
);
--> statement-breakpoint
CREATE TABLE "chat_conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"resource_id" uuid,
"issue_id" uuid NOT NULL,
"external_conversation_id" text NOT NULL,
"external_thread_id" text DEFAULT '' NOT NULL,
"external_label" text NOT NULL,
"provider_url" text,
"is_direct_message" boolean DEFAULT false NOT NULL,
"state" text DEFAULT 'active' NOT NULL,
"last_activity_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_conversations_company_id_uq" UNIQUE("company_id","id"),
CONSTRAINT "chat_conversations_state_check" CHECK ("chat_conversations"."state" in ('active', 'waiting', 'completed', 'unavailable', 'endpoint_removed'))
);
--> statement-breakpoint
CREATE TABLE "chat_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"conversation_id" uuid,
"principal_id" uuid,
"provider_event_id" text NOT NULL,
"deduplication_key" text NOT NULL,
"event_kind" text NOT NULL,
"normalized_event" jsonb NOT NULL,
"state" text DEFAULT 'received' NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"redacted_error" text,
"next_attempt_at" timestamp with time zone,
"received_at" timestamp with time zone DEFAULT now() NOT NULL,
"processed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_deliveries_state_check" CHECK ("chat_deliveries"."state" in ('received', 'filtered', 'processing', 'processed', 'retry', 'failed'))
);
--> statement-breakpoint
CREATE TABLE "chat_endpoint_leases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"lease_key" text NOT NULL,
"token" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "chat_endpoint_resources" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"type" text NOT NULL,
"provider_resource_id" text NOT NULL,
"parent_provider_resource_id" text,
"label" text NOT NULL,
"detail" text,
"provider_url" text,
"availability" text DEFAULT 'available' NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_endpoint_resources_company_id_uq" UNIQUE("company_id","id"),
CONSTRAINT "chat_endpoint_resources_availability_check" CHECK ("chat_endpoint_resources"."availability" in ('available', 'unavailable', 'removed'))
);
--> statement-breakpoint
CREATE TABLE "chat_endpoints" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"connection_id" uuid NOT NULL,
"provider" text NOT NULL,
"public_id" text NOT NULL,
"assigned_agent_id" uuid NOT NULL,
"sponsor_user_id" text,
"status" text DEFAULT 'draft' NOT NULL,
"deployment_mode" text DEFAULT 'direct' NOT NULL,
"provider_account_id" text,
"provider_account_label" text,
"bot_external_id" text,
"bot_username" text,
"bot_display_name" text,
"bot_avatar_url" text,
"allow_direct_messages" boolean DEFAULT true NOT NULL,
"allow_group_chats" boolean DEFAULT true NOT NULL,
"allow_unlinked_people" boolean DEFAULT true NOT NULL,
"concurrency_policy" text DEFAULT 'queue' NOT NULL,
"capabilities" jsonb DEFAULT '{"threads":false,"directMessages":false,"nativeStreaming":false,"messageEdits":false,"messageDeletes":false,"reactions":false,"files":false,"cards":false,"actions":false,"modals":false,"slashCommands":false,"ephemeralMessages":false,"proactiveDirectMessages":false}'::jsonb NOT NULL,
"setup" jsonb DEFAULT '{"step":"provider_setup"}'::jsonb NOT NULL,
"health_message" text,
"last_event_at" timestamp with time zone,
"last_publication_at" timestamp with time zone,
"last_error" text,
"activated_at" timestamp with time zone,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_endpoints_company_id_uq" UNIQUE("company_id","id"),
CONSTRAINT "chat_endpoints_provider_check" CHECK ("chat_endpoints"."provider" in ('slack', 'github', 'microsoft-teams', 'telegram')),
CONSTRAINT "chat_endpoints_status_check" CHECK ("chat_endpoints"."status" in ('draft', 'verifying', 'active', 'paused', 'attention', 'revoked', 'archived')),
CONSTRAINT "chat_endpoints_deployment_check" CHECK ("chat_endpoints"."deployment_mode" in ('direct', 'relay')),
CONSTRAINT "chat_endpoints_concurrency_check" CHECK ("chat_endpoints"."concurrency_policy" in ('burst', 'queue', 'debounce', 'drop', 'concurrent'))
);
--> statement-breakpoint
CREATE TABLE "chat_external_principals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"provider" text NOT NULL,
"provider_account_id" text NOT NULL,
"external_id" text NOT NULL,
"kind" text DEFAULT 'user' NOT NULL,
"display_name" text,
"handle" text,
"avatar_url" text,
"is_bot" boolean DEFAULT false NOT NULL,
"last_seen_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_external_principals_company_id_uq" UNIQUE("company_id","id"),
CONSTRAINT "chat_external_principals_provider_check" CHECK ("chat_external_principals"."provider" in ('slack', 'github', 'microsoft-teams', 'telegram')),
CONSTRAINT "chat_external_principals_kind_check" CHECK ("chat_external_principals"."kind" in ('user', 'bot', 'app', 'system'))
);
--> statement-breakpoint
CREATE TABLE "chat_identity_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"principal_id" uuid NOT NULL,
"paperclip_user_id" text,
"status" text DEFAULT 'pending' NOT NULL,
"confirmation_token_hash" text,
"expires_at" timestamp with time zone,
"confirmed_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_identity_links_status_check" CHECK ("chat_identity_links"."status" in ('pending', 'linked', 'revoked', 'expired'))
);
--> statement-breakpoint
CREATE TABLE "chat_message_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"conversation_id" uuid NOT NULL,
"delivery_id" uuid,
"publication_id" uuid,
"comment_id" uuid,
"provider_message_id" text NOT NULL,
"direction" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_message_links_direction_check" CHECK ("chat_message_links"."direction" in ('inbound', 'outbound'))
);
--> statement-breakpoint
CREATE TABLE "chat_publications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"conversation_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"comment_id" uuid,
"idempotency_key" text NOT NULL,
"payload" jsonb NOT NULL,
"state" text DEFAULT 'pending' NOT NULL,
"provider_message_id" text,
"provider_url" text,
"attempts" integer DEFAULT 0 NOT NULL,
"redacted_error" text,
"next_attempt_at" timestamp with time zone,
"published_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_publications_state_check" CHECK ("chat_publications"."state" in ('pending', 'streaming', 'published', 'retry', 'failed', 'cancelled'))
);
--> statement-breakpoint
CREATE TABLE "chat_sdk_state" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"state_key" text NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"value" jsonb NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_transport_check";--> statement-breakpoint
ALTER TABLE "tool_connections" ADD COLUMN "connection_purpose" text DEFAULT 'tool' NOT NULL;--> statement-breakpoint
ALTER TABLE "chat_actions" ADD CONSTRAINT "chat_actions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_actions" ADD CONSTRAINT "chat_actions_delivery_id_chat_deliveries_id_fk" FOREIGN KEY ("delivery_id") REFERENCES "public"."chat_deliveries"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_actions" ADD CONSTRAINT "chat_actions_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_agent_routes" ADD CONSTRAINT "chat_agent_routes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_agent_routes" ADD CONSTRAINT "chat_agent_routes_company_source_fk" FOREIGN KEY ("company_id","source_endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_agent_routes" ADD CONSTRAINT "chat_agent_routes_company_destination_fk" FOREIGN KEY ("company_id","destination_endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_company_resource_fk" FOREIGN KEY ("company_id","resource_id") REFERENCES "public"."chat_endpoint_resources"("company_id","id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_company_conversation_fk" FOREIGN KEY ("company_id","conversation_id") REFERENCES "public"."chat_conversations"("company_id","id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_company_principal_fk" FOREIGN KEY ("company_id","principal_id") REFERENCES "public"."chat_external_principals"("company_id","id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoint_leases" ADD CONSTRAINT "chat_endpoint_leases_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoint_leases" ADD CONSTRAINT "chat_endpoint_leases_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoint_resources" ADD CONSTRAINT "chat_endpoint_resources_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoint_resources" ADD CONSTRAINT "chat_endpoint_resources_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_assigned_agent_id_agents_id_fk" FOREIGN KEY ("assigned_agent_id") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_company_connection_fk" FOREIGN KEY ("company_id","connection_id") REFERENCES "public"."tool_connections"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_external_principals" ADD CONSTRAINT "chat_external_principals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_identity_links" ADD CONSTRAINT "chat_identity_links_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_identity_links" ADD CONSTRAINT "chat_identity_links_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_identity_links" ADD CONSTRAINT "chat_identity_links_company_principal_fk" FOREIGN KEY ("company_id","principal_id") REFERENCES "public"."chat_external_principals"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_delivery_id_chat_deliveries_id_fk" FOREIGN KEY ("delivery_id") REFERENCES "public"."chat_deliveries"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_publication_id_chat_publications_id_fk" FOREIGN KEY ("publication_id") REFERENCES "public"."chat_publications"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_comment_id_issue_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_company_conversation_fk" FOREIGN KEY ("company_id","conversation_id") REFERENCES "public"."chat_conversations"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_comment_id_issue_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_company_conversation_fk" FOREIGN KEY ("company_id","conversation_id") REFERENCES "public"."chat_conversations"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_sdk_state" ADD CONSTRAINT "chat_sdk_state_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_sdk_state" ADD CONSTRAINT "chat_sdk_state_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "chat_actions_provider_action_uq" ON "chat_actions" USING btree ("endpoint_id","provider_action_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_agent_routes_pair_uq" ON "chat_agent_routes" USING btree ("source_endpoint_id","destination_endpoint_id");--> statement-breakpoint
CREATE INDEX "chat_conversations_issue_idx" ON "chat_conversations" USING btree ("company_id","issue_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_conversations_thread_uq" ON "chat_conversations" USING btree ("endpoint_id","external_conversation_id","external_thread_id");--> statement-breakpoint
CREATE INDEX "chat_deliveries_work_idx" ON "chat_deliveries" USING btree ("state","next_attempt_at");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_deliveries_event_uq" ON "chat_deliveries" USING btree ("endpoint_id","provider_event_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_deliveries_dedupe_uq" ON "chat_deliveries" USING btree ("endpoint_id","deduplication_key");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_endpoint_leases_active_uq" ON "chat_endpoint_leases" USING btree ("endpoint_id","lease_key");--> statement-breakpoint
CREATE INDEX "chat_endpoint_leases_expiry_idx" ON "chat_endpoint_leases" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "chat_endpoint_resources_endpoint_idx" ON "chat_endpoint_resources" USING btree ("company_id","endpoint_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_endpoint_resources_external_uq" ON "chat_endpoint_resources" USING btree ("endpoint_id","type","provider_resource_id");--> statement-breakpoint
CREATE INDEX "chat_endpoints_company_idx" ON "chat_endpoints" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "chat_endpoints_agent_idx" ON "chat_endpoints" USING btree ("company_id","assigned_agent_id");--> statement-breakpoint
CREATE INDEX "chat_endpoints_status_idx" ON "chat_endpoints" USING btree ("company_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_endpoints_public_id_uq" ON "chat_endpoints" USING btree ("public_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_endpoints_connection_uq" ON "chat_endpoints" USING btree ("connection_id");--> statement-breakpoint
CREATE INDEX "chat_external_principals_company_idx" ON "chat_external_principals" USING btree ("company_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_external_principals_external_uq" ON "chat_external_principals" USING btree ("company_id","provider","provider_account_id","external_id");--> statement-breakpoint
CREATE INDEX "chat_identity_links_user_idx" ON "chat_identity_links" USING btree ("company_id","paperclip_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_identity_links_endpoint_principal_uq" ON "chat_identity_links" USING btree ("endpoint_id","principal_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_message_links_provider_message_uq" ON "chat_message_links" USING btree ("endpoint_id","provider_message_id");--> statement-breakpoint
CREATE INDEX "chat_publications_work_idx" ON "chat_publications" USING btree ("state","next_attempt_at");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_publications_idempotency_uq" ON "chat_publications" USING btree ("company_id","idempotency_key");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_sdk_state_key_uq" ON "chat_sdk_state" USING btree ("endpoint_id","state_key");--> statement-breakpoint
CREATE INDEX "chat_sdk_state_expiry_idx" ON "chat_sdk_state" USING btree ("expires_at");--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_purpose_check" CHECK ("tool_connections"."connection_purpose" in ('tool', 'channel'));--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_channel_transport_check" CHECK ((
("tool_connections"."connection_purpose" = 'tool' and "tool_connections"."transport" <> 'chat_sdk')
or
("tool_connections"."connection_purpose" = 'channel' and "tool_connections"."transport" = 'chat_sdk')
));--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_transport_check" CHECK ("tool_connections"."transport" in ('mcp_remote', 'rest_api', 'local_stdio', 'chat_sdk'));

View File

@ -0,0 +1,3 @@
DROP INDEX "chat_conversations_thread_uq";--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD COLUMN "session_generation" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "chat_conversations_thread_uq" ON "chat_conversations" USING btree ("endpoint_id","external_conversation_id","external_thread_id","session_generation");

View File

@ -0,0 +1,8 @@
DROP INDEX "chat_message_links_provider_message_uq";--> statement-breakpoint
CREATE UNIQUE INDEX "chat_endpoints_live_bot_external_uq" ON "chat_endpoints" USING btree ("provider","provider_account_id","bot_external_id") WHERE "chat_endpoints"."status" in ('verifying', 'active', 'paused', 'attention')
and "chat_endpoints"."provider_account_id" is not null
and "chat_endpoints"."bot_external_id" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "chat_endpoints_live_bot_username_uq" ON "chat_endpoints" USING btree ("provider","provider_account_id","bot_username") WHERE "chat_endpoints"."status" in ('verifying', 'active', 'paused', 'attention')
and "chat_endpoints"."provider_account_id" is not null
and "chat_endpoints"."bot_username" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "chat_message_links_provider_message_uq" ON "chat_message_links" USING btree ("endpoint_id","conversation_id","provider_message_id");

View File

@ -0,0 +1,2 @@
ALTER TABLE "chat_publications" DROP CONSTRAINT "chat_publications_state_check";--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_state_check" CHECK ("chat_publications"."state" in ('pending', 'streaming', 'published', 'retry', 'delivery_unknown', 'failed', 'cancelled'));

View File

@ -0,0 +1 @@
ALTER TABLE "chat_endpoints" ALTER COLUMN "allow_group_chats" SET DEFAULT false;

View File

@ -0,0 +1,106 @@
-- The old partial index does not cover interaction:* keys, so this is
-- necessarily one prefix scan of agent_wakeup_requests. Drizzle applies the
-- migration transactionally: writers cannot observe the repair without the
-- replacement index, and the index cannot admit a new duplicate before the
-- repair commits. A temporary helper index would require the same large-table
-- scan while adding another transactional DDL lock, so the selective prefix
-- scan is the lower-lock upgrade path.
--
-- Preserve the most meaningful run-backed wake as the canonical key holder.
-- Terminal and actively executing duplicates keep their status and run link;
-- they are re-keyed outside the canonical namespace with audit metadata. Only
-- duplicate work that has not acquired a run and is safe to retire is marked
-- skipped. In particular, claimed/running work is never falsely cancelled.
WITH ranked AS (
SELECT
"id",
"idempotency_key" AS "original_idempotency_key",
"status" AS "previous_status",
"run_id" AS "linked_run_id",
first_value("id") OVER (
PARTITION BY "company_id", "idempotency_key"
ORDER BY
CASE
WHEN "run_id" IS NOT NULL AND "status" IN ('succeeded', 'completed', 'coalesced') THEN 0
WHEN "run_id" IS NOT NULL AND "status" IN ('running', 'claimed') THEN 1
WHEN "run_id" IS NOT NULL THEN 2
WHEN "status" IN ('running', 'claimed') THEN 3
WHEN "status" IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry') THEN 4
ELSE 5
END,
"requested_at" ASC,
"created_at" ASC,
"id" ASC
) AS "retained_id",
row_number() OVER (
PARTITION BY "company_id", "idempotency_key"
ORDER BY
CASE
WHEN "run_id" IS NOT NULL AND "status" IN ('succeeded', 'completed', 'coalesced') THEN 0
WHEN "run_id" IS NOT NULL AND "status" IN ('running', 'claimed') THEN 1
WHEN "run_id" IS NOT NULL THEN 2
WHEN "status" IN ('running', 'claimed') THEN 3
WHEN "status" IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry') THEN 4
ELSE 5
END,
"requested_at" ASC,
"created_at" ASC,
"id" ASC
) AS "ordinal"
FROM "agent_wakeup_requests"
WHERE "idempotency_key" LIKE 'interaction:%'
AND "status" NOT IN ('skipped', 'failed', 'cancelled')
), duplicates AS (
SELECT * FROM ranked WHERE "ordinal" > 1
)
UPDATE "agent_wakeup_requests" AS wake
SET
"idempotency_key" = CASE
WHEN duplicates."linked_run_id" IS NULL
AND duplicates."previous_status" IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry')
THEN duplicates."original_idempotency_key"
ELSE 'historical-interaction-wake-duplicate:' || wake."id"::text
END,
"status" = CASE
WHEN duplicates."linked_run_id" IS NULL
AND duplicates."previous_status" IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry')
THEN 'skipped'
ELSE wake."status"
END,
"finished_at" = CASE
WHEN duplicates."linked_run_id" IS NULL
AND duplicates."previous_status" IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry')
THEN COALESCE(wake."finished_at", now())
ELSE wake."finished_at"
END,
"error" = CASE
WHEN duplicates."linked_run_id" IS NULL
AND duplicates."previous_status" IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry')
THEN concat_ws(
E'\n',
NULLIF(wake."error", ''),
'Safely retired duplicate by migration 0245; retained wake request ' || duplicates."retained_id"::text
)
ELSE wake."error"
END,
"payload" = COALESCE(wake."payload", '{}'::jsonb) || jsonb_build_object(
'migrationDedupe', jsonb_build_object(
'migration', '0245_chat_interaction_wakeup_idempotency',
'retainedWakeRequestId', duplicates."retained_id",
'originalIdempotencyKey', duplicates."original_idempotency_key",
'previousStatus', duplicates."previous_status",
'linkedRunId', duplicates."linked_run_id",
'resolution', CASE
WHEN duplicates."linked_run_id" IS NULL
AND duplicates."previous_status" IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry')
THEN 'retired_unstarted_duplicate'
ELSE 'rekeyed_preserving_execution_history'
END
)
),
"updated_at" = now()
FROM duplicates
WHERE wake."id" = duplicates."id";--> statement-breakpoint
DROP INDEX IF EXISTS "agent_wakeup_requests_question_response_delivery_idempotency_uq";--> statement-breakpoint
-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable. The selective duplicate repair above preserves historical evidence and the expanded predicate must commit atomically before board and external-chat resolvers share the canonical interaction wake key.
CREATE UNIQUE INDEX IF NOT EXISTS "agent_wakeup_requests_question_response_delivery_idempotency_uq" ON "agent_wakeup_requests" USING btree ("company_id","idempotency_key") WHERE ("agent_wakeup_requests"."idempotency_key" LIKE 'question-response:%' OR "agent_wakeup_requests"."idempotency_key" LIKE 'interaction:%') AND "agent_wakeup_requests"."status" NOT IN ('skipped', 'failed', 'cancelled');

View File

@ -0,0 +1,4 @@
ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_provider_check";--> statement-breakpoint
ALTER TABLE "chat_external_principals" DROP CONSTRAINT "chat_external_principals_provider_check";--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_provider_check" CHECK ("chat_endpoints"."provider" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram'));--> statement-breakpoint
ALTER TABLE "chat_external_principals" ADD CONSTRAINT "chat_external_principals_provider_check" CHECK ("chat_external_principals"."provider" in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram'));

View File

@ -0,0 +1,3 @@
CREATE UNIQUE INDEX "chat_endpoints_live_discord_bot_external_uq" ON "chat_endpoints" USING btree ("provider","bot_external_id") WHERE "chat_endpoints"."provider" = 'discord'
and "chat_endpoints"."status" in ('verifying', 'active', 'paused', 'attention')
and "chat_endpoints"."bot_external_id" is not null;

View File

@ -0,0 +1,3 @@
CREATE UNIQUE INDEX "chat_endpoints_live_global_app_bot_external_uq" ON "chat_endpoints" USING btree ("provider","bot_external_id") WHERE "chat_endpoints"."provider" in ('github', 'microsoft-teams')
and "chat_endpoints"."status" in ('verifying', 'active', 'paused', 'attention')
and "chat_endpoints"."bot_external_id" is not null;

View File

@ -0,0 +1,3 @@
ALTER TABLE "issue_attachments" ADD COLUMN "originating_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_attachments" ADD CONSTRAINT "issue_attachments_originating_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("originating_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_attachments_originating_run_idx" ON "issue_attachments" USING btree ("originating_run_id");

View File

@ -0,0 +1,80 @@
-- 0251 was originally deployed as 0245. Its SQL bytes must remain unchanged:
-- migration history recognizes that deployed repair by hash. Correct only its
-- stored provenance here, without replaying duplicate retirement or rekeying.
-- The primary-key cursor visits every row once, including unrelated-only
-- batches. Query/write batches are bounded, but locks still last until commit.
-- paperclip:migration-safety-ignore loop-mutation-large-table: Existing agent_wakeup_requests primary key supports the strictly advancing UUID cursor. Each batch selects at most 500 IDs and updates only matching IDs and exact legacy repair metadata.
-- paperclip:migration-safety-ignore batched-mutation-large-table-missing-index: Existing agent_wakeup_requests primary key supports ORDER BY id and id > last_id. No JSON predicate is used to search repeatedly for the next batch.
DO $provenance$
DECLARE
last_id uuid;
batch_ids uuid[];
old_prefix constant text := 'Safely retired duplicate by migration 0245; retained wake request ';
new_prefix constant text := 'Safely retired duplicate by migration 0251; retained wake request ';
uuid_pattern constant text := '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
BEGIN
LOOP
IF last_id IS NULL THEN
SELECT ARRAY(SELECT "id" FROM "agent_wakeup_requests" ORDER BY "id" LIMIT 500) INTO batch_ids;
ELSE
SELECT ARRAY(SELECT "id" FROM "agent_wakeup_requests" WHERE "id" > last_id ORDER BY "id" LIMIT 500) INTO batch_ids;
END IF;
EXIT WHEN cardinality(batch_ids) = 0;
UPDATE "agent_wakeup_requests" AS wake
SET
"payload" = jsonb_set(
wake."payload",
'{migrationDedupe,migration}',
'"0251_chat_interaction_wakeup_idempotency"'::jsonb,
false
),
"error" = CASE
WHEN wake."payload" #>> '{migrationDedupe,resolution}' = 'retired_unstarted_duplicate'
AND right(wake."error", length(old_prefix) + 36) = old_prefix || (wake."payload" #>> '{migrationDedupe,retainedWakeRequestId}')
AND (
length(wake."error") = length(old_prefix) + 36
OR substring(wake."error" FROM length(wake."error") - length(old_prefix) - 36 FOR 1) = E'\n'
)
THEN left(wake."error", length(wake."error") - length(old_prefix) - 36)
|| new_prefix || (wake."payload" #>> '{migrationDedupe,retainedWakeRequestId}')
ELSE wake."error"
END
WHERE wake."id" = ANY(batch_ids)
AND jsonb_typeof(wake."payload") = 'object'
AND jsonb_typeof(wake."payload" -> 'migrationDedupe') = 'object'
AND wake."payload" #>> '{migrationDedupe,migration}' = '0245_chat_interaction_wakeup_idempotency'
AND jsonb_typeof(wake."payload" #> '{migrationDedupe,retainedWakeRequestId}') = 'string'
AND (wake."payload" #>> '{migrationDedupe,retainedWakeRequestId}') ~ uuid_pattern
AND jsonb_typeof(wake."payload" #> '{migrationDedupe,originalIdempotencyKey}') = 'string'
AND wake."payload" #>> '{migrationDedupe,originalIdempotencyKey}' LIKE 'interaction:%'
AND jsonb_typeof(wake."payload" #> '{migrationDedupe,previousStatus}') = 'string'
AND wake."payload" #>> '{migrationDedupe,previousStatus}' NOT IN ('skipped', 'failed', 'cancelled')
AND (
wake."payload" #> '{migrationDedupe,linkedRunId}' = 'null'::jsonb
OR (
jsonb_typeof(wake."payload" #> '{migrationDedupe,linkedRunId}') = 'string'
AND (wake."payload" #>> '{migrationDedupe,linkedRunId}') ~ uuid_pattern
)
)
AND (
(
wake."payload" #>> '{migrationDedupe,resolution}' = 'retired_unstarted_duplicate'
AND wake."payload" #> '{migrationDedupe,linkedRunId}' = 'null'::jsonb
AND wake."payload" #>> '{migrationDedupe,previousStatus}' IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry')
AND wake."idempotency_key" = wake."payload" #>> '{migrationDedupe,originalIdempotencyKey}'
)
OR (
wake."payload" #>> '{migrationDedupe,resolution}' = 'rekeyed_preserving_execution_history'
AND NOT (
wake."payload" #> '{migrationDedupe,linkedRunId}' = 'null'::jsonb
AND wake."payload" #>> '{migrationDedupe,previousStatus}' IN ('queued', 'deferred_issue_execution', 'retrying', 'scheduled_retry')
)
AND wake."idempotency_key" = 'historical-interaction-wake-duplicate:' || wake."id"::text
)
);
last_id := batch_ids[cardinality(batch_ids)];
END LOOP;
END
$provenance$;

View File

@ -0,0 +1,56 @@
CREATE TABLE "chat_teams_file_transfers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"conversation_id" uuid NOT NULL,
"publication_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"comment_id" uuid NOT NULL,
"attachment_id" uuid NOT NULL,
"principal_id" uuid NOT NULL,
"authorized_user_id" text,
"runtime_generation" integer NOT NULL,
"credential_fingerprint" text NOT NULL,
"conversation_generation" integer NOT NULL,
"source_digest" text NOT NULL,
"authority_digest" text NOT NULL,
"tenant_id" uuid NOT NULL,
"bot_app_id" uuid NOT NULL,
"aad_object_id" uuid NOT NULL,
"provider_conversation_id" text NOT NULL,
"provider_user_id" text NOT NULL,
"sha256" text NOT NULL,
"byte_size" integer NOT NULL,
"filename" text NOT NULL,
"token_sha256" text NOT NULL,
"phase" text DEFAULT 'consent_pending' NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"attempt_id" uuid,
"attempt_expires_at" timestamp with time zone,
"consent_message_id" text,
"file_info_message_id" text,
"response_activity_id" text,
"response_digest" text,
"private_state" jsonb NOT NULL,
"reason" text,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_teams_file_transfers_phase_check" CHECK ("chat_teams_file_transfers"."phase" in ('consent_pending','consent_sending','consent_unknown','awaiting_consent','upload_pending','uploading','upload_unknown','file_info_pending','file_info_sending','file_info_unknown','delivered','declined','expired','cancelled','conflict')),
CONSTRAINT "chat_teams_file_transfers_bounds_check" CHECK ("chat_teams_file_transfers"."version" > 0 and "chat_teams_file_transfers"."runtime_generation" >= 0 and "chat_teams_file_transfers"."conversation_generation" > 0 and "chat_teams_file_transfers"."byte_size" > 0 and "chat_teams_file_transfers"."byte_size" < 62914560),
CONSTRAINT "chat_teams_file_transfers_hash_check" CHECK ("chat_teams_file_transfers"."source_digest" ~ '^[a-f0-9]{64}$' and "chat_teams_file_transfers"."authority_digest" ~ '^[a-f0-9]{64}$' and "chat_teams_file_transfers"."sha256" ~ '^[a-f0-9]{64}$' and "chat_teams_file_transfers"."token_sha256" ~ '^[a-f0-9]{64}$'),
CONSTRAINT "chat_teams_file_transfers_attempt_check" CHECK (("chat_teams_file_transfers"."attempt_id" is null) = ("chat_teams_file_transfers"."attempt_expires_at" is null))
);
--> statement-breakpoint
ALTER TABLE "chat_publications" DROP CONSTRAINT "chat_publications_state_check";--> statement-breakpoint
CREATE UNIQUE INDEX "chat_publications_company_id_uq" ON "chat_publications" USING btree ("company_id","id");--> statement-breakpoint
ALTER TABLE "chat_teams_file_transfers" ADD CONSTRAINT "chat_teams_file_transfers_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_teams_file_transfers" ADD CONSTRAINT "chat_teams_file_transfers_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_teams_file_transfers" ADD CONSTRAINT "chat_teams_file_transfers_company_id_endpoint_id_chat_endpoints_company_id_id_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_teams_file_transfers" ADD CONSTRAINT "chat_teams_file_transfers_company_id_publication_id_chat_publications_company_id_id_fk" FOREIGN KEY ("company_id","publication_id") REFERENCES "public"."chat_publications"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_teams_file_transfers" ADD CONSTRAINT "chat_teams_file_transfers_company_id_conversation_id_chat_conversations_company_id_id_fk" FOREIGN KEY ("company_id","conversation_id") REFERENCES "public"."chat_conversations"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_teams_file_transfers" ADD CONSTRAINT "chat_teams_file_transfers_company_id_principal_id_chat_external_principals_company_id_id_fk" FOREIGN KEY ("company_id","principal_id") REFERENCES "public"."chat_external_principals"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "chat_teams_file_transfers_publication_uq" ON "chat_teams_file_transfers" USING btree ("company_id","publication_id");--> statement-breakpoint
CREATE UNIQUE INDEX "chat_teams_file_transfers_token_uq" ON "chat_teams_file_transfers" USING btree ("endpoint_id","token_sha256");--> statement-breakpoint
CREATE INDEX "chat_teams_file_transfers_work_idx" ON "chat_teams_file_transfers" USING btree ("phase","attempt_expires_at","expires_at");--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_state_check" CHECK ("chat_publications"."state" in ('pending', 'streaming', 'published', 'retry', 'delivery_unknown', 'failed', 'cancelled', 'awaiting_consent'));

View File

@ -0,0 +1,8 @@
CREATE TABLE "chat_discord_command_owners" (
"application_id" text PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"action_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "chat_discord_command_owners_application_check" CHECK ("chat_discord_command_owners"."application_id" ~ '^[1-9][0-9]{16,19}$')
);

View File

@ -0,0 +1 @@
CREATE SEQUENCE "public"."chat_telegram_draft_ids" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1;

View File

@ -0,0 +1,15 @@
-- Create referenced composite keys before the generated foreign keys.
ALTER TABLE "agents" ADD CONSTRAINT "agents_company_id_uq" UNIQUE("company_id","id");--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_company_id_uq" UNIQUE("company_id","id");--> statement-breakpoint
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_company_id_uq" UNIQUE("company_id","id");--> statement-breakpoint
ALTER TABLE "chat_actions" ADD CONSTRAINT "chat_actions_company_delivery_fk" FOREIGN KEY ("company_id","delivery_id") REFERENCES "public"."chat_deliveries"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_actions" ADD CONSTRAINT "chat_actions_company_conversation_fk" FOREIGN KEY ("company_id","conversation_id") REFERENCES "public"."chat_conversations"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_actions" ADD CONSTRAINT "chat_actions_company_principal_fk" FOREIGN KEY ("company_id","principal_id") REFERENCES "public"."chat_external_principals"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_company_issue_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_company_agent_fk" FOREIGN KEY ("company_id","assigned_agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_company_endpoint_fk" FOREIGN KEY ("company_id","endpoint_id") REFERENCES "public"."chat_endpoints"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_company_delivery_fk" FOREIGN KEY ("company_id","delivery_id") REFERENCES "public"."chat_deliveries"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_company_publication_fk" FOREIGN KEY ("company_id","publication_id") REFERENCES "public"."chat_publications"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_message_links" ADD CONSTRAINT "chat_message_links_company_comment_fk" FOREIGN KEY ("company_id","comment_id") REFERENCES "public"."issue_comments"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_company_issue_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_publications" ADD CONSTRAINT "chat_publications_company_comment_fk" FOREIGN KEY ("company_id","comment_id") REFERENCES "public"."issue_comments"("company_id","id") ON DELETE no action ON UPDATE no action;

View File

@ -0,0 +1,12 @@
ALTER TABLE "chat_conversations" DROP CONSTRAINT "chat_conversations_company_resource_fk";
--> statement-breakpoint
ALTER TABLE "chat_deliveries" DROP CONSTRAINT "chat_deliveries_company_conversation_fk";
--> statement-breakpoint
ALTER TABLE "chat_deliveries" DROP CONSTRAINT "chat_deliveries_company_principal_fk";
--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_resource_id_chat_endpoint_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."chat_endpoint_resources"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_company_resource_fk" FOREIGN KEY ("company_id","resource_id") REFERENCES "public"."chat_endpoint_resources"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_conversation_id_chat_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."chat_conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_principal_id_chat_external_principals_id_fk" FOREIGN KEY ("principal_id") REFERENCES "public"."chat_external_principals"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_company_conversation_fk" FOREIGN KEY ("company_id","conversation_id") REFERENCES "public"."chat_conversations"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_deliveries" ADD CONSTRAINT "chat_deliveries_company_principal_fk" FOREIGN KEY ("company_id","principal_id") REFERENCES "public"."chat_external_principals"("company_id","id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1772,6 +1772,118 @@
"when": 1788906545648,
"tag": "0254_military_calypso",
"breakpoints": true
},
{
"idx": 255,
"version": "7",
"when": 1788964999870,
"tag": "0255_previous_captain_america",
"breakpoints": true
},
{
"idx": 256,
"version": "7",
"when": 1788965003480,
"tag": "0256_married_king_cobra",
"breakpoints": true
},
{
"idx": 257,
"version": "7",
"when": 1788965006905,
"tag": "0257_bizarre_the_hunter",
"breakpoints": true
},
{
"idx": 258,
"version": "7",
"when": 1788965010559,
"tag": "0258_typical_sauron",
"breakpoints": true
},
{
"idx": 259,
"version": "7",
"when": 1788965014270,
"tag": "0259_tan_chat",
"breakpoints": true
},
{
"idx": 260,
"version": "7",
"when": 1788965018153,
"tag": "0260_chat_interaction_wakeup_idempotency",
"breakpoints": true
},
{
"idx": 261,
"version": "7",
"when": 1788965023236,
"tag": "0261_faulty_iceman",
"breakpoints": true
},
{
"idx": 262,
"version": "7",
"when": 1788965027575,
"tag": "0262_lying_avengers",
"breakpoints": true
},
{
"idx": 263,
"version": "7",
"when": 1788965029603,
"tag": "0263_nebulous_iron_lad",
"breakpoints": true
},
{
"idx": 264,
"version": "7",
"when": 1788965031515,
"tag": "0264_cynical_hellcat",
"breakpoints": true
},
{
"idx": 265,
"version": "7",
"when": 1788965033473,
"tag": "0265_chat_interaction_wakeup_provenance",
"breakpoints": true
},
{
"idx": 266,
"version": "7",
"when": 1788965035366,
"tag": "0266_brave_living_mummy",
"breakpoints": true
},
{
"idx": 267,
"version": "7",
"when": 1788965037225,
"tag": "0267_warm_wild_child",
"breakpoints": true
},
{
"idx": 268,
"version": "7",
"when": 1788965039217,
"tag": "0268_lively_runaways",
"breakpoints": true
},
{
"idx": 269,
"version": "7",
"when": 1788972722287,
"tag": "0269_fearless_young_avengers",
"breakpoints": true
},
{
"idx": 270,
"version": "7",
"when": 1788973120697,
"tag": "0270_harsh_queen_noir",
"breakpoints": true
}
]
}
}

View File

@ -1,5 +1,14 @@
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, jsonb, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
integer,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
@ -7,8 +16,12 @@ export const agentWakeupRequests = pgTable(
"agent_wakeup_requests",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
agentId: uuid("agent_id").notNull().references(() => agents.id),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id),
agentId: uuid("agent_id")
.notNull()
.references(() => agents.id),
source: text("source").notNull(),
triggerDetail: text("trigger_detail"),
reason: text("reason"),
@ -19,35 +32,49 @@ export const agentWakeupRequests = pgTable(
requestedByActorId: text("requested_by_actor_id"),
idempotencyKey: text("idempotency_key"),
runId: uuid("run_id"),
requestedAt: timestamp("requested_at", { withTimezone: true }).notNull().defaultNow(),
requestedAt: timestamp("requested_at", { withTimezone: true })
.notNull()
.defaultNow(),
claimedAt: timestamp("claimed_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
companyAgentStatusIdx: index("agent_wakeup_requests_company_agent_status_idx").on(
table.companyId,
companyAgentStatusIdx: index(
"agent_wakeup_requests_company_agent_status_idx",
).on(table.companyId, table.agentId, table.status),
companyRequestedIdx: index(
"agent_wakeup_requests_company_requested_idx",
).on(table.companyId, table.requestedAt),
agentRequestedIdx: index("agent_wakeup_requests_agent_requested_idx").on(
table.agentId,
table.status,
),
companyRequestedIdx: index("agent_wakeup_requests_company_requested_idx").on(
table.companyId,
table.requestedAt,
),
agentRequestedIdx: index("agent_wakeup_requests_agent_requested_idx").on(table.agentId, table.requestedAt),
reviewPathRecoveryIdempotencyUq: uniqueIndex("agent_wakeup_requests_review_path_recovery_idempotency_uq")
reviewPathRecoveryIdempotencyUq: uniqueIndex(
"agent_wakeup_requests_review_path_recovery_idempotency_uq",
)
.on(table.companyId, table.idempotencyKey)
.where(sql`${table.idempotencyKey} LIKE 'issue_review_path_lost:%' AND ${table.status} <> 'skipped'`),
dispositionRepairIdempotencyUq: uniqueIndex("agent_wakeup_requests_disposition_repair_idempotency_uq")
.where(
sql`${table.idempotencyKey} LIKE 'issue_review_path_lost:%' AND ${table.status} <> 'skipped'`,
),
dispositionRepairIdempotencyUq: uniqueIndex(
"agent_wakeup_requests_disposition_repair_idempotency_uq",
)
.on(table.companyId, table.idempotencyKey)
.where(sql`${table.idempotencyKey} LIKE 'issue_disposition_repair:%' AND ${table.status} <> 'skipped'`),
.where(
sql`${table.idempotencyKey} LIKE 'issue_disposition_repair:%' AND ${table.status} <> 'skipped'`,
),
questionResponseDeliveryIdempotencyUq: uniqueIndex(
"agent_wakeup_requests_question_response_delivery_idempotency_uq",
)
.on(table.companyId, table.idempotencyKey)
.where(sql`${table.idempotencyKey} LIKE 'question-response:%' AND ${table.status} NOT IN ('skipped', 'failed', 'cancelled')`),
.where(sql`(${table.idempotencyKey} LIKE 'question-response:%' OR ${table.idempotencyKey} LIKE 'interaction:%') AND ${table.status} NOT IN ('skipped', 'failed', 'cancelled')`),
connectionIntentDeliveryIdempotencyUq: uniqueIndex("agent_wakeup_requests_connection_intent_delivery_idempotency_uq")
.on(table.companyId, table.idempotencyKey)
.where(sql`${table.idempotencyKey} LIKE 'connection-intent:%' AND ${table.status} NOT IN ('skipped', 'failed', 'cancelled')`),

View File

@ -7,6 +7,7 @@ import {
timestamp,
jsonb,
index,
unique,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
@ -39,6 +40,7 @@ export const agents = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdUq: unique("agents_company_id_uq").on(table.companyId, table.id),
companyStatusIdx: index("agents_company_status_idx").on(table.companyId, table.status),
companyReportsToIdx: index("agents_company_reports_to_idx").on(table.companyId, table.reportsTo),
companyDefaultEnvironmentIdx: index("agents_company_default_environment_idx").on(table.companyId, table.defaultEnvironmentId),

View File

@ -0,0 +1,790 @@
import { sql } from "drizzle-orm";
import {
boolean,
check,
foreignKey,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
unique,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type {
ChatAdapterCapabilities,
ChatConcurrencyPolicy,
ChatDeliveryState,
ChatDeploymentMode,
ChatEndpointSetupState,
ChatEndpointStatus,
ChatEventKind,
ChatIdentityLinkStatus,
ChatPrincipalKind,
ChatProvider,
ChatPublicationState,
ChatResourceAvailability,
SafeChatPublicationPayload,
} from "@paperclipai/shared";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { issueComments } from "./issue_comments.js";
import { issues } from "./issues.js";
import { toolConnections } from "./tool_access.js";
export const chatEndpoints = pgTable(
"chat_endpoints",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
connectionId: uuid("connection_id").notNull(),
provider: text("provider").$type<ChatProvider>().notNull(),
publicId: text("public_id").notNull(),
assignedAgentId: uuid("assigned_agent_id")
.notNull()
.references(() => agents.id, { onDelete: "restrict" }),
sponsorUserId: text("sponsor_user_id"),
status: text("status")
.$type<ChatEndpointStatus>()
.notNull()
.default("draft"),
deploymentMode: text("deployment_mode")
.$type<ChatDeploymentMode>()
.notNull()
.default("direct"),
providerAccountId: text("provider_account_id"),
providerAccountLabel: text("provider_account_label"),
botExternalId: text("bot_external_id"),
botUsername: text("bot_username"),
botDisplayName: text("bot_display_name"),
botAvatarUrl: text("bot_avatar_url"),
allowDirectMessages: boolean("allow_direct_messages")
.notNull()
.default(true),
allowGroupChats: boolean("allow_group_chats").notNull().default(false),
allowUnlinkedPeople: boolean("allow_unlinked_people")
.notNull()
.default(true),
concurrencyPolicy: text("concurrency_policy")
.$type<ChatConcurrencyPolicy>()
.notNull()
.default("queue"),
capabilities: jsonb("capabilities")
.$type<ChatAdapterCapabilities>()
.notNull()
.default({
threads: false,
directMessages: false,
nativeStreaming: false,
messageEdits: false,
messageDeletes: false,
reactions: false,
files: false,
cards: false,
actions: false,
modals: false,
slashCommands: false,
ephemeralMessages: false,
proactiveDirectMessages: false,
}),
setup: jsonb("setup")
.$type<ChatEndpointSetupState>()
.notNull()
.default({ step: "provider_setup" }),
healthMessage: text("health_message"),
lastEventAt: timestamp("last_event_at", { withTimezone: true }),
lastPublicationAt: timestamp("last_publication_at", { withTimezone: true }),
lastError: text("last_error"),
activatedAt: timestamp("activated_at", { withTimezone: true }),
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_endpoints_provider_check",
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram')`,
),
check(
"chat_endpoints_status_check",
sql`${table.status} in ('draft', 'verifying', 'active', 'paused', 'attention', 'revoked', 'archived')`,
),
check(
"chat_endpoints_deployment_check",
sql`${table.deploymentMode} in ('direct', 'relay')`,
),
check(
"chat_endpoints_concurrency_check",
sql`${table.concurrencyPolicy} in ('burst', 'queue', 'debounce', 'drop', 'concurrent')`,
),
index("chat_endpoints_company_idx").on(table.companyId),
index("chat_endpoints_agent_idx").on(
table.companyId,
table.assignedAgentId,
),
index("chat_endpoints_status_idx").on(table.companyId, table.status),
uniqueIndex("chat_endpoints_public_id_uq").on(table.publicId),
uniqueIndex("chat_endpoints_connection_uq").on(table.connectionId),
// A native provider identity can back only one live Paperclip endpoint.
// Historical archived/revoked endpoints retain attribution without
// preventing an operator from deliberately reusing the provider bot later.
uniqueIndex("chat_endpoints_live_bot_external_uq")
.on(table.provider, table.providerAccountId, table.botExternalId)
.where(
sql`${table.status} in ('verifying', 'active', 'paused', 'attention')
and ${table.providerAccountId} is not null
and ${table.botExternalId} is not null`,
),
// One Discord application can be installed in many guilds, but it remains
// one native bot identity. Excluding providerAccountId closes the race
// where concurrent setup in two guilds could otherwise claim that bot for
// two Paperclip agents after both application-level prechecks passed.
uniqueIndex("chat_endpoints_live_discord_bot_external_uq")
.on(table.provider, table.botExternalId)
.where(
sql`${table.provider} = 'discord'
and ${table.status} in ('verifying', 'active', 'paused', 'attention')
and ${table.botExternalId} is not null`,
),
// GitHub App and Microsoft Bot application ids are provider-global bot
// identities. Their mutable owner/tenant coordinate is useful metadata,
// but it cannot be part of the exclusivity key: an App transfer or a
// multi-tenant service principal must never let one native bot represent
// two Paperclip agents through two different webhook URLs.
uniqueIndex("chat_endpoints_live_global_app_bot_external_uq")
.on(table.provider, table.botExternalId)
.where(
sql`${table.provider} in ('github', 'microsoft-teams')
and ${table.status} in ('verifying', 'active', 'paused', 'attention')
and ${table.botExternalId} is not null`,
),
// GitHub App verification does not expose the bot user's numeric id, so
// retain an equivalent live-slot constraint on the provider-native name.
uniqueIndex("chat_endpoints_live_bot_username_uq")
.on(table.provider, table.providerAccountId, table.botUsername)
.where(
sql`${table.status} in ('verifying', 'active', 'paused', 'attention')
and ${table.providerAccountId} is not null
and ${table.botUsername} is not null`,
),
unique("chat_endpoints_company_id_uq").on(table.companyId, table.id),
foreignKey({
columns: [table.companyId, table.assignedAgentId],
foreignColumns: [agents.companyId, agents.id],
name: "chat_endpoints_company_agent_fk",
}),
foreignKey({
columns: [table.companyId, table.connectionId],
foreignColumns: [toolConnections.companyId, toolConnections.id],
name: "chat_endpoints_company_connection_fk",
}).onDelete("cascade"),
],
);
export const chatEndpointResources = pgTable(
"chat_endpoint_resources",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
type: text("type").notNull(),
providerResourceId: text("provider_resource_id").notNull(),
parentProviderResourceId: text("parent_provider_resource_id"),
label: text("label").notNull(),
detail: text("detail"),
providerUrl: text("provider_url"),
availability: text("availability")
.$type<ChatResourceAvailability>()
.notNull()
.default("available"),
enabled: boolean("enabled").notNull().default(false),
metadata: jsonb("metadata")
.$type<Record<string, unknown>>()
.notNull()
.default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_endpoint_resources_availability_check",
sql`${table.availability} in ('available', 'unavailable', 'removed')`,
),
index("chat_endpoint_resources_endpoint_idx").on(
table.companyId,
table.endpointId,
),
uniqueIndex("chat_endpoint_resources_external_uq").on(
table.endpointId,
table.type,
table.providerResourceId,
),
unique("chat_endpoint_resources_company_id_uq").on(
table.companyId,
table.id,
),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_endpoint_resources_company_endpoint_fk",
}).onDelete("cascade"),
],
);
export const chatExternalPrincipals = pgTable(
"chat_external_principals",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
provider: text("provider").$type<ChatProvider>().notNull(),
providerAccountId: text("provider_account_id").notNull(),
externalId: text("external_id").notNull(),
kind: text("kind").$type<ChatPrincipalKind>().notNull().default("user"),
displayName: text("display_name"),
handle: text("handle"),
avatarUrl: text("avatar_url"),
isBot: boolean("is_bot").notNull().default(false),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_external_principals_provider_check",
sql`${table.provider} in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram')`,
),
check(
"chat_external_principals_kind_check",
sql`${table.kind} in ('user', 'bot', 'app', 'system')`,
),
index("chat_external_principals_company_idx").on(table.companyId),
uniqueIndex("chat_external_principals_external_uq").on(
table.companyId,
table.provider,
table.providerAccountId,
table.externalId,
),
unique("chat_external_principals_company_id_uq").on(
table.companyId,
table.id,
),
],
);
export const chatIdentityLinks = pgTable(
"chat_identity_links",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
principalId: uuid("principal_id").notNull(),
paperclipUserId: text("paperclip_user_id"),
status: text("status")
.$type<ChatIdentityLinkStatus>()
.notNull()
.default("pending"),
confirmationTokenHash: text("confirmation_token_hash"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_identity_links_status_check",
sql`${table.status} in ('pending', 'linked', 'revoked', 'expired')`,
),
index("chat_identity_links_user_idx").on(
table.companyId,
table.paperclipUserId,
),
uniqueIndex("chat_identity_links_endpoint_principal_uq").on(
table.endpointId,
table.principalId,
),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_identity_links_company_endpoint_fk",
}).onDelete("cascade"),
foreignKey({
columns: [table.companyId, table.principalId],
foreignColumns: [
chatExternalPrincipals.companyId,
chatExternalPrincipals.id,
],
name: "chat_identity_links_company_principal_fk",
}).onDelete("cascade"),
],
);
export const chatConversations = pgTable(
"chat_conversations",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
resourceId: uuid("resource_id").references(() => chatEndpointResources.id, {
onDelete: "set null",
}),
issueId: uuid("issue_id")
.notNull()
.references(() => issues.id, { onDelete: "restrict" }),
externalConversationId: text("external_conversation_id").notNull(),
externalThreadId: text("external_thread_id").notNull().default(""),
// Providers with linear conversations (DMs, Telegram groups, Teams group
// chats) reuse one native thread id. A generation preserves the native id
// used for replies while allowing completed Paperclip tasks to roll over.
sessionGeneration: integer("session_generation").notNull().default(1),
externalLabel: text("external_label").notNull(),
providerUrl: text("provider_url"),
isDirectMessage: boolean("is_direct_message").notNull().default(false),
state: text("state").notNull().default("active"),
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_conversations_state_check",
sql`${table.state} in ('active', 'waiting', 'completed', 'unavailable', 'endpoint_removed')`,
),
index("chat_conversations_issue_idx").on(table.companyId, table.issueId),
uniqueIndex("chat_conversations_thread_uq").on(
table.endpointId,
table.externalConversationId,
table.externalThreadId,
table.sessionGeneration,
),
unique("chat_conversations_company_id_uq").on(table.companyId, table.id),
foreignKey({
columns: [table.companyId, table.issueId],
foreignColumns: [issues.companyId, issues.id],
name: "chat_conversations_company_issue_fk",
}),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_conversations_company_endpoint_fk",
}).onDelete("cascade"),
foreignKey({
columns: [table.companyId, table.resourceId],
foreignColumns: [
chatEndpointResources.companyId,
chatEndpointResources.id,
],
name: "chat_conversations_company_resource_fk",
}),
],
);
export const chatDeliveries = pgTable(
"chat_deliveries",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
conversationId: uuid("conversation_id").references(
() => chatConversations.id,
{
onDelete: "set null",
},
),
principalId: uuid("principal_id").references(
() => chatExternalPrincipals.id,
{
onDelete: "set null",
},
),
providerEventId: text("provider_event_id").notNull(),
deduplicationKey: text("deduplication_key").notNull(),
eventKind: text("event_kind").$type<ChatEventKind>().notNull(),
normalizedEvent: jsonb("normalized_event")
.$type<Record<string, unknown>>()
.notNull(),
state: text("state")
.$type<ChatDeliveryState>()
.notNull()
.default("received"),
attempts: integer("attempts").notNull().default(0),
redactedError: text("redacted_error"),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
receivedAt: timestamp("received_at", { withTimezone: true })
.notNull()
.defaultNow(),
processedAt: timestamp("processed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_deliveries_state_check",
sql`${table.state} in ('received', 'filtered', 'processing', 'processed', 'retry', 'failed')`,
),
index("chat_deliveries_work_idx").on(table.state, table.nextAttemptAt),
unique("chat_deliveries_company_id_uq").on(table.companyId, table.id),
uniqueIndex("chat_deliveries_event_uq").on(
table.endpointId,
table.providerEventId,
),
uniqueIndex("chat_deliveries_dedupe_uq").on(
table.endpointId,
table.deduplicationKey,
),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_deliveries_company_endpoint_fk",
}).onDelete("cascade"),
foreignKey({
columns: [table.companyId, table.conversationId],
foreignColumns: [chatConversations.companyId, chatConversations.id],
name: "chat_deliveries_company_conversation_fk",
}),
foreignKey({
columns: [table.companyId, table.principalId],
foreignColumns: [
chatExternalPrincipals.companyId,
chatExternalPrincipals.id,
],
name: "chat_deliveries_company_principal_fk",
}),
],
);
export const chatPublications = pgTable(
"chat_publications",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
conversationId: uuid("conversation_id").notNull(),
issueId: uuid("issue_id")
.notNull()
.references(() => issues.id, { onDelete: "restrict" }),
commentId: uuid("comment_id").references(() => issueComments.id, {
onDelete: "set null",
}),
idempotencyKey: text("idempotency_key").notNull(),
payload: jsonb("payload").$type<SafeChatPublicationPayload>().notNull(),
state: text("state")
.$type<ChatPublicationState>()
.notNull()
.default("pending"),
providerMessageId: text("provider_message_id"),
providerUrl: text("provider_url"),
attempts: integer("attempts").notNull().default(0),
redactedError: text("redacted_error"),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_publications_state_check",
sql`${table.state} in ('pending', 'streaming', 'published', 'retry', 'delivery_unknown', 'failed', 'cancelled', 'awaiting_consent')`,
),
uniqueIndex("chat_publications_company_id_uq").on(
table.companyId,
table.id,
),
foreignKey({
columns: [table.companyId, table.issueId],
foreignColumns: [issues.companyId, issues.id],
name: "chat_publications_company_issue_fk",
}),
// Retain the single-column SET NULL action above. The additional tenant
// key uses NO ACTION so deletion clears only comment_id, never company_id.
foreignKey({
columns: [table.companyId, table.commentId],
foreignColumns: [issueComments.companyId, issueComments.id],
name: "chat_publications_company_comment_fk",
}),
index("chat_publications_work_idx").on(table.state, table.nextAttemptAt),
uniqueIndex("chat_publications_idempotency_uq").on(
table.companyId,
table.idempotencyKey,
),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_publications_company_endpoint_fk",
}).onDelete("cascade"),
foreignKey({
columns: [table.companyId, table.conversationId],
foreignColumns: [chatConversations.companyId, chatConversations.id],
name: "chat_publications_company_conversation_fk",
}).onDelete("cascade"),
],
);
export const chatMessageLinks = pgTable(
"chat_message_links",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
conversationId: uuid("conversation_id").notNull(),
deliveryId: uuid("delivery_id").references(() => chatDeliveries.id, {
onDelete: "set null",
}),
publicationId: uuid("publication_id").references(
() => chatPublications.id,
{ onDelete: "set null" },
),
commentId: uuid("comment_id").references(() => issueComments.id, {
onDelete: "set null",
}),
providerMessageId: text("provider_message_id").notNull(),
direction: text("direction").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_message_links_direction_check",
sql`${table.direction} in ('inbound', 'outbound')`,
),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_message_links_company_endpoint_fk",
}).onDelete("cascade"),
foreignKey({
columns: [table.companyId, table.deliveryId],
foreignColumns: [chatDeliveries.companyId, chatDeliveries.id],
name: "chat_message_links_company_delivery_fk",
}),
foreignKey({
columns: [table.companyId, table.publicationId],
foreignColumns: [chatPublications.companyId, chatPublications.id],
name: "chat_message_links_company_publication_fk",
}),
foreignKey({
columns: [table.companyId, table.commentId],
foreignColumns: [issueComments.companyId, issueComments.id],
name: "chat_message_links_company_comment_fk",
}),
uniqueIndex("chat_message_links_provider_message_uq").on(
table.endpointId,
table.conversationId,
table.providerMessageId,
),
foreignKey({
columns: [table.companyId, table.conversationId],
foreignColumns: [chatConversations.companyId, chatConversations.id],
name: "chat_message_links_company_conversation_fk",
}).onDelete("cascade"),
],
);
export const chatActions = pgTable(
"chat_actions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
deliveryId: uuid("delivery_id").references(() => chatDeliveries.id, {
onDelete: "set null",
}),
conversationId: uuid("conversation_id"),
principalId: uuid("principal_id"),
kind: text("kind").notNull(),
providerActionId: text("provider_action_id").notNull(),
payload: jsonb("payload")
.$type<Record<string, unknown>>()
.notNull()
.default({}),
status: text("status").notNull().default("received"),
result: jsonb("result").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
uniqueIndex("chat_actions_provider_action_uq").on(
table.endpointId,
table.providerActionId,
),
foreignKey({
columns: [table.companyId, table.deliveryId],
foreignColumns: [chatDeliveries.companyId, chatDeliveries.id],
name: "chat_actions_company_delivery_fk",
}),
foreignKey({
columns: [table.companyId, table.conversationId],
foreignColumns: [chatConversations.companyId, chatConversations.id],
name: "chat_actions_company_conversation_fk",
}),
foreignKey({
columns: [table.companyId, table.principalId],
foreignColumns: [
chatExternalPrincipals.companyId,
chatExternalPrincipals.id,
],
name: "chat_actions_company_principal_fk",
}),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_actions_company_endpoint_fk",
}).onDelete("cascade"),
],
);
export const chatAgentRoutes = pgTable(
"chat_agent_routes",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
sourceEndpointId: uuid("source_endpoint_id").notNull(),
destinationEndpointId: uuid("destination_endpoint_id").notNull(),
enabled: boolean("enabled").notNull().default(false),
triggerMode: text("trigger_mode").notNull().default("explicit_mention"),
maxHops: integer("max_hops").notNull().default(1),
createdByUserId: text("created_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_agent_routes_hops_check",
sql`${table.maxHops} between 1 and 8`,
),
uniqueIndex("chat_agent_routes_pair_uq").on(
table.sourceEndpointId,
table.destinationEndpointId,
),
foreignKey({
columns: [table.companyId, table.sourceEndpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_agent_routes_company_source_fk",
}).onDelete("cascade"),
foreignKey({
columns: [table.companyId, table.destinationEndpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_agent_routes_company_destination_fk",
}).onDelete("cascade"),
],
);
export const chatEndpointLeases = pgTable(
"chat_endpoint_leases",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
leaseKey: text("lease_key").notNull(),
token: text("token").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
uniqueIndex("chat_endpoint_leases_active_uq").on(
table.endpointId,
table.leaseKey,
),
index("chat_endpoint_leases_expiry_idx").on(table.expiresAt),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_endpoint_leases_company_endpoint_fk",
}).onDelete("cascade"),
],
);
export const chatSdkState = pgTable(
"chat_sdk_state",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
stateKey: text("state_key").notNull(),
version: integer("version").notNull().default(1),
value: jsonb("value").$type<unknown>().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
uniqueIndex("chat_sdk_state_key_uq").on(table.endpointId, table.stateKey),
index("chat_sdk_state_expiry_idx").on(table.expiresAt),
foreignKey({
columns: [table.companyId, table.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
name: "chat_sdk_state_company_endpoint_fk",
}).onDelete("cascade"),
],
);

View File

@ -0,0 +1,26 @@
import { check, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
// Instance-wide namespace tombstone, not company content or a credential.
// The public application ID and opaque original owner IDs intentionally have
// no cascading foreign keys. Deleting a company must not erase evidence of an
// unknown Discord UPSERT and let another endpoint claim that application.
// No token, command payload, user identity or private endpoint URL is retained.
export const chatDiscordCommandOwners = pgTable(
"chat_discord_command_owners",
{
applicationId: text("application_id").primaryKey(),
companyId: uuid("company_id").notNull(),
endpointId: uuid("endpoint_id").notNull(),
actionId: uuid("action_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
check(
"chat_discord_command_owners_application_check",
sql`${table.applicationId} ~ '^[1-9][0-9]{16,19}$'`,
),
],
);

View File

@ -0,0 +1,128 @@
import { sql } from "drizzle-orm";
import {
check,
foreignKey,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import {
chatConversations,
chatEndpoints,
chatExternalPrincipals,
chatPublications,
} from "./chat_channels.js";
import { issues } from "./issues.js";
// Private operational state. Neither ciphertext nor its decrypted capability
// belongs in the public publication/action projection.
export const chatTeamsFileTransfers = pgTable(
"chat_teams_file_transfers",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
endpointId: uuid("endpoint_id").notNull(),
conversationId: uuid("conversation_id").notNull(),
publicationId: uuid("publication_id").notNull(),
issueId: uuid("issue_id")
.notNull()
.references(() => issues.id, { onDelete: "restrict" }),
// Immutable evidence, not foreign keys: deleting a source must remain
// possible without erasing unknown provider effects or blocking user deletion.
commentId: uuid("comment_id").notNull(),
attachmentId: uuid("attachment_id").notNull(),
principalId: uuid("principal_id").notNull(),
authorizedUserId: text("authorized_user_id"),
runtimeGeneration: integer("runtime_generation").notNull(),
credentialFingerprint: text("credential_fingerprint").notNull(),
conversationGeneration: integer("conversation_generation").notNull(),
sourceDigest: text("source_digest").notNull(),
authorityDigest: text("authority_digest").notNull(),
tenantId: uuid("tenant_id").notNull(),
botAppId: uuid("bot_app_id").notNull(),
aadObjectId: uuid("aad_object_id").notNull(),
providerConversationId: text("provider_conversation_id").notNull(),
providerUserId: text("provider_user_id").notNull(),
sha256: text("sha256").notNull(),
byteSize: integer("byte_size").notNull(),
filename: text("filename").notNull(),
tokenSha256: text("token_sha256").notNull(),
phase: text("phase").notNull().default("consent_pending"),
version: integer("version").notNull().default(1),
attemptId: uuid("attempt_id"),
attemptExpiresAt: timestamp("attempt_expires_at", { withTimezone: true }),
consentMessageId: text("consent_message_id"),
fileInfoMessageId: text("file_info_message_id"),
responseActivityId: text("response_activity_id"),
responseDigest: text("response_digest"),
privateState: jsonb("private_state")
.$type<Record<string, unknown>>()
.notNull(),
reason: text("reason"),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
uniqueIndex("chat_teams_file_transfers_publication_uq").on(
t.companyId,
t.publicationId,
),
uniqueIndex("chat_teams_file_transfers_token_uq").on(
t.endpointId,
t.tokenSha256,
),
index("chat_teams_file_transfers_work_idx").on(
t.phase,
t.attemptExpiresAt,
t.expiresAt,
),
foreignKey({
columns: [t.companyId, t.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
}).onDelete("cascade"),
foreignKey({
columns: [t.companyId, t.publicationId],
foreignColumns: [chatPublications.companyId, chatPublications.id],
}).onDelete("cascade"),
foreignKey({
columns: [t.companyId, t.conversationId],
foreignColumns: [chatConversations.companyId, chatConversations.id],
}).onDelete("cascade"),
foreignKey({
columns: [t.companyId, t.principalId],
foreignColumns: [
chatExternalPrincipals.companyId,
chatExternalPrincipals.id,
],
}),
check(
"chat_teams_file_transfers_phase_check",
sql`${t.phase} in ('consent_pending','consent_sending','consent_unknown','awaiting_consent','upload_pending','uploading','upload_unknown','file_info_pending','file_info_sending','file_info_unknown','delivered','declined','expired','cancelled','conflict')`,
),
check(
"chat_teams_file_transfers_bounds_check",
sql`${t.version} > 0 and ${t.runtimeGeneration} >= 0 and ${t.conversationGeneration} > 0 and ${t.byteSize} > 0 and ${t.byteSize} < 62914560`,
),
check(
"chat_teams_file_transfers_hash_check",
sql`${t.sourceDigest} ~ '^[a-f0-9]{64}$' and ${t.authorityDigest} ~ '^[a-f0-9]{64}$' and ${t.sha256} ~ '^[a-f0-9]{64}$' and ${t.tokenSha256} ~ '^[a-f0-9]{64}$'`,
),
check(
"chat_teams_file_transfers_attempt_check",
sql`(${t.attemptId} is null) = (${t.attemptExpiresAt} is null)`,
),
],
);

View File

@ -0,0 +1,14 @@
import { pgSequence } from "drizzle-orm/pg-core";
// Telegram Stop updates contain a draft ID but no actor or generation. This
// content-free instance sequence must survive endpoint/company deletion and
// transaction rollback, so an old Stop can never name a newly allocated draft.
// Do not attach it to a table or cycle/reset it when clearing chat records.
export const chatTelegramDraftIds = pgSequence("chat_telegram_draft_ids", {
startWith: 1,
minValue: 1,
maxValue: 2_147_483_647,
increment: 1,
cache: 1,
cycle: false,
});

View File

@ -48,6 +48,20 @@ export { issueReferenceMentions } from "./issue_reference_mentions.js";
export { externalObjects } from "./external_objects.js";
export { externalObjectMentions } from "./external_object_mentions.js";
export { connectionEventDeliveries } from "./connection_event_deliveries.js";
export {
chatEndpoints,
chatEndpointResources,
chatExternalPrincipals,
chatIdentityLinks,
chatConversations,
chatDeliveries,
chatPublications,
chatMessageLinks,
chatActions,
chatAgentRoutes,
chatEndpointLeases,
chatSdkState,
} from "./chat_channels.js";
export { issueRelations } from "./issue_relations.js";
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
export { pipelines, pipelineStages, pipelineTransitions } from "./pipelines.js";
@ -187,3 +201,6 @@ export { runIdentityContexts } from "./run_identity_contexts.js";
export { connectionIntentDeliveries } from "./connection_intent_deliveries.js";
export { toolActionDeliveries } from "./tool_action_deliveries.js";
export { chatTeamsFileTransfers } from "./chat_teams_file_transfers.js";
export { chatDiscordCommandOwners } from "./chat_discord_command_owners.js";
export { chatTelegramDraftIds } from "./chat_telegram_draft_ids.js";

View File

@ -3,6 +3,7 @@ import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { assets } from "./assets.js";
import { issueComments } from "./issue_comments.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const issueAttachments = pgTable(
"issue_attachments",
@ -12,12 +13,18 @@ export const issueAttachments = pgTable(
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
assetId: uuid("asset_id").notNull().references(() => assets.id, { onDelete: "cascade" }),
issueCommentId: uuid("issue_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
originatingRunId: uuid("originating_run_id").references(() => heartbeatRuns.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueIdx: index("issue_attachments_company_issue_idx").on(table.companyId, table.issueId),
issueCommentIdx: index("issue_attachments_issue_comment_idx").on(table.issueCommentId),
originatingRunIdx: index("issue_attachments_originating_run_idx").on(
table.originatingRunId,
),
assetUq: uniqueIndex("issue_attachments_asset_uq").on(table.assetId),
}),
);

View File

@ -5,7 +5,7 @@ import type {
IssueCommentPresentation,
SourceTrustMetadata,
} from "@paperclipai/shared";
import { pgTable, uuid, text, timestamp, index, jsonb } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, index, jsonb, unique } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
@ -43,6 +43,7 @@ export const issueComments = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdUq: unique("issue_comments_company_id_uq").on(table.companyId, table.id),
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
companyIdx: index("issue_comments_company_idx").on(table.companyId),
companyIssueCreatedAtIdx: index("issue_comments_company_issue_created_at_idx").on(

View File

@ -1,25 +1,59 @@
import { pgTable, uuid, text, integer, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
/** Immutable attribution records. Only acceptance state and redacted diagnostics advance. */
export const runIdentityContexts = pgTable("run_identity_contexts", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
// Retain attribution after an agent and its runs are deleted: surviving tasks
// and approvals still reference these contexts. The original run UUID is archival.
runId: uuid("run_id").notNull(),
revision: integer("revision").notNull(),
responsibleUserId: text("responsible_user_id"),
messageId: uuid("message_id"),
parentContextId: uuid("parent_context_id"),
cause: text("cause").notNull(),
correlationId: text("correlation_id").notNull(),
status: text("status").notNull().default("accepted"),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
github: jsonb("github").$type<{ status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string; connectionId?: string; grantId?: string; authenticationMode?: "managed" | "host" | "anonymous" }>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
revisionIdx: uniqueIndex("run_identity_contexts_run_revision_idx").on(t.runId, t.revision),
correlationIdx: uniqueIndex("run_identity_contexts_run_correlation_idx").on(t.runId, t.correlationId),
companyRunIdx: index("run_identity_contexts_company_run_idx").on(t.companyId, t.runId),
}));
export const runIdentityContexts = pgTable(
"run_identity_contexts",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
// Retain attribution after an agent and its runs are deleted: surviving tasks
// and approvals still reference these contexts. The original run UUID is archival.
runId: uuid("run_id").notNull(),
revision: integer("revision").notNull(),
responsibleUserId: text("responsible_user_id"),
messageId: uuid("message_id"),
parentContextId: uuid("parent_context_id"),
cause: text("cause").notNull(),
correlationId: text("correlation_id").notNull(),
status: text("status").notNull().default("accepted"),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
github: jsonb("github").$type<{
status: "available" | "absent" | "unavailable";
login?: string;
source?: "personal" | "dedicated";
reason?: string;
connectionId?: string;
grantId?: string;
authenticationMode?: "managed" | "host" | "anonymous";
}>(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
revisionIdx: uniqueIndex("run_identity_contexts_run_revision_idx").on(
t.runId,
t.revision,
),
correlationIdx: uniqueIndex("run_identity_contexts_run_correlation_idx").on(
t.runId,
t.correlationId,
),
companyRunIdx: index("run_identity_contexts_company_run_idx").on(
t.companyId,
t.runId,
),
}),
);

View File

@ -32,6 +32,7 @@ import type {
ToolConnectionKind,
ToolConnectionCredentialPolicy,
ToolConnectionOwnership,
ToolConnectionPurpose,
ToolConnectionInstallTargetType,
ToolConnectionStatus,
ToolConnectionTransport,
@ -119,6 +120,7 @@ export const toolConnections = pgTable(
name: text("name").notNull(),
uid: text("uid").notNull(),
connectionKind: text("connection_kind").$type<ToolConnectionKind>().notNull().default("managed"),
connectionPurpose: text("connection_purpose").$type<ToolConnectionPurpose>().notNull().default("tool"),
ownership: text("ownership").$type<ToolConnectionOwnership>().notNull().default("customer"),
transport: text("transport").$type<ToolConnectionTransport>().notNull(),
authKind: text("auth_kind").$type<ToolConnectionAuthKind>().notNull().default("none"),
@ -144,7 +146,13 @@ export const toolConnections = pgTable(
},
(table) => [
check("tool_connections_ownership_check", sql`${table.ownership} in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')`),
check("tool_connections_transport_check", sql`${table.transport} in ('mcp_remote', 'rest_api', 'local_stdio')`),
check("tool_connections_transport_check", sql`${table.transport} in ('mcp_remote', 'rest_api', 'local_stdio', 'chat_sdk')`),
check("tool_connections_purpose_check", sql`${table.connectionPurpose} in ('tool', 'channel')`),
check("tool_connections_channel_transport_check", sql`(
(${table.connectionPurpose} = 'tool' and ${table.transport} <> 'chat_sdk')
or
(${table.connectionPurpose} = 'channel' and ${table.transport} = 'chat_sdk')
)`),
check("tool_connections_auth_kind_check", sql`${table.authKind} in ('oauth', 'api_key', 'none')`),
check("tool_connections_credential_source_check", sql`${table.credentialSource} in ('paperclip_vault', 'vercel_connect')`),
check("tool_connections_credential_source_one_of_check", sql`(

View File

@ -341,6 +341,8 @@ export {
SELF_SERVE_MCP_RESEARCH,
} from "./self-serve-mcp-research.js";
export * from "./validators/status-card.js";
export * from "./types/chat-channels.js";
export * from "./validators/chat-channels.js";
export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js";
export {
humanizeConnectionDisplayName,
@ -1441,6 +1443,7 @@ export type {
ToolConnectionCredentialSource,
ToolConnectionCredentialPolicy,
ToolConnectionOwnership,
ToolConnectionPurpose,
ToolConnectionTransport,
ToolConnectionStatus,
ToolConnectionKind,

View File

@ -0,0 +1,498 @@
/** Provider-neutral contracts for Paperclip's native external chat subsystem. */
export const CHAT_PROVIDERS = [
"slack",
"github",
"discord",
"microsoft-teams",
"telegram",
] as const;
export type ChatProvider = (typeof CHAT_PROVIDERS)[number];
export const CHAT_ENDPOINT_STATUSES = [
"draft",
"verifying",
"active",
"paused",
"attention",
"revoked",
"archived",
] as const;
export type ChatEndpointStatus = (typeof CHAT_ENDPOINT_STATUSES)[number];
export const CHAT_DEPLOYMENT_MODES = ["direct", "relay"] as const;
export type ChatDeploymentMode = (typeof CHAT_DEPLOYMENT_MODES)[number];
export const CHAT_CONCURRENCY_POLICIES = [
"burst",
"queue",
"debounce",
"drop",
"concurrent",
] as const;
export type ChatConcurrencyPolicy = (typeof CHAT_CONCURRENCY_POLICIES)[number];
export const CHAT_EVENT_KINDS = [
"mention",
"message",
"direct_message",
"message_updated",
"message_deleted",
"message_restored",
"reaction_added",
"reaction_removed",
"action",
"modal_submitted",
"modal_closed",
"slash_command",
"file_shared",
"installation",
"uninstallation",
"unknown",
] as const;
export type ChatEventKind = (typeof CHAT_EVENT_KINDS)[number];
export const CHAT_DELIVERY_STATES = [
"received",
"filtered",
"processing",
"processed",
"retry",
"failed",
] as const;
export type ChatDeliveryState = (typeof CHAT_DELIVERY_STATES)[number];
export const CHAT_PUBLICATION_STATES = [
"pending",
"awaiting_consent",
"streaming",
"published",
"retry",
"delivery_unknown",
"failed",
"cancelled",
] as const;
export type ChatPublicationState = (typeof CHAT_PUBLICATION_STATES)[number];
export const CHAT_FILE_TRANSFER_PHASES = [
"consent_pending",
"consent_sending",
"consent_unknown",
"awaiting_consent",
"upload_pending",
"uploading",
"upload_unknown",
"file_info_pending",
"file_info_sending",
"file_info_unknown",
"delivered",
"declined",
"expired",
"cancelled",
"conflict",
] as const;
export type ChatFileTransferPhase = (typeof CHAT_FILE_TRANSFER_PHASES)[number];
/** Closed presentation only: never provider URLs, credentials or private state. */
export interface ChatFileTransferSummary {
provider: "microsoft-teams";
phase: ChatFileTransferPhase;
filename: string;
expiresAt?: string | null;
/** Monotonic durable row revision, not a schema version. */
version: number;
}
export type ChatFileTransferResolutionPrecondition = Pick<
ChatFileTransferSummary,
"phase" | "version"
>;
export const CHAT_CONVERSATION_STATES = [
"active",
"waiting",
"completed",
"unavailable",
"endpoint_removed",
] as const;
export type ChatConversationState = (typeof CHAT_CONVERSATION_STATES)[number];
export const CHAT_PRINCIPAL_KINDS = ["user", "bot", "app", "system"] as const;
export type ChatPrincipalKind = (typeof CHAT_PRINCIPAL_KINDS)[number];
export const CHAT_IDENTITY_LINK_STATUSES = [
"pending",
"linked",
"revoked",
"expired",
] as const;
export type ChatIdentityLinkStatus =
(typeof CHAT_IDENTITY_LINK_STATUSES)[number];
export const CHAT_RESOURCE_AVAILABILITIES = [
"available",
"unavailable",
"removed",
] as const;
export type ChatResourceAvailability =
(typeof CHAT_RESOURCE_AVAILABILITIES)[number];
export interface ChatAdapterCapabilities {
threads: boolean;
directMessages: boolean;
nativeStreaming: boolean;
messageEdits: boolean;
messageDeletes: boolean;
reactions: boolean;
files: boolean;
cards: boolean;
actions: boolean;
modals: boolean;
slashCommands: boolean;
ephemeralMessages: boolean;
proactiveDirectMessages: boolean;
}
export interface ChatEndpointBehaviorPolicy {
/** Defaults to queue and is not exposed in the initial settings UI. */
concurrency: ChatConcurrencyPolicy;
allowDirectMessages: boolean;
allowGroupChats: boolean;
allowUnlinkedPeople: boolean;
}
export const CHAT_CALLBACK_SURFACE_STATUSES = [
"current",
"stale",
"unverified",
] as const;
export type ChatCallbackSurfaceStatus =
(typeof CHAT_CALLBACK_SURFACE_STATUSES)[number];
export interface ChatEndpointCallbackSurfaceState {
status: ChatCallbackSurfaceStatus;
observedAt?: string | null;
}
export interface ChatEndpointCallbackSurfaces {
events: ChatEndpointCallbackSurfaceState;
interactivity: ChatEndpointCallbackSurfaceState;
slashCommands: ChatEndpointCallbackSurfaceState;
}
export interface ChatEndpointSetupState {
step: "choose_agent" | "provider_setup" | "test" | "complete";
/** Server-generated boundary; only provider events at or after this time can complete setup. */
testStartedAt?: string | null;
/** Set only after the provider has delivered a signed callback challenge. */
webhookVerifiedAt?: string | null;
authorizationUrl?: string | null;
providerUrl?: string | null;
command?: string | null;
webhookUrl?: string | null;
messagingEndpoint?: string | null;
/** Safe presence signal only; the secret value is returned once by its generation endpoint. */
webhookSecretConfigured?: boolean;
/** Provider callback surfaces observed at the endpoint's current public URL. */
callbackSurfaces?: ChatEndpointCallbackSurfaces;
/** True when at least one previously observed callback still targets an old public URL. */
callbacksNeedUpdate?: boolean;
}
export interface ChatEndpointSetupSecret {
webhookSecret: string;
}
export interface ChatEndpoint {
id: string;
companyId: string;
connectionId: string;
provider: ChatProvider;
publicId: string;
status: ChatEndpointStatus;
deploymentMode: ChatDeploymentMode;
assignedAgentId: string;
assignedAgentName?: string | null;
sponsorUserId?: string | null;
providerAccountId?: string | null;
providerAccountLabel?: string | null;
botExternalId?: string | null;
botUsername?: string | null;
botLabel?: string | null;
botAvatarUrl?: string | null;
allowDirectMessages: boolean;
allowGroupChats: boolean;
allowUnlinkedPeople: boolean;
replyMode: "subscribed";
capabilities: ChatAdapterCapabilities;
setup: ChatEndpointSetupState;
healthMessage?: string | null;
lastError?: string | null;
lastActivityAt?: string | null;
lastPublicationAt?: string | null;
activatedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface ChatEndpointResource {
id: string;
companyId: string;
endpointId: string;
type: string;
providerResourceId: string;
parentProviderResourceId?: string | null;
label: string;
detail?: string | null;
providerUrl?: string | null;
availability: ChatResourceAvailability;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface ChatExternalPrincipal {
id: string;
companyId: string;
provider: ChatProvider;
providerAccountId: string;
externalId: string;
kind: ChatPrincipalKind;
displayName?: string | null;
handle?: string | null;
avatarUrl?: string | null;
isBot: boolean;
lastSeenAt?: string | null;
}
export interface ChatIdentityLink {
id: string;
companyId: string;
endpointId: string;
principalId: string;
externalLabel: string;
externalDetail?: string | null;
paperclipUserId?: string | null;
paperclipUserLabel?: string | null;
status: ChatIdentityLinkStatus;
expiresAt?: string | null;
confirmedAt?: string | null;
revokedAt?: string | null;
}
export interface ChatConversation {
id: string;
companyId: string;
endpointId: string;
resourceId?: string | null;
issueId: string;
issueIdentifier?: string | null;
issueTitle?: string | null;
externalConversationId: string;
externalThreadId: string;
sessionGeneration: number;
externalLabel: string;
externalUrl?: string | null;
isDirectMessage: boolean;
state: ChatConversationState;
lastPublicationStatus?: ChatPublicationState | null;
lastActivityAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface ChatDelivery {
id: string;
companyId: string;
endpointId: string;
conversationId?: string | null;
principalId?: string | null;
providerEventId: string;
deduplicationKey: string;
eventKind: ChatEventKind;
state: ChatDeliveryState;
attempts: number;
summary?: string | null;
redactedError?: string | null;
receivedAt: string;
processedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export type SafeExternalChatCardKind = "status" | "question" | "confirmation";
export type SafeExternalChatCardAction =
| {
type: "callback";
actionId: string;
label: string;
style?: "default" | "primary" | "danger";
}
| {
type: "link";
label: string;
url: string;
};
export interface SafeExternalChatCard {
schema: "paperclip.chat.card.v1";
kind: SafeExternalChatCardKind;
title: string;
body?: string;
actions?: SafeExternalChatCardAction[];
}
export interface SafeChatPublicationPayload {
text: string;
attachmentIds?: string[];
interactionId?: string;
card?: SafeExternalChatCard;
/** Server-managed metadata for a logical publication split into durable messages. */
transportPart?: {
batchId: string;
count: number;
index: number;
/** Server-managed provider rendering for this transport part. */
mode?:
"inline" | "discord_markdown_attachment" | "telegram_markdown_attachment";
orderKey: string;
/** Closed, server-generated Markdown fence wrappers; text remains an exact source slice. */
prefix?: string;
suffix?: string;
};
progressState?:
| "queued"
| "working"
| "waiting_for_input"
| "approval_needed"
| "completed"
| "failed";
}
export interface ChatPublication {
id: string;
companyId: string;
endpointId: string;
conversationId: string;
issueId: string;
commentId?: string | null;
idempotencyKey: string;
state: ChatPublicationState;
providerMessageId?: string | null;
providerUrl?: string | null;
attempts: number;
redactedError?: string | null;
createdAt: string;
updatedAt: string;
publishedAt?: string | null;
}
export interface ChatPublicationSummary {
id: string;
state: ChatPublicationState;
providerUrl?: string | null;
attempts: number;
redactedError?: string | null;
nextAttemptAt?: string | null;
publishedAt?: string | null;
fileTransfer?: ChatFileTransferSummary;
}
export interface ChatPublicationBatchStatus {
/** First unresolved part; a terminal nonpublished part for mixed outcomes. */
publication: ChatPublicationSummary;
total: number;
published: number;
/** Additive during rolling upgrades; missing settlement proof is not permission to dismiss. */
parts?: ChatPublicationSummary[];
awaitingConsent?: number;
declined?: number;
expired?: number;
/** Excludes declined and expired. */
cancelled?: number;
/** published + declined + expired + cancelled, never failed or uncertain. */
settled?: number;
canDismiss?: boolean;
}
export interface ChatActivityItem {
id: string;
kind: "delivery" | "publication" | "action" | "health" | "repair";
actionType?:
| "slash_task_start"
| "provider_effect"
| "github_webhook_ingress"
| "slack_session_sync"
| "slack_session_stop";
status: string;
summary: string;
detail?: string | null;
createdAt: string;
replayable?: boolean;
resolutionActions?: Array<"mark_delivered" | "retry_anyway" | "cancel">;
fileTransfer?: ChatFileTransferSummary;
}
export interface ExternalChannelBindingSummary {
endpointId: string;
provider: ChatProvider;
botLabel?: string | null;
externalLabel: string;
externalUrl?: string | null;
conversationId: string;
publicationState?: ChatPublicationState | null;
assignedAgentLocked: true;
}
export interface CreateChatEndpointInput {
provider: ChatProvider;
assignedAgentId: string;
applicationId?: string;
name?: string;
}
export interface UpdateChatEndpointInput {
allowDirectMessages?: boolean;
allowGroupChats?: boolean;
allowUnlinkedPeople?: boolean;
}
export interface ConfigureChatEndpointInput {
action: "configure" | "verify" | "pause" | "resume" | "reconnect" | "remove";
credentials?: Record<string, string>;
}
export interface NormalizedChatEvent {
providerEventId: string;
kind: ChatEventKind;
providerAccountId: string;
principal: {
externalId: string;
kind: ChatPrincipalKind;
displayName?: string;
handle?: string;
isBot?: boolean;
};
resource: {
type: string;
providerResourceId: string;
parentProviderResourceId?: string;
label: string;
providerUrl?: string;
};
conversation: {
externalConversationId: string;
externalThreadId?: string;
label: string;
providerUrl?: string;
isDirectMessage?: boolean;
};
message?: {
providerMessageId: string;
text: string;
mentionedBot?: boolean;
replyToProviderMessageId?: string;
attachmentUrls?: string[];
};
raw: Record<string, unknown>;
}

View File

@ -507,6 +507,7 @@ export type {
ToolConnectionCredentialSource,
ToolConnectionCredentialPolicy,
ToolConnectionOwnership,
ToolConnectionPurpose,
ToolConnectionTransport,
ToolConnectionStatus,
ToolConnectionKind,

View File

@ -68,6 +68,7 @@ export type {
export type ToolActorType = "agent" | "user" | "system" | "plugin";
export type ToolConnectionTransport = "mcp_remote" | "rest_api" | "local_stdio";
export type ToolConnectionPurpose = "tool" | "channel";
export type ToolConnectionAuthKind = "oauth" | "api_key" | "none";
export type ToolConnectionOwnership = "platform_shared" | "platform_provisioned" | "customer" | "dcr";
export type ToolConnectionCredentialSource = "paperclip_vault" | "vercel_connect";

View File

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
configureChatEndpointSchema,
microsoftTeamsCredentialIdSchema,
resolveChatActionSchema,
resolveChatPublicationSchema,
chatPublicationStateSchema,
} from "./chat-channels.js";
describe("Microsoft Teams chat credential validation", () => {
it("normalizes canonical Entra application and tenant UUIDs", () => {
const parsed = configureChatEndpointSchema.parse({
action: "configure",
credentials: {
clientId: " 76D0CB17-5EC4-4B3D-983B-DA8A01DC02C4 ",
tenantId: "F8CDEF31-A31E-4B4A-93E4-5F571E91255A",
clientSecret: "keep-case-sensitive-secret",
},
});
expect(parsed.credentials).toEqual({
clientId: "76d0cb17-5ec4-4b3d-983b-da8a01dc02c4",
tenantId: "f8cdef31-a31e-4b4a-93e4-5f571e91255a",
clientSecret: "keep-case-sensitive-secret",
});
});
it.each([
"common",
"organizations",
"contoso.onmicrosoft.com",
"76d0cb175ec44b3d983bda8a01dc02c4",
"00000000-0000-0000-0000-000000000000",
])("rejects non-canonical Teams credential id %s", (value) => {
expect(microsoftTeamsCredentialIdSchema.safeParse(value).success).toBe(
false,
);
expect(
configureChatEndpointSchema.safeParse({
action: "configure",
credentials: {
clientId: "76d0cb17-5ec4-4b3d-983b-da8a01dc02c4",
tenantId: value,
clientSecret: "secret",
},
}).success,
).toBe(false);
});
it("does not impose Teams UUID rules on unrelated provider credentials", () => {
expect(
configureChatEndpointSchema.parse({
action: "configure",
credentials: {
botToken: "xoxb-test",
signingSecret: "secret",
},
}),
).toEqual({
action: "configure",
credentials: {
botToken: "xoxb-test",
signingSecret: "secret",
},
});
});
});
describe("chat provider-action resolution validation", () => {
it("accepts consent waiting and a closed versioned file-stage precondition", () => {
expect(chatPublicationStateSchema.parse("awaiting_consent")).toBe(
"awaiting_consent",
);
const request = {
action: "cancel",
fileTransfer: { phase: "upload_unknown", version: 3 },
};
expect(resolveChatPublicationSchema.parse(request)).toEqual(request);
expect(resolveChatPublicationSchema.parse({ action: "cancel" })).toEqual({
action: "cancel",
});
});
it.each([
{ phase: "upload_unknown", version: 0 },
{ phase: "upload_unknown", version: 1.5 },
{ phase: "upload_unknown", version: Number.MAX_SAFE_INTEGER + 1 },
{ phase: "new_unreviewed_phase", version: 1 },
{ phase: "upload_unknown" },
{ phase: "upload_unknown", version: 1, uploadUrl: "private" },
])(
"rejects malformed file-stage resolution preconditions %#",
(fileTransfer) => {
expect(
resolveChatPublicationSchema.safeParse({
action: "cancel",
fileTransfer,
}).success,
).toBe(false);
},
);
it.each(["mark_delivered", "retry_anyway", "cancel"] as const)(
"accepts the explicit %s resolution",
(action) => {
expect(resolveChatActionSchema.parse({ action })).toEqual({ action });
},
);
it("rejects automatic or unknown provider-effect replay modes", () => {
expect(resolveChatActionSchema.safeParse({ action: "retry" }).success).toBe(
false,
);
});
});

View File

@ -0,0 +1,193 @@
import { z } from "zod";
import { multilineTextSchema } from "./text.js";
import {
CHAT_CONCURRENCY_POLICIES,
CHAT_DELIVERY_STATES,
CHAT_ENDPOINT_STATUSES,
CHAT_EVENT_KINDS,
CHAT_FILE_TRANSFER_PHASES,
CHAT_IDENTITY_LINK_STATUSES,
CHAT_PRINCIPAL_KINDS,
CHAT_PROVIDERS,
CHAT_PUBLICATION_STATES,
CHAT_RESOURCE_AVAILABILITIES,
} from "../types/chat-channels.js";
export const chatProviderSchema = z.enum(CHAT_PROVIDERS);
export const chatEndpointStatusSchema = z.enum(CHAT_ENDPOINT_STATUSES);
export const chatConcurrencyPolicySchema = z.enum(CHAT_CONCURRENCY_POLICIES);
export const chatEventKindSchema = z.enum(CHAT_EVENT_KINDS);
export const chatDeliveryStateSchema = z.enum(CHAT_DELIVERY_STATES);
export const chatPublicationStateSchema = z.enum(CHAT_PUBLICATION_STATES);
export const chatPrincipalKindSchema = z.enum(CHAT_PRINCIPAL_KINDS);
export const chatIdentityLinkStatusSchema = z.enum(CHAT_IDENTITY_LINK_STATUSES);
export const chatResourceAvailabilitySchema = z.enum(
CHAT_RESOURCE_AVAILABILITIES,
);
/**
* Microsoft emits Entra application and tenant identifiers in canonical UUID
* form in Bot Framework activities. Tenant aliases such as `common` or an
* `onmicrosoft.com` domain can be accepted by the token endpoint, but cannot
* be compared safely with the activity tenant id used by Paperclip's runtime
* fence. Normalize the UUIDs at the API boundary instead.
*/
export const microsoftTeamsCredentialIdSchema = z
.string()
.trim()
.uuid()
.refine((value) => value !== "00000000-0000-0000-0000-000000000000", {
message: "Microsoft Teams credential IDs cannot be the nil UUID",
})
.transform((value) => value.toLowerCase());
const chatEndpointCredentialsSchema = z
.record(z.string(), z.string().min(1))
.superRefine((credentials, ctx) => {
for (const key of ["clientId", "tenantId"] as const) {
const value = credentials[key];
if (value === undefined) continue;
const parsed = microsoftTeamsCredentialIdSchema.safeParse(value);
if (parsed.success) continue;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `${key} must be a canonical Microsoft Entra UUID`,
});
}
})
.transform((credentials) => {
const normalized = { ...credentials };
for (const key of ["clientId", "tenantId"] as const) {
const value = normalized[key];
if (value !== undefined) {
normalized[key] = microsoftTeamsCredentialIdSchema.parse(value);
}
}
return normalized;
});
export const createChatEndpointSchema = z
.object({
provider: chatProviderSchema,
assignedAgentId: z.string().uuid(),
applicationId: z.string().uuid().optional(),
name: z.string().trim().min(1).max(160).optional(),
})
.strict();
export const updateChatEndpointSchema = z
.object({
allowDirectMessages: z.boolean().optional(),
allowGroupChats: z.boolean().optional(),
allowUnlinkedPeople: z.boolean().optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, {
message: "At least one chat endpoint field is required",
});
export const configureChatEndpointSchema = z
.object({
action: z.enum([
"configure",
"verify",
"pause",
"resume",
"reconnect",
"remove",
]),
credentials: chatEndpointCredentialsSchema.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (
value.credentials &&
value.action !== "configure" &&
value.action !== "reconnect"
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["credentials"],
message: `Credentials are not accepted for the ${value.action} action`,
});
}
});
export const replaceChatEndpointResourcesSchema = z
.object({
resources: z
.array(
z
.object({
id: z.string().uuid(),
enabled: z.boolean(),
})
.strict(),
)
.max(500),
})
.strict();
export const publishChatCommentSchema = z
.object({
commentId: z.string().uuid(),
})
.strict();
export const publishChatBoardMessageSchema = z
.object({
body: multilineTextSchema.pipe(z.string().trim().min(1).max(100_000)),
idempotencyKey: z.string().trim().min(16).max(200),
attachmentIds: z
.array(z.string().uuid())
.max(20)
.refine((ids) => new Set(ids).size === ids.length, {
message: "Attachment ids must be unique",
})
.optional(),
})
.strict();
export const publishChatPublicationSchema = z.union([
publishChatCommentSchema,
publishChatBoardMessageSchema,
]);
export const resolveChatPublicationSchema = z
.object({
action: z.enum(["mark_delivered", "retry_anyway", "cancel"]),
fileTransfer: z
.object({
phase: z.enum(CHAT_FILE_TRANSFER_PHASES),
version: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
})
.strict()
.optional(),
})
.strict();
export const resolveChatActionSchema = z
.object({
action: z.enum(["mark_delivered", "retry_anyway", "cancel"]),
})
.strict();
export const createChatIdentityLinkIntentSchema = z
.object({
expiresInSeconds: z.number().int().min(300).max(86_400).default(1_800),
})
.strict()
.default({ expiresInSeconds: 1_800 });
export const confirmChatIdentityLinkSchema = z
.object({
token: z.string().min(32).max(4096),
})
.strict();
export const replayChatDeliverySchema = z.object({}).strict();
export const chatPublicEndpointIdSchema = z
.string()
.regex(/^[a-zA-Z0-9_-]{32,128}$/);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
diff --git a/dist/index.js b/dist/index.js
index 32d5ca33adadd3928983ae76d78949190320cdab..056ab06ceb047e4bb08c2b0dacfd8b2a41540ec1 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -8,7 +8,7 @@ import {
} from "@chat-adapter/shared";
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
-import { ConsoleLogger, convertEmojiPlaceholders, Message } from "chat";
+import { ConsoleLogger, convertEmojiPlaceholders, Message, toPlainText } from "chat";
// src/cards.ts
import { renderGfmTable } from "@chat-adapter/shared";
@@ -144,6 +144,55 @@ var GitHubFormatConverter = class extends BaseFormatConverter {
}
};
+// GitHub comments commonly represent uploaded files as Markdown images. The
+// shared plain-text converter preserves their label but drops the destination,
+// which makes the uploaded artifact impossible to recover downstream. Append
+// a bounded set of safe HTTPS destinations without treating them as fetchable
+// attachments or retaining credential-bearing URLs.
+function safeGitHubMarkdownUrl(value) {
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) {
+ return null;
+ }
+ let parsed;
+ try {
+ parsed = new URL(value);
+ } catch {
+ return null;
+ }
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password) {
+ return null;
+ }
+ return parsed.href;
+}
+function normalizedGitHubCommentText(formatted) {
+ const plainText = toPlainText(formatted);
+ const urls = [];
+ const seen = /* @__PURE__ */ new Set();
+ const stack = [formatted];
+ let visited = 0;
+ while (stack.length > 0 && urls.length < 32 && visited < 1e4) {
+ const node = stack.pop();
+ visited += 1;
+ if (node && typeof node === "object") {
+ if (node.type === "link" || node.type === "image" || node.type === "definition") {
+ const url = safeGitHubMarkdownUrl(node.url);
+ if (url && !seen.has(url)) {
+ seen.add(url);
+ urls.push(url);
+ }
+ }
+ if (Array.isArray(node.children)) {
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
+ stack.push(node.children[index]);
+ }
+ }
+ }
+ }
+ const missingUrls = urls.filter((url) => !plainText.includes(url));
+ if (missingUrls.length === 0) return plainText;
+ return [plainText, ...missingUrls].filter(Boolean).join("\n");
+}
+
// src/index.ts
var REVIEW_COMMENT_THREAD_PATTERN = /^([^/]+)\/([^:]+):(\d+):rc:(\d+)$/;
var ISSUE_THREAD_PATTERN = /^([^/]+)\/([^:]+):issue:(\d+)$/;
@@ -677,11 +726,12 @@ var GitHubAdapter = class {
*/
parseIssueComment(comment, repository, prNumber, threadId, threadType = "pr") {
const author = this.parseAuthor(comment.user);
+ const formatted = this.formatConverter.toAst(comment.body);
return new Message({
id: comment.id.toString(),
threadId,
- text: this.formatConverter.extractPlainText(comment.body),
- formatted: this.formatConverter.toAst(comment.body),
+ text: normalizedGitHubCommentText(formatted),
+ formatted,
raw: {
type: "issue_comment",
comment,
@@ -709,11 +759,12 @@ var GitHubAdapter = class {
*/
parseReviewComment(comment, repository, prNumber, threadId) {
const author = this.parseAuthor(comment.user);
+ const formatted = this.formatConverter.toAst(comment.body);
return new Message({
id: comment.id.toString(),
threadId,
- text: this.formatConverter.extractPlainText(comment.body),
- formatted: this.formatConverter.toAst(comment.body),
+ text: normalizedGitHubCommentText(formatted),
+ formatted,
raw: {
type: "review_comment",
comment,

View File

@ -0,0 +1,519 @@
diff --git a/dist/index.js b/dist/index.js
index b80888b6d87e8ad429d18cc955c4c5048a55722a..081e3b1664d3e65d1decf82bbf314909a36c143d 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2154,6 +2154,7 @@
const command = params.get("command") || "";
const text = params.get("text") || "";
const userId = params.get("user_id") || "";
+ const userName = params.get("user_name") || userId;
const channelId = params.get("channel_id") || "";
const triggerId = params.get("trigger_id") || void 0;
this.logger.debug("Processing Slack slash command", {
@@ -2163,14 +2164,13 @@
channelId,
triggerId
});
- const userInfo = await this.lookupUser(userId);
const event = {
command,
text,
user: {
userId,
- userName: userInfo?.displayName ?? userId,
- fullName: userInfo?.realName ?? userId,
+ userName,
+ fullName: userName,
isBot: false,
isMe: false
},
@@ -2179,8 +2179,11 @@
triggerId,
channelId: channelId ? `slack:${channelId}` : ""
};
- this.chat.processSlashCommand(event, options);
- return new Response("", { status: 200 });
+ await this.chat.processSlashCommand(event, options);
+ return Response.json({
+ response_type: "ephemeral",
+ text: "Paperclip received this command."
+ });
}
/**
* Handle block_actions payload (button clicks in Block Kit).
@@ -2706,7 +2709,8 @@
"Content-Type": "application/json",
"x-slack-socket-token": this.socketForwardingSecret
},
- body: JSON.stringify(event)
+ body: JSON.stringify(event),
+ signal: AbortSignal.timeout(45e3)
});
if (response.ok) {
this.logger.debug("Socket event forwarded successfully", {
@@ -2894,8 +2898,19 @@
}
}
const previousMessage = event.previous_message;
+ // File edits can retain both text and edited.ts. Compare only attachment
+ // identity and consumed metadata, not expiring URLs or unfurl metadata.
+ const fileRevision = (files) => JSON.stringify(
+ (Array.isArray(files) ? files : []).map((value) => {
+ const file = value && typeof value === "object" && !Array.isArray(value) ? value : {};
+ return [
+ ...[file.id, file.name, file.mimetype].map((field) => typeof field === "string" ? field : null),
+ ...[file.size, file.original_w, file.original_h].map((field) => typeof field === "number" && Number.isFinite(field) ? field : null)
+ ];
+ })
+ );
const isHiddenMessageEdit = Boolean(
- previousMessage && (inner.edited?.ts !== previousMessage.edited?.ts || inner.text !== previousMessage.text)
+ previousMessage && (inner.edited?.ts !== previousMessage.edited?.ts || inner.text !== previousMessage.text || fileRevision(inner.files) !== fileRevision(previousMessage.files))
);
if (event.hidden === true && !isHiddenMessageEdit) {
return;
@@ -3900,17 +3915,22 @@
const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId);
const threadTs = rawThreadTs || void 0;
try {
- let uploadedFileIds;
+ let uploadedFiles;
const files = extractFiles(message);
if (files.length > 0) {
- uploadedFileIds = await this.uploadFiles(files, channel, threadTs);
+ uploadedFiles = await this.uploadFiles(
+ files,
+ channel,
+ threadTs,
+ this.paperclipFileUploadReceiptContext?.getStore?.()
+ );
const hasText = typeof message === "string" || typeof message === "object" && message !== null && ("raw" in message && message.raw || "markdown" in message && message.markdown || "ast" in message && message.ast);
const card2 = extractCard(message);
if (!(hasText || card2)) {
return {
- id: `file-${Date.now()}`,
+ id: uploadedFiles.messageId,
threadId,
- raw: { files, uploadedFileIds }
+ raw: { files, uploadedFileIds: uploadedFiles.fileIds }
};
}
}
@@ -3946,7 +3966,7 @@
return {
id: result2.ts,
threadId,
- raw: uploadedFileIds === void 0 ? result2 : { ...result2, uploadedFileIds }
+ raw: uploadedFiles === void 0 ? result2 : { ...result2, uploadedFileIds: uploadedFiles.fileIds }
};
}
const payload = this.formatConverter.toSlackPayload(message);
@@ -3971,7 +3991,7 @@
return {
id: result.ts,
threadId,
- raw: uploadedFileIds === void 0 ? result : { ...result, uploadedFileIds }
+ raw: uploadedFiles === void 0 ? result : { ...result, uploadedFileIds: uploadedFiles.fileIds }
};
} catch (error) {
this.handleSlackError(error);
@@ -4183,7 +4203,7 @@
* Upload files to Slack and share them to a channel.
* Returns the file IDs of uploaded files.
*/
- async uploadFiles(files, channel, threadTs) {
+ async uploadFiles(files, channel, threadTs, onFileUploadAccepted) {
const bufferResults = await Promise.all(
files.map(async (file) => {
try {
@@ -4205,7 +4225,9 @@
(result2) => result2 !== null
);
if (fileUploads.length === 0) {
- return [];
+ const error = new Error("Slack file upload could not prepare any files");
+ error.name = "ValidationError";
+ throw error;
}
this.logger.debug("Slack API: files.uploadV2 (batch)", {
fileCount: fileUploads.length,
@@ -4219,15 +4241,158 @@
const result = await this._client.files.uploadV2(uploadArgs);
this.logger.debug("Slack API: files.uploadV2 response", { ok: result.ok });
const fileIds = [];
+ const completedFiles = [];
if (result.files?.[0]?.files) {
- for (const uploadedFile of result.files[0].files) {
- if (uploadedFile.id) {
- fileIds.push(uploadedFile.id);
+ for (const completion of result.files) {
+ if (Array.isArray(completion.files)) {
+ for (const uploadedFile of completion.files) {
+ completedFiles.push(uploadedFile);
+ if (uploadedFile.id) {
+ fileIds.push(uploadedFile.id);
+ }
+ }
}
}
}
- return fileIds;
+ if (fileIds.length !== fileUploads.length || new Set(fileIds).size !== fileIds.length || fileIds.some((fileId) => typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId))) {
+ throw new Error(
+ "Slack file upload completed, but its message identity could not be confirmed"
+ );
+ }
+ if (typeof onFileUploadAccepted === "function") {
+ await onFileUploadAccepted({
+ version: 1,
+ fileIds: [...fileIds],
+ channelId: channel,
+ threadTs: threadTs ?? null
+ });
+ }
+ let messageId = this.slackFileShareMessageId(
+ completedFiles,
+ channel,
+ threadTs
+ );
+ if (!messageId) {
+ try {
+ messageId = await this.paperclipResolveFileUploadMessageId(fileIds, channel, threadTs);
+ } catch {
+ throw new Error(
+ "Slack file upload completed, but its message identity could not be confirmed"
+ );
+ }
+ }
+ if (!messageId) {
+ throw new Error(
+ "Slack file upload completed, but its message identity could not be confirmed"
+ );
+ }
+ return { fileIds, messageId };
}
+ async paperclipResolveFileUploadReceipt(fileIds, threadId) {
+ if (!Array.isArray(fileIds) || fileIds.length === 0 || fileIds.length > 20 || new Set(fileIds).size !== fileIds.length || fileIds.some((fileId) => typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId))) {
+ const error = new Error("Slack file upload receipt is invalid");
+ error.name = "ValidationError";
+ throw error;
+ }
+ const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId);
+ try {
+ return await this.paperclipResolveFileUploadMessageId(fileIds, channel, rawThreadTs || void 0);
+ } catch (error) {
+ this.handleSlackError(error);
+ }
+ }
+ async paperclipResolveFileUploadMessageId(fileIds, channel, threadTs) {
+ const deadline = Date.now() + 5e3;
+ const retryDelays = [100, 250, 500, 1e3];
+ let lookupAttempt = 0;
+ while (Date.now() < deadline) {
+ let timeout;
+ let messageId = null;
+ try {
+ const lookup = Promise.all(
+ fileIds.map(async (file) => {
+ const args = await this.withToken({ file });
+ if (Date.now() >= deadline) {
+ throw new Error("Slack file identity lookup timed out");
+ }
+ return this._client.files.info(args);
+ })
+ );
+ const infos = await Promise.race([
+ lookup,
+ new Promise((_, reject) => {
+ timeout = setTimeout(
+ () => reject(new Error("Slack file identity lookup timed out")),
+ Math.max(1, deadline - Date.now())
+ );
+ timeout.unref?.();
+ })
+ ]);
+ const infoFiles = infos.map((info) => info.file);
+ if (infoFiles.some((file, index) => !file || file.id !== fileIds[index])) {
+ const error = new Error("Slack file identity lookup did not match the uploaded files");
+ error.name = "ValidationError";
+ throw error;
+ }
+ messageId = this.slackFileShareMessageId(infoFiles, channel, threadTs);
+ } finally {
+ clearTimeout(timeout);
+ }
+ if (messageId) return messageId;
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) break;
+ const delay = Math.min(
+ retryDelays[Math.min(lookupAttempt, retryDelays.length - 1)],
+ remaining
+ );
+ lookupAttempt += 1;
+ await new Promise((resolve) => {
+ const retryTimer = setTimeout(resolve, delay);
+ retryTimer.unref?.();
+ });
+ }
+ return null;
+ }
+ slackFileShareMessageId(files, channel, threadTs) {
+ if (!Array.isArray(files) || files.length === 0) {
+ return null;
+ }
+ const messageIdsByFile = files.map((file) => {
+ const messageIds = /* @__PURE__ */ new Set();
+ const shares = file?.shares;
+ if (!shares || typeof shares !== "object") {
+ return messageIds;
+ }
+ for (const group of Object.values(shares)) {
+ if (!group || typeof group !== "object") {
+ continue;
+ }
+ const channelShares = group[channel];
+ if (!Array.isArray(channelShares)) {
+ continue;
+ }
+ for (const share of channelShares) {
+ if (!share || typeof share !== "object" || typeof share.ts !== "string" || !/^\d+\.\d+$/.test(share.ts)) {
+ continue;
+ }
+ const shareThreadTs = typeof share.thread_ts === "string" && share.thread_ts ? share.thread_ts : void 0;
+ if (threadTs ? shareThreadTs !== threadTs : shareThreadTs !== void 0) {
+ continue;
+ }
+ messageIds.add(share.ts);
+ }
+ }
+ return messageIds;
+ });
+ if (messageIdsByFile.some((messageIds) => messageIds.size === 0)) {
+ return null;
+ }
+ const [first, ...rest] = messageIdsByFile;
+ const sharedMessageIds = [...first].filter(
+ (messageId) => rest.every((messageIds) => messageIds.has(messageId))
+ );
+ return sharedMessageIds.length === 1 ? sharedMessageIds[0] : null;
+ }
async editMessage(threadId, messageId, _message) {
const message = await this.resolveMessageMentions(_message, threadId);
const ephemeral = this.decodeEphemeralMessageId(messageId);
@@ -4622,6 +4787,8 @@
this.logger.debug("Slack: starting stream", { channel, threadTs });
const token = await this.getToken();
const streamer = this._client.chatStream({
+ // Keep the Web API's batching, but account for its pending tail below.
+ buffer_size: 256,
channel,
thread_ts: threadTs,
...options?.recipientUserId && {
@@ -4667,6 +4834,72 @@
}
};
const fallback = { message: null, mode: "native", nativeRendered: false };
+ let nativePendingLength = 0;
+ let nativeMessageTs = null;
+ const nativeDeliveryUnknown = () => new NetworkError(
+ "slack",
+ "Slack native stream delivery could not be confirmed"
+ );
+ const confirmNativeResponse = (response) => {
+ if (response?.ok !== true || typeof response.ts !== "string" ||
+ response.ts.length > 128 || !/^\d+\.\d+$/.test(response.ts) ||
+ response.channel !== void 0 && response.channel !== channel ||
+ nativeMessageTs !== null && response.ts !== nativeMessageTs ||
+ response.message?.ts !== void 0 && response.message.ts !== response.ts) {
+ throw nativeDeliveryUnknown();
+ }
+ nativeMessageTs = response.ts;
+ fallback.nativeRendered = true;
+ nativePendingLength = 0;
+ };
+ const appendNative = async (args) => {
+ const response = await streamer.append({ ...args, token });
+ if (response === null) {
+ nativePendingLength += args.markdown_text?.length ?? 0;
+ } else {
+ // Record each awaited receipt before another fragment can fail.
+ confirmNativeResponse(response);
+ }
+ };
+ const appendRendered = async (delta) => {
+ let part = "";
+ // Preserve complete Slack links/mentions, escaped entities and Unicode
+ // scalars. Never silently truncate or split an oversized opaque token.
+ for (const [atom] of delta.matchAll(/<[^<>\r\n]*>|&(?:amp|lt|gt);|[\s\S]/gu)) {
+ if (atom.length > 12e3) {
+ throw new ValidationError("slack", "Rendered Slack token exceeds the native stream limit");
+ }
+ if (nativePendingLength + part.length + atom.length > 12e3) {
+ if (part.length > 0) {
+ await appendNative({ markdown_text: part });
+ part = "";
+ }
+ if (nativePendingLength + atom.length > 12e3) {
+ // A whole token fits, but not alongside the Web API's <256 tail.
+ await appendNative({ chunks: [] });
+ }
+ }
+ part += atom;
+ }
+ if (part.length > 0) {
+ await appendNative({ markdown_text: part });
+ }
+ };
+ const definiteNativeUnsupported = (error) => {
+ const code = slackPlatformErrorCode(error);
+ return error?.data?.ok === false && code !== void 0 && NATIVE_STREAMING_UNSUPPORTED_ERRORS.has(code);
+ };
+ const rethrowNativeFailure = (error) => {
+ // A later explicit rejection does not undo an already accepted prefix.
+ // Do not expose its retryable/definite code as the whole send's outcome.
+ const code = slackPlatformErrorCode(error);
+ if (fallback.nativeRendered || fallback.message !== null ||
+ error?.code === "slack_webapi_platform_error" && error.data?.ok !== false ||
+ code === "internal_error" || code === "fatal_error") {
+ throw nativeDeliveryUnknown();
+ }
+ throw error;
+ };
const updateIntervalMs = options?.updateIntervalMs ?? 1e3;
let fallbackSent = "";
let lastFallbackEditAt = 0;
@@ -4709,17 +4942,11 @@
return;
}
try {
- const response = await streamer.append({
- markdown_text: delta,
- token
- });
- if (response) {
- fallback.nativeRendered = true;
- }
+ await appendRendered(delta);
lastAppended = resolvedCommitted;
} catch (error) {
- if (fallback.nativeRendered) {
- throw error;
+ if (fallback.nativeRendered || !definiteNativeUnsupported(error)) {
+ rethrowNativeFailure(error);
}
switchToFallback(error);
await flushFallback(force);
@@ -4736,12 +4963,15 @@
return;
}
try {
- await streamer.append({
- chunks: [chunk],
- token
- });
- fallback.nativeRendered = true;
+ await appendNative({ chunks: [chunk] });
} catch (error) {
+ const code = slackPlatformErrorCode(error);
+ if (fallback.nativeRendered || error?.data?.ok !== false || ![
+ "invalid_chunks", "invalid_blocks", "missing_scope",
+ ...NATIVE_STREAMING_UNSUPPORTED_ERRORS
+ ].includes(code)) {
+ rethrowNativeFailure(error);
+ }
structuredChunksSupported = false;
this.logger.warn(
"Structured streaming chunk failed, falling back to text-only streaming. Ensure your Slack app manifest includes the agent/assistant feature and the assistant:write scope",
@@ -4749,22 +4979,28 @@
);
}
};
- for await (const chunk of textStream) {
- if (options?.signal?.aborted) {
- break;
+ try {
+ for await (const chunk of textStream) {
+ if (options?.signal?.aborted) {
+ break;
+ }
+ if (typeof chunk === "string") {
+ renderer.push(chunk);
+ await flushCommitted();
+ } else if (chunk.type === "markdown_text") {
+ renderer.push(chunk.text);
+ await flushCommitted();
+ } else {
+ await sendStructuredChunk(chunk);
+ }
}
- if (typeof chunk === "string") {
- renderer.push(chunk);
- await flushCommitted();
- } else if (chunk.type === "markdown_text") {
- renderer.push(chunk.text);
- await flushCommitted();
- } else {
- await sendStructuredChunk(chunk);
- }
+ renderer.finish();
+ await flushCommitted(true);
+ } catch (error) {
+ // Iterator/renderer/mention lookup failures also follow an irreversible
+ // accepted native prefix; never classify them as safe whole-send retry.
+ rethrowNativeFailure(error);
}
- renderer.finish();
- await flushCommitted(true);
if (fallback.mode === "fallback") {
if (options?.stopBlocks || this.feedbackButtons) {
this.logger.warn(
@@ -4775,7 +5011,7 @@
this.logger.debug("Slack: fallback stream complete", {
messageId: fallback.message?.id
});
- await this.endTyping(threadId, options?.sessionStatus ?? "active");
+ await this.endTyping(threadId, options?.sessionStatus ?? "active").catch(rethrowNativeFailure);
return fallback.message;
}
const stopBlocks = [
@@ -4784,21 +5020,27 @@
];
let result;
try {
+ if (!fallback.nativeRendered) {
+ // Observe the initial receipt even when all text is still buffered;
+ // stop() otherwise hides its internal startStream response.
+ await appendNative({ chunks: [] });
+ }
result = await streamer.stop({
token,
...this.agentView ? { session_status: options?.sessionStatus ?? "active" } : {},
...stopBlocks.length > 0 ? { blocks: stopBlocks } : {}
});
+ confirmNativeResponse(result);
} catch (error) {
- if (fallback.nativeRendered) {
- throw error;
+ if (fallback.nativeRendered || !definiteNativeUnsupported(error)) {
+ rethrowNativeFailure(error);
}
switchToFallback(error);
await flushFallback(true);
this.logger.debug("Slack: fallback stream complete", {
messageId: fallback.message?.id
});
- await this.endTyping(threadId, options?.sessionStatus ?? "active");
+ await this.endTyping(threadId, options?.sessionStatus ?? "active").catch(rethrowNativeFailure);
return fallback.message;
}
const messageTs = result.message?.ts ?? result.ts;
@@ -5636,7 +5878,8 @@
const response = await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload)
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(45e3)
});
if (!response.ok) {
const errorText = await response.text();

View File

@ -0,0 +1,141 @@
diff --git a/dist/index.js b/dist/index.js
index b0383953a27e4426ce544128a26bf3c8d8964b06..9ae852cba707495c3e72b0e07523cb6fca2e197a 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -163,7 +163,6 @@ var BridgeHttpAdapter = class {
}
async dispatch(request, options) {
const body = await request.text();
- this.logger.debug("Teams webhook raw body", { body });
let parsedBody;
try {
parsedBody = JSON.parse(body);
@@ -504,6 +503,52 @@ import {
NetworkError as NetworkError2,
PermissionError
} from "@chat-adapter/shared";
+function teamsErrorRecord(value) {
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
+}
+function teamsErrorBody(value) {
+ if (typeof value === "string") {
+ if (value.length > 16384) return void 0;
+ try {
+ return teamsErrorRecord(JSON.parse(value));
+ } catch {
+ return void 0;
+ }
+ }
+ return teamsErrorRecord(value);
+}
+function teamsErrorCode(value) {
+ return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(value) ? value : void 0;
+}
+function teamsErrorCodes(value, depth = 0, seen = /* @__PURE__ */ new Set()) {
+ if (depth > 4) return [];
+ const record = teamsErrorBody(value);
+ if (!record || seen.has(record)) return [];
+ seen.add(record);
+ const codes = [teamsErrorCode(record.code), teamsErrorCode(record.subCode), teamsErrorCode(record.subcode)].filter(Boolean);
+ for (const key of ["error", "innerError", "details", "body", "data"]) {
+ codes.push(...teamsErrorCodes(record[key], depth + 1, seen));
+ }
+ return [...new Set(codes)].slice(0, 8);
+}
+function teamsPermissionError(error, operation, statusCode) {
+ const err = teamsErrorRecord(error) || {};
+ const innerError = teamsErrorRecord(err.innerHttpError);
+ const response = teamsErrorRecord(innerError?.response) || teamsErrorRecord(err.response);
+ const bodies = [innerError, response, err];
+ const providerCodes = [...new Set(bodies.flatMap((body) => teamsErrorCodes(body)))].slice(0, 8);
+ const permissionError = new PermissionError("teams", operation);
+ permissionError.status = statusCode;
+ permissionError.statusCode = statusCode;
+ permissionError.providerCodes = providerCodes;
+ permissionError.subCode = providerCodes.find((code) => code === "MessageWritesBlocked" || code === "ForbiddenOperationException");
+ permissionError.details = {
+ providerStatus: statusCode,
+ providerCodes,
+ ...permissionError.subCode ? { providerSubCode: permissionError.subCode } : {}
+ };
+ return permissionError;
+}
function handleTeamsError(error, operation) {
if (error && typeof error === "object") {
const err = error;
@@ -516,7 +561,7 @@ function handleTeamsError(error, operation) {
);
}
if (statusCode === 403 || err.message && typeof err.message === "string" && err.message.toLowerCase().includes("permission")) {
- throw new PermissionError("teams", operation);
+ throw teamsPermissionError(error, operation, statusCode);
}
if (statusCode === 404) {
throw new NetworkError2(
@@ -560,18 +605,15 @@ function encodeThreadId(platformData) {
const encodedConversationId = Buffer.from(
platformData.conversationId
).toString("base64url");
- const encodedServiceUrl = Buffer.from(platformData.serviceUrl).toString(
- "base64url"
- );
const conversationType = platformData.conversationType;
const legacyIsDM = !platformData.conversationId.startsWith("19:");
const explicitIsDM = conversationType === "personal";
const needsConversationType = conversationType !== void 0 && explicitIsDM !== legacyIsDM;
- return needsConversationType ? `teams:${encodedConversationId}:${encodedServiceUrl}:${conversationType}` : `teams:${encodedConversationId}:${encodedServiceUrl}`;
+ return needsConversationType ? `teams:${encodedConversationId}:${conversationType}` : `teams:${encodedConversationId}`;
}
function decodeThreadId(threadId) {
const parts = threadId.split(":");
- const hasValidPartCount = parts.length === 3 || parts.length === 4;
+ const hasValidPartCount = parts.length >= 2 && parts.length <= 4;
if (!hasValidPartCount || parts[0] !== "teams") {
throw new ValidationError("teams", `Invalid Teams thread ID: ${threadId}`);
}
@@ -578,13 +620,16 @@ function decodeThreadId(threadId) {
const conversationId = Buffer.from(parts[1], "base64url").toString(
"utf-8"
);
- const serviceUrl = Buffer.from(parts[2], "base64url").toString(
- "utf-8"
- );
- const rawConversationType = parts[3];
- if (!rawConversationType) {
- return { conversationId, serviceUrl };
+ if (!parts[2]) {
+ return { conversationId };
+ }
+ const canonicalConversationType = parseConversationType(parts[2]);
+ if (parts.length === 3 && canonicalConversationType) {
+ return { conversationId, conversationType: canonicalConversationType };
}
+ const serviceUrl = Buffer.from(parts[2], "base64url").toString("utf-8");
+ const rawConversationType = parts[3];
+ if (!rawConversationType) return { conversationId, serviceUrl };
const conversationType = parseConversationType(rawConversationType);
if (!conversationType) {
throw new ValidationError(
@@ -1668,7 +1713,8 @@ var TeamsAdapter = class {
this.app = new App({
...toAppOptions(config),
client: {
- headers: { "User-Agent": "Vercel.ChatSDK" }
+ headers: { "User-Agent": "Vercel.ChatSDK" },
+ timeout: 45e3
},
httpServerAdapter: this.bridgeAdapter
});
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 3f516b01355b28baaebc3d3540f85f22521bd4e4..a40328baaf4ee95a9db394001f4865fc662b6794 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -125,7 +125,7 @@ interface TeamsThreadId {
conversationId: string;
conversationType?: "channel" | "groupChat" | "personal";
replyToId?: string;
- serviceUrl: string;
+ serviceUrl?: string;
}
/** Teams channel context extracted from activity.channelData */
interface TeamsChannelContext {

View File

@ -0,0 +1,238 @@
diff --git a/dist/index.d.ts b/dist/index.d.ts
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -53,6 +53,8 @@
botToken?: string | (() => string | Promise<string>);
/** Logger instance for error reporting. Defaults to ConsoleLogger. */
logger?: Logger;
+ /** Maximum bytes buffered for one downloaded Telegram attachment. Defaults to Telegram's 25 MB ceiling. */
+ maxDownloadBytes?: number;
/** Optional long-polling configuration for getUpdates flow. */
longPolling?: TelegramLongPollingConfig;
/**
@@ -646,6 +648,7 @@
protected readonly botTokenProvider: () => Promise<string>;
protected readonly staticBotToken?: string;
protected readonly apiBaseUrl: string;
+ protected readonly maxDownloadBytes: number;
protected readonly secretToken?: string;
protected readonly mentionOnReply: boolean;
private botIdentityPromise;
@@ -669,6 +672,8 @@
private pollingTask;
private pollingActive;
private nextDraftId;
+ /** Paperclip durable private-draft stop handshake; not task cancellation. */
+ readonly paperclipDraftStopVersion: 1;
private richMessagesAvailable;
get botUserId(): string | undefined;
get userName(): string;
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -771,9 +771,10 @@
var TELEGRAM_API_BASE = "https://api.telegram.org";
var TELEGRAM_FILE_LIMIT = 25 * 1024 * 1024;
var TELEGRAM_FILE_TIMEOUT_MS = 3e4;
-async function readTelegramFile(response, fileId) {
+var TELEGRAM_API_TIMEOUT_MS = 45e3;
+async function readTelegramFile(response, fileId, maxDownloadBytes = TELEGRAM_FILE_LIMIT) {
const declared = Number(response.headers.get("content-length"));
- if (Number.isFinite(declared) && declared > TELEGRAM_FILE_LIMIT) {
+ if (Number.isFinite(declared) && declared > maxDownloadBytes) {
await response.body?.cancel();
throw new NetworkError(
"telegram",
@@ -792,7 +793,7 @@
break;
}
size += value.length;
- if (size > TELEGRAM_FILE_LIMIT) {
+ if (size > maxDownloadBytes) {
await reader.cancel();
throw new NetworkError(
"telegram",
@@ -989,6 +990,7 @@
botTokenProvider;
staticBotToken;
apiBaseUrl;
+ maxDownloadBytes;
secretToken;
mentionOnReply;
botIdentityPromise = null;
@@ -1012,6 +1014,7 @@
pollingTask = null;
pollingActive = false;
nextDraftId = Math.max(1, Date.now() % 2147483647);
+ paperclipDraftStopVersion = 1;
richMessagesAvailable = true;
get botUserId() {
return this._botUserId;
@@ -1038,6 +1041,12 @@
this.apiBaseUrl = trimTrailingSlashes(
config.apiUrl ?? config.apiBaseUrl ?? process.env.TELEGRAM_API_BASE_URL ?? TELEGRAM_API_BASE
);
+ this.maxDownloadBytes = this.clampInteger(
+ config.maxDownloadBytes,
+ TELEGRAM_FILE_LIMIT,
+ 1,
+ TELEGRAM_FILE_LIMIT
+ );
this.secretToken = config.secretToken ?? process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN;
this.allowUnverifiedWebhooks = config.allowUnverifiedWebhooks ?? process.env.TELEGRAM_ALLOW_UNVERIFIED_WEBHOOKS === "true";
this.mentionOnReply = config.mentionOnReply ?? process.env.TELEGRAM_MENTION_ON_REPLY === "true";
@@ -1949,6 +1958,9 @@
});
}
async stream(threadId, textStream, options) {
+ if (options?.paperclipDraftControl && (!this.nativeStreaming || !this.isDM(threadId))) {
+ throw new ValidationError2("telegram", "Durable draft control requires native private streaming");
+ }
if (this.nativeStreaming && this.isDM(threadId)) {
return await this.nativeDraftStream(threadId, textStream, options);
}
@@ -2089,6 +2101,29 @@
}
async nativeDraftStream(threadId, textStream, options) {
const parsedThread = this.resolveThreadId(threadId);
+ const draftControl = options?.paperclipDraftControl;
+ if (draftControl && (draftControl.version !== 1 || !Number.isSafeInteger(draftControl.draftId) || draftControl.draftId <= 0 || draftControl.draftId > 2147483647 || typeof draftControl.beforeDraft !== "function" || typeof draftControl.beforeFinal !== "function")) {
+ throw new ValidationError2("telegram", "Invalid durable draft control");
+ }
+ let draftStopped = false;
+ let draftControlFailed = false;
+ const sendControlledDraft = async (method, payload) => {
+ if (draftControl) {
+ try {
+ if (!await draftControl.beforeDraft()) {
+ draftStopped = true;
+ return;
+ }
+ } catch (error) {
+ draftControlFailed = true;
+ throw error;
+ }
+ }
+ return await this.telegramFetch(method, {
+ ...payload,
+ ...draftControl ? { can_stop: true, keep_on_stop: false } : {}
+ });
+ };
const updateIntervalMs = this.clampInteger(
options?.updateIntervalMs,
TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS,
@@ -2096,7 +2131,7 @@
Number.MAX_SAFE_INTEGER
);
const renderer = new StreamingMarkdownRenderer2();
- const draftId = this.createDraftId();
+ const draftId = draftControl?.draftId ?? this.createDraftId();
let accumulated = "";
let lastDraftText = null;
let lastFlushAt = 0;
@@ -2124,7 +2159,7 @@
let draftText = text2;
if (streamUsesRich) {
try {
- await this.telegramFetch("sendRichMessageDraft", {
+ await sendControlledDraft("sendRichMessageDraft", {
chat_id: parsedThread.chatId,
message_thread_id: parsedThread.messageThreadId,
draft_id: draftId,
@@ -2136,6 +2171,7 @@
lastFlushAt = Date.now();
return;
} catch (error) {
+ if (draftControlFailed) throw error;
if (!this.canFallbackFromRichMessage(error, "sendRichMessageDraft")) {
draftStreamingEnabled = false;
this.logger.warn("Telegram rich draft streaming update failed", {
@@ -2158,7 +2194,7 @@
}
try {
if (useMarkdown) {
- await this.telegramFetch("sendMessageDraft", {
+ await sendControlledDraft("sendMessageDraft", {
chat_id: parsedThread.chatId,
message_thread_id: parsedThread.messageThreadId,
draft_id: draftId,
@@ -2166,7 +2202,7 @@
parse_mode: toBotApiParseMode("MarkdownV2")
});
} else {
- await this.telegramFetch("sendMessageDraft", {
+ await sendControlledDraft("sendMessageDraft", {
chat_id: parsedThread.chatId,
message_thread_id: parsedThread.messageThreadId,
draft_id: draftId,
@@ -2176,11 +2212,12 @@
lastDraftText = draftText;
lastFlushAt = Date.now();
} catch (error) {
+ if (draftControlFailed) throw error;
if (useMarkdown && this.isTelegramMarkdownParseError(error)) {
streamUsesMarkdown = false;
const plainDraftText = renderPlainText(accumulated);
try {
- await this.telegramFetch("sendMessageDraft", {
+ await sendControlledDraft("sendMessageDraft", {
chat_id: parsedThread.chatId,
message_thread_id: parsedThread.messageThreadId,
draft_id: draftId,
@@ -2189,6 +2226,7 @@
lastDraftText = plainDraftText;
lastFlushAt = Date.now();
} catch (retryError) {
+ if (draftControlFailed) throw retryError;
draftStreamingEnabled = false;
this.logger.warn("Telegram draft streaming update failed", {
error: String(retryError),
@@ -2230,6 +2268,7 @@
renderer.push(text2);
if (Date.now() - lastFlushAt >= updateIntervalMs) {
await flushDraft();
+ if (draftStopped) return { paperclipDraftStopped: true };
}
}
if (!accumulated.trim()) {
@@ -2240,6 +2279,9 @@
}
const finalMarkdown = renderer.finish();
await flushDraft();
+ if (draftStopped || draftControl && !await draftControl.beforeFinal()) {
+ return { paperclipDraftStopped: true };
+ }
if (streamUsesRich) {
const markdown = truncateRichMarkdown(finalMarkdown);
try {
@@ -2625,7 +2667,7 @@
);
}
try {
- return await readTelegramFile(response, fileId);
+ return await readTelegramFile(response, fileId, this.maxDownloadBytes);
} catch (error) {
if (error instanceof NetworkError) {
throw error;
@@ -3439,6 +3481,12 @@
async telegramFetch(method, payload, request) {
const botToken = this.staticBotToken ?? await this.resolveBotToken();
const url = `${this.apiBaseUrl}/bot${botToken}/${method}`;
+ const requestTimeoutMs = method === "getUpdates" && payload && typeof payload === "object" && typeof payload.timeout === "number" ? Math.max(TELEGRAM_API_TIMEOUT_MS, (payload.timeout + 5) * 1e3) : TELEGRAM_API_TIMEOUT_MS;
+ const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
+ const signal = request?.signal ? AbortSignal.any([
+ request.signal,
+ timeoutSignal
+ ]) : timeoutSignal;
let response;
try {
response = await fetch(url, {
@@ -3447,7 +3495,7 @@
"Content-Type": "application/json"
},
body: payload instanceof FormData ? payload : JSON.stringify(payload ?? {}),
- signal: request?.signal
+ signal
});
} catch (error) {
if (this.isAbortError(error)) {

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
diff --git a/dist/live-checkpoint-ClPCSdrW.js b/dist/live-checkpoint-ClPCSdrW.js
index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73ee2ad1626 100644
index 243c9d13bcba520923b63adfddad75cf2d94362d..643315c1e9b529768695c863eae7a29e9f5a3dac 100644
--- a/dist/live-checkpoint-ClPCSdrW.js
+++ b/dist/live-checkpoint-ClPCSdrW.js
@@ -1532,7 +1532,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
@ -20,65 +20,50 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73e
current: state.current,
quote: state.quote,
escaping: true,
@@ -2914,15 +2914,15 @@
@@ -2912,8 +2912,8 @@ function promotePrefixedAuthEnvironment(env) {
}
return protectedKeys;
}
-function buildAgentEnvironment(authCredentials, sessionEnv) {
- const env = { ...process.env };
- const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
- if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
- addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
- assignAuthCredentialEnv(env, methodId, credential);
- }
- if (sessionEnv) for (const [key, value] of Object.entries(sessionEnv)) {
- if (typeof value !== "string" || protectedAuthEnvKeys.has(protectedEnvKey(key))) continue;
- assignSessionEnv(env, key, value);
- }
- return env;
-}
+function buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv = true) {
+ const env = inheritProcessEnv ? { ...process.env } : {};
+ const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
+ if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
+ addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
+ assignAuthCredentialEnv(env, methodId, credential);
+ }
+ if (sessionEnv) for (const [key, value] of Object.entries(sessionEnv)) {
+ if (typeof value !== "string" || protectedAuthEnvKeys.has(protectedEnvKey(key))) continue;
+ assignSessionEnv(env, key, value);
+ }
+ return env;
+}
function assignSessionEnv(env, key, value) {
@@ -2957,14 +2957,14 @@
const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
@@ -2955,10 +2955,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) {
const configCredentials = authCredentials ?? {};
return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)];
}
-function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv) {
- return {
- cwd,
- env: buildAgentEnvironment(authCredentials, sessionEnv),
- stdio: [
- "pipe",
- "pipe",
- "pipe"
- ],
- windowsHide: true
- };
-}
+function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv, inheritProcessEnv) {
+ return {
+ cwd,
return {
cwd,
- env: buildAgentEnvironment(authCredentials, sessionEnv),
+ env: buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv),
+ stdio: [
+ "pipe",
+ "pipe",
+ "pipe"
+ ],
+ windowsHide: true
stdio: [
"pipe",
"pipe",
@@ -3724,10 +3724,13 @@ function resolveClientCapabilities(params) {
},
terminal: params.terminal
};
- if (!params.devinAcp) return baseCapabilities;
+ const typedSessionFailureMeta = {
+ jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } }
+ };
+}
//#endregion
@@ -3959,7 +3959,24 @@ var AcpClient = class {
- this.attachAgentLifecycleObservers(child);
+ this.attachAgentLifecycleObservers(child);
+ if (!params.devinAcp) return { ...baseCapabilities, _meta: typedSessionFailureMeta };
return {
...baseCapabilities,
- _meta: DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META
+ _meta: { ...typedSessionFailureMeta, ...DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META }
};
}
function isDevinRequestDiagnosticsMethod(method) {
@@ -3957,9 +3960,26 @@ var AcpClient = class {
this.lastAgentExit = void 0;
this.lastKnownPid = child.pid ?? void 0;
this.attachAgentLifecycleObservers(child);
+ if (this.options.onAgentSpawn) {
+ try {
+ if (typeof child.pid !== "number" || child.pid <= 0) {
@ -102,7 +87,7 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73e
if (!this.options.verbose) return;
process.stderr.write(chunk);
});
@@ -3994,7 +4011,7 @@ var AcpClient = class {
@@ -3994,7 +4014,7 @@ var AcpClient = class {
geminiAcp: isGeminiAcpCommand(spawnCommand, args),
copilotAcp: isCopilotAcpCommand(spawnCommand, args),
claudeAcp: isClaudeAcpCommand(spawnCommand, args),
@ -111,8 +96,46 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73e
};
}
logAgentLaunch(plan) {
@@ -6070,6 +6090,27 @@ async function withConnectedSession(options) {
//#region src/runtime/engine/prompt-turn.ts
const SESSION_REPLY_IDLE_MS = 1e3;
const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3;
+const TYPED_SESSION_FAILURE_CATEGORIES = /* @__PURE__ */ new Set([
+ "connection",
+ "access",
+ "limit",
+ "service",
+ "request",
+ "unknown"
+]);
+function typedTerminalSessionFailureCategory(response) {
+ if (response === null || typeof response !== "object" || Array.isArray(response)) return null;
+ const meta = response._meta;
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return null;
+ const jetbrains = meta.jetbrains;
+ if (jetbrains === null || typeof jetbrains !== "object" || Array.isArray(jetbrains)) return null;
+ const air = jetbrains.air;
+ if (air === null || typeof air !== "object" || Array.isArray(air)) return null;
+ if (!Number.isInteger(air.version) || air.version < 1) return null;
+ const failure = air.sessionFailure;
+ if (failure === null || typeof failure !== "object" || Array.isArray(failure) || failure.severity !== "error") return null;
+ return typeof failure.category === "string" && TYPED_SESSION_FAILURE_CATEGORIES.has(failure.category) ? failure.category : "unknown";
+}
async function runPromptTurn(params) {
try {
const promptPromise = params.client.prompt(params.sessionId, params.prompt);
@@ -6079,6 +6120,9 @@ async function runPromptTurn(params) {
idleMs: SESSION_REPLY_IDLE_MS,
timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS
}).catch(() => {});
+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response);
+ if (terminalFailureCategory !== null)
+ throw new Error(`ACP agent reported a terminal ${terminalFailureCategory} failure.`);
recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId);
return {
stopReason: response.stopReason,
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
index ccdbe5b032521518022223733049b8b38793473b..3d4e04231e78efeecd8540735b08e1e43547ff2d 100644
index ccdbe5b032521518022223733049b8b38793473b..a04948e2b1ab8e169778e0d839ab5bf680573266 100644
--- a/dist/runtime.d.ts
+++ b/dist/runtime.d.ts
@@ -266,6 +266,10 @@ type AcpRuntimeOptions = {
@ -127,7 +150,7 @@ index ccdbe5b032521518022223733049b8b38793473b..3d4e04231e78efeecd8540735b08e1e4
signal: AbortSignal;
}) => Promise<AcpPermissionDecision | undefined>;
diff --git a/dist/runtime.js b/dist/runtime.js
index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..33b5054b2906502d1d4b512bfa259bd2ba5a9f05 100644
index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..7ec28ecf778623c6e63a2149a599172c184c49bd 100644
--- a/dist/runtime.js
+++ b/dist/runtime.js
@@ -744,7 +744,8 @@ var AcpRuntimeManager = class {
@ -141,7 +164,7 @@ index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..33b5054b2906502d1d4b512bfa259bd2
async readPendingPersistentClient(record, options) {
const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
diff --git a/dist/session-options-jkYbBxGE.d.ts b/dist/session-options-jkYbBxGE.d.ts
index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..680bc080fc5d6ffd266ed1b27d3d5056add9d980 100644
index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..22dda59e0b7616a20ad45218913e5d8f884bae49 100644
--- a/dist/session-options-jkYbBxGE.d.ts
+++ b/dist/session-options-jkYbBxGE.d.ts
@@ -84,6 +84,10 @@ type AcpClientOptions = {

View File

@ -11,7 +11,7 @@ index 5e2113a..b7b5151 100644
private setSessionModelThroughConfig;
private setSessionModelThroughLegacyMethod;
diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766f46ce3db 100644
index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84dc5d3c1b 100644
--- a/dist/live-checkpoint-BSIrfgVo.js
+++ b/dist/live-checkpoint-BSIrfgVo.js
@@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) {
@ -73,7 +73,23 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766
stdio: [
"pipe",
"pipe",
@@ -4216,9 +4226,21 @@ var AcpClient = class {
@@ -3961,10 +3971,13 @@ function resolveClientCapabilities(params) {
...params.elicitationModes.includes("url") ? { url: {} } : {}
} } : {}
};
- if (!params.devinAcp) return baseCapabilities;
+ const typedSessionFailureMeta = {
+ jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } }
+ };
+ if (!params.devinAcp) return { ...baseCapabilities, _meta: typedSessionFailureMeta };
return {
...baseCapabilities,
- _meta: DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META
+ _meta: { ...typedSessionFailureMeta, ...DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META }
};
}
function hasResponseField(response, field) {
@@ -4216,9 +4229,21 @@ var AcpClient = class {
this.lastAgentExit = void 0;
this.lastKnownPid = child.pid ?? void 0;
this.attachAgentLifecycleObservers(child);
@ -95,7 +111,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766
if (!this.options.verbose) return;
process.stderr.write(chunk);
});
@@ -4253,7 +4275,12 @@ var AcpClient = class {
@@ -4253,7 +4278,12 @@ var AcpClient = class {
geminiAcp: isGeminiAcpCommand(spawnCommand, args),
copilotAcp: isCopilotAcpCommand(spawnCommand, args),
claudeAcp: isClaudeAcpCommand(spawnCommand, args),
@ -109,7 +125,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766
};
}
logAgentLaunch(plan) {
@@ -4279,10 +4306,17 @@ var AcpClient = class {
@@ -4279,10 +4309,17 @@ var AcpClient = class {
}
async spawnAgentProcess(plan) {
const spawnCommand = buildAgentSpawnCommand(plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env);
@ -129,7 +145,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766
try {
await waitForSpawn$1(spawnedChild);
} catch (error) {
@@ -5028,6 +5062,12 @@ var AcpClient = class {
@@ -5028,6 +5065,12 @@ var AcpClient = class {
attachAgentLifecycleObservers(child) {
child.once("exit", (exitCode, signal) => {
this.recordAgentExit("process_exit", exitCode, signal);
@ -142,6 +158,44 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766
});
child.once("close", (exitCode, signal) => {
this.recordAgentExit("process_close", exitCode, signal);
@@ -6435,6 +6478,27 @@ async function withConnectedSession(options) {
//#region src/runtime/engine/prompt-turn.ts
const SESSION_REPLY_IDLE_MS = 1e3;
const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3;
+const TYPED_SESSION_FAILURE_CATEGORIES = /* @__PURE__ */ new Set([
+ "connection",
+ "access",
+ "limit",
+ "service",
+ "request",
+ "unknown"
+]);
+function typedTerminalSessionFailureCategory(response) {
+ if (response === null || typeof response !== "object" || Array.isArray(response)) return null;
+ const meta = response._meta;
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return null;
+ const jetbrains = meta.jetbrains;
+ if (jetbrains === null || typeof jetbrains !== "object" || Array.isArray(jetbrains)) return null;
+ const air = jetbrains.air;
+ if (air === null || typeof air !== "object" || Array.isArray(air)) return null;
+ if (!Number.isInteger(air.version) || air.version < 1) return null;
+ const failure = air.sessionFailure;
+ if (failure === null || typeof failure !== "object" || Array.isArray(failure) || failure.severity !== "error") return null;
+ return typeof failure.category === "string" && TYPED_SESSION_FAILURE_CATEGORIES.has(failure.category) ? failure.category : "unknown";
+}
async function runPromptTurn(params) {
try {
const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation);
@@ -6444,6 +6508,9 @@ async function runPromptTurn(params) {
idleMs: SESSION_REPLY_IDLE_MS,
timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS
}).catch(() => {});
+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response);
+ if (terminalFailureCategory !== null)
+ throw new Error(`ACP agent reported a terminal ${terminalFailureCategory} failure.`);
recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId);
return {
stopReason: response.stopReason,
@@ -6518,4 +6547,4 @@ var LiveSessionCheckpoint = class {
//#endregion
export { writeSessionRecord as $, PERMISSION_POLICY_ACTIONS as $t, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE as A, TimeoutError as At, getAcpxVersion as B, formatErrorMessage as Bt, mergeSessionOptions as C, PromptInputValidationError as Ct, applyLifecycleSnapshotToRecord as D, promptToDisplayText as Dt, applyConversation as E, parsePromptSource as Et, modelStateFromConfigOptions as F, normalizeAgentName$1 as Ft, findSession as G, toAcpErrorPayload as Gt, DEFAULT_HISTORY_LIMIT as H, normalizeOutputError as Ht, normalizeAgentCommandInput as I, resolveAgentArgv as It, listSessions as J, NON_INTERACTIVE_PERMISSION_POLICIES as Jt, findSessionByDirectoryWalk as K, AUTH_POLICIES as Kt, renderArgvIdentity as L, resolveAgentCommand as Lt, RequestedModelUnsupportedError as M, withTimeout as Mt, assertRequestedModelSupported as N, DEFAULT_AGENT_NAME as Nt, reconcileAgentSessionId as O, textPrompt as Ot, isRequestedModelUnsupportedError as P, listBuiltInAgents as Pt, resolveSessionRecord as Q, PERMISSION_MODES as Qt, runTimedExecFile as R, resolveCanonicalAgentName as Rt, advertisedModelState as S, parsePromptStopReason as St, sessionOptionsFromRecord as T, mergePromptSourceWithText as Tt, absolutePath as U, extractAcpError as Ut, permissionModeSatisfies as V, isRetryablePromptError as Vt, findGitRepositoryRoot as W, isAcpResourceNotFoundError as Wt, normalizeName as X, OUTPUT_ERROR_ORIGINS as Xt, listSessionsForAgent as Y, OUTPUT_ERROR_CODES as Yt, pruneSessions as Z, OUTPUT_FORMATS as Zt, createSessionConversation as _, sessionEventLockPath as _t, applyRequestedModelIfAdvertised as a, measurePerf as at, recordSessionUpdate as b, isAcpJsonRpcMessage as bt, setCurrentModelId as c, setPerfGauge as ct, setDesiredModelId as d, serializeSessionRecordForDisk as dt, SESSION_RECORD_SCHEMA as en, createAtomicWriteTempPath as et, syncAdvertisedModelState as f, normalizeRuntimeSessionId as ft, cloneSessionConversation as g, sessionEventActivePath as gt, cloneSessionAcpxState as h, sessionBaseDir$1 as ht, connectAndLoadSession as i, QueueProtocolError as in, incrementPerfCounter as it, REQUESTED_MODEL_UNSUPPORTED_REASONS as j, withInterrupt as jt, AcpClient as k, InterruptedError as kt, setDesiredConfigOption as l, startPerfTimer as lt, applyConfigOptionsToState as m, defaultSessionEventLog as mt, runPromptTurn as n, AgentSpawnError as nn, formatPerfMetric as nt, currentModelIdFromSetModelResponse as o, recordPerfDuration as ot, applyConfigOptionsToRecord as p, DEFAULT_EVENT_SEGMENT_MAX_BYTES as pt, isoNow$2 as q, EXIT_CODES as qt, withConnectedSession as r, QueueConnectionError as rn, getPerfMetricsSnapshot as rt, clearDesiredConfigOption as s, resetPerfMetrics as st, LiveSessionCheckpoint as t, AcpxOperationalError as tn, assertPersistedKeyPolicy as tt, setDesiredModeId as u, parseSessionRecord as ut, recordClientOperation as v, sessionEventSegmentPath as vt, persistSessionOptions as w, isPromptInput as wt, trimConversationForRuntime as x, parseJsonRpcErrorMessage as xt, recordPromptSubmission as y, extractSessionUpdateNotification as yt, splitCommandLine as z, exitCodeForOutputErrorCode as zt };
@ -150,7 +204,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..f5775e731d6a4808c7bd59b4228da766
\ No newline at end of file
+//# sourceMappingURL=live-checkpoint-BSIrfgVo.js.map
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
index e8102acb03c4c38830ad5ec22f356125eb0423b7..fcb1a1906f587b33ad818389c035f90362b0617e 100644
index e8102acb03c4c38830ad5ec22f356125eb0423b7..fa94c67bb8389437fe3ac3e8b92f56949778b1f1 100644
--- a/dist/runtime.d.ts
+++ b/dist/runtime.d.ts
@@ -1,7 +1,8 @@
@ -223,7 +277,7 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..fcb1a1906f587b33ad818389c035f903
type AcpFileSessionStoreOptions = {
stateDir: string;
diff --git a/dist/runtime.js b/dist/runtime.js
index a1f4a70a003792c6eacf68b6b038f37bfec1db53..50029e881c07a7228ddd978bb03d040629cf776b 100644
index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f83e5a8b5 100644
--- a/dist/runtime.js
+++ b/dist/runtime.js
@@ -371,7 +371,7 @@ const PROMPT_EVENT_PARSERS = {
@ -407,7 +461,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..50029e881c07a7228ddd978bb03d0406
\ No newline at end of file
+//# sourceMappingURL=runtime.js.map
diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts
index c3da1645235bbea22de3f8484149051cd7dca56b..ad1f2f6c6a7f477e83dc0061e4168c00c23382da 100644
index c3da1645235bbea22de3f8484149051cd7dca56b..1cd88ed98a5346a345838227e8fd9e7616d0b42b 100644
--- a/dist/session-options-DwRDODlr.d.ts
+++ b/dist/session-options-DwRDODlr.d.ts
@@ -1,4 +1,5 @@

View File

@ -22,6 +22,12 @@ patchedDependencies:
"@agentclientprotocol/claude-agent-acp@0.70.0": patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch
"@agentclientprotocol/claude-agent-acp@0.73.0": patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
"@agentclientprotocol/codex-acp@1.6.2": patches/@agentclientprotocol__codex-acp@1.6.2.patch
"@chat-adapter/slack@4.39.0": patches/@chat-adapter__slack@4.39.0.patch
"@chat-adapter/discord@4.39.0": patches/@chat-adapter__discord@4.39.0.patch
"@chat-adapter/telegram@4.39.0": patches/@chat-adapter__telegram@4.39.0.patch
"@chat-adapter/teams@4.39.0": patches/@chat-adapter__teams@4.39.0.patch
"@chat-adapter/github@4.39.0": patches/@chat-adapter__github@4.39.0.patch
"@discordjs/ws@1.2.3": patches/@discordjs__ws@1.2.3.patch
# Agent CLIs share the current runtime used by the native provider pack.
# pnpm patches change package files, but overrides control dependency resolution.

View File

@ -49,6 +49,64 @@ const claudeAcpPatch = await readFile(
"utf8",
);
for (const version of ["0.12.0", "0.13.1"]) {
test(`ACPX ${version} release patch uses portable generated unified hunks`, async () => {
const patch = await readFile(
new URL(`../patches/acpx@${version}.patch`, import.meta.url),
"utf8",
);
const lines = patch.split("\n");
let hunkCount = 0;
for (let index = 0; index < lines.length; index += 1) {
const header = lines[index].match(
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/,
);
if (!header) continue;
hunkCount += 1;
const body = [];
let oldLines = 0;
let newLines = 0;
while (index + 1 < lines.length && (oldLines < Number(header[2] ?? 1) || newLines < Number(header[4] ?? 1))) {
const line = lines[++index];
// Unified diff's EOF marker is metadata, not a source/destination
// line, and may occur between the removed and added final lines.
if (line === "\\ No newline at end of file") continue;
// Git accepts an empty context line with its optional space omitted.
assert.ok(line === "" || /^[ +\-]/.test(line), `invalid unified hunk line: ${line}`);
const normalized = line === "" ? " " : line;
body.push(normalized);
if (!normalized.startsWith("+")) oldLines += 1;
if (!normalized.startsWith("-")) newLines += 1;
}
assert.equal(
body.filter((line) => !line.startsWith("+")).length,
Number(header[2] ?? 1),
);
assert.equal(
body.filter((line) => !line.startsWith("-")).length,
Number(header[4] ?? 1),
);
const prefix = body.findIndex((line) => !line.startsWith(" "));
const suffix = body
.slice()
.reverse()
.findIndex((line) => !line.startsWith(" "));
// pnpm patch-commit emits three context lines. Hand-added asymmetric
// context can force GNU patch's locate_hunk() to require EOF even when
// BSD patch and git apply accept the same source and hunk.
assert.ok(
prefix >= 0 && prefix <= 3,
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (prefix ${prefix})`,
);
assert.ok(
suffix >= 0 && suffix <= 3,
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (suffix ${suffix})`,
);
}
assert.ok(hunkCount > 0);
});
}
test("published packages preserve the patched ACPX runtime", () => {
assert.equal(
rootPackage.pnpm.patchedDependencies["acpx@0.12.0"],
@ -61,7 +119,7 @@ test("published packages preserve the patched ACPX runtime", () => {
assert.equal(adapterUtilsPackage.dependencies.acpx, "0.12.0");
assert.deepEqual(adapterUtilsPackage.bundleDependencies, ["acpx"]);
assert.equal(serverPackage.dependencies.acpx, "0.13.1");
assert.deepEqual(serverPackage.bundleDependencies, ["acpx"]);
assert.ok(serverPackage.bundleDependencies.includes("acpx"));
assert.equal(bundledCliNpmDependencies.has("acpx"), true);
assert.equal(cliEsbuildConfig.external.includes("acpx"), false);
});
@ -226,7 +284,7 @@ test("bundled package patch selection rejects an unpatched installed version", (
);
});
test("server package staging bundles and patches the vendored runner's acpx runtime", (t) => {
test("server package staging applies every bundled runtime patch and preserves the vendored runner", (t) => {
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-"));
const sourceDir = join(fixtureDir, "source");
const destinationDir = join(fixtureDir, "destination");
@ -263,7 +321,7 @@ mkdir -p "$destination/node_modules/.pnpm"
set -euo pipefail
printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG"
[ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ]
node -e 'const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1)'
node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); }'
mkdir -p node_modules/acpx/dist
printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js
printf '{"name":"acpx","version":"0.13.1"}\\n' > node_modules/acpx/package.json
@ -284,6 +342,10 @@ while [ "$#" -gt 0 ]; do
fi
done
patch_input="$(cat)"
printf '%s\\n' "$patch_input" > "$target/applied.patch"
if [[ "$target" != */acpx ]]; then
exit 0
fi
grep -q spawnEnvironment <<< "$patch_input"
grep -q spawnAgent <<< "$patch_input"
grep -q onAgentStderr <<< "$patch_input"
@ -293,7 +355,11 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js"
execFileSync(
process.execPath,
[new URL("./prepare-bundled-package.mjs", import.meta.url).pathname, sourceDir, destinationDir],
[
new URL("./prepare-bundled-package.mjs", import.meta.url).pathname,
sourceDir,
destinationDir,
],
{
env: {
...process.env,
@ -318,9 +384,23 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js"
/patch -p1 --forward -d .*node_modules\/acpx/,
);
assert.equal(
readFileSync(callLog, "utf8").split("\n").filter((line) => line.startsWith("patch ")).length,
1,
readFileSync(callLog, "utf8")
.split("\n")
.filter((line) => line.startsWith("patch ")).length,
serverPackage.bundleDependencies.length,
);
for (const name of serverPackage.bundleDependencies) {
const specifier = `${name}@${serverPackage.dependencies[name]}`;
const patchPath = rootPackage.pnpm.patchedDependencies[specifier];
assert.equal(
readFileSync(
join(destinationDir, "node_modules", name, "applied.patch"),
"utf8",
),
`${readFileSync(new URL(`../${patchPath}`, import.meta.url), "utf8").trimEnd()}\n`,
`${specifier} receives its own full configured patch`,
);
}
});
test("bundled package dry runs preview without querying published versions", () => {

View File

@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
createBundledInstallManifest,
materializePublishManifest,
selectBundledDependencyPatches,
} from "./prepare-bundled-package.mjs";
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const rootPackage = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
);
const serverPackage = JSON.parse(
readFileSync(new URL("../server/package.json", import.meta.url), "utf8"),
);
const workspace = readFileSync(
new URL("../pnpm-workspace.yaml", import.meta.url),
"utf8",
);
const required = [
["@chat-adapter/discord", "4.39.0"],
["@chat-adapter/github", "4.39.0"],
["@chat-adapter/slack", "4.39.0"],
["@chat-adapter/teams", "4.39.0"],
["@chat-adapter/telegram", "4.39.0"],
["@discordjs/ws", "1.2.3"],
];
for (const [name, version] of required) {
test(`published server retains the patched ${name}@${version} runtime`, () => {
const published = materializePublishManifest(serverPackage);
const installed = createBundledInstallManifest(
published,
serverPackage.bundleDependencies,
);
assert.equal(
serverPackage.dependencies[name],
version,
"the patched runtime must have an exact direct version",
);
assert.ok(
serverPackage.bundleDependencies.includes(name),
"npm consumers cannot apply this repository's pnpm patches",
);
assert.equal(
installed.dependencies[name],
version,
"release staging must install the runtime before patching",
);
assert.ok(
published.bundleDependencies.includes(name),
"the patched runtime must remain in the published tarball",
);
const specifier = `${name}@${version}`;
const patchPath = rootPackage.pnpm.patchedDependencies[specifier];
assert.equal(typeof patchPath, "string");
assert.ok(
workspace.includes(`"${specifier}": ${patchPath}`),
"both supported pnpm configuration paths must match",
);
// Parse the actual full patch, not merely its configured filename. This is
// read-only; clean-package materialization is a separate qualification.
execFileSync("git", ["apply", "--stat", patchPath], {
cwd: repoRoot,
stdio: "pipe",
});
});
}
test("release selection includes every chat runtime patch and the existing ACPX patch", (t) => {
const destination = mkdtempSync(
join(tmpdir(), "paperclip-chat-release-contract-"),
);
t.after(() => rmSync(destination, { recursive: true, force: true }));
for (const [name, version] of [...required, ["acpx", "0.13.1"]]) {
const directory = join(destination, "node_modules", name);
mkdirSync(directory, { recursive: true });
writeFileSync(
join(directory, "package.json"),
JSON.stringify({ name, version }),
);
}
const selected = selectBundledDependencyPatches(
destination,
serverPackage.bundleDependencies,
rootPackage.pnpm.patchedDependencies,
);
assert.deepEqual(
selected.map(({ specifier }) => specifier).sort(),
[
...required.map(([name, version]) => `${name}@${version}`),
"acpx@0.13.1",
].sort(),
);
writeFileSync(
join(destination, "node_modules", "@discordjs/ws", "package.json"),
JSON.stringify({
name: "@discordjs/ws",
version: "1.2.4",
}),
);
assert.throws(
() =>
selectBundledDependencyPatches(
destination,
serverPackage.bundleDependencies,
rootPackage.pnpm.patchedDependencies,
),
/installed @discordjs\/ws@1\.2\.4/,
);
});

View File

@ -45,6 +45,12 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1122.0",
"@chat-adapter/github": "4.39.0",
"@chat-adapter/discord": "4.39.0",
"@chat-adapter/slack": "4.39.0",
"@chat-adapter/teams": "4.39.0",
"@chat-adapter/telegram": "4.39.0",
"@discordjs/ws": "1.2.3",
"@opentelemetry/api": "^1.9.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
@ -67,6 +73,7 @@
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"better-auth": "1.7.2",
"chat": "4.39.0",
"chokidar": "^5.0.0",
"detect-port": "^2.1.0",
"dompurify": "^3.4.14",
@ -86,6 +93,12 @@
"zod": "^4.4.3"
},
"bundleDependencies": [
"@chat-adapter/discord",
"@chat-adapter/github",
"@chat-adapter/slack",
"@chat-adapter/teams",
"@chat-adapter/telegram",
"@discordjs/ws",
"acpx"
],
"devDependencies": {

View File

@ -0,0 +1,8 @@
// One valid 16x16, one-frame MPEG4 video, generated without external input:
// ffmpeg -f lavfi -i color=c=teal:s=16x16:r=1 -frames:v 1 -c:v mpeg4
// -an -fflags +bitexact -flags:v +bitexact -f mp4
// -movflags frag_keyframe+empty_moov pipe:1
export const TELEGRAM_VIDEO_NOTE_MP4 = Buffer.from(
"AAAAIGZ0eXBpc29tAAACAGlzb21pc282aXNvMm1wNDEAAAL3bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAAAAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAh50cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAABAAAAAQAAAAAAG6bWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABZW1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAASVzdGJsAAAA2XN0c2QAAAAAAAAAAQAAAMltcDR2AAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAABAAEABIAAAASAAAAAAAAAABCkxhdmMgbXBlZzQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAT2VzZHMAAAAAA4CAgD4AAQAEgICAMCARAAAAAAMNQAADDUAFgICAHgAAAbABAAABtYkTAAABAAAAASAAxI2IAA0AhAIUYwaAgIABAgAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAAw1AAAMNQAAAABBzdHRzAAAAAAAAAAAAAAAQc3RzYwAAAAAAAAAAAAAAFHN0c3oAAAAAAAAAAAAAAAAAAAAQc3RjbwAAAAAAAAAAAAAAKG12ZXgAAAAgdHJleAAAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAD11ZHRhAAAANW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAACGlsc3QAAABwbW9vZgAAABBtZmhkAAAAAAAAAAEAAABYdHJhZgAAACR0ZmhkAAAAOQAAAAEAAAAAAAADFwAAQAAAAAATAQEAAAAAABR0ZmR0AQAAAAAAAAAAAAAAAAAAGHRydW4AAAAFAAAAAQAAAHgCAAAAAAAAG21kYXQAAAGzABAHAAABthYFGFxthmCOAAAAQ21mcmEAAAArdGZyYQEAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAMXAQEBAAAAEG1mcm8AAAAAAAAAQw==",
"base64",
);

View File

@ -0,0 +1,19 @@
// Valid 0.1-second mono Ogg/Opus silence. Generated without external input:
// ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 0.1 -c:a libopus
// -fflags +bitexact -flags:a +bitexact -f ogg -
export const TELEGRAM_VOICE_OGG = Buffer.from(
"T2dnUwACAAAAAAAAAAAAAAAAAAAAAAIotXIBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAAAAAAAAAAAAAAAAABAAAASZW+VAEuT3B1c1RhZ3MGAAAAZmZtcGVnAQAAABQAAABlbmNvZGVyPUxhdmMgbGlib3B1c09nZ1MABPgTAAAAAAAAAAAAAAIAAAAglTxQBgMDAwMDA/j//vj//vj//vj//vj//vj//g==",
"base64",
);
// Same synthetic silence, 0.02 s at 22050 Hz, libmp3lame 8k, bitexact MP3.
export const TELEGRAM_AUDIO_MP3 = Buffer.from(
"SUQzBAAAAAAACgAAAAAAAAAAAAD/8xDEAAAAA0gAAAAATEFNRTQuMFVVVVVVVf/zEsQNAAADSAAAAABVVVVVVVVVVVVVVVVVVf/zEMQbAAADSAAAAABVVVVVVVVVVVVVVVVV",
"base64",
);
// Same 0.1-second silence, AAC in fragmented MP4 (empty_moov), bitexact.
export const TELEGRAM_AUDIO_MP4 = Buffer.from(
"AAAAIGZ0eXBpc29tAAACAGlzb21pc282aXNvMm1wNDEAAAKYbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAAAAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAb90cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAFbbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAC7gAAAAABVxAAAAAAALWhkbHIAAAAAAAAAAHNvdW4AAAAAAAAAAAAAAABTb3VuZEhhbmRsZXIAAAABBm1pbmYAAAAQc21oZAAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAAynN0YmwAAAB+c3RzZAAAAAAAAAABAAAAbm1wNGEAAAAAAAAAAQAAAAAAAAAAAAEAEAAAAAC7gAAAAAAANmVzZHMAAAAAA4CAgCUAAQAEgICAF0AVAAAAAAENiAABDYgFgICABRGIVuUABoCAgAECAAAAFGJ0cnQAAAAAAAENiAABDYgAAAAQc3R0cwAAAAAAAAAAAAAAEHN0c2MAAAAAAAAAAAAAABRzdHN6AAAAAAAAAAAAAAAAAAAAEHN0Y28AAAAAAAAAAAAAAChtdmV4AAAAIHRyZXgAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAA9dWR0YQAAADVtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAAhpbHN0AAAAhG1vb2YAAAAQbWZoZAAAAAAAAAABAAAAbHRyYWYAAAAkdGZoZAAAADkAAAABAAAAAAAAArgAAAQAAAAABAIAAAAAAAAUdGZkdAEAAAAAAAAAAAAAAAAAACx0cnVuAAABAQAAAAYAAACMAAAEAAAABAAAAAQAAAAEAAAABAAAAALAAAAAIG1kYXQBGCAHARggBwEYIAcBGCAHARggBwEYIAcAAABDbWZyYQAAACt0ZnJhAQAAAAAAAAEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAArgBAQEAAAAQbWZybwAAAAAAAABD",
"base64",
);

View File

@ -20,6 +20,11 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/jpg",
"image/webp",
"image/gif",
"audio/mpeg",
"audio/mp4",
"audio/ogg",
"audio/wav",
"audio/webm",
"application/pdf",
"application/zip",
"text/markdown",
@ -90,7 +95,13 @@ export function matchesContentType(contentType: string, allowedPatterns: string[
}
export function normalizeContentType(contentType: string | null | undefined): string {
const normalized = (contentType ?? "").trim().toLowerCase();
// Provider APIs commonly return a complete Content-Type header value (for
// example Discord uses `text/plain; charset=utf-8`) while Paperclip's
// allowlist and persisted asset metadata operate on the MIME essence. MIME
// parameters do not change the media type, so normalize them away before
// enforcing the allowlist. Invalid/empty essences still fail closed to the
// generic binary type.
const normalized = (contentType ?? "").split(";", 1)[0]!.trim().toLowerCase();
return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,663 @@
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import {
createDiscordCommandRegistration,
discordPaperclipCommandDefinition,
parseDiscordCommandRegistration,
reconcileDiscordCommandRegistration,
type DiscordCommandRegistration,
type ReconcileDiscordCommandRegistrationOptions,
} from "./chat-discord-command-registration.js";
const scope = {
companyId: "11111111-1111-4111-8111-111111111111",
endpointId: "22222222-2222-4222-8222-222222222222",
applicationId: "123456789012345678",
guildId: "234567890123456789",
};
const runtimeFence = { generation: 2, credentialFingerprint: "a".repeat(64) };
const commandId = "345678901234567890";
const version = "456789012345678901";
const secret = "SYNTHETIC-BOT-SECRET";
const json = (body: unknown, status = 200) => Response.json(body, { status });
function fixture() {
let stored = createDiscordCommandRegistration(scope);
const unrelated = {
id: "567890123456789012",
application_id: scope.applicationId,
version,
type: 1,
name: "customer",
description: "Customer-owned command",
};
let commands: Record<string, unknown>[] = [unrelated];
const order: string[] = [];
const fetch = vi.fn<typeof globalThis.fetch>(async (url, init) => {
expect(String(url)).toMatch(
/^https:\/\/discord\.com\/api\/v10\/applications\/123456789012345678\/commands(?:\/345678901234567890)?$/,
);
expect(new Headers(init?.headers).get("authorization")).toBe(
`Bot ${secret}`,
);
expect(init?.redirect).toBe("error");
expect(init?.signal).toBeInstanceOf(AbortSignal);
const method = init?.method ?? "GET";
order.push(method);
if (method === "GET") return json(commands);
expect(stored.phase).toBe("attempted");
const body = JSON.parse(String(init?.body));
expect(body).toEqual(discordPaperclipCommandDefinition(stored.ownerId));
expect(["POST", "PATCH"]).toContain(method);
if (method === "PATCH")
expect(String(url).endsWith(`/${commandId}`)).toBe(true);
const result = {
...body,
id: commandId,
application_id: scope.applicationId,
version,
};
commands = [
...commands.filter((command) => command.id !== commandId),
result,
];
return json(result, method === "POST" ? 201 : 200);
});
const commit = vi.fn(
async (
expected: DiscordCommandRegistration,
next: DiscordCommandRegistration,
) => {
expect(expected).toEqual(stored);
order.push(`persist:${next.phase}`);
stored = JSON.parse(JSON.stringify(next));
},
);
const authorize = vi.fn(async () => undefined);
const options = (): ReconcileDiscordCommandRegistrationOptions => ({
scope,
state: JSON.parse(JSON.stringify(stored)),
runtimeFence,
verifiedIdentity: {
botExternalId: scope.applicationId,
providerAccountId: scope.guildId,
},
botToken: secret,
fetch,
authorize,
commit,
requestTimeoutMs: 1000,
});
return {
options,
fetch,
commit,
authorize,
order,
unrelated,
get stored() {
return stored;
},
set stored(value: DiscordCommandRegistration) {
stored = structuredClone(value);
},
get commands() {
return commands;
},
set commands(value: Record<string, unknown>[]) {
commands = value;
},
};
}
describe("Discord owned native-command registration", () => {
async function priorCopyFixture() {
const f = fixture();
await reconcileDiscordCommandRegistration(f.options());
const prior = discordPaperclipCommandDefinition(f.stored.ownerId);
prior.options[2]!.description = "Close the current Paperclip task";
if (f.stored.phase !== "registered") throw new Error("Missing receipt");
f.stored = {
...f.stored,
receipt: {
...f.stored.receipt,
definitionDigest: createHash("sha256")
.update(JSON.stringify(prior))
.digest("hex"),
},
};
f.commands[1] = {
...prior,
id: commandId,
application_id: scope.applicationId,
version,
};
f.order.length = 0;
f.commit.mockClear();
return f;
}
it("upgrades the exact prior close description using its retained owner, command and version", async () => {
const f = await priorCopyFixture();
expect(parseDiscordCommandRegistration(f.stored, scope)).toBeNull();
expect(parseDiscordCommandRegistration(f.stored, scope, true)).toEqual(
f.stored,
);
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "registered" });
expect(f.order).toEqual([
"GET",
"persist:attempted",
"PATCH",
"persist:registered",
]);
expect(f.commands[0]).toEqual(f.unrelated);
expect(f.commands[1]).toMatchObject({
id: commandId,
...discordPaperclipCommandDefinition(f.stored.ownerId),
});
});
it.each(["version", "custom", "foreign"])(
"does not migrate prior copy over a %s remote change",
async (change) => {
const f = await priorCopyFixture();
if (change === "version") f.commands[1]!.version = "456789012345678902";
if (change === "custom")
(
f.commands[1]!.options as Array<{ description: string }>
)[2]!.description = "Custom operator behavior";
if (change === "foreign") f.commands[1]!.description = "Foreign owner";
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "conflict" });
expect(f.order).toEqual(["GET"]);
expect(f.commit).not.toHaveBeenCalled();
},
);
it("reconciles a lost prior-copy PATCH receipt without repeating the write", async () => {
const f = await priorCopyFixture();
const send = f.fetch.getMockImplementation()!;
f.fetch.mockImplementation(async (url, init) => {
const response = await send(url, init);
if (init?.method === "PATCH") throw new Error("synthetic lost receipt");
return response;
});
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "unknown" });
f.fetch.mockImplementation(send);
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "registered" });
expect(f.order).toEqual(["GET", "persist:registered"]);
});
it("does not replay an ambiguous upgrade while GET still shows the prior copy", async () => {
const f = await priorCopyFixture();
const send = f.fetch.getMockImplementation()!;
f.fetch.mockImplementation(async (url, init) => {
if (init?.method === "PATCH")
throw new Error("synthetic indeterminate write");
return send(url, init);
});
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "unknown" });
f.fetch.mockImplementation(send);
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "unknown" });
expect(f.order).toEqual(["GET"]);
});
it("does not recognize arbitrary prior digests or unconfirmed prior write attempts", async () => {
const f = await priorCopyFixture();
if (f.stored.phase !== "registered") throw new Error("Missing receipt");
const { receipt, ...base } = f.stored;
for (const invalid of [
{ ...base, receipt: { ...receipt, definitionDigest: "a".repeat(64) } },
{
...base,
phase: "attempted",
attempt: {
operation: "update",
commandId,
definitionDigest: receipt.definitionDigest,
runtimeFence,
},
},
]) {
expect(parseDiscordCommandRegistration(invalid, scope, true)).toBeNull();
await expect(
reconcileDiscordCommandRegistration({ ...f.options(), state: invalid }),
).rejects.toThrow("Invalid Discord command registration authority");
}
expect(f.order).toEqual([]);
expect(f.commit).not.toHaveBeenCalled();
});
it("describes closing a conversation without claiming to close the Paperclip task", () => {
const definition = discordPaperclipCommandDefinition(
createDiscordCommandRegistration(scope).ownerId,
);
expect(
definition.options.find((option) => option.name === "close"),
).toMatchObject({ description: "Close the current chat conversation" });
});
it("creates one namespaced command after durable intent and preserves unrelated commands", async () => {
const f = fixture();
const result = await reconcileDiscordCommandRegistration(f.options());
expect(result).toMatchObject({
kind: "registered",
state: { phase: "registered", receipt: { commandId, version } },
});
expect(f.order).toEqual([
"GET",
"persist:attempted",
"POST",
"persist:registered",
]);
expect(f.commands[0]).toEqual(f.unrelated);
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "registered" });
expect(f.order).toEqual(["GET"]);
});
it("refuses an existing unowned namespace even if its command shape looks familiar", async () => {
const f = fixture();
f.commands = [
{
...discordPaperclipCommandDefinition("b".repeat(32)),
id: commandId,
application_id: scope.applicationId,
version,
},
];
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toEqual({ kind: "conflict", reason: "unowned_namespace" });
expect(f.fetch).toHaveBeenCalledTimes(1);
expect(f.commit).not.toHaveBeenCalled();
});
it("leaves customer-owned status, new and close commands untouched", async () => {
const f = fixture();
const generic = ["status", "new", "close"].map((name, index) => ({
...f.unrelated,
id: String(BigInt(f.unrelated.id) + BigInt(index + 1)),
name,
}));
f.commands = [f.unrelated, ...generic];
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "registered" });
expect(f.commands.slice(0, 4)).toEqual([f.unrelated, ...generic]);
expect(f.commands[4]!.name).toBe("paperclip");
});
it("reconciles a committed create after lost response without sending another POST", async () => {
const f = fixture();
const send = f.fetch.getMockImplementation()!;
f.fetch.mockImplementation(async (url, init) => {
const response = await send(url, init);
if (init?.method === "POST")
throw new Error(`${secret}: socket lost after provider commit`);
return response;
});
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({
kind: "unknown",
state: { phase: "attempted" },
});
f.fetch.mockImplementation(send);
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "registered" });
expect(f.order).toEqual(["GET", "persist:registered"]);
expect(JSON.stringify(f.stored)).not.toContain(secret);
});
it("leaves an absent command after a timed-out attempt unknown instead of blindly creating again", async () => {
const f = fixture();
const send = f.fetch.getMockImplementation()!;
f.fetch.mockImplementation(async (url, init) => {
if (init?.method === "POST") throw new Error(secret);
return send(url, init);
});
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "unknown" });
f.fetch.mockImplementation(send);
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "unknown" });
expect(f.order).toEqual(["GET"]);
expect(f.stored.phase).toBe("attempted");
});
it("updates only the registered owned command ID and narrows its definition", async () => {
const f = fixture();
await reconcileDiscordCommandRegistration(f.options());
f.commands[1]!.contexts = [0, 1, 2];
f.commands[1]!.integration_types = [0, 1];
f.commands[1]!.options = [];
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "registered" });
expect(f.order).toEqual([
"GET",
"persist:attempted",
"PATCH",
"persist:registered",
]);
expect(f.commands[0]).toEqual(f.unrelated);
expect(f.commands[1]).toMatchObject({
contexts: [0, 1],
integration_types: [0],
});
});
it("does not reclaim a deleted, renamed or unmarked registered command", async () => {
for (const mutation of ["deleted", "renamed", "marker_removed", "new_id"]) {
const f = fixture();
await reconcileDiscordCommandRegistration(f.options());
if (mutation === "deleted") f.commands = [f.unrelated];
if (mutation === "renamed") f.commands[1]!.name = "customer_controls";
if (mutation === "marker_removed")
f.commands[1]!.description = "Customer now owns this command";
if (mutation === "new_id") f.commands[1]!.id = "678901234567890123";
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toEqual({ kind: "conflict", reason: "owned_command_changed" });
expect(f.order).toEqual(["GET"]);
}
});
it("requires exact definition and known update ID when reconciling an unknown write", async () => {
const f = fixture();
await reconcileDiscordCommandRegistration(f.options());
f.commands[1]!.options = [];
const send = f.fetch.getMockImplementation()!;
f.fetch.mockImplementation(async (url, init) => {
if (init?.method === "PATCH") throw new Error(secret);
return send(url, init);
});
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "unknown" });
f.fetch.mockImplementation(send);
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "unknown" });
expect(f.order).toEqual(["GET"]);
f.commands[1] = {
...discordPaperclipCommandDefinition(f.stored.ownerId),
id: "678901234567890123",
application_id: scope.applicationId,
version,
};
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "conflict" });
});
it("does not write before durable intent and can reconcile a failed receipt save", async () => {
const f = fixture();
f.commit.mockRejectedValueOnce(new Error(secret));
await expect(
reconcileDiscordCommandRegistration(f.options()),
).rejects.toThrow("persistence unproven");
expect(f.order).toEqual(["GET"]);
expect(f.stored.phase).toBe("prepared");
const persist = f.commit.getMockImplementation()!;
f.commit.mockImplementation(async (before, next) => {
if (next.phase === "registered") throw new Error(secret);
await persist(before, next);
});
await expect(
reconcileDiscordCommandRegistration(f.options()),
).rejects.toThrow("persistence unproven");
expect(f.stored.phase).toBe("attempted");
f.commit.mockImplementation(persist);
f.order.length = 0;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toMatchObject({ kind: "registered" });
expect(f.order).toEqual(["GET", "persist:registered"]);
});
it("rechecks authorization after read and after receipt without claiming success on revocation", async () => {
const f = fixture();
f.authorize.mockImplementation(async (stage?: string) => {
if (stage === "before_write") throw new Error(secret);
});
await expect(
reconcileDiscordCommandRegistration(f.options()),
).rejects.toThrow("authorization denied");
expect(f.order).toEqual(["GET", "persist:attempted"]);
const g = fixture();
g.authorize.mockImplementation(async (stage?: string) => {
if (stage === "before_receipt") throw new Error(secret);
});
await expect(
reconcileDiscordCommandRegistration(g.options()),
).rejects.toThrow("authorization denied");
expect(g.stored.phase).toBe("attempted");
});
it("rejects wrong application, guild, scope and malformed durable ownership before HTTP", async () => {
const f = fixture();
for (const override of [
{
verifiedIdentity: {
botExternalId: scope.guildId,
providerAccountId: scope.guildId,
},
},
{
verifiedIdentity: {
botExternalId: scope.applicationId,
providerAccountId: scope.applicationId,
},
},
{ scope: { ...scope, endpointId: scope.companyId } },
{ state: { ...f.stored, ownerId: "guessed" } },
{ state: { ...f.stored, botToken: secret } },
{ runtimeFence: { ...runtimeFence, generation: -1 } },
]) {
await expect(
reconcileDiscordCommandRegistration({ ...f.options(), ...override }),
).rejects.toThrow("Invalid Discord command registration authority");
}
expect(f.fetch).not.toHaveBeenCalled();
});
it("enforces Discord command limits without deleting customer commands", async () => {
const f = fixture();
const body = discordPaperclipCommandDefinition(f.stored.ownerId);
expect(body.name.length).toBeLessThanOrEqual(32);
expect(body.description.length).toBeLessThanOrEqual(100);
expect(body.options).toHaveLength(3);
expect(
body.options.every(
(option) =>
option.name.length <= 32 && option.description.length <= 100,
),
).toBe(true);
expect(body).toMatchObject({
default_member_permissions: null,
integration_types: [0],
contexts: [0, 1],
});
f.commands = Array.from({ length: 100 }, (_, index) => ({
...f.unrelated,
id: String(BigInt(commandId) + BigInt(index)),
name: `customer_${index}`,
}));
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toEqual({ kind: "conflict", reason: "command_limit" });
expect(f.order).toEqual(["GET"]);
expect(f.commit).not.toHaveBeenCalled();
});
it("retains structured rate-limit delay but never exposes provider text or credentials", async () => {
const f = fixture();
f.fetch.mockResolvedValue(
json(
{ message: `${secret} private details`, retry_after: 172800.5 },
429,
),
);
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toEqual({
kind: "unavailable",
reason: "request_failed",
retryAfterSeconds: 172800.5,
});
expect(f.commit).not.toHaveBeenCalled();
});
it("bounds stalled transport and response bytes", async () => {
const f = fixture();
f.fetch.mockImplementation(
async () => new Promise<Response>(() => undefined),
);
await expect(
reconcileDiscordCommandRegistration({
...f.options(),
requestTimeoutMs: 5,
}),
).resolves.toEqual({ kind: "unavailable", reason: "request_failed" });
f.fetch.mockResolvedValue(
new Response(`"${"x".repeat(2 * 1024 * 1024)}"`, {
headers: { "content-type": "application/json" },
}),
);
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toEqual({ kind: "unavailable", reason: "invalid_response" });
expect(f.commit).not.toHaveBeenCalled();
});
it("does not accept foreign or duplicate command-list identities", async () => {
for (const commands of [
[
{
id: commandId,
application_id: scope.guildId,
version,
type: 1,
name: "customer",
description: "Customer",
},
],
Array.from({ length: 2 }, () => ({
id: commandId,
application_id: scope.applicationId,
version,
type: 1,
name: "customer",
description: "Customer",
})),
]) {
const f = fixture();
f.commands = commands;
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toEqual({ kind: "unavailable", reason: "invalid_response" });
expect(f.commit).not.toHaveBeenCalled();
}
});
it("does not treat a prepared descriptor's public marker as proof of an earlier write", async () => {
const f = fixture();
f.commands = [
{
...discordPaperclipCommandDefinition(f.stored.ownerId),
id: commandId,
application_id: scope.applicationId,
version,
},
];
await expect(
reconcileDiscordCommandRegistration(f.options()),
).resolves.toEqual({ kind: "conflict", reason: "unowned_namespace" });
expect(f.commit).not.toHaveBeenCalled();
expect(
parseDiscordCommandRegistration(
{ ...f.stored, credential: secret },
scope,
),
).toBeNull();
});
it("allows only one writer when concurrent callers race the durable descriptor CAS", async () => {
const f = fixture();
const first = f.options();
const second = f.options();
const outcomes = await Promise.allSettled([
reconcileDiscordCommandRegistration(first),
reconcileDiscordCommandRegistration(second),
]);
expect(
outcomes.filter((outcome) => outcome.status === "fulfilled"),
).toHaveLength(1);
expect(
outcomes.filter((outcome) => outcome.status === "rejected"),
).toHaveLength(1);
expect(
f.fetch.mock.calls.filter(([, init]) => init?.method === "POST"),
).toHaveLength(1);
expect(f.stored.phase).toBe("registered");
});
it("snapshots HTTP authority before an asynchronous gate and freezes journal candidates", async () => {
const f = fixture();
let release!: () => void;
let observed!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
observed = resolve;
});
f.authorize.mockImplementationOnce(async () => {
observed();
await held;
});
const options = f.options();
options.scope = { ...scope };
const run = reconcileDiscordCommandRegistration(options);
await started;
options.scope.applicationId = scope.guildId;
options.botToken = "foreign-secret";
options.fetch = vi.fn(async () => {
throw new Error("wrong fetch");
});
release();
await expect(run).resolves.toMatchObject({ kind: "registered" });
expect(
f.commit.mock.calls.every(
([before, next]) =>
Object.isFrozen(before) &&
Object.isFrozen(next) &&
Object.isFrozen(next.scope),
),
).toBe(true);
});
});

View File

@ -0,0 +1,602 @@
import { createHash, randomBytes } from "node:crypto";
import { z } from "zod";
import type { DiscordBotIdentity } from "./chat-discord.js";
// https://docs.discord.com/developers/interactions/application-commands
// Global commands are required for BOT_DM. Never bulk overwrite an app's
// command set, and never request user-install/private-channel contexts.
const snowflake = z.string().regex(/^[1-9][0-9]{16,19}$/);
const digest = z.string().regex(/^[a-f0-9]{64}$/);
const ownerId = z.string().regex(/^[a-f0-9]{32}$/);
const scopeSchema = z
.object({
companyId: z.uuid(),
endpointId: z.uuid(),
applicationId: snowflake,
guildId: snowflake,
})
.strict();
const fenceSchema = z
.object({
generation: z.number().int().min(0).max(2_147_483_647),
credentialFingerprint: digest,
})
.strict();
const common = {
schema: z.literal("paperclip.discord.command-registration.v1"),
scope: scopeSchema,
ownerId,
};
const attemptSchema = z
.object({
operation: z.enum(["create", "update"]),
commandId: snowflake.nullable(),
definitionDigest: digest,
runtimeFence: fenceSchema,
})
.strict()
.refine((a) => (a.operation === "create") === (a.commandId === null));
const stateSchema = z.discriminatedUnion("phase", [
z.object({ ...common, phase: z.literal("prepared") }).strict(),
z
.object({
...common,
phase: z.literal("attempted"),
attempt: attemptSchema,
})
.strict(),
z
.object({
...common,
phase: z.literal("registered"),
receipt: z
.object({
commandId: snowflake,
version: snowflake,
definitionDigest: digest,
})
.strict(),
})
.strict(),
]);
export type DiscordCommandRegistrationScope = z.infer<typeof scopeSchema>;
export type DiscordCommandRegistrationFence = z.infer<typeof fenceSchema>;
export type DiscordCommandRegistration = z.infer<typeof stateSchema>;
export type DiscordCommandRegistrationStage =
"before_read" | "before_intent" | "before_write" | "before_receipt";
export type DiscordCommandRegistrationResult =
| {
kind: "registered";
state: Extract<DiscordCommandRegistration, { phase: "registered" }>;
}
| {
kind: "conflict";
reason: "unowned_namespace" | "owned_command_changed" | "command_limit";
}
| {
kind: "unknown";
state: Extract<DiscordCommandRegistration, { phase: "attempted" }>;
retryAfterSeconds?: number;
}
| {
kind: "unavailable";
reason: "request_failed" | "invalid_response";
retryAfterSeconds?: number;
};
function freezeState(
state: DiscordCommandRegistration,
): DiscordCommandRegistration {
Object.freeze(state.scope);
if (state.phase === "attempted") {
Object.freeze(state.attempt.runtimeFence);
Object.freeze(state.attempt);
}
if (state.phase === "registered") Object.freeze(state.receipt);
return Object.freeze(state);
}
/** The public marker is an identifier, never a credential or standalone proof. */
export function discordPaperclipCommandDefinition(publicOwnerId: string) {
if (!ownerId.safeParse(publicOwnerId).success)
throw new Error("Invalid Discord command owner identifier");
return {
type: 1,
name: "paperclip",
description: `Paperclip session controls [pc:${publicOwnerId}]`,
options: [
{
type: 1,
name: "status",
description: "Show the current Paperclip task",
},
{
type: 1,
name: "new",
description: "Start a new task in a DM or show new-thread guidance",
},
{
type: 1,
name: "close",
description: "Close the current chat conversation",
},
],
default_member_permissions: null,
integration_types: [0],
contexts: [0, 1],
nsfw: false,
};
}
// One explicitly shipped prior definition. This is maintenance evidence only:
// it never enables command handling before a current definition is confirmed.
function priorCloseCopyDefinition(id: string) {
const definition = discordPaperclipCommandDefinition(id);
definition.options[2]!.description = "Close the current Paperclip task";
return definition;
}
function definitionDigest(id: string, priorCloseCopy = false): string {
return createHash("sha256")
.update(
JSON.stringify(
priorCloseCopy
? priorCloseCopyDefinition(id)
: discordPaperclipCommandDefinition(id),
),
)
.digest("hex");
}
/** Persist this prepared descriptor before calling reconcile; its CAS must
* require that durable row. Do not mint a new owner to bypass a conflict. */
export function createDiscordCommandRegistration(
scope: DiscordCommandRegistrationScope,
): DiscordCommandRegistration {
const parsed = scopeSchema.safeParse(scope);
if (!parsed.success)
throw new Error("Invalid Discord command registration scope");
return freezeState({
schema: "paperclip.discord.command-registration.v1",
scope: parsed.data,
ownerId: randomBytes(16).toString("hex"),
phase: "prepared",
});
}
export function parseDiscordCommandRegistration(
input: unknown,
scope: DiscordCommandRegistrationScope,
/** Maintenance only; prior attempted writes and arbitrary digests stay closed. */
allowKnownPriorDefinition = false,
): DiscordCommandRegistration | null {
const expected = scopeSchema.safeParse(scope);
const parsed = stateSchema.safeParse(input);
if (!expected.success || !parsed.success) return null;
for (const key of Object.keys(
expected.data,
) as (keyof DiscordCommandRegistrationScope)[]) {
if (expected.data[key] !== parsed.data.scope[key]) return null;
}
const state = parsed.data;
const storedDigest =
state.phase === "attempted"
? state.attempt.definitionDigest
: state.phase === "registered"
? state.receipt.definitionDigest
: null;
return storedDigest === null ||
storedDigest === definitionDigest(state.ownerId) ||
(allowKnownPriorDefinition &&
state.phase === "registered" &&
storedDigest === definitionDigest(state.ownerId, true))
? freezeState(state)
: null;
}
export type ReconcileDiscordCommandRegistrationOptions = {
state: unknown;
scope: DiscordCommandRegistrationScope;
verifiedIdentity: Pick<
DiscordBotIdentity,
"botExternalId" | "providerAccountId"
>;
runtimeFence: DiscordCommandRegistrationFence;
botToken: string;
fetch: typeof globalThis.fetch;
/** Recheck the exact current app/endpoint/credential lease. No DB row locks
* may remain held during HTTP. A local lease cannot fence an external admin. */
authorize(stage: DiscordCommandRegistrationStage): Promise<void>;
/** Durable compare-and-swap of the EXACT previous descriptor. Throw on any
* conflict or unknown durability; never publish success from memory alone. */
commit(
expected: DiscordCommandRegistration,
next: DiscordCommandRegistration,
): Promise<void>;
requestTimeoutMs?: number;
};
const remoteCommandSchema = z
.object({
id: snowflake,
application_id: snowflake,
version: snowflake,
type: z.number().int().min(1).max(4),
name: z.string().min(1).max(32),
description: z.string().max(100),
})
.passthrough();
type RemoteCommand = z.infer<typeof remoteCommandSchema>;
const emptyLocalizations = z
.union([z.null(), z.object({}).strict()])
.optional();
const optionSchema = z
.object({
type: z.literal(1),
name: z.string().min(1).max(32),
description: z.string().min(1).max(100),
name_localizations: emptyLocalizations,
description_localizations: emptyLocalizations,
options: z.array(z.never()).max(0).optional(),
required: z.literal(false).optional(),
})
.strict();
function exactDefinition(
command: RemoteCommand,
id: string,
priorCloseCopy = false,
): boolean {
const options = z.array(optionSchema).length(3).safeParse(command.options);
if (
!options.success ||
command.guild_id !== undefined ||
!emptyLocalizations.safeParse(command.name_localizations).success ||
!emptyLocalizations.safeParse(command.description_localizations).success
)
return false;
const normalized = {
type: command.type,
name: command.name,
description: command.description,
options: options.data.map(({ type, name, description }) => ({
type,
name,
description,
})),
default_member_permissions: command.default_member_permissions ?? null,
integration_types: command.integration_types,
contexts: command.contexts,
nsfw: command.nsfw ?? false,
};
return (
JSON.stringify(normalized) ===
JSON.stringify(
priorCloseCopy
? priorCloseCopyDefinition(id)
: discordPaperclipCommandDefinition(id),
)
);
}
function markedOwner(
command: RemoteCommand,
state: DiscordCommandRegistration,
): boolean {
return (
command.type === 1 &&
command.name === "paperclip" &&
command.application_id === state.scope.applicationId &&
command.guild_id === undefined &&
command.description.endsWith(`[pc:${state.ownerId}]`)
);
}
class RequestFailure extends Error {
constructor(
readonly reason: "request_failed" | "invalid_response",
readonly retryAfterSeconds?: number,
) {
super(`Discord command registration ${reason}`);
}
}
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const abort = () => reject(new RequestFailure("request_failed"));
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
promise
.then(resolve, reject)
.finally(() => signal.removeEventListener("abort", abort));
});
}
async function request(
input: ReconcileDiscordCommandRegistrationOptions,
method: "GET" | "POST" | "PATCH",
commandId?: string,
): Promise<unknown> {
const timeoutMs = input.requestTimeoutMs ?? 25_000;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 25_000)
throw new RequestFailure("invalid_response");
const signal = AbortSignal.timeout(timeoutMs);
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const response = await abortable(
input.fetch(
`https://discord.com/api/v10/applications/${input.scope.applicationId}/commands${commandId ? `/${commandId}` : ""}`,
{
method,
signal,
redirect: "error",
headers: {
authorization: `Bot ${input.botToken}`,
...(method === "GET" ? {} : { "content-type": "application/json" }),
},
...(method === "GET"
? {}
: {
body: JSON.stringify(
discordPaperclipCommandDefinition(
(input.state as DiscordCommandRegistration).ownerId,
),
),
}),
},
),
signal,
);
if (
!response.body ||
response.headers
.get("content-type")
?.split(";", 1)[0]
?.trim()
.toLowerCase() !== "application/json"
)
throw new RequestFailure("invalid_response");
reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let length = 0;
for (;;) {
const chunk = await abortable(reader.read(), signal);
if (chunk.done) break;
length += chunk.value.byteLength;
// A full global list can contain 100 chat commands, 15 user commands,
// 15 message commands and one entry-point command. Bound raw bytes too.
if (length > 2 * 1024 * 1024)
throw new RequestFailure("invalid_response");
chunks.push(chunk.value);
}
let body: unknown;
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new RequestFailure("invalid_response");
}
if (!response.ok) {
const value =
body && typeof body === "object"
? (body as { retry_after?: unknown }).retry_after
: undefined;
const retry =
response.status === 429 &&
typeof value === "number" &&
Number.isFinite(value) &&
value >= 0 &&
value <= Number.MAX_SAFE_INTEGER
? value
: undefined;
throw new RequestFailure("request_failed", retry);
}
return body;
} catch (error) {
throw error instanceof RequestFailure
? error
: new RequestFailure("request_failed");
} finally {
void reader?.cancel().catch(() => undefined);
}
}
/**
* Requires a current verified bot identity and caller-owned, app-scoped lease.
* Discord POST is an UPSERT, not create-if-absent. The preflight GET prevents
* known collisions but cannot fence a concurrent external administrator.
* The public marker plus the independently persisted descriptor establishes
* reconciliation identity; the marker alone never grants command ownership.
* Unknown writes remain attempted until GET shows the exact expected command.
* An absent command after timeout is NOT proof that the POST never committed.
*/
export async function reconcileDiscordCommandRegistration(
input: ReconcileDiscordCommandRegistrationOptions,
): Promise<DiscordCommandRegistrationResult> {
let state = parseDiscordCommandRegistration(input.state, input.scope, true);
const runtimeFence = fenceSchema.safeParse(input.runtimeFence);
if (
!state ||
!runtimeFence.success ||
input.verifiedIdentity.botExternalId !== input.scope.applicationId ||
input.verifiedIdentity.providerAccountId !== input.scope.guildId ||
!input.botToken ||
input.botToken.length > 4096 ||
/[\r\n]/.test(input.botToken)
) {
throw new Error("Invalid Discord command registration authority");
}
// The caller may retain its options while an authorization hook is held.
// Snapshot the validated identity, credential and HTTP function before await.
input = {
...input,
scope: state.scope,
state,
runtimeFence: Object.freeze(runtimeFence.data),
verifiedIdentity: Object.freeze({ ...input.verifiedIdentity }),
};
const authorize = async (stage: DiscordCommandRegistrationStage) => {
try {
await input.authorize(stage);
} catch {
throw new Error("Discord command registration authorization denied");
}
};
const persist = async (next: DiscordCommandRegistration) => {
freezeState(next);
try {
await input.commit(state!, next);
} catch {
throw new Error("Discord command registration persistence unproven");
}
state = next;
};
const settle = async (
command: RemoteCommand,
): Promise<DiscordCommandRegistrationResult> => {
const next: Extract<DiscordCommandRegistration, { phase: "registered" }> = {
schema: state!.schema,
scope: state!.scope,
ownerId: state!.ownerId,
phase: "registered",
receipt: {
commandId: command.id,
version: command.version,
definitionDigest: definitionDigest(state!.ownerId),
},
};
freezeState(next);
await authorize("before_receipt");
if (JSON.stringify(state) !== JSON.stringify(next)) await persist(next);
return { kind: "registered", state: next };
};
await authorize("before_read");
let commands: RemoteCommand[];
try {
const parsed = z
.array(remoteCommandSchema)
.max(131)
.safeParse(await request({ ...input, state }, "GET"));
if (
!parsed.success ||
parsed.data.some(
(command) =>
command.application_id !== input.scope.applicationId ||
command.guild_id !== undefined,
) ||
new Set(parsed.data.map((command) => command.id)).size !==
parsed.data.length
)
throw new RequestFailure("invalid_response");
commands = parsed.data;
} catch (error) {
const failure =
error instanceof RequestFailure
? error
: new RequestFailure("invalid_response");
return state.phase === "attempted"
? {
kind: "unknown",
state,
...(failure.retryAfterSeconds === undefined
? {}
: { retryAfterSeconds: failure.retryAfterSeconds }),
}
: {
kind: "unavailable",
reason: failure.reason,
...(failure.retryAfterSeconds === undefined
? {}
: { retryAfterSeconds: failure.retryAfterSeconds }),
};
}
const namespace = commands.filter(
(command) => command.type === 1 && command.name === "paperclip",
);
if (namespace.length > 1)
return { kind: "unavailable", reason: "invalid_response" };
const existing = namespace[0];
if (state.phase === "attempted") {
if (!existing) return { kind: "unknown", state };
if (
!markedOwner(existing, state) ||
(state.attempt.commandId !== null &&
state.attempt.commandId !== existing.id)
) {
return { kind: "conflict", reason: "unowned_namespace" };
}
return exactDefinition(existing, state.ownerId)
? settle(existing)
: { kind: "unknown", state };
}
if (state.phase === "prepared" && existing)
return { kind: "conflict", reason: "unowned_namespace" };
if (state.phase === "registered") {
if (
!existing ||
existing.id !== state.receipt.commandId ||
!markedOwner(existing, state)
) {
return { kind: "conflict", reason: "owned_command_changed" };
}
if (exactDefinition(existing, state.ownerId)) return settle(existing);
if (
state.receipt.definitionDigest !== definitionDigest(state.ownerId) &&
(existing.version !== state.receipt.version ||
!exactDefinition(existing, state.ownerId, true))
) {
// A software copy migration must not overwrite an operator's intervening
// remote edit. Require the exact prior receipt and complete prior shape.
return { kind: "conflict", reason: "owned_command_changed" };
}
} else if (commands.filter((command) => command.type === 1).length >= 100) {
return { kind: "conflict", reason: "command_limit" };
}
const attempted: Extract<DiscordCommandRegistration, { phase: "attempted" }> =
{
schema: state.schema,
scope: state.scope,
ownerId: state.ownerId,
phase: "attempted",
attempt: {
operation: state.phase === "prepared" ? "create" : "update",
commandId: state.phase === "prepared" ? null : state.receipt.commandId,
definitionDigest: definitionDigest(state.ownerId),
runtimeFence: runtimeFence.data,
},
};
await authorize("before_intent");
await persist(attempted);
await authorize("before_write");
let command: RemoteCommand;
try {
const parsed = remoteCommandSchema.safeParse(
await request(
{ ...input, state: attempted },
attempted.attempt.operation === "create" ? "POST" : "PATCH",
attempted.attempt.commandId ?? undefined,
),
);
if (
!parsed.success ||
!markedOwner(parsed.data, attempted) ||
!exactDefinition(parsed.data, attempted.ownerId) ||
(attempted.attempt.commandId !== null &&
parsed.data.id !== attempted.attempt.commandId)
)
throw new RequestFailure("invalid_response");
command = parsed.data;
} catch (error) {
const failure =
error instanceof RequestFailure
? error
: new RequestFailure("request_failed");
return {
kind: "unknown",
state: attempted,
...(failure.retryAfterSeconds === undefined
? {}
: { retryAfterSeconds: failure.retryAfterSeconds }),
};
}
return settle(command);
}

View File

@ -0,0 +1,425 @@
import { createRequire } from "node:module";
import { Modal, Select, SelectOption, TextInput } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createChatSdkEndpointRuntime,
type ChatSdkEndpointRuntime,
type ChatSdkRuntimeCallbacks,
} from "./chat-sdk-runtime.js";
import type {
ChatSdkStatePersistence,
ChatSdkStateRecord,
ChatSdkStateScope,
} from "./chat-sdk-state.js";
interface WireClient {
guilds: { _add(data: Record<string, unknown>): { id: string } };
channels: {
_add(data: Record<string, unknown>, guild: { id: string }): unknown;
};
rest: {
post(route: string, options: Record<string, unknown>): Promise<unknown>;
};
destroy(): Promise<void>;
}
interface WireInteraction {
id: string;
customId: string;
replied: boolean;
deferred: boolean;
components?: unknown[];
}
// Resolve the adapter's own pinned discord.js, not a separately installed test
// dependency. These are actual constructors and response methods, never mocks.
const discordJs = createRequire(import.meta.resolve("@chat-adapter/discord"))(
"discord.js",
) as {
Client: new (options: { intents: number[] }) => WireClient;
ButtonInteraction: new (
client: WireClient,
data: Record<string, unknown>,
) => WireInteraction;
ModalSubmitInteraction: new (
client: WireClient,
data: Record<string, unknown>,
) => WireInteraction;
};
const guildId = "1457808928258658549";
const channelId = "333333333333333333";
const threadId = "555555555555555610";
const applicationId = "123456789012345678";
const userId = "444444444444444410";
const messageId = "666666666666666610";
const callbackId = `pcfs:${"A".repeat(22)}`;
const selectId = `pcff:${"B".repeat(22)}`;
const optionId = `pcfo:${"C".repeat(22)}`;
const textId = `pcff:${"D".repeat(22)}`;
const token = "synthetic-discord-wire-interaction-token";
function modal() {
return Modal({
callbackId,
privateMetadata: callbackId,
title: "Deployment details",
children: [
Select({
id: selectId,
label: "Environment",
options: [SelectOption({ label: "Staging", value: optionId })],
}),
TextInput({
id: textId,
label: "Release note",
maxLength: 4000,
multiline: true,
}),
],
});
}
function envelope(type: 3 | 5, data: Record<string, unknown>) {
return {
id: type === 3 ? "777777777777777710" : "777777777777777711",
application_id: applicationId,
type,
token,
version: 1,
guild_id: guildId,
channel: { id: threadId, type: 11 },
user: {
id: userId,
username: "operator",
global_name: "Operator",
discriminator: "0",
avatar: null,
bot: false,
},
locale: "en-US",
guild_locale: "en-US",
entitlements: [],
authorizing_integration_owners: { "0": guildId },
data,
message: {
id: messageId,
channel_id: threadId,
type: 0,
author: {
id: applicationId,
username: "maya",
discriminator: "0",
avatar: null,
bot: true,
},
content: "Please provide deployment details",
timestamp: "2026-09-09T00:00:00.000Z",
attachments: [],
embeds: [],
components: [],
},
};
}
function memoryPersistence() {
const rows = new Map<string, ChatSdkStateRecord>();
const keyFor = (scope: ChatSdkStateScope, key: string) =>
JSON.stringify([scope.companyId, scope.endpointId, key]);
const persistence: ChatSdkStatePersistence = {
async read(scope, key) {
return rows.get(keyFor(scope, key)) ?? null;
},
async compareAndSet(input) {
const key = keyFor(input, input.key);
const previous = rows.get(key);
if ((previous?.version ?? null) !== input.expectedVersion) return false;
rows.set(key, {
value: input.value,
expiresAt: input.expiresAt,
version: (previous?.version ?? 0) + 1,
});
return true;
},
async deleteIfVersion(input) {
const key = keyFor(input, input.key);
if (rows.get(key)?.version !== input.expectedVersion) return false;
return rows.delete(key);
},
};
return { rows, persistence };
}
// Wire JSON -> installed discord.js -> installed adapter -> Chat SDK -> scoped
// runtime callback. Only REST post returns a provider result. Other network is
// denied, so SDK source-message lookup exercises its supported missing-message
// fallback. Callback observers stop short of service/DB or live Discord proof.
describe("Discord modal installed discord.js wire contract", () => {
const runtimes: ChatSdkEndpointRuntime[] = [];
const clients: WireClient[] = [];
afterEach(async () => {
try {
await Promise.all(
runtimes.splice(0).map((runtime) => runtime.shutdown()),
);
} finally {
try {
await Promise.all(clients.splice(0).map((client) => client.destroy()));
} finally {
vi.restoreAllMocks();
vi.unstubAllGlobals();
}
}
});
async function harness(callbacks: Partial<ChatSdkRuntimeCallbacks> = {}) {
const { rows, persistence } = memoryPersistence();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("Provider network is disabled");
}),
);
const runtime = createChatSdkEndpointRuntime({
companyId: "company-discord-wire",
endpointId: "endpoint-discord-wire",
callbacks: { onMessage() {}, ...callbacks },
enableDiscordGateway: false,
logger: "silent",
persistence,
providerConfig: {
provider: "discord",
userName: "maya",
credentials: {
applicationId,
botToken: "synthetic-bot-token",
guildId,
},
},
});
runtimes.push(runtime);
await runtime.initialize();
const client = new discordJs.Client({ intents: [] });
clients.push(client);
const guild = client.guilds._add({
id: guildId,
name: "Wire fixture",
unavailable: false,
});
client.channels._add(
{
id: threadId,
type: 11,
guild_id: guildId,
parent_id: channelId,
name: "deployment",
thread_metadata: {
archived: false,
archive_timestamp: "2026-09-09T00:00:00.000Z",
auto_archive_duration: 60,
locked: false,
},
},
guild,
);
const post = vi.spyOn(client.rest, "post").mockResolvedValue(undefined);
const adapter = runtime.getProviderAdapter() as unknown as {
handleGatewayInteraction(interaction: WireInteraction): Promise<void>;
};
const button = () =>
new discordJs.ButtonInteraction(
client,
envelope(3, {
component_type: 2,
custom_id: `pcf:${"E".repeat(22)}`,
}),
);
const submit = (customId: string, value = "Ship safely") =>
new discordJs.ModalSubmitInteraction(
client,
envelope(5, {
custom_id: customId,
components: [
{
type: 18,
id: 1,
component: {
type: 3,
id: 2,
custom_id: selectId,
values: [optionId],
},
},
{
type: 18,
id: 3,
component: { type: 4, id: 4, custom_id: textId, value },
},
],
}),
);
return { rows, adapter, post, button, submit };
}
it("posts modern Label modal JSON once with exact opaque IDs and token-free SDK state", async () => {
const onAction = vi.fn<NonNullable<ChatSdkRuntimeCallbacks["onAction"]>>(
async ({ event }) => {
await event.openModal!(modal());
},
);
const h = await harness({ onAction });
const button = h.button();
await h.adapter.handleGatewayInteraction(button);
expect(h.post).toHaveBeenCalledTimes(1);
const [route, options] = h.post.mock.calls[0]!;
expect(route).toBe(`/interactions/${button.id}/${token}/callback`);
expect(options.auth).toBe(false);
expect(options.body).toEqual({
type: 9,
data: {
custom_id: expect.stringMatching(
new RegExp(`^${callbackId}:[0-9a-f-]{36}$`),
),
title: "Deployment details",
components: [
{
type: 18,
label: "Environment",
component: {
type: 3,
custom_id: selectId,
options: [{ label: "Staging", value: optionId }],
required: true,
min_values: 1,
max_values: 1,
},
},
{
type: 18,
label: "Release note",
component: {
type: 4,
custom_id: textId,
style: 2,
required: true,
max_length: 4000,
},
},
],
},
});
expect(button.replied).toBe(true);
expect(button.deferred).toBe(false);
expect(onAction).toHaveBeenCalledTimes(1);
expect(onAction.mock.calls[0]![0]).toMatchObject({
transport: "discord_gateway",
event: {
threadId: `discord:${guildId}:${channelId}:${threadId}`,
messageId,
user: { userId },
},
});
expect(h.rows.size).toBeGreaterThan(0);
expect(JSON.stringify([...h.rows])).toContain(messageId);
expect(JSON.stringify([...h.rows])).not.toContain(token);
expect(JSON.stringify(onAction.mock.calls[0]![0].event.raw)).not.toContain(
token,
);
});
it("does not defer, reply, or retry after actual showModal REST failure", async () => {
const onAction = vi.fn<NonNullable<ChatSdkRuntimeCallbacks["onAction"]>>(
async ({ event }) => {
await event.openModal!(modal());
},
);
const h = await harness({ onAction });
h.post.mockRejectedValue(new Error("Synthetic ambiguous callback failure"));
const button = h.button();
await h.adapter.handleGatewayInteraction(button);
expect(h.post).toHaveBeenCalledTimes(1);
expect(h.post.mock.calls[0]![1].body).toMatchObject({ type: 9 });
expect(button.replied).toBe(false);
expect(button.deferred).toBe(false);
expect(onAction).toHaveBeenCalledTimes(1);
expect(JSON.stringify([...h.rows])).not.toContain(token);
});
it.each(["accepted", "denied"])(
"parses actual Label submit values and sends one private %s response",
async (outcome) => {
const onModalSubmit = vi.fn<
NonNullable<ChatSdkRuntimeCallbacks["onModalSubmit"]>
>(async () =>
outcome === "accepted"
? { action: "clear" as const }
: {
action: "errors" as const,
errors: { _form: "This response was not accepted." },
},
);
const h = await harness({
onAction: async ({ event }) => {
await event.openModal!(modal());
},
onModalSubmit,
});
await h.adapter.handleGatewayInteraction(h.button());
const body = h.post.mock.calls[0]![1].body as {
data: { custom_id: string };
};
const before = JSON.stringify([...h.rows]);
expect(before).toContain(messageId);
expect(before).not.toContain(token);
h.post.mockClear();
const submission = h.submit(body.data.custom_id);
expect(submission.components).toEqual([
{
type: 18,
id: 1,
component: { type: 3, id: 2, customId: selectId, values: [optionId] },
},
{
type: 18,
id: 3,
component: { type: 4, id: 4, customId: textId, value: "Ship safely" },
},
]);
await h.adapter.handleGatewayInteraction(submission);
expect(onModalSubmit).toHaveBeenCalledTimes(1);
expect(onModalSubmit.mock.calls[0]![0]).toMatchObject({
transport: "discord_gateway",
event: {
callbackId,
privateMetadata: callbackId,
values: { [selectId]: optionId, [textId]: "Ship safely" },
user: { userId },
relatedThread: { id: `discord:${guildId}:${channelId}:${threadId}` },
},
});
expect(h.post).toHaveBeenCalledTimes(1);
expect(h.post.mock.calls[0]![0]).toBe(
`/interactions/${submission.id}/${token}/callback`,
);
expect(h.post.mock.calls[0]![1]).toMatchObject({
auth: false,
body: {
type: 4,
data: {
content:
outcome === "accepted"
? "Your response was received."
: "This response was not accepted. Open the linked Paperclip task or reopen the question to try again.",
flags: 64,
allowed_mentions: { parse: [] },
},
},
});
expect(submission.replied).toBe(true);
expect(submission.deferred).toBe(false);
expect(JSON.stringify([...h.rows])).not.toContain(token);
expect(JSON.stringify(onModalSubmit.mock.calls[0]![0])).not.toContain(
token,
);
},
);
});

View File

@ -0,0 +1,651 @@
import { createRequire } from "node:module";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createChatSdkEndpointRuntime,
type ChatSdkEndpointRuntime,
type ChatSdkRuntimeCallbacks,
} from "./chat-sdk-runtime.js";
import type {
ChatSdkStatePersistence,
ChatSdkStateRecord,
} from "./chat-sdk-state.js";
interface WireClient {
guilds: { _add(data: Record<string, unknown>): { id: string } };
channels: {
_add(data: Record<string, unknown>, guild?: { id: string }): unknown;
};
rest: {
post(route: string, options: Record<string, unknown>): Promise<unknown>;
patch(route: string, options: Record<string, unknown>): Promise<unknown>;
};
destroy(): Promise<void>;
}
interface WireInteraction {
id: string;
replied: boolean;
deferred: boolean;
}
const discordJs = createRequire(import.meta.resolve("@chat-adapter/discord"))(
"discord.js",
) as {
Client: new (options: { intents: number[] }) => WireClient;
ChatInputCommandInteraction: new (
client: WireClient,
data: Record<string, unknown>,
) => WireInteraction;
};
const applicationId = "123456789012345678";
const guildId = "1457808928258658549";
const channelId = "333333333333333333";
const threadId = "555555555555555610";
const userId = "444444444444444410";
const commandId = "888888888888888880";
let interactionSequence = 0n;
const freshInteractionId = (at = Date.now()) =>
(((BigInt(at) - 1420070400000n) << 22n) + interactionSequence++).toString();
const token = "synthetic-private-discord-command-token";
function wire(overrides: Record<string, unknown> = {}) {
return {
id: freshInteractionId(),
application_id: applicationId,
type: 2,
token,
version: 1,
context: 0,
authorizing_integration_owners: { "0": guildId },
guild_id: guildId,
channel: { id: threadId, type: 11 },
user: {
id: userId,
username: "operator",
global_name: "Operator",
discriminator: "0",
avatar: null,
bot: false,
},
locale: "en-US",
guild_locale: "en-US",
entitlements: [],
data: {
id: commandId,
name: "paperclip",
type: 1,
options: [{ type: 1, name: "status" }],
},
...overrides,
};
}
// Actual installed discord.js -> adapter -> Chat SDK -> scoped callback. Only
// HTTP is simulated; this is not durable service admission or live Discord proof.
describe("Discord native command Gateway boundary", () => {
const runtimes: ChatSdkEndpointRuntime[] = [];
const clients: WireClient[] = [];
afterEach(async () => {
try {
await Promise.all(
runtimes.splice(0).map((runtime) => runtime.shutdown()),
);
} finally {
try {
await Promise.all(clients.splice(0).map((client) => client.destroy()));
} finally {
vi.restoreAllMocks();
vi.unstubAllGlobals();
}
}
});
async function setup(
onSlashCommand: ChatSdkRuntimeCallbacks["onSlashCommand"],
) {
const rows = new Map<string, ChatSdkStateRecord>();
const persistence: ChatSdkStatePersistence = {
async read(_scope, key) {
return rows.get(key) ?? null;
},
async compareAndSet(input) {
const previous = rows.get(input.key);
if ((previous?.version ?? null) !== input.expectedVersion) return false;
rows.set(input.key, {
value: input.value,
expiresAt: input.expiresAt,
version: (previous?.version ?? 0) + 1,
});
return true;
},
async deleteIfVersion(input) {
if (rows.get(input.key)?.version !== input.expectedVersion)
return false;
return rows.delete(input.key);
},
};
const fetch = vi.fn(async () => {
throw new Error("Network disabled");
});
vi.stubGlobal("fetch", fetch);
const runtime = createChatSdkEndpointRuntime({
companyId: "company-command",
endpointId: "endpoint-command",
callbacks: {
onMessage() {},
...(onSlashCommand ? { onSlashCommand } : {}),
},
enableDiscordGateway: false,
logger: "silent",
persistence,
providerConfig: {
provider: "discord",
userName: "maya",
credentials: {
applicationId,
guildId,
botToken: "synthetic-bot-token",
},
},
});
runtimes.push(runtime);
await runtime.initialize();
const client = new discordJs.Client({ intents: [] });
clients.push(client);
const guild = client.guilds._add({
id: guildId,
name: "Fixture",
unavailable: false,
});
client.channels._add(
{
id: threadId,
type: 11,
guild_id: guildId,
parent_id: channelId,
name: "task",
thread_metadata: {
archived: false,
archive_timestamp: "2026-09-09T00:00:00.000Z",
auto_archive_duration: 60,
locked: false,
},
},
guild,
);
client.channels._add({
id: "333333333333333334",
type: 1,
recipients: [
{ id: userId, username: "operator", discriminator: "0", avatar: null },
],
});
const post = vi.spyOn(client.rest, "post").mockResolvedValue(undefined);
const patch = vi.spyOn(client.rest, "patch").mockResolvedValue({
id: "999999999999999999",
channel_id: threadId,
content: "Reply",
author: {
id: applicationId,
username: "maya",
discriminator: "0",
avatar: null,
bot: true,
},
timestamp: "2026-09-09T00:00:00.000Z",
type: 0,
attachments: [],
embeds: [],
});
const adapter = runtime.getProviderAdapter() as unknown as {
handleGatewayInteraction(interaction: WireInteraction): Promise<void>;
postMessage(threadId: string, message: string): Promise<unknown>;
discordFetch(
path: string,
method: string,
body: unknown,
): Promise<Response>;
requestContext: { getStore(): unknown };
};
const command = (overrides: Record<string, unknown> = {}) =>
new discordJs.ChatInputCommandInteraction(client, wire(overrides));
return { runtime, rows, adapter, post, patch, command, fetch };
}
it("awaits closed admission after an immediate private defer and retains exact no-argument command identity without token", async () => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const callback = vi.fn(
async (
input: Parameters<
NonNullable<ChatSdkRuntimeCallbacks["onSlashCommand"]>
>[0],
) => {
await input.event.channel.setState({ nativeCommand: input.event.raw });
await held;
return { kind: "accepted" as const, content: "Task is running." };
},
);
const f = await setup(callback);
let completed = false;
const command = f.command();
const pending = f.adapter.handleGatewayInteraction(command).then(() => {
completed = true;
});
await vi.waitFor(() => expect(callback).toHaveBeenCalledOnce());
const earlyCompleted = completed;
const earlyPost = f.post.mock.calls.map((call) => call[1]);
release();
await pending;
expect(earlyCompleted).toBe(false);
expect(earlyPost).toMatchObject([
{ body: { type: 5, data: { flags: 64 } } },
]);
expect(f.patch).toHaveBeenCalledOnce();
expect(f.patch.mock.calls[0]?.[1]).toMatchObject({
body: { content: "Task is running.", allowed_mentions: { parse: [] } },
});
const event = callback.mock.calls[0]?.[0] as unknown as {
transport: string;
event: { command: string; text: string; raw: Record<string, unknown> };
};
expect(event).toMatchObject({
endpointId: "endpoint-command",
provider: "discord",
transport: "discord_gateway",
event: {
command: "/paperclip status",
text: "",
channelId: `discord:${guildId}:${channelId}:${threadId}`,
raw: {
id: command.id,
application_id: applicationId,
data: {
id: commandId,
name: "paperclip",
options: [{ type: 1, name: "status" }],
},
},
},
});
expect(JSON.stringify(event.event.raw)).not.toContain(token);
expect(f.rows.size).toBeGreaterThan(0);
expect(JSON.stringify([...f.rows.values()])).not.toContain(token);
expect(f.fetch).not.toHaveBeenCalled();
});
it("does not turn a throwing callback into accepted text or a second initial response", async () => {
const callback = vi.fn(async () => {
throw new Error("sensitive-bootstrap-error");
});
const f = await setup(callback);
await f.adapter.handleGatewayInteraction(f.command());
expect(callback).toHaveBeenCalledOnce();
expect(f.post).toHaveBeenCalledOnce();
expect(f.patch).toHaveBeenCalledOnce();
expect(JSON.stringify(f.patch.mock.calls)).toContain(
"could not be confirmed",
);
expect(JSON.stringify(f.patch.mock.calls)).not.toContain(
"sensitive-bootstrap-error",
);
});
it("keeps a publication inside command handling on the ordinary bot route", async () => {
let f: Awaited<ReturnType<typeof setup>>;
const callback = vi.fn(async () => {
expect(f.adapter.requestContext.getStore()).toBeUndefined();
await f.adapter.postMessage(
`discord:${guildId}:${channelId}:${threadId}`,
"Durable task publication",
);
return { kind: "accepted", content: "Command recorded." };
});
f = await setup(
callback as unknown as ChatSdkRuntimeCallbacks["onSlashCommand"],
);
const publicSend = vi
.spyOn(f.adapter, "discordFetch")
.mockResolvedValue(
new Response(JSON.stringify({ id: "999999999999999999" })),
);
await f.adapter.handleGatewayInteraction(f.command());
expect(publicSend).toHaveBeenCalledWith(
`/channels/${threadId}/messages`,
"POST",
expect.objectContaining({ content: "Durable task publication" }),
);
expect(f.patch).toHaveBeenCalledOnce();
});
it.each(["new", "close"])(
"normalizes the exact no-argument %s subcommand",
async (name) => {
const callback = vi.fn(async () => ({
kind: "accepted" as const,
content: "Recorded.",
}));
const f = await setup(callback);
await f.adapter.handleGatewayInteraction(
f.command({
data: {
id: commandId,
name: "paperclip",
type: 1,
options: [{ name, type: 1, options: [] }],
},
}),
);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
command: `/paperclip ${name}`,
text: "",
}),
}),
);
expect(f.patch).toHaveBeenCalledOnce();
},
);
it("admits only the explicit guild-installed bot DM context without inventing a guild", async () => {
const callback = vi.fn(async () => ({
kind: "accepted" as const,
content: "Recorded.",
}));
const f = await setup(callback);
await f.adapter.handleGatewayInteraction(
f.command({
guild_id: undefined,
channel: { id: "333333333333333334", type: 1 },
context: 1,
authorizing_integration_owners: { "0": "0" },
}),
);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
channelId: "discord:@me:333333333333333334",
raw: expect.objectContaining({
guild_id: "@me",
context: 1,
authorizing_integration_owners: { "0": "0" },
}),
}),
}),
);
});
it.each([
["foreign application", { application_id: "123456789012345679" }],
["foreign guild", { guild_id: "1457808928258658550" }],
[
"foreign install",
{ authorizing_integration_owners: { "0": "1457808928258658550" } },
],
[
"user install",
{ authorizing_integration_owners: { "0": guildId, "1": userId } },
],
["missing install", { authorizing_integration_owners: {} }],
["private context", { context: 2 }],
["missing context", { context: undefined }],
[
"unknown namespace",
{
data: {
id: commandId,
name: "customer",
type: 1,
options: [{ name: "status", type: 1 }],
},
},
],
[
"unknown subcommand",
{
data: {
id: commandId,
name: "paperclip",
type: 1,
options: [{ name: "delete", type: 1 }],
},
},
],
[
"argument value",
{
data: {
id: commandId,
name: "paperclip",
type: 1,
options: [{ name: "status", type: 3, value: "secret" }],
},
},
],
[
"nested options",
{
data: {
id: commandId,
name: "paperclip",
type: 1,
options: [
{
name: "status",
type: 1,
options: [{ name: "task", type: 3, value: "other" }],
},
],
},
},
],
[
"multiple options",
{
data: {
id: commandId,
name: "paperclip",
type: 1,
options: [
{ name: "status", type: 1 },
{ name: "close", type: 1 },
],
},
},
],
[
"missing command identity",
{
data: {
name: "paperclip",
type: 1,
options: [{ name: "status", type: 1 }],
},
},
],
])(
"privately denies %s without calling scoped admission",
async (_name, overrides) => {
const callback = vi.fn(async () => ({
kind: "accepted" as const,
content: "Recorded.",
}));
const f = await setup(callback);
await f.adapter.handleGatewayInteraction(
f.command(overrides as Record<string, unknown>),
);
expect(callback).not.toHaveBeenCalled();
expect(f.post).toHaveBeenCalledOnce();
expect(f.patch).toHaveBeenCalledOnce();
expect(JSON.stringify(f.patch.mock.calls)).toContain(
"not available here",
);
},
);
it.each([
undefined,
{ kind: "accepted", content: "" },
{ kind: "accepted", content: "x".repeat(2001) },
{ kind: "accepted", content: "x", extra: true },
])("never accepts a missing or invalid completion %#", async (result) => {
const callback = vi.fn(async () => result) as unknown as NonNullable<
ChatSdkRuntimeCallbacks["onSlashCommand"]
>;
const f = await setup(callback);
await f.adapter.handleGatewayInteraction(f.command());
expect(JSON.stringify(f.patch.mock.calls)).toContain(
"could not be confirmed",
);
});
it("keeps a deliberate denial private and suppresses all mentions", async () => {
const f = await setup(async () => ({ kind: "denied" }));
await f.adapter.handleGatewayInteraction(f.command());
expect(f.patch.mock.calls[0]?.[1]).toMatchObject({
body: {
content: expect.stringContaining("not available here"),
allowed_mentions: { parse: [] },
},
});
});
it("does not replay the callback or any response after an ambiguous initial POST", async () => {
const callback = vi.fn(async () => ({
kind: "accepted" as const,
content: "Recorded.",
}));
const f = await setup(callback);
const command = f.command();
f.post.mockRejectedValueOnce(new Error(`HTTP outcome unknown ${token}`));
await f.adapter.handleGatewayInteraction(command);
await f.adapter.handleGatewayInteraction(f.command({ id: command.id }));
expect(f.post).toHaveBeenCalledOnce();
expect(f.patch).not.toHaveBeenCalled();
expect(callback).not.toHaveBeenCalled();
});
it("suppresses concurrent and completed duplicate IDs but admits a new interaction", async () => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const callback = vi.fn(async () => {
await held;
return { kind: "accepted" as const, content: "Recorded." };
});
const f = await setup(callback);
const command = f.command();
const pending = f.adapter.handleGatewayInteraction(command);
await vi.waitFor(() => expect(callback).toHaveBeenCalledOnce());
await f.adapter.handleGatewayInteraction(f.command({ id: command.id }));
release();
await pending;
await f.adapter.handleGatewayInteraction(f.command({ id: command.id }));
expect(f.post).toHaveBeenCalledOnce();
expect(f.patch).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledOnce();
await f.adapter.handleGatewayInteraction(f.command());
expect(callback).toHaveBeenCalledTimes(2);
});
it("never retries an uncertain private completion or repeats its committed callback", async () => {
const callback = vi.fn(async () => ({
kind: "accepted" as const,
content: "Recorded.",
}));
const f = await setup(callback);
const command = f.command();
f.patch.mockRejectedValueOnce(new Error("lost completion"));
await f.adapter.handleGatewayInteraction(command);
await f.adapter.handleGatewayInteraction(f.command({ id: command.id }));
expect(callback).toHaveBeenCalledOnce();
expect(f.post).toHaveBeenCalledOnce();
expect(f.patch).toHaveBeenCalledOnce();
});
it("refuses to start an expired interaction even after its local dedupe entry could expire", async () => {
const callback = vi.fn(async () => ({
kind: "accepted" as const,
content: "Recorded.",
}));
const f = await setup(callback);
await f.adapter.handleGatewayInteraction(
f.command({ id: freshInteractionId(Date.now() - 3000) }),
);
expect(callback).not.toHaveBeenCalled();
expect(f.post).not.toHaveBeenCalled();
expect(f.patch).not.toHaveBeenCalled();
});
it("keeps concurrent private completions attached to their own interaction", async () => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const callback: NonNullable<
ChatSdkRuntimeCallbacks["onSlashCommand"]
> = async ({ event }) => {
if (event.command === "/paperclip status") await held;
return { kind: "accepted", content: event.command };
};
const f = await setup(callback);
const status = f.adapter.handleGatewayInteraction(
f.command({ token: "private-status-token" }),
);
await vi.waitFor(() => expect(f.post).toHaveBeenCalledOnce());
await f.adapter.handleGatewayInteraction(
f.command({
token: "private-close-token",
data: {
id: commandId,
name: "paperclip",
type: 1,
options: [{ name: "close", type: 1 }],
},
}),
);
release();
await status;
expect(f.patch.mock.calls).toMatchObject([
[
expect.stringContaining("private-close-token"),
{ body: { content: "/paperclip close" } },
],
[
expect.stringContaining("private-status-token"),
{ body: { content: "/paperclip status" } },
],
]);
expect(JSON.stringify([...f.rows.values()])).not.toContain("private-");
});
it("does not trust a forged transport marker through ordinary SDK dispatch", async () => {
const callback = vi.fn(async () => ({
kind: "accepted" as const,
content: "Recorded.",
}));
const f = await setup(callback);
const sdk = (
f.runtime as unknown as {
chat: {
handleSlashCommandEvent(
event: Record<string, unknown>,
): Promise<void>;
};
}
).chat;
await sdk.handleSlashCommandEvent({
command: "/paperclip status",
text: "",
adapter: f.adapter,
channelId: `discord:${guildId}:${channelId}:${threadId}`,
user: { userId, userName: "operator", isMe: false, isBot: false },
raw: { ...wire(), transport: "discord_gateway" },
});
expect(callback).not.toHaveBeenCalled();
expect(f.post).not.toHaveBeenCalled();
expect(f.patch).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,408 @@
import { createHash } from "node:crypto";
import type { SlashCommandEvent } from "chat";
import { createDiscordAdapter } from "@chat-adapter/discord";
import { describe, expect, it } from "vitest";
import type { ChatSdkCallbackEvent } from "./chat-sdk-runtime.js";
import {
createDiscordCommandRegistration,
discordPaperclipCommandDefinition,
} from "./chat-discord-command-registration.js";
import {
isCurrentDiscordCommandRegistration,
parseDiscordNativeCommand,
parseDiscordNativeCommandReceipt,
} from "./chat-discord-native-commands.js";
const scope = {
companyId: "11111111-1111-4111-8111-111111111111",
endpointId: "22222222-2222-4222-8222-222222222222",
applicationId: "123456789012345678",
guildId: "1457808928258658549",
};
const parentId = "333333333333333333";
const threadId = "555555555555555555";
const userId = "444444444444444444";
const commandId = "888888888888888888";
const interactionId = "777777777777777777";
function callback(rawOverrides: Record<string, unknown> = {}) {
const thread = `discord:${scope.guildId}:${parentId}:${threadId}`;
return {
endpointId: scope.endpointId,
provider: "discord",
transport: "discord_gateway",
event: {
command: "/paperclip status",
text: "",
channelId: thread,
channel: { id: thread },
user: { userId, isBot: false, isMe: false },
raw: {
id: interactionId,
application_id: scope.applicationId,
type: 2,
version: 1,
channel_id: threadId,
channel: { id: threadId, type: 11, parent_id: parentId },
guild_id: scope.guildId,
context: 0,
authorizing_integration_owners: { "0": scope.guildId },
data: {
id: commandId,
type: 1,
name: "paperclip",
options: [{ type: 1, name: "status" }],
},
user: { id: userId, username: "operator", bot: false },
...rawOverrides,
},
},
} as unknown as ChatSdkCallbackEvent<
SlashCommandEvent & { channelId: string }
>;
}
function registrationAction() {
const prepared = createDiscordCommandRegistration(scope);
return {
companyId: scope.companyId,
endpointId: scope.endpointId,
conversationId: null,
kind: "discord_command_registration",
providerActionId: `discord-command-registration:${scope.applicationId}`,
status: "processed",
payload: {
registration: {
...prepared,
phase: "registered",
receipt: {
commandId,
version: "999999999999999999",
definitionDigest: createHash("sha256")
.update(
JSON.stringify(
discordPaperclipCommandDefinition(prepared.ownerId),
),
)
.digest("hex"),
},
},
},
};
}
describe("Discord native command service authority parser", () => {
it("accepts the pinned adapter's actual normalized user fields without treating presentation as authority", () => {
const adapter = createDiscordAdapter({
applicationId: scope.applicationId,
botToken: "synthetic-token",
webhookVerifier: async () => false,
}) as unknown as {
normalizeGatewayUser(input: Record<string, unknown>): unknown;
};
const normalized = adapter.normalizeGatewayUser({
id: userId,
username: "operator",
bot: false,
discriminator: "0",
avatar: "a_fixture_avatar",
globalName: "Operator",
});
expect(normalized).toMatchObject({
discriminator: "0",
avatar: "a_fixture_avatar",
});
expect(
parseDiscordNativeCommand(callback({ user: normalized }), scope),
).toMatchObject({ actorExternalId: userId });
});
it("preserves exact native thread, registered command, actor and immutable invocation digest", () => {
const parsed = parseDiscordNativeCommand(callback(), scope);
expect(parsed).toMatchObject({
command: "status",
interactionId,
applicationId: scope.applicationId,
registeredCommandId: commandId,
actorExternalId: userId,
sourceKind: "native_thread",
threadId: `discord:${scope.guildId}:${parentId}:${threadId}`,
channelId: `discord:${scope.guildId}:${parentId}`,
providerResourceId: parentId,
guildId: scope.guildId,
digest: expect.stringMatching(/^[a-f0-9]{64}$/),
});
});
it("requires the current processed registration receipt, never just a familiar command name", () => {
expect(
isCurrentDiscordCommandRegistration(
registrationAction(),
scope,
commandId,
),
).toBe(true);
});
it("preserves explicit bot-DM source without inventing a guild binding", () => {
const c = callback({
channel: { id: parentId, type: 1 },
channel_id: parentId,
guild_id: "@me",
context: 1,
authorizing_integration_owners: { "0": "0" },
});
c.event.channelId = `discord:@me:${parentId}`;
Object.assign(c.event.channel, { id: c.event.channelId });
expect(parseDiscordNativeCommand(c, scope)).toMatchObject({
sourceKind: "direct_message",
guildId: null,
threadId: `discord:@me:${parentId}`,
channelId: `discord:@me:${parentId}`,
});
});
it.each([
["token-bearing raw", { token: "never-persist-this" }],
["foreign application", { application_id: "123456789012345679" }],
["foreign guild", { guild_id: "1457808928258658550" }],
[
"foreign install",
{ authorizing_integration_owners: { "0": "1457808928258658550" } },
],
[
"user install",
{ authorizing_integration_owners: { "0": scope.guildId, "1": userId } },
],
["private context", { context: 2 }],
["missing parent", { channel: { id: threadId, type: 11 } }],
["wrong channel", { channel_id: parentId }],
[
"extra argument",
{
data: {
id: commandId,
type: 1,
name: "paperclip",
options: [
{
type: 1,
name: "status",
options: [{ type: 3, name: "task", value: "foreign" }],
},
],
},
},
],
[
"unknown command",
{
data: {
id: commandId,
type: 1,
name: "paperclip",
options: [{ type: 1, name: "delete" }],
},
},
],
[
"self",
{ user: { id: scope.applicationId, username: "maya", bot: true } },
],
])("rejects %s without extracting a command", (_reason, raw) => {
expect(
parseDiscordNativeCommand(
callback(raw as Record<string, unknown>),
scope,
),
).toBeNull();
});
it("rejects altered runtime-normalized actor, channel, command and ingress context", () => {
for (const mutate of [
(c: ReturnType<typeof callback>) => {
delete c.transport;
},
(c: ReturnType<typeof callback>) => {
c.endpointId = "33333333-3333-4333-8333-333333333333";
},
(c: ReturnType<typeof callback>) => {
c.event.user.userId = "444444444444444445";
},
(c: ReturnType<typeof callback>) => {
c.event.command = "/paperclip close";
},
(c: ReturnType<typeof callback>) => {
c.event.text = "status";
},
(c: ReturnType<typeof callback>) => {
c.event.channelId = `discord:${scope.guildId}:${parentId}`;
},
(c: ReturnType<typeof callback>) => {
Object.assign(c.event.channel, {
id: `discord:${scope.guildId}:${parentId}`,
});
},
]) {
const c = callback();
mutate(c);
expect(parseDiscordNativeCommand(c, scope)).toBeNull();
}
});
it("uses exact canonical identity for the digest, not display-name or object-key ordering", () => {
const first = parseDiscordNativeCommand(callback(), scope)!;
const renamed = parseDiscordNativeCommand(
callback({
user: {
bot: false,
username: "renamed",
global_name: "New Display",
id: userId,
},
}),
scope,
)!;
expect(renamed.digest).toBe(first.digest);
expect(Object.isFrozen(first)).toBe(true);
expect(
parseDiscordNativeCommand(callback({ id: "777777777777777778" }), scope)
?.digest,
).not.toBe(first.digest);
expect(
parseDiscordNativeCommand(callback(), {
...scope,
companyId: "33333333-3333-4333-8333-333333333333",
})?.digest,
).not.toBe(first.digest);
});
it("keeps a guild root distinct from an existing native task thread", () => {
const c = callback({
channel: { id: parentId, type: 0 },
channel_id: parentId,
});
c.event.channelId = `discord:${scope.guildId}:${parentId}`;
Object.assign(c.event.channel, { id: c.event.channelId });
expect(parseDiscordNativeCommand(c, scope)).toMatchObject({
sourceKind: "guild_channel",
threadId: c.event.channelId,
});
expect(parseDiscordNativeCommand(c, scope)?.digest).not.toBe(
parseDiscordNativeCommand(callback(), scope)?.digest,
);
});
it("rejects incomplete, foreign, ambiguous and mismatched registration rows", () => {
const row = registrationAction();
for (const changed of [
{ ...row, status: "processing" },
{ ...row, endpointId: "33333333-3333-4333-8333-333333333333" },
{ ...row, companyId: "33333333-3333-4333-8333-333333333333" },
{ ...row, kind: "ordinary_action" },
{ ...row, conversationId: "33333333-3333-4333-8333-333333333333" },
{
...row,
providerActionId: "discord-command-registration:123456789012345679",
},
{
...row,
payload: { registration: createDiscordCommandRegistration(scope) },
},
{
...row,
payload: {
registration: {
...row.payload.registration,
receipt: {
...row.payload.registration.receipt,
definitionDigest: "0".repeat(64),
},
},
},
},
{
...row,
payload: {
registration: {
...row.payload.registration,
scope: { ...scope, guildId: "1457808928258658550" },
},
},
},
null,
])
expect(
isCurrentDiscordCommandRegistration(changed, scope, commandId),
).toBe(false);
expect(
isCurrentDiscordCommandRegistration(row, scope, "888888888888888889"),
).toBe(false);
});
it("returns only the exact processed invocation, origin fence and original target for a replay", () => {
const invocation = parseDiscordNativeCommand(callback(), scope)!;
const target = {
conversationId: scope.endpointId,
issueId: scope.companyId,
sessionGeneration: 1,
};
const row = {
...scope,
principalId: scope.companyId,
conversationId: target.conversationId,
kind: "discord_native_command",
providerActionId: `discord-native-command:${interactionId}`,
status: "processed",
payload: {
version: 1,
invocation,
runtimeFence: { generation: 7, credentialFingerprint: "a".repeat(64) },
target,
},
result: {
kind: "discord_native_command_recorded",
content: "Current task status",
publicationId: null,
},
};
expect(parseDiscordNativeCommandReceipt(row, invocation, scope)).toEqual({
principalId: scope.companyId,
runtimeFence: row.payload.runtimeFence,
target,
result: row.result,
});
for (const changed of [
{ ...row, status: "received" },
{ ...row, companyId: scope.endpointId },
{ ...row, principalId: "" },
{ ...row, conversationId: null },
{ ...row, payload: { ...row.payload, extra: true } },
{
...row,
payload: {
...row.payload,
invocation: { ...invocation, command: "close" },
},
},
{
...row,
payload: {
...row.payload,
invocation: { ...invocation, extra: "opaque" },
},
},
{ ...row, payload: { ...row.payload, runtimeFence: { generation: 7 } } },
{
...row,
payload: {
...row.payload,
target: { ...target, sessionGeneration: 0 },
},
},
{ ...row, result: { ...row.result, publicationId: scope.endpointId } },
{ ...row, result: { ...row.result, content: "" } },
{ ...row, result: { ...row.result, raw: "provider error" } },
])
expect(
parseDiscordNativeCommandReceipt(changed, invocation, scope),
).toBeNull();
});
});

View File

@ -0,0 +1,272 @@
import { createHash } from "node:crypto";
import type { SlashCommandEvent } from "chat";
import { z } from "zod";
import type { ChatSdkCallbackEvent } from "./chat-sdk-runtime.js";
import {
parseDiscordCommandRegistration,
type DiscordCommandRegistrationScope,
} from "./chat-discord-command-registration.js";
const snowflake = z.string().regex(/^[1-9][0-9]{16,19}$/);
const sourceScope = z
.object({
companyId: z.uuid(),
endpointId: z.uuid(),
applicationId: snowflake,
guildId: snowflake,
})
.strict();
// This is the runtime's closed token-free normalized object, not arbitrary
// Discord JSON. A caller cannot turn a raw transport hint into Gateway proof.
const rawCommand = z
.object({
id: snowflake,
application_id: snowflake,
type: z.literal(2),
version: z.literal(1),
channel_id: snowflake,
channel: z
.object({
id: snowflake,
type: z.number().int(),
parent_id: snowflake.optional(),
})
.strict(),
guild_id: z.union([snowflake, z.literal("@me")]),
context: z.union([z.literal(0), z.literal(1)]),
authorizing_integration_owners: z
.object({ "0": z.union([snowflake, z.literal("0")]) })
.strict(),
data: z
.object({
id: snowflake,
type: z.literal(1),
name: z.literal("paperclip"),
options: z.tuple([
z
.object({
type: z.literal(1),
name: z.enum(["status", "new", "close"]),
})
.strict(),
]),
})
.strict(),
user: z
.object({
id: snowflake,
username: z.string().min(1).max(100),
global_name: z.string().max(100).nullable().optional(),
avatar: z.string().max(100).optional(),
discriminator: z
.string()
.regex(/^[0-9]{1,4}$/)
.optional(),
bot: z.boolean().optional(),
})
.strict(),
})
.strict();
export interface DiscordNativeCommandInvocation {
schema: "paperclip.discord.native-command.v1";
interactionId: string;
applicationId: string;
registeredCommandId: string;
command: "status" | "new" | "close";
actorExternalId: string;
sourceKind: "direct_message" | "native_thread" | "guild_channel";
threadId: string;
channelId: string;
providerResourceId: string;
guildId: string | null;
digest: string;
}
export function parseDiscordNativeCommand(
callback: ChatSdkCallbackEvent<SlashCommandEvent>,
scope: DiscordCommandRegistrationScope,
): DiscordNativeCommandInvocation | null {
if (
!sourceScope.safeParse(scope).success ||
callback.provider !== "discord" ||
callback.transport !== "discord_gateway" ||
callback.endpointId !== scope.endpointId
)
return null;
const result = rawCommand.safeParse(callback.event.raw);
if (!result.success) return null;
const raw = result.data;
const command = raw.data.options[0].name;
if (
raw.application_id !== scope.applicationId ||
raw.user.bot ||
raw.user.id === scope.applicationId ||
callback.event.user.userId !== raw.user.id ||
callback.event.user.isBot ||
callback.event.user.isMe ||
callback.event.command !== `/paperclip ${command}` ||
callback.event.text !== "" ||
raw.channel_id !== raw.channel.id
)
return null;
const isDm =
raw.context === 1 &&
raw.guild_id === "@me" &&
raw.authorizing_integration_owners["0"] === "0" &&
raw.channel.type === 1 &&
raw.channel.parent_id === undefined;
const isGuild =
raw.context === 0 &&
raw.guild_id === scope.guildId &&
raw.authorizing_integration_owners["0"] === scope.guildId &&
[0, 5, 11, 12, 15, 16].includes(raw.channel.type);
if (!isDm && !isGuild) return null;
const isThread = raw.channel.type === 11 || raw.channel.type === 12;
if (isThread && !raw.channel.parent_id) return null;
const providerResourceId = isThread ? raw.channel.parent_id! : raw.channel_id;
const channelId = `discord:${isDm ? "@me" : scope.guildId}:${providerResourceId}`;
const threadId = isThread ? `${channelId}:${raw.channel_id}` : channelId;
if (
(callback.event as SlashCommandEvent & { channelId?: unknown })
.channelId !== threadId ||
callback.event.channel.id !== threadId
)
return null;
const invocation = {
schema: "paperclip.discord.native-command.v1" as const,
interactionId: raw.id,
applicationId: raw.application_id,
registeredCommandId: raw.data.id,
command,
actorExternalId: raw.user.id,
sourceKind: isDm
? ("direct_message" as const)
: isThread
? ("native_thread" as const)
: ("guild_channel" as const),
threadId,
channelId,
providerResourceId,
guildId: isDm ? null : scope.guildId,
};
const digest = createHash("sha256")
.update(JSON.stringify([scope.companyId, scope.endpointId, invocation]))
.digest("hex");
return Object.freeze({ ...invocation, digest });
}
export function isCurrentDiscordCommandRegistration(
action: unknown,
scope: DiscordCommandRegistrationScope,
commandId: string,
): boolean {
if (
!sourceScope.safeParse(scope).success ||
!snowflake.safeParse(commandId).success ||
!action ||
typeof action !== "object" ||
Array.isArray(action)
)
return false;
const row = action as Record<string, unknown>;
if (
row.companyId !== scope.companyId ||
row.endpointId !== scope.endpointId ||
row.conversationId !== null ||
row.kind !== "discord_command_registration" ||
row.providerActionId !==
`discord-command-registration:${scope.applicationId}` ||
row.status !== "processed" ||
!row.payload ||
typeof row.payload !== "object" ||
Array.isArray(row.payload)
)
return false;
const registration = parseDiscordCommandRegistration(
(row.payload as Record<string, unknown>).registration,
scope,
);
return (
registration?.phase === "registered" &&
registration.receipt.commandId === commandId
);
}
const targetSchema = z
.object({
conversationId: z.uuid(),
issueId: z.uuid(),
sessionGeneration: z.number().int().positive(),
})
.strict();
const receiptPayloadSchema = z
.object({
version: z.literal(1),
invocation: z.record(z.string(), z.unknown()),
runtimeFence: z
.object({
generation: z.number().int().nonnegative(),
credentialFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
})
.strict(),
target: targetSchema.nullable(),
})
.strict();
const receiptResultSchema = z
.object({
kind: z.literal("discord_native_command_recorded"),
content: z.string().min(1).max(2000),
publicationId: z.uuid().nullable(),
})
.strict();
export type DiscordNativeCommandTarget = z.infer<typeof targetSchema>;
/** A prior callback is observation-only; fresh source/actor authority is still required. */
export function parseDiscordNativeCommandReceipt(
action: unknown,
invocation: DiscordNativeCommandInvocation,
scope: DiscordCommandRegistrationScope,
) {
if (!action || typeof action !== "object" || Array.isArray(action))
return null;
const row = action as Record<string, unknown>;
if (
row.companyId !== scope.companyId ||
row.endpointId !== scope.endpointId ||
row.kind !== "discord_native_command" ||
row.providerActionId !==
`discord-native-command:${invocation.interactionId}` ||
row.status !== "processed" ||
!z.uuid().safeParse(row.principalId).success
)
return null;
const payload = receiptPayloadSchema.safeParse(row.payload);
const result = receiptResultSchema.safeParse(row.result);
if (
!payload.success ||
!result.success ||
row.conversationId !== (payload.data.target?.conversationId ?? null)
)
return null;
const stored = payload.data.invocation;
if (
Object.keys(stored).length !== Object.keys(invocation).length ||
!Object.entries(invocation).every(([key, value]) => stored[key] === value)
)
return null;
if (
result.data.publicationId !== null &&
(!payload.data.target ||
invocation.command === "status" ||
(invocation.command === "new" &&
invocation.sourceKind !== "direct_message"))
)
return null;
return {
principalId: row.principalId as string,
runtimeFence: payload.data.runtimeFence,
target: payload.data.target,
result: result.data,
};
}

View File

@ -0,0 +1,513 @@
import * as discord from "@chat-adapter/discord";
import {
Modal,
Select,
SelectOption,
TextInput,
type ModalElement,
} from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createChatSdkEndpointRuntime,
type ChatSdkEndpointRuntime,
type ChatSdkRuntimeCallbacks,
} from "./chat-sdk-runtime.js";
import type {
ChatSdkStatePersistence,
ChatSdkStateRecord,
ChatSdkStateScope,
} from "./chat-sdk-state.js";
const guildId = "1457808928258658549";
const channelId = "333333333333333333";
const threadId = "555555555555555610";
const applicationId = "123456789012345678";
const userId = "444444444444444410";
const messageId = "666666666666666610";
const callbackId = `pcfs:${"A".repeat(22)}`;
const contextId = "11111111-1111-4111-8111-111111111111";
const modalCustomId = `${callbackId}:${contextId}`;
function modal(): ModalElement {
return Modal({
callbackId,
privateMetadata: callbackId,
title: "Deployment details",
children: [
Select({
id: `pcff:${"B".repeat(22)}`,
label: "Environment",
options: [
SelectOption({ label: "Staging", value: `pcfo:${"C".repeat(22)}` }),
SelectOption({
label: "Production",
value: `pcfo:${"D".repeat(22)}`,
}),
],
}),
TextInput({
id: `pcff:${"E".repeat(22)}`,
label: "Release note",
maxLength: 4000,
multiline: true,
}),
],
});
}
function interaction(overrides: Record<string, unknown> = {}) {
return {
applicationId,
channel: { id: threadId, parentId: channelId, type: 11 },
channelId: threadId,
componentType: 2,
customId: `pcf:${"F".repeat(22)}`,
deferUpdate: vi.fn().mockResolvedValue(undefined),
guildId,
id: "777777777777777710",
isChatInputCommand: () => false,
isMessageComponent: () => true,
isModalSubmit: () => false,
message: { id: messageId },
reply: vi.fn().mockResolvedValue(undefined),
showModal: vi.fn().mockResolvedValue(undefined),
token: "synthetic-interaction-token-never-persisted",
type: 3,
user: {
id: userId,
username: "operator",
globalName: "Operator",
bot: false,
},
version: 1,
...overrides,
};
}
function submit(overrides: Record<string, unknown> = {}) {
return interaction({
customId: modalCustomId,
type: 5,
isMessageComponent: () => false,
isModalSubmit: () => true,
components: [
{
type: 18,
component: {
type: 3,
customId: `pcff:${"B".repeat(22)}`,
values: [`pcfo:${"C".repeat(22)}`],
},
},
{
type: 18,
component: {
type: 4,
customId: `pcff:${"E".repeat(22)}`,
value: "Ship safely",
},
},
],
...overrides,
});
}
function memoryPersistence() {
const rows = new Map<string, ChatSdkStateRecord>();
const keyFor = (scope: ChatSdkStateScope, key: string) =>
JSON.stringify([scope.companyId, scope.endpointId, key]);
const persistence: ChatSdkStatePersistence = {
async read(scope, key) {
return rows.get(keyFor(scope, key)) ?? null;
},
async compareAndSet(input) {
const key = keyFor(input, input.key);
const previous = rows.get(key);
if ((previous?.version ?? null) !== input.expectedVersion) return false;
rows.set(key, {
value: input.value,
expiresAt: input.expiresAt,
version: (previous?.version ?? 0) + 1,
});
return true;
},
async deleteIfVersion(input) {
const key = keyFor(input, input.key);
if (rows.get(key)?.version !== input.expectedVersion) return false;
return rows.delete(key);
},
};
return { persistence, rows };
}
interface AdapterSeam {
handleGatewayInteraction(
event: ReturnType<typeof interaction>,
): Promise<void>;
fetchMessage: (...args: unknown[]) => Promise<unknown>;
}
// Real installed adapter, Chat SDK and scoped runtime. Provider socket/HTTP and
// the final service callback are test doubles; this is not live Discord proof.
describe("Discord native modal Gateway bridge", () => {
const runtimes: ChatSdkEndpointRuntime[] = [];
afterEach(async () => {
try {
await Promise.all(
runtimes.splice(0).map((runtime) => runtime.shutdown()),
);
} finally {
vi.unstubAllGlobals();
}
});
async function harness(callbacks: Partial<ChatSdkRuntimeCallbacks> = {}) {
const { persistence, rows } = memoryPersistence();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("No provider network permitted");
}),
);
const runtime = createChatSdkEndpointRuntime({
companyId: "company-discord-modal",
endpointId: "endpoint-discord-modal",
callbacks: { onMessage() {}, ...callbacks },
enableDiscordGateway: false,
logger: "silent",
persistence,
providerConfig: {
provider: "discord",
userName: "maya",
credentials: {
applicationId,
botToken: "synthetic-bot-token",
guildId,
},
},
});
runtimes.push(runtime);
await runtime.initialize();
const adapter = runtime.getProviderAdapter() as unknown as AdapterSeam;
// Chat SDK fetches the source message while storing its modal context.
adapter.fetchMessage = vi.fn().mockResolvedValue(null);
return { adapter, runtime, rows };
}
it("renders native Label select and text components without changing opaque values", () => {
const render = (
discord as unknown as {
modalToDiscordPayload?: (
modal: ModalElement,
contextId: string,
) => Record<string, unknown>;
}
).modalToDiscordPayload;
expect(render).toBeTypeOf("function");
expect(render!(modal(), contextId)).toEqual({
custom_id: modalCustomId,
title: "Deployment details",
components: [
{
type: 18,
label: "Environment",
component: {
type: 3,
custom_id: `pcff:${"B".repeat(22)}`,
required: true,
min_values: 1,
max_values: 1,
options: [
{ label: "Staging", value: `pcfo:${"C".repeat(22)}` },
{ label: "Production", value: `pcfo:${"D".repeat(22)}` },
],
},
},
{
type: 18,
label: "Release note",
component: {
type: 4,
custom_id: `pcff:${"E".repeat(22)}`,
style: 2,
required: true,
max_length: 4000,
},
},
],
});
});
it("opens through actual Chat SDK and consumes exactly the initial modal response", async () => {
const opened = vi.fn();
const { adapter, rows } = await harness({
onAction: async ({ event }) => {
opened(await event.openModal(modal()));
},
});
const click = interaction();
await adapter.handleGatewayInteraction(click);
expect(click.showModal).toHaveBeenCalledOnce();
expect(click.showModal.mock.calls[0]![0]).toMatchObject({
title: "Deployment details",
components: [{ type: 18 }, { type: 18 }],
});
expect(opened).toHaveBeenCalledWith({
viewId: click.showModal.mock.calls[0]![0].custom_id,
});
expect(click.deferUpdate).not.toHaveBeenCalled();
expect(click.reply).not.toHaveBeenCalled();
expect(JSON.stringify([...rows])).not.toContain(click.token);
});
it("parses a native modal submit into exact scoped callback and privately acknowledges durable clear", async () => {
const onModalSubmit = vi
.fn<NonNullable<ChatSdkRuntimeCallbacks["onModalSubmit"]>>()
.mockResolvedValue({ action: "clear" });
const { adapter } = await harness({ onModalSubmit });
const submission = submit();
await adapter.handleGatewayInteraction(submission);
expect(onModalSubmit).toHaveBeenCalledOnce();
expect(onModalSubmit.mock.calls[0]![0]).toMatchObject({
endpointId: "endpoint-discord-modal",
provider: "discord",
transport: "discord_gateway",
event: {
callbackId,
privateMetadata: callbackId,
user: { userId },
values: {
[`pcff:${"B".repeat(22)}`]: `pcfo:${"C".repeat(22)}`,
[`pcff:${"E".repeat(22)}`]: "Ship safely",
},
raw: {
guild_id: guildId,
channel_id: threadId,
message: { id: messageId },
type: 5,
},
},
});
expect(submission.reply).toHaveBeenCalledWith({
content: "Your response was received.",
flags: 64,
allowedMentions: { parse: [] },
});
expect(submission.showModal).not.toHaveBeenCalled();
expect(submission.deferUpdate).not.toHaveBeenCalled();
});
it.each([
"empty",
"six fields",
"26 options",
"duplicate fields",
"foreign metadata",
"oversized title",
"oversized label",
"oversized input",
])("rejects unsupported renderer shape: %s", (variant) => {
const value = modal();
if (variant === "empty") value.children = [];
if (variant === "six fields")
value.children = Array.from({ length: 6 }, (_, index) => ({
...value.children[1]!,
id: `pcff:${String(index).repeat(22)}`,
}));
if (variant === "26 options" && value.children[0]?.type === "select")
value.children[0].options = Array.from({ length: 26 }, (_, index) =>
SelectOption({
label: "Choice",
value: `pcfo:${String(index).padStart(22, "0")}`,
}),
);
if (variant === "duplicate fields")
value.children = [value.children[0]!, value.children[0]!];
if (variant === "foreign metadata") value.privateMetadata = "other";
if (variant === "oversized title") value.title = "t".repeat(46);
if (
variant === "oversized label" &&
value.children[1]?.type === "text_input"
)
value.children[1].label = "l".repeat(46);
if (
variant === "oversized input" &&
value.children[1]?.type === "text_input"
)
value.children[1].maxLength = 4001;
expect(() => discord.modalToDiscordPayload(value, contextId)).toThrow(
"Unsupported Discord modal shape",
);
});
it("does not retry or acknowledge an ambiguous initial modal response", async () => {
const onAction = vi.fn<NonNullable<ChatSdkRuntimeCallbacks["onAction"]>>(
async ({ event }) => {
await event.openModal(modal());
},
);
const { adapter } = await harness({ onAction });
const click = interaction({
showModal: vi
.fn()
.mockRejectedValue(new Error("synthetic connection loss")),
});
await adapter.handleGatewayInteraction(click);
expect(onAction).toHaveBeenCalledOnce();
expect(click.showModal).toHaveBeenCalledOnce();
expect(click.reply).not.toHaveBeenCalled();
expect(click.deferUpdate).not.toHaveBeenCalled();
});
it("does not turn an application-swallowed unsupported modal into a success ACK", async () => {
const { adapter } = await harness({
onAction: async ({ event }) => {
try {
await event.openModal({ ...modal(), children: [] });
} catch {
/* Simulates durable modal-open failure audit. */
}
},
});
const click = interaction();
await adapter.handleGatewayInteraction(click);
expect(click.showModal).not.toHaveBeenCalled();
expect(click.deferUpdate).not.toHaveBeenCalled();
expect(click.reply).toHaveBeenCalledWith(
expect.objectContaining({
content:
"This form could not be opened. Open the linked Paperclip task.",
flags: 64,
}),
);
});
it.each([
"missing callback",
"throwing callback",
"unknown response",
"foreign guild",
"malformed field",
"duplicate field",
"malformed callback",
])(
"returns private failure, never success or a modal, for %s",
async (variant) => {
const onModalSubmit = vi
.fn<NonNullable<ChatSdkRuntimeCallbacks["onModalSubmit"]>>()
.mockResolvedValue({ action: "clear" });
if (variant === "throwing callback")
onModalSubmit.mockRejectedValue(
new Error("secret provider error not visible"),
);
if (variant === "unknown response")
onModalSubmit.mockResolvedValue({ action: "update", modal: modal() });
const { adapter } = await harness(
variant === "missing callback" ? {} : { onModalSubmit },
);
const input = submit();
if (variant === "foreign guild") input.guildId = "999999999999999999";
if (variant === "malformed field")
(input as unknown as { components: unknown[] }).components = [
{
type: 18,
component: { type: 4, customId: "untrusted", value: "x" },
},
];
if (variant === "duplicate field")
(input as unknown as { components: unknown[] }).components = Array(
2,
).fill({
type: 18,
component: {
type: 4,
customId: `pcff:${"E".repeat(22)}`,
value: "x",
},
});
if (variant === "malformed callback") input.customId = "foreign-token";
await adapter.handleGatewayInteraction(input);
expect(input.reply).toHaveBeenCalledWith({
content:
"This response was not accepted. Open the linked Paperclip task or reopen the question to try again.",
flags: 64,
allowedMentions: { parse: [] },
});
expect(input.showModal).not.toHaveBeenCalled();
expect(input.deferUpdate).not.toHaveBeenCalled();
if (
[
"foreign guild",
"malformed field",
"duplicate field",
"malformed callback",
].includes(variant)
)
expect(onModalSubmit).not.toHaveBeenCalled();
},
);
it("uses the explicit internal correction response only for a private reopen button", async () => {
const actionId = `pcfr:${"R".repeat(43)}`;
const { adapter } = await harness({
onModalSubmit: async () => ({
action: "errors",
errors: { field: "Required" },
paperclipDiscordCorrection: {
version: 1,
actionId,
message: "Release note: Please provide an answer.",
},
}),
});
const input = submit();
await adapter.handleGatewayInteraction(input);
expect(input.reply).toHaveBeenCalledWith({
content: "Release note: Please provide an answer.",
flags: 64,
allowedMentions: { parse: [] },
components: [
{
type: 1,
components: [
{ type: 2, style: 1, label: "Edit answers", custom_id: actionId },
],
},
],
});
expect(input.showModal).not.toHaveBeenCalled();
});
it("retains the durable callback/source when SDK context was consumed by an invalid submission", async () => {
const onModalSubmit = vi
.fn<NonNullable<ChatSdkRuntimeCallbacks["onModalSubmit"]>>()
.mockResolvedValueOnce({
action: "errors",
errors: { field: "Required" },
})
.mockResolvedValue({ action: "clear" });
const { adapter, rows } = await harness({
onAction: async ({ event }) => {
await event.openModal(modal());
},
onModalSubmit,
});
const click = interaction();
await adapter.handleGatewayInteraction(click);
const realCustomId = click.showModal.mock.calls[0]![0].custom_id;
await adapter.handleGatewayInteraction(submit({ customId: realCustomId }));
expect(onModalSubmit.mock.calls[0]![0].event.relatedThread?.id).toBe(
`discord:${guildId}:${channelId}:${threadId}`,
);
await adapter.handleGatewayInteraction(
submit({ customId: realCustomId, id: "777777777777777711" }),
);
expect(onModalSubmit.mock.calls[1]![0].event.relatedThread).toBeUndefined();
expect(onModalSubmit.mock.calls[1]![0].event).toMatchObject({
callbackId,
privateMetadata: callbackId,
raw: { guild_id: guildId, channel_id: threadId },
});
expect(JSON.stringify([...rows])).not.toContain(click.token);
});
});

View File

@ -0,0 +1,262 @@
import { Modal, Select, SelectOption, TextInput } from "chat";
import { describe, expect, it } from "vitest";
import {
deleteDiscordQuestionFormCorrection,
discordQuestionFormCorrectionModal,
discordQuestionFormThreadId,
loadDiscordQuestionFormCorrection,
retainDiscordQuestionFormCorrection,
} from "./chat-discord-question-forms.js";
import type {
ChatSdkStatePersistence,
ChatSdkStateRecord,
} from "./chat-sdk-state.js";
const scope = { companyId: "company", endpointId: "endpoint" };
const owner = {
principalId: "principal",
userId: "member",
externalUserId: "444444444444444444",
};
const threadId =
"discord:111111111111111111:222222222222222222:333333333333333333";
const now = new Date("2026-09-09T01:00:00Z");
const inputId = `pcff:${"I".repeat(22)}`;
const selectId = `pcff:${"S".repeat(22)}`;
const optionId = `pcfo:${"O".repeat(22)}`;
const submitActionId = `pcfs:${"A".repeat(22)}`;
function fixture() {
const rows = new Map<string, ChatSdkStateRecord>();
const key = (scope: { companyId: string; endpointId: string }, key: string) =>
JSON.stringify([scope.companyId, scope.endpointId, key]);
const persistence: ChatSdkStatePersistence = {
async read(scope, id) {
return rows.get(key(scope, id)) ?? null;
},
async compareAndSet(input) {
const id = key(input, input.key);
const old = rows.get(id);
if ((old?.version ?? null) !== input.expectedVersion) return false;
rows.set(id, {
value: input.value,
expiresAt: input.expiresAt,
version: (old?.version ?? 0) + 1,
});
return true;
},
async deleteIfVersion(input) {
const id = key(input, input.key);
if (rows.get(id)?.version !== input.expectedVersion) return false;
return rows.delete(id);
},
};
const modal = Modal({
callbackId: submitActionId,
privateMetadata: submitActionId,
title: "Details",
children: [
TextInput({ id: inputId, label: "Release note", maxLength: 12 }),
Select({
id: selectId,
label: "Environment",
options: [SelectOption({ label: "Staging", value: optionId })],
}),
],
});
const input = {
...owner,
conversationId: "conversation",
publicationId: "publication",
providerMessageId: "555555555555555555",
threadId,
interactionId: "interaction",
openActionId: `pcf:${"B".repeat(22)}`,
submitActionId,
parentExpiresAt: new Date(now.getTime() + 60_000).toISOString(),
modal,
fieldErrors: { [inputId]: "Too long" },
values: {
[inputId]: "A very long release note",
[selectId]: optionId,
foreign: "never retained",
},
};
return { rows, persistence, input };
}
describe("Discord actor-scoped correction drafts", () => {
it("retains only bounded known values under one opaque actor/form key with parent TTL", async () => {
const { persistence, rows, input } = fixture();
const response = await retainDiscordQuestionFormCorrection(
persistence,
scope,
input,
now,
);
expect(response.paperclipDiscordCorrection.actionId).toMatch(
/^pcfr:[\w-]{43}$/,
);
expect(response.paperclipDiscordCorrection.message).toContain(
"Release note: Too long",
);
expect(response.paperclipDiscordCorrection.message).toContain(
"shortened to 12",
);
expect(response.paperclipDiscordCorrection.message).not.toContain(inputId);
const draft = await loadDiscordQuestionFormCorrection(
persistence,
scope,
response.paperclipDiscordCorrection.actionId,
owner,
threadId,
now,
);
expect(draft?.values).toEqual({
[inputId]: "A very long ",
[selectId]: optionId,
});
expect(draft?.expiresAt).toBe(input.parentExpiresAt);
const retried = await retainDiscordQuestionFormCorrection(
persistence,
scope,
{ ...input, values: { [inputId]: "New note" } },
now,
);
expect(retried.paperclipDiscordCorrection.actionId).toBe(
response.paperclipDiscordCorrection.actionId,
);
expect(rows.size).toBe(1);
expect(JSON.stringify([...rows])).not.toContain("foreign");
expect(
discordQuestionFormCorrectionModal(input.modal, draft!)?.children[0],
).toMatchObject({ initialValue: "A very long " });
});
it.each([
"company",
"endpoint",
"principal",
"member",
"external actor",
"thread",
"handle",
])("does not expose editable values after %s mismatch", async (kind) => {
const { persistence, input } = fixture();
const response = await retainDiscordQuestionFormCorrection(
persistence,
scope,
input,
now,
);
const readScope = { ...scope };
const readOwner = { ...owner };
if (kind === "company") readScope.companyId = "other";
if (kind === "endpoint") readScope.endpointId = "other";
if (kind === "principal") readOwner.principalId = "other";
if (kind === "member") readOwner.userId = "other";
if (kind === "external actor") readOwner.externalUserId = "other";
expect(
await loadDiscordQuestionFormCorrection(
persistence,
readScope,
kind === "handle"
? `pcfr:${"X".repeat(43)}`
: response.paperclipDiscordCorrection.actionId,
readOwner,
kind === "thread" ? "other" : threadId,
now,
),
).toBeNull();
});
it("deletes on expiry access and successful completion without a physical TTL sweep claim", async () => {
const { persistence, rows, input } = fixture();
const response = await retainDiscordQuestionFormCorrection(
persistence,
scope,
input,
now,
);
expect(
await loadDiscordQuestionFormCorrection(
persistence,
scope,
response.paperclipDiscordCorrection.actionId,
owner,
threadId,
new Date(input.parentExpiresAt),
),
).toBeNull();
expect(rows.size).toBe(0);
await retainDiscordQuestionFormCorrection(persistence, scope, input, now);
await deleteDiscordQuestionFormCorrection(
persistence,
scope,
owner,
submitActionId,
);
expect(rows.size).toBe(0);
});
it("caps retention at ten minutes and omits unknown option values", async () => {
const { persistence, input } = fixture();
const response = await retainDiscordQuestionFormCorrection(
persistence,
scope,
{
...input,
parentExpiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
values: { [selectId]: "forged" },
},
now,
);
const draft = await loadDiscordQuestionFormCorrection(
persistence,
scope,
response.paperclipDiscordCorrection.actionId,
owner,
threadId,
now,
);
expect(draft?.expiresAt).toBe(
new Date(now.getTime() + 600_000).toISOString(),
);
expect(draft?.values).toEqual({});
});
it("does not issue a reopen handle if draft persistence fails", async () => {
const { persistence, input } = fixture();
persistence.compareAndSet = async () => false;
await expect(
retainDiscordQuestionFormCorrection(persistence, scope, input, now),
).rejects.toThrow("ownership changed");
});
it("requires a matching current raw thread identity even without SDK context", () => {
expect(
discordQuestionFormThreadId({
guild_id: "111111111111111111",
channel_id: "333333333333333333",
channel: {
id: "333333333333333333",
parent_id: "222222222222222222",
type: 11,
},
}),
).toBe(threadId);
expect(
discordQuestionFormThreadId({
guild_id: "@me",
channel_id: "333333333333333333",
channel: { id: "333333333333333333", type: 1 },
}),
).toBe("discord:@me:333333333333333333");
expect(
discordQuestionFormThreadId({
guild_id: "111111111111111111",
channel_id: "333333333333333333",
channel: { id: "other", type: 11 },
}),
).toBeNull();
});
});

View File

@ -0,0 +1,337 @@
import { createHash } from "node:crypto";
import type { ModalElement, ModalResponse } from "chat";
import type {
ChatSdkStatePersistence,
ChatSdkStateScope,
} from "./chat-sdk-state.js";
const CORRECTION_TTL_MS = 10 * 60 * 1000;
const MAX_CAS_ATTEMPTS = 8;
/** Internal service response, never parsed from provider JSON. A modal-submit
* cannot open a modal: this authorizes only a private error + reopen button. */
export interface DiscordModalCorrectionResponse {
action: "errors";
errors: Record<string, string>;
paperclipDiscordCorrection: {
version: 1;
actionId: string;
message: string;
};
}
export interface DiscordQuestionFormDraftOwner {
principalId: string;
userId: string;
externalUserId: string;
}
export interface DiscordQuestionFormCorrectionDraft extends DiscordQuestionFormDraftOwner {
version: 1;
conversationId: string;
publicationId: string;
providerMessageId: string;
threadId: string;
interactionId: string;
openActionId: string;
submitActionId: string;
expiresAt: string;
values: Record<string, string>;
}
const token = (value: unknown, prefix: string, length = 22): value is string =>
typeof value === "string" &&
new RegExp(`^${prefix}[A-Za-z0-9_-]{${length}}$`).test(value);
export function isDiscordQuestionFormCorrectionId(
value: unknown,
): value is string {
return token(value, "pcfr:", 43);
}
function correctionId(
scope: ChatSdkStateScope,
owner: DiscordQuestionFormDraftOwner,
submitActionId: string,
) {
if (!token(submitActionId, "pcfs:"))
throw new Error("Invalid Discord form token");
// Includes the high-entropy secret submit token, not merely public IDs. One
// overwritable row per form+actor bounds retry storage without persisting a
// Discord interaction token or raw callback envelope.
return `pcfr:${createHash("sha256")
.update(
JSON.stringify([
scope.companyId,
scope.endpointId,
submitActionId,
owner.principalId,
owner.userId,
owner.externalUserId,
]),
)
.digest("base64url")}`;
}
function validDraft(
value: unknown,
): value is DiscordQuestionFormCorrectionDraft {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const row = value as Record<string, unknown>;
if (
Object.keys(row).sort().join(",") !==
"conversationId,expiresAt,externalUserId,interactionId,openActionId,principalId,providerMessageId,publicationId,submitActionId,threadId,userId,values,version"
)
return false;
if (
row.version !== 1 ||
!token(row.openActionId, "pcf:") ||
!token(row.submitActionId, "pcfs:")
)
return false;
if (
![
row.conversationId,
row.publicationId,
row.providerMessageId,
row.threadId,
row.interactionId,
row.principalId,
row.userId,
row.externalUserId,
].every(
(field) =>
typeof field === "string" && field.length > 0 && field.length <= 512,
)
)
return false;
if (
typeof row.expiresAt !== "string" ||
!Number.isFinite(Date.parse(row.expiresAt))
)
return false;
if (
!row.values ||
typeof row.values !== "object" ||
Array.isArray(row.values)
)
return false;
const entries = Object.entries(row.values);
return (
entries.length <= 5 &&
entries.every(
([field, value]) =>
token(field, "pcff:") &&
typeof value === "string" &&
value.length <= 4000,
)
);
}
function stateKey(actionId: string) {
return `discord-question-correction:${actionId}`;
}
export async function retainDiscordQuestionFormCorrection(
persistence: ChatSdkStatePersistence,
scope: ChatSdkStateScope,
input: Omit<
DiscordQuestionFormCorrectionDraft,
"version" | "expiresAt" | "values"
> & {
parentExpiresAt: string;
modal: ModalElement;
fieldErrors: Record<string, string>;
values: Record<string, string>;
},
now = new Date(),
): Promise<DiscordModalCorrectionResponse> {
const expiresAt = new Date(
Math.min(
Date.parse(input.parentExpiresAt),
now.getTime() + CORRECTION_TTL_MS,
),
);
if (
!Number.isFinite(expiresAt.getTime()) ||
expiresAt <= now ||
input.modal.callbackId !== input.submitActionId ||
input.modal.privateMetadata !== input.submitActionId ||
input.modal.children.length < 1 ||
input.modal.children.length > 5
)
throw new Error("Discord form correction is not current");
const values: Record<string, string> = {};
const messages: string[] = [
"Please check your answers, then select Edit answers.",
];
for (const child of input.modal.children) {
if (child.type !== "text_input" && child.type !== "select")
throw new Error("Unsupported Discord correction field");
const value = input.values[child.id];
const error = input.fieldErrors[child.id];
// Both label and error are produced by canonical form validation, never
// provider labels/error bodies. Opaque input IDs are not visible copy.
if (error) messages.push(`${child.label}: ${error}`);
if (typeof value !== "string") continue;
if (child.type === "text_input") {
const maximum = Math.min(child.maxLength ?? 4000, 4000);
values[child.id] = value.slice(0, maximum);
if (value.length > maximum)
messages.push(
`${child.label}: This draft was shortened to ${maximum} characters.`,
);
} else if (child.options.some((option) => option.value === value))
values[child.id] = value;
}
const {
modal: _modal,
values: _values,
fieldErrors: _errors,
parentExpiresAt: _expiry,
...binding
} = input;
const draft: DiscordQuestionFormCorrectionDraft = {
...binding,
version: 1,
expiresAt: expiresAt.toISOString(),
values,
};
if (!validDraft(draft)) throw new Error("Invalid Discord correction binding");
const actionId = correctionId(scope, input, input.submitActionId);
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
const prior = await persistence.read(scope, stateKey(actionId));
if (
await persistence.compareAndSet({
...scope,
key: stateKey(actionId),
expectedVersion: prior?.version ?? null,
value: draft,
expiresAt,
})
) {
return {
action: "errors",
errors: input.fieldErrors,
paperclipDiscordCorrection: {
version: 1,
actionId,
message: messages.join("\n").slice(0, 1500),
},
};
}
}
throw new Error("Discord correction draft ownership changed");
}
export async function loadDiscordQuestionFormCorrection(
persistence: ChatSdkStatePersistence,
scope: ChatSdkStateScope,
actionId: string,
owner: DiscordQuestionFormDraftOwner,
threadId: string,
now = new Date(),
): Promise<DiscordQuestionFormCorrectionDraft | null> {
if (!isDiscordQuestionFormCorrectionId(actionId)) return null;
const prior = await persistence.read(scope, stateKey(actionId));
if (!prior) return null;
const value = prior.value;
if (
!validDraft(value) ||
!prior.expiresAt ||
prior.expiresAt.getTime() !== Date.parse(value.expiresAt)
)
return null;
if (prior.expiresAt <= now) {
await persistence.deleteIfVersion({
...scope,
key: stateKey(actionId),
expectedVersion: prior.version,
});
return null;
}
if (
value.principalId !== owner.principalId ||
value.userId !== owner.userId ||
value.externalUserId !== owner.externalUserId ||
value.threadId !== threadId ||
correctionId(scope, value, value.submitActionId) !== actionId
)
return null;
return value;
}
export async function deleteDiscordQuestionFormCorrection(
persistence: ChatSdkStatePersistence,
scope: ChatSdkStateScope,
owner: DiscordQuestionFormDraftOwner,
submitActionId: string,
) {
const key = stateKey(correctionId(scope, owner, submitActionId));
const prior = await persistence.read(scope, key);
if (prior)
await persistence.deleteIfVersion({
...scope,
key,
expectedVersion: prior.version,
});
}
/** Current Gateway route, independent of consumed SDK modal context. */
export function discordQuestionFormThreadId(raw: unknown): string | null {
if (!raw || typeof raw !== "object") return null;
const value = raw as Record<string, unknown>;
const guild = value.guild_id;
const channelId = value.channel_id;
const channel = value.channel as
{ id?: unknown; type?: unknown; parent_id?: unknown } | undefined;
const snowflake = (value: unknown): value is string =>
typeof value === "string" && /^\d{17,20}$/.test(value);
if (
(guild !== "@me" && !snowflake(guild)) ||
!snowflake(channelId) ||
!channel ||
channel.id !== channelId
)
return null;
if (channel.type === 11 || channel.type === 12)
return snowflake(channel.parent_id)
? `discord:${guild}:${channel.parent_id}:${channelId}`
: null;
return `discord:${guild}:${channelId}`;
}
export function discordQuestionFormCorrectionModal(
modal: ModalElement,
draft: DiscordQuestionFormCorrectionDraft,
): ModalElement | null {
if (
modal.callbackId !== draft.submitActionId ||
modal.privateMetadata !== draft.submitActionId
)
return null;
return {
...modal,
children: modal.children.map((child) => {
if (child.type === "text_input")
return { ...child, initialValue: draft.values[child.id] ?? "" };
if (child.type === "select") {
const value = draft.values[child.id];
return {
...child,
...(child.options.some((option) => option.value === value)
? { initialOption: value }
: {}),
};
}
return child;
}),
};
}
export const discordQuestionFormDenialResponse = (): ModalResponse => ({
action: "errors",
errors: {
form: "This form is no longer authorized. Open the linked Paperclip task.",
},
});

View File

@ -0,0 +1,234 @@
import { describe, expect, it, vi } from "vitest";
import {
discordMarkdownRequiresAttachment,
listDiscordBotChannels,
verifyDiscordBot,
} from "./chat-discord.js";
const applicationId = "123456789012345678";
const guildId = "1457808928258658549";
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function fetchFixture(
routes: Record<string, unknown | (() => Response)>,
): typeof globalThis.fetch {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
expect(init?.headers).toEqual({ authorization: "Bot discord-secret" });
const url = new URL(String(input));
const route = routes[url.pathname];
if (route === undefined) return json({ message: "missing fixture" }, 404);
return typeof route === "function" ? route() : json(route);
}) as unknown as typeof globalThis.fetch;
}
describe("Discord bot validation and inventory", () => {
it("measures the final Discord Markdown instead of raw source length", () => {
expect(discordMarkdownRequiresAttachment("x".repeat(2_000))).toBe(false);
expect(discordMarkdownRequiresAttachment("🙂".repeat(1_001))).toBe(true);
// The Discord formatter expands each bare mention by two characters.
expect(discordMarkdownRequiresAttachment("@a ".repeat(500))).toBe(true);
expect(
discordMarkdownRequiresAttachment(
`\`\`\`ts\n${"const value = 1;\n".repeat(120)}\`\`\``,
),
).toBe(true);
});
it("binds the token, application, privileged intent, and server", async () => {
const fetch = fetchFixture({
"/api/v10/users/@me": {
id: applicationId,
username: "maya",
global_name: "Maya",
bot: true,
avatar: "avatar-hash",
},
"/api/v10/oauth2/applications/@me": {
id: applicationId,
name: "Maya",
flags: 1 << 18,
},
[`/api/v10/guilds/${guildId}`]: { id: guildId, name: "Clawd" },
});
await expect(
verifyDiscordBot({
applicationId,
botToken: "discord-secret",
fetch,
guildId,
}),
).resolves.toEqual({
providerAccountId: guildId,
providerAccountLabel: "Clawd",
botExternalId: applicationId,
botUsername: "maya",
botLabel: "Maya",
botAvatarUrl: `https://cdn.discordapp.com/avatars/${applicationId}/avatar-hash.png`,
});
});
it("rejects an application mismatch and disabled Message Content intent", async () => {
const base = {
"/api/v10/users/@me": {
id: applicationId,
username: "maya",
bot: true,
},
[`/api/v10/guilds/${guildId}`]: { id: guildId, name: "Clawd" },
};
await expect(
verifyDiscordBot({
applicationId,
botToken: "discord-secret",
fetch: fetchFixture({
...base,
"/api/v10/oauth2/applications/@me": {
id: "999999999999999999",
flags: 1 << 18,
},
}),
guildId,
}),
).rejects.toThrow("Application ID does not match");
await expect(
verifyDiscordBot({
applicationId,
botToken: "discord-secret",
fetch: fetchFixture({
...base,
"/api/v10/oauth2/applications/@me": {
id: applicationId,
flags: 0,
},
}),
guildId,
}),
).rejects.toThrow("Message Content intent is not enabled");
});
it("identifies the failed Discord lookup without exposing provider response text", async () => {
const rawProviderText = "Invalid Form Body contains private canary";
const failure = await verifyDiscordBot({
applicationId,
botToken: "discord-secret",
fetch: fetchFixture({
"/api/v10/users/@me": {
id: applicationId,
username: "maya",
bot: true,
},
"/api/v10/oauth2/applications/@me": () =>
json(
{
code: 50035,
message: rawProviderText,
errors: {
application_id: {
_errors: [
{ code: "NUMBER_TYPE_COERCE", message: rawProviderText },
],
},
},
},
400,
),
[`/api/v10/guilds/${guildId}`]: { id: guildId, name: "Clawd" },
}),
guildId,
}).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(Error);
expect((failure as Error).message).toBe(
"Discord application lookup failed (HTTP 400, code 50035, invalid fields: application_id)",
);
expect((failure as Error).message).not.toContain(rawProviderText);
expect((failure as Error).message).not.toContain("discord-secret");
});
it("discovers only text channels where the bot has the complete safe feature set", async () => {
const requiredPermissions = "309237763136";
const fetch = fetchFixture({
[`/api/v10/guilds/${guildId}`]: { id: guildId, name: "Clawd" },
[`/api/v10/guilds/${guildId}/members/${applicationId}`]: {
roles: ["222222222222222222"],
user: { id: applicationId },
},
[`/api/v10/guilds/${guildId}/roles`]: [
{ id: guildId, permissions: "0" },
{ id: "222222222222222222", permissions: requiredPermissions },
],
[`/api/v10/guilds/${guildId}/channels`]: [
{
id: "333333333333333333",
type: 0,
name: "agent-lab",
position: 2,
permission_overwrites: [],
},
{
id: "444444444444444444",
type: 0,
name: "blocked",
position: 1,
permission_overwrites: [
{ id: guildId, type: 0, deny: "2048", allow: "0" },
],
},
{ id: "555555555555555555", type: 2, name: "voice" },
],
});
await expect(
listDiscordBotChannels({
botUserId: applicationId,
botToken: "discord-secret",
fetch,
guildId,
}),
).resolves.toEqual({
provider: "discord",
resources: [
{
providerResourceId: "333333333333333333",
parentProviderResourceId: guildId,
type: "channel",
label: "#agent-lab",
providerUrl: `https://discord.com/channels/${guildId}/333333333333333333`,
metadata: { source: "provider_inventory" },
},
],
});
});
it("fails closed when no channel grants the full required permission set", async () => {
const fetch = fetchFixture({
[`/api/v10/guilds/${guildId}`]: { id: guildId, name: "Clawd" },
[`/api/v10/guilds/${guildId}/members/${applicationId}`]: {
roles: [],
user: { id: applicationId },
},
[`/api/v10/guilds/${guildId}/roles`]: [
{ id: guildId, permissions: "1024" },
],
[`/api/v10/guilds/${guildId}/channels`]: [
{ id: "333333333333333333", type: 0, name: "read-only" },
],
});
await expect(
listDiscordBotChannels({
botUserId: applicationId,
botToken: "discord-secret",
fetch,
guildId,
}),
).rejects.toThrow("Send Messages in Threads");
});
});

View File

@ -0,0 +1,328 @@
import { DiscordFormatConverter } from "@chat-adapter/discord";
import type {
ChatProviderInventoryResult,
ChatProviderResourceInventoryItem,
} from "./chat-provider-inventory.js";
const DISCORD_API_URL = "https://discord.com/api/v10";
const REQUEST_TIMEOUT_MS = 25_000;
const PERMISSIONS = {
addReactions: 1n << 6n,
administrator: 1n << 3n,
attachFiles: 1n << 15n,
createPublicThreads: 1n << 35n,
embedLinks: 1n << 14n,
readMessageHistory: 1n << 16n,
sendMessages: 1n << 11n,
sendMessagesInThreads: 1n << 38n,
viewChannel: 1n << 10n,
} as const;
const REQUIRED_CHANNEL_PERMISSIONS =
PERMISSIONS.addReactions |
PERMISSIONS.attachFiles |
PERMISSIONS.createPublicThreads |
PERMISSIONS.embedLinks |
PERMISSIONS.readMessageHistory |
PERMISSIONS.sendMessages |
PERMISSIONS.sendMessagesInThreads |
PERMISSIONS.viewChannel;
const MESSAGE_CONTENT_FLAGS = (1 << 18) | (1 << 19);
const DISCORD_MAX_MESSAGE_UTF16_CODE_UNITS = 2_000;
const discordFormatConverter = new DiscordFormatConverter();
/**
* Discord applies its content limit after the chat adapter has normalized
* Markdown and expanded bare mentions. Use that exact formatter so Paperclip
* can move an oversized response to a lossless file before the adapter's
* defensive truncation would discard its tail.
*/
export function discordMarkdownRequiresAttachment(text: string): boolean {
return (
discordFormatConverter.renderPostable({ markdown: text }).length >
DISCORD_MAX_MESSAGE_UTF16_CODE_UNITS
);
}
type DiscordUser = {
avatar?: string | null;
bot?: boolean;
global_name?: string | null;
id?: string;
username?: string;
};
type DiscordApplication = {
flags?: number;
id?: string;
name?: string;
};
type DiscordGuild = { id?: string; name?: string; owner_id?: string };
type DiscordMember = { roles?: string[]; user?: DiscordUser };
type DiscordRole = { id?: string; permissions?: string };
type DiscordOverwrite = {
allow?: string;
deny?: string;
id?: string;
type?: number;
};
type DiscordChannel = {
id?: string;
name?: string;
permission_overwrites?: DiscordOverwrite[];
position?: number;
type?: number;
};
type DiscordErrorBody = {
code?: unknown;
errors?: unknown;
};
const SAFE_DISCORD_ERROR_FIELDS = new Set([
"application_id",
"channel_id",
"guild_id",
"user_id",
]);
export interface DiscordBotIdentity {
botAvatarUrl?: string;
botExternalId: string;
botLabel: string;
botUsername: string;
providerAccountId: string;
providerAccountLabel: string;
}
function requestSignal(): AbortSignal {
return AbortSignal.timeout(REQUEST_TIMEOUT_MS);
}
function snowflake(value: string, label: string): string {
const normalized = value.trim();
if (!/^\d{17,20}$/.test(normalized)) {
throw new Error(`${label} must be a Discord snowflake ID`);
}
return normalized;
}
function bigint(value: string | undefined): bigint {
try {
return BigInt(value ?? "0");
} catch {
return 0n;
}
}
async function discordJson<T>(
fetchImpl: typeof globalThis.fetch,
token: string,
path: string,
operation: string,
): Promise<T> {
const response = await fetchImpl(`${DISCORD_API_URL}${path}`, {
signal: requestSignal(),
headers: { authorization: `Bot ${token}` },
});
let body: unknown;
try {
body = await response.json();
} catch {
throw new Error("Discord returned an unreadable response");
}
if (!response.ok) {
const errorBody =
body && typeof body === "object" ? (body as DiscordErrorBody) : null;
const codeValue = String(errorBody?.code ?? "");
const code = /^\d{1,10}$/.test(codeValue) ? codeValue : null;
const invalidFields =
errorBody?.errors && typeof errorBody.errors === "object"
? Object.keys(errorBody.errors)
.filter((field) => SAFE_DISCORD_ERROR_FIELDS.has(field))
.slice(0, 8)
: [];
throw new Error(
`Discord ${operation} failed (HTTP ${response.status}${code ? `, code ${code}` : ""}${invalidFields.length > 0 ? `, invalid fields: ${invalidFields.join(", ")}` : ""})`,
);
}
return body as T;
}
function channelPermissions(input: {
channel: DiscordChannel;
guildId: string;
memberId: string;
memberRoleIds: Set<string>;
roles: DiscordRole[];
}): bigint {
let permissions = 0n;
for (const role of input.roles) {
if (role.id === input.guildId || input.memberRoleIds.has(role.id ?? "")) {
permissions |= bigint(role.permissions);
}
}
if ((permissions & PERMISSIONS.administrator) !== 0n) return ~0n;
const overwrites = input.channel.permission_overwrites ?? [];
const everyone = overwrites.find(
(overwrite) => overwrite.type === 0 && overwrite.id === input.guildId,
);
if (everyone) {
permissions &= ~bigint(everyone.deny);
permissions |= bigint(everyone.allow);
}
let roleDeny = 0n;
let roleAllow = 0n;
for (const overwrite of overwrites) {
if (overwrite.type !== 0 || !input.memberRoleIds.has(overwrite.id ?? ""))
continue;
roleDeny |= bigint(overwrite.deny);
roleAllow |= bigint(overwrite.allow);
}
permissions &= ~roleDeny;
permissions |= roleAllow;
const member = overwrites.find(
(overwrite) => overwrite.type === 1 && overwrite.id === input.memberId,
);
if (member) {
permissions &= ~bigint(member.deny);
permissions |= bigint(member.allow);
}
return permissions;
}
export async function verifyDiscordBot(input: {
applicationId: string;
botToken: string;
fetch: typeof globalThis.fetch;
guildId: string;
}): Promise<DiscordBotIdentity> {
const applicationId = snowflake(
input.applicationId,
"Discord Application ID",
);
const guildId = snowflake(input.guildId, "Discord Server ID");
const [user, application, guild] = await Promise.all([
discordJson<DiscordUser>(
input.fetch,
input.botToken,
"/users/@me",
"bot identity lookup",
),
discordJson<DiscordApplication>(
input.fetch,
input.botToken,
"/oauth2/applications/@me",
"application lookup",
),
discordJson<DiscordGuild>(
input.fetch,
input.botToken,
`/guilds/${encodeURIComponent(guildId)}`,
"server membership lookup",
),
]);
if (!user.bot || !user.id || !user.username) {
throw new Error("Discord token does not identify a bot user");
}
if (application.id !== applicationId || user.id !== applicationId) {
throw new Error(
"Discord Application ID does not match the supplied bot token",
);
}
if (((application.flags ?? 0) & MESSAGE_CONTENT_FLAGS) === 0) {
throw new Error(
"Discord Message Content intent is not enabled for this application",
);
}
if (guild.id !== guildId) {
throw new Error("Discord bot is not installed in the selected server");
}
return {
providerAccountId: guildId,
providerAccountLabel: guild.name ?? guildId,
botExternalId: user.id,
botUsername: user.username,
botLabel: user.global_name ?? application.name ?? user.username,
...(user.avatar
? {
botAvatarUrl: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
}
: {}),
};
}
export async function listDiscordBotChannels(input: {
botUserId: string;
botToken: string;
fetch: typeof globalThis.fetch;
guildId: string;
}): Promise<ChatProviderInventoryResult> {
const guildId = snowflake(input.guildId, "Discord Server ID");
const botUserId = snowflake(input.botUserId, "Discord bot user ID");
const [guild, member, roles, channels] = await Promise.all([
discordJson<DiscordGuild>(
input.fetch,
input.botToken,
`/guilds/${encodeURIComponent(guildId)}`,
"server lookup",
),
discordJson<DiscordMember>(
input.fetch,
input.botToken,
`/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(botUserId)}`,
"bot membership lookup",
),
discordJson<DiscordRole[]>(
input.fetch,
input.botToken,
`/guilds/${encodeURIComponent(guildId)}/roles`,
"server roles lookup",
),
discordJson<DiscordChannel[]>(
input.fetch,
input.botToken,
`/guilds/${encodeURIComponent(guildId)}/channels`,
"server channels lookup",
),
]);
const memberId = member.user?.id;
if (!memberId || guild.id !== guildId) {
throw new Error("Discord bot membership could not be verified");
}
const memberRoleIds = new Set(member.roles ?? []);
const resources: ChatProviderResourceInventoryItem[] = channels
.filter((channel) => channel.type === 0 && channel.id)
.filter((channel) => {
const permissions = channelPermissions({
channel,
guildId,
memberId,
memberRoleIds,
roles,
});
return (
(permissions & REQUIRED_CHANNEL_PERMISSIONS) ===
REQUIRED_CHANNEL_PERMISSIONS
);
})
.sort((left, right) => (left.position ?? 0) - (right.position ?? 0))
.map((channel) => ({
providerResourceId: channel.id!,
parentProviderResourceId: guildId,
type: "channel",
label: channel.name ? `#${channel.name}` : channel.id!,
providerUrl: `https://discord.com/channels/${guildId}/${channel.id}`,
metadata: { source: "provider_inventory" },
}));
if (resources.length === 0) {
throw new Error(
"Discord bot needs View Channels, Send Messages, Create Public Threads, Send Messages in Threads, Read Message History, Add Reactions, Embed Links, and Attach Files in at least one text channel",
);
}
return { provider: "discord", resources };
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,856 @@
import type { Attachment, Message } from "chat";
import { createHash } from "node:crypto";
import { JSDOM } from "jsdom";
import {
isAllowedContentType,
MAX_ATTACHMENT_BYTES,
normalizeContentType,
normalizeUploadAttachmentContentType,
} from "../attachment-types.js";
import { guardedRemoteHttpFetch } from "./remote-http-fetch.js";
const MAX_URL_LENGTH = 2048;
const DOWNLOAD_TIMEOUT_MS = 20_000;
export const GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS = 60_000;
const MAX_ATTACHMENTS = 20;
const MAX_REFERENCES = 10_000;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const CDN_HOSTS = new Set([
"user-images.githubusercontent.com",
"private-user-images.githubusercontent.com",
"github-production-user-asset-6210df.s3.amazonaws.com",
"github-production-repository-file-5c1aeb.s3.amazonaws.com",
]);
const MIME_EXTENSIONS: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"application/pdf": ".pdf",
"text/plain": ".txt",
"text/markdown": ".md",
"text/csv": ".csv",
"application/json": ".json",
"application/zip": ".zip",
"video/mp4": ".mp4",
"video/webm": ".webm",
"audio/mpeg": ".mp3",
};
/** Provenance is the admitted comment, not ownership of GitHub's anonymized upload. */
export interface GitHubPublicAttachmentLocator {
kind: "github_public_attachment";
url: string;
sourceThreadId: string;
sourceMessageId: string;
/** Old four-field descriptors remain anonymous-only. */
version?: 2;
sourceBodySha256?: string;
}
export interface GitHubAttachmentCommentRequest {
url: string;
accept: string;
}
export type GitHubAttachmentCommentResolver = (
request: GitHubAttachmentCommentRequest,
signal: AbortSignal,
) => Promise<unknown>;
export function isGitHubAttachmentCommentRequest(
request: GitHubAttachmentCommentRequest,
): boolean {
const match =
/^https:\/\/api\.github\.com\/repos\/[a-z0-9][a-z0-9-]{0,38}\/([a-z0-9_.-]{1,100})\/(issues|pulls)\/comments\/[1-9][0-9]{0,24}$/i.exec(
request.url,
);
return Boolean(
match &&
![".", ".."].includes(match[1]!) &&
request.accept ===
(match[2] === "pulls"
? "application/vnd.github-commitcomment.full+json"
: "application/vnd.github.full+json"),
);
}
const handles = new WeakMap<Attachment, GitHubPublicAttachmentLocator>();
const limitOmissions = new WeakMap<Message, number>();
/** Informational only: this count cannot authorize or identify a downloadable file. */
export function githubAttachmentLimitOmissions(message: Message): number {
return limitOmissions.get(message) ?? 0;
}
export function restoreGitHubAttachmentLimitOmissions(
message: Message,
value: unknown,
): void {
limitOmissions.delete(message);
if (
typeof value === "number" &&
Number.isInteger(value) &&
value > 0 &&
value <= MAX_REFERENCES
)
limitOmissions.set(message, value);
}
const GITHUB_ATTACHMENT_DIAGNOSTIC_CODES = [
"github_attachment_not_public",
"github_attachment_invalid_response",
"github_attachment_unsafe_redirect",
"github_attachment_too_large",
"github_attachment_empty",
"github_attachment_unsupported_type",
"github_attachment_download_failed",
"github_attachment_source_mismatch",
"github_attachment_canonical_authority_unavailable",
"github_attachment_canonical_api_request_failed",
"github_attachment_canonical_api_access_denied",
"github_attachment_canonical_api_status_unexpected",
"github_attachment_canonical_api_invalid_response",
"github_attachment_canonical_api_too_large",
"github_attachment_canonical_response_unavailable",
"github_attachment_canonical_source_mismatch",
"github_attachment_canonical_body_mismatch",
"github_attachment_canonical_html_unavailable",
"github_attachment_canonical_file_unsupported",
"github_attachment_canonical_image_count_invalid",
"github_attachment_canonical_target_denied",
"github_attachment_canonical_mapping_ambiguous",
"github_attachment_canonical_signed_anchor_only",
"github_attachment_canonical_image_without_source_anchor",
"github_attachment_canonical_anchor_missing",
] as const;
type GitHubAttachmentDiagnosticCode =
(typeof GITHUB_ATTACHMENT_DIAGNOSTIC_CODES)[number];
/** Read no request/response data; SDK wrappers may retain a closed error as cause. */
export function githubAttachmentDiagnosticCode(
error: unknown,
): GitHubAttachmentDiagnosticCode | null {
for (let depth = 0; depth < 4 && error instanceof Error; depth++) {
if (
GITHUB_ATTACHMENT_DIAGNOSTIC_CODES.includes(
error.message as GitHubAttachmentDiagnosticCode,
)
)
return error.message as GitHubAttachmentDiagnosticCode;
error = error.cause;
}
return null;
}
export class GitHubAttachmentUnavailableError extends Error {
constructor(readonly code: GitHubAttachmentDiagnosticCode) {
// Closed codes only: URLs, signed redirects and provider response bodies never escape.
super(code);
this.name = "GitHubAttachmentUnavailableError";
}
}
export function canonicalGitHubAttachmentUrl(value: unknown): string | null {
if (typeof value !== "string" || value.length > MAX_URL_LENGTH) return null;
try {
const url = new URL(value);
if (
url.origin !== "https://github.com" ||
url.username ||
url.password ||
url.search ||
url.hash
)
return null;
if (
!/^\/user-attachments\/(?:assets\/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|files\/[1-9][0-9]*\/[^/]+)$/i.test(
url.pathname,
)
)
return null;
if (
/%(?:2f|5c|00|0[ad])/i.test(url.pathname) ||
url.pathname.includes("\\")
)
return null;
return url.href;
} catch {
return null;
}
}
function validThread(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 512 &&
/^github:[a-z0-9_.-]+\/[a-z0-9_.-]+:(?:issue:)?[1-9][0-9]*(?::rc:[1-9][0-9]*)?$/i.test(
value,
)
);
}
export function validateGitHubAttachmentLocator(
value: unknown,
): GitHubPublicAttachmentLocator | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (
![
"kind,sourceMessageId,sourceThreadId,url",
"kind,sourceBodySha256,sourceMessageId,sourceThreadId,url,version",
].includes(Object.keys(row).sort().join(",")) ||
(("version" in row || "sourceBodySha256" in row) &&
(row.version !== 2 ||
typeof row.sourceBodySha256 !== "string" ||
!/^[a-f0-9]{64}$/.test(row.sourceBodySha256))) ||
row.kind !== "github_public_attachment" ||
!validThread(row.sourceThreadId) ||
typeof row.sourceMessageId !== "string" ||
!/^[1-9][0-9]{0,24}$/.test(row.sourceMessageId)
)
return null;
const url = canonicalGitHubAttachmentUrl(row.url);
return url
? {
kind: "github_public_attachment",
url,
sourceThreadId: row.sourceThreadId,
sourceMessageId: row.sourceMessageId,
...(row.version === 2
? {
version: 2 as const,
sourceBodySha256: row.sourceBodySha256 as string,
}
: {}),
}
: null;
}
function safeName(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const name = value
.replace(/[\u0000-\u001f\u007f/\\]/g, "_")
.trim()
.slice(0, 200);
return name && name !== "." && name !== ".." ? name : undefined;
}
export function githubAttachmentLocator(
attachment: Attachment,
): GitHubPublicAttachmentLocator | null {
return handles.get(attachment) ?? null;
}
export function rehydrateGitHubPublicAttachment(
value: unknown,
source: { threadId: string; messageId: string },
): Attachment | null {
const locator = validateGitHubAttachmentLocator(value);
if (
!locator ||
locator.sourceThreadId !== source.threadId ||
locator.sourceMessageId !== source.messageId
)
return null;
const url = new URL(locator.url);
let name: string | undefined;
if (url.pathname.startsWith("/user-attachments/files/")) {
try {
name = safeName(decodeURIComponent(url.pathname.split("/").at(-1)!));
} catch {
return null;
}
}
const attachment: Attachment = {
type: "file",
name: name ?? `github-attachment-${url.pathname.split("/").at(-1)}`,
};
handles.set(attachment, locator);
return attachment;
}
/** Parse references only; network I/O happens later, after Paperclip's admission fence. */
export function githubPublicAttachmentsFromMessage(
message: Message,
): Attachment[] {
limitOmissions.delete(message);
const raw = message.raw as Record<string, unknown> | null;
if (
!raw ||
!validThread(message.threadId) ||
!/^[1-9][0-9]{0,24}$/.test(message.id)
)
return [];
const comment = raw.comment as Record<string, unknown> | undefined;
const repository = raw.repository as Record<string, unknown> | undefined;
if (
!comment ||
!repository ||
String(comment.id) !== message.id ||
typeof comment.body !== "string" ||
comment.body.length > 200_000
)
return [];
const thread =
/^github:([^:]+):(?:(issue):)?([1-9][0-9]*)(?::rc:([1-9][0-9]*))?$/i.exec(
message.threadId,
);
if (
!thread ||
typeof repository.full_name !== "string" ||
thread[1].toLowerCase() !== repository.full_name.toLowerCase() ||
Number(thread[3]) !== raw.prNumber
)
return [];
if (raw.type === "review_comment") {
if (thread[2] || thread[4] !== String(comment.in_reply_to_id ?? comment.id))
return [];
} else if (
raw.type !== "issue_comment" ||
thread[4] ||
Boolean(thread[2]) !== (raw.threadType === "issue")
)
return [];
const urls = new Set<string>();
const definitions = new Map<string, unknown>();
const references: string[] = [];
const stack: unknown[] = [message.formatted];
let visited = 0;
const sourceBody = comment.body;
const add = (value: unknown) => {
if (typeof value !== "string" || !sourceBody.includes(value)) return;
const url = canonicalGitHubAttachmentUrl(value);
if (url && urls.size < MAX_REFERENCES) urls.add(url);
};
while (stack.length && visited++ < 10_000) {
const node = stack.pop() as Record<string, unknown> | null;
if (!node || typeof node !== "object") continue;
if (node.type === "link" || node.type === "image") add(node.url);
if (node.type === "definition" && typeof node.identifier === "string")
definitions.set(node.identifier.toLowerCase(), node.url);
if (
(node.type === "imageReference" || node.type === "linkReference") &&
typeof node.identifier === "string"
)
references.push(node.identifier.toLowerCase());
if (node.type === "html" && typeof node.value === "string") {
const html = node.value.replace(/<!--[\s\S]*?(?:-->|$)/g, "");
for (const match of html.matchAll(
/<img\b[^>]{0,8192}?\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi,
))
add(match[1] ?? match[2]);
}
if (Array.isArray(node.children))
for (let index = node.children.length - 1; index >= 0; index--)
stack.push(node.children[index]);
}
for (const id of references) add(definitions.get(id));
restoreGitHubAttachmentLimitOmissions(
message,
Math.max(0, urls.size - MAX_ATTACHMENTS),
);
const sourceBodySha256 = createHash("sha256")
.update(comment.body)
.digest("hex");
return [...urls]
.slice(0, MAX_ATTACHMENTS)
.map((url) =>
rehydrateGitHubPublicAttachment(
{
kind: "github_public_attachment",
url,
sourceThreadId: message.threadId,
sourceMessageId: message.id,
version: 2,
sourceBodySha256,
},
{ threadId: message.threadId, messageId: message.id },
)!,
)
.filter(Boolean);
}
function allowedRedirect(value: string, original: string): URL | null {
try {
const url = new URL(value, original);
if (
url.protocol !== "https:" ||
url.port ||
url.username ||
url.password ||
url.hash ||
url.href.length > 8192
)
return null;
if (url.hostname === "github.com")
return canonicalGitHubAttachmentUrl(url.href) ? url : null;
if (!CDN_HOSTS.has(url.hostname) || url.pathname === "/") return null;
return url;
} catch {
return null;
}
}
/** Exact documented comment route; no caller-supplied API origin or query. */
export function githubAttachmentCommentRequest(
attachment: Attachment,
): GitHubAttachmentCommentRequest | null {
const locator = handles.get(attachment);
if (locator?.version !== 2 || !locator.sourceBodySha256) return null;
const thread =
/^github:([^/:]+)\/([^:]+):(?:(issue):)?([1-9][0-9]*)(?::rc:([1-9][0-9]*))?$/i.exec(
locator.sourceThreadId,
);
if (
!thread ||
!/^[a-z0-9][a-z0-9-]{0,38}$/i.test(thread[1]!) ||
!/^[a-z0-9_.-]{1,100}$/i.test(thread[2]!) ||
[".", ".."].includes(thread[2]!)
)
return null;
return {
url: `https://api.github.com/repos/${thread[1]}/${thread[2]}/${thread[5] ? "pulls" : "issues"}/comments/${locator.sourceMessageId}`,
accept: thread[5]
? "application/vnd.github-commitcomment.full+json"
: "application/vnd.github.full+json",
};
}
const MAX_COMMENT_RESPONSE_BYTES = 1_048_576;
/** Octokit's authenticated request may go only to this one fixed API route. */
export function githubAttachmentCommentFetch(
expected: GitHubAttachmentCommentRequest,
signal: AbortSignal,
): typeof fetch {
return async (input, init) => {
if (
!isGitHubAttachmentCommentRequest(expected) ||
typeof input !== "string" ||
input !== expected.url ||
init?.method !== "GET"
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_source_mismatch",
);
signal.throwIfAborted();
const headers = new Headers(init.headers);
if (headers.get("accept") !== expected.accept || headers.has("cookie"))
throw new GitHubAttachmentUnavailableError(
"github_attachment_source_mismatch",
);
const response = await guardedRemoteHttpFetch(
expected.url,
{
...init,
method: "GET",
headers,
credentials: "omit",
redirect: "manual",
signal,
},
{
allowPrivateNetwork: false,
connectTimeoutMs: 5000,
responseTimeoutMs: DOWNLOAD_TIMEOUT_MS,
error: () =>
new GitHubAttachmentUnavailableError(
"github_attachment_canonical_api_request_failed",
),
},
);
if (
response.status !== 200 ||
!response.body ||
!/^application\/json(?:;|$)/i.test(
response.headers.get("content-type") ?? "",
) ||
Number(response.headers.get("content-length") ?? 0) >
MAX_COMMENT_RESPONSE_BYTES
) {
await response.body?.cancel();
throw new GitHubAttachmentUnavailableError(
[401, 403, 404].includes(response.status)
? "github_attachment_canonical_api_access_denied"
: response.status !== 200
? "github_attachment_canonical_api_status_unexpected"
: Number(response.headers.get("content-length") ?? 0) >
MAX_COMMENT_RESPONSE_BYTES
? "github_attachment_canonical_api_too_large"
: "github_attachment_canonical_api_invalid_response",
);
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
const cancel = () => {
void reader.cancel().catch(() => undefined);
};
signal.addEventListener("abort", cancel, { once: true });
try {
for (;;) {
signal.throwIfAborted();
const next = await reader.read();
signal.throwIfAborted();
if (next.done) break;
size += next.value.byteLength;
if (size > MAX_COMMENT_RESPONSE_BYTES)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_api_too_large",
);
chunks.push(next.value);
}
} finally {
signal.removeEventListener("abort", cancel);
await reader.cancel().catch(() => undefined);
}
return new Response(Buffer.concat(chunks), {
status: 200,
headers: { "content-type": "application/json" },
});
};
}
/**
* The authenticated rendering is evidence only for this exact unchanged source.
* We support GitHub's image anchor mapping, not arbitrary HTML URL extraction.
* No signed URL is returned to the model or added to durable descriptors.
*/
export function resolveGitHubCommentAttachmentTarget(
attachment: Attachment,
value: unknown,
): URL | null {
try {
return resolveCanonicalAttachmentTargetOrThrow(attachment, value);
} catch (error) {
if (error instanceof GitHubAttachmentUnavailableError) return null;
throw error;
}
}
function resolveCanonicalAttachmentTargetOrThrow(
attachment: Attachment,
value: unknown,
): URL {
const locator = handles.get(attachment);
const request = githubAttachmentCommentRequest(attachment);
if (
!locator ||
!request ||
!value ||
typeof value !== "object" ||
Array.isArray(value)
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_response_unavailable",
);
const row = value as Record<string, unknown>;
const thread =
/^github:([^:]+):(?:(issue):)?([1-9][0-9]*)(?::rc:([1-9][0-9]*))?$/i.exec(
locator.sourceThreadId,
)!;
if (
String(row.id) !== locator.sourceMessageId ||
typeof row.url !== "string" ||
row.url.toLowerCase() !== request.url.toLowerCase()
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_source_mismatch",
);
if (
typeof row.body !== "string" ||
row.body.length > 200_000 ||
createHash("sha256").update(row.body).digest("hex") !==
locator.sourceBodySha256
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_body_mismatch",
);
if (typeof row.body_html !== "string" || row.body_html.length > 600_000)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_html_unavailable",
);
if (thread[4]) {
if (
row.pull_request_url !==
`https://api.github.com/repos/${thread[1]}/pulls/${thread[3]}` ||
String(row.in_reply_to_id ?? row.id) !== thread[4]
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_source_mismatch",
);
} else if (
row.issue_url !==
`https://api.github.com/repos/${thread[1]}/issues/${thread[3]}`
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_source_mismatch",
);
const assetId = /\/assets\/([a-f0-9-]+)$/i.exec(locator.url)?.[1];
// Generic private files have no documented signed-download representation.
if (!assetId)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_file_unsupported",
);
const sourceBody = row.body;
const imagePath = new RegExp(
`^/[1-9][0-9]*/[1-9][0-9]*-${assetId}\\.(?:png|jpe?g|gif|webp)$`,
"i",
);
const sameAssetImage = (src: string): URL | null => {
const target = allowedRedirect(src, locator.url);
return target?.hostname === "private-user-images.githubusercontent.com" &&
imagePath.test(target.pathname)
? target
: null;
};
const signedImage = (src: string): URL | null => {
const target = sameAssetImage(src);
return target &&
[...target.searchParams.keys()].join(",") === "jwt" &&
/^[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+$/i.test(
target.searchParams.get("jwt") ?? "",
) &&
!sourceBody.includes(src)
? target
: null;
};
const fragment = JSDOM.fragment(row.body_html);
const candidates: URL[] = [];
for (const anchor of fragment.querySelectorAll("a[href]")) {
const href = anchor.getAttribute("href")!;
const images = anchor.querySelectorAll("img[src]");
if (
href !== locator.url &&
!sameAssetImage(href) &&
![...images].some((image) => sameAssetImage(image.getAttribute("src")!))
)
continue;
if (images.length !== 1)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_image_count_invalid",
);
const src = images[0]!.getAttribute("src")!;
const target = signedImage(src);
// The second form was observed in the exact App-rendered live comment.
// Both the original-anchor and signed-anchor forms enter one candidate set
// so duplicated or mixed renderings cannot silently choose a target.
if (
!target ||
(href !== locator.url && (href !== src || !signedImage(href)))
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_target_denied",
);
candidates.push(target);
}
const sameAssetImages = [...fragment.querySelectorAll("img[src]")].filter(
(image) =>
image.getAttribute("src") === locator.url ||
sameAssetImage(image.getAttribute("src")!),
);
if (candidates.length > 1 || sameAssetImages.length > 1)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_mapping_ambiguous",
);
if (candidates.length === 1 && sameAssetImages.length === 1)
return candidates[0]!;
if (
[...fragment.querySelectorAll("img[src]")].some((image) =>
signedImage(image.getAttribute("src")!),
)
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_image_without_source_anchor",
);
throw new GitHubAttachmentUnavailableError(
"github_attachment_canonical_anchor_missing",
);
}
function imageSignatureMatches(body: Buffer, mime: string): boolean {
if (mime === "image/png")
return body
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
if (mime === "image/jpeg" || mime === "image/jpg")
return body[0] === 255 && body[1] === 216 && body[2] === 255;
if (mime === "image/gif")
return /^(GIF87a|GIF89a)$/.test(body.subarray(0, 6).toString("ascii"));
if (mime === "image/webp")
return (
body.subarray(0, 4).toString("ascii") === "RIFF" &&
body.subarray(8, 12).toString("ascii") === "WEBP"
);
return !mime.startsWith("image/");
}
/** Download hosts never receive provider credentials, including after canonical resolution. */
export async function prepareGitHubPublicAttachment(
attachment: Attachment,
batchSignal?: AbortSignal,
resolveComment?: GitHubAttachmentCommentResolver,
): Promise<Attachment> {
const locator = handles.get(attachment);
if (!locator)
throw new GitHubAttachmentUnavailableError(
"github_attachment_source_mismatch",
);
const downloadSignal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
const signal = batchSignal
? AbortSignal.any([downloadSignal, batchSignal])
: downloadSignal;
try {
let url = new URL(locator.url);
let resolvedCanonicalComment = false;
for (let redirects = 0; redirects <= 3; redirects++) {
signal.throwIfAborted();
const response = await guardedRemoteHttpFetch(
url,
{
method: "GET",
redirect: "manual",
credentials: "omit",
signal,
headers: { accept: "*/*", "user-agent": "Paperclip/ChatAttachments" },
},
{
allowPrivateNetwork: false,
connectTimeoutMs: 5000,
responseTimeoutMs: DOWNLOAD_TIMEOUT_MS,
error: () =>
new GitHubAttachmentUnavailableError(
"github_attachment_download_failed",
),
},
);
if (REDIRECT_STATUSES.has(response.status)) {
const target = allowedRedirect(
response.headers.get("location") ?? "",
url.href,
);
await response.body?.cancel();
if (!target || redirects === 3)
throw new GitHubAttachmentUnavailableError(
"github_attachment_unsafe_redirect",
);
url = target;
continue;
}
const rejectResponse = async (
code: GitHubAttachmentUnavailableError["code"],
): Promise<never> => {
await response.body?.cancel();
throw new GitHubAttachmentUnavailableError(code);
};
if (
response.status === 401 ||
response.status === 403 ||
response.status === 404
) {
const commentRequest = githubAttachmentCommentRequest(attachment);
if (!resolvedCanonicalComment && resolveComment && commentRequest) {
await response.body?.cancel();
resolvedCanonicalComment = true;
signal.throwIfAborted();
const canonical = await resolveComment(commentRequest, signal);
signal.throwIfAborted();
const target = resolveCanonicalAttachmentTargetOrThrow(
attachment,
canonical,
);
url = target;
continue;
}
return await rejectResponse("github_attachment_not_public");
}
if (response.status !== 200 || !response.body)
return await rejectResponse("github_attachment_invalid_response");
const mimeType = normalizeUploadAttachmentContentType({
contentType: normalizeContentType(response.headers.get("content-type")),
originalFilename: attachment.name,
isAllowedContentType,
});
// A login/error document is never a successfully downloaded attachment.
if (mimeType === "text/html" || !isAllowedContentType(mimeType))
return await rejectResponse("github_attachment_unsupported_type");
const declared = response.headers.get("content-length");
if (
declared &&
(!/^\d+$/.test(declared) || Number(declared) > MAX_ATTACHMENT_BYTES)
)
return await rejectResponse("github_attachment_too_large");
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let size = 0;
const cancel = () => {
void reader.cancel().catch(() => undefined);
};
signal.addEventListener("abort", cancel, { once: true });
try {
for (;;) {
signal.throwIfAborted();
const next = await reader.read();
signal.throwIfAborted();
if (next.done) break;
size += next.value.byteLength;
if (size > MAX_ATTACHMENT_BYTES)
throw new GitHubAttachmentUnavailableError(
"github_attachment_too_large",
);
chunks.push(Buffer.from(next.value));
}
} finally {
signal.removeEventListener("abort", cancel);
await reader.cancel().catch(() => undefined);
}
if (!size)
throw new GitHubAttachmentUnavailableError("github_attachment_empty");
// Content-Length describes compressed bytes when Content-Encoding is present.
if (
declared &&
!response.headers.get("content-encoding") &&
size !== Number(declared)
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_invalid_response",
);
const body = Buffer.concat(chunks, size);
if (
!imageSignatureMatches(body, mimeType) ||
/^\s*(?:<!doctype\s+html|<html\b)/i.test(
body.subarray(0, 512).toString("utf8"),
)
)
throw new GitHubAttachmentUnavailableError(
"github_attachment_invalid_response",
);
const name = attachment.name?.startsWith("github-attachment-")
? `${attachment.name}${MIME_EXTENSIONS[mimeType] ?? ""}`
: attachment.name;
return {
type: mimeType.startsWith("image/")
? "image"
: mimeType.startsWith("audio/")
? "audio"
: mimeType.startsWith("video/")
? "video"
: "file",
name,
mimeType,
size,
fetchData: async () => body,
};
}
throw new GitHubAttachmentUnavailableError(
"github_attachment_unsafe_redirect",
);
} catch (error) {
if (error instanceof GitHubAttachmentUnavailableError) throw error;
throw new GitHubAttachmentUnavailableError(
"github_attachment_download_failed",
);
}
}

View File

@ -0,0 +1,474 @@
import { createHmac } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createChatSdkEndpointRuntime,
type ChatSdkMessageCallbackEvent,
} from "./chat-sdk-runtime.js";
import type {
ChatSdkStateCompareAndSetInput,
ChatSdkStateDeleteInput,
ChatSdkStatePersistence,
ChatSdkStateRecord,
ChatSdkStateScope,
} from "./chat-sdk-state.js";
function memoryPersistence(): ChatSdkStatePersistence {
const rows = new Map<string, ChatSdkStateRecord>();
const key = (scope: ChatSdkStateScope, value: string) =>
`${scope.companyId}:${scope.endpointId}:${value}`;
return {
async read(scope, value) {
return rows.get(key(scope, value)) ?? null;
},
async compareAndSet(input: ChatSdkStateCompareAndSetInput) {
const storageKey = key(input, input.key);
const current = rows.get(storageKey);
if ((current?.version ?? null) !== input.expectedVersion) return false;
rows.set(storageKey, {
expiresAt: input.expiresAt,
value: input.value,
version: (current?.version ?? 0) + 1,
});
return true;
},
async deleteIfVersion(input: ChatSdkStateDeleteInput) {
const storageKey = key(input, input.key);
const current = rows.get(storageKey);
if (current?.version !== input.expectedVersion) return false;
rows.delete(storageKey);
return true;
},
};
}
const webhookSecret = "github-provider-stress-secret";
function signedGitHubRequest(
event: "issue_comment" | "pull_request_review_comment",
payload: Record<string, unknown>,
deliveryId?: string,
) {
const body = JSON.stringify(payload);
const signature = createHmac("sha256", webhookSecret)
.update(body)
.digest("hex");
const resolvedDeliveryId =
deliveryId ??
`delivery-${String((payload.comment as { id?: unknown } | undefined)?.id ?? "event")}`;
return new Request("https://paperclip.example/github", {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-delivery": resolvedDeliveryId,
"x-github-event": event,
"x-hub-signature-256": `sha256=${signature}`,
},
body,
});
}
function commentPayload(input: {
body: string;
commentId: number;
number: number;
pullRequest?: boolean;
reviewRootId?: number;
senderId?: number;
}) {
const userId = input.senderId ?? 7001;
return {
action: "created",
comment: {
id: input.commentId,
in_reply_to_id: input.reviewRootId,
body: input.body,
created_at: "2026-09-05T12:00:00Z",
updated_at: "2026-09-05T12:00:00Z",
html_url: `https://github.com/paperclipai/chat-e2e/issues/${input.number}#issuecomment-${input.commentId}`,
user: {
id: userId,
login: userId === 9001 ? "maya-paperclip[bot]" : "alex-e2e",
type: userId === 9001 ? "Bot" : "User",
},
},
installation: { id: 2468 },
issue: {
number: input.number,
...(input.pullRequest ? { pull_request: {} } : {}),
},
pull_request: { number: input.number },
repository: {
id: 97531,
name: "chat-e2e",
full_name: "paperclipai/chat-e2e",
owner: { id: 1357, login: "paperclipai" },
},
sender: {
id: userId,
login: userId === 9001 ? "maya-paperclip[bot]" : "alex-e2e",
},
};
}
describe("GitHub published adapter stress contract", () => {
afterEach(() => vi.unstubAllGlobals());
it("normalizes issue, PR, inline, and subscribed follow-up boundaries and suppresses the bot's own event", async () => {
const providerRequests: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
providerRequests.push(String(input));
return new Response(
JSON.stringify({
id: 1,
content: "+1",
user: { id: 9001, login: "maya-paperclip[bot]" },
}),
{ status: 201, headers: { "content-type": "application/json" } },
);
}),
);
const deliveries: ChatSdkMessageCallbackEvent[] = [];
const runtime = createChatSdkEndpointRuntime({
callbacks: {
async onMessage(event) {
deliveries.push(event);
await event.thread.adapter.addReaction(
event.thread.id,
event.message.id,
"eyes",
);
if (event.trigger === "mention") await event.thread.subscribe();
},
},
companyId: "company-github-provider-stress",
endpointId: "endpoint-github-provider-stress",
logger: "silent",
persistence: memoryPersistence(),
providerConfig: {
provider: "github",
// GitHub App actors are reported as `<slug>[bot]`, but users invoke
// them with the human-facing `@<slug>` mention.
userName: "maya-paperclip",
credentials: {
botUserId: 9001,
token: "github-token-never-logged",
webhookSecret,
},
},
});
await runtime.initialize();
const uploadedImageUrl =
"https://github.com/user-attachments/assets/11111111-2222-3333-4444-555555555555";
const uploadedTextUrl =
"https://github.com/user-attachments/files/31917991/media-qa-0907.txt";
const cases = [
{
event: "issue_comment" as const,
payload: commentPayload({
body: "@maya-paperclip issue root",
commentId: 4201,
number: 42,
}),
},
{
event: "issue_comment" as const,
payload: commentPayload({
body: "@maya-paperclip PR root",
commentId: 4301,
number: 43,
pullRequest: true,
}),
},
{
event: "pull_request_review_comment" as const,
payload: commentPayload({
body: `@maya-paperclip inline root\n\n<img width="1254" height="1254" alt="Image" src="${uploadedImageUrl}" />\n\n[media-qa-0907.txt](${uploadedTextUrl})`,
commentId: 4401,
number: 43,
pullRequest: true,
reviewRootId: 4401,
}),
},
];
for (const item of cases) {
const response = await runtime.handleWebhook(
signedGitHubRequest(item.event, item.payload),
);
expect(response.status).toBe(200);
}
for (const followup of [
{
event: "issue_comment" as const,
payload: commentPayload({
body: "unmentioned issue follow-up",
commentId: 4202,
number: 42,
}),
},
{
event: "issue_comment" as const,
payload: commentPayload({
body: "unmentioned PR follow-up",
commentId: 4302,
number: 43,
pullRequest: true,
}),
},
{
event: "pull_request_review_comment" as const,
payload: commentPayload({
body: "unmentioned inline review follow-up",
commentId: 4402,
number: 43,
pullRequest: true,
reviewRootId: 4401,
}),
},
]) {
expect(
(
await runtime.handleWebhook(
signedGitHubRequest(followup.event, followup.payload),
)
).status,
).toBe(200);
}
const selfEvent = commentPayload({
body: "@maya-paperclip outbound self event",
commentId: 4203,
number: 42,
senderId: 9001,
});
expect(
(
await runtime.handleWebhook(
signedGitHubRequest("issue_comment", selfEvent),
)
).status,
).toBe(200);
expect(
deliveries.map((delivery) => ({
id: delivery.message.id,
threadId: delivery.thread.id,
trigger: delivery.trigger,
})),
).toEqual([
{
id: "4201",
threadId: "github:paperclipai/chat-e2e:issue:42",
trigger: "mention",
},
{
id: "4301",
threadId: "github:paperclipai/chat-e2e:43",
trigger: "mention",
},
{
id: "4401",
threadId: "github:paperclipai/chat-e2e:43:rc:4401",
trigger: "mention",
},
{
id: "4202",
threadId: "github:paperclipai/chat-e2e:issue:42",
trigger: "subscribed_message",
},
{
id: "4302",
threadId: "github:paperclipai/chat-e2e:43",
trigger: "subscribed_message",
},
{
id: "4402",
threadId: "github:paperclipai/chat-e2e:43:rc:4401",
trigger: "subscribed_message",
},
]);
expect(deliveries[2]?.message.text).toContain(uploadedImageUrl);
expect(deliveries[2]?.message.text).toContain(uploadedTextUrl);
expect(deliveries[2]?.message.attachments).toHaveLength(2);
for (const attachment of deliveries[2]!.message.attachments) {
expect(attachment.fetchData).toBeUndefined();
const descriptor = runtime.attachmentRecoveryDescriptor(attachment);
expect(descriptor).toMatchObject({
provider: "github",
locator: { kind: "github_public_attachment", sourceMessageId: "4401" },
});
expect(
runtime.rehydrateAttachment(descriptor, {
threadId: deliveries[2]!.message.threadId,
messageId: "4401",
}),
).not.toBeNull();
expect(
runtime.rehydrateAttachment(descriptor, {
threadId: deliveries[2]!.message.threadId,
messageId: "4402",
}),
).toBeNull();
}
expect(providerRequests).toHaveLength(6);
expect(providerRequests.every((url) => url.includes("/reactions"))).toBe(
true,
);
await runtime.shutdown();
});
it("posts and edits inline output through the review-thread API boundary", async () => {
const providerRequests: Array<{ method: string; url: string }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
const method =
input instanceof Request ? input.method : (init?.method ?? "GET");
providerRequests.push({ method, url });
if (
method === "POST" &&
url.endsWith(
"/repos/paperclipai/chat-e2e/pulls/43/comments/4401/replies",
)
) {
return Response.json(
{
id: 9901,
body: "inline result",
created_at: "2026-09-05T12:01:00Z",
updated_at: "2026-09-05T12:01:00Z",
user: {
id: 9001,
login: "maya-paperclip[bot]",
type: "Bot",
},
},
{ status: 201 },
);
}
if (
method === "PATCH" &&
url.endsWith("/repos/paperclipai/chat-e2e/pulls/comments/9901")
) {
return Response.json({
id: 9901,
body: "final inline result",
created_at: "2026-09-05T12:01:00Z",
updated_at: "2026-09-05T12:02:00Z",
user: {
id: 9001,
login: "maya-paperclip[bot]",
type: "Bot",
},
});
}
throw new Error(`Unexpected GitHub provider request: ${method} ${url}`);
}),
);
const runtime = createChatSdkEndpointRuntime({
callbacks: { onMessage() {} },
companyId: "company-github-inline-egress",
endpointId: "endpoint-github-inline-egress",
logger: "silent",
persistence: memoryPersistence(),
providerConfig: {
provider: "github",
userName: "maya-paperclip",
credentials: {
botUserId: 9001,
token: "github-token-never-logged",
webhookSecret,
},
},
});
try {
const sent = await runtime
.thread("github:paperclipai/chat-e2e:43:rc:4401")
.post({ markdown: "inline result" });
expect(sent.id).toBe("9901");
const edited = await sent.edit({ markdown: "final inline result" });
expect(edited.id).toBe("9901");
expect(providerRequests).toEqual([
{
method: "POST",
url: "https://api.github.com/repos/paperclipai/chat-e2e/pulls/43/comments/4401/replies",
},
{
method: "PATCH",
url: "https://api.github.com/repos/paperclipai/chat-e2e/pulls/comments/9901",
},
]);
} finally {
await runtime.shutdown();
}
});
it("keeps stable identities across reordered and exactly duplicated raw deliveries", async () => {
const deliveries: ChatSdkMessageCallbackEvent[] = [];
const runtime = createChatSdkEndpointRuntime({
callbacks: {
onMessage(event) {
deliveries.push(event);
},
},
companyId: "company-github-reorder-stress",
endpointId: "endpoint-github-reorder-stress",
logger: "silent",
persistence: memoryPersistence(),
providerConfig: {
provider: "github",
userName: "maya-paperclip",
credentials: {
botUserId: 9001,
token: "github-token-never-logged",
webhookSecret,
},
},
});
await runtime.initialize();
const newer = commentPayload({
body: "@maya-paperclip newer comment delivered first",
commentId: 5102,
number: 51,
});
const older = commentPayload({
body: "@maya-paperclip older root delivered late",
commentId: 5101,
number: 51,
});
try {
for (const request of [
signedGitHubRequest("issue_comment", newer, "github-delivery-5102"),
signedGitHubRequest("issue_comment", older, "github-delivery-5101"),
signedGitHubRequest("issue_comment", older, "github-delivery-5101"),
]) {
expect((await runtime.handleWebhook(request)).status).toBe(200);
}
expect(
deliveries.map((delivery) => ({
id: delivery.message.id,
threadId: delivery.thread.id,
})),
).toEqual([
{
id: "5102",
threadId: "github:paperclipai/chat-e2e:issue:51",
},
{
id: "5101",
threadId: "github:paperclipai/chat-e2e:issue:51",
},
{
id: "5101",
threadId: "github:paperclipai/chat-e2e:issue:51",
},
]);
} finally {
await runtime.shutdown();
}
});
});

View File

@ -0,0 +1,398 @@
import { generateKeyPairSync } from "node:crypto";
import { createGitHubAdapter } from "@chat-adapter/github";
import { afterEach, describe, expect, it, vi } from "vitest";
import { classifyChatPublicationError } from "./chat-publication-errors.js";
import {
applyGitHubReceiptReaction,
GITHUB_RECEIPT_MAX_PAGES,
GITHUB_RECEIPT_TIMEOUT_MS,
} from "./chat-github-receipt-reactions.js";
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" })
.toString();
const appId = "4853886";
const botUserId = "9001";
const own = { id: 700, content: "eyes", user: { id: Number(botUserId) } };
function fixture() {
// Actual pinned adapter and App-auth implementation; only provider HTTP is
// simulated. Its deliberately wrong cached env-style ID is not authority.
const adapter = createGitHubAdapter({
appId,
privateKey,
installationId: 123,
webhookSecret: "synthetic-webhook",
botUserId: Number(appId),
});
const calls: Array<{ method: string; path: string; page: string | null }> =
[];
let handler:
| ((
url: URL,
init: RequestInit,
) => Promise<Response> | Response | undefined)
| undefined;
const fetchImpl = vi.fn<typeof fetch>(async (input, init = {}) => {
const url = new URL(String(input));
expect(url.origin).toBe("https://api.github.com");
expect(init.redirect).toBe("error");
expect(init.signal).toBeInstanceOf(AbortSignal);
calls.push({
method: init.method ?? "GET",
path: url.pathname,
page: url.searchParams.get("page"),
});
const custom = await handler?.(url, init);
if (custom) return custom;
if (url.pathname === "/app/installations/123/access_tokens")
return Response.json(
{ token: "synthetic-installation-token" },
{ status: 201 },
);
if (url.pathname === "/app")
return Response.json({ id: Number(appId), slug: "fixture-app" });
if (url.pathname === "/users/fixture-app%5Bbot%5D")
return Response.json({
id: Number(botUserId),
login: "fixture-app[bot]",
type: "Bot",
});
if (init.method === "POST") return Response.json(own, { status: 201 });
if (init.method === "DELETE") return new Response(null, { status: 204 });
return Response.json([
{ id: 701, content: "eyes", user: { id: 9002 } },
own,
]);
});
const current = vi.fn(async () => undefined);
const invoke = (
operation: "add" | "remove" = "remove",
threadId = "github:owner/repo:issue:5",
githubReceipt?: { botUserId: string; reactionId: string | null },
) =>
applyGitHubReceiptReaction(
adapter,
appId,
123,
{
operation,
threadId,
messageId: "5603841952",
reaction: "eyes",
...(githubReceipt ? { githubReceipt } : {}),
},
current,
fetchImpl,
);
return {
adapter,
calls,
current,
invoke,
fetchImpl,
setHandler(next: typeof handler) {
handler = next;
},
};
}
afterEach(() => vi.useRealTimers());
describe("GitHub exact receipt reactions through pinned App auth", () => {
it.each(["stream_error", "oversized"])(
"preserves received HTTP backoff when the bounded body read fails (%s)",
async (kind) => {
const test = fixture();
test.setHandler((url) =>
url.pathname.endsWith("/reactions")
? new Response(
kind === "oversized"
? "x".repeat(524289)
: new ReadableStream({
pull(controller) {
controller.error(new Error("private stream detail"));
},
}),
{ status: 429, headers: { "retry-after": "10" } },
)
: undefined,
);
const error = await test.invoke().catch((error: unknown) => error);
expect(error).toMatchObject({
status: 429,
response: { headers: { "retry-after": "10" } },
});
expect(classifyChatPublicationError(error, 1)).toMatchObject({
kind: "retry",
retryAfterMs: 10000,
});
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
},
);
it.each([
"github:owner/..:issue:5",
"github:owner/repo:issue:0",
"github:owner/repo:issue:5:rc:6",
"https://elsewhere.example/owner/repo",
])("refuses invalid destinations before HTTP %s", async (thread) => {
const test = fixture();
await expect(test.invoke("remove", thread)).rejects.toThrow();
expect(test.calls).toEqual([]);
});
it.each([
[
"github:owner/repo:issue:5",
"/repos/owner/repo/issues/comments/5603841952/reactions",
],
[
"github:owner/repo:5",
"/repos/owner/repo/issues/comments/5603841952/reactions",
],
[
"github:owner/repo:5:rc:456",
"/repos/owner/repo/pulls/comments/5603841952/reactions",
],
])(
"uses the exact own-user comment route for %s",
async (threadId, route) => {
const test = fixture();
expect(await test.invoke("add", threadId)).toEqual({
botUserId,
reactionId: "700",
});
expect(
await test.invoke("remove", threadId, { botUserId, reactionId: "700" }),
).toEqual({ botUserId, reactionId: "700" });
expect(test.calls.filter((call) => call.method === "DELETE")).toEqual([
{ method: "DELETE", path: `${route}/700`, page: null },
]);
expect(test.calls.filter((call) => call.path === "/app")).toHaveLength(1);
expect(
test.calls.some((call) => call.path.includes(`/issues/5/reactions`)),
).toBe(false);
},
);
it("finds the exact own reaction on page two before deleting it", async () => {
const test = fixture();
test.setHandler((url, init) =>
init.method === "GET" && url.pathname.endsWith("/reactions")
? Response.json(
url.searchParams.get("page") === "1"
? Array.from({ length: 100 }, (_, i) => ({
id: 1000 + i,
content: "eyes",
user: { id: 2000 + i },
}))
: [own],
)
: undefined,
);
await test.invoke();
expect(
test.calls.filter((call) => call.page).map((call) => call.page),
).toEqual(["1", "2"]);
expect(test.calls.at(-1)?.path).toMatch(/\/700$/);
});
it("treats complete absence as idempotent without deleting another actor's eyes", async () => {
const test = fixture();
test.setHandler((url, init) =>
init.method === "GET" && url.pathname.endsWith("/reactions")
? Response.json([{ ...own, user: { id: 9002 } }])
: undefined,
);
expect(await test.invoke()).toEqual({ botUserId, reactionId: null });
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
});
it.each(["app", "bot_id", "bot_type", "bot_login"])(
"fails closed for unknown or wrong %s identity",
async (kind) => {
const test = fixture();
test.setHandler((url) =>
kind === "app" && url.pathname === "/app"
? Response.json({ id: 123, slug: "fixture-app" })
: url.pathname.startsWith("/users/")
? Response.json({
id: kind === "bot_id" ? null : 9001,
login:
kind === "bot_login" ? "someone-else" : "fixture-app[bot]",
type: kind === "bot_type" ? "User" : "Bot",
})
: undefined,
);
await expect(test.invoke()).rejects.toThrow("could not be confirmed");
expect(test.calls.some((call) => call.path.endsWith("/reactions"))).toBe(
false,
);
},
);
it.each([
null,
[{ ...own, id: "invalid" }],
[own, own],
[{ ...own, content: "heart" }],
])("refuses malformed/incomplete reaction pages %j", async (page) => {
const test = fixture();
test.setHandler((url, init) =>
init.method === "GET" && url.pathname.endsWith("/reactions")
? Response.json(page)
: undefined,
);
await expect(test.invoke()).rejects.toThrow("could not be confirmed");
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
});
it("refuses an exhausted pagination budget instead of claiming cleanup", async () => {
const test = fixture();
test.setHandler((url, init) =>
init.method === "GET" && url.pathname.endsWith("/reactions")
? Response.json(
Array.from({ length: 100 }, (_, i) => ({
id: Number(url.searchParams.get("page")) * 1000 + i,
content: "eyes",
user: { id: 10000 + i },
})),
)
: undefined,
);
await expect(test.invoke()).rejects.toThrow("could not be confirmed");
expect(test.calls.filter((call) => call.page)).toHaveLength(
GITHUB_RECEIPT_MAX_PAGES,
);
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
});
it.each([
{ botUserId: "9002", reactionId: "700" },
{ botUserId, reactionId: "699" },
])("does not replace durable reaction ownership %j", async (identity) => {
const test = fixture();
await expect(test.invoke("remove", undefined, identity)).rejects.toThrow(
"could not be confirmed",
);
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
});
it("checks current ownership again before delete after an otherwise complete read", async () => {
const test = fixture();
test.setHandler((url, init) => {
if (init.method === "GET" && url.pathname.endsWith("/reactions"))
test.current.mockRejectedValue(new Error("lost exact lease"));
return undefined;
});
await expect(test.invoke()).rejects.toThrow("could not be confirmed");
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
});
it.each(["token", "page", "delete"])(
"awaits local %s abort and cannot report processed success",
async (stage) => {
vi.useFakeTimers();
const test = fixture();
let started!: () => void;
const ready = new Promise<void>((resolve) => {
started = resolve;
});
let settled = false;
test.setHandler((url, init) => {
const match =
stage === "token"
? url.pathname.includes("access_tokens")
: stage === "delete"
? init.method === "DELETE"
: init.method === "GET" && url.pathname.endsWith("/reactions");
if (!match) return undefined;
started();
return new Promise((_resolve, reject) =>
init.signal!.addEventListener(
"abort",
() => {
settled = true;
reject(new Error("synthetic private provider details"));
},
{ once: true },
),
);
});
const result = test.invoke();
const rejected = expect(result).rejects.toThrow("could not be confirmed");
await ready;
await vi.advanceTimersByTimeAsync(GITHUB_RECEIPT_TIMEOUT_MS);
await rejected;
expect(settled).toBe(true);
},
);
it("redacts provider errors while retaining only HTTP retry information", async () => {
const test = fixture();
test.setHandler((url) =>
url.pathname.endsWith("/reactions")
? Response.json(
{ secret: "PRIVATE-PROVIDER-BODY" },
{ status: 429, headers: { "retry-after": "60" } },
)
: undefined,
);
await expect(test.invoke()).rejects.toMatchObject({
status: 429,
response: { headers: { "retry-after": "60" } },
message: "GitHub receipt reaction could not be confirmed (unconfirmed)",
});
});
it("cannot confirm absence if the deadline expires during the final authority read", async () => {
vi.useFakeTimers();
const test = fixture();
let entered!: () => void;
const ready = new Promise<void>((resolve) => {
entered = resolve;
});
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
test.setHandler((url, init) => {
if (init.method !== "GET" || !url.pathname.endsWith("/reactions"))
return undefined;
test.current.mockImplementationOnce(async () => {
entered();
await held;
});
return Response.json([]);
});
const result = test.invoke();
const rejection = expect(result).rejects.toThrow("could not be confirmed");
await ready;
await vi.advanceTimersByTimeAsync(GITHUB_RECEIPT_TIMEOUT_MS);
release();
await rejection;
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
});
it.each(["", "<html>rate limited</html>", "{"])(
"preserves safe HTTP backoff despite a non-JSON error body %j",
async (body) => {
const test = fixture();
test.setHandler((url) =>
url.pathname.endsWith("/reactions")
? new Response(body, {
status: 429,
headers: { "retry-after": "10" },
})
: undefined,
);
const error = await test.invoke().catch((error: unknown) => error);
expect(error).toMatchObject({
status: 429,
response: { headers: { "retry-after": "10" } },
});
expect(classifyChatPublicationError(error, 1)).toMatchObject({
kind: "retry",
retryAfterMs: 10000,
});
expect(test.calls.some((call) => call.method === "DELETE")).toBe(false);
},
);
});

View File

@ -0,0 +1,344 @@
/** Noncritical receipt work stays inside its caller's renewable credential
* lease. Incomplete reads are never evidence that a reaction was removed. */
export const GITHUB_RECEIPT_TIMEOUT_MS = 2_000;
export const GITHUB_RECEIPT_MAX_PAGES = 10;
export interface GitHubReceiptIdentity {
botUserId: string;
reactionId: string | null;
}
export interface GitHubReceiptMutation {
operation: "add" | "remove";
threadId: string;
messageId: string;
reaction: "eyes";
githubReceipt?: GitHubReceiptIdentity;
}
type Client = { auth(input: { type: "app" }): Promise<unknown> };
type Adapter = {
octokit: Client;
decodeThreadId(id: string): {
owner: string;
repo: string;
reviewCommentId?: number;
prNumber: number;
};
};
const identities = new WeakMap<object, { appId: string; botUserId: string }>();
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const numericId = (value: unknown): string | null => {
const text =
typeof value === "number" && Number.isSafeInteger(value)
? String(value)
: typeof value === "string"
? value
: "";
return /^[1-9][0-9]{0,15}$/.test(text) && Number.isSafeInteger(Number(text))
? text
: null;
};
export function parseGitHubReceiptIdentity(
value: unknown,
): GitHubReceiptIdentity | null {
const row = record(value);
const botUserId = numericId(row.botUserId);
const reactionId = numericId(row.reactionId);
return botUserId && (reactionId || row.reactionId === null)
? { botUserId, reactionId }
: null;
}
function unavailable(code = "unconfirmed") {
return Object.assign(
new Error(`GitHub receipt reaction could not be confirmed (${code})`),
{
name: "NetworkError",
code: "chat_github_receipt_unconfirmed",
},
);
}
/** Uses the pinned adapter's offline App signer and canonical thread decoder.
* Bot identity comes from that exact App's /app and
* bot-user responses, never GITHUB_BOT_USER_ID, an App ID, or caller metadata. */
export async function applyGitHubReceiptReaction(
adapterValue: unknown,
appId: string,
installationId: number,
input: GitHubReceiptMutation,
assertCurrent: () => Promise<void>,
fetchImpl: typeof globalThis.fetch = globalThis.fetch,
): Promise<GitHubReceiptIdentity> {
const adapter = adapterValue as Adapter;
if (
!adapter ||
typeof adapter.decodeThreadId !== "function" ||
!numericId(appId) ||
!numericId(installationId) ||
!numericId(input.messageId) ||
input.reaction !== "eyes" ||
!["add", "remove"].includes(input.operation) ||
!/^github:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:(?:issue:)?[1-9][0-9]*(?::rc:[1-9][0-9]*)?$/.test(
input.threadId,
)
) {
throw Object.assign(new Error("Invalid GitHub receipt destination"), {
code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED",
});
}
const destination = adapter.decodeThreadId(input.threadId);
if (
!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(destination.owner) ||
!/^[A-Za-z0-9_.-]{1,100}$/.test(destination.repo) ||
[".", ".."].includes(destination.repo) ||
!numericId(destination.prNumber) ||
(destination.reviewCommentId !== undefined &&
!numericId(destination.reviewCommentId))
) {
throw Object.assign(new Error("Invalid GitHub receipt destination"), {
code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED",
});
}
const client = adapter.octokit;
if (typeof client?.auth !== "function") throw unavailable("adapter_contract");
const expected =
input.githubReceipt === undefined
? null
: parseGitHubReceiptIdentity(input.githubReceipt);
if (input.githubReceipt !== undefined && !expected)
throw unavailable("receipt_identity");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GITHUB_RECEIPT_TIMEOUT_MS);
let appToken = "";
let installationToken = "";
const request = async (
route: string,
parameters: Record<string, unknown> = {},
) => {
controller.signal.throwIfAborted();
await assertCurrent();
controller.signal.throwIfAborted();
const [method, template] = route.split(" ");
const query: Record<string, unknown> = { ...parameters };
const path = template!.replace(/\{([a-z_]+)\}/g, (_match, key: string) => {
const value = query[key];
delete query[key];
return encodeURIComponent(String(value));
});
const url = new URL(path, "https://api.github.com");
if (method === "GET")
for (const [key, value] of Object.entries(query))
url.searchParams.set(key, String(value));
// Octokit's internal token request drops the outer AbortSignal. Sign the
// App JWT offline, but keep both token exchange and reaction HTTP here so
// timeout awaits actual fetch/body abort instead of racing local I/O.
const response = await fetchImpl(url, {
method,
redirect: "error",
signal: controller.signal,
headers: {
authorization: `Bearer ${path === "/app" || path.startsWith("/app/installations/") ? appToken : installationToken}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"content-type": "application/json",
},
...(method === "POST" ? { body: JSON.stringify(query) } : {}),
});
const httpFailure = () =>
Object.assign(unavailable("http"), {
status: response.status,
response: {
headers: { "retry-after": response.headers.get("retry-after") },
},
});
try {
const chunks: Uint8Array[] = [];
let bytes = 0;
const reader = response.body?.getReader();
if (reader)
try {
while (true) {
const next = await reader.read();
if (next.done) break;
bytes += next.value.byteLength;
if (bytes > 524_288) {
controller.abort();
await reader.cancel();
throw unavailable("response_limit");
}
chunks.push(next.value);
}
} finally {
reader.releaseLock();
}
controller.signal.throwIfAborted();
await assertCurrent();
controller.signal.throwIfAborted();
if (!response.ok) throw httpFailure();
let data: unknown = null;
if (bytes)
try {
data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw unavailable("response_body");
}
return {
status: response.status,
data,
headers: { link: response.headers.get("link") },
};
} catch (error) {
// Once headers arrive, retain only safe status/backoff even when an
// error body is malformed, truncated, oversized or locally aborted.
// A failed body read never turns a provider rejection into a fast retry.
if (!response.ok) throw httpFailure();
throw error;
}
};
try {
await assertCurrent();
const authentication = record(await client.auth({ type: "app" }));
if (
typeof authentication.token !== "string" ||
!authentication.token ||
authentication.token.length > 8192 ||
/[\r\n]/.test(authentication.token)
)
throw unavailable("app_authentication");
appToken = authentication.token;
const tokenResponse = await request(
"POST /app/installations/{installation_id}/access_tokens",
{ installation_id: installationId },
);
const installation = record(tokenResponse.data);
if (
tokenResponse.status !== 201 ||
typeof installation.token !== "string" ||
!installation.token ||
installation.token.length > 8192 ||
/[\r\n]/.test(installation.token)
)
throw unavailable("installation_authentication");
installationToken = installation.token;
let identity = identities.get(adapter as object);
if (!identity || identity.appId !== appId) {
const appResponse = await request("GET /app");
const app = record(appResponse.data);
if (
appResponse.status !== 200 ||
numericId(app.id) !== appId ||
typeof app.slug !== "string" ||
!/^[A-Za-z0-9-]{1,100}$/.test(app.slug)
)
throw unavailable("app_identity");
const login = `${app.slug}[bot]`;
const botResponse = await request("GET /users/{username}", {
username: login,
});
const bot = record(botResponse.data);
const botUserId = numericId(bot.id);
if (
botResponse.status !== 200 ||
!botUserId ||
bot.type !== "Bot" ||
bot.login !== login
)
throw unavailable("bot_identity");
identity = { appId, botUserId };
identities.set(adapter as object, identity);
}
if (expected && expected.botUserId !== identity.botUserId)
throw unavailable("owner_changed");
const route = destination.reviewCommentId
? "/repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions"
: "/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions";
const parameters = {
owner: destination.owner,
repo: destination.repo,
comment_id: Number(input.messageId),
};
if (input.operation === "add") {
const response = await request(`POST ${route}`, {
...parameters,
content: "eyes",
});
const reaction = record(response.data);
const reactionId = numericId(reaction.id);
if (
![200, 201].includes(response.status) ||
!reactionId ||
reaction.content !== "eyes" ||
numericId(record(reaction.user).id) !== identity.botUserId ||
(expected?.reactionId && expected.reactionId !== reactionId)
)
throw unavailable("add_receipt");
return { botUserId: identity.botUserId, reactionId };
}
// Fetch every bounded page before deletion. A partial page set must not
// accidentally delete a replacement or report absence as success.
let found: string | null = null;
const seen = new Set<string>();
for (let page = 1; page <= GITHUB_RECEIPT_MAX_PAGES; page++) {
const response = await request(`GET ${route}`, {
...parameters,
content: "eyes",
per_page: 100,
page,
});
if (
response.status !== 200 ||
!Array.isArray(response.data) ||
response.data.length > 100
)
throw unavailable("reaction_page");
for (const raw of response.data) {
const row = record(raw);
const id = numericId(row.id);
const user = numericId(record(row.user).id);
if (!id || !user || row.content !== "eyes" || seen.has(id))
throw unavailable("reaction_page");
seen.add(id);
if (user === identity.botUserId) {
if (found || (expected?.reactionId && expected.reactionId !== id))
throw unavailable("reaction_changed");
found = id;
}
}
const hasNext =
typeof response.headers.link === "string" &&
/rel="next"/.test(response.headers.link);
if (response.data.length === 100 || hasNext) {
if (page === GITHUB_RECEIPT_MAX_PAGES)
throw unavailable("pagination_limit");
continue;
}
if (found) {
const deleted = await request(`DELETE ${route}/{reaction_id}`, {
...parameters,
reaction_id: Number(found),
});
if (deleted.status !== 204) throw unavailable("delete_receipt");
}
return {
botUserId: identity.botUserId,
reactionId: expected?.reactionId ?? found,
};
}
throw unavailable("pagination_limit");
} catch (error) {
// Keep only status/backoff, not provider bodies, URLs or authentication.
const data = record(error);
const status = typeof data.status === "number" ? data.status : undefined;
const retryAfter = record(record(data.response).headers)["retry-after"];
throw Object.assign(unavailable(), {
...(status ? { status } : {}),
...(typeof retryAfter === "string" && /^\d{1,10}$/.test(retryAfter)
? { response: { headers: { "retry-after": retryAfter } } }
: {}),
});
} finally {
clearTimeout(timer);
}
}

View File

@ -0,0 +1,784 @@
import { createHash } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GitHubWebhookRecoveryError,
getGitHubAppWebhookDelivery,
getGitHubRecoveryComment,
listGitHubAppWebhookDeliveries,
readGitHubAppWebhookConfig,
requestGitHubAppWebhookRedelivery,
resyncGitHubAppWebhook,
} from "./chat-github-webhook-config.js";
const webhookUrl =
"https://paperclip.example:8443/api/chat-webhooks/public-id/github";
const appToken = "test-app-jwt-private";
const webhookSecret = "test-webhook-secret-private";
const config = { url: webhookUrl, content_type: "json", insecure_ssl: "0" };
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status });
function resync(fetch: typeof globalThis.fetch, url = webhookUrl) {
return resyncGitHubAppWebhook({
fetch,
appToken,
webhookSecret,
webhookUrl: url,
});
}
describe("GitHub App webhook reconnect", () => {
it.each(["0", 0])(
"reconciles only callback settings and accepts secure SSL %s",
async (ssl) => {
const fetch = vi.fn(async () =>
json({ ...config, insecure_ssl: ssl, secret: "********" }),
);
await expect(resync(fetch)).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledOnce();
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/app/hook/config",
{
method: "PATCH",
redirect: "error",
signal: expect.any(AbortSignal),
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${appToken}`,
"content-type": "application/json",
"x-github-api-version": "2022-11-28",
},
body: JSON.stringify({ ...config, secret: webhookSecret }),
},
);
},
);
it.each([302, 401, 403, 429, 500])(
"does not expose provider bodies on HTTP %s",
async (status) => {
const fetch = vi.fn(async () =>
json({ message: `${appToken} ${webhookSecret}` }, status),
);
const failure = await resync(fetch).catch(
(error: Error) => error.message,
);
expect(failure).toContain(`HTTP ${status}`);
expect(failure).not.toContain(appToken);
expect(failure).not.toContain(webhookSecret);
expect(fetch).toHaveBeenCalledOnce();
},
);
it("does not leak fetch/timeout details or automatically retry an uncertain mutation", async () => {
const fetch = vi.fn(async () => {
throw new Error(`${appToken} ${webhookSecret}`);
});
await expect(resync(fetch)).rejects.toThrow(
"configuration could not be confirmed",
);
expect(fetch).toHaveBeenCalledOnce();
});
it.each([
{ ...config, url: "https://unexpected.example/private-secret" },
{ ...config, content_type: "form" },
{ ...config, insecure_ssl: "1" },
{ ...config, insecure_ssl: false },
{},
])(
"fails closed when the applied configuration does not match",
async (value) => {
await expect(resync(async () => json(value))).rejects.toThrow(
"did not confirm the expected secure Paperclip webhook",
);
},
);
it.each(["not-json-private-secret", "[]", "null", " ".repeat(32_769)])(
"rejects unreadable/oversized successful bodies without echoing them",
async (body) => {
await expect(resync(async () => new Response(body))).rejects.toThrow(
"GitHub returned an unreadable webhook configuration",
);
},
);
it.each([
"http://paperclip.example/hook",
"https://user:password@paperclip.example/hook",
"https://paperclip.example/hook?private=secret",
"https://paperclip.example/hook#secret",
])(
"rejects unsafe callback inputs before sending credentials",
async (url) => {
const fetch = vi.fn(async () => json(config));
await expect(resync(fetch, url)).rejects.toThrow(
"configuration is incomplete",
);
expect(fetch).not.toHaveBeenCalled();
},
);
});
const deliveryId = "3841617089160847360";
const guid = "ab0f5340-abb3-11f1-9eed-2b258532ab82";
const deliveredAt = "2026-09-08T18:32:39.082Z";
const repositoryFullName = "paperclip/example";
const commentBody = "synthetic-private-comment-must-not-return";
const bodySha256 = createHash("sha256").update(commentBody).digest("hex");
const comment = {
id: "9007199254740993",
created_at: "2026-09-08T18:32:36Z",
updated_at: "2026-09-08T18:32:36Z",
body: commentBody,
user: { id: "9007199254740995", type: "User", login: "private-login" },
issue_url: "https://api.github.com/repos/paperclip/example/issues/3",
};
const delivery = {
id: deliveryId,
guid,
delivered_at: deliveredAt,
redelivery: false,
status_code: 502,
event: "issue_comment",
action: "created",
installation_id: "9007199254740997",
repository_id: "9007199254740999",
throttled_at: null,
};
const detail = {
...delivery,
url: webhookUrl,
request: {
headers: { authorization: appToken, "x-hub-signature-256": webhookSecret },
payload: {
action: "created",
installation: { id: delivery.installation_id },
repository: { id: delivery.repository_id, full_name: repositoryFullName },
issue: { id: "300", number: "3", body: "unrelated private task" },
comment,
sender: { id: comment.user.id },
},
},
response: { payload: "private proxy error" },
};
// Serialize numeric IDs as genuine JSON numeric tokens without rounding them.
function losslessJson(value: unknown, headers?: HeadersInit) {
return new Response(
JSON.stringify(value).replace(
/"(id|installation_id|repository_id|number)":"([0-9]+)"/g,
'"$1":$2',
),
{ headers },
);
}
describe("GitHub App webhook recovery HTTP boundaries", () => {
afterEach(() => vi.useRealTimers());
it("lists all statuses, retaining 64-bit IDs and only a validated next cursor", async () => {
const fetch = vi.fn(async () =>
losslessJson(
[
delivery,
{
...delivery,
id: "3841617677642645504",
status_code: 202,
redelivery: true,
},
],
{
link: '<https://api.github.com/app/hook/deliveries?per_page=100&cursor=abc%2Bdef%3D>; rel="next"',
},
),
);
const result = await listGitHubAppWebhookDeliveries({ fetch, appToken });
expect(result.deliveries.map((item) => [item.id, item.statusCode])).toEqual(
[
[deliveryId, 502],
["3841617677642645504", 202],
],
);
expect(result.deliveries[0]?.installationId).toBe(delivery.installation_id);
expect(result.nextCursor).toBe("abc+def=");
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/app/hook/deliveries?per_page=100",
expect.objectContaining({ method: "GET", redirect: "error" }),
);
});
it("projects exact detail/comment identity and hashes without returning private payloads", async () => {
const fetch = vi.fn(async () => losslessJson(detail));
const result = await getGitHubAppWebhookDelivery({
fetch,
appToken,
deliveryId,
});
expect(result).toMatchObject({
id: deliveryId,
guid,
url: webhookUrl,
payload: {
repositoryFullName,
installationId: delivery.installation_id,
comment: {
id: comment.id,
userId: comment.user.id,
bodySha256,
issueNumber: "3",
},
},
});
for (const secret of [
commentBody,
appToken,
webhookSecret,
"private-login",
"private proxy error",
"unrelated private task",
]) {
expect(JSON.stringify(result)).not.toContain(secret);
}
expect(result).not.toHaveProperty("request");
});
it("reads only closed current config fields and never returns the masked secret", async () => {
await expect(
readGitHubAppWebhookConfig({
fetch: async () => json({ ...config, secret: webhookSecret }),
appToken,
}),
).resolves.toEqual({
url: webhookUrl,
contentType: "json",
insecureSsl: "0",
});
});
it("requests the exact lossless delivery once and treats 202 only as accepted", async () => {
const fetch = vi.fn(
async () => new Response("ignored-private-response", { status: 202 }),
);
await expect(
requestGitHubAppWebhookRedelivery({ fetch, appToken, deliveryId }),
).resolves.toEqual({ accepted: true });
expect(fetch).toHaveBeenCalledOnce();
expect(fetch).toHaveBeenCalledWith(
`https://api.github.com/app/hook/deliveries/${deliveryId}/attempts`,
expect.objectContaining({ method: "POST", redirect: "error" }),
);
});
it("does not retry an uncertain POST or expose its error/cause", async () => {
const fetch = vi.fn(async () => {
throw new Error(`${appToken} ${webhookSecret}`);
});
const failure = await requestGitHubAppWebhookRedelivery({
fetch,
appToken,
deliveryId,
}).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(GitHubWebhookRecoveryError);
expect(failure).toMatchObject({
code: "github_webhook_recovery_transport",
requestMayHaveBeenAccepted: true,
});
expect(String(failure)).not.toContain(appToken);
expect(failure).not.toHaveProperty("cause");
expect(fetch).toHaveBeenCalledOnce();
});
it("gets the current exact issue comment using only installation authority", async () => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(json({ token: "installation-test-token" }, 201))
.mockResolvedValueOnce(losslessJson(comment))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const result = await getGitHubRecoveryComment({
fetch,
appToken,
installationId: delivery.installation_id,
repositoryFullName,
event: "issue_comment",
commentId: comment.id,
});
expect(result).toMatchObject({
id: comment.id,
userId: comment.user.id,
bodySha256,
issueNumber: "3",
});
expect(fetch).toHaveBeenCalledWith(
`https://api.github.com/repos/paperclip/example/issues/comments/${comment.id}`,
expect.objectContaining({
method: "GET",
redirect: "error",
headers: expect.objectContaining({
authorization: "Bearer installation-test-token",
}),
}),
);
expect(JSON.stringify(result)).not.toContain(commentBody);
expect(fetch).toHaveBeenCalledTimes(3);
expect(fetch.mock.calls[0]).toEqual([
`https://api.github.com/app/installations/${delivery.installation_id}/access_tokens`,
expect.objectContaining({
method: "POST",
redirect: "error",
body: JSON.stringify({
repositories: ["example"],
permissions: { issues: "read", pull_requests: "read" },
}),
headers: expect.objectContaining({
authorization: `Bearer ${appToken}`,
}),
}),
]);
expect(fetch.mock.calls[2]).toEqual([
"https://api.github.com/installation/token",
expect.objectContaining({
method: "DELETE",
redirect: "error",
headers: expect.objectContaining({
authorization: "Bearer installation-test-token",
}),
}),
]);
});
it.each([
"0",
"-1",
"01",
"1.2",
"1e9",
"18446744073709551616",
"123/attempts",
3841617089160847360,
])("rejects unsafe identifier %s before network access", async (id) => {
const fetch = vi.fn<typeof globalThis.fetch>();
await expect(
requestGitHubAppWebhookRedelivery({
fetch,
appToken,
deliveryId: id as string,
}),
).rejects.toMatchObject({ code: "github_webhook_recovery_invalid_input" });
expect(fetch).not.toHaveBeenCalled();
});
it.each(["1e18", "1.5", "-1", "18446744073709551616"])(
"rejects noncanonical or out-of-range JSON ID token %s",
async (id) => {
const raw = JSON.stringify(delivery).replace(
`"id":"${deliveryId}"`,
`"id":${id}`,
);
await expect(
listGitHubAppWebhookDeliveries({
fetch: async () => new Response(`[${raw}]`),
appToken,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
},
);
it.each([
"https://evil.example/app/hook/deliveries?cursor=abc",
"https://api.github.com.evil.example/app/hook/deliveries?cursor=abc",
"https://user:password@api.github.com/app/hook/deliveries?cursor=abc",
"https://api.github.com/repos/private?cursor=abc",
"https://api.github.com/app/hook/deliveries?cursor=abc&token=secret",
"https://api.github.com/app/hook/deliveries?cursor=abc&cursor=def",
"https://api.github.com/app/hook/deliveries?cursor=abc#secret",
])(
"rejects untrusted pagination destination %s without another request",
async (url) => {
const fetch = vi.fn(async () =>
losslessJson([delivery], { link: `<${url}>; rel="next"` }),
);
await expect(
listGitHubAppWebhookDeliveries({ fetch, appToken }),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
expect(fetch).toHaveBeenCalledOnce();
},
);
it("round-trips opaque validated cursors only as encoded query values", async () => {
const fetch = vi.fn(async () => json([]));
await listGitHubAppWebhookDeliveries({
fetch,
appToken,
cursor: "v1:abc+def/==",
});
expect(fetch.mock.calls[0]).toEqual([
"https://api.github.com/app/hook/deliveries?per_page=100&cursor=v1%3Aabc%2Bdef%2F%3D%3D",
expect.any(Object),
]);
await expect(
listGitHubAppWebhookDeliveries({
fetch,
appToken,
cursor: "abc&token=private",
}),
).rejects.toMatchObject({ code: "github_webhook_recovery_invalid_input" });
expect(fetch).toHaveBeenCalledOnce();
});
it.each([101, 1000])(
"rejects %s deliveries rather than silently truncating scan evidence",
async (count) => {
await expect(
listGitHubAppWebhookDeliveries({
fetch: async () =>
losslessJson(Array.from({ length: count }, () => delivery)),
appToken,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
},
);
it.each(["invalid-private-json", "null", "{}", " ".repeat(262_145)])(
"rejects malformed or oversized metadata without echoing it",
async (body) => {
const failure = await listGitHubAppWebhookDeliveries({
fetch: async () => new Response(body),
appToken,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
expect(String(failure)).not.toContain("invalid-private-json");
},
);
it("bounds streamed detail bytes even without Content-Length and cancels the reader", async () => {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(1_048_577));
},
cancel,
});
await expect(
getGitHubAppWebhookDelivery({
fetch: async () => new Response(stream),
appToken,
deliveryId,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
expect(cancel).toHaveBeenCalledOnce();
});
it("rejects a changed exact delivery or credential-bearing callback", async () => {
for (const value of [
{ ...detail, id: "3841617089160847361" },
{ ...detail, url: `${webhookUrl}?secret=private` },
]) {
await expect(
getGitHubAppWebhookDelivery({
fetch: async () => losslessJson(value),
appToken,
deliveryId,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
}
});
it.each([302, 401, 403, 404, 422, 429, 500, 502])(
"returns only closed HTTP %s metadata and never follows/retries",
async (status) => {
const fetch = vi.fn(
async () =>
new Response(`${appToken}:${webhookSecret}`, {
status,
headers: { location: "https://evil.example", "retry-after": "60" },
}),
);
const failure = await requestGitHubAppWebhookRedelivery({
fetch,
appToken,
deliveryId,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "github_webhook_recovery_http",
statusCode: status,
retryAfterMs: 60_000,
requestMayHaveBeenAccepted: status >= 500,
});
expect(String(failure)).not.toContain(appToken);
expect(fetch).toHaveBeenCalledOnce();
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: "error" }),
);
},
);
it("honors closed rate-limit reset metadata without returning header text", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-08T18:00:00Z"));
const response = new Response("private", {
status: 403,
headers: {
"retry-after": "Tue, 08 Sep 2026 18:00:30 GMT",
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": String(Date.now() / 1000 + 90),
},
});
await expect(
listGitHubAppWebhookDeliveries({ fetch: async () => response, appToken }),
).rejects.toMatchObject({ retryAfterMs: 90_000 });
});
it.each(["fetch", "body"])(
"enforces one bounded deadline for a stalled %s",
async (stage) => {
vi.useFakeTimers();
const fetch = vi.fn<typeof globalThis.fetch>(async () =>
stage === "fetch"
? await new Promise<Response>(() => undefined)
: new Response(new ReadableStream<Uint8Array>({ start() {} })),
);
const settled = listGitHubAppWebhookDeliveries({ fetch, appToken }).catch(
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(25_000);
expect(await settled).toMatchObject({
code: "github_webhook_recovery_transport",
requestMayHaveBeenAccepted: false,
});
expect(fetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
},
);
it.each([404, 403])(
"revokes the narrowed token after a current-comment HTTP %s failure",
async (status) => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(json({ token: "installation-test-token" }, 201))
.mockResolvedValueOnce(new Response("private denied", { status }))
.mockRejectedValueOnce(new Error("private cleanup failure"));
await expect(
getGitHubRecoveryComment({
fetch,
appToken,
installationId: delivery.installation_id,
repositoryFullName,
event: "issue_comment",
commentId: comment.id,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_http",
statusCode: status,
});
expect(fetch).toHaveBeenCalledTimes(3);
expect(fetch.mock.calls[2]?.[0]).toBe(
"https://api.github.com/installation/token",
);
},
);
it("uses the pull-comment route, validates its target, and revokes before returning", async () => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(json({ token: "installation-test-token" }, 201))
.mockResolvedValueOnce(
losslessJson({
...comment,
issue_url: undefined,
pull_request_url:
"https://api.github.com/repos/paperclip/example/pulls/4",
}),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(
getGitHubRecoveryComment({
fetch,
appToken,
installationId: delivery.installation_id,
repositoryFullName,
event: "pull_request_review_comment",
commentId: comment.id,
}),
).resolves.toMatchObject({ issueNumber: null, pullRequestNumber: "4" });
expect(fetch.mock.calls[1]?.[0]).toBe(
`https://api.github.com/repos/paperclip/example/pulls/comments/${comment.id}`,
);
expect(fetch).toHaveBeenCalledTimes(3);
});
it.each([
{ ...comment, id: "9007199254740994" },
{
...comment,
issue_url: "https://api.github.com/repos/other/repository/issues/3",
},
{
...comment,
issue_url: "https://evil.example/repos/paperclip/example/issues/3",
},
{ ...comment, issue_url: `${comment.issue_url}?secret=private` },
{ ...comment, body: null },
])(
"denies changed current comment identity/body/target and still revokes",
async (value) => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(json({ token: "installation-test-token" }, 201))
.mockResolvedValueOnce(losslessJson(value))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(
getGitHubRecoveryComment({
fetch,
appToken,
installationId: delivery.installation_id,
repositoryFullName,
event: "issue_comment",
commentId: comment.id,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
expect(fetch).toHaveBeenCalledTimes(3);
},
);
it.each([
"owner/../private",
"owner/repo?secret=private",
"https://evil.example/repo",
"owner/%2e%2e",
])("rejects unsafe repository %s before minting authority", async (name) => {
const fetch = vi.fn<typeof globalThis.fetch>();
await expect(
getGitHubRecoveryComment({
fetch,
appToken,
installationId: delivery.installation_id,
repositoryFullName: name,
event: "issue_comment",
commentId: comment.id,
}),
).rejects.toMatchObject({ code: "github_webhook_recovery_invalid_input" });
expect(fetch).not.toHaveBeenCalled();
});
it("fails closed if the runtime does not provide lossless numeric source text", async () => {
const originalParse = JSON.parse;
const response = losslessJson([delivery]);
const parse = vi
.spyOn(JSON, "parse")
.mockImplementation((text, reviver) =>
originalParse(
text,
reviver ? (key, value) => reviver(key, value) : undefined,
),
);
try {
await expect(
listGitHubAppWebhookDeliveries({
fetch: async () => response,
appToken,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
} finally {
parse.mockRestore();
}
});
it("rejects invalid calendar dates instead of silently rolling the scan frontier forward", async () => {
await expect(
listGitHubAppWebhookDeliveries({
fetch: async () =>
losslessJson([{ ...delivery, delivered_at: "2026-02-30T18:00:00Z" }]),
appToken,
}),
).rejects.toMatchObject({
code: "github_webhook_recovery_invalid_response",
});
});
it("cancels a timed-out body reader as well as its fetch signal", async () => {
vi.useFakeTimers();
const cancel = vi.fn();
const response = new Response(
new ReadableStream<Uint8Array>({ start() {}, cancel }),
);
const settled = listGitHubAppWebhookDeliveries({
fetch: async () => response,
appToken,
}).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(25_000);
expect(await settled).toMatchObject({
code: "github_webhook_recovery_transport",
});
expect(cancel).toHaveBeenCalledOnce();
});
it("revokes after a timed-out comment read without masking its original failure", async () => {
vi.useFakeTimers();
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(json({ token: "installation-test-token" }, 201))
.mockImplementationOnce(
async () => await new Promise<Response>(() => undefined),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const settled = getGitHubRecoveryComment({
fetch,
appToken,
installationId: delivery.installation_id,
repositoryFullName,
event: "issue_comment",
commentId: comment.id,
}).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(10_000);
expect(await settled).toMatchObject({
code: "github_webhook_recovery_transport",
requestMayHaveBeenAccepted: false,
});
expect(fetch).toHaveBeenCalledTimes(3);
expect(fetch.mock.calls[2]?.[0]).toBe(
"https://api.github.com/installation/token",
);
});
it("bounds best-effort token revocation without exposing or changing a successful comment read", async () => {
vi.useFakeTimers();
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(json({ token: "installation-test-token" }, 201))
.mockResolvedValueOnce(losslessJson(comment))
.mockImplementationOnce(
async () => await new Promise<Response>(() => undefined),
);
const settled = getGitHubRecoveryComment({
fetch,
appToken,
installationId: delivery.installation_id,
repositoryFullName,
event: "issue_comment",
commentId: comment.id,
});
await vi.advanceTimersByTimeAsync(2_000);
expect(await settled).toMatchObject({ id: comment.id, bodySha256 });
expect(fetch.mock.calls[2]?.[1]?.signal?.aborted).toBe(true);
expect(fetch).toHaveBeenCalledTimes(3);
});
});

View File

@ -0,0 +1,780 @@
import { createHash } from "node:crypto";
const GITHUB_APP_WEBHOOK_CONFIG_URL = "https://api.github.com/app/hook/config";
const MAX_CONFIG_RESPONSE_BYTES = 32_768;
/** Decimal strings, including for identifiers beyond Number.MAX_SAFE_INTEGER. */
export type GitHubWebhookDeliveryId = string;
export interface GitHubAppWebhookDelivery {
id: GitHubWebhookDeliveryId;
guid: string;
deliveredAt: string;
redelivery: boolean;
statusCode: number | null;
event: string;
action: string | null;
installationId: string | null;
repositoryId: string | null;
throttledAt: string | null;
}
export interface GitHubRecoveryComment {
id: string;
createdAt: string;
updatedAt: string;
userId: string;
userType: "User" | "Bot" | "Organization" | "Mannequin";
bodySha256: string;
issueNumber: string | null;
pullRequestNumber: string | null;
}
export interface GitHubAppWebhookDeliveryDetail extends GitHubAppWebhookDelivery {
url: string;
payload: {
action: string | null;
installationId: string | null;
repositoryId: string | null;
repositoryFullName: string | null;
issueId: string | null;
issueNumber: string | null;
pullRequestId: string | null;
pullRequestNumber: string | null;
comment: GitHubRecoveryComment | null;
senderId: string | null;
};
}
export interface GitHubAppWebhookConfig {
url: string;
contentType: "json" | "form";
insecureSsl: "0" | "1";
}
type RecoveryErrorCode =
| "github_webhook_recovery_invalid_input"
| "github_webhook_recovery_transport"
| "github_webhook_recovery_http"
| "github_webhook_recovery_invalid_response";
/** Closed errors only: never retain a fetch cause, response, token, or payload. */
export class GitHubWebhookRecoveryError extends Error {
constructor(
readonly code: RecoveryErrorCode,
readonly statusCode: number | null = null,
readonly retryAfterMs: number | null = null,
readonly requestMayHaveBeenAccepted = false,
) {
super(
`GitHub webhook recovery could not be confirmed (${code}${statusCode === null ? "" : `; HTTP ${statusCode}`}).`,
);
this.name = "GitHubWebhookRecoveryError";
}
}
type AppRequest = { fetch: typeof globalThis.fetch; appToken: string };
const GITHUB_DELIVERIES_URL = "https://api.github.com/app/hook/deliveries";
const MAX_DELIVERIES_BYTES = 262_144;
const MAX_DETAIL_BYTES = 1_048_576;
const MAX_RETRY_DELAY_MS = 86_400_000;
const ID_KEYS = new Set([
"id",
"installation_id",
"repository_id",
"number",
"in_reply_to_id",
]);
function invalidResponse(): never {
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_invalid_response",
);
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
invalidResponse();
return value as Record<string, unknown>;
}
function decimalId(value: unknown): string {
if (
typeof value !== "string" ||
!/^[1-9][0-9]{0,19}$/.test(value) ||
(value.length === 20 && value > "18446744073709551615")
)
invalidResponse();
return value;
}
function inputId(value: unknown): string {
try {
return decimalId(value);
} catch {
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_invalid_input",
);
}
}
function optionalId(value: unknown): string | null {
return value === undefined || value === null ? null : decimalId(value);
}
function timestamp(value: unknown): string {
if (
typeof value !== "string" ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(value) ||
!Number.isFinite(Date.parse(value))
)
invalidResponse();
const normalized = value.replace(
/(?:\.(\d{1,3}))?Z$/,
(_, fraction: string | undefined) => `.${(fraction ?? "").padEnd(3, "0")}Z`,
);
if (new Date(value).toISOString() !== normalized) invalidResponse();
return value;
}
function optionalTimestamp(value: unknown): string | null {
return value === undefined || value === null ? null : timestamp(value);
}
function eventName(value: unknown): string {
if (typeof value !== "string" || !/^[a-z][a-z_]{0,63}$/.test(value))
invalidResponse();
return value;
}
function optionalEventName(value: unknown): string | null {
return value === undefined || value === null ? null : eventName(value);
}
function callbackUrl(value: unknown): string {
if (typeof value !== "string" || value.length > 2048) invalidResponse();
let url: URL;
try {
url = new URL(value);
} catch {
return invalidResponse();
}
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
/[\s\\]/.test(value)
)
invalidResponse();
return value;
}
function repositoryName(value: unknown): string {
if (
typeof value !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/.test(value) ||
value.endsWith("/.") ||
value.endsWith("/..")
)
invalidResponse();
return value;
}
function cursorValue(value: unknown): string {
if (typeof value !== "string" || !/^[A-Za-z0-9+/_=:-]{1,512}$/.test(value))
invalidResponse();
return value;
}
function nextCursor(link: string | null): string | null {
if (!link) return null;
if (link.length > 8192) invalidResponse();
let next: string | null = null;
for (const entry of link.split(",")) {
const match = /^\s*<([^<>]+)>\s*;\s*rel="(next|prev|first|last)"\s*$/.exec(
entry,
);
if (!match) invalidResponse();
let url: URL;
try {
url = new URL(match[1]!);
} catch {
return invalidResponse();
}
if (
url.origin !== "https://api.github.com" ||
url.pathname !== "/app/hook/deliveries" ||
url.username ||
url.password ||
url.hash ||
[...url.searchParams.keys()].some(
(key) => key !== "per_page" && key !== "cursor",
) ||
url.searchParams.getAll("cursor").length !== 1 ||
url.searchParams.getAll("per_page").length > 1 ||
(url.searchParams.has("per_page") &&
url.searchParams.get("per_page") !== "100")
)
invalidResponse();
const cursor = cursorValue(url.searchParams.get("cursor"));
if (match[2] === "next") {
if (next !== null) invalidResponse();
next = cursor;
}
}
return next;
}
function retryDelay(headers: Headers): number | null {
const retry = headers.get("retry-after");
let delay: number | null = null;
if (retry && /^\d{1,10}$/.test(retry)) delay = Number(retry) * 1000;
else if (
retry &&
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(
retry,
) &&
Number.isFinite(Date.parse(retry))
) {
delay = Math.max(0, Date.parse(retry) - Date.now());
}
const reset = headers.get("x-ratelimit-reset");
if (
headers.get("x-ratelimit-remaining") === "0" &&
reset &&
/^\d{1,12}$/.test(reset)
) {
delay = Math.max(delay ?? 0, Number(reset) * 1000 - Date.now(), 0);
}
return delay === null ? null : Math.min(MAX_RETRY_DELAY_MS, delay);
}
async function readLosslessJson(
response: Response,
maxBytes: number,
signal: AbortSignal,
): Promise<unknown> {
const length = response.headers.get("content-length");
if (length && (!/^\d{1,12}$/.test(length) || Number(length) > maxBytes)) {
await response.body?.cancel().catch(() => undefined);
invalidResponse();
}
const reader = response.body?.getReader();
if (!reader) invalidResponse();
const abort = () => {
void reader.cancel().catch(() => undefined);
};
signal.addEventListener("abort", abort, { once: true });
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
size += chunk.value.byteLength;
if (size > maxBytes) invalidResponse();
chunks.push(chunk.value);
}
// Node >=24.11 (the repository's supported runtime) supplies context.source.
// Never recover an unsafe ID from the already rounded numeric value.
return JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)),
(key: string, value: unknown, context?: { source?: string }) => {
if (ID_KEYS.has(key) && typeof value === "number") {
return decimalId(context?.source);
}
return value;
},
);
} catch {
await reader.cancel().catch(() => undefined);
return invalidResponse();
} finally {
signal.removeEventListener("abort", abort);
reader.releaseLock();
}
}
async function recoveryRequest<T>(input: {
fetch: typeof globalThis.fetch;
token: string;
path: string;
method?: "GET" | "POST" | "DELETE";
body?: string;
expectedStatus?: number;
timeoutMs?: number;
uncertainMutation?: boolean;
project: (response: Response, signal: AbortSignal) => Promise<T>;
}): Promise<T> {
if (
!input.token ||
input.token.length > 8192 ||
/[^\x21-\x7e]/.test(input.token)
) {
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_invalid_input",
);
}
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(
new GitHubWebhookRecoveryError(
"github_webhook_recovery_transport",
null,
null,
input.uncertainMutation === true,
),
);
}, input.timeoutMs ?? 25_000);
});
try {
return await Promise.race([
timeout,
(async () => {
const response = await input.fetch(
`https://api.github.com${input.path}`,
{
method: input.method ?? "GET",
redirect: "error",
signal: controller.signal,
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${input.token}`,
"x-github-api-version": "2022-11-28",
...(input.body ? { "content-type": "application/json" } : {}),
},
...(input.body ? { body: input.body } : {}),
},
);
if (controller.signal.aborted) {
await response.body?.cancel().catch(() => undefined);
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_transport",
null,
null,
input.uncertainMutation === true,
);
}
if (response.status !== (input.expectedStatus ?? 200)) {
await response.body?.cancel().catch(() => undefined);
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_http",
response.status,
retryDelay(response.headers),
input.uncertainMutation === true &&
(response.status >= 500 || response.status === 408),
);
}
return input.project(response, controller.signal);
})(),
]);
} catch (error) {
if (error instanceof GitHubWebhookRecoveryError) throw error;
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_transport",
null,
null,
input.uncertainMutation === true,
);
} finally {
clearTimeout(timer);
}
}
function deliveryMetadata(value: unknown): GitHubAppWebhookDelivery {
const row = record(value);
if (
typeof row.guid !== "string" ||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(
row.guid,
) ||
typeof row.redelivery !== "boolean" ||
(row.status_code !== null &&
(!Number.isInteger(row.status_code) ||
typeof row.status_code !== "number" ||
(row.status_code !== 0 &&
(row.status_code < 100 || row.status_code > 599))))
)
invalidResponse();
return {
id: decimalId(row.id),
guid: row.guid.toLowerCase(),
deliveredAt: timestamp(row.delivered_at),
redelivery: row.redelivery,
statusCode: row.status_code as number | null,
event: eventName(row.event),
action: optionalEventName(row.action),
installationId: optionalId(row.installation_id),
repositoryId: optionalId(row.repository_id),
throttledAt: optionalTimestamp(row.throttled_at),
};
}
function commentNumber(
value: unknown,
fullName: string,
kind: "issues" | "pulls",
): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== "string" || value.length > 2048) invalidResponse();
let url: URL;
try {
url = new URL(value);
} catch {
return invalidResponse();
}
const prefix = `/repos/${fullName}/${kind}/`;
if (
url.origin !== "https://api.github.com" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!url.pathname.toLowerCase().startsWith(prefix.toLowerCase())
)
invalidResponse();
return decimalId(url.pathname.slice(prefix.length));
}
function commentMetadata(
value: unknown,
fullName: string,
): GitHubRecoveryComment {
const row = record(value);
const user = record(row.user);
if (
typeof row.body !== "string" ||
typeof user.type !== "string" ||
!["User", "Bot", "Organization", "Mannequin"].includes(user.type)
)
invalidResponse();
return {
id: decimalId(row.id),
createdAt: timestamp(row.created_at),
updatedAt: timestamp(row.updated_at),
userId: decimalId(user.id),
userType: user.type as GitHubRecoveryComment["userType"],
bodySha256: createHash("sha256").update(row.body).digest("hex"),
issueNumber: commentNumber(row.issue_url, fullName, "issues"),
pullRequestNumber: commentNumber(row.pull_request_url, fullName, "pulls"),
};
}
/** App JWT only. See https://docs.github.com/en/rest/apps/webhooks. No status
* filter: successful sibling attempts must remain visible to the recovery gate. */
export async function listGitHubAppWebhookDeliveries(
input: AppRequest & { cursor?: string | null },
): Promise<{
deliveries: GitHubAppWebhookDelivery[];
nextCursor: string | null;
}> {
let cursor: string | null = null;
if (input.cursor !== undefined && input.cursor !== null) {
try {
cursor = cursorValue(input.cursor);
} catch {
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_invalid_input",
);
}
}
const url = new URL(GITHUB_DELIVERIES_URL);
url.searchParams.set("per_page", "100");
if (cursor !== null) url.searchParams.set("cursor", cursor);
return recoveryRequest({
...input,
token: input.appToken,
path: `${url.pathname}${url.search}`,
project: async (response, signal) => {
const parsed = await readLosslessJson(
response,
MAX_DELIVERIES_BYTES,
signal,
);
if (!Array.isArray(parsed) || parsed.length > 100) invalidResponse();
return {
deliveries: parsed.map(deliveryMetadata),
nextCursor: nextCursor(response.headers.get("link")),
};
},
});
}
export async function readGitHubAppWebhookConfig(
input: AppRequest,
): Promise<GitHubAppWebhookConfig> {
return recoveryRequest({
...input,
token: input.appToken,
path: "/app/hook/config",
project: async (response, signal) => {
const config = record(
await readLosslessJson(response, MAX_CONFIG_RESPONSE_BYTES, signal),
);
if (
(config.content_type !== "json" && config.content_type !== "form") ||
!["0", "1", 0, 1].includes(config.insecure_ssl as string | number)
)
invalidResponse();
return {
url: callbackUrl(config.url),
contentType: config.content_type,
insecureSsl: String(config.insecure_ssl) as "0" | "1",
};
},
});
}
export async function getGitHubAppWebhookDelivery(
input: AppRequest & { deliveryId: string },
): Promise<GitHubAppWebhookDeliveryDetail> {
const id = inputId(input.deliveryId);
return recoveryRequest({
...input,
token: input.appToken,
path: `/app/hook/deliveries/${id}`,
project: async (response, signal) => {
const row = record(
await readLosslessJson(response, MAX_DETAIL_BYTES, signal),
);
const metadata = deliveryMetadata(row);
if (metadata.id !== id) invalidResponse();
const payload = record(record(row.request).payload);
const nestedId = (key: string) =>
payload[key] == null ? null : optionalId(record(payload[key]).id);
const nestedNumber = (key: string) =>
payload[key] == null ? null : optionalId(record(payload[key]).number);
const fullName =
payload.repository == null
? null
: repositoryName(record(payload.repository).full_name);
if (payload.comment != null && fullName === null) invalidResponse();
return {
...metadata,
url: callbackUrl(row.url),
payload: {
action: optionalEventName(payload.action),
installationId: nestedId("installation"),
repositoryId: nestedId("repository"),
repositoryFullName: fullName,
issueId: nestedId("issue"),
issueNumber: nestedNumber("issue"),
pullRequestId: nestedId("pull_request"),
pullRequestNumber: nestedNumber("pull_request"),
comment:
payload.comment == null
? null
: commentMetadata(payload.comment, fullName!),
senderId: nestedId("sender"),
},
};
},
});
}
/** Never retry here: a transport error can follow an accepted remote mutation. */
export async function requestGitHubAppWebhookRedelivery(
input: AppRequest & { deliveryId: string },
): Promise<{ accepted: true }> {
const id = inputId(input.deliveryId);
return recoveryRequest({
...input,
token: input.appToken,
path: `/app/hook/deliveries/${id}/attempts`,
method: "POST",
expectedStatus: 202,
uncertainMutation: true,
project: async (response) => {
await response.body?.cancel().catch(() => undefined);
return { accepted: true };
},
});
}
/** Mint a read-only, single-repository token, inspect one exact comment, then
* revoke. See the official Apps token and Issues/Pulls comment REST endpoints.
* No installation token or comment text crosses this helper's return boundary. */
export async function getGitHubRecoveryComment(
input: AppRequest & {
installationId: string;
repositoryFullName: string;
event: "issue_comment" | "pull_request_review_comment";
commentId: string;
},
): Promise<GitHubRecoveryComment> {
const id = inputId(input.commentId);
const installationId = inputId(input.installationId);
let fullName: string;
try {
fullName = repositoryName(input.repositoryFullName);
} catch {
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_invalid_input",
);
}
if (
input.event !== "issue_comment" &&
input.event !== "pull_request_review_comment"
) {
throw new GitHubWebhookRecoveryError(
"github_webhook_recovery_invalid_input",
);
}
const token = await recoveryRequest({
...input,
token: input.appToken,
path: `/app/installations/${installationId}/access_tokens`,
method: "POST",
expectedStatus: 201,
timeoutMs: 10_000,
body: JSON.stringify({
repositories: [fullName.split("/")[1]],
permissions: { issues: "read", pull_requests: "read" },
}),
project: async (response, signal) => {
const row = record(await readLosslessJson(response, 16_384, signal));
if (
typeof row.token !== "string" ||
!/^[\x21-\x7e]{1,8192}$/.test(row.token)
)
invalidResponse();
return row.token;
},
});
try {
const kind = input.event === "issue_comment" ? "issues" : "pulls";
return await recoveryRequest({
...input,
token,
path: `/repos/${fullName}/${kind}/comments/${id}`,
timeoutMs: 10_000,
project: async (response, signal) => {
const comment = commentMetadata(
await readLosslessJson(response, MAX_DETAIL_BYTES, signal),
fullName,
);
if (
comment.id !== id ||
(kind === "issues"
? comment.issueNumber
: comment.pullRequestNumber) === null
)
invalidResponse();
return comment;
},
});
} finally {
await recoveryRequest({
...input,
token,
path: "/installation/token",
method: "DELETE",
expectedStatus: 204,
timeoutMs: 2_000,
project: async (response) => {
await response.body?.cancel().catch(() => undefined);
},
}).catch(() => undefined);
}
}
/**
* Reconcile an already-owned App's callback, not its installation or permissions.
* A successful PATCH is configuration evidence only, never a signed ping or a
* successful chat round trip. See https://docs.github.com/en/rest/apps/webhooks.
*/
export async function resyncGitHubAppWebhook(input: {
fetch: typeof globalThis.fetch;
appToken: string;
webhookUrl: string;
webhookSecret: string;
}): Promise<void> {
const webhookUrl = new URL(input.webhookUrl);
if (
webhookUrl.protocol !== "https:" ||
webhookUrl.username ||
webhookUrl.password ||
webhookUrl.search ||
webhookUrl.hash ||
!input.webhookSecret
) {
throw new Error("GitHub webhook configuration is incomplete");
}
let response: Response;
try {
response = await input.fetch(GITHUB_APP_WEBHOOK_CONFIG_URL, {
method: "PATCH",
redirect: "error",
signal: AbortSignal.timeout(25_000),
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${input.appToken}`,
"content-type": "application/json",
"x-github-api-version": "2022-11-28",
},
body: JSON.stringify({
url: input.webhookUrl,
content_type: "json",
insecure_ssl: "0",
secret: input.webhookSecret,
}),
});
} catch {
// A fetch error can embed request bodies, headers, or a proxy response.
// Keep it out of endpoint health, the audit log, and the board response.
throw new Error(
"GitHub webhook configuration could not be confirmed. Reconnect to retry; repository access was not changed.",
);
}
if (response.status !== 200) {
await response.body?.cancel().catch(() => undefined);
throw new Error(
`GitHub could not update this App's webhook (HTTP ${response.status}). Check that the App is active and reconnect.`,
);
}
// GitHub can echo a masked secret and provider error bodies are untrusted.
// Read a bounded response and return no provider body to callers or logs.
let config: Record<string, unknown>;
const reader = response.body?.getReader();
try {
if (!reader) throw new Error("Missing webhook configuration response");
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
size += chunk.value.byteLength;
if (size > MAX_CONFIG_RESPONSE_BYTES) {
throw new Error("Oversized webhook configuration response");
}
chunks.push(chunk.value);
}
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid webhook configuration response");
}
config = parsed as Record<string, unknown>;
} catch {
await reader?.cancel().catch(() => undefined);
throw new Error(
"GitHub returned an unreadable webhook configuration. Reconnect to confirm the callback settings.",
);
} finally {
reader?.releaseLock();
}
if (
config.url !== input.webhookUrl ||
config.content_type !== "json" ||
(config.insecure_ssl !== "0" && config.insecure_ssl !== 0)
) {
throw new Error(
"GitHub did not confirm the expected secure Paperclip webhook. Reconnect to retry.",
);
}
}

View File

@ -0,0 +1,318 @@
import { describe, expect, it, vi } from "vitest";
import {
discoverDedicatedGitHubAppInstallation,
getSlackBotChannel,
listGitHubInstallationRepositories,
listSlackBotChannels,
} from "./chat-provider-inventory.js";
function response(value: unknown, status = 200) {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}
describe("chat provider inventory", () => {
it("paginates Slack and returns only non-archived bot memberships", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
response({
ok: true,
channels: [
{ id: "C1", name: "agents", is_member: true },
{ id: "C2", name: "not-invited", is_member: false },
{ id: "C3", name: "archived", is_member: true, is_archived: true },
],
response_metadata: { next_cursor: "next" },
}),
)
.mockResolvedValueOnce(
response({
ok: true,
channels: [
{
id: "G1",
name: "private-agents",
is_member: true,
is_private: true,
},
],
response_metadata: { next_cursor: "" },
}),
) as unknown as typeof globalThis.fetch;
const result = await listSlackBotChannels({
botToken: "xoxb-secret",
fetch,
});
expect(result.resources).toEqual([
expect.objectContaining({ providerResourceId: "C1", label: "#agents" }),
expect.objectContaining({
providerResourceId: "G1",
label: "#private-agents",
metadata: expect.objectContaining({ private: true }),
}),
]);
expect(fetch).toHaveBeenCalledTimes(2);
for (const call of (fetch as unknown as ReturnType<typeof vi.fn>).mock
.calls) {
const request = call[1] as RequestInit | undefined;
expect(request?.signal).toBeInstanceOf(AbortSignal);
expect(request?.signal?.aborted).toBe(false);
}
const secondUrl = String(
(fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[1]?.[0],
);
expect(secondUrl).toContain("cursor=next");
});
it("resolves one newly joined Slack channel without exposing the bot token", async () => {
const fetch = vi.fn(async () =>
response({
ok: true,
channel: {
id: "C-NEW",
name: "new-agent-work",
is_member: true,
is_private: true,
context_team_id: "T-ENTERPRISE",
},
}),
) as unknown as typeof globalThis.fetch;
const result = await getSlackBotChannel({
botToken: "xoxb-secret",
channelId: "C-NEW",
fetch,
});
expect(result).toEqual({
providerResourceId: "C-NEW",
type: "channel",
label: "#new-agent-work",
metadata: {
private: true,
contextTeamId: "T-ENTERPRISE",
source: "provider_inventory",
},
});
expect(JSON.stringify(result)).not.toContain("xoxb-secret");
expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({
pathname: "/api/conversations.info",
search: "?channel=C-NEW",
}),
expect.objectContaining({
headers: { authorization: "Bearer xoxb-secret" },
}),
);
});
it("does not hydrate a Slack resource after the bot has already left", async () => {
const fetch = vi.fn(async () =>
response({
ok: true,
channel: {
id: "C-LEFT",
name: "former-channel",
is_member: false,
},
}),
) as unknown as typeof globalThis.fetch;
await expect(
getSlackBotChannel({
botToken: "xoxb-secret",
channelId: "C-LEFT",
fetch,
}),
).resolves.toBeNull();
});
it("exchanges a GitHub App JWT and never exposes the installation token", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ token: "installation-secret" }))
.mockResolvedValueOnce(
response({
repositories: [
{
id: 101,
name: "repo",
full_name: "paperclip/repo",
html_url: "https://github.com/paperclip/repo",
owner: { id: 12, login: "paperclip" },
private: true,
},
],
}),
) as unknown as typeof globalThis.fetch;
const result = await listGitHubInstallationRepositories({
appJwt: "app-jwt",
installationId: "44",
fetch,
});
expect(result.resources).toEqual([
expect.objectContaining({
providerResourceId: "101",
parentProviderResourceId: "12",
label: "paperclip/repo",
providerUrl: "https://github.com/paperclip/repo",
}),
]);
expect(JSON.stringify(result)).not.toContain("installation-secret");
expect(fetch).toHaveBeenNthCalledWith(
1,
"https://api.github.com/app/installations/44/access_tokens",
expect.objectContaining({
method: "POST",
signal: expect.any(AbortSignal),
}),
);
expect(fetch).toHaveBeenNthCalledWith(
2,
expect.any(URL),
expect.objectContaining({
headers: expect.objectContaining({
authorization: "Bearer installation-secret",
}),
signal: expect.any(AbortSignal),
}),
);
});
it("discovers the one active installation without asking for an ID", async () => {
const fetch = vi.fn(async () =>
response([
{
id: 44,
account: { id: 12, login: "paperclip", type: "Organization" },
permissions: {
issues: "write",
metadata: "read",
pull_requests: "write",
},
suspended_at: null,
},
{
id: 55,
account: { id: 13, login: "suspended" },
suspended_at: "2026-09-05T00:00:00Z",
},
]),
) as unknown as typeof globalThis.fetch;
await expect(
discoverDedicatedGitHubAppInstallation({ appJwt: "jwt", fetch }),
).resolves.toEqual({
installationId: "44",
accountId: "12",
accountLabel: "paperclip",
accountType: "Organization",
permissions: {
issues: "write",
metadata: "read",
pull_requests: "write",
},
});
});
it("searches every GitHub App installation page before enforcing uniqueness", async () => {
const fetch = vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith("&page=2")) {
return response([
{
id: 144,
account: { id: 12, login: "paperclip", type: "Organization" },
permissions: {
issues: "write",
metadata: "read",
pull_requests: "write",
},
suspended_at: null,
},
]);
}
return response(
Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
account: { id: index + 1, login: `suspended-${index + 1}` },
suspended_at: "2026-09-05T00:00:00Z",
})),
);
}) as unknown as typeof globalThis.fetch;
await expect(
discoverDedicatedGitHubAppInstallation({ appJwt: "jwt", fetch }),
).resolves.toMatchObject({
installationId: "144",
accountId: "12",
accountLabel: "paperclip",
});
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenLastCalledWith(
"https://api.github.com/app/installations?per_page=100&page=2",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("rejects an installation whose effective grants lag the App registration", async () => {
await expect(
discoverDedicatedGitHubAppInstallation({
appJwt: "jwt",
fetch: vi.fn(async () =>
response([
{
id: 44,
account: { id: 12, login: "paperclip" },
permissions: {
issues: "read",
metadata: "read",
pull_requests: "write",
},
suspended_at: null,
},
]),
) as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow(
"active installation has not granted the required access for: issues",
);
});
it("rejects zero or multiple active installations", async () => {
await expect(
discoverDedicatedGitHubAppInstallation({
appJwt: "jwt",
fetch: vi.fn(async () =>
response([]),
) as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow("install this GitHub App");
await expect(
discoverDedicatedGitHubAppInstallation({
appJwt: "jwt",
fetch: vi.fn(async () =>
response([{ id: 1 }, { id: 2 }]),
) as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow("exactly one active installation");
});
it("fails closed when either provider rejects inventory", async () => {
await expect(
listSlackBotChannels({
botToken: "bad",
fetch: vi.fn(async () =>
response({ ok: false, error: "invalid_auth" }),
) as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow("Slack inventory failed: invalid_auth");
await expect(
listGitHubInstallationRepositories({
appJwt: "bad",
installationId: "1",
fetch: vi.fn(async () =>
response({ message: "Not Found" }, 404),
) as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow("GitHub inventory failed: Not Found");
});
});

View File

@ -0,0 +1,331 @@
import type { ChatProvider } from "@paperclipai/shared";
export interface ChatProviderResourceInventoryItem {
providerResourceId: string;
parentProviderResourceId?: string;
type: string;
label: string;
providerUrl?: string;
metadata?: Record<string, unknown>;
}
export interface ChatProviderInventoryResult {
provider: ChatProvider;
resources: ChatProviderResourceInventoryItem[];
}
export interface GitHubAppInstallationIdentity {
installationId: string;
accountId?: string;
accountLabel?: string;
accountType?: string;
permissions: Record<string, string>;
}
const REQUIRED_GITHUB_INSTALLATION_PERMISSIONS = {
issues: "write",
metadata: "read",
pull_requests: "write",
} as const;
const SLACK_API_TIMEOUT_MS = 25_000;
const GITHUB_API_TIMEOUT_MS = 25_000;
function slackRequestSignal(): AbortSignal {
return AbortSignal.timeout(SLACK_API_TIMEOUT_MS);
}
function githubRequestSignal(): AbortSignal {
return AbortSignal.timeout(GITHUB_API_TIMEOUT_MS);
}
async function jsonResponse<T>(
response: Response,
provider: string,
): Promise<T> {
let body: unknown;
try {
body = await response.json();
} catch {
throw new Error(`${provider} returned an unreadable inventory response`);
}
if (!response.ok) {
const message =
body && typeof body === "object" && "message" in body
? String((body as { message?: unknown }).message)
: String(response.status);
throw new Error(`${provider} inventory failed: ${message}`);
}
return body as T;
}
/** List only Slack conversations where the installed bot is a member. */
export async function listSlackBotChannels(input: {
botToken: string;
fetch: typeof globalThis.fetch;
}): Promise<ChatProviderInventoryResult> {
const resources: ChatProviderResourceInventoryItem[] = [];
let cursor = "";
do {
const url = new URL("https://slack.com/api/conversations.list");
url.searchParams.set("types", "public_channel,private_channel");
url.searchParams.set("exclude_archived", "true");
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const response = await input.fetch(url, {
headers: { authorization: `Bearer ${input.botToken}` },
signal: slackRequestSignal(),
});
const body = await jsonResponse<{
ok?: boolean;
error?: string;
channels?: Array<{
id?: string;
name?: string;
is_member?: boolean;
is_private?: boolean;
is_archived?: boolean;
context_team_id?: string;
}>;
response_metadata?: { next_cursor?: string };
}>(response, "Slack");
if (!body.ok)
throw new Error(
`Slack inventory failed: ${body.error ?? "unknown error"}`,
);
for (const channel of body.channels ?? []) {
if (!channel.id || !channel.is_member || channel.is_archived) continue;
resources.push({
providerResourceId: channel.id,
type: "channel",
label: channel.name ? `#${channel.name}` : channel.id,
metadata: {
private: channel.is_private === true,
...(channel.context_team_id
? { contextTeamId: channel.context_team_id }
: {}),
source: "provider_inventory",
},
});
}
cursor = body.response_metadata?.next_cursor?.trim() ?? "";
} while (cursor);
return { provider: "slack", resources };
}
/** Resolve one newly joined Slack channel to its provider-authoritative label. */
export async function getSlackBotChannel(input: {
botToken: string;
channelId: string;
fetch: typeof globalThis.fetch;
}): Promise<ChatProviderResourceInventoryItem | null> {
const url = new URL("https://slack.com/api/conversations.info");
url.searchParams.set("channel", input.channelId);
const response = await input.fetch(url, {
headers: { authorization: `Bearer ${input.botToken}` },
signal: AbortSignal.timeout(5_000),
});
const body = await jsonResponse<{
ok?: boolean;
error?: string;
channel?: {
id?: string;
name?: string;
is_member?: boolean;
is_private?: boolean;
is_archived?: boolean;
context_team_id?: string;
};
}>(response, "Slack");
if (!body.ok) {
throw new Error(`Slack inventory failed: ${body.error ?? "unknown error"}`);
}
const channel = body.channel;
if (
!channel?.id ||
channel.id !== input.channelId ||
channel.is_member === false ||
channel.is_archived
) {
return null;
}
return {
providerResourceId: channel.id,
type: "channel",
label: channel.name ? `#${channel.name}` : channel.id,
metadata: {
private: channel.is_private === true,
...(channel.context_team_id
? { contextTeamId: channel.context_team_id }
: {}),
source: "provider_inventory",
},
};
}
/**
* Exchange a GitHub App JWT for a short-lived installation token and list the
* repositories selected for that installation. The token never leaves this
* function and is never persisted in Paperclip.
*/
export async function listGitHubInstallationRepositories(input: {
appJwt: string;
installationId: string;
fetch: typeof globalThis.fetch;
}): Promise<ChatProviderInventoryResult> {
const headers = {
accept: "application/vnd.github+json",
authorization: `Bearer ${input.appJwt}`,
"x-github-api-version": "2022-11-28",
};
const tokenResponse = await input.fetch(
`https://api.github.com/app/installations/${encodeURIComponent(input.installationId)}/access_tokens`,
{ method: "POST", headers, signal: githubRequestSignal() },
);
const tokenBody = await jsonResponse<{ token?: string; message?: string }>(
tokenResponse,
"GitHub",
);
if (!tokenBody.token)
throw new Error("GitHub inventory failed: installation token was missing");
const resources: ChatProviderResourceInventoryItem[] = [];
let page = 1;
try {
while (true) {
const url = new URL("https://api.github.com/installation/repositories");
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await input.fetch(url, {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${tokenBody.token}`,
"x-github-api-version": "2022-11-28",
},
signal: githubRequestSignal(),
});
const body = await jsonResponse<{
repositories?: Array<{
id?: number;
name?: string;
full_name?: string;
html_url?: string;
owner?: { id?: number; login?: string };
private?: boolean;
}>;
}>(response, "GitHub");
const repositories = body.repositories ?? [];
for (const repository of repositories) {
if (!Number.isFinite(repository.id)) continue;
const id = String(repository.id);
resources.push({
providerResourceId: id,
parentProviderResourceId: repository.owner?.id
? String(repository.owner.id)
: undefined,
type: "repository",
label: repository.full_name ?? repository.name ?? id,
providerUrl:
repository.html_url ??
(repository.full_name
? `https://github.com/${repository.full_name}`
: undefined),
metadata: {
private: repository.private === true,
...(repository.owner?.login
? { owner: repository.owner.login }
: {}),
source: "provider_inventory",
},
});
}
if (repositories.length < 100) break;
page += 1;
}
} finally {
// Avoid keeping the installation token reachable longer than the request
// scope. JavaScript strings cannot be reliably zeroed, but this prevents
// accidental return/persistence through the inventory result.
tokenBody.token = undefined;
}
return { provider: "github", resources };
}
/**
* Resolve the one installation belonging to a dedicated per-agent GitHub App.
* Keeping one app identity per endpoint is the same invariant used for Slack
* and Teams bots; it also avoids exposing an installation-id field to users.
*/
export async function discoverDedicatedGitHubAppInstallation(input: {
appJwt: string;
fetch: typeof globalThis.fetch;
}): Promise<GitHubAppInstallationIdentity> {
const installations: Array<{
id?: number;
account?: { id?: number; login?: string; name?: string; type?: string };
permissions?: Record<string, string>;
suspended_at?: string | null;
}> = [];
for (let page = 1; ; page += 1) {
const response = await input.fetch(
page === 1
? "https://api.github.com/app/installations?per_page=100"
: `https://api.github.com/app/installations?per_page=100&page=${page}`,
{
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${input.appJwt}`,
"x-github-api-version": "2022-11-28",
},
signal: githubRequestSignal(),
},
);
const pageInstallations = await jsonResponse<
Array<{
id?: number;
account?: { id?: number; login?: string; name?: string; type?: string };
permissions?: Record<string, string>;
suspended_at?: string | null;
}>
>(response, "GitHub");
installations.push(...pageInstallations);
if (pageInstallations.length < 100) break;
}
const active = installations.filter(
(installation) =>
Number.isFinite(installation.id) && !installation.suspended_at,
);
if (active.length === 0) {
throw new Error(
"GitHub inventory failed: install this GitHub App on the selected repositories first",
);
}
if (active.length !== 1) {
throw new Error(
"GitHub inventory failed: this chat connection requires a dedicated GitHub App with exactly one active installation",
);
}
const installation = active[0]!;
const missingPermissions = Object.entries(
REQUIRED_GITHUB_INSTALLATION_PERMISSIONS,
)
.filter(
([permission, access]) =>
installation.permissions?.[permission] !== access,
)
.map(([permission]) => permission);
if (missingPermissions.length > 0) {
throw new Error(
`GitHub inventory failed: the active installation has not granted the required access for: ${missingPermissions.join(", ")}. Approve the GitHub App permission update, then retry`,
);
}
return {
installationId: String(installation.id),
accountId: Number.isFinite(installation.account?.id)
? String(installation.account?.id)
: undefined,
accountLabel:
installation.account?.login ?? installation.account?.name ?? undefined,
accountType: installation.account?.type,
permissions: installation.permissions ?? {},
};
}

View File

@ -0,0 +1,408 @@
import { describe, expect, it } from "vitest";
import { parseChatProviderLifecycle } from "./chat-provider-lifecycle.js";
describe("chat provider lifecycle normalization", () => {
it("tracks only the configured Slack bot's channel membership", () => {
expect(
parseChatProviderLifecycle({
provider: "slack",
botExternalId: "U-BOT",
payload: {
event_id: "Ev1",
event: {
type: "member_joined_channel",
event_ts: "1725551000.125000",
user: "U-BOT",
channel: "C123",
channel_type: "C",
},
},
}),
).toEqual([
expect.objectContaining({
kind: "resource",
providerEventId: "Ev1",
providerResourceId: "C123",
availability: "available",
providerOrder: { sequence: "1725551000.125000" },
}),
]);
expect(
parseChatProviderLifecycle({
provider: "slack",
botExternalId: "U-BOT",
payload: {
event_id: "Ev2",
event: {
type: "member_left_channel",
user: "U-SOMEONE-ELSE",
channel: "C123",
},
},
}),
).toEqual([]);
expect(
parseChatProviderLifecycle({
provider: "slack",
botExternalId: "U-BOT",
payload: {
event_id: "Ev3",
event: {
type: "member_left_channel",
event_ts: "1725551001.125000",
user: "U-BOT",
channel: "C123",
},
},
}),
).toEqual([
expect.objectContaining({
kind: "resource",
providerEventId: "Ev3",
providerResourceId: "C123",
availability: "unavailable",
}),
]);
});
it.each([
["channel_left", "C-PUBLIC"],
["group_left", "G-PRIVATE"],
])(
"tracks Slack bot-self %s events without a user field",
(type, channel) => {
expect(
parseChatProviderLifecycle({
provider: "slack",
botExternalId: "U-BOT",
payload: {
event_id: `Ev-${type}`,
event: {
type,
event_ts: "1725551002.125000",
channel,
},
},
}),
).toEqual([
expect.objectContaining({
kind: "resource",
providerEventId: `Ev-${type}`,
providerResourceId: channel,
availability: "unavailable",
metadata: { source: type },
}),
]);
},
);
it.each([
["channel_archive", "unavailable"],
["group_archive", "unavailable"],
["channel_unarchive", "available"],
["group_unarchive", "available"],
["channel_rename", "available"],
["group_rename", "available"],
["channel_deleted", "removed"],
] as const)("normalizes Slack %s lifecycle events", (type, availability) => {
expect(
parseChatProviderLifecycle({
provider: "slack",
payload: {
event_id: `Ev-${type}`,
event: {
type,
event_ts: "1725551003.125000",
channel: { id: "C-LIFECYCLE", name: "renamed-channel" },
},
},
}),
).toEqual([
expect.objectContaining({
kind: "resource",
providerEventId: `Ev-${type}`,
providerResourceId: "C-LIFECYCLE",
label: "renamed-channel",
availability,
metadata: { source: type },
}),
]);
});
it("turns Slack uninstall and token revocation into endpoint effects", () => {
for (const type of ["app_uninstalled", "tokens_revoked"]) {
expect(
parseChatProviderLifecycle({
provider: "slack",
payload: { event_id: `Ev-${type}`, event: { type } },
}),
).toEqual([
expect.objectContaining({
kind: "endpoint",
availability: "revoked",
providerEventId: `Ev-${type}`,
}),
]);
}
});
it("turns each GitHub repository-selection callback into one canonical refresh", () => {
const effects = parseChatProviderLifecycle({
provider: "github",
headers: {
"x-github-event": "installation_repositories",
"x-github-delivery": "gh-delivery-1",
},
payload: {
action: "added",
repositories_added: [
{
id: 101,
name: "enabled",
full_name: "paperclip/enabled",
html_url: "https://github.com/paperclip/enabled",
},
],
repositories_removed: [
{
id: 202,
name: "removed",
full_name: "paperclip/removed",
},
],
},
});
expect(effects).toEqual([
expect.objectContaining({
kind: "endpoint",
providerEventId: "gh-delivery-1",
availability: "available",
metadata: { repositoriesAdded: 1, repositoriesRemoved: 1 },
}),
]);
});
it("distinguishes suspended and deleted GitHub installations", () => {
expect(
parseChatProviderLifecycle({
provider: "github",
headers: {
"x-github-event": "installation",
"x-github-delivery": "gh-suspended",
},
payload: { action: "suspend", installation: { id: 123 } },
}),
).toEqual([
expect.objectContaining({
kind: "endpoint",
availability: "attention",
metadata: { installationId: "123" },
}),
]);
expect(
parseChatProviderLifecycle({
provider: "github",
headers: {
"x-github-event": "installation",
"x-github-delivery": "gh-deleted",
},
payload: { action: "deleted", installation: { id: 123 } },
}),
).toEqual([
expect.objectContaining({
kind: "endpoint",
availability: "revoked",
}),
]);
});
it("normalizes Teams installation add/remove for the chosen conversation", () => {
const base = {
type: "installationUpdate",
id: "teams-event-1",
timestamp: "2026-09-05T14:00:00.000Z",
conversation: {
id: "19:conversation@thread.tacv2;messageid=1729",
isGroup: true,
},
channelData: {
team: { id: "team-1", name: "Paperclip Test" },
channel: { id: "channel-1", name: "Bots" },
},
};
expect(
parseChatProviderLifecycle({
provider: "microsoft-teams",
payload: { ...base, action: "add" },
}),
).toEqual([
expect.objectContaining({
providerResourceId: "19:conversation@thread.tacv2",
parentProviderResourceId: "team-1",
resourceType: "channel",
label: "Bots",
availability: "available",
providerOrder: { occurredAt: "2026-09-05T14:00:00.000Z" },
}),
]);
expect(
parseChatProviderLifecycle({
provider: "microsoft-teams",
payload: { ...base, action: "remove" },
}),
).toEqual([expect.objectContaining({ availability: "removed" })]);
});
it("uses Teams conversationType for personal and group lifecycle resources", () => {
const personal = {
type: "installationUpdate",
id: "teams-personal-add",
action: "add",
conversation: {
id: "a:personal-conversation",
conversationType: "personal",
},
};
expect(
parseChatProviderLifecycle({
provider: "microsoft-teams",
payload: personal,
}),
).toEqual([
expect.objectContaining({
providerResourceId: "a:personal-conversation",
resourceType: "direct_message",
availability: "available",
}),
]);
expect(
parseChatProviderLifecycle({
provider: "microsoft-teams",
payload: {
...personal,
id: "teams-personal-remove",
action: "remove",
},
}),
).toEqual([
expect.objectContaining({
resourceType: "direct_message",
availability: "removed",
}),
]);
expect(
parseChatProviderLifecycle({
provider: "microsoft-teams",
botExternalId: "00000000-0000-4000-8000-000000000111",
payload: {
type: "conversationUpdate",
id: "teams-group-membership",
conversation: {
id: "19:group-conversation@unq.gbl.spaces",
conversationType: "group",
},
membersAdded: [{ id: "28:00000000-0000-4000-8000-000000000111" }],
},
}),
).toEqual([
expect.objectContaining({
providerResourceId: "19:group-conversation@unq.gbl.spaces",
resourceType: "group_chat",
availability: "available",
}),
]);
});
it("normalizes Telegram bot membership without requesting chat_member", () => {
const effect = parseChatProviderLifecycle({
provider: "telegram",
payload: {
update_id: 44,
my_chat_member: {
chat: { id: -100123, type: "supergroup", title: "Agent Lab" },
new_chat_member: { status: "administrator" },
},
},
});
expect(effect).toEqual([
expect.objectContaining({
providerEventId: "telegram:44",
providerResourceId: "-100123",
resourceType: "chat",
label: "Agent Lab",
availability: "available",
providerOrder: { sequence: "44" },
}),
]);
expect(
parseChatProviderLifecycle({
provider: "telegram",
payload: {
update_id: 45,
my_chat_member: {
chat: { id: -100123, type: "supergroup", title: "Agent Lab" },
new_chat_member: { status: "kicked" },
},
},
}),
).toEqual([expect.objectContaining({ availability: "unavailable" })]);
});
it("normalizes both Telegram basic-group migration payload shapes", () => {
expect(
parseChatProviderLifecycle({
provider: "telegram",
payload: {
update_id: 46,
message: {
message_id: 10,
chat: { id: -5546433913, type: "group", title: "Agent Lab" },
migrate_to_chat_id: -1004415501660,
},
},
}),
).toEqual([
expect.objectContaining({
providerEventId: "telegram:46",
previousProviderResourceId: "-5546433913",
providerResourceId: "-1004415501660",
resourceType: "chat",
label: "Agent Lab",
availability: "available",
providerOrder: { sequence: "46" },
metadata: {
source: "chat_migration",
migratedFrom: "-5546433913",
migratedTo: "-1004415501660",
},
}),
]);
expect(
parseChatProviderLifecycle({
provider: "telegram",
payload: {
update_id: 47,
message: {
message_id: 11,
chat: {
id: -1004415501660,
type: "supergroup",
title: "Agent Lab",
},
migrate_from_chat_id: -5546433913,
},
},
}),
).toEqual([
expect.objectContaining({
previousProviderResourceId: "-5546433913",
providerResourceId: "-1004415501660",
label: "Agent Lab",
}),
]);
});
});

View File

@ -0,0 +1,510 @@
import type {
ChatProvider,
ChatResourceAvailability,
} from "@paperclipai/shared";
export type ChatProviderLifecycleEffect =
| {
kind: "resource";
provider: ChatProvider;
providerEventId: string;
providerResourceId: string;
/** Telegram basic-group id superseded by this supergroup id. */
previousProviderResourceId?: string;
parentProviderResourceId?: string;
resourceType: string;
label: string;
providerUrl?: string;
availability: ChatResourceAvailability;
providerOrder?: {
sequence?: string;
occurredAt?: string;
};
metadata?: Record<string, unknown>;
}
| {
kind: "endpoint";
provider: ChatProvider;
providerEventId: string;
availability: "available" | "attention" | "revoked";
reason: string;
providerOrder?: {
sequence?: string;
occurredAt?: string;
};
metadata?: Record<string, unknown>;
};
export interface ParseChatProviderLifecycleInput {
provider: ChatProvider;
headers?: Headers | Record<string, string | undefined>;
payload: unknown;
/** The provider-verified bot identity stored on the endpoint. */
botExternalId?: string | null;
}
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function string(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function identifier(value: unknown): string | null {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return string(value);
}
function header(
headers: ParseChatProviderLifecycleInput["headers"],
name: string,
): string | null {
if (!headers) return null;
if (headers instanceof Headers) return headers.get(name);
const target = name.toLowerCase();
const entry = Object.entries(headers).find(
([key]) => key.toLowerCase() === target,
);
return entry ? string(entry[1]) : null;
}
function numericSequence(value: unknown): string | undefined {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
const candidate = string(value);
return candidate && /^\d+(?:\.\d+)?$/.test(candidate) ? candidate : undefined;
}
function occurredAt(value: unknown): string | undefined {
const candidate = string(value);
if (!candidate) return undefined;
const timestamp = Date.parse(candidate);
return Number.isFinite(timestamp)
? new Date(timestamp).toISOString()
: undefined;
}
function parseSlackLifecycle(
input: ParseChatProviderLifecycleInput,
): ChatProviderLifecycleEffect[] {
const payload = record(input.payload);
if (!payload) return [];
const envelopeId =
string(payload.event_id) ?? string(payload.trigger_id) ?? "slack:lifecycle";
const event = record(payload.event) ?? payload;
const type = string(event.type);
if (!type) return [];
const sequence = numericSequence(event.event_ts ?? payload.event_time);
const providerOrder = sequence ? { sequence } : undefined;
if (type === "app_uninstalled" || type === "tokens_revoked") {
return [
{
kind: "endpoint",
provider: "slack",
providerEventId: envelopeId,
availability: "revoked",
providerOrder,
reason:
type === "app_uninstalled"
? "Slack app was uninstalled"
: "Slack app credentials were revoked",
},
];
}
if (type === "member_joined_channel" || type === "member_left_channel") {
const user = identifier(event.user);
const channel = identifier(event.channel);
if (!channel || !input.botExternalId || user !== input.botExternalId)
return [];
return [
{
kind: "resource",
provider: "slack",
providerEventId: envelopeId,
providerResourceId: channel,
resourceType: "channel",
label: channel,
availability:
type === "member_joined_channel" ? "available" : "unavailable",
providerOrder,
metadata: {
source: "membership_event",
...(string(event.channel_type)
? { channelType: string(event.channel_type) }
: {}),
},
},
];
}
// Slack emits these bot-self events when the installed app leaves a public
// or private channel. They are distinct from member_left_channel and do not
// include a user field because the authenticated bot is the member that
// left. Without them, `/remove @bot` can leave Paperclip's reach inventory
// incorrectly available until a later manual reconciliation.
if (type === "channel_left" || type === "group_left") {
const channel = identifier(event.channel);
if (!channel) return [];
return [
{
kind: "resource",
provider: "slack",
providerEventId: envelopeId,
providerResourceId: channel,
resourceType: "channel",
label: channel,
availability: "unavailable",
providerOrder,
metadata: { source: type },
},
];
}
if (
type === "channel_archive" ||
type === "group_archive" ||
type === "channel_unarchive" ||
type === "group_unarchive" ||
type === "channel_deleted" ||
type === "channel_rename" ||
type === "group_rename"
) {
const channelValue = record(event.channel);
const channelId =
identifier(channelValue?.id) ?? identifier(event.channel) ?? null;
if (!channelId) return [];
const availability: ChatResourceAvailability =
type === "channel_deleted"
? "removed"
: type === "channel_archive" || type === "group_archive"
? "unavailable"
: "available";
return [
{
kind: "resource",
provider: "slack",
providerEventId: envelopeId,
providerResourceId: channelId,
resourceType: "channel",
label: string(channelValue?.name) ?? channelId,
availability,
providerOrder,
metadata: { source: type },
},
];
}
return [];
}
function parseGitHubLifecycle(
input: ParseChatProviderLifecycleInput,
): ChatProviderLifecycleEffect[] {
const payload = record(input.payload);
if (!payload) return [];
const event = header(input.headers, "x-github-event");
const delivery =
header(input.headers, "x-github-delivery") ?? "github:lifecycle";
const action = string(payload.action);
if (event === "installation") {
const installation = record(payload.installation);
const installationId = identifier(installation?.id);
if (action === "deleted" || action === "suspend") {
return [
{
kind: "endpoint",
provider: "github",
providerEventId: delivery,
availability: action === "deleted" ? "revoked" : "attention",
reason:
action === "deleted"
? "GitHub App installation was removed"
: "GitHub App installation was suspended",
metadata: installationId ? { installationId } : undefined,
},
];
}
if (
action === "created" ||
action === "unsuspend" ||
action === "new_permissions_accepted"
) {
return [
{
kind: "endpoint",
provider: "github",
providerEventId: delivery,
availability: "available",
reason:
action === "unsuspend"
? "GitHub App installation was unsuspended"
: "GitHub App installation is available",
metadata: installationId ? { installationId } : undefined,
},
];
}
return [];
}
if (event !== "installation_repositories") return [];
// A repository-selection callback is one canonical reconciliation trigger,
// regardless of how many repositories GitHub includes in its delta. The
// service deliberately re-reads the complete installation inventory rather
// than trusting these callback-local additions/removals.
return [
{
kind: "endpoint",
provider: "github",
providerEventId: delivery,
availability: "available",
reason: "GitHub App repository access changed",
metadata: {
repositoriesAdded: Array.isArray(payload.repositories_added)
? payload.repositories_added.length
: 0,
repositoriesRemoved: Array.isArray(payload.repositories_removed)
? payload.repositories_removed.length
: 0,
},
},
];
}
function teamsResourceType(payload: Record<string, unknown>): string {
const conversation = record(payload.conversation);
const conversationType = string(conversation?.conversationType)
?.toLowerCase()
.replace(/[^a-z]/g, "");
if (conversationType === "personal") return "direct_message";
if (conversationType === "group" || conversationType === "groupchat")
return "group_chat";
if (conversationType === "channel") return "channel";
if (conversation?.isGroup === false) return "direct_message";
const channelData = record(payload.channelData);
if (record(channelData?.team) || record(channelData?.channel))
return "channel";
return "group_chat";
}
function teamsBotIdentityMatches(
botExternalId: string,
memberId: unknown,
): boolean {
const canonical = (value: unknown): string | null => {
const id = identifier(value)?.toLowerCase();
return id?.replace(/^28:/, "") ?? null;
};
const expected = canonical(botExternalId);
return expected !== null && canonical(memberId) === expected;
}
function teamsResourceId(payload: Record<string, unknown>): string | null {
const conversation = record(payload.conversation);
const channelData = record(payload.channelData);
const channel = record(channelData?.channel);
// Teams root replies append `;messageid=...` to the Bot Framework
// conversation id. Use its stable base as the access-control resource so
// installation events and later message ingress address the same row.
const conversationId = identifier(conversation?.id);
return (
conversationId?.replace(/;messageid=[^;]+/i, "") ?? identifier(channel?.id)
);
}
function teamsResourceLabel(
payload: Record<string, unknown>,
fallback: string,
): string {
const conversation = record(payload.conversation);
const channelData = record(payload.channelData);
const channel = record(channelData?.channel);
const team = record(channelData?.team);
return (
string(channel?.name) ??
string(conversation?.name) ??
string(team?.name) ??
fallback
);
}
function parseTeamsLifecycle(
input: ParseChatProviderLifecycleInput,
): ChatProviderLifecycleEffect[] {
const payload = record(input.payload);
if (!payload) return [];
const type = string(payload.type);
const eventId = identifier(payload.id) ?? "teams:lifecycle";
const eventOccurredAt = occurredAt(payload.timestamp);
const providerOrder = eventOccurredAt
? { occurredAt: eventOccurredAt }
: undefined;
const resourceId = teamsResourceId(payload);
if (!resourceId) return [];
if (type === "installationUpdate") {
const action = string(payload.action)?.toLowerCase();
if (
!["add", "add-upgrade", "remove", "remove-upgrade"].includes(action ?? "")
)
return [];
const channelData = record(payload.channelData);
const team = record(channelData?.team);
return [
{
kind: "resource",
provider: "microsoft-teams",
providerEventId: eventId,
providerResourceId: resourceId,
parentProviderResourceId: identifier(team?.id) ?? undefined,
resourceType: teamsResourceType(payload),
label: teamsResourceLabel(payload, resourceId),
availability: action?.startsWith("remove") ? "removed" : "available",
providerOrder,
metadata: { source: "installation_update", action },
},
];
}
if (type === "conversationUpdate" && input.botExternalId) {
const added = Array.isArray(payload.membersAdded)
? payload.membersAdded.map(record).filter(Boolean)
: [];
const removed = Array.isArray(payload.membersRemoved)
? payload.membersRemoved.map(record).filter(Boolean)
: [];
const joined = added.some((member) =>
teamsBotIdentityMatches(input.botExternalId!, member?.id),
);
const left = removed.some((member) =>
teamsBotIdentityMatches(input.botExternalId!, member?.id),
);
if (!joined && !left) return [];
return [
{
kind: "resource",
provider: "microsoft-teams",
providerEventId: eventId,
providerResourceId: resourceId,
resourceType: teamsResourceType(payload),
label: teamsResourceLabel(payload, resourceId),
availability: left ? "unavailable" : "available",
providerOrder,
metadata: { source: "conversation_membership" },
},
];
}
return [];
}
function parseTelegramLifecycle(
input: ParseChatProviderLifecycleInput,
): ChatProviderLifecycleEffect[] {
const payload = record(input.payload);
const migrationMessage =
record(payload?.message) ?? record(payload?.channel_post);
const migrationChat = record(migrationMessage?.chat);
const migrationChatId = identifier(migrationChat?.id);
const migrateToChatId = identifier(migrationMessage?.migrate_to_chat_id);
const migrateFromChatId = identifier(migrationMessage?.migrate_from_chat_id);
const migrationFromId = migrateToChatId ? migrationChatId : migrateFromChatId;
const migrationToId =
migrateToChatId ?? (migrateFromChatId ? migrationChatId : null);
if (
payload &&
migrationMessage &&
migrationChat &&
migrationFromId &&
migrationToId
) {
const title =
string(migrationChat.title) ??
[string(migrationChat.first_name), string(migrationChat.last_name)]
.filter(Boolean)
.join(" ") ??
string(migrationChat.username) ??
migrationToId;
const updateId = numericSequence(payload.update_id);
return [
{
kind: "resource",
provider: "telegram",
providerEventId: `telegram:${identifier(payload.update_id) ?? "migration"}`,
providerResourceId: migrationToId,
previousProviderResourceId: migrationFromId,
resourceType: "chat",
label: title || migrationToId,
availability: "available",
providerOrder: updateId ? { sequence: updateId } : undefined,
metadata: {
source: "chat_migration",
migratedFrom: migrationFromId,
migratedTo: migrationToId,
},
},
];
}
const membership = record(payload?.my_chat_member);
const chat = record(membership?.chat);
const member = record(membership?.new_chat_member);
const chatId = identifier(chat?.id);
const status = string(member?.status);
const updateId = numericSequence(payload?.update_id);
if (!payload || !membership || !chat || !member || !chatId || !status)
return [];
const available =
status === "member" ||
status === "administrator" ||
(status === "restricted" && member.is_member === true);
const unavailable =
status === "left" || status === "kicked" || status === "restricted";
if (!available && !unavailable) return [];
const title =
string(chat.title) ??
[string(chat.first_name), string(chat.last_name)]
.filter(Boolean)
.join(" ") ??
string(chat.username) ??
chatId;
return [
{
kind: "resource",
provider: "telegram",
providerEventId: `telegram:${identifier(payload.update_id) ?? "lifecycle"}`,
providerResourceId: chatId,
resourceType: string(chat.type) === "private" ? "direct_message" : "chat",
label: title || chatId,
availability: available ? "available" : "unavailable",
providerOrder: updateId ? { sequence: updateId } : undefined,
metadata: { source: "my_chat_member", memberStatus: status },
},
];
}
/**
* Parse provider installation and membership events only after the native
* adapter has verified the webhook. The returned effects contain no provider
* credentials and are safe to persist in Paperclip's lifecycle ledger.
*/
export function parseChatProviderLifecycle(
input: ParseChatProviderLifecycleInput,
): ChatProviderLifecycleEffect[] {
switch (input.provider) {
case "slack":
return parseSlackLifecycle(input);
case "github":
return parseGitHubLifecycle(input);
case "discord":
// Discord server/channel reach is reconciled from the Bot REST API on
// connect, resume, and reconnect. Gateway membership events are not yet
// treated as an authorization source.
return [];
case "microsoft-teams":
return parseTeamsLifecycle(input);
case "telegram":
return parseTelegramLifecycle(input);
}
}

View File

@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import { chatProviderConversationUrl } from "./chat-provider-links.js";
describe("chat provider conversation links", () => {
it("links Slack and GitHub threads to their native conversation", () => {
expect(
chatProviderConversationUrl({
provider: "slack",
providerAccountId: "T123",
threadId: "slack:C456:1712345678.000100",
providerMessageId: "1712345678.000100",
}),
).toBe(
"https://app.slack.com/client/T123/C456/thread/C456-1712345678000100",
);
expect(
chatProviderConversationUrl({
provider: "slack",
providerAccountId: "T123",
threadId: "slack:D456:",
providerMessageId: "1712345678.000100",
}),
).toBe("https://slack.com/app_redirect?channel=D456&team=T123");
expect(
chatProviderConversationUrl({
provider: "github",
threadId: "github:paperclipai/paperclip:issue:42",
providerMessageId: "99",
}),
).toBe(
"https://github.com/paperclipai/paperclip/issues/42#issuecomment-99",
);
expect(
chatProviderConversationUrl({
provider: "github",
threadId: "github:paperclipai/paperclip:43",
providerMessageId: "100",
}),
).toBe("https://github.com/paperclipai/paperclip/pull/43#issuecomment-100");
expect(
chatProviderConversationUrl({
provider: "github",
threadId: "github:paperclipai/paperclip:43:rc:101",
providerMessageId: "102",
}),
).toBe("https://github.com/paperclipai/paperclip/pull/43#discussion_r101");
});
it("links Teams channel threads and chats using verified activity IDs", () => {
const channelId = "19:channel@thread.tacv2";
const rootId = "1740000000000";
const encodedConversation = Buffer.from(
`${channelId};messageid=${rootId}`,
).toString("base64url");
const encodedService = Buffer.from(
"https://smba.trafficmanager.net/amer/",
).toString("base64url");
const channelUrl = chatProviderConversationUrl({
provider: "microsoft-teams",
providerAccountId: "tenant-fallback",
threadId: `teams:${encodedConversation}:${encodedService}:channel`,
providerMessageId: rootId,
raw: {
channelData: {
tenant: { id: "tenant-1" },
team: { aadGroupId: "group-1", name: "Product" },
channel: { id: channelId, name: "General" },
},
},
});
expect(channelUrl).toContain(
`https://teams.microsoft.com/l/message/${encodeURIComponent(channelId)}/${rootId}`,
);
expect(channelUrl).toContain("tenantId=tenant-1");
expect(channelUrl).toContain("groupId=group-1");
expect(channelUrl).toContain(`parentMessageId=${rootId}`);
const canonicalChannelUrl = chatProviderConversationUrl({
provider: "microsoft-teams",
providerAccountId: "tenant-fallback",
threadId: `teams:${encodedConversation}`,
providerMessageId: rootId,
raw: {
channelData: {
tenant: { id: "tenant-1" },
team: { aadGroupId: "group-1" },
channel: { id: channelId },
},
},
});
expect(canonicalChannelUrl).toContain(
`https://teams.microsoft.com/l/message/${encodeURIComponent(channelId)}/${rootId}`,
);
const chatId = "19:chat@thread.v2";
const encodedChat = Buffer.from(chatId).toString("base64url");
expect(
chatProviderConversationUrl({
provider: "microsoft-teams",
threadId: `teams:${encodedChat}:${encodedService}:groupChat`,
providerMessageId: "175",
raw: { id: "175" },
}),
).toBe(
`https://teams.microsoft.com/l/message/${encodeURIComponent(chatId)}/175?context=${encodeURIComponent(JSON.stringify({ contextType: "chat" }))}`,
);
expect(
chatProviderConversationUrl({
provider: "microsoft-teams",
threadId: `teams:${encodedChat}:groupChat`,
providerMessageId: "176",
}),
).toBe(
`https://teams.microsoft.com/l/message/${encodeURIComponent(chatId)}/176?context=${encodeURIComponent(JSON.stringify({ contextType: "chat" }))}`,
);
});
it("links Telegram public and private forum topics and falls back to the bot DM", () => {
expect(
chatProviderConversationUrl({
provider: "telegram",
threadId: "telegram:-100123456:77",
providerMessageId: "88",
raw: { message_id: 88, chat: { username: "paperclip_e2e" } },
}),
).toBe("https://t.me/paperclip_e2e/77/88");
expect(
chatProviderConversationUrl({
provider: "telegram",
threadId: "telegram:-100123456:77",
providerMessageId: "88",
}),
).toBe("https://t.me/c/123456/77/88");
expect(
chatProviderConversationUrl({
provider: "telegram",
botUsername: "@MayaBot",
threadId: "telegram:1234",
providerMessageId: "9",
}),
).toBe("https://t.me/MayaBot");
});
it.each([
{
threadId: "telegram:1234",
chat: { id: 1234, type: "private", username: "human_user" },
},
{ threadId: "telegram:1234", chat: { id: 1234, username: "human_user" } },
{
threadId: "telegram:1234:77",
chat: { id: 1234, type: "private", username: "human_user" },
},
])(
"links a Telegram DM to its bot, never the human's public-message URL ($threadId)",
({ threadId, chat }) => {
expect(
chatProviderConversationUrl({
provider: "telegram",
botUsername: "@MayaBot",
threadId,
providerMessageId: "88",
raw: { message_id: 88, chat },
}),
).toBe("https://t.me/MayaBot");
expect(
chatProviderConversationUrl({
provider: "telegram",
threadId,
providerMessageId: "88",
raw: { message_id: 88, chat },
}),
).toBeNull();
},
);
it("fails closed when a provider ID cannot produce a safe documented link", () => {
expect(
chatProviderConversationUrl({
provider: "microsoft-teams",
threadId: "teams:not-valid:also-not-valid:channel",
providerMessageId: "1",
}),
).toBeNull();
});
});

View File

@ -0,0 +1,193 @@
import type { ChatProvider } from "@paperclipai/shared";
type ProviderLinkInput = {
provider: ChatProvider;
providerAccountId?: string | null;
botUsername?: string | null;
threadId: string;
providerMessageId: string;
raw?: unknown;
};
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object"
? (value as Record<string, unknown>)
: null;
}
function nestedString(
value: unknown,
...path: readonly string[]
): string | null {
let current: unknown = value;
for (const key of path) current = object(current)?.[key];
return typeof current === "string" && current.trim() ? current.trim() : null;
}
function decodeBase64Url(value: string): string | null {
try {
return Buffer.from(value, "base64url").toString("utf8");
} catch {
return null;
}
}
function teamsConversationLink(input: ProviderLinkInput): string | null {
const parts = input.threadId.split(":");
// Canonical ids keep the mutable Bot Connector serviceUrl out of identity:
// teams:<conversation>[:<conversationType>]
// Keep accepting the pre-canonical route-bearing forms as existing
// conversation rows and publications may still contain them:
// teams:<conversation>:<serviceUrl>[:<conversationType>]
if (parts[0] !== "teams" || parts.length < 2 || parts.length > 4) return null;
const encodedConversationId = parts[1];
if (!encodedConversationId) return null;
const encoded = decodeBase64Url(encodedConversationId);
if (!encoded) return null;
const [chatOrChannelId, encodedRootMessageId] = encoded.split(
";messageid=",
2,
);
if (!chatOrChannelId) return null;
const raw = object(input.raw);
const canonicalConversationType =
parts.length === 3 &&
(parts[2] === "personal" ||
parts[2] === "groupChat" ||
parts[2] === "channel")
? parts[2]
: null;
const conversationType =
parts[3] ??
canonicalConversationType ??
nestedString(raw, "conversation", "conversationType");
const messageId =
encodedRootMessageId ||
nestedString(raw, "replyToId") ||
nestedString(raw, "id") ||
input.providerMessageId;
if (!messageId) return null;
if (conversationType === "personal" || conversationType === "groupChat") {
const context = encodeURIComponent(JSON.stringify({ contextType: "chat" }));
return `https://teams.microsoft.com/l/message/${encodeURIComponent(chatOrChannelId)}/${encodeURIComponent(messageId)}?context=${context}`;
}
const tenantId =
nestedString(raw, "conversation", "tenantId") ||
nestedString(raw, "channelData", "tenant", "id") ||
input.providerAccountId?.trim() ||
null;
const groupId =
nestedString(raw, "channelData", "team", "aadGroupId") ||
nestedString(raw, "channelData", "team", "id");
const channelId =
nestedString(raw, "channelData", "channel", "id") || chatOrChannelId;
if (!tenantId || !groupId || !channelId) return null;
const params = new URLSearchParams({
tenantId,
groupId,
parentMessageId: messageId,
createdTime: messageId,
});
const teamName = nestedString(raw, "channelData", "team", "name");
const channelName = nestedString(raw, "channelData", "channel", "name");
if (teamName) params.set("teamName", teamName);
if (channelName) params.set("channelName", channelName);
return `https://teams.microsoft.com/l/message/${encodeURIComponent(channelId)}/${encodeURIComponent(messageId)}?${params.toString()}`;
}
function telegramConversationLink(input: ProviderLinkInput): string | null {
const parts = input.threadId.split(":");
if (parts[0] !== "telegram" || parts.length < 2 || parts.length > 3)
return null;
const chatId = parts[1];
const topicId = parts[2];
const raw = object(input.raw);
const chat = object(raw?.chat);
const botUsername = input.botUsername?.replace(/^@/, "").trim();
const botUrl = botUsername
? `https://t.me/${encodeURIComponent(botUsername)}`
: null;
// A private chat's username belongs to the human, not the bot. Telegram's
// /username/message links apply only to groups and channels; DMs must open
// the configured bot instead, including legacy messages without chat.type.
if (chat?.type === "private" || /^[1-9]\d*$/.test(chatId ?? "")) {
return botUrl;
}
const username =
(typeof chat?.username === "string" ? chat.username.trim() : "") || null;
const messageId =
typeof raw?.message_id === "number" || typeof raw?.message_id === "string"
? String(raw.message_id)
: input.providerMessageId;
if (username && messageId) {
return topicId
? `https://t.me/${encodeURIComponent(username)}/${encodeURIComponent(topicId)}/${encodeURIComponent(messageId)}`
: `https://t.me/${encodeURIComponent(username)}/${encodeURIComponent(messageId)}`;
}
if (chatId?.startsWith("-100") && messageId) {
const channel = chatId.slice(4);
return topicId
? `https://t.me/c/${encodeURIComponent(channel)}/${encodeURIComponent(topicId)}/${encodeURIComponent(messageId)}`
: `https://t.me/c/${encodeURIComponent(channel)}/${encodeURIComponent(messageId)}`;
}
return botUrl;
}
/** Produces only documented HTTPS provider links from stable, verified IDs. */
export function chatProviderConversationUrl(
input: ProviderLinkInput,
): string | null {
if (input.provider === "github") {
const rawUrl = nestedString(input.raw, "comment", "html_url");
if (rawUrl) {
try {
const url = new URL(rawUrl);
if (
url.protocol === "https:" &&
url.hostname === "github.com" &&
/^\/[^/]+\/[^/]+\/(issues|pull)\/\d+/.test(url.pathname)
) {
return url.toString();
}
} catch {
// Fall through to a URL derived from the verified native identifiers.
}
}
const issue = /^github:([^/]+)\/([^:]+):issue:(\d+)$/.exec(input.threadId);
if (issue)
return `https://github.com/${encodeURIComponent(issue[1])}/${encodeURIComponent(issue[2])}/issues/${encodeURIComponent(issue[3])}#issuecomment-${encodeURIComponent(input.providerMessageId)}`;
const review = /^github:([^/]+)\/([^:]+):(\d+)(?::rc:(\d+))?$/.exec(
input.threadId,
);
if (review) {
const anchor = review[4]
? `discussion_r${review[4]}`
: `issuecomment-${input.providerMessageId}`;
return `https://github.com/${encodeURIComponent(review[1])}/${encodeURIComponent(review[2])}/pull/${encodeURIComponent(review[3])}#${anchor}`;
}
return null;
}
if (input.provider === "slack" && input.providerAccountId) {
const match = /^slack:([^:]+):(.*)$/.exec(input.threadId);
if (!match) return null;
if (!match[2]) {
const params = new URLSearchParams({
channel: match[1],
team: input.providerAccountId,
});
return `https://slack.com/app_redirect?${params.toString()}`;
}
return `https://app.slack.com/client/${encodeURIComponent(input.providerAccountId)}/${encodeURIComponent(match[1])}/thread/${encodeURIComponent(`${match[1]}-${match[2].replace(".", "")}`)}`;
}
if (input.provider === "discord") {
const match = /^discord:(\d+):(\d+)(?::(\d+))?$/.exec(input.threadId);
if (!match) return null;
const destination = match[3] ?? match[2];
return `https://discord.com/channels/${encodeURIComponent(match[1])}/${encodeURIComponent(destination)}/${encodeURIComponent(input.providerMessageId)}`;
}
if (input.provider === "microsoft-teams") return teamsConversationLink(input);
if (input.provider === "telegram") return telegramConversationLink(input);
return null;
}

View File

@ -0,0 +1,446 @@
import { describe, expect, it } from "vitest";
import { classifyChatPublicationError } from "./chat-publication-errors.js";
function providerError(
name: string,
code: string,
extra: Record<string, unknown> = {},
) {
return Object.assign(new Error(`${name} test`), { name, code, ...extra });
}
describe("chat publication error classification", () => {
it("honors provider rate-limit timing", () => {
expect(
classifyChatPublicationError(
providerError("AdapterRateLimitError", "RATE_LIMITED", {
retryAfter: 42,
}),
1,
),
).toMatchObject({
kind: "retry",
retryAfterMs: 42_000,
providerRateLimit: true,
});
expect(
classifyChatPublicationError(
Object.assign(new Error("telegram flood control"), {
retry_after: 7,
}),
1,
),
).toMatchObject({ kind: "delivery_unknown" });
expect(
classifyChatPublicationError(
providerError("RateLimitError", "RATE_LIMITED", {
retryAfterMs: 1250,
}),
1,
),
).toMatchObject({ kind: "retry", retryAfterMs: 1250 });
expect(
classifyChatPublicationError(
Object.assign(new Error("slack rate limit"), {
code: "slack_webapi_platform_error",
data: { error: "ratelimited" },
retryAfter: 3,
}),
1,
),
).toMatchObject({ kind: "retry", retryAfterMs: 3000 });
expect(
classifyChatPublicationError(
Object.assign(new Error("You have exceeded a secondary rate limit"), {
name: "HttpError",
status: 403,
response: { headers: { "retry-after": "61" } },
}),
1,
),
).toMatchObject({ kind: "retry", retryAfterMs: 61_000 });
expect(
classifyChatPublicationError(
Object.assign(new Error("API rate limit exceeded"), {
name: "HttpError",
status: 403,
response: {
headers: {
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 60),
},
},
}),
1,
),
).toMatchObject({ kind: "retry" });
});
it("preserves long numeric and HTTP-date provider retry hints", () => {
expect(
classifyChatPublicationError(
providerError("AdapterRateLimitError", "RATE_LIMITED", {
retryAfter: 60 * 60,
}),
4,
),
).toMatchObject({
kind: "retry",
retryAfterMs: 60 * 60 * 1000,
providerRateLimit: true,
});
const now = Date.now();
const retryAt = new Date(now + 2 * 60 * 60 * 1000).toUTCString();
const result = classifyChatPublicationError(
Object.assign(new Error("rate limited"), {
status: 429,
response: { status: 429, headers: { "retry-after": retryAt } },
}),
4,
);
expect(result).toMatchObject({
kind: "retry",
providerRateLimit: true,
});
if (result.kind !== "retry") throw new Error("Expected retry");
expect(result.retryAfterMs).toBeGreaterThanOrEqual(
2 * 60 * 60 * 1000 - 1_500,
);
});
it("keeps a rejected Slack WebClient 429 under durable outbox retry control", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("slack web api rate limit"), {
code: "slack_webapi_rate_limited_error",
retryAfter: 9,
}),
1,
),
).toEqual({
kind: "retry",
retryAfterMs: 9_000,
providerRateLimit: true,
reason: "slack web api rate limit",
});
});
it("retries endpoint lease contention as a definite pre-transport outcome", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("credential mutation is busy"), {
status: 409,
details: { code: "chat_endpoint_credentials_busy" },
}),
1,
),
).toEqual({
kind: "retry",
retryAfterMs: 1_000,
reason: "credential mutation is busy",
});
});
it("does not mistake an ordinary GitHub permission denial for rate limiting", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("Resource not accessible by integration"), {
name: "HttpError",
status: 403,
response: { headers: { "x-ratelimit-remaining": "4999" } },
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it("does not call explicit GitHub 4xx rejections ambiguous delivery", () => {
for (const status of [400, 409, 422]) {
expect(
classifyChatPublicationError(
Object.assign(new Error("GitHub rejected the comment"), {
name: "HttpError",
status,
response: { status, headers: {} },
}),
1,
),
).toMatchObject({ kind: "failed" });
}
expect(
classifyChatPublicationError(
Object.assign(new Error("GitHub destination is gone"), {
name: "HttpError",
status: 410,
response: { status: 410, headers: {} },
}),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
});
it("keeps GitHub 5xx responses ambiguous", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("GitHub internal error"), {
name: "HttpError",
status: 502,
response: { status: 502, headers: {} },
}),
1,
),
).toMatchObject({ kind: "delivery_unknown" });
});
it.each([
["AuthenticationError", "AUTH_FAILED"],
["PermissionError", "PERMISSION_DENIED"],
])("moves definite %s rejections to repair", (name, code) => {
expect(
classifyChatPublicationError(providerError(name, code), 1),
).toMatchObject({
kind: "endpoint_attention",
});
});
it("quarantines Telegram 403 destinations without invalidating the bot token", () => {
expect(
classifyChatPublicationError(
providerError("PermissionError", "PERMISSION_DENIED", {
adapter: "telegram",
action: "sendMessage",
}),
1,
),
).toEqual({
kind: "resource_unavailable",
reason: "PermissionError test",
});
expect(
classifyChatPublicationError(
providerError("AuthenticationError", "AUTH_FAILED", {
adapter: "telegram",
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it.each(["MessageWritesBlocked", "ForbiddenOperationException"])(
"quarantines a Teams destination rejected with %s",
(providerSubCode) => {
expect(
classifyChatPublicationError(
providerError("PermissionError", "PERMISSION_DENIED", {
adapter: "teams",
status: 403,
subCode: providerSubCode,
providerCodes: ["Forbidden", providerSubCode],
details: {
providerStatus: 403,
providerSubCode,
providerCodes: ["Forbidden", providerSubCode],
},
}),
1,
),
).toEqual({
kind: "resource_unavailable",
reason: "PermissionError test",
});
},
);
it("keeps Teams authentication and generic permission failures endpoint-scoped", () => {
expect(
classifyChatPublicationError(
providerError("AuthenticationError", "AUTH_FAILED", {
adapter: "teams",
status: 401,
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
expect(
classifyChatPublicationError(
providerError("PermissionError", "PERMISSION_DENIED", {
adapter: "teams",
status: 403,
providerCodes: ["Authorization_RequestDenied"],
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it.each([50001, 50013])(
"quarantines the Discord destination rejected with provider code %s",
(providerCode) => {
expect(
classifyChatPublicationError(
Object.assign(new Error("Discord API error: 403"), {
name: "NetworkError",
adapter: "discord",
code: "NETWORK_ERROR",
status: 403,
response: { status: 403, headers: {} },
originalError: {
name: "DiscordApiError",
code: providerCode,
status: 403,
},
}),
1,
),
).toEqual({
kind: "resource_unavailable",
reason: "Discord API error: 403",
});
},
);
it("keeps Discord token and generic app permission failures endpoint-scoped", () => {
for (const { status, providerCode } of [
{ status: 401, providerCode: 0 },
{ status: 403, providerCode: 20012 },
]) {
expect(
classifyChatPublicationError(
Object.assign(new Error(`Discord API error: ${status}`), {
name: "NetworkError",
adapter: "discord",
code: "NETWORK_ERROR",
status,
response: { status, headers: {} },
originalError: {
name: "DiscordApiError",
code: providerCode,
status,
},
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
}
});
it("marks a missing provider destination unavailable", () => {
expect(
classifyChatPublicationError(
providerError("ResourceNotFoundError", "NOT_FOUND"),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
expect(
classifyChatPublicationError(
Object.assign(new Error("not invited"), {
code: "slack_webapi_platform_error",
data: { error: "not_in_channel" },
}),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
expect(
classifyChatPublicationError(
providerError("NetworkError", "NETWORK_ERROR", {}),
1,
),
).toMatchObject({ kind: "delivery_unknown" });
expect(
classifyChatPublicationError(
Object.assign(
new Error(
"Resource not found during send activity: conversation may no longer exist",
),
{ name: "NetworkError", code: "NETWORK_ERROR" },
),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
});
it("recognizes definite Slack credential rejection", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("invalid auth"), {
code: "slack_webapi_platform_error",
data: { error: "invalid_auth" },
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it.each(["is_archived", "channel_is_archived"])(
"quarantines a Slack destination rejected with %s",
(platformCode) => {
expect(
classifyChatPublicationError(
Object.assign(new Error("Slack rejected the destination"), {
code: "slack_webapi_platform_error",
data: { error: platformCode },
}),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
},
);
it("fails definite Slack platform rejections without an HTTP status", () => {
for (const platformCode of ["invalid_blocks", "unknown_provider_code"]) {
expect(
classifyChatPublicationError(
Object.assign(new Error("Slack rejected the request"), {
code: "slack_webapi_platform_error",
data: { error: platformCode },
}),
1,
),
).toMatchObject({ kind: "failed" });
}
});
it("finds a structured Slack platform rejection through bounded wrappers", () => {
const platformError = Object.assign(new Error("Slack invalid_blocks"), {
code: "slack_webapi_platform_error",
data: { error: "invalid_blocks" },
});
const wrapper = Object.assign(new Error("Slack block fallback failed"), {
cause: platformError,
});
platformError.cause = wrapper;
expect(classifyChatPublicationError(wrapper, 1)).toMatchObject({
kind: "failed",
});
});
it.each([
["ValidationError", "VALIDATION_ERROR"],
["NotImplementedError", "NOT_IMPLEMENTED"],
["TeamsServiceUrlValidationError", "CHAT_PROVIDER_PRETRANSPORT_REJECTED"],
["TeamsAdapterCompatibilityError", "CHAT_ADAPTER_COMPATIBILITY_ERROR"],
])("fails definite non-retryable %s rejections", (name, code) => {
expect(
classifyChatPublicationError(providerError(name, code), 1),
).toMatchObject({
kind: "failed",
});
});
it.each([
providerError("NetworkError", "NETWORK_ERROR"),
new TypeError("fetch failed"),
new Error("unknown provider transport error"),
])(
"requires operator reconciliation for ambiguous transport errors",
(error) => {
expect(classifyChatPublicationError(error, 1)).toMatchObject({
kind: "delivery_unknown",
});
},
);
});

View File

@ -0,0 +1,376 @@
export type ChatPublicationErrorDisposition =
| {
kind: "retry";
retryAfterMs: number;
/** True when the provider explicitly asked Paperclip to slow down. */
providerRateLimit?: boolean;
reason: string;
}
| {
kind: "delivery_unknown";
reason: string;
}
| {
kind: "endpoint_attention";
reason: string;
}
| {
kind: "resource_unavailable";
reason: string;
}
| {
kind: "failed";
reason: string;
};
function finitePositive(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: null;
}
function text(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function responseHeader(
headers: Headers | Record<string, unknown> | undefined,
name: string,
): string | null {
if (!headers) return null;
if (headers instanceof Headers) return headers.get(name);
const target = name.toLowerCase();
const entry = Object.entries(headers).find(
([key]) => key.toLowerCase() === target,
);
const value = entry?.[1];
return typeof value === "string" || typeof value === "number"
? String(value)
: null;
}
function positiveNumber(value: string | null): number | null {
if (!value) return null;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
// Node timers accept delays through 2^31-1 milliseconds. Keep the durable
// provider hint intact up to that boundary instead of turning an hour-long
// flood-control response into a rapid series of fifteen-minute retries.
const MAX_PROVIDER_RETRY_AFTER_MS = 2_147_000_000;
function retryAfterHeaderMilliseconds(value: string | null): number | null {
if (!value) return null;
const seconds = positiveNumber(value);
if (seconds !== null) return seconds * 1000;
const absolute = Date.parse(value);
if (!Number.isFinite(absolute)) return null;
return Math.max(1, absolute - Date.now());
}
type StructuredProviderError = {
name?: unknown;
adapter?: unknown;
code?: unknown;
retryAfter?: unknown;
retryAfterMs?: unknown;
retry_after?: unknown;
status?: unknown;
statusCode?: unknown;
subCode?: unknown;
providerCodes?: unknown;
data?: { error?: unknown };
response?: {
status?: unknown;
headers?: Headers | Record<string, unknown>;
};
cause?: unknown;
original?: unknown;
originalError?: unknown;
details?: {
code?: unknown;
providerStatus?: unknown;
providerSubCode?: unknown;
providerCodes?: unknown;
};
innerHttpError?: { statusCode?: unknown };
};
/**
* Adapters sometimes wrap the provider SDK error before it reaches the
* durable outbox. Follow only the documented structured wrapper properties,
* with a small depth and cycle guard, so provider codes survive without ever
* classifying on free-form error text.
*/
function structuredProviderErrors(error: unknown): StructuredProviderError[] {
const records: StructuredProviderError[] = [];
const pending: Array<{ value: unknown; depth: number }> = [
{ value: error, depth: 0 },
];
const seen = new Set<object>();
while (pending.length > 0) {
const current = pending.shift();
if (
!current ||
current.depth > 4 ||
!current.value ||
typeof current.value !== "object" ||
seen.has(current.value)
) {
continue;
}
seen.add(current.value);
const record = current.value as StructuredProviderError;
records.push(record);
for (const nested of [
record.cause,
record.original,
record.originalError,
]) {
pending.push({ value: nested, depth: current.depth + 1 });
}
}
return records;
}
/**
* Classify a provider send failure by whether an external side effect could
* already have happened. Only ambiguous network failures stop automatic
* retry. Explicit provider rejections are safe to retry or repair without
* risking a duplicate message.
*/
export function classifyChatPublicationError(
error: unknown,
attempt: number,
): ChatPublicationErrorDisposition {
const values = structuredProviderErrors(error);
const firstNumber = (select: (value: StructuredProviderError) => unknown) =>
values
.map(select)
.find((candidate): candidate is number => typeof candidate === "number");
const names = values
.map((value) => value.name)
.filter((candidate): candidate is string => typeof candidate === "string");
const adapters = values
.map((value) => value.adapter)
.filter((candidate): candidate is string => typeof candidate === "string")
.map((candidate) => candidate.toLowerCase());
const codes = values
.map((value) => value.code)
.filter((candidate): candidate is string => typeof candidate === "string");
const platformCodes = values
.map((value) => value.data?.error)
.filter((candidate): candidate is string => typeof candidate === "string");
const detailsCodes = values
.map((value) => value.details?.code)
.filter((candidate): candidate is string => typeof candidate === "string");
const discordProviderCode =
values
.map((value) => value.code)
.find(
(candidate): candidate is number => typeof candidate === "number",
) ?? null;
const status = values
.flatMap((value) => [
value.status,
value.statusCode,
value.response?.status,
value.innerHttpError?.statusCode,
value.details?.providerStatus,
])
.find((candidate): candidate is number => typeof candidate === "number");
const teamsProviderCodes = values
.flatMap((value) => [
value.subCode,
value.details?.providerSubCode,
...(Array.isArray(value.providerCodes) ? value.providerCodes : []),
...(Array.isArray(value.details?.providerCodes)
? value.details.providerCodes
: []),
])
.filter((candidate): candidate is string => typeof candidate === "string")
.map((candidate) => candidate.toLowerCase());
const reason = text(error);
const responseHeaders = values
.map((value) => value.response?.headers)
.find((headers) => headers !== undefined);
const retryAfterHeaderMs = retryAfterHeaderMilliseconds(
responseHeader(responseHeaders, "retry-after"),
);
const rateLimitRemaining = responseHeader(
responseHeaders,
"x-ratelimit-remaining",
);
const rateLimitReset = positiveNumber(
responseHeader(responseHeaders, "x-ratelimit-reset"),
);
const githubRateLimit =
status === 403 &&
(retryAfterHeaderMs !== null ||
rateLimitRemaining === "0" ||
reason.toLowerCase().includes("secondary rate limit") ||
reason.toLowerCase().includes("rate limit exceeded"));
// Endpoint management owns the same lease as provider transport. Losing a
// short contention race is a definite local pre-transport outcome, so it is
// safe to retry automatically and must never be presented as an ambiguous
// provider delivery.
if (detailsCodes.includes("chat_endpoint_credentials_busy")) {
return { kind: "retry", retryAfterMs: 1_000, reason };
}
if (
names.includes("AdapterRateLimitError") ||
names.includes("RateLimitError") ||
codes.includes("RATE_LIMITED") ||
codes.includes("slack_webapi_rate_limited_error") ||
platformCodes.includes("ratelimited") ||
status === 429 ||
githubRateLimit
) {
const seconds = finitePositive(firstNumber((value) => value.retryAfter));
const milliseconds = finitePositive(
firstNumber((value) => value.retryAfterMs),
);
const telegramSeconds = finitePositive(
firstNumber((value) => value.retry_after),
);
const resetMilliseconds = rateLimitReset
? Math.max(1_000, rateLimitReset * 1_000 - Date.now())
: null;
const structuredSeconds = seconds ?? telegramSeconds;
return {
kind: "retry",
retryAfterMs: Math.min(
MAX_PROVIDER_RETRY_AFTER_MS,
milliseconds ??
(structuredSeconds !== null
? structuredSeconds * 1000
: retryAfterHeaderMs !== null
? retryAfterHeaderMs
: (resetMilliseconds ?? 2 ** Math.max(0, attempt) * 1000)),
),
providerRateLimit: true,
reason,
};
}
if (
// Discord distinguishes a destination the bot cannot access (50001) or
// cannot write to (50013) from invalid credentials and app-wide failures.
// The pinned adapter preserves the bounded numeric API code on its nested
// DiscordApiError. Quarantine only the affected channel/conversation;
// generic Discord 403s and every 401 remain endpoint-scoped below.
adapters.includes("discord") &&
status === 403 &&
(discordProviderCode === 50001 || discordProviderCode === 50013)
) {
return { kind: "resource_unavailable", reason };
}
if (
// Telegram uses 403 for destination-local conditions such as a user
// blocking the bot or removing it from a chat. Its adapter deliberately
// represents those responses as PermissionError while keeping an invalid
// bot token as AuthenticationError (401). Quarantine only that
// conversation/resource; the same bot may still serve every other chat.
adapters.includes("telegram") &&
names.includes("PermissionError") &&
codes.includes("PERMISSION_DENIED")
) {
return { kind: "resource_unavailable", reason };
}
if (
// A Teams app can remain healthy while Microsoft rejects writes to one
// conversation after the bot is blocked, uninstalled, or loses access.
// The pinned adapter preserves only bounded provider code tokens from the
// 403 response body; never use the free-form message for this distinction.
adapters.includes("teams") &&
names.includes("PermissionError") &&
codes.includes("PERMISSION_DENIED") &&
status === 403 &&
teamsProviderCodes.some((providerCode) =>
["messagewritesblocked", "forbiddenoperationexception"].includes(
providerCode,
),
)
) {
return { kind: "resource_unavailable", reason };
}
if (
names.includes("AuthenticationError") ||
names.includes("PermissionError") ||
codes.includes("AUTH_FAILED") ||
codes.includes("PERMISSION_DENIED") ||
status === 401 ||
status === 403 ||
[
"account_inactive",
"invalid_auth",
"missing_scope",
"not_authed",
"no_permission",
"token_revoked",
].some((platformCode) => platformCodes.includes(platformCode))
) {
return { kind: "endpoint_attention", reason };
}
if (
names.includes("ResourceNotFoundError") ||
codes.includes("NOT_FOUND") ||
status === 404 ||
status === 410 ||
[
"channel_not_found",
"channel_is_archived",
"is_archived",
"message_not_found",
"not_in_channel",
"thread_not_found",
].some((platformCode) => platformCodes.includes(platformCode)) ||
(names.includes("NetworkError") &&
reason.toLowerCase().includes("resource not found during"))
) {
return { kind: "resource_unavailable", reason };
}
if (
names.includes("ValidationError") ||
names.includes("NotImplementedError") ||
codes.includes("VALIDATION_ERROR") ||
codes.includes("NOT_IMPLEMENTED") ||
// Paperclip rejected the destination locally before opening a provider
// request, so delivery is definitively impossible rather than ambiguous.
codes.includes("CHAT_PROVIDER_PRETRANSPORT_REJECTED") ||
// Adapter-contract drift is detected during runtime construction, before
// any provider request can have been attempted.
codes.includes("CHAT_ADAPTER_COMPATIBILITY_ERROR")
) {
return { kind: "failed", reason };
}
// Slack WebClient platform errors represent a completed, structured API
// rejection even though the SDK does not expose an HTTP status. Unknown
// platform codes are therefore definite failures, not ambiguous delivery.
if (codes.includes("slack_webapi_platform_error")) {
return { kind: "failed", reason };
}
// Any remaining 4xx response is a definite provider rejection: the
// provider returned an HTTP response and did not accept the operation.
// Keep it out of delivery_unknown, which is reserved for requests whose
// external side effect cannot be determined. Provider-specific repairable
// cases (rate limits, auth, and missing destinations) were handled above.
if (status !== undefined && status >= 400 && status < 500) {
return { kind: "failed", reason };
}
// Adapter NetworkError and ordinary fetch/transport errors are ambiguous:
// the request may have reached the provider even when no response reached
// Paperclip. An operator must inspect the native conversation before replay.
return { kind: "delivery_unknown", reason };
}

Some files were not shown because too many files have changed in this diff Show More