diff --git a/packages/shared/src/clip-manifest.ts b/packages/shared/src/clip-manifest.ts index 932ee05885e..1fcb44285f7 100644 --- a/packages/shared/src/clip-manifest.ts +++ b/packages/shared/src/clip-manifest.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { z } from "zod"; import type { CompanyPortabilityAgentManifestEntry, @@ -924,7 +923,91 @@ function cloneManifest(manifest: CompanyPortabilityManifest): CompanyPortability } function sha256Stable(value: unknown) { - return `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`; + return `sha256:${sha256Hex(stableStringify(value))}`; +} + +const SHA256_K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +function sha256Hex(input: string) { + const bytes = new TextEncoder().encode(input); + const bitLengthHigh = Math.floor((bytes.length * 8) / 0x100000000); + const bitLengthLow = (bytes.length * 8) >>> 0; + const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6); + const padded = new Uint8Array(paddedLength); + padded.set(bytes); + padded[bytes.length] = 0x80; + padded[paddedLength - 8] = bitLengthHigh >>> 24; + padded[paddedLength - 7] = bitLengthHigh >>> 16; + padded[paddedLength - 6] = bitLengthHigh >>> 8; + padded[paddedLength - 5] = bitLengthHigh; + padded[paddedLength - 4] = bitLengthLow >>> 24; + padded[paddedLength - 3] = bitLengthLow >>> 16; + padded[paddedLength - 2] = bitLengthLow >>> 8; + padded[paddedLength - 1] = bitLengthLow; + + const state = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]; + const words = new Uint32Array(64); + + for (let offset = 0; offset < padded.length; offset += 64) { + for (let index = 0; index < 16; index += 1) { + const wordOffset = offset + index * 4; + words[index] = + ((padded[wordOffset] << 24) | + (padded[wordOffset + 1] << 16) | + (padded[wordOffset + 2] << 8) | + padded[wordOffset + 3]) >>> 0; + } + for (let index = 16; index < 64; index += 1) { + const s0 = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ (words[index - 15] >>> 3); + const s1 = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ (words[index - 2] >>> 10); + words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0; + } + + let [a, b, c, d, e, f, g, h] = state; + for (let index = 0; index < 64; index += 1) { + const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + s1 + ch + SHA256_K[index] + words[index]) >>> 0; + const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (s0 + maj) >>> 0; + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + state[0] = (state[0] + a) >>> 0; + state[1] = (state[1] + b) >>> 0; + state[2] = (state[2] + c) >>> 0; + state[3] = (state[3] + d) >>> 0; + state[4] = (state[4] + e) >>> 0; + state[5] = (state[5] + f) >>> 0; + state[6] = (state[6] + g) >>> 0; + state[7] = (state[7] + h) >>> 0; + } + + return state.map((word) => word.toString(16).padStart(8, "0")).join(""); +} + +function rotateRight(value: number, bits: number) { + return (value >>> bits) | (value << (32 - bits)); } function stableStringify(value: unknown): string { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fc771f7f640..40d4231902b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -730,6 +730,12 @@ export { type ClipDependency, type ClipRevision, type PublicClip, + type ClipSharePreviewRequest, + type ClipSharePreviewResult, + type ClipImportPreviewRequest, + type ClipImportPreviewResult, + type ClipImportApplyRequest, + type ClipImportApplyResult, } from "./types/clip.js"; export { @@ -779,6 +785,9 @@ export { createClipCommentSchema, createClipShowcaseSchema, createClipImportTelemetrySchema, + clipSharePreviewSchema, + clipImportPreviewSchema, + clipImportApplySchema, type CreateClipCreatorProfile, type PublishClip, type CreateClipRevision, @@ -786,6 +795,9 @@ export { type CreateClipVote, type CreateClipReport, type CreateClipComment, + type ClipSharePreview, + type ClipImportPreview, + type ClipImportApply, type CreateClipShowcase, type CreateClipImportTelemetry, environmentDriverSchema, diff --git a/packages/shared/src/types/clip.ts b/packages/shared/src/types/clip.ts index dccb6784bab..e5f07fa3901 100644 --- a/packages/shared/src/types/clip.ts +++ b/packages/shared/src/types/clip.ts @@ -142,3 +142,75 @@ export interface PublicClip { createdAt: Date | string; updatedAt: Date | string; } + +export interface ClipSharePreviewRequest { + source: { + type: ClipType; + id: string; + }; + title?: string; + summary?: string; + slug?: string; + visibility?: ClipVisibility; + revisionNote?: string | null; +} + +export interface ClipSharePreviewResult { + source: { + type: ClipType; + id: string; + label: string; + }; + publishRequest: Record; + exportPreview: Record; + manifest: Record; + dependencyCounts: { + adapters: number; + plugins: number; + skills: number; + secrets: number; + permissions: number; + workspaces: number; + }; + redactionSummary: { + allowed: number; + redacted: number; + summarized: number; + omitted: number; + }; + dangerousCapabilities: string[]; + warnings: string[]; +} + +export interface ClipImportPreviewRequest { + url: string; + collisionStrategy?: "rename" | "skip"; +} + +export interface ClipImportPreviewResult { + clip: PublicClip; + preview: Record; + safety: { + dangerousCapabilities: string[]; + requiredSecrets: string[]; + permissions: string[]; + routineTriggersEnabledByDefault: boolean; + webhookSecretsRegenerated: boolean; + }; + source: { + url: string; + revisionNumber: number; + manifestChecksum: string; + artifactChecksum: string; + }; +} + +export interface ClipImportApplyRequest extends ClipImportPreviewRequest { + selectedOptions?: Record; +} + +export interface ClipImportApplyResult { + importResult: Record; + clip: PublicClip; + source: ClipImportPreviewResult["source"]; +} diff --git a/packages/shared/src/validators/clip.ts b/packages/shared/src/validators/clip.ts index 1f656714325..57eee63cec5 100644 --- a/packages/shared/src/validators/clip.ts +++ b/packages/shared/src/validators/clip.ts @@ -178,3 +178,30 @@ export const createClipImportTelemetrySchema = z.object({ }); export type CreateClipImportTelemetry = z.infer; + +export const clipSharePreviewSchema = z.object({ + source: z.object({ + type: clipTypeSchema, + id: z.string().trim().min(1).max(160), + }), + title: z.string().trim().min(1).max(160).optional(), + summary: shortPublicTextSchema.optional(), + slug: slugSchema.optional(), + visibility: clipVisibilitySchema.optional(), + revisionNote: z.string().trim().max(4_000).nullable().optional(), +}); + +export type ClipSharePreview = z.infer; + +export const clipImportPreviewSchema = z.object({ + url: z.string().trim().min(1).max(2_000), + collisionStrategy: z.enum(["rename", "skip"]).optional(), +}); + +export type ClipImportPreview = z.infer; + +export const clipImportApplySchema = clipImportPreviewSchema.extend({ + selectedOptions: recordSchema.optional(), +}); + +export type ClipImportApply = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index f98009923ae..4fc3ea7f38b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -47,6 +47,9 @@ export { createClipCommentSchema, createClipShowcaseSchema, createClipImportTelemetrySchema, + clipSharePreviewSchema, + clipImportPreviewSchema, + clipImportApplySchema, type CreateClipCreatorProfile, type ClipDependencyInput, type ClipRevisionInput, @@ -58,6 +61,9 @@ export { type CreateClipComment, type CreateClipShowcase, type CreateClipImportTelemetry, + type ClipSharePreview, + type ClipImportPreview, + type ClipImportApply, } from "./clip.js"; export { environmentDriverSchema, diff --git a/server/src/app.ts b/server/src/app.ts index 00a0109be27..a3b5c25dd5c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -205,7 +205,7 @@ export async function createApp( api.use(approvalRoutes(db, { pluginWorkerManager: workerManager })); api.use(secretRoutes(db)); api.use(costRoutes(db, { pluginWorkerManager: workerManager })); - api.use(clipRoutes(db)); + api.use(clipRoutes(db, opts.storageService)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(userProfileRoutes(db)); diff --git a/server/src/routes/clips.ts b/server/src/routes/clips.ts index 489bff100aa..708f87f0aad 100644 --- a/server/src/routes/clips.ts +++ b/server/src/routes/clips.ts @@ -1,6 +1,15 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { + buildAgentClipSnapshot, + buildBundleClipSnapshot, + buildRoutineClipSnapshot, + buildSkillClipSnapshot, + buildTeamClipSnapshot, + clipImportApplySchema, + clipImportPreviewSchema, + clipManifestSchema, + clipSharePreviewSchema, createClipCommentSchema, createClipCreatorProfileSchema, createClipImportTelemetrySchema, @@ -10,12 +19,14 @@ import { createClipVoteSchema, publishClipSchema, updateClipSchema, + type CompanyPortabilityFileEntry, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { forbidden } from "../errors.js"; +import { forbidden, unprocessable } from "../errors.js"; import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js"; import { clipService } from "../services/clips.js"; -import { logActivity } from "../services/index.js"; +import { companyPortabilityService, companyService, logActivity } from "../services/index.js"; +import type { StorageService } from "../storage/types.js"; const CLIP_RATE_LIMIT_WINDOW_MS = 60_000; const CLIP_RATE_LIMIT_MAX_REQUESTS = 60; @@ -72,9 +83,140 @@ function consumeClipRateLimit(req: Request, res: { setHeader(name: string, value return true; } -export function clipRoutes(db: Db) { +export function clipRoutes(db: Db, storage?: StorageService) { const router = Router(); const svc = clipService(db); + const portability = companyPortabilityService(db, storage); + const companies = companyService(db); + + function slugify(value: string, fallback: string) { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") + .slice(0, 96); + return slug.length >= 3 ? slug : fallback; + } + + function clipSlugFromUrl(value: string) { + const trimmed = value.trim(); + if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed)) return trimmed; + try { + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + const index = parts.findIndex((part) => part === "clips"); + const slug = index >= 0 ? parts[index + 1] : parts.at(-1); + if (slug && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return slug; + } catch { + // Fall through to the validation error below. + } + throw unprocessable("Enter a clip URL or slug."); + } + + function dependencyInputsFromManifest(manifest: ReturnType) { + return [ + ...manifest.dependencies.adapters.map((entry) => ({ + type: "adapter" as const, + key: entry.type, + displayName: entry.type, + required: entry.required ? "required" as const : "optional" as const, + metadata: { sourceRefs: entry.sourceRefs, note: entry.note }, + })), + ...manifest.dependencies.plugins.map((entry) => ({ + type: "plugin" as const, + key: entry.key, + displayName: entry.key, + required: entry.requirement, + metadata: { sourceRefs: entry.sourceRefs, note: entry.note }, + })), + ...manifest.dependencies.skills.map((entry) => ({ + type: "skill" as const, + key: entry.key, + displayName: entry.slug, + required: entry.requirement, + metadata: { sourceRefs: entry.sourceRefs }, + })), + ...manifest.dependencies.secrets.map((entry) => ({ + type: "secret" as const, + key: entry.key, + displayName: entry.key, + required: entry.requirement, + metadata: { sourceRefs: entry.sourceRefs, description: entry.description }, + })), + ...manifest.dependencies.permissions.map((entry) => ({ + type: "permission" as const, + key: entry.capability, + displayName: entry.capability, + required: "required" as const, + metadata: { sourceRefs: entry.sourceRefs, reason: entry.reason }, + })), + ...manifest.dependencies.workspaces.map((entry) => ({ + type: "workspace" as const, + key: entry.key, + displayName: entry.key, + required: entry.repoUrlRequired ? "required" as const : "optional" as const, + metadata: { sourceRefs: entry.sourceRefs, pinnedRefRecommended: entry.pinnedRefRecommended }, + })), + ]; + } + + function clipFilesFromPayload(payload: Record) { + const artifact = payload.artifact; + const artifactPayload = + artifact && typeof artifact === "object" + ? (artifact as Record).payload + : null; + if (!artifactPayload || typeof artifactPayload !== "object") return null; + const files = (artifactPayload as Record).files; + if (!files || typeof files !== "object" || Array.isArray(files)) return null; + return files as Record; + } + + async function buildClipImportPreview(companyId: string, input: { url: string; collisionStrategy?: "rename" | "skip" }) { + const slug = clipSlugFromUrl(input.url); + const clip = await svc.getPublicDetail(slug); + if (!clip?.currentRevision) { + throw unprocessable("Clip was not found or is not importable."); + } + const manifestPayload = clip.currentRevision.manifestPayload; + const parsed = clipManifestSchema.safeParse(manifestPayload); + if (!parsed.success) { + throw unprocessable("Clip revision does not contain a valid clip manifest."); + } + const files = clipFilesFromPayload(manifestPayload); + if (!files) { + throw unprocessable("Clip revision does not include portable files. Publish it again from the app before importing."); + } + const preview = await portability.previewImport({ + source: { type: "inline", rootPath: parsed.data.clip.slug, files }, + include: { company: false, agents: true, projects: true, issues: true, skills: true }, + target: { mode: "existing_company", companyId }, + collisionStrategy: input.collisionStrategy ?? "rename", + }, { + mode: "agent_safe", + sourceCompanyId: companyId, + }); + return { + clip, + parsedManifest: parsed.data, + files, + preview, + source: { + url: input.url, + revisionNumber: clip.currentRevision.revisionNumber, + manifestChecksum: clip.currentRevision.manifestChecksum, + artifactChecksum: clip.currentRevision.artifactChecksum, + }, + safety: { + dangerousCapabilities: parsed.data.security.dangerousCapabilities, + requiredSecrets: parsed.data.dependencies.secrets.map((entry) => entry.key), + permissions: parsed.data.dependencies.permissions.map((entry) => entry.capability), + routineTriggersEnabledByDefault: parsed.data.security.routinePolicy.importedTriggersEnabledByDefault, + webhookSecretsRegenerated: parsed.data.security.routinePolicy.webhookSecretsRegenerated, + }, + }; + } router.get("/public/clips", async (req, res) => { const q = typeof req.query.q === "string" ? req.query.q : null; @@ -155,6 +297,185 @@ export function clipRoutes(db: Db) { res.status(201).json(profile); }); + router.post("/companies/:companyId/clips/share-preview", validate(clipSharePreviewSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const company = await companies.getById(companyId); + if (!company) { + res.status(404).json({ error: "Company not found" }); + return; + } + + const body = req.body as { + source: { type: "team" | "agent" | "skill" | "routine" | "bundle"; id: string }; + title?: string; + summary?: string; + slug?: string; + visibility?: "private_share" | "unlisted" | "public"; + revisionNote?: string | null; + }; + const include = { company: false, agents: false, projects: false, issues: false, skills: false }; + const exportRequest: { + include: typeof include; + agents?: string[]; + skills?: string[]; + issues?: string[]; + expandReferencedSkills?: boolean; + } = { include }; + + if (body.source.type === "agent") { + exportRequest.include.agents = true; + exportRequest.include.skills = true; + exportRequest.agents = [body.source.id]; + } else if (body.source.type === "team") { + exportRequest.include.agents = true; + exportRequest.include.skills = true; + exportRequest.agents = body.source.id === "company" ? undefined : [body.source.id]; + } else if (body.source.type === "skill") { + exportRequest.include.skills = true; + exportRequest.skills = [body.source.id]; + } else if (body.source.type === "routine") { + exportRequest.include.agents = true; + exportRequest.include.projects = true; + exportRequest.include.issues = true; + exportRequest.include.skills = true; + exportRequest.issues = [body.source.id]; + } else { + exportRequest.include.agents = true; + exportRequest.include.projects = true; + exportRequest.include.issues = true; + exportRequest.include.skills = true; + } + exportRequest.expandReferencedSkills = true; + + const exportPreview = await portability.previewExport(companyId, exportRequest); + const sourceLabel = + exportPreview.manifest.agents[0]?.name + ?? exportPreview.manifest.skills[0]?.name + ?? exportPreview.manifest.issues[0]?.title + ?? exportPreview.manifest.company?.name + ?? company.name; + const title = body.title ?? `${sourceLabel} ${body.source.type === "bundle" ? "bundle" : "clip"}`; + const summary = body.summary ?? `Portable ${body.source.type} package shared from ${company.name}.`; + const visibility = body.visibility ?? "unlisted"; + const slug = body.slug ?? slugify(title, `clip-${body.source.type}`); + const creator = { + profileId: null, + handle: slugify(company.issuePrefix || company.name, "paperclip-company"), + displayName: company.name, + }; + const snapshotInput = { + clip: { + slug, + revisionNumber: 1, + title, + summary, + visibility, + creator, + }, + artifact: { + manifest: exportPreview.manifest, + files: exportPreview.files, + }, + source: { + exportedAt: new Date().toISOString(), + paperclipCompatibility: null, + }, + }; + const snapshot = + body.source.type === "agent" + ? buildAgentClipSnapshot(snapshotInput) + : body.source.type === "team" + ? buildTeamClipSnapshot(snapshotInput) + : body.source.type === "skill" + ? buildSkillClipSnapshot(snapshotInput) + : body.source.type === "routine" + ? buildRoutineClipSnapshot(snapshotInput) + : buildBundleClipSnapshot(snapshotInput); + const manifestPayload = { + ...snapshot, + artifact: { + ...snapshot.artifact, + payload: { + ...snapshot.artifact.payload, + files: exportPreview.files, + }, + }, + }; + const parsedManifest = clipManifestSchema.parse(manifestPayload); + const publishRequest = { + creatorProfile: { + handle: creator.handle, + displayName: creator.displayName, + }, + slug, + type: body.source.type, + title, + summary, + description: summary, + visibility, + tags: [body.source.type], + categories: [], + useCases: [], + requiredProviders: parsedManifest.dependencies.adapters.map((entry) => entry.type), + compatibility: parsedManifest.publication.compatibility, + sourceKind: "paperclip_company_object", + sourceObjectType: body.source.type, + sourceObjectId: body.source.id, + revision: { + manifestVersion: parsedManifest.schema, + manifestChecksum: parsedManifest.checksums.manifest, + artifactChecksum: parsedManifest.checksums.artifact, + manifestPayload, + artifactRef: null, + dependencyGraph: parsedManifest.dependencies, + dependencies: dependencyInputsFromManifest(parsedManifest), + permissions: parsedManifest.dependencies.permissions.map((entry) => ({ + capability: entry.capability, + reason: entry.reason, + })), + secretsSchema: parsedManifest.dependencies.secrets.map((entry) => ({ + key: entry.key, + description: entry.description, + requirement: entry.requirement, + })), + budgetEstimate: { + monthlyCents: parsedManifest.dependencies.budgetHints.monthlyCents, + }, + redactionReport: parsedManifest.security.redactionReport, + dangerousCapabilities: parsedManifest.security.dangerousCapabilities, + securityReviewState: parsedManifest.security.reviewState, + verificationState: parsedManifest.verification.validationStatus, + compatibility: parsedManifest.publication.compatibility, + changeSummary: body.revisionNote ?? null, + breakingChanges: null, + migrationNotes: null, + }, + }; + + res.json({ + source: { + type: body.source.type, + id: body.source.id, + label: sourceLabel, + }, + publishRequest, + exportPreview, + manifest: parsedManifest, + dependencyCounts: { + adapters: parsedManifest.dependencies.adapters.length, + plugins: parsedManifest.dependencies.plugins.length, + skills: parsedManifest.dependencies.skills.length, + secrets: parsedManifest.dependencies.secrets.length, + permissions: parsedManifest.dependencies.permissions.length, + workspaces: parsedManifest.dependencies.workspaces.length, + }, + redactionSummary: parsedManifest.security.redactionReport.summary, + dangerousCapabilities: parsedManifest.security.dangerousCapabilities, + warnings: exportPreview.warnings, + }); + }); + router.post("/companies/:companyId/clips/publish", validate(publishClipSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -181,6 +502,71 @@ export function clipRoutes(db: Db) { res.status(201).json(result); }); + router.post("/companies/:companyId/clips/import-preview", validate(clipImportPreviewSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await buildClipImportPreview(companyId, req.body); + res.json({ + clip: result.clip, + preview: result.preview, + safety: result.safety, + source: result.source, + }); + }); + + router.post("/companies/:companyId/clips/import", validate(clipImportApplySchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await buildClipImportPreview(companyId, req.body); + const importResult = await portability.importBundle({ + source: { type: "inline", rootPath: result.parsedManifest.clip.slug, files: result.files }, + include: { company: false, agents: true, projects: true, issues: true, skills: true }, + target: { mode: "existing_company", companyId }, + collisionStrategy: req.body.collisionStrategy ?? "rename", + }, req.actor.type === "board" ? req.actor.userId : null, { + mode: "agent_safe", + sourceCompanyId: companyId, + }); + await svc.recordImportTelemetry(result.clip.slug, { + revisionNumber: result.source.revisionNumber, + destinationCompanyId: companyId, + status: "applied", + sourceUrl: result.source.url.startsWith("http") ? result.source.url : null, + revisionUrl: result.clip.currentRevision + ? `/clips/${result.clip.slug}/revisions/${result.clip.currentRevision.revisionNumber}` + : null, + metadata: { + selectedOptions: req.body.selectedOptions ?? {}, + agentCount: importResult.agents.length, + projectCount: importResult.projects.length, + warningCount: importResult.warnings.length, + }, + }, actorForClip(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "clip.imported", + entityType: "clip", + entityId: result.clip.id, + details: { + slug: result.clip.slug, + revisionNumber: result.source.revisionNumber, + agentCount: importResult.agents.length, + projectCount: importResult.projects.length, + warningCount: importResult.warnings.length, + }, + }); + res.json({ + importResult, + clip: result.clip, + source: result.source, + }); + }); + router.post("/clips/:clipId/revisions", validate(createClipRevisionSchema), async (req, res) => { const clip = await svc.getClipById(req.params.clipId as string); if (!clip) { diff --git a/server/src/services/clips.ts b/server/src/services/clips.ts index 5abb693edf8..f23b113cd6c 100644 --- a/server/src/services/clips.ts +++ b/server/src/services/clips.ts @@ -123,6 +123,10 @@ export function clipService(db: Db) { return first(await db.select().from(clipCreatorProfiles).where(eq(clipCreatorProfiles.id, id)).limit(1)); } + async function getCreatorProfileByHandle(handle: string) { + return first(await db.select().from(clipCreatorProfiles).where(eq(clipCreatorProfiles.handle, handle)).limit(1)); + } + async function getClipById(id: string) { return first(await db.select().from(clips).where(eq(clips.id, id)).limit(1)); } @@ -252,6 +256,13 @@ export function clipService(db: Db) { return profile; } if (!input.creatorProfile) throw unprocessable("creatorProfileId or creatorProfile is required"); + const existing = await getCreatorProfileByHandle(input.creatorProfile.handle); + if (existing) { + if (existing.companyId !== companyId) { + throw conflict("Creator profile handle already belongs to another company"); + } + return existing; + } return createCreatorProfile(companyId, input.creatorProfile); } @@ -670,6 +681,7 @@ export function clipService(db: Db) { return { createCreatorProfile, getCreatorProfileById, + getCreatorProfileByHandle, getClipById, getClipBySlug, listPublic, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0da91b9a183..d0770f78d11 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,7 @@ import { Search } from "./pages/Search"; import { IssueDetail } from "./pages/IssueDetail"; import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf"; import { Routines } from "./pages/Routines"; +import { Clips } from "./pages/Clips"; import { RoutineDetail } from "./pages/RoutineDetail"; import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; @@ -110,6 +111,7 @@ function boardRoutes() { } /> ) : null} } /> + } /> } /> } /> } /> diff --git a/ui/src/api/clips.ts b/ui/src/api/clips.ts new file mode 100644 index 00000000000..4a43fd29dbf --- /dev/null +++ b/ui/src/api/clips.ts @@ -0,0 +1,44 @@ +import type { + ClipImportApplyRequest, + ClipImportApplyResult, + ClipImportPreviewRequest, + ClipImportPreviewResult, + ClipSharePreviewRequest, + ClipSharePreviewResult, + PublicClip, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const clipsApi = { + listPublic: (params: { q?: string; type?: string; tag?: string; limit?: number } = {}) => { + const searchParams = new URLSearchParams(); + if (params.q) searchParams.set("q", params.q); + if (params.type) searchParams.set("type", params.type); + if (params.tag) searchParams.set("tag", params.tag); + if (params.limit) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + return api.get(`/public/clips${qs ? `?${qs}` : ""}`); + }, + getPublic: (slug: string) => + api.get(`/public/clips/${encodeURIComponent(slug)}`), + sharePreview: (companyId: string, payload: ClipSharePreviewRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/clips/share-preview`, + payload, + ), + publish: (companyId: string, payload: Record) => + api.post<{ clip: PublicClip; revision: Record; creatorProfile: Record }>( + `/companies/${encodeURIComponent(companyId)}/clips/publish`, + payload, + ), + importPreview: (companyId: string, payload: ClipImportPreviewRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/clips/import-preview`, + payload, + ), + importClip: (companyId: string, payload: ClipImportApplyRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/clips/import`, + payload, + ), +}; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 5a73a7d14f5..e01848d587d 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -18,3 +18,4 @@ export { sidebarBadgesApi } from "./sidebarBadges"; export { sidebarPreferencesApi } from "./sidebarPreferences"; export { inboxDismissalsApi } from "./inboxDismissals"; export { companySkillsApi } from "./companySkills"; +export { clipsApi } from "./clips"; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index a0bc71955ab..0ee9b4f0b92 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -10,6 +10,7 @@ import { Network, Boxes, Repeat, + Paperclip, GitBranch, Settings, } from "lucide-react"; @@ -101,6 +102,7 @@ export function Sidebar() { + {showWorkspacesLink ? ( diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 302199f6e5e..35978873b79 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -12,6 +12,12 @@ export const queryKeys = { file: (companyId: string, skillId: string, relativePath: string) => ["company-skills", companyId, skillId, "file", relativePath] as const, }, + clips: { + publicList: (params?: { q?: string; type?: string; tag?: string; limit?: number }) => + ["clips", "public", params ?? {}] as const, + publicDetail: (slug: string) => ["clips", "public", slug] as const, + sharePreview: (companyId: string) => ["clips", companyId, "share-preview"] as const, + }, agents: { list: (companyId: string) => ["agents", companyId] as const, detail: (id: string) => ["agents", "detail", id] as const, diff --git a/ui/src/pages/Clips.tsx b/ui/src/pages/Clips.tsx new file mode 100644 index 00000000000..28f699a74bb --- /dev/null +++ b/ui/src/pages/Clips.tsx @@ -0,0 +1,604 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + Check, + ClipboardCheck, + Copy, + Download, + ExternalLink, + FileSearch, + Package, + Repeat, + ShieldAlert, + Sparkles, + Upload, +} from "lucide-react"; +import type { + ClipSharePreviewResult, + ClipType, + ClipVisibility, + PublicClip, +} from "@paperclipai/shared"; +import { clipsApi } from "../api/clips"; +import { agentsApi } from "../api/agents"; +import { routinesApi } from "../api/routines"; +import { companySkillsApi } from "../api/companySkills"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; +import { useToastActions } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { cn } from "../lib/utils"; +import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +type ShareSource = { + id: string; + type: ClipType; + label: string; + description: string; +}; + +const visibilityLabels: Record = { + private_share: "Private link", + unlisted: "Unlisted", + public: "Public review", +}; + +function asPreviewRecord(value: unknown) { + return value && typeof value === "object" ? value as Record : {}; +} + +function getPreviewPlan(preview: unknown) { + const record = asPreviewRecord(preview); + return asPreviewRecord(record.plan); +} + +function getPreviewArray>(preview: unknown, key: string): T[] { + const value = getPreviewPlan(preview)[key]; + return Array.isArray(value) ? value as T[] : []; +} + +function formatCount(value: number, singular: string, plural = `${singular}s`) { + return `${value} ${value === 1 ? singular : plural}`; +} + +function clipRevisionLabel(clip: PublicClip) { + const revision = clip.currentRevision?.revisionNumber; + return revision ? `revision ${revision}` : "no public revision"; +} + +function safetyTone(clip: PublicClip) { + if (clip.moderationState === "warning" || clip.moderationState === "under_review") return "text-amber-600"; + if (clip.moderationState === "limited" || clip.moderationState === "blocked") return "text-destructive"; + return "text-muted-foreground"; +} + +export function Clips() { + const { selectedCompanyId, selectedCompany } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToastActions(); + const queryClient = useQueryClient(); + const [importUrl, setImportUrl] = useState(""); + const [shareOpen, setShareOpen] = useState(false); + const [shareSource, setShareSource] = useState(null); + const [shareDraft, setShareDraft] = useState({ + title: "", + summary: "", + slug: "", + visibility: "unlisted" as ClipVisibility, + revisionNote: "", + }); + + useEffect(() => { + setBreadcrumbs([{ label: "Clips" }]); + }, [setBreadcrumbs]); + + const { data: publicClips, isLoading: clipsLoading } = useQuery({ + queryKey: queryKeys.clips.publicList({ limit: 18 }), + queryFn: () => clipsApi.listPublic({ limit: 18 }), + }); + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const { data: routines } = useQuery({ + queryKey: queryKeys.routines.list(selectedCompanyId!), + queryFn: () => routinesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const { data: skills } = useQuery({ + queryKey: queryKeys.companySkills.list(selectedCompanyId!), + queryFn: () => companySkillsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const shareSources = useMemo(() => { + const sources: ShareSource[] = []; + if (selectedCompany) { + sources.push({ + id: "company", + type: "bundle", + label: `${selectedCompany.name} bundle`, + description: "Share the current company as a bundled starter package.", + }); + sources.push({ + id: "company", + type: "team", + label: `${selectedCompany.name} team`, + description: "Share the org team and selected skills as a team clip.", + }); + } + for (const agent of agents ?? []) { + sources.push({ + id: agent.id, + type: "agent", + label: agent.name, + description: agent.capabilities ?? agent.title ?? agent.role, + }); + } + for (const skill of skills ?? []) { + sources.push({ + id: skill.id, + type: "skill", + label: skill.name, + description: skill.description ?? skill.key, + }); + } + for (const routine of routines ?? []) { + sources.push({ + id: routine.id, + type: "routine", + label: routine.title, + description: routine.description ?? "Recurring workflow with safe trigger review on import.", + }); + } + return sources; + }, [agents, routines, selectedCompany, skills]); + + const sharePreviewMutation = useMutation({ + mutationFn: () => { + if (!selectedCompanyId || !shareSource) throw new Error("Select a source first."); + return clipsApi.sharePreview(selectedCompanyId, { + source: { type: shareSource.type, id: shareSource.id }, + title: shareDraft.title || undefined, + summary: shareDraft.summary || undefined, + slug: shareDraft.slug || undefined, + visibility: shareDraft.visibility, + revisionNote: shareDraft.revisionNote || null, + }); + }, + onError: (error) => { + pushToast({ + title: "Share preview failed", + body: error instanceof Error ? error.message : "Paperclip could not build the clip preview.", + tone: "error", + }); + }, + }); + + const publishMutation = useMutation({ + mutationFn: (preview: ClipSharePreviewResult) => { + if (!selectedCompanyId) throw new Error("Select a company first."); + return clipsApi.publish(selectedCompanyId, preview.publishRequest); + }, + onSuccess: (result) => { + pushToast({ + title: "Clip published", + body: `${result.clip.title} is available as ${result.clip.visibility}.`, + tone: "success", + }); + setShareOpen(false); + sharePreviewMutation.reset(); + void queryClient.invalidateQueries({ queryKey: queryKeys.clips.publicList({ limit: 18 }) }); + }, + onError: (error) => { + pushToast({ + title: "Publish failed", + body: error instanceof Error ? error.message : "Paperclip could not publish this clip.", + tone: "error", + }); + }, + }); + + const importPreviewMutation = useMutation({ + mutationFn: (urlOverride?: string) => { + if (!selectedCompanyId) throw new Error("Select a company first."); + const url = (urlOverride ?? importUrl).trim(); + return clipsApi.importPreview(selectedCompanyId, { + url, + collisionStrategy: "rename", + }); + }, + onError: (error) => { + pushToast({ + title: "Import preview failed", + body: error instanceof Error ? error.message : "Paperclip could not resolve that clip.", + tone: "error", + }); + }, + }); + + const importMutation = useMutation({ + mutationFn: () => { + if (!selectedCompanyId) throw new Error("Select a company first."); + return clipsApi.importClip(selectedCompanyId, { + url: importUrl.trim(), + collisionStrategy: "rename", + selectedOptions: { routineTriggers: "review_required" }, + }); + }, + onSuccess: (result) => { + pushToast({ + title: "Clip imported", + body: `${result.clip.title} was imported with routine triggers still review-gated.`, + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Import failed", + body: error instanceof Error ? error.message : "Paperclip could not import this clip.", + tone: "error", + }); + }, + }); + + function openShare(source: ShareSource) { + setShareSource(source); + setShareDraft({ + title: source.label, + summary: source.description.slice(0, 240) || `Portable ${source.type} shared from Paperclip.`, + slug: source.label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80), + visibility: "unlisted", + revisionNote: "Initial app share.", + }); + sharePreviewMutation.reset(); + publishMutation.reset(); + setShareOpen(true); + } + + const activePreview = importPreviewMutation.data; + const importAgentPlans = getPreviewArray(activePreview?.preview, "agentPlans"); + const importProjectPlans = getPreviewArray(activePreview?.preview, "projectPlans"); + const importIssuePlans = getPreviewArray(activePreview?.preview, "issuePlans"); + + if (!selectedCompanyId) { + return ; + } + + if (clipsLoading) { + return ; + } + + return ( +
+
+
+

Clips

+

+ Publish reusable teams, agents, skills, and routines, or import a clip after a safety preview. +

+
+ +
+ +
+
+
+ + Import from URL +
+
+
+ setImportUrl(event.target.value)} + placeholder="https://paperclip.ing/clips/support-triage or support-triage" + /> + +
+ {activePreview ? ( +
+
+
+
{activePreview.clip.title}
+
+ {clipRevisionLabel(activePreview.clip)} · {activePreview.source.manifestChecksum} +
+
+ + {(asPreviewRecord(activePreview.preview).warnings as string[] | undefined)?.length ? ( + + ) : null} +
+ +
+
+ Import is blocked if preview errors exist. Recurring and webhook triggers stay disabled until reviewed. +
+ +
+
+ ) : null} +
+
+ +
+
+ + Share from app +
+
+ {shareSources.slice(0, 6).map((source) => ( + + ))} +
+
+
+ +
+
+ + Public catalog +
+ {publicClips && publicClips.length > 0 ? ( +
+ {publicClips.map((clip) => ( + { + setImportUrl(clip.slug); + importPreviewMutation.mutate(clip.slug); + }} /> + ))} +
+ ) : ( + + )} +
+ + + + + Share clip + + Review dependencies, redaction, visibility, and the public preview before publishing. + + +
+
+ setShareDraft((prev) => ({ ...prev, title: value }))} /> + setShareDraft((prev) => ({ ...prev, slug: value }))} /> +