mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 20:53:23 +02:00
refactor(chat): isolate Slack identity connect workflow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
825f32620a
commit
62ee045a47
4 files changed
+151
-77
No files matched your search
@@ -187,3 +187,19 @@ transaction locks, token consumption, membership checks and post-commit notice.
|
||||
body substitution moves the runtime lookup behind the supplied live callback.
|
||||
All remaining statements are unchanged. Local PostgreSQL remains unavailable;
|
||||
record this slice's complete CI and review in its PR before continuing.
|
||||
|
||||
### 2026-10-12 channel-folder reassessment: slice 3
|
||||
|
||||
Keep identity-token consumption, principal scope, membership checks, link locks
|
||||
and revocation in shared `identity-links.ts`. Move Slack receipt queries,
|
||||
self-service access requests and confirmation-notice staging into
|
||||
`slack/identity-links.ts`. GitHub identity fields live in `github/identity-links.ts`.
|
||||
The shared confirmation transaction still stages the Slack notice before commit
|
||||
and schedules it only after commit. The live runtime and public URL callbacks
|
||||
remain live. The Slack factory holds no cache or worker state.
|
||||
|
||||
Four existing public-service cases passed before and after, including the full
|
||||
Slack connect journey: discovery, preview, outsider access request, duplicate
|
||||
request, member confirmation, provider notice, consumed-token rejection and
|
||||
endpoint isolation. Receipt queries, access-request transaction and notice
|
||||
payload retain their statements with explicit parameter substitutions.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { chatExternalPrincipals } from "@paperclipai/db";
|
||||
|
||||
export function githubIdentityFields(
|
||||
principal: Pick<typeof chatExternalPrincipals.$inferSelect, "externalId" | "handle">,
|
||||
) {
|
||||
return { githubUserId: principal.externalId, githubLogin: principal.handle };
|
||||
}
|
||||
@@ -1,33 +1,28 @@
|
||||
import { githubIdentityFields } from "./github/identity-links.js";
|
||||
import { createSlackIdentityLinks, type SlackIdentityEffectStager } from "./slack/identity-links.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, asc, desc, eq, gt, inArray, ne, or, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, gt, inArray, ne, sql } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { authUsers, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, companies, companyMemberships, invites, joinRequests } from "@paperclipai/db";
|
||||
import { authUsers, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, companies, companyMemberships } from "@paperclipai/db";
|
||||
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../../errors.js";
|
||||
|
||||
import { logActivity } from "../activity-log.js";
|
||||
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type DbOrTransaction = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type ConfirmationRuntime = { credentialFingerprint: string; generation: number };
|
||||
|
||||
type ChatIdentityLinkOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
|
||||
getPublicBaseUrl: () => string | null;
|
||||
confirmationRuntimeFor: (endpoint: EndpointRow) => Promise<ConfirmationRuntime | undefined>;
|
||||
stageProviderEffect: (database: DbOrTransaction, input: {
|
||||
endpoint: EndpointRow; principalId: string; providerActionId: string;
|
||||
payload: { version: 1; effect: "ephemeral_message"; authorizationMode: "safe_notice"; threadId: string; userId: string; text: string; settleDelivery: false };
|
||||
runtimeContext: ConfirmationRuntime;
|
||||
}) => Promise<{ id: string } | null>;
|
||||
stageProviderEffect: SlackIdentityEffectStager;
|
||||
scheduleProviderEffect: (actionId: string) => void;
|
||||
};
|
||||
|
||||
export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions) {
|
||||
const { endpointRecord, getPublicBaseUrl, confirmationRuntimeFor, stageProviderEffect, scheduleProviderEffect } = options;
|
||||
|
||||
const slackIdentityLinks = createSlackIdentityLinks(db, { previewIdentityLink, stageProviderEffect });
|
||||
|
||||
async function listPrincipals(endpointId: string) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
@@ -50,16 +45,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
.select()
|
||||
.from(chatIdentityLinks)
|
||||
.where(eq(chatIdentityLinks.endpointId, endpointId));
|
||||
const connects = record.endpoint.provider === "slack" ? await db
|
||||
.select({ principalId: chatActions.principalId, lastConnectAt: sql<string>`max(${chatActions.createdAt})::text` })
|
||||
.from(chatActions)
|
||||
.where(and(
|
||||
eq(chatActions.companyId, record.endpoint.companyId),
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
eq(chatActions.kind, "slack_connect"),
|
||||
eq(chatActions.status, "processed"),
|
||||
))
|
||||
.groupBy(chatActions.principalId) : [];
|
||||
const connects = record.endpoint.provider === "slack" ? await slackIdentityLinks.listConnects(record.endpoint) : [];
|
||||
const connectByPrincipal = new Map(connects.map((row) => [row.principalId, new Date(row.lastConnectAt).toISOString()]));
|
||||
const userIds = links.flatMap((link) =>
|
||||
link.paperclipUserId ? [link.paperclipUserId] : [],
|
||||
@@ -86,7 +72,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
return {
|
||||
id: link?.id ?? principal.id,
|
||||
principalId: principal.id,
|
||||
...(record.endpoint.provider === "github" ? { githubUserId: principal.externalId, githubLogin: principal.handle } : {}),
|
||||
...(record.endpoint.provider === "github" ? githubIdentityFields(principal) : {}),
|
||||
externalLabel:
|
||||
principal.displayName ?? principal.handle ?? principal.externalId,
|
||||
externalDetail: principal.handle
|
||||
@@ -216,11 +202,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
throw unprocessable("This identity-link request is invalid or expired");
|
||||
}
|
||||
if (!["active", "verifying"].includes(row.endpoint.status)) throw unprocessable("This connection is not available");
|
||||
const selfService = Boolean(await db.select({ id: chatActions.id }).from(chatActions).where(and(
|
||||
eq(chatActions.companyId, row.link.companyId), eq(chatActions.endpointId, row.link.endpointId),
|
||||
eq(chatActions.principalId, row.link.principalId), eq(chatActions.kind, "slack_connect"),
|
||||
sql`${chatActions.payload}->>'identityLinkHash' = ${tokenHash}`,
|
||||
)).limit(1).then((rows) => rows[0]));
|
||||
const selfService = await slackIdentityLinks.isSelfService(row.link, tokenHash);
|
||||
const membership = userId ? await db.select({ status: companyMemberships.status }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, row.link.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId),
|
||||
)).then((rows) => rows[0]) : null;
|
||||
@@ -245,43 +227,6 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
};
|
||||
}
|
||||
|
||||
async function requestIdentityAccess(token: string, userId: string, requestIp: string) {
|
||||
const preview = await previewIdentityLink(token, userId);
|
||||
if (!preview.selfService) throw forbidden("This identity link cannot request access");
|
||||
if (preview.canConfirm) return { status: "member" as const };
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-join:${preview.companyId}:${userId}`}, 0))`);
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const validLink = await tx.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.companyId, preview.companyId), eq(chatIdentityLinks.endpointId, preview.endpointId),
|
||||
eq(chatIdentityLinks.confirmationTokenHash, tokenHash), eq(chatIdentityLinks.status, "pending"), gt(chatIdentityLinks.expiresAt, new Date()),
|
||||
)).for("update").then((rows) => rows[0]);
|
||||
if (!validLink) throw unprocessable("This identity-link request is invalid or expired");
|
||||
const user = await tx.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).then((rows) => rows[0]);
|
||||
if (!user) throw forbidden("Sign in to request company access");
|
||||
const existing = await tx.select({ id: joinRequests.id }).from(joinRequests).where(and(
|
||||
eq(joinRequests.companyId, preview.companyId), eq(joinRequests.requestType, "human"), eq(joinRequests.status, "pending_approval"),
|
||||
or(eq(joinRequests.requestingUserId, userId), sql`lower(${joinRequests.requestEmailSnapshot}) = ${user.email.toLowerCase()}`),
|
||||
)).then((rows) => rows[0]);
|
||||
if (existing) return { status: "pending_approval" as const };
|
||||
const now = new Date();
|
||||
const [invite] = await tx.insert(invites).values({
|
||||
companyId: preview.companyId, tokenHash: createHash("sha256").update(randomBytes(32)).digest("hex"),
|
||||
inviteType: "company_join", allowedJoinTypes: "human", acceptedAt: now, expiresAt: now,
|
||||
defaultsPayload: { human: { role: "operator" }, source: "slack_identity_link" },
|
||||
}).returning({ id: invites.id });
|
||||
const [request] = await tx.insert(joinRequests).values({
|
||||
inviteId: invite.id, companyId: preview.companyId, requestType: "human", status: "pending_approval",
|
||||
requestIp, requestingUserId: userId, requestEmailSnapshot: user.email,
|
||||
}).returning({ id: joinRequests.id });
|
||||
await logActivity(tx as unknown as Db, {
|
||||
companyId: preview.companyId, actorType: "user", actorId: userId, action: "join.requested",
|
||||
entityType: "join_request", entityId: request.id, details: { requestType: "human", source: "slack_identity_link", endpointId: preview.endpointId },
|
||||
});
|
||||
return { status: "pending_approval" as const };
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmIdentityLink(token: string, paperclipUserId: string) {
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const link = await db
|
||||
@@ -298,9 +243,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
throw unprocessable("This identity-link request is invalid or expired");
|
||||
const endpointRecordForLink = await endpointRecord(link.endpointId);
|
||||
if (!endpointRecordForLink || !["active", "verifying"].includes(endpointRecordForLink.endpoint.status)) throw unprocessable("This connection is not available");
|
||||
const connectReceipt = endpointRecordForLink.endpoint.provider === "slack" ? await db.select({ payload: chatActions.payload }).from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, link.endpointId), eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
|
||||
)).orderBy(desc(chatActions.createdAt)).limit(1).then((rows) => rows[0]) : null;
|
||||
const connectReceipt = endpointRecordForLink.endpoint.provider === "slack" ? await slackIdentityLinks.latestConnect(link) : null;
|
||||
const confirmationRuntime = connectReceipt ? await confirmationRuntimeFor(endpointRecordForLink.endpoint) : null;
|
||||
let confirmationEffectId: string | null = null;
|
||||
const confirmed = await db.transaction(async (tx) => {
|
||||
@@ -395,16 +338,11 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (confirmedLink && connectReceipt && confirmationRuntime &&
|
||||
typeof connectReceipt.payload.channelId === "string" && typeof connectReceipt.payload.userId === "string") {
|
||||
const effect = await stageProviderEffect(tx, {
|
||||
endpoint: endpointRecordForLink.endpoint, principalId: link.principalId,
|
||||
providerActionId: `provider_effect:identity_linked:${link.id}:${tokenHash}`,
|
||||
payload: { version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice",
|
||||
threadId: connectReceipt.payload.channelId, userId: connectReceipt.payload.userId,
|
||||
text: "Your Slack account is connected to Paperclip. Future messages use your Paperclip permissions.", settleDelivery: false },
|
||||
runtimeContext: confirmationRuntime,
|
||||
confirmationEffectId = await slackIdentityLinks.stageConfirmation(tx, {
|
||||
endpoint: endpointRecordForLink.endpoint, link, tokenHash,
|
||||
channelId: connectReceipt.payload.channelId, userId: connectReceipt.payload.userId,
|
||||
confirmationRuntime,
|
||||
});
|
||||
if (!effect) throw conflict("The Slack connection changed; try confirming again");
|
||||
confirmationEffectId = effect.id;
|
||||
}
|
||||
return confirmedLink;
|
||||
});
|
||||
@@ -455,5 +393,5 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
if (!revoked) throw notFound("Identity link not found");
|
||||
}
|
||||
|
||||
return { listPrincipals, createLinkIntent, previewIdentityLink, requestIdentityAccess, confirmIdentityLink, revokeLink };
|
||||
return { listPrincipals, createLinkIntent, previewIdentityLink, requestIdentityAccess: slackIdentityLinks.requestIdentityAccess, confirmIdentityLink, revokeLink };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, desc, eq, gt, or, sql } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { authUsers, chatActions, chatEndpoints, chatIdentityLinks, invites, joinRequests } from "@paperclipai/db";
|
||||
|
||||
import { conflict, forbidden, unprocessable } from "../../../errors.js";
|
||||
|
||||
import { logActivity } from "../../activity-log.js";
|
||||
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type DbOrTransaction = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type ConfirmationRuntime = { credentialFingerprint: string; generation: number };
|
||||
export type SlackIdentityEffectStager = (database: DbOrTransaction, input: {
|
||||
endpoint: EndpointRow; principalId: string; providerActionId: string;
|
||||
payload: { version: 1; effect: "ephemeral_message"; authorizationMode: "safe_notice"; threadId: string; userId: string; text: string; settleDelivery: false };
|
||||
runtimeContext: ConfirmationRuntime;
|
||||
}) => Promise<{ id: string } | null>;
|
||||
|
||||
type SlackIdentityLinkOptions = {
|
||||
previewIdentityLink: (token: string, userId: string) => Promise<{ selfService: boolean; canConfirm: boolean; companyId: string; endpointId: string }>;
|
||||
stageProviderEffect: SlackIdentityEffectStager;
|
||||
};
|
||||
|
||||
export function createSlackIdentityLinks(db: Db, { previewIdentityLink, stageProviderEffect }: SlackIdentityLinkOptions) {
|
||||
function listConnects(endpoint: EndpointRow) {
|
||||
const endpointId = endpoint.id;
|
||||
return db
|
||||
.select({ principalId: chatActions.principalId, lastConnectAt: sql<string>`max(${chatActions.createdAt})::text` })
|
||||
.from(chatActions)
|
||||
.where(and(
|
||||
eq(chatActions.companyId, endpoint.companyId),
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
eq(chatActions.kind, "slack_connect"),
|
||||
eq(chatActions.status, "processed"),
|
||||
))
|
||||
.groupBy(chatActions.principalId);
|
||||
}
|
||||
|
||||
async function isSelfService(link: typeof chatIdentityLinks.$inferSelect, tokenHash: string) {
|
||||
const selfService = Boolean(await db.select({ id: chatActions.id }).from(chatActions).where(and(
|
||||
eq(chatActions.companyId, link.companyId), eq(chatActions.endpointId, link.endpointId),
|
||||
eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
|
||||
sql`${chatActions.payload}->>'identityLinkHash' = ${tokenHash}`,
|
||||
)).limit(1).then((rows) => rows[0]));
|
||||
return selfService;
|
||||
}
|
||||
|
||||
function latestConnect(link: typeof chatIdentityLinks.$inferSelect) {
|
||||
return db.select({ payload: chatActions.payload }).from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, link.endpointId), eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
|
||||
)).orderBy(desc(chatActions.createdAt)).limit(1).then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
async function stageConfirmation(tx: DbOrTransaction, input: {
|
||||
endpoint: EndpointRow; link: typeof chatIdentityLinks.$inferSelect; tokenHash: string;
|
||||
channelId: string; userId: string; confirmationRuntime: ConfirmationRuntime;
|
||||
}) {
|
||||
const { endpoint, link, tokenHash, channelId, userId, confirmationRuntime } = input;
|
||||
const effect = await stageProviderEffect(tx, {
|
||||
endpoint: endpoint, principalId: link.principalId,
|
||||
providerActionId: `provider_effect:identity_linked:${link.id}:${tokenHash}`,
|
||||
payload: {
|
||||
version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice",
|
||||
threadId: channelId, userId: userId,
|
||||
text: "Your Slack account is connected to Paperclip. Future messages use your Paperclip permissions.", settleDelivery: false
|
||||
},
|
||||
runtimeContext: confirmationRuntime,
|
||||
});
|
||||
if (!effect) throw conflict("The Slack connection changed; try confirming again");
|
||||
return effect.id;
|
||||
}
|
||||
|
||||
async function requestIdentityAccess(token: string, userId: string, requestIp: string) {
|
||||
const preview = await previewIdentityLink(token, userId);
|
||||
if (!preview.selfService) throw forbidden("This identity link cannot request access");
|
||||
if (preview.canConfirm) return { status: "member" as const };
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-join:${preview.companyId}:${userId}`}, 0))`);
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const validLink = await tx.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.companyId, preview.companyId), eq(chatIdentityLinks.endpointId, preview.endpointId),
|
||||
eq(chatIdentityLinks.confirmationTokenHash, tokenHash), eq(chatIdentityLinks.status, "pending"), gt(chatIdentityLinks.expiresAt, new Date()),
|
||||
)).for("update").then((rows) => rows[0]);
|
||||
if (!validLink) throw unprocessable("This identity-link request is invalid or expired");
|
||||
const user = await tx.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).then((rows) => rows[0]);
|
||||
if (!user) throw forbidden("Sign in to request company access");
|
||||
const existing = await tx.select({ id: joinRequests.id }).from(joinRequests).where(and(
|
||||
eq(joinRequests.companyId, preview.companyId), eq(joinRequests.requestType, "human"), eq(joinRequests.status, "pending_approval"),
|
||||
or(eq(joinRequests.requestingUserId, userId), sql`lower(${joinRequests.requestEmailSnapshot}) = ${user.email.toLowerCase()}`),
|
||||
)).then((rows) => rows[0]);
|
||||
if (existing) return { status: "pending_approval" as const };
|
||||
const now = new Date();
|
||||
const [invite] = await tx.insert(invites).values({
|
||||
companyId: preview.companyId, tokenHash: createHash("sha256").update(randomBytes(32)).digest("hex"),
|
||||
inviteType: "company_join", allowedJoinTypes: "human", acceptedAt: now, expiresAt: now,
|
||||
defaultsPayload: { human: { role: "operator" }, source: "slack_identity_link" },
|
||||
}).returning({ id: invites.id });
|
||||
const [request] = await tx.insert(joinRequests).values({
|
||||
inviteId: invite.id, companyId: preview.companyId, requestType: "human", status: "pending_approval",
|
||||
requestIp, requestingUserId: userId, requestEmailSnapshot: user.email,
|
||||
}).returning({ id: joinRequests.id });
|
||||
await logActivity(tx as unknown as Db, {
|
||||
companyId: preview.companyId, actorType: "user", actorId: userId, action: "join.requested",
|
||||
entityType: "join_request", entityId: request.id, details: { requestType: "human", source: "slack_identity_link", endpointId: preview.endpointId },
|
||||
});
|
||||
return { status: "pending_approval" as const };
|
||||
});
|
||||
}
|
||||
|
||||
return { listConnects, isSelfService, latestConnect, stageConfirmation, requestIdentityAccess };
|
||||
}
|
||||
Reference in new issue
Block a user