diff --git a/packages/db/src/migrations/0140_clips_social.sql b/packages/db/src/migrations/0140_clips_social.sql new file mode 100644 index 00000000000..c479cdd49ae --- /dev/null +++ b/packages/db/src/migrations/0140_clips_social.sql @@ -0,0 +1,271 @@ +CREATE TABLE "clip_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid, + "creator_profile_id" uuid, + "author_user_id" text, + "author_agent_id" uuid, + "scope" text DEFAULT 'clip' NOT NULL, + "category" text DEFAULT 'question' NOT NULL, + "moderation_state" text DEFAULT 'normal' NOT NULL, + "body" text 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 "clip_creator_profiles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "handle" text NOT NULL, + "display_name" text NOT NULL, + "bio" text, + "avatar_url" text, + "website_url" text, + "verification_state" text DEFAULT 'unverified' NOT NULL, + "reputation_summary" 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 +); +--> statement-breakpoint +CREATE TABLE "clip_dependencies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid NOT NULL, + "type" text NOT NULL, + "key" text NOT NULL, + "display_name" text, + "required" text DEFAULT 'required' NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clip_flags" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid, + "reporter_type" text DEFAULT 'anonymous' NOT NULL, + "reporter_id" text, + "reason" text NOT NULL, + "details" text, + "status" text DEFAULT 'open' 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 +); +--> statement-breakpoint +CREATE TABLE "clip_imported_objects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_import_id" uuid NOT NULL, + "destination_object_type" text NOT NULL, + "destination_object_id" text NOT NULL, + "package_path" text, + "action" text NOT NULL, + "local_fingerprint" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clip_imports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "destination_company_id" uuid NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid NOT NULL, + "revision_number" integer NOT NULL, + "manifest_checksum" text NOT NULL, + "artifact_checksum" text NOT NULL, + "source_url" text, + "revision_url" text, + "status" text DEFAULT 'previewed' NOT NULL, + "imported_by_user_id" text, + "imported_by_agent_id" uuid, + "collision_strategy" text, + "selected_options" jsonb DEFAULT '{}'::jsonb NOT NULL, + "first_run_validation_state" text DEFAULT 'not_run' 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 "clip_moderation_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid, + "actor_type" text NOT NULL, + "actor_id" text NOT NULL, + "from_state" text, + "to_state" text NOT NULL, + "reason" text NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clip_public_metric_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid, + "event_type" text NOT NULL, + "actor_hash" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clip_ranking_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "scope" text DEFAULT 'global' NOT NULL, + "score" integer DEFAULT 0 NOT NULL, + "factors" jsonb DEFAULT '{}'::jsonb NOT NULL, + "snapshot_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clip_revisions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_number" integer NOT NULL, + "manifest_version" text DEFAULT 'paperclip.clip/v1' NOT NULL, + "manifest_checksum" text NOT NULL, + "artifact_checksum" text NOT NULL, + "manifest_payload" jsonb NOT NULL, + "artifact_ref" jsonb, + "dependency_graph" jsonb DEFAULT '{}'::jsonb NOT NULL, + "permissions" jsonb DEFAULT '[]'::jsonb NOT NULL, + "secrets_schema" jsonb DEFAULT '[]'::jsonb NOT NULL, + "budget_estimate" jsonb, + "redaction_report" jsonb DEFAULT '{}'::jsonb NOT NULL, + "dangerous_capabilities" jsonb DEFAULT '[]'::jsonb NOT NULL, + "security_review_state" text DEFAULT 'unreviewed' NOT NULL, + "verification_state" text DEFAULT 'not_run' NOT NULL, + "compatibility" jsonb DEFAULT '{}'::jsonb NOT NULL, + "change_summary" text, + "breaking_changes" text, + "migration_notes" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clip_showcase_posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid NOT NULL, + "creator_profile_id" uuid, + "author_user_id" text, + "author_agent_id" uuid, + "type" text DEFAULT 'community_example' NOT NULL, + "title" text NOT NULL, + "body" text, + "media_refs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "validation_state" text DEFAULT 'not_run' NOT NULL, + "moderation_state" text DEFAULT 'normal' 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 "clip_votes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clip_id" uuid NOT NULL, + "revision_id" uuid NOT NULL, + "actor_type" text NOT NULL, + "actor_id" text NOT NULL, + "vote" text NOT NULL, + "reason" text, + "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 +); +--> statement-breakpoint +CREATE TABLE "clips" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "source_company_id" uuid NOT NULL, + "creator_profile_id" uuid NOT NULL, + "slug" text NOT NULL, + "type" text NOT NULL, + "title" text NOT NULL, + "summary" text NOT NULL, + "description" text, + "visibility" text DEFAULT 'unlisted' NOT NULL, + "status" text DEFAULT 'pending_review' NOT NULL, + "moderation_state" text DEFAULT 'normal' NOT NULL, + "current_revision_id" uuid, + "latest_approved_revision_id" uuid, + "tags" jsonb DEFAULT '[]'::jsonb NOT NULL, + "categories" jsonb DEFAULT '[]'::jsonb NOT NULL, + "use_cases" jsonb DEFAULT '[]'::jsonb NOT NULL, + "required_providers" jsonb DEFAULT '[]'::jsonb NOT NULL, + "compatibility" jsonb DEFAULT '{}'::jsonb NOT NULL, + "source_kind" text, + "source_object_type" text, + "source_object_id" text, + "import_count" integer DEFAULT 0 NOT NULL, + "successful_first_run_count" integer DEFAULT 0 NOT NULL, + "vote_score" integer DEFAULT 0 NOT NULL, + "upvote_count" integer DEFAULT 0 NOT NULL, + "downvote_count" integer DEFAULT 0 NOT NULL, + "comment_count" integer DEFAULT 0 NOT NULL, + "showcase_count" integer DEFAULT 0 NOT NULL, + "report_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "delisted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "clip_comments" ADD CONSTRAINT "clip_comments_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_comments" ADD CONSTRAINT "clip_comments_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_comments" ADD CONSTRAINT "clip_comments_creator_profile_id_clip_creator_profiles_id_fk" FOREIGN KEY ("creator_profile_id") REFERENCES "public"."clip_creator_profiles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_creator_profiles" ADD CONSTRAINT "clip_creator_profiles_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_dependencies" ADD CONSTRAINT "clip_dependencies_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_dependencies" ADD CONSTRAINT "clip_dependencies_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_flags" ADD CONSTRAINT "clip_flags_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_flags" ADD CONSTRAINT "clip_flags_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_imported_objects" ADD CONSTRAINT "clip_imported_objects_clip_import_id_clip_imports_id_fk" FOREIGN KEY ("clip_import_id") REFERENCES "public"."clip_imports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_imports" ADD CONSTRAINT "clip_imports_destination_company_id_companies_id_fk" FOREIGN KEY ("destination_company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_imports" ADD CONSTRAINT "clip_imports_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_imports" ADD CONSTRAINT "clip_imports_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_moderation_events" ADD CONSTRAINT "clip_moderation_events_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_moderation_events" ADD CONSTRAINT "clip_moderation_events_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_public_metric_events" ADD CONSTRAINT "clip_public_metric_events_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_public_metric_events" ADD CONSTRAINT "clip_public_metric_events_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_ranking_snapshots" ADD CONSTRAINT "clip_ranking_snapshots_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_revisions" ADD CONSTRAINT "clip_revisions_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_showcase_posts" ADD CONSTRAINT "clip_showcase_posts_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_showcase_posts" ADD CONSTRAINT "clip_showcase_posts_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_showcase_posts" ADD CONSTRAINT "clip_showcase_posts_creator_profile_id_clip_creator_profiles_id_fk" FOREIGN KEY ("creator_profile_id") REFERENCES "public"."clip_creator_profiles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_votes" ADD CONSTRAINT "clip_votes_clip_id_clips_id_fk" FOREIGN KEY ("clip_id") REFERENCES "public"."clips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clip_votes" ADD CONSTRAINT "clip_votes_revision_id_clip_revisions_id_fk" FOREIGN KEY ("revision_id") REFERENCES "public"."clip_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clips" ADD CONSTRAINT "clips_source_company_id_companies_id_fk" FOREIGN KEY ("source_company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clips" ADD CONSTRAINT "clips_creator_profile_id_clip_creator_profiles_id_fk" FOREIGN KEY ("creator_profile_id") REFERENCES "public"."clip_creator_profiles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "clip_comments_clip_created_idx" ON "clip_comments" USING btree ("clip_id","created_at");--> statement-breakpoint +CREATE INDEX "clip_comments_revision_idx" ON "clip_comments" USING btree ("revision_id");--> statement-breakpoint +CREATE INDEX "clip_creator_profiles_company_idx" ON "clip_creator_profiles" USING btree ("company_id");--> statement-breakpoint +CREATE UNIQUE INDEX "clip_creator_profiles_handle_uq" ON "clip_creator_profiles" USING btree ("handle");--> statement-breakpoint +CREATE INDEX "clip_dependencies_revision_idx" ON "clip_dependencies" USING btree ("revision_id");--> statement-breakpoint +CREATE INDEX "clip_dependencies_type_key_idx" ON "clip_dependencies" USING btree ("type","key");--> statement-breakpoint +CREATE UNIQUE INDEX "clip_dependencies_revision_type_key_uq" ON "clip_dependencies" USING btree ("revision_id","type","key");--> statement-breakpoint +CREATE INDEX "clip_flags_clip_status_idx" ON "clip_flags" USING btree ("clip_id","status");--> statement-breakpoint +CREATE INDEX "clip_flags_revision_idx" ON "clip_flags" USING btree ("revision_id");--> statement-breakpoint +CREATE INDEX "clip_imported_objects_import_idx" ON "clip_imported_objects" USING btree ("clip_import_id");--> statement-breakpoint +CREATE INDEX "clip_imported_objects_destination_idx" ON "clip_imported_objects" USING btree ("destination_object_type","destination_object_id");--> statement-breakpoint +CREATE INDEX "clip_imports_destination_idx" ON "clip_imports" USING btree ("destination_company_id","created_at");--> statement-breakpoint +CREATE INDEX "clip_imports_revision_idx" ON "clip_imports" USING btree ("revision_id");--> statement-breakpoint +CREATE INDEX "clip_moderation_events_clip_created_idx" ON "clip_moderation_events" USING btree ("clip_id","created_at");--> statement-breakpoint +CREATE INDEX "clip_public_metric_events_clip_event_idx" ON "clip_public_metric_events" USING btree ("clip_id","event_type","created_at");--> statement-breakpoint +CREATE INDEX "clip_ranking_snapshots_scope_score_idx" ON "clip_ranking_snapshots" USING btree ("scope","score");--> statement-breakpoint +CREATE UNIQUE INDEX "clip_ranking_snapshots_clip_scope_uq" ON "clip_ranking_snapshots" USING btree ("clip_id","scope");--> statement-breakpoint +CREATE UNIQUE INDEX "clip_revisions_clip_revision_uq" ON "clip_revisions" USING btree ("clip_id","revision_number");--> statement-breakpoint +CREATE INDEX "clip_revisions_clip_created_idx" ON "clip_revisions" USING btree ("clip_id","created_at");--> statement-breakpoint +CREATE INDEX "clip_revisions_manifest_checksum_idx" ON "clip_revisions" USING btree ("manifest_checksum");--> statement-breakpoint +CREATE INDEX "clip_showcase_posts_clip_created_idx" ON "clip_showcase_posts" USING btree ("clip_id","created_at");--> statement-breakpoint +CREATE INDEX "clip_showcase_posts_revision_idx" ON "clip_showcase_posts" USING btree ("revision_id");--> statement-breakpoint +CREATE INDEX "clip_votes_revision_idx" ON "clip_votes" USING btree ("revision_id");--> statement-breakpoint +CREATE UNIQUE INDEX "clip_votes_revision_actor_uq" ON "clip_votes" USING btree ("revision_id","actor_type","actor_id");--> statement-breakpoint +CREATE UNIQUE INDEX "clips_slug_uq" ON "clips" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "clips_source_company_idx" ON "clips" USING btree ("source_company_id");--> statement-breakpoint +CREATE INDEX "clips_creator_idx" ON "clips" USING btree ("creator_profile_id");--> statement-breakpoint +CREATE INDEX "clips_public_browse_idx" ON "clips" USING btree ("visibility","status","moderation_state","updated_at");--> statement-breakpoint +CREATE INDEX "clips_type_idx" ON "clips" USING btree ("type");--> statement-breakpoint +CREATE OR REPLACE FUNCTION prevent_clip_revision_update() +RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'clip_revisions are immutable; publish a new revision instead'; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +CREATE TRIGGER prevent_clip_revision_update_trigger +BEFORE UPDATE ON "clip_revisions" +FOR EACH ROW EXECUTE FUNCTION prevent_clip_revision_update(); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 1526989ee33..a2fce34d4b7 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -967,6 +967,13 @@ "when": 1783555203000, "tag": "0139_skill_studio_run_templates", "breakpoints": true + }, + { + "idx": 140, + "version": "7", + "when": 1783555204000, + "tag": "0140_clips_social", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/clips.ts b/packages/db/src/schema/clips.ts new file mode 100644 index 00000000000..396688e0350 --- /dev/null +++ b/packages/db/src/schema/clips.ts @@ -0,0 +1,304 @@ +import { integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid, index } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const clipCreatorProfiles = pgTable( + "clip_creator_profiles", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + handle: text("handle").notNull(), + displayName: text("display_name").notNull(), + bio: text("bio"), + avatarUrl: text("avatar_url"), + websiteUrl: text("website_url"), + verificationState: text("verification_state").notNull().default("unverified"), + reputationSummary: jsonb("reputation_summary").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("clip_creator_profiles_company_idx").on(table.companyId), + handleUq: uniqueIndex("clip_creator_profiles_handle_uq").on(table.handle), + }), +); + +export const clips = pgTable( + "clips", + { + id: uuid("id").primaryKey().defaultRandom(), + sourceCompanyId: uuid("source_company_id").notNull().references(() => companies.id), + creatorProfileId: uuid("creator_profile_id").notNull().references(() => clipCreatorProfiles.id), + slug: text("slug").notNull(), + type: text("type").notNull(), + title: text("title").notNull(), + summary: text("summary").notNull(), + description: text("description"), + visibility: text("visibility").notNull().default("unlisted"), + status: text("status").notNull().default("pending_review"), + moderationState: text("moderation_state").notNull().default("normal"), + currentRevisionId: uuid("current_revision_id"), + latestApprovedRevisionId: uuid("latest_approved_revision_id"), + tags: jsonb("tags").$type().notNull().default([]), + categories: jsonb("categories").$type().notNull().default([]), + useCases: jsonb("use_cases").$type().notNull().default([]), + requiredProviders: jsonb("required_providers").$type().notNull().default([]), + compatibility: jsonb("compatibility").$type>().notNull().default({}), + sourceKind: text("source_kind"), + sourceObjectType: text("source_object_type"), + sourceObjectId: text("source_object_id"), + importCount: integer("import_count").notNull().default(0), + successfulFirstRunCount: integer("successful_first_run_count").notNull().default(0), + voteScore: integer("vote_score").notNull().default(0), + upvoteCount: integer("upvote_count").notNull().default(0), + downvoteCount: integer("downvote_count").notNull().default(0), + commentCount: integer("comment_count").notNull().default(0), + showcaseCount: integer("showcase_count").notNull().default(0), + reportCount: integer("report_count").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + delistedAt: timestamp("delisted_at", { withTimezone: true }), + }, + (table) => ({ + slugUq: uniqueIndex("clips_slug_uq").on(table.slug), + sourceCompanyIdx: index("clips_source_company_idx").on(table.sourceCompanyId), + creatorIdx: index("clips_creator_idx").on(table.creatorProfileId), + publicBrowseIdx: index("clips_public_browse_idx").on(table.visibility, table.status, table.moderationState, table.updatedAt), + typeIdx: index("clips_type_idx").on(table.type), + }), +); + +export const clipRevisions = pgTable( + "clip_revisions", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionNumber: integer("revision_number").notNull(), + manifestVersion: text("manifest_version").notNull().default("paperclip.clip/v1"), + manifestChecksum: text("manifest_checksum").notNull(), + artifactChecksum: text("artifact_checksum").notNull(), + manifestPayload: jsonb("manifest_payload").$type>().notNull(), + artifactRef: jsonb("artifact_ref").$type | null>(), + dependencyGraph: jsonb("dependency_graph").$type>().notNull().default({}), + permissions: jsonb("permissions").$type[]>().notNull().default([]), + secretsSchema: jsonb("secrets_schema").$type[]>().notNull().default([]), + budgetEstimate: jsonb("budget_estimate").$type | null>(), + redactionReport: jsonb("redaction_report").$type>().notNull().default({}), + dangerousCapabilities: jsonb("dangerous_capabilities").$type().notNull().default([]), + securityReviewState: text("security_review_state").notNull().default("unreviewed"), + verificationState: text("verification_state").notNull().default("not_run"), + compatibility: jsonb("compatibility").$type>().notNull().default({}), + changeSummary: text("change_summary"), + breakingChanges: text("breaking_changes"), + migrationNotes: text("migration_notes"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + clipRevisionUq: uniqueIndex("clip_revisions_clip_revision_uq").on(table.clipId, table.revisionNumber), + clipCreatedIdx: index("clip_revisions_clip_created_idx").on(table.clipId, table.createdAt), + manifestChecksumIdx: index("clip_revisions_manifest_checksum_idx").on(table.manifestChecksum), + }), +); + +export const clipDependencies = pgTable( + "clip_dependencies", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").notNull().references(() => clipRevisions.id), + type: text("type").notNull(), + key: text("key").notNull(), + displayName: text("display_name"), + required: text("required").notNull().default("required"), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + revisionIdx: index("clip_dependencies_revision_idx").on(table.revisionId), + typeKeyIdx: index("clip_dependencies_type_key_idx").on(table.type, table.key), + revisionTypeKeyUq: uniqueIndex("clip_dependencies_revision_type_key_uq").on(table.revisionId, table.type, table.key), + }), +); + +export const clipComments = pgTable( + "clip_comments", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").references(() => clipRevisions.id), + creatorProfileId: uuid("creator_profile_id").references(() => clipCreatorProfiles.id), + authorUserId: text("author_user_id"), + authorAgentId: uuid("author_agent_id"), + scope: text("scope").notNull().default("clip"), + category: text("category").notNull().default("question"), + moderationState: text("moderation_state").notNull().default("normal"), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + clipCreatedIdx: index("clip_comments_clip_created_idx").on(table.clipId, table.createdAt), + revisionIdx: index("clip_comments_revision_idx").on(table.revisionId), + }), +); + +export const clipVotes = pgTable( + "clip_votes", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").notNull().references(() => clipRevisions.id), + actorType: text("actor_type").notNull(), + actorId: text("actor_id").notNull(), + vote: text("vote").notNull(), + reason: text("reason"), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + revisionIdx: index("clip_votes_revision_idx").on(table.revisionId), + actorUq: uniqueIndex("clip_votes_revision_actor_uq").on(table.revisionId, table.actorType, table.actorId), + }), +); + +export const clipShowcasePosts = pgTable( + "clip_showcase_posts", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").notNull().references(() => clipRevisions.id), + creatorProfileId: uuid("creator_profile_id").references(() => clipCreatorProfiles.id), + authorUserId: text("author_user_id"), + authorAgentId: uuid("author_agent_id"), + type: text("type").notNull().default("community_example"), + title: text("title").notNull(), + body: text("body"), + mediaRefs: jsonb("media_refs").$type[]>().notNull().default([]), + validationState: text("validation_state").notNull().default("not_run"), + moderationState: text("moderation_state").notNull().default("normal"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + clipCreatedIdx: index("clip_showcase_posts_clip_created_idx").on(table.clipId, table.createdAt), + revisionIdx: index("clip_showcase_posts_revision_idx").on(table.revisionId), + }), +); + +export const clipFlags = pgTable( + "clip_flags", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").references(() => clipRevisions.id), + reporterType: text("reporter_type").notNull().default("anonymous"), + reporterId: text("reporter_id"), + reason: text("reason").notNull(), + details: text("details"), + status: text("status").notNull().default("open"), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + clipStatusIdx: index("clip_flags_clip_status_idx").on(table.clipId, table.status), + revisionIdx: index("clip_flags_revision_idx").on(table.revisionId), + }), +); + +export const clipModerationEvents = pgTable( + "clip_moderation_events", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").references(() => clipRevisions.id), + actorType: text("actor_type").notNull(), + actorId: text("actor_id").notNull(), + fromState: text("from_state"), + toState: text("to_state").notNull(), + reason: text("reason").notNull(), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + clipCreatedIdx: index("clip_moderation_events_clip_created_idx").on(table.clipId, table.createdAt), + }), +); + +export const clipRankingSnapshots = pgTable( + "clip_ranking_snapshots", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + scope: text("scope").notNull().default("global"), + score: integer("score").notNull().default(0), + factors: jsonb("factors").$type>().notNull().default({}), + snapshotAt: timestamp("snapshot_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + scopeScoreIdx: index("clip_ranking_snapshots_scope_score_idx").on(table.scope, table.score), + clipScopeUq: uniqueIndex("clip_ranking_snapshots_clip_scope_uq").on(table.clipId, table.scope), + }), +); + +export const clipImports = pgTable( + "clip_imports", + { + id: uuid("id").primaryKey().defaultRandom(), + destinationCompanyId: uuid("destination_company_id").notNull().references(() => companies.id), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").notNull().references(() => clipRevisions.id), + revisionNumber: integer("revision_number").notNull(), + manifestChecksum: text("manifest_checksum").notNull(), + artifactChecksum: text("artifact_checksum").notNull(), + sourceUrl: text("source_url"), + revisionUrl: text("revision_url"), + status: text("status").notNull().default("previewed"), + importedByUserId: text("imported_by_user_id"), + importedByAgentId: uuid("imported_by_agent_id"), + collisionStrategy: text("collision_strategy"), + selectedOptions: jsonb("selected_options").$type>().notNull().default({}), + firstRunValidationState: text("first_run_validation_state").notNull().default("not_run"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + destinationIdx: index("clip_imports_destination_idx").on(table.destinationCompanyId, table.createdAt), + revisionIdx: index("clip_imports_revision_idx").on(table.revisionId), + }), +); + +export const clipImportedObjects = pgTable( + "clip_imported_objects", + { + id: uuid("id").primaryKey().defaultRandom(), + clipImportId: uuid("clip_import_id").notNull().references(() => clipImports.id), + destinationObjectType: text("destination_object_type").notNull(), + destinationObjectId: text("destination_object_id").notNull(), + packagePath: text("package_path"), + action: text("action").notNull(), + localFingerprint: text("local_fingerprint"), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + importIdx: index("clip_imported_objects_import_idx").on(table.clipImportId), + destinationIdx: index("clip_imported_objects_destination_idx").on(table.destinationObjectType, table.destinationObjectId), + }), +); + +export const clipPublicMetricEvents = pgTable( + "clip_public_metric_events", + { + id: uuid("id").primaryKey().defaultRandom(), + clipId: uuid("clip_id").notNull().references(() => clips.id), + revisionId: uuid("revision_id").references(() => clipRevisions.id), + eventType: text("event_type").notNull(), + actorHash: text("actor_hash"), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + clipEventIdx: index("clip_public_metric_events_clip_event_idx").on(table.clipId, table.eventType, table.createdAt), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7d1180376c8..185d4305391 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -99,6 +99,21 @@ export { companySkillTestRunTemplates, companySkillTestRuns, } from "./company_skills.js"; +export { + clipCreatorProfiles, + clips, + clipRevisions, + clipDependencies, + clipComments, + clipVotes, + clipShowcasePosts, + clipFlags, + clipModerationEvents, + clipRankingSnapshots, + clipImports, + clipImportedObjects, + clipPublicMetricEvents, +} from "./clips.js"; export { plugins } from "./plugins.js"; export { pluginConfig } from "./plugin_config.js"; export { pluginCompanySettings } from "./plugin_company_settings.js"; diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index e5194ac476e..06a99e267df 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -23,6 +23,8 @@ export const API = { goals: `${API_PREFIX}/goals`, approvals: `${API_PREFIX}/approvals`, secrets: `${API_PREFIX}/secrets`, + clips: `${API_PREFIX}/clips`, + publicClips: `${API_PREFIX}/public/clips`, userSecretDefinitions: `${API_PREFIX}/companies/:companyId/user-secret-definitions`, userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`, userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`, diff --git a/packages/shared/src/clip-manifest.test.ts b/packages/shared/src/clip-manifest.test.ts new file mode 100644 index 00000000000..0442b806a55 --- /dev/null +++ b/packages/shared/src/clip-manifest.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from "vitest"; +import type { CompanyPortabilityManifest } from "./types/company-portability.js"; +import { + buildAgentClipSnapshot, + buildBundleClipSnapshot, + buildRoutineClipSnapshot, + buildSkillClipSnapshot, + buildTeamClipSnapshot, + clipManifestSchema, +} from "./clip-manifest.js"; + +const exportedAt = "2026-05-16T00:00:00.000Z"; + +function fixtureManifest(): CompanyPortabilityManifest { + return { + schemaVersion: 1, + generatedAt: "2026-05-15T00:00:00.000Z", + source: { + companyId: "67f12493-f655-41e7-b5f5-dbe2f44dd7ba", + companyName: "Private Customer Ops", + }, + includes: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + company: { + path: "COMPANY.md", + name: "Private Customer Ops", + description: "Routes support for https://localhost:8443/internal and customer jane@example.com", + brandColor: "#111111", + logoPath: null, + attachmentMaxBytes: null, + requireBoardApprovalForNewAgents: true, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + }, + sidebar: { + agents: ["ceo", "support"], + projects: ["support"], + }, + agents: [ + { + slug: "support", + name: "Support Engineer", + path: "agents/support/AGENTS.md", + skills: ["triage"], + role: "engineer", + title: "Support Engineer", + icon: "code", + capabilities: "Uses bearer token Bearer shh-secret-token for examples", + reportsToSlug: "ceo", + reportsToExistingAgentId: null, + reportsToExistingAgentSlug: null, + adapterType: "codex_local", + adapterConfig: { + cwd: "/Users/dotta/private-support", + command: "/Users/dotta/.local/bin/codex", + env: { + GH_TOKEN: { + type: "secret_ref", + secretId: "secret-gh-token", + version: "latest", + }, + INTERNAL_URL: { + type: "plain", + value: "http://localhost:3100/private", + }, + SAFE_MODE: { + type: "plain", + value: "true", + }, + }, + }, + runtimeConfig: { + heartbeat: { + intervalSec: 3600, + }, + }, + permissions: { + github: { + issues: { + write: true, + }, + }, + browser: true, + }, + budgetMonthlyCents: 1200, + metadata: { + privateRepoUrl: "https://git.internal/example/private", + }, + }, + { + slug: "ceo", + name: "CEO", + path: "agents/ceo/AGENTS.md", + skills: [], + role: "ceo", + title: "Chief Executive Officer", + icon: null, + capabilities: "Owns strategy", + reportsToSlug: null, + reportsToExistingAgentId: null, + reportsToExistingAgentSlug: null, + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You are CEO.", + }, + runtimeConfig: {}, + permissions: {}, + budgetMonthlyCents: 0, + metadata: null, + }, + ], + skills: [ + { + key: "triage", + slug: "triage", + name: "Triage", + path: "skills/triage/SKILL.md", + description: "Classifies inbound support work.", + sourceType: "bundled", + sourceLocator: null, + sourceRef: null, + trustLevel: "company", + compatibility: null, + metadata: null, + fileInventory: [{ path: "skills/triage/SKILL.md", kind: "skill" }], + }, + ], + projects: [ + { + slug: "support", + name: "Support", + path: "projects/support/PROJECT.md", + description: "Private support project", + ownerAgentSlug: "support", + leadAgentSlug: "support", + targetDate: null, + color: null, + icon: null, + status: "in_progress", + env: { + SUPPORT_DSN: { + type: "plain", + value: "postgres://user:pass@localhost:5432/app", + }, + }, + executionWorkspacePolicy: null, + workspaces: [ + { + key: "support-repo", + name: "Support repo", + sourceType: "git", + repoUrl: "https://github.com/paperclipai/paperclip", + repoRef: null, + defaultRef: "main", + visibility: "public", + setupCommand: "pnpm install", + cleanupCommand: null, + metadata: null, + isPrimary: true, + }, + ], + metadata: null, + }, + ], + issues: [ + { + slug: "triage-hourly", + identifier: "PAP-1", + title: "Triage hourly", + path: "tasks/triage-hourly/TASK.md", + projectSlug: "support", + projectWorkspaceKey: "support-repo", + assigneeAgentSlug: "support", + description: "Runs on a webhook and schedule.", + recurring: true, + routine: { + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { + name: "CUSTOMER_EMAIL", + type: "text", + label: "Customer email", + required: false, + defaultValue: "jane@example.com", + options: [], + }, + ], + triggers: [ + { + kind: "webhook", + label: "Inbound webhook", + enabled: true, + cronExpression: null, + timezone: null, + signingMode: "hmac_sha256", + replayWindowSec: 120, + }, + { + kind: "schedule", + label: "Hourly", + enabled: true, + cronExpression: "0 * * * *", + timezone: "UTC", + signingMode: null, + replayWindowSec: null, + }, + ], + }, + legacyRecurrence: null, + status: "todo", + priority: "medium", + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + comments: [ + { + body: "Private comment with sk-testsecret1234567890", + authorType: "agent", + authorAgentSlug: "support", + authorUserId: null, + presentation: null, + metadata: null, + createdAt: exportedAt, + }, + ], + metadata: null, + }, + ], + envInputs: [ + { + key: "GH_TOKEN", + description: "GitHub token", + agentSlug: "support", + projectSlug: null, + kind: "secret", + requirement: "required", + defaultValue: "ghp_supersecretsecret", + portability: "portable", + }, + ], + }; +} + +const clip = { + slug: "support-triage", + revisionNumber: 1, + title: "Support Triage", + summary: "Routes support tickets.", + visibility: "public" as const, + creator: { + profileId: "creator_1", + handle: "paperclip", + displayName: "Paperclip", + }, +}; + +describe("clip manifest foundation", () => { + it("builds a deterministic versioned bundle manifest with checksums and dependencies", () => { + const first = buildBundleClipSnapshot({ + clip, + artifact: { manifest: fixtureManifest(), files: { "COMPANY.md": "# Company" } }, + source: { exportedAt }, + social: { + sourceUrl: "https://paperclip.ing/clips/support-triage", + revisionUrl: "https://paperclip.ing/clips/support-triage/revisions/1", + }, + }); + const second = buildBundleClipSnapshot({ + clip, + artifact: { manifest: fixtureManifest(), files: { "COMPANY.md": "# Company" } }, + source: { exportedAt }, + social: { + sourceUrl: "https://paperclip.ing/clips/support-triage", + revisionUrl: "https://paperclip.ing/clips/support-triage/revisions/1", + }, + }); + + expect(clipManifestSchema.parse(first)).toEqual(first); + expect(first.checksums).toEqual(second.checksums); + expect(first.clip.revisionId).toEqual(second.clip.revisionId); + expect(first.schema).toBe("paperclip.clip/v1"); + expect(first.artifact.version).toBe("agentcompanies/v1-draft"); + expect(first.dependencies.adapters.map((entry) => entry.type)).toEqual(["claude_local", "codex_local"]); + expect(first.dependencies.skills.map((entry) => entry.key)).toEqual(["triage"]); + expect(first.dependencies.secrets.map((entry) => entry.key)).toContain("GH_TOKEN"); + expect(first.dependencies.permissions.map((entry) => entry.capability)).toContain("github.issues.write"); + expect(first.dependencies.runtime).toMatchObject({ + browser: true, + filesystem: "declared", + webhooks: true, + recurringRoutines: true, + }); + expect(first.dependencies.budgetHints.monthlyCents).toBe(1200); + }); + + it("omits secrets, local paths, private URLs, raw comments, and routine webhook signing details", () => { + const manifest = buildRoutineClipSnapshot({ + clip: { ...clip, slug: "support-routine" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + routineSlug: "triage-hourly", + }); + const serialized = JSON.stringify(manifest); + + expect(serialized).not.toContain("secret-gh-token"); + expect(serialized).not.toContain("ghp_supersecretsecret"); + expect(serialized).not.toContain("sk-testsecret"); + expect(serialized).not.toContain("/Users/dotta"); + expect(serialized).not.toContain("localhost"); + expect(serialized).not.toContain("https://git.internal"); + expect(serialized).not.toContain("hmac_sha256"); + expect(manifest.artifact.payload.manifest.issues[0]?.comments).toEqual([]); + expect(manifest.artifact.payload.manifest.issues[0]?.routine?.triggers.every((trigger) => !trigger.enabled)).toBe(true); + expect(manifest.security.routinePolicy).toEqual({ + importedTriggersEnabledByDefault: false, + webhookSecretsRegenerated: true, + }); + expect(manifest.security.redactionReport.summary.omitted).toBeGreaterThan(0); + expect(manifest.security.redactionReport.entries.map((entry) => entry.path)).toContain("issues[0].comments"); + }); + + it("scopes agent, team, skill, and routine snapshots to the selected portable object", () => { + const agent = buildAgentClipSnapshot({ + clip: { ...clip, slug: "support-agent" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + agentSlug: "support", + }); + const team = buildTeamClipSnapshot({ + clip: { ...clip, slug: "support-team" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + teamRootAgentSlug: "ceo", + }); + const skill = buildSkillClipSnapshot({ + clip: { ...clip, slug: "triage-skill" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + skillKey: "triage", + }); + const routine = buildRoutineClipSnapshot({ + clip: { ...clip, slug: "triage-routine" }, + artifact: { manifest: fixtureManifest() }, + source: { exportedAt }, + routineSlug: "triage-hourly", + }); + + expect(agent.clip.type).toBe("agent"); + expect(agent.artifact.payload.manifest.agents.map((entry) => entry.slug)).toEqual(["support"]); + expect(team.clip.type).toBe("team"); + expect(team.artifact.payload.manifest.agents.map((entry) => entry.slug)).toEqual(["ceo", "support"]); + expect(skill.clip.type).toBe("skill"); + expect(skill.artifact.payload.manifest.skills.map((entry) => entry.key)).toEqual(["triage"]); + expect(skill.artifact.payload.manifest.agents).toEqual([]); + expect(routine.clip.type).toBe("routine"); + expect(routine.artifact.payload.manifest.issues.map((entry) => entry.slug)).toEqual(["triage-hourly"]); + }); +}); diff --git a/packages/shared/src/clip-manifest.ts b/packages/shared/src/clip-manifest.ts new file mode 100644 index 00000000000..1fcb44285f7 --- /dev/null +++ b/packages/shared/src/clip-manifest.ts @@ -0,0 +1,1017 @@ +import { z } from "zod"; +import type { + CompanyPortabilityAgentManifestEntry, + CompanyPortabilityFileEntry, + CompanyPortabilityManifest, + CompanyPortabilitySkillManifestEntry, +} from "./types/company-portability.js"; + +export const CLIP_MANIFEST_SCHEMA = "paperclip.clip/v1" as const; +export const CLIP_MANIFEST_VERSION = 1 as const; +export const CLIP_ARTIFACT_FORMAT = "agentcompanies" as const; +export const CLIP_ARTIFACT_VERSION = "agentcompanies/v1-draft" as const; + +const SECRET_KEY_PATTERN = /(?:api[_-]?key|auth|authorization|bearer|cookie|credential|dsn|oauth|password|private[_-]?key|secret|signing[_-]?secret|token|webhook[_-]?secret)/i; +const LOCAL_PATH_PATTERN = /(?:^|["'\s])(?:\/Users\/|\/home\/|\/var\/folders\/|[A-Za-z]:\\)/; +const PRIVATE_URL_PATTERN = /\b(?:https?:\/\/)(?:localhost|127\.0\.0\.1|0\.0\.0\.0|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|[^/\s]+\.local|[^/\s]+\.internal|[^/\s]+\.test)(?::\d+)?(?:[/?#][^\s]*)?/i; +const SECRET_VALUE_PATTERN = /(?:Bearer\s+[A-Za-z0-9._~+/-]+=*|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/; + +export type ClipManifestType = "team" | "agent" | "skill" | "routine" | "bundle"; +export type ClipManifestVisibility = "private_share" | "unlisted" | "public"; +export type ClipReviewState = "unreviewed" | "automated_checked" | "security_reviewed" | "blocked"; +export type ClipValidationStatus = "not_run" | "passed" | "failed" | "stale"; +export type ClipRedactionOutcome = "allow" | "redact" | "summarize" | "omit"; +export type ClipRuntimeFilesystem = "none" | "declared" | "broad"; + +export interface ClipManifestCreator { + profileId: string | null; + handle: string | null; + displayName: string | null; +} + +export interface ClipManifestClip { + id: string; + slug: string; + type: ClipManifestType; + revisionId: string; + revisionNumber: number; + manifestVersion: typeof CLIP_MANIFEST_VERSION; + title: string; + summary: string; + visibility: ClipManifestVisibility; + creator: ClipManifestCreator | null; +} + +export interface ClipDependencyGraph { + adapters: Array<{ type: string; required: boolean; sourceRefs: string[]; note: string | null }>; + plugins: Array<{ key: string; requirement: "required" | "optional"; sourceRefs: string[]; note: string | null }>; + skills: Array<{ key: string; slug: string; requirement: "required" | "optional"; sourceRefs: string[] }>; + secrets: Array<{ key: string; kind: "secret"; requirement: "required" | "optional"; description: string | null; sourceRefs: string[] }>; + permissions: Array<{ capability: string; reason: string | null; sourceRefs: string[] }>; + runtime: { + localShell: boolean; + browser: boolean; + filesystem: ClipRuntimeFilesystem; + webhooks: boolean; + recurringRoutines: boolean; + }; + workspaces: Array<{ key: string; repoUrlRequired: boolean; pinnedRefRecommended: boolean; sourceRefs: string[] }>; + budgetHints: { + monthlyCents: number; + sourceRefs: string[]; + }; +} + +export interface ClipRedactionReportEntry { + path: string; + outcome: ClipRedactionOutcome; + reason: string; +} + +export interface ClipRedactionReport { + schema: "paperclip.clip.redaction/v1"; + generatedAt: string; + entries: ClipRedactionReportEntry[]; + summary: { + allowed: number; + redacted: number; + summarized: number; + omitted: number; + }; +} + +export interface ClipManifest { + schema: typeof CLIP_MANIFEST_SCHEMA; + clip: ClipManifestClip; + publication: { + source: { + kind: "paperclip_company_object"; + objectType: ClipManifestType; + exportedAt: string; + }; + compatibility: { + paperclip: string | null; + manifest: typeof CLIP_ARTIFACT_VERSION; + }; + }; + artifact: { + format: typeof CLIP_ARTIFACT_FORMAT; + version: typeof CLIP_ARTIFACT_VERSION; + checksum: string; + entrypoint: "COMPANY.md" | "TEAM.md" | "AGENTS.md" | "TASK.md" | "SKILL.md"; + paperclipExtension: ".paperclip.yaml"; + payload: { + manifest: CompanyPortabilityManifest; + }; + }; + dependencies: ClipDependencyGraph; + security: { + redactionReport: ClipRedactionReport; + dangerousCapabilities: string[]; + routinePolicy: { + importedTriggersEnabledByDefault: false; + webhookSecretsRegenerated: true; + }; + reviewState: ClipReviewState; + }; + verification: { + expectedFirstRun: Record | null; + sampleOutputs: Array>; + validationStatus: ClipValidationStatus; + }; + social: { + sourceUrl: string | null; + revisionUrl: string | null; + }; + provenance: { + publishedByProfileId: string | null; + revisionPublishedAt: string; + previousRevisionId: string | null; + }; + checksums: { + artifact: string; + redactionReport: string; + manifest: string; + }; +} + +export interface ClipSnapshotBuilderInput { + clip: { + id?: string | null; + slug: string; + revisionNumber: number; + title: string; + summary: string; + visibility?: ClipManifestVisibility | null; + creator?: ClipManifestCreator | null; + previousRevisionId?: string | null; + }; + artifact: { + manifest: CompanyPortabilityManifest; + files?: Record; + }; + source?: { + exportedAt?: string | null; + paperclipCompatibility?: string | null; + } | null; + social?: { + sourceUrl?: string | null; + revisionUrl?: string | null; + } | null; +} + +export interface ClipScopedSnapshotBuilderInput extends ClipSnapshotBuilderInput { + agentSlug?: string; + teamRootAgentSlug?: string; + skillKey?: string; + skillSlug?: string; + routineSlug?: string; +} + +export const clipTypeSchema = z.enum(["team", "agent", "skill", "routine", "bundle"]); +export const clipManifestCreatorSchema = z.object({ + profileId: z.string().min(1).nullable(), + handle: z.string().min(1).nullable(), + displayName: z.string().min(1).nullable(), +}); + +export const clipRedactionReportSchema = z.object({ + schema: z.literal("paperclip.clip.redaction/v1"), + generatedAt: z.string().datetime(), + entries: z.array(z.object({ + path: z.string().min(1), + outcome: z.enum(["allow", "redact", "summarize", "omit"]), + reason: z.string().min(1), + })), + summary: z.object({ + allowed: z.number().int().nonnegative(), + redacted: z.number().int().nonnegative(), + summarized: z.number().int().nonnegative(), + omitted: z.number().int().nonnegative(), + }), +}); + +export const clipDependencyGraphSchema = z.object({ + adapters: z.array(z.object({ + type: z.string().min(1), + required: z.boolean(), + sourceRefs: z.array(z.string().min(1)), + note: z.string().nullable(), + })), + plugins: z.array(z.object({ + key: z.string().min(1), + requirement: z.enum(["required", "optional"]), + sourceRefs: z.array(z.string().min(1)), + note: z.string().nullable(), + })), + skills: z.array(z.object({ + key: z.string().min(1), + slug: z.string().min(1), + requirement: z.enum(["required", "optional"]), + sourceRefs: z.array(z.string().min(1)), + })), + secrets: z.array(z.object({ + key: z.string().min(1), + kind: z.literal("secret"), + requirement: z.enum(["required", "optional"]), + description: z.string().nullable(), + sourceRefs: z.array(z.string().min(1)), + })), + permissions: z.array(z.object({ + capability: z.string().min(1), + reason: z.string().nullable(), + sourceRefs: z.array(z.string().min(1)), + })), + runtime: z.object({ + localShell: z.boolean(), + browser: z.boolean(), + filesystem: z.enum(["none", "declared", "broad"]), + webhooks: z.boolean(), + recurringRoutines: z.boolean(), + }), + workspaces: z.array(z.object({ + key: z.string().min(1), + repoUrlRequired: z.boolean(), + pinnedRefRecommended: z.boolean(), + sourceRefs: z.array(z.string().min(1)), + })), + budgetHints: z.object({ + monthlyCents: z.number().int().nonnegative(), + sourceRefs: z.array(z.string().min(1)), + }), +}); + +export const clipManifestSchema = z.object({ + schema: z.literal(CLIP_MANIFEST_SCHEMA), + clip: z.object({ + id: z.string().min(1), + slug: z.string().min(1), + type: clipTypeSchema, + revisionId: z.string().min(1), + revisionNumber: z.number().int().positive(), + manifestVersion: z.literal(CLIP_MANIFEST_VERSION), + title: z.string().min(1), + summary: z.string(), + visibility: z.enum(["private_share", "unlisted", "public"]), + creator: clipManifestCreatorSchema.nullable(), + }), + publication: z.object({ + source: z.object({ + kind: z.literal("paperclip_company_object"), + objectType: clipTypeSchema, + exportedAt: z.string().datetime(), + }), + compatibility: z.object({ + paperclip: z.string().nullable(), + manifest: z.literal(CLIP_ARTIFACT_VERSION), + }), + }), + artifact: z.object({ + format: z.literal(CLIP_ARTIFACT_FORMAT), + version: z.literal(CLIP_ARTIFACT_VERSION), + checksum: z.string().regex(/^sha256:[a-f0-9]{64}$/), + entrypoint: z.enum(["COMPANY.md", "TEAM.md", "AGENTS.md", "TASK.md", "SKILL.md"]), + paperclipExtension: z.literal(".paperclip.yaml"), + payload: z.object({ + manifest: z.custom(), + }), + }), + dependencies: clipDependencyGraphSchema, + security: z.object({ + redactionReport: clipRedactionReportSchema, + dangerousCapabilities: z.array(z.string().min(1)), + routinePolicy: z.object({ + importedTriggersEnabledByDefault: z.literal(false), + webhookSecretsRegenerated: z.literal(true), + }), + reviewState: z.enum(["unreviewed", "automated_checked", "security_reviewed", "blocked"]), + }), + verification: z.object({ + expectedFirstRun: z.record(z.string(), z.unknown()).nullable(), + sampleOutputs: z.array(z.record(z.string(), z.unknown())), + validationStatus: z.enum(["not_run", "passed", "failed", "stale"]), + }), + social: z.object({ + sourceUrl: z.string().url().nullable(), + revisionUrl: z.string().url().nullable(), + }), + provenance: z.object({ + publishedByProfileId: z.string().min(1).nullable(), + revisionPublishedAt: z.string().datetime(), + previousRevisionId: z.string().min(1).nullable(), + }), + checksums: z.object({ + artifact: z.string().regex(/^sha256:[a-f0-9]{64}$/), + redactionReport: z.string().regex(/^sha256:[a-f0-9]{64}$/), + manifest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + }), +}); + +export function buildBundleClipSnapshot(input: ClipSnapshotBuilderInput): ClipManifest { + return buildClipSnapshot("bundle", input); +} + +export function buildAgentClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const agentSlug = input.agentSlug ?? input.artifact.manifest.agents[0]?.slug; + const manifest = agentSlug ? scopeManifestToAgents(input.artifact.manifest, [agentSlug]) : input.artifact.manifest; + return buildClipSnapshot("agent", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildTeamClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const rootSlug = input.teamRootAgentSlug ?? input.agentSlug ?? input.artifact.manifest.agents.find((agent) => !agent.reportsToSlug)?.slug; + const agentSlugs = rootSlug ? collectAgentSubtreeSlugs(input.artifact.manifest.agents, rootSlug) : []; + const manifest = agentSlugs.length > 0 ? scopeManifestToAgents(input.artifact.manifest, agentSlugs) : input.artifact.manifest; + return buildClipSnapshot("team", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildSkillClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const manifest = cloneManifest(input.artifact.manifest); + const skill = findSkill(manifest.skills, input.skillKey, input.skillSlug) ?? manifest.skills[0] ?? null; + manifest.agents = []; + manifest.projects = []; + manifest.issues = []; + manifest.skills = skill ? [skill] : []; + manifest.includes = { company: false, agents: false, projects: false, issues: false, skills: manifest.skills.length > 0 }; + return buildClipSnapshot("skill", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildRoutineClipSnapshot(input: ClipScopedSnapshotBuilderInput): ClipManifest { + const manifest = cloneManifest(input.artifact.manifest); + const routine = manifest.issues.find((issue) => ( + issue.slug === input.routineSlug + || issue.identifier === input.routineSlug + || (!input.routineSlug && (issue.recurring || issue.routine)) + )); + const assigneeSlug = routine?.assigneeAgentSlug ?? null; + manifest.issues = routine ? [routine] : []; + manifest.agents = assigneeSlug ? manifest.agents.filter((agent) => agent.slug === assigneeSlug) : []; + manifest.projects = []; + manifest.skills = filterSkillsForAgents(manifest.skills, manifest.agents); + manifest.includes = { + company: false, + agents: manifest.agents.length > 0, + projects: false, + issues: manifest.issues.length > 0, + skills: manifest.skills.length > 0, + }; + return buildClipSnapshot("routine", { ...input, artifact: { ...input.artifact, manifest } }); +} + +export function buildClipSnapshot(type: ClipManifestType, input: ClipSnapshotBuilderInput): ClipManifest { + const exportedAt = input.source?.exportedAt ?? new Date(0).toISOString(); + const sanitized = sanitizeManifest(input.artifact.manifest, exportedAt); + const dependencyGraph = buildDependencyGraph(input.artifact.manifest); + const dangerousCapabilities = buildDangerousCapabilities(dependencyGraph); + const artifactChecksum = sha256Stable({ + manifest: sanitized.manifest, + files: normalizeFilesForChecksum(input.artifact.files ?? {}), + }); + const redactionReportChecksum = sha256Stable(sanitized.redactionReport); + const revisionSeed = sha256Stable({ + schema: CLIP_MANIFEST_SCHEMA, + slug: input.clip.slug, + type, + revisionNumber: input.clip.revisionNumber, + artifactChecksum, + redactionReportChecksum, + }); + const revisionId = `cliprev_${revisionSeed.slice("sha256:".length, "sha256:".length + 16)}`; + + const withoutManifestChecksum: ClipManifest = { + schema: CLIP_MANIFEST_SCHEMA, + clip: { + id: input.clip.id ?? `clip_${normalizeSlug(input.clip.slug)}`, + slug: normalizeSlug(input.clip.slug), + type, + revisionId, + revisionNumber: input.clip.revisionNumber, + manifestVersion: CLIP_MANIFEST_VERSION, + title: input.clip.title, + summary: input.clip.summary, + visibility: input.clip.visibility ?? "unlisted", + creator: input.clip.creator ?? null, + }, + publication: { + source: { + kind: "paperclip_company_object", + objectType: type, + exportedAt, + }, + compatibility: { + paperclip: input.source?.paperclipCompatibility ?? null, + manifest: CLIP_ARTIFACT_VERSION, + }, + }, + artifact: { + format: CLIP_ARTIFACT_FORMAT, + version: CLIP_ARTIFACT_VERSION, + checksum: artifactChecksum, + entrypoint: entrypointForClipType(type), + paperclipExtension: ".paperclip.yaml", + payload: { + manifest: sanitized.manifest, + }, + }, + dependencies: dependencyGraph, + security: { + redactionReport: sanitized.redactionReport, + dangerousCapabilities, + routinePolicy: { + importedTriggersEnabledByDefault: false, + webhookSecretsRegenerated: true, + }, + reviewState: "unreviewed", + }, + verification: { + expectedFirstRun: null, + sampleOutputs: [], + validationStatus: "not_run", + }, + social: { + sourceUrl: input.social?.sourceUrl ?? null, + revisionUrl: input.social?.revisionUrl ?? null, + }, + provenance: { + publishedByProfileId: input.clip.creator?.profileId ?? null, + revisionPublishedAt: exportedAt, + previousRevisionId: input.clip.previousRevisionId ?? null, + }, + checksums: { + artifact: artifactChecksum, + redactionReport: redactionReportChecksum, + manifest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + }, + }; + + const manifestChecksum = sha256Stable(withoutManifestChecksum); + const result = { + ...withoutManifestChecksum, + checksums: { + ...withoutManifestChecksum.checksums, + manifest: manifestChecksum, + }, + }; + clipManifestSchema.parse(result); + return result; +} + +function sanitizeManifest(source: CompanyPortabilityManifest, generatedAt: string) { + const entries: ClipRedactionReportEntry[] = []; + const manifest = cloneManifest(source); + manifest.generatedAt = generatedAt; + manifest.source = null; + manifest.envInputs = manifest.envInputs.map((envInput, index) => { + if (envInput.defaultValue !== null) { + entries.push({ + path: `envInputs[${index}].defaultValue`, + outcome: "omit", + reason: "environment default values are not published in clip manifests", + }); + } + return { + ...envInput, + defaultValue: null, + description: sanitizeText(envInput.description, `envInputs[${index}].description`, entries), + }; + }); + manifest.company = manifest.company + ? { + ...manifest.company, + name: sanitizeText(manifest.company.name, "company.name", entries) ?? manifest.company.name, + description: sanitizeText(manifest.company.description, "company.description", entries), + } + : null; + manifest.agents = manifest.agents.map((agent, index) => ({ + ...agent, + capabilities: sanitizeText(agent.capabilities, `agents[${index}].capabilities`, entries), + adapterConfig: sanitizeValue(agent.adapterConfig, `agents[${index}].adapterConfig`, entries) as Record, + runtimeConfig: sanitizeValue(agent.runtimeConfig, `agents[${index}].runtimeConfig`, entries) as Record, + permissions: sanitizeValue(agent.permissions, `agents[${index}].permissions`, entries) as Record, + metadata: sanitizeNullableRecord(agent.metadata, `agents[${index}].metadata`, entries), + })); + manifest.projects = manifest.projects.map((project, index) => ({ + ...project, + description: sanitizeText(project.description, `projects[${index}].description`, entries), + env: sanitizeValue(project.env, `projects[${index}].env`, entries) as typeof project.env, + executionWorkspacePolicy: sanitizeNullableRecord(project.executionWorkspacePolicy, `projects[${index}].executionWorkspacePolicy`, entries), + workspaces: project.workspaces.map((workspace, workspaceIndex) => ({ + ...workspace, + repoUrl: shouldOmitString(workspace.repoUrl) + ? recordOmission(`projects[${index}].workspaces[${workspaceIndex}].repoUrl`, "private workspace URLs are not published", entries) + : workspace.repoUrl, + setupCommand: sanitizeText(workspace.setupCommand, `projects[${index}].workspaces[${workspaceIndex}].setupCommand`, entries), + cleanupCommand: sanitizeText(workspace.cleanupCommand, `projects[${index}].workspaces[${workspaceIndex}].cleanupCommand`, entries), + metadata: sanitizeNullableRecord(workspace.metadata, `projects[${index}].workspaces[${workspaceIndex}].metadata`, entries), + })), + metadata: sanitizeNullableRecord(project.metadata, `projects[${index}].metadata`, entries), + })); + manifest.issues = manifest.issues.map((issue, index) => ({ + ...issue, + description: sanitizeText(issue.description, `issues[${index}].description`, entries), + routine: issue.routine + ? { + ...issue.routine, + variables: issue.routine.variables?.map((variable, variableIndex) => ( + sanitizeValue(variable, `issues[${index}].routine.variables[${variableIndex}]`, entries) + )) as typeof issue.routine.variables, + triggers: issue.routine.triggers.map((trigger, triggerIndex) => ({ + ...trigger, + enabled: false, + signingMode: trigger.kind === "webhook" ? null : trigger.signingMode, + replayWindowSec: trigger.kind === "webhook" ? null : trigger.replayWindowSec, + })), + } + : null, + executionWorkspaceSettings: sanitizeNullableRecord(issue.executionWorkspaceSettings, `issues[${index}].executionWorkspaceSettings`, entries), + assigneeAdapterOverrides: sanitizeNullableRecord(issue.assigneeAdapterOverrides, `issues[${index}].assigneeAdapterOverrides`, entries), + comments: [], + metadata: sanitizeNullableRecord(issue.metadata, `issues[${index}].metadata`, entries), + })); + for (const [index, issue] of source.issues.entries()) { + if (issue.comments.length > 0) { + entries.push({ + path: `issues[${index}].comments`, + outcome: "omit", + reason: "raw issue comments are private source-company state", + }); + } + issue.routine?.triggers.forEach((trigger, triggerIndex) => { + if (trigger.enabled) { + entries.push({ + path: `issues[${index}].routine.triggers[${triggerIndex}].enabled`, + outcome: "redact", + reason: "imported routine triggers are disabled by default", + }); + } + if (trigger.kind === "webhook" && (trigger.signingMode || trigger.replayWindowSec !== null)) { + entries.push({ + path: `issues[${index}].routine.triggers[${triggerIndex}]`, + outcome: "omit", + reason: "webhook signing details are regenerated on import", + }); + } + }); + } + + const redactionReport = buildRedactionReport(entries, generatedAt); + return { manifest: sortManifest(manifest), redactionReport }; +} + +function buildDependencyGraph(manifest: CompanyPortabilityManifest): ClipDependencyGraph { + const adapters = new Map; note: string | null }>(); + const plugins = new Map; note: string | null }>(); + const skills = new Map }>(); + const secrets = new Map }>(); + const permissions = new Map }>(); + const workspaces = new Map }>(); + const runtime = { + localShell: false, + browser: false, + filesystem: "none" as ClipRuntimeFilesystem, + webhooks: false, + recurringRoutines: false, + }; + let monthlyCents = 0; + const budgetRefs = new Set(); + + for (const [index, agent] of manifest.agents.entries()) { + const sourceRef = `agents.${agent.slug || index}`; + addMapEntry(adapters, agent.adapterType, { + type: agent.adapterType, + required: true, + sourceRefs: new Set([sourceRef]), + note: null, + }); + monthlyCents += agent.budgetMonthlyCents; + if (agent.budgetMonthlyCents > 0) budgetRefs.add(sourceRef); + for (const skillRef of agent.skills) { + addSkill(skills, skillRef, skillRef, sourceRef); + } + for (const key of collectSecretKeys(agent.adapterConfig)) { + addSecret(secrets, key, "optional", null, sourceRef); + } + for (const permission of collectPermissionCapabilities(agent.permissions)) { + addPermission(permissions, permission, null, sourceRef); + } + if (agent.adapterType === "process" || hasAnyKey(agent.adapterConfig, ["command", "shell", "cwd"])) { + runtime.localShell = true; + } + if (hasAnyKey(agent.permissions, ["browser", "browserAutomation"])) { + runtime.browser = true; + } + if (hasAnyKey(agent.adapterConfig, ["cwd", "workspace", "filesystem", "path"])) { + runtime.filesystem = "declared"; + } + } + + for (const skill of manifest.skills) { + addSkill(skills, skill.key, skill.slug, `skills.${skill.slug}`); + } + + for (const [index, envInput] of manifest.envInputs.entries()) { + if (envInput.kind === "secret") { + addSecret(secrets, envInput.key, envInput.requirement, envInput.description, `envInputs.${index}`); + } + } + + for (const project of manifest.projects) { + for (const key of collectSecretKeys(project.env)) { + addSecret(secrets, key, "optional", null, `projects.${project.slug}.env`); + } + for (const workspace of project.workspaces) { + addMapEntry(workspaces, workspace.key, { + key: workspace.key, + repoUrlRequired: Boolean(workspace.repoUrl), + pinnedRefRecommended: Boolean(workspace.repoUrl && !workspace.repoRef), + sourceRefs: new Set([`projects.${project.slug}.workspaces.${workspace.key}`]), + }); + } + } + + for (const issue of manifest.issues) { + if (issue.recurring || issue.routine) { + runtime.recurringRoutines = true; + } + if (issue.routine?.triggers.some((trigger) => trigger.kind === "webhook")) { + runtime.webhooks = true; + addPermission(permissions, "webhook.receive", "Routine has a webhook trigger placeholder.", `issues.${issue.slug}.routine`); + } + for (const trigger of issue.routine?.triggers ?? []) { + if (trigger.kind === "schedule") { + addPermission(permissions, "routine.schedule", "Routine has a recurring schedule.", `issues.${issue.slug}.routine`); + } + } + } + + return { + adapters: finalizeMap(adapters), + plugins: finalizeMap(plugins), + skills: finalizeMap(skills), + secrets: finalizeMap(secrets), + permissions: finalizeMap(permissions), + runtime, + workspaces: finalizeMap(workspaces), + budgetHints: { + monthlyCents, + sourceRefs: Array.from(budgetRefs).sort(), + }, + }; +} + +function buildDangerousCapabilities(graph: ClipDependencyGraph) { + const capabilities = new Set(); + if (graph.runtime.browser) capabilities.add("browser"); + if (graph.runtime.localShell) capabilities.add("shell"); + if (graph.runtime.filesystem !== "none") capabilities.add("filesystem"); + if (graph.runtime.webhooks) capabilities.add("webhook"); + if (graph.runtime.recurringRoutines) capabilities.add("recurring_routine"); + for (const permission of graph.permissions) { + const root = permission.capability.split(".")[0]; + if (["calendar", "drive", "email", "filesystem", "github", "slack"].includes(root)) { + capabilities.add(root); + } + } + return Array.from(capabilities).sort(); +} + +function sanitizeValue(value: unknown, path: string, entries: ClipRedactionReportEntry[]): unknown { + if (value === null || value === undefined) return value; + if (typeof value === "string") return sanitizeText(value, path, entries); + if (typeof value !== "object") return value; + if (Array.isArray(value)) { + return value.map((entry, index) => sanitizeValue(entry, `${path}[${index}]`, entries)); + } + const record = value as Record; + if (record.type === "secret_ref") { + entries.push({ path, outcome: "omit", reason: "source secret references are replaced with setup inputs" }); + return { type: "secret_input" }; + } + const next: Record = {}; + for (const key of Object.keys(record).sort()) { + const childPath = `${path}.${key}`; + const child = record[key]; + if (SECRET_KEY_PATTERN.test(key) && child !== null && child !== undefined) { + entries.push({ path: childPath, outcome: "omit", reason: "secret-like fields are not published" }); + continue; + } + if (typeof child === "string" && shouldOmitString(child)) { + entries.push({ path: childPath, outcome: "omit", reason: "private local paths, URLs, or credentials are not published" }); + continue; + } + next[key] = sanitizeValue(child, childPath, entries); + } + return next; +} + +function sanitizeText(value: string | null, path: string, entries: ClipRedactionReportEntry[]) { + if (value === null) return null; + if (shouldOmitString(value)) { + entries.push({ path, outcome: "redact", reason: "text contained private paths, URLs, or secret-looking values" }); + return "[redacted]"; + } + return value; +} + +function sanitizeNullableRecord(value: Record | null, path: string, entries: ClipRedactionReportEntry[]) { + return value === null ? null : sanitizeValue(value, path, entries) as Record; +} + +function shouldOmitString(value: string | null) { + return Boolean(value && (SECRET_VALUE_PATTERN.test(value) || LOCAL_PATH_PATTERN.test(value) || PRIVATE_URL_PATTERN.test(value))); +} + +function recordOmission(path: string, reason: string, entries: ClipRedactionReportEntry[]) { + entries.push({ path, outcome: "omit", reason }); + return null; +} + +function buildRedactionReport(entries: ClipRedactionReportEntry[], generatedAt: string): ClipRedactionReport { + const sortedEntries = [...entries].sort((left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason)); + return { + schema: "paperclip.clip.redaction/v1", + generatedAt, + entries: sortedEntries, + summary: { + allowed: 0, + redacted: sortedEntries.filter((entry) => entry.outcome === "redact").length, + summarized: sortedEntries.filter((entry) => entry.outcome === "summarize").length, + omitted: sortedEntries.filter((entry) => entry.outcome === "omit").length, + }, + }; +} + +function scopeManifestToAgents(source: CompanyPortabilityManifest, agentSlugs: string[]) { + const selected = new Set(agentSlugs); + const manifest = cloneManifest(source); + manifest.agents = manifest.agents.filter((agent) => selected.has(agent.slug)); + manifest.skills = filterSkillsForAgents(manifest.skills, manifest.agents); + manifest.projects = []; + manifest.issues = manifest.issues.filter((issue) => issue.assigneeAgentSlug ? selected.has(issue.assigneeAgentSlug) : false); + manifest.includes = { + company: false, + agents: manifest.agents.length > 0, + projects: false, + issues: manifest.issues.length > 0, + skills: manifest.skills.length > 0, + }; + return manifest; +} + +function collectAgentSubtreeSlugs(agents: CompanyPortabilityAgentManifestEntry[], rootSlug: string) { + const result = new Set(); + const visit = (slug: string) => { + if (result.has(slug)) return; + result.add(slug); + for (const child of agents.filter((agent) => agent.reportsToSlug === slug)) { + visit(child.slug); + } + }; + visit(rootSlug); + return Array.from(result).sort(); +} + +function filterSkillsForAgents(skills: CompanyPortabilitySkillManifestEntry[], agents: CompanyPortabilityAgentManifestEntry[]) { + const refs = new Set(agents.flatMap((agent) => agent.skills)); + return skills.filter((skill) => refs.has(skill.key) || refs.has(skill.slug)); +} + +function findSkill(skills: CompanyPortabilitySkillManifestEntry[], key?: string, slug?: string) { + return skills.find((skill) => (key && skill.key === key) || (slug && skill.slug === slug)) ?? null; +} + +function sortManifest(manifest: CompanyPortabilityManifest): CompanyPortabilityManifest { + return { + ...manifest, + agents: [...manifest.agents].sort(bySlug), + skills: [...manifest.skills].sort((left, right) => left.key.localeCompare(right.key) || left.slug.localeCompare(right.slug)), + projects: [...manifest.projects].sort(bySlug), + issues: [...manifest.issues].sort(bySlug), + envInputs: [...manifest.envInputs].sort((left, right) => left.key.localeCompare(right.key)), + }; +} + +function normalizeFilesForChecksum(files: Record) { + return Object.fromEntries(Object.entries(files).sort(([left], [right]) => left.localeCompare(right))); +} + +function entrypointForClipType(type: ClipManifestType) { + if (type === "agent") return "AGENTS.md"; + if (type === "skill") return "SKILL.md"; + if (type === "routine") return "TASK.md"; + if (type === "team") return "TEAM.md"; + return "COMPANY.md"; +} + +function collectSecretKeys(value: unknown): string[] { + const keys = new Set(); + const visit = (entry: unknown, keyHint: string | null) => { + if (entry === null || entry === undefined) return; + if (typeof entry !== "object") { + if (keyHint && SECRET_KEY_PATTERN.test(keyHint)) keys.add(keyHint); + return; + } + if (Array.isArray(entry)) { + entry.forEach((item) => visit(item, keyHint)); + return; + } + const record = entry as Record; + if (record.type === "secret_ref") { + if (keyHint) keys.add(keyHint); + return; + } + for (const [key, child] of Object.entries(record)) { + if (SECRET_KEY_PATTERN.test(key)) keys.add(key); + visit(child, key); + } + }; + visit(value, null); + return Array.from(keys).sort(); +} + +function collectPermissionCapabilities(value: unknown): string[] { + const capabilities = new Set(); + const visit = (entry: unknown, path: string[]) => { + if (entry === null || entry === undefined) return; + if (typeof entry === "boolean") { + if (entry && path.length > 0) capabilities.add(path.join(".")); + return; + } + if (typeof entry === "string") { + if (entry === "true" || entry === "allow" || entry === "write") capabilities.add(path.join(".")); + return; + } + if (typeof entry !== "object" || Array.isArray(entry)) return; + for (const [key, child] of Object.entries(entry as Record)) { + visit(child, [...path, key]); + } + }; + visit(value, []); + return Array.from(capabilities).sort(); +} + +function hasAnyKey(value: unknown, keys: string[]) { + if (!value || typeof value !== "object") return false; + const wanted = new Set(keys); + const visit = (entry: unknown): boolean => { + if (!entry || typeof entry !== "object") return false; + if (Array.isArray(entry)) return entry.some(visit); + return Object.entries(entry as Record).some(([key, child]) => wanted.has(key) || visit(child)); + }; + return visit(value); +} + +function addSecret( + secrets: Map }>, + key: string, + requirement: "required" | "optional", + description: string | null, + sourceRef: string, +) { + addMapEntry(secrets, key, { key, kind: "secret", requirement, description, sourceRefs: new Set([sourceRef]) }); +} + +function addSkill( + skills: Map }>, + key: string, + slug: string, + sourceRef: string, +) { + addMapEntry(skills, key, { key, slug, requirement: "required", sourceRefs: new Set([sourceRef]) }); +} + +function addPermission( + permissions: Map }>, + capability: string, + reason: string | null, + sourceRef: string, +) { + addMapEntry(permissions, capability, { capability, reason, sourceRefs: new Set([sourceRef]) }); +} + +function addMapEntry }>(map: Map, key: string, value: T) { + const existing = map.get(key); + if (!existing) { + map.set(key, value); + return; + } + for (const sourceRef of value.sourceRefs) { + existing.sourceRefs.add(sourceRef); + } +} + +function finalizeMap }>(map: Map) { + return Array.from(map.values()) + .map((entry) => ({ ...entry, sourceRefs: Array.from(entry.sourceRefs).sort() })) + .sort((left, right) => stableStringify(left).localeCompare(stableStringify(right))); +} + +function bySlug(left: T, right: T) { + return left.slug.localeCompare(right.slug); +} + +function normalizeSlug(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") || "clip"; +} + +function cloneManifest(manifest: CompanyPortabilityManifest): CompanyPortabilityManifest { + return JSON.parse(JSON.stringify(manifest)) as CompanyPortabilityManifest; +} + +function sha256Stable(value: unknown) { + 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 { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + return `{${Object.keys(value as Record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}`).join(",")}}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9502219f3e7..ae551a9d963 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,44 @@ export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; +export * from "./clip-manifest.js"; +export * from "./types/clip.js"; +export { + clipVisibilitySchema, + clipStatusSchema, + clipModerationStateSchema, + clipSecurityReviewStateSchema, + clipVerificationStateSchema, + clipDependencyTypeSchema, + clipVoteValueSchema, + clipReportReasonSchema, + createClipCreatorProfileSchema, + clipDependencyInputSchema, + clipRevisionInputSchema, + publishClipSchema, + createClipRevisionSchema, + updateClipSchema, + createClipVoteSchema, + createClipReportSchema, + createClipCommentSchema, + createClipShowcaseSchema, + createClipImportTelemetrySchema, + clipSharePreviewSchema, + clipImportPreviewSchema, + clipImportApplySchema, + type CreateClipCreatorProfile, + type ClipDependencyInput, + type ClipRevisionInput, + type PublishClip, + type CreateClipRevision, + type UpdateClip, + type CreateClipVote, + type CreateClipReport, + type CreateClipComment, + type CreateClipShowcase, + type CreateClipImportTelemetry, + type ClipSharePreview, + type ClipImportPreview, + type ClipImportApply, +} from "./validators/clip.js"; export { getAgentOrgChainHealth, getAgentWorkEligibility, diff --git a/packages/shared/src/types/clip.ts b/packages/shared/src/types/clip.ts new file mode 100644 index 00000000000..e5f07fa3901 --- /dev/null +++ b/packages/shared/src/types/clip.ts @@ -0,0 +1,216 @@ +export const CLIP_TYPES = ["team", "agent", "skill", "routine", "bundle"] as const; +export type ClipType = (typeof CLIP_TYPES)[number]; + +export const CLIP_VISIBILITIES = ["private_share", "unlisted", "public"] as const; +export type ClipVisibility = (typeof CLIP_VISIBILITIES)[number]; + +export const CLIP_STATUSES = ["draft", "pending_review", "published", "delisted", "archived"] as const; +export type ClipStatus = (typeof CLIP_STATUSES)[number]; + +export const CLIP_MODERATION_STATES = ["normal", "under_review", "warning", "limited", "blocked", "delisted"] as const; +export type ClipModerationState = (typeof CLIP_MODERATION_STATES)[number]; + +export const CLIP_SECURITY_REVIEW_STATES = ["unreviewed", "automated_checked", "security_reviewed", "blocked"] as const; +export type ClipSecurityReviewState = (typeof CLIP_SECURITY_REVIEW_STATES)[number]; + +export const CLIP_VERIFICATION_STATES = ["not_run", "passed", "failed", "stale"] as const; +export type ClipVerificationState = (typeof CLIP_VERIFICATION_STATES)[number]; + +export const CLIP_DEPENDENCY_TYPES = ["adapter", "plugin", "skill", "secret", "permission", "runtime", "workspace"] as const; +export type ClipDependencyType = (typeof CLIP_DEPENDENCY_TYPES)[number]; + +export const CLIP_DEPENDENCY_REQUIREMENTS = ["required", "optional"] as const; +export type ClipDependencyRequirement = (typeof CLIP_DEPENDENCY_REQUIREMENTS)[number]; + +export const CLIP_COMMENT_CATEGORIES = [ + "question", + "bug", + "integration_help", + "use_case_report", + "security_concern", + "maintainer_note", +] as const; +export type ClipCommentCategory = (typeof CLIP_COMMENT_CATEGORIES)[number]; + +export const CLIP_VOTE_VALUES = ["up", "down"] as const; +export type ClipVoteValue = (typeof CLIP_VOTE_VALUES)[number]; + +export const CLIP_REPORT_REASONS = [ + "malicious_instructions", + "credential_theft", + "unsafe_automation", + "spam", + "copyright", + "misleading_metadata", + "broken_install", + "impersonation", + "other", +] as const; +export type ClipReportReason = (typeof CLIP_REPORT_REASONS)[number]; + +export const CLIP_IMPORT_STATUSES = [ + "previewed", + "applied", + "failed", + "rolled_back", + "update_available", + "superseded", +] as const; +export type ClipImportStatus = (typeof CLIP_IMPORT_STATUSES)[number]; + +export interface ClipCreatorProfile { + id: string; + companyId: string; + handle: string; + displayName: string; + bio: string | null; + avatarUrl: string | null; + websiteUrl: string | null; + verificationState: string; + reputationSummary: Record; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface ClipDependency { + id: string; + clipId: string; + revisionId: string; + type: ClipDependencyType; + key: string; + displayName: string | null; + required: ClipDependencyRequirement; + metadata: Record; + createdAt: Date | string; +} + +export interface ClipRevision { + id: string; + clipId: string; + revisionNumber: number; + manifestVersion: string; + manifestChecksum: string; + artifactChecksum: string; + manifestPayload: Record; + artifactRef: Record | null; + dependencyGraph: Record; + permissions: Record[]; + secretsSchema: Record[]; + budgetEstimate: Record | null; + redactionReport: Record; + dangerousCapabilities: string[]; + securityReviewState: ClipSecurityReviewState; + verificationState: ClipVerificationState; + compatibility: Record; + changeSummary: string | null; + breakingChanges: string | null; + migrationNotes: string | null; + createdAt: Date | string; +} + +export interface PublicClip { + id: string; + slug: string; + type: ClipType; + title: string; + summary: string; + description: string | null; + visibility: ClipVisibility; + status: ClipStatus; + moderationState: ClipModerationState; + currentRevisionId: string | null; + latestApprovedRevisionId: string | null; + creatorProfileId: string; + creator: Pick; + tags: string[]; + categories: string[]; + useCases: string[]; + requiredProviders: string[]; + compatibility: Record; + metrics: { + importCount: number; + successfulFirstRunCount: number; + voteScore: number; + upvoteCount: number; + downvoteCount: number; + commentCount: number; + showcaseCount: number; + reportCount: number; + }; + currentRevision?: ClipRevision | null; + dependencies?: ClipDependency[]; + 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/types/index.ts b/packages/shared/src/types/index.ts index 246c30f1616..d92139b09b8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -569,6 +569,7 @@ export type { UserCompanyAccessResponse, } from "./access.js"; export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; +export * from "./clip.js"; export type { CompanyPortabilityInclude, CompanyPortabilityEnvInput, diff --git a/packages/shared/src/validators/clip.ts b/packages/shared/src/validators/clip.ts new file mode 100644 index 00000000000..57eee63cec5 --- /dev/null +++ b/packages/shared/src/validators/clip.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; +import { + CLIP_COMMENT_CATEGORIES, + CLIP_DEPENDENCY_REQUIREMENTS, + CLIP_DEPENDENCY_TYPES, + CLIP_IMPORT_STATUSES, + CLIP_MODERATION_STATES, + CLIP_REPORT_REASONS, + CLIP_SECURITY_REVIEW_STATES, + CLIP_STATUSES, + CLIP_TYPES, + CLIP_VERIFICATION_STATES, + CLIP_VISIBILITIES, + CLIP_VOTE_VALUES, +} from "../types/clip.js"; + +const slugSchema = z + .string() + .min(3) + .max(96) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + +const publicTextSchema = z.string().trim().min(1).max(12_000); +const shortPublicTextSchema = z.string().trim().min(1).max(280); +const tagArraySchema = z.array(z.string().trim().min(1).max(48)).max(24).default([]); +const recordSchema = z.record(z.string(), z.unknown()); +const optionalRecordSchema = recordSchema.nullable().optional(); + +export const clipTypeSchema = z.enum(CLIP_TYPES); +export const clipVisibilitySchema = z.enum(CLIP_VISIBILITIES); +export const clipStatusSchema = z.enum(CLIP_STATUSES); +export const clipModerationStateSchema = z.enum(CLIP_MODERATION_STATES); +export const clipSecurityReviewStateSchema = z.enum(CLIP_SECURITY_REVIEW_STATES); +export const clipVerificationStateSchema = z.enum(CLIP_VERIFICATION_STATES); +export const clipDependencyTypeSchema = z.enum(CLIP_DEPENDENCY_TYPES); +export const clipVoteValueSchema = z.enum(CLIP_VOTE_VALUES); +export const clipReportReasonSchema = z.enum(CLIP_REPORT_REASONS); + +export const createClipCreatorProfileSchema = z.object({ + handle: slugSchema, + displayName: z.string().trim().min(1).max(96), + bio: z.string().trim().max(1_000).nullable().optional(), + avatarUrl: z.string().url().nullable().optional(), + websiteUrl: z.string().url().nullable().optional(), + verificationState: z.string().trim().min(1).max(48).optional(), + reputationSummary: recordSchema.optional(), +}); + +export type CreateClipCreatorProfile = z.infer; + +export const clipDependencyInputSchema = z.object({ + type: clipDependencyTypeSchema, + key: z.string().trim().min(1).max(160), + displayName: z.string().trim().min(1).max(160).nullable().optional(), + required: z.enum(CLIP_DEPENDENCY_REQUIREMENTS).optional(), + metadata: recordSchema.optional(), +}); + +export type ClipDependencyInput = z.infer; + +export const clipRevisionInputSchema = z.object({ + manifestVersion: z.string().trim().min(1).max(64).optional(), + manifestChecksum: z.string().trim().min(8).max(160), + artifactChecksum: z.string().trim().min(8).max(160), + manifestPayload: recordSchema, + artifactRef: optionalRecordSchema, + dependencyGraph: recordSchema.optional(), + dependencies: z.array(clipDependencyInputSchema).max(200).optional(), + permissions: z.array(recordSchema).max(200).optional(), + secretsSchema: z.array(recordSchema).max(200).optional(), + budgetEstimate: optionalRecordSchema, + redactionReport: recordSchema.optional(), + dangerousCapabilities: z.array(z.string().trim().min(1).max(80)).max(100).optional(), + securityReviewState: clipSecurityReviewStateSchema.optional(), + verificationState: clipVerificationStateSchema.optional(), + compatibility: recordSchema.optional(), + changeSummary: z.string().trim().max(4_000).nullable().optional(), + breakingChanges: z.string().trim().max(4_000).nullable().optional(), + migrationNotes: z.string().trim().max(4_000).nullable().optional(), +}); + +export type ClipRevisionInput = z.infer; + +export const publishClipSchema = z.object({ + creatorProfileId: z.string().uuid().optional(), + creatorProfile: createClipCreatorProfileSchema.optional(), + slug: slugSchema, + type: clipTypeSchema, + title: z.string().trim().min(1).max(160), + summary: shortPublicTextSchema, + description: publicTextSchema.nullable().optional(), + visibility: clipVisibilitySchema.optional(), + status: clipStatusSchema.optional(), + tags: tagArraySchema.optional(), + categories: tagArraySchema.optional(), + useCases: tagArraySchema.optional(), + requiredProviders: tagArraySchema.optional(), + compatibility: recordSchema.optional(), + sourceKind: z.string().trim().min(1).max(80).nullable().optional(), + sourceObjectType: z.string().trim().min(1).max(80).nullable().optional(), + sourceObjectId: z.string().trim().min(1).max(160).nullable().optional(), + revision: clipRevisionInputSchema, +}).refine((value) => value.creatorProfileId || value.creatorProfile, { + message: "creatorProfileId or creatorProfile is required", + path: ["creatorProfileId"], +}); + +export type PublishClip = z.infer; + +export const createClipRevisionSchema = clipRevisionInputSchema; +export type CreateClipRevision = z.infer; + +export const updateClipSchema = z + .object({ + title: z.string().trim().min(1).max(160).optional(), + summary: shortPublicTextSchema.optional(), + description: publicTextSchema.nullable().optional(), + visibility: clipVisibilitySchema.optional(), + status: clipStatusSchema.optional(), + moderationState: clipModerationStateSchema.optional(), + latestApprovedRevisionId: z.string().uuid().nullable().optional(), + tags: tagArraySchema.optional(), + categories: tagArraySchema.optional(), + useCases: tagArraySchema.optional(), + requiredProviders: tagArraySchema.optional(), + compatibility: recordSchema.optional(), + moderationReason: z.string().trim().max(1_000).optional(), + }) + .strict(); + +export type UpdateClip = z.infer; + +export const createClipVoteSchema = z.object({ + revisionNumber: z.number().int().positive().optional(), + vote: clipVoteValueSchema, + reason: z.string().trim().max(400).nullable().optional(), + metadata: recordSchema.optional(), +}); + +export type CreateClipVote = z.infer; + +export const createClipReportSchema = z.object({ + revisionNumber: z.number().int().positive().optional(), + reason: clipReportReasonSchema, + details: z.string().trim().max(4_000).nullable().optional(), + metadata: recordSchema.optional(), +}); + +export type CreateClipReport = z.infer; + +export const createClipCommentSchema = z.object({ + revisionNumber: z.number().int().positive().optional(), + scope: z.enum(["clip", "revision"]).optional(), + category: z.enum(CLIP_COMMENT_CATEGORIES).optional(), + body: publicTextSchema, +}); + +export type CreateClipComment = z.infer; + +export const createClipShowcaseSchema = z.object({ + revisionNumber: z.number().int().positive().optional(), + type: z.string().trim().min(1).max(80).optional(), + title: z.string().trim().min(1).max(160), + body: publicTextSchema.nullable().optional(), + mediaRefs: z.array(recordSchema).max(12).optional(), + validationState: clipVerificationStateSchema.optional(), +}); + +export type CreateClipShowcase = z.infer; + +export const createClipImportTelemetrySchema = z.object({ + revisionNumber: z.number().int().positive().optional(), + destinationCompanyId: z.string().uuid().optional(), + status: z.enum(CLIP_IMPORT_STATUSES).optional(), + sourceUrl: z.string().url().nullable().optional(), + revisionUrl: z.string().url().nullable().optional(), + metadata: recordSchema.optional(), +}); + +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 b76d6dee126..14c2f0b8734 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -81,6 +81,7 @@ export { updateResourceMembershipSchema, type UpdateResourceMembership, } from "./resource-memberships.js"; +export * from "./clip.js"; export { externalObjectStatusCategorySchema, diff --git a/server/src/__tests__/clips-routes.test.ts b/server/src/__tests__/clips-routes.test.ts new file mode 100644 index 00000000000..23cc8d11115 --- /dev/null +++ b/server/src/__tests__/clips-routes.test.ts @@ -0,0 +1,75 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clipRoutes } from "../routes/clips.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockClipService = vi.hoisted(() => ({ + getPublicDetail: vi.fn(), + getPublicRevision: vi.fn(), + getCreatorPublicProfile: vi.fn(), + publish: vi.fn(), + getClipById: vi.fn(), + createRevision: vi.fn(), + updateClip: vi.fn(), + createVote: vi.fn(), + createReport: vi.fn(), + createComment: vi.fn(), + createShowcase: vi.fn(), + recordImportTelemetry: vi.fn(), +})); + +const mockCompanyService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockCompanyPortabilityService = vi.hoisted(() => ({ + previewExport: vi.fn(), + previewImport: vi.fn(), + importBundle: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/clips.js", () => ({ + clipService: () => mockClipService, +})); + +vi.mock("../services/index.js", () => ({ + companyPortabilityService: () => mockCompanyPortabilityService, + companyService: () => mockCompanyService, + logActivity: mockLogActivity, +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: "board", + }; + next(); + }); + app.use("/api", clipRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("clip routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects malformed companyId path params before share-preview database lookups", async () => { + const res = await request(createApp()) + .post("/api/companies/random-company-id/clips/share-preview") + .send({ source: { type: "agent", id: "x" } }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "Invalid companyId path parameter." }); + expect(mockCompanyService.getById).not.toHaveBeenCalled(); + expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/clips-service.test.ts b/server/src/__tests__/clips-service.test.ts new file mode 100644 index 00000000000..709310d9f91 --- /dev/null +++ b/server/src/__tests__/clips-service.test.ts @@ -0,0 +1,186 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + clipComments, + clipCreatorProfiles, + clipDependencies, + clipFlags, + clipImportedObjects, + clipImports, + clipModerationEvents, + clipPublicMetricEvents, + clipRankingSnapshots, + clipRevisions, + clipShowcasePosts, + clipVotes, + clips, + companies, + createDb, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { clipService } from "../services/clips.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres clips service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("clipService", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-clips-service-"); + db = createDb(tempDb.connectionString); + svc = clipService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(clipPublicMetricEvents); + await db.delete(clipImportedObjects); + await db.delete(clipImports); + await db.delete(clipRankingSnapshots); + await db.delete(clipModerationEvents); + await db.delete(clipShowcasePosts); + await db.delete(clipVotes); + await db.delete(clipComments); + await db.delete(clipFlags); + await db.delete(clipDependencies); + await db.delete(clipRevisions); + await db.delete(clips); + await db.delete(clipCreatorProfiles); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function publishFixture(companyId: string) { + return svc.publish(companyId, { + creatorProfile: { + handle: `creator-${companyId.slice(0, 8)}`, + displayName: "Creator Labs", + }, + slug: `support-triage-${companyId.slice(0, 8)}`, + type: "agent", + title: "Support Triage Agent", + summary: "Routes support tickets to the right specialist.", + description: "A sanitized public description.", + visibility: "public", + status: "published", + tags: ["support"], + requiredProviders: ["github"], + sourceKind: "paperclip_company_object", + sourceObjectType: "agent", + sourceObjectId: "private-agent-id", + revision: { + manifestVersion: "paperclip.clip/v1", + manifestChecksum: "sha256:manifest-v1", + artifactChecksum: "sha256:artifact-v1", + manifestPayload: { + schema: "paperclip.clip/v1", + privateSourceCompanyId: "must-not-come-from-public-api", + clip: { slug: "support-triage" }, + }, + dependencyGraph: { adapters: [{ type: "codex_local" }] }, + dependencies: [ + { type: "adapter", key: "codex_local", required: "optional" }, + { type: "permission", key: "github.issues.write" }, + ], + redactionReport: { summary: { omitted: 3 } }, + dangerousCapabilities: ["github"], + }, + }); + } + + it("publishes sanitized public detail without exposing private source identifiers", async () => { + const companyId = await seedCompany(); + const { clip, revision } = await publishFixture(companyId); + + const detail = await svc.getPublicDetail(clip.slug); + + expect(detail).toMatchObject({ + id: clip.id, + slug: clip.slug, + title: "Support Triage Agent", + currentRevision: { + id: revision.id, + revisionNumber: 1, + manifestChecksum: "sha256:manifest-v1", + }, + }); + expect(detail?.dependencies?.map((dependency) => dependency.key).sort()).toEqual([ + "codex_local", + "github.issues.write", + ]); + expect(detail).not.toHaveProperty("sourceCompanyId"); + expect(detail).not.toHaveProperty("sourceObjectId"); + }); + + it("stores revisions append-only and serves exact revision manifests", async () => { + const companyId = await seedCompany(); + const { clip, revision } = await publishFixture(companyId); + + const update = await svc.createRevision(clip.id, { + manifestChecksum: "sha256:manifest-v2", + artifactChecksum: "sha256:artifact-v2", + manifestPayload: { schema: "paperclip.clip/v1", revision: 2 }, + changeSummary: "Adds a safer default.", + }); + + expect(update.revision.revisionNumber).toBe(2); + const firstRevision = await svc.getPublicRevision(clip.slug, 1); + const secondRevision = await svc.getPublicDetail(clip.slug); + + expect(firstRevision?.currentRevision?.manifestChecksum).toBe("sha256:manifest-v1"); + expect(secondRevision?.currentRevision?.manifestChecksum).toBe("sha256:manifest-v2"); + await expect( + db.update(clipRevisions).set({ changeSummary: "mutated" }).where(eq(clipRevisions.id, revision.id)), + ).rejects.toThrow(/Failed query/); + const [unchanged] = await db.select().from(clipRevisions).where(eq(clipRevisions.id, revision.id)); + expect(unchanged?.changeSummary).toBeNull(); + }); + + it("enforces public moderation state on reads while keeping report audit data", async () => { + const companyId = await seedCompany(); + const { clip } = await publishFixture(companyId); + + await svc.createReport(clip.slug, { reason: "unsafe_automation", details: "It asks for broad shell access." }, { + actorType: "anonymous", + actorId: "anonymous", + }); + const underReview = await svc.getPublicDetail(clip.slug); + expect(underReview?.moderationState).toBe("under_review"); + + await svc.updateClip(clip.id, { moderationState: "blocked", moderationReason: "Confirmed malicious behavior." }, { + actorType: "user", + actorId: "moderator", + }); + + expect(await svc.getPublicDetail(clip.slug)).toBeNull(); + const reports = await db.select().from(clipFlags).where(eq(clipFlags.clipId, clip.id)); + expect(reports).toHaveLength(1); + expect(reports[0]?.reason).toBe("unsafe_automation"); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 3ee97f25f81..1ac6207bcf6 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -22,6 +22,7 @@ import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; import { fileResourceRoutes } from "./routes/file-resources.js"; import { routineRoutes } from "./routes/routines.js"; import { pipelineRoutes } from "./routes/pipelines.js"; +import { clipRoutes } from "./routes/clips.js"; import { environmentRoutes } from "./routes/environments.js"; import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; import { goalRoutes } from "./routes/goals.js"; @@ -238,6 +239,7 @@ export async function createApp( api.use(fileResourceRoutes(db)); api.use(routineRoutes(db, { pluginWorkerManager: workerManager })); api.use(pipelineRoutes(db)); + api.use(clipRoutes(db, opts.storageService)); api.use(environmentRoutes(db, { pluginWorkerManager: workerManager })); api.use(executionWorkspaceRoutes(db, { pluginWorkerManager: workerManager })); api.use(goalRoutes(db)); diff --git a/server/src/routes/clips.ts b/server/src/routes/clips.ts new file mode 100644 index 00000000000..94bbe2acbab --- /dev/null +++ b/server/src/routes/clips.ts @@ -0,0 +1,737 @@ +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, + createClipReportSchema, + createClipRevisionSchema, + createClipShowcaseSchema, + createClipVoteSchema, + publishClipSchema, + updateClipSchema, + isUuidLike, + type CompanyPortabilityFileEntry, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { badRequest, forbidden, unprocessable } from "../errors.js"; +import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { clipService } from "../services/clips.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; +const clipRateLimitHits = new Map(); + +function actorForClip(req: Request) { + if (req.actor.type === "agent") { + return { + actorType: "agent" as const, + actorId: req.actor.agentId ?? "unknown-agent", + agentId: req.actor.agentId ?? null, + userId: null, + }; + } + if (req.actor.type === "board") { + return { + actorType: "user" as const, + actorId: req.actor.userId ?? "board", + agentId: null, + userId: req.actor.userId ?? null, + }; + } + return { + actorType: "anonymous" as const, + actorId: "anonymous", + agentId: null, + userId: null, + }; +} + +function requireAuthenticatedClipActor(req: Request) { + assertAuthenticated(req); + const actor = actorForClip(req); + if (actor.actorType === "anonymous") throw forbidden("Authenticated actor required"); + return actor; +} + +function getClipCompanyIdParam(req: Request) { + const companyId = req.params.companyId as string; + if (!isUuidLike(companyId)) { + throw badRequest("Invalid companyId path parameter."); + } + return companyId; +} + +function consumeClipRateLimit(req: Request, res: { setHeader(name: string, value: string): void; status(code: number): { json(value: unknown): void } }, action: string) { + const now = Date.now(); + const cutoff = now - CLIP_RATE_LIMIT_WINDOW_MS; + const actor = actorForClip(req); + const ip = req.ip || req.socket.remoteAddress || "unknown-ip"; + const key = `${action}:${actor.actorType}:${actor.actorId}:${ip}`; + const recent = (clipRateLimitHits.get(key) ?? []).filter((hit) => hit > cutoff); + if (recent.length >= CLIP_RATE_LIMIT_MAX_REQUESTS) { + const retryAfterSeconds = Math.max(1, Math.ceil(((recent[0] ?? now) + CLIP_RATE_LIMIT_WINDOW_MS - now) / 1000)); + clipRateLimitHits.set(key, recent); + res.setHeader("Retry-After", String(retryAfterSeconds)); + res.status(429).json({ error: "Clip rate limit exceeded", retryAfterSeconds }); + return false; + } + recent.push(now); + clipRateLimitHits.set(key, recent); + return true; +} + +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; + const type = typeof req.query.type === "string" ? req.query.type : null; + const tag = typeof req.query.tag === "string" ? req.query.tag : null; + const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined; + res.json(await svc.listPublic({ q, type, tag, limit, offset })); + }); + + router.get("/public/clips/:slug", async (req, res) => { + const detail = await svc.getPublicDetail(req.params.slug as string); + if (!detail) { + res.status(404).json({ error: "Clip not found" }); + return; + } + res.json(detail); + }); + + router.get("/public/clips/:slug/revisions/:revisionNumber", async (req, res) => { + const revisionNumber = Number(req.params.revisionNumber); + const detail = Number.isInteger(revisionNumber) && revisionNumber > 0 + ? await svc.getPublicRevision(req.params.slug as string, revisionNumber) + : null; + if (!detail) { + res.status(404).json({ error: "Clip revision not found" }); + return; + } + res.json(detail); + }); + + router.get("/public/clips/:slug/manifest", async (req, res) => { + const detail = await svc.getPublicDetail(req.params.slug as string); + if (!detail?.currentRevision) { + res.status(404).json({ error: "Clip manifest not found" }); + return; + } + res.json(detail.currentRevision.manifestPayload); + }); + + router.get("/public/clips/:slug/revisions/:revisionNumber/manifest", async (req, res) => { + const revisionNumber = Number(req.params.revisionNumber); + const detail = Number.isInteger(revisionNumber) && revisionNumber > 0 + ? await svc.getPublicRevision(req.params.slug as string, revisionNumber) + : null; + if (!detail?.currentRevision) { + res.status(404).json({ error: "Clip manifest not found" }); + return; + } + res.json(detail.currentRevision.manifestPayload); + }); + + router.get("/public/creators/:handle", async (req, res) => { + const profile = await svc.getCreatorPublicProfile(req.params.handle as string); + if (!profile) { + res.status(404).json({ error: "Creator profile not found" }); + return; + } + res.json(profile); + }); + + router.post("/companies/:companyId/clips/profiles", validate(createClipCreatorProfileSchema), async (req, res) => { + const companyId = getClipCompanyIdParam(req); + assertCompanyAccess(req, companyId); + const profile = await svc.createCreatorProfile(companyId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "clip.profile_created", + entityType: "clip_creator_profile", + entityId: profile.id, + details: { handle: profile.handle }, + }); + res.status(201).json(profile); + }); + + router.post("/companies/:companyId/clips/share-preview", validate(clipSharePreviewSchema), async (req, res) => { + const companyId = getClipCompanyIdParam(req); + 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 = getClipCompanyIdParam(req); + assertCompanyAccess(req, companyId); + if (!consumeClipRateLimit(req, res, "publish")) return; + const result = await svc.publish(companyId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "clip.published", + entityType: "clip", + entityId: result.clip.id, + details: { + slug: result.clip.slug, + revisionId: result.revision.id, + revisionNumber: result.revision.revisionNumber, + visibility: result.clip.visibility, + status: result.clip.status, + }, + }); + res.status(201).json(result); + }); + + router.post("/companies/:companyId/clips/import-preview", validate(clipImportPreviewSchema), async (req, res) => { + const companyId = getClipCompanyIdParam(req); + 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 = getClipCompanyIdParam(req); + 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) { + res.status(404).json({ error: "Clip not found" }); + return; + } + assertCompanyAccess(req, clip.sourceCompanyId); + if (!consumeClipRateLimit(req, res, "update")) return; + const result = await svc.createRevision(clip.id, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: clip.sourceCompanyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "clip.revision_published", + entityType: "clip_revision", + entityId: result.revision.id, + details: { + clipId: clip.id, + slug: clip.slug, + revisionNumber: result.revision.revisionNumber, + }, + }); + res.status(201).json(result); + }); + + router.patch("/clips/:clipId", validate(updateClipSchema), async (req, res) => { + const clip = await svc.getClipById(req.params.clipId as string); + if (!clip) { + res.status(404).json({ error: "Clip not found" }); + return; + } + assertCompanyAccess(req, clip.sourceCompanyId); + const actorInfo = getActorInfo(req); + const updated = await svc.updateClip(clip.id, req.body, { + actorType: actorInfo.actorType, + actorId: actorInfo.actorId, + agentId: actorInfo.agentId, + userId: req.actor.type === "board" ? req.actor.userId ?? null : null, + }); + await logActivity(db, { + companyId: clip.sourceCompanyId, + actorType: actorInfo.actorType, + actorId: actorInfo.actorId, + agentId: actorInfo.agentId, + runId: actorInfo.runId, + action: req.body.status === "delisted" || req.body.moderationState === "delisted" + ? "clip.delisted" + : req.body.moderationState + ? "clip.moderated" + : "clip.updated", + entityType: "clip", + entityId: clip.id, + details: { + slug: clip.slug, + status: updated.status, + moderationState: updated.moderationState, + }, + }); + res.json(updated); + }); + + router.post("/public/clips/:slug/votes", validate(createClipVoteSchema), async (req, res) => { + const actor = requireAuthenticatedClipActor(req); + if (!consumeClipRateLimit(req, res, "vote")) return; + const result = await svc.createVote(req.params.slug as string, req.body, actor); + await logActivity(db, { + companyId: result.clip.sourceCompanyId, + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + agentId: actor.agentId, + action: "clip.vote_created", + entityType: "clip", + entityId: result.clip.id, + details: { + slug: result.clip.slug, + revisionId: result.revision.id, + revisionNumber: result.revision.revisionNumber, + vote: req.body.vote, + }, + }); + res.status(201).json({ ok: true, metrics: result.clip }); + }); + + router.post("/public/clips/:slug/report", validate(createClipReportSchema), async (req, res) => { + const actor = actorForClip(req); + if (!consumeClipRateLimit(req, res, "report")) return; + const clip = await svc.createReport(req.params.slug as string, req.body, actor); + await logActivity(db, { + companyId: clip.sourceCompanyId, + actorType: actor.actorType === "agent" ? "agent" : actor.actorType === "user" ? "user" : "system", + actorId: actor.actorId, + agentId: actor.agentId, + action: "clip.report_created", + entityType: "clip", + entityId: clip.id, + details: { slug: clip.slug, reason: req.body.reason, moderationState: clip.moderationState }, + }); + res.status(201).json({ ok: true }); + }); + + router.post("/public/clips/:slug/comments", validate(createClipCommentSchema), async (req, res) => { + const actor = requireAuthenticatedClipActor(req); + if (!consumeClipRateLimit(req, res, "comment")) return; + const clip = await svc.createComment(req.params.slug as string, req.body, actor); + await logActivity(db, { + companyId: clip.sourceCompanyId, + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + agentId: actor.agentId, + action: "clip.comment_created", + entityType: "clip", + entityId: clip.id, + details: { slug: clip.slug }, + }); + res.status(201).json({ ok: true }); + }); + + router.post("/public/clips/:slug/showcase", validate(createClipShowcaseSchema), async (req, res) => { + const actor = requireAuthenticatedClipActor(req); + if (!consumeClipRateLimit(req, res, "showcase")) return; + const clip = await svc.createShowcase(req.params.slug as string, req.body, actor); + await logActivity(db, { + companyId: clip.sourceCompanyId, + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + agentId: actor.agentId, + action: "clip.showcase_created", + entityType: "clip", + entityId: clip.id, + details: { slug: clip.slug, validationState: req.body.validationState }, + }); + res.status(201).json({ ok: true }); + }); + + router.post("/public/clips/:slug/import-telemetry", validate(createClipImportTelemetrySchema), async (req, res) => { + if (req.body.destinationCompanyId) { + assertCompanyAccess(req, req.body.destinationCompanyId); + } + if (!consumeClipRateLimit(req, res, "import")) return; + const actor = actorForClip(req); + const clip = await svc.recordImportTelemetry(req.params.slug as string, req.body, actor); + await logActivity(db, { + companyId: req.body.destinationCompanyId ?? clip.sourceCompanyId, + actorType: actor.actorType === "agent" ? "agent" : actor.actorType === "user" ? "user" : "system", + actorId: actor.actorId, + agentId: actor.agentId, + action: "clip.import_telemetry_created", + entityType: "clip", + entityId: clip.id, + details: { slug: clip.slug, status: req.body.status ?? "previewed" }, + }); + res.status(201).json({ ok: true }); + }); + + return router; +} diff --git a/server/src/services/clips.ts b/server/src/services/clips.ts new file mode 100644 index 00000000000..f23b113cd6c --- /dev/null +++ b/server/src/services/clips.ts @@ -0,0 +1,700 @@ +import { and, desc, eq, ilike, inArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + clipComments, + clipCreatorProfiles, + clipDependencies, + clipFlags, + clipImports, + clipModerationEvents, + clipPublicMetricEvents, + clipRankingSnapshots, + clipRevisions, + clipShowcasePosts, + clipVotes, + clips, +} from "@paperclipai/db"; +import type { + ClipDependency, + ClipModerationState, + ClipRevision, + CreateClipComment, + CreateClipCreatorProfile, + CreateClipImportTelemetry, + CreateClipReport, + CreateClipRevision, + CreateClipShowcase, + CreateClipVote, + PublicClip, + PublishClip, + UpdateClip, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +const PUBLIC_BROWSE_MODERATION_STATES = ["normal", "warning", "limited"]; +const PUBLIC_DETAIL_MODERATION_STATES = ["normal", "under_review", "warning", "limited"]; +const PUBLIC_DETAIL_VISIBILITIES = ["public", "unlisted"]; +const PUBLIC_STATUS = "published"; +const DEFAULT_MANIFEST_VERSION = "paperclip.clip/v1"; + +type Actor = { + actorType: "agent" | "user" | "system" | "anonymous"; + actorId: string; + agentId?: string | null; + userId?: string | null; +}; + +function first(rows: T[]): T | null { + return rows[0] ?? null; +} + +function publicMetrics(row: typeof clips.$inferSelect) { + return { + importCount: row.importCount, + successfulFirstRunCount: row.successfulFirstRunCount, + voteScore: row.voteScore, + upvoteCount: row.upvoteCount, + downvoteCount: row.downvoteCount, + commentCount: row.commentCount, + showcaseCount: row.showcaseCount, + reportCount: row.reportCount, + }; +} + +function serializePublicClip(input: { + clip: typeof clips.$inferSelect; + creator: typeof clipCreatorProfiles.$inferSelect; + revision?: typeof clipRevisions.$inferSelect | null; + dependencies?: (typeof clipDependencies.$inferSelect)[]; +}): PublicClip { + return { + id: input.clip.id, + slug: input.clip.slug, + type: input.clip.type as PublicClip["type"], + title: input.clip.title, + summary: input.clip.summary, + description: input.clip.description, + visibility: input.clip.visibility as PublicClip["visibility"], + status: input.clip.status as PublicClip["status"], + moderationState: input.clip.moderationState as PublicClip["moderationState"], + currentRevisionId: input.clip.currentRevisionId, + latestApprovedRevisionId: input.clip.latestApprovedRevisionId, + creatorProfileId: input.clip.creatorProfileId, + creator: { + id: input.creator.id, + handle: input.creator.handle, + displayName: input.creator.displayName, + avatarUrl: input.creator.avatarUrl, + verificationState: input.creator.verificationState, + reputationSummary: input.creator.reputationSummary, + }, + tags: input.clip.tags, + categories: input.clip.categories, + useCases: input.clip.useCases, + requiredProviders: input.clip.requiredProviders, + compatibility: input.clip.compatibility, + metrics: publicMetrics(input.clip), + currentRevision: input.revision + ? { + ...input.revision, + securityReviewState: input.revision.securityReviewState as ClipRevision["securityReviewState"], + verificationState: input.revision.verificationState as ClipRevision["verificationState"], + } + : null, + dependencies: input.dependencies?.map((dependency) => ({ + ...dependency, + type: dependency.type as ClipDependency["type"], + required: dependency.required as ClipDependency["required"], + })), + createdAt: input.clip.createdAt, + updatedAt: input.clip.updatedAt, + }; +} + +function actorColumns(actor: Actor) { + return { + actorType: actor.actorType, + actorId: actor.actorId, + }; +} + +export function clipService(db: Db) { + async function getCreatorProfileById(id: string) { + 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)); + } + + async function getClipBySlug(slug: string) { + return first(await db.select().from(clips).where(eq(clips.slug, slug)).limit(1)); + } + + async function getRevisionByNumber(clipId: string, revisionNumber: number) { + return first( + await db + .select() + .from(clipRevisions) + .where(and(eq(clipRevisions.clipId, clipId), eq(clipRevisions.revisionNumber, revisionNumber))) + .limit(1), + ); + } + + async function getPublicCurrentRevision(clip: typeof clips.$inferSelect) { + const revisionId = clip.latestApprovedRevisionId ?? clip.currentRevisionId; + if (!revisionId) return null; + return first(await db.select().from(clipRevisions).where(eq(clipRevisions.id, revisionId)).limit(1)); + } + + async function getPublicClipBySlug(slug: string) { + const rows = await db + .select({ clip: clips, creator: clipCreatorProfiles }) + .from(clips) + .innerJoin(clipCreatorProfiles, eq(clips.creatorProfileId, clipCreatorProfiles.id)) + .where( + and( + eq(clips.slug, slug), + inArray(clips.visibility, PUBLIC_DETAIL_VISIBILITIES), + eq(clips.status, PUBLIC_STATUS), + inArray(clips.moderationState, PUBLIC_DETAIL_MODERATION_STATES), + ), + ) + .limit(1); + return first(rows); + } + + async function listPublic(input: { + q?: string | null; + type?: string | null; + tag?: string | null; + limit?: number; + offset?: number; + } = {}) { + const conditions = [ + eq(clips.visibility, "public"), + eq(clips.status, PUBLIC_STATUS), + inArray(clips.moderationState, PUBLIC_BROWSE_MODERATION_STATES), + ]; + if (input.type) conditions.push(eq(clips.type, input.type)); + if (input.q) { + const q = `%${input.q.trim()}%`; + conditions.push(sql`(${clips.title} ilike ${q} or ${clips.summary} ilike ${q})`); + } + if (input.tag) { + conditions.push(sql`${clips.tags} @> ${JSON.stringify([input.tag])}::jsonb`); + } + + const rows = await db + .select({ clip: clips, creator: clipCreatorProfiles }) + .from(clips) + .innerJoin(clipCreatorProfiles, eq(clips.creatorProfileId, clipCreatorProfiles.id)) + .where(and(...conditions)) + .orderBy(desc(clips.updatedAt)) + .limit(Math.min(Math.max(input.limit ?? 24, 1), 100)) + .offset(Math.max(input.offset ?? 0, 0)); + + return rows.map((row) => serializePublicClip({ ...row })); + } + + async function getPublicDetail(slug: string) { + const row = await getPublicClipBySlug(slug); + if (!row) return null; + const revision = await getPublicCurrentRevision(row.clip); + const dependencies = revision + ? await db.select().from(clipDependencies).where(eq(clipDependencies.revisionId, revision.id)) + : []; + return serializePublicClip({ + clip: row.clip, + creator: row.creator, + revision, + dependencies, + }); + } + + async function getPublicRevision(slug: string, revisionNumber: number) { + const row = await getPublicClipBySlug(slug); + if (!row) return null; + const revision = await getRevisionByNumber(row.clip.id, revisionNumber); + if (!revision) return null; + const dependencies = await db.select().from(clipDependencies).where(eq(clipDependencies.revisionId, revision.id)); + return serializePublicClip({ + clip: row.clip, + creator: row.creator, + revision, + dependencies, + }); + } + + async function createCreatorProfile(companyId: string, input: CreateClipCreatorProfile) { + const [profile] = await db + .insert(clipCreatorProfiles) + .values({ + companyId, + handle: input.handle, + displayName: input.displayName, + bio: input.bio ?? null, + avatarUrl: input.avatarUrl ?? null, + websiteUrl: input.websiteUrl ?? null, + verificationState: input.verificationState ?? "unverified", + reputationSummary: input.reputationSummary ?? {}, + }) + .returning(); + return profile; + } + + async function resolveCreatorProfile(companyId: string, input: PublishClip) { + if (input.creatorProfileId) { + const profile = await getCreatorProfileById(input.creatorProfileId); + if (!profile || profile.companyId !== companyId) { + throw unprocessable("Creator profile does not belong to this company"); + } + 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); + } + + async function insertRevision(clipId: string, input: CreateClipRevision, requestedRevisionNumber?: number) { + const revisionNumber = requestedRevisionNumber ?? await nextRevisionNumber(clipId); + const [revision] = await db + .insert(clipRevisions) + .values({ + clipId, + revisionNumber, + manifestVersion: input.manifestVersion ?? DEFAULT_MANIFEST_VERSION, + manifestChecksum: input.manifestChecksum, + artifactChecksum: input.artifactChecksum, + manifestPayload: input.manifestPayload, + artifactRef: input.artifactRef ?? null, + dependencyGraph: input.dependencyGraph ?? {}, + permissions: input.permissions ?? [], + secretsSchema: input.secretsSchema ?? [], + budgetEstimate: input.budgetEstimate ?? null, + redactionReport: input.redactionReport ?? {}, + dangerousCapabilities: input.dangerousCapabilities ?? [], + securityReviewState: input.securityReviewState ?? "unreviewed", + verificationState: input.verificationState ?? "not_run", + compatibility: input.compatibility ?? {}, + changeSummary: input.changeSummary ?? null, + breakingChanges: input.breakingChanges ?? null, + migrationNotes: input.migrationNotes ?? null, + }) + .returning(); + + if (input.dependencies?.length) { + await db.insert(clipDependencies).values( + input.dependencies.map((dependency) => ({ + clipId, + revisionId: revision.id, + type: dependency.type, + key: dependency.key, + displayName: dependency.displayName ?? null, + required: dependency.required ?? "required", + metadata: dependency.metadata ?? {}, + })), + ); + } + return revision; + } + + async function nextRevisionNumber(clipId: string) { + const [row] = await db + .select({ maxRevision: sql`coalesce(max(${clipRevisions.revisionNumber}), 0)` }) + .from(clipRevisions) + .where(eq(clipRevisions.clipId, clipId)); + return Number(row?.maxRevision ?? 0) + 1; + } + + async function publish(companyId: string, input: PublishClip) { + const existing = await getClipBySlug(input.slug); + if (existing) throw conflict("Clip slug already exists"); + const creatorProfile = await resolveCreatorProfile(companyId, input); + const [clip] = await db + .insert(clips) + .values({ + sourceCompanyId: companyId, + creatorProfileId: creatorProfile.id, + slug: input.slug, + type: input.type, + title: input.title, + summary: input.summary, + description: input.description ?? null, + visibility: input.visibility ?? "unlisted", + status: input.status ?? (input.visibility === "public" ? "pending_review" : "published"), + tags: input.tags ?? [], + categories: input.categories ?? [], + useCases: input.useCases ?? [], + requiredProviders: input.requiredProviders ?? [], + compatibility: input.compatibility ?? {}, + sourceKind: input.sourceKind ?? null, + sourceObjectType: input.sourceObjectType ?? null, + sourceObjectId: input.sourceObjectId ?? null, + }) + .returning(); + const revision = await insertRevision(clip.id, input.revision, 1); + const visibleRevisionId = clip.status === "published" ? revision.id : null; + const [updated] = await db + .update(clips) + .set({ + currentRevisionId: revision.id, + latestApprovedRevisionId: visibleRevisionId, + updatedAt: new Date(), + }) + .where(eq(clips.id, clip.id)) + .returning(); + await rebuildRankingSnapshot(updated); + return { clip: updated, revision, creatorProfile }; + } + + async function createRevision(clipId: string, input: CreateClipRevision) { + const clip = await getClipById(clipId); + if (!clip) throw notFound("Clip not found"); + const revision = await insertRevision(clip.id, input); + const [updated] = await db + .update(clips) + .set({ + currentRevisionId: revision.id, + latestApprovedRevisionId: clip.status === "published" && clip.moderationState !== "blocked" ? revision.id : clip.latestApprovedRevisionId, + updatedAt: new Date(), + }) + .where(eq(clips.id, clip.id)) + .returning(); + await rebuildRankingSnapshot(updated); + return { clip: updated, revision }; + } + + async function updateClip(clipId: string, input: UpdateClip, actor: Actor) { + const existing = await getClipById(clipId); + if (!existing) throw notFound("Clip not found"); + + if (input.latestApprovedRevisionId) { + const revision = first( + await db + .select() + .from(clipRevisions) + .where(and(eq(clipRevisions.id, input.latestApprovedRevisionId), eq(clipRevisions.clipId, clipId))) + .limit(1), + ); + if (!revision) throw unprocessable("latestApprovedRevisionId must belong to this clip"); + } + + const moderationStateChanged = + input.moderationState !== undefined && input.moderationState !== existing.moderationState; + + const [updated] = await db + .update(clips) + .set({ + title: input.title ?? existing.title, + summary: input.summary ?? existing.summary, + description: input.description === undefined ? existing.description : input.description, + visibility: input.visibility ?? existing.visibility, + status: input.status ?? existing.status, + moderationState: input.moderationState ?? existing.moderationState, + latestApprovedRevisionId: input.latestApprovedRevisionId === undefined + ? existing.latestApprovedRevisionId + : input.latestApprovedRevisionId, + tags: input.tags ?? existing.tags, + categories: input.categories ?? existing.categories, + useCases: input.useCases ?? existing.useCases, + requiredProviders: input.requiredProviders ?? existing.requiredProviders, + compatibility: input.compatibility ?? existing.compatibility, + delistedAt: input.status === "delisted" || input.moderationState === "delisted" ? new Date() : existing.delistedAt, + updatedAt: new Date(), + }) + .where(eq(clips.id, clipId)) + .returning(); + + if (moderationStateChanged) { + await db.insert(clipModerationEvents).values({ + clipId, + ...actorColumns(actor), + fromState: existing.moderationState, + toState: input.moderationState as ClipModerationState, + reason: input.moderationReason ?? "moderation_state_updated", + }); + } + await rebuildRankingSnapshot(updated); + return updated; + } + + async function createVote(slug: string, input: CreateClipVote, actor: Actor) { + const row = await getPublicClipBySlug(slug); + if (!row) throw notFound("Clip not found"); + const revision = input.revisionNumber + ? await getRevisionByNumber(row.clip.id, input.revisionNumber) + : await getPublicCurrentRevision(row.clip); + if (!revision) throw notFound("Clip revision not found"); + + const existing = first( + await db + .select() + .from(clipVotes) + .where(and( + eq(clipVotes.revisionId, revision.id), + eq(clipVotes.actorType, actor.actorType), + eq(clipVotes.actorId, actor.actorId), + )) + .limit(1), + ); + + if (existing) { + await db + .update(clipVotes) + .set({ vote: input.vote, reason: input.reason ?? null, metadata: input.metadata ?? {}, updatedAt: new Date() }) + .where(eq(clipVotes.id, existing.id)); + } else { + await db.insert(clipVotes).values({ + clipId: row.clip.id, + revisionId: revision.id, + actorType: actor.actorType, + actorId: actor.actorId, + vote: input.vote, + reason: input.reason ?? null, + metadata: input.metadata ?? {}, + }); + } + const updated = await refreshVoteCounters(row.clip.id); + await rebuildRankingSnapshot(updated); + return { clip: updated, revision }; + } + + async function createReport(slug: string, input: CreateClipReport, actor: Actor) { + const row = await getPublicClipBySlug(slug); + if (!row) throw notFound("Clip not found"); + const revision = input.revisionNumber ? await getRevisionByNumber(row.clip.id, input.revisionNumber) : null; + await db.insert(clipFlags).values({ + clipId: row.clip.id, + revisionId: revision?.id ?? null, + reporterType: actor.actorType, + reporterId: actor.actorType === "anonymous" ? null : actor.actorId, + reason: input.reason, + details: input.details ?? null, + metadata: input.metadata ?? {}, + }); + const [updated] = await db + .update(clips) + .set({ + reportCount: sql`${clips.reportCount} + 1`, + moderationState: row.clip.moderationState === "normal" ? "under_review" : row.clip.moderationState, + updatedAt: new Date(), + }) + .where(eq(clips.id, row.clip.id)) + .returning(); + await rebuildRankingSnapshot(updated); + return updated; + } + + async function createComment(slug: string, input: CreateClipComment, actor: Actor) { + const row = await getPublicClipBySlug(slug); + if (!row) throw notFound("Clip not found"); + const revision = input.revisionNumber ? await getRevisionByNumber(row.clip.id, input.revisionNumber) : null; + await db.insert(clipComments).values({ + clipId: row.clip.id, + revisionId: revision?.id ?? null, + authorUserId: actor.userId ?? null, + authorAgentId: actor.agentId ?? null, + scope: input.scope ?? (revision ? "revision" : "clip"), + category: input.category ?? "question", + body: input.body, + }); + const [updated] = await db + .update(clips) + .set({ commentCount: sql`${clips.commentCount} + 1`, updatedAt: new Date() }) + .where(eq(clips.id, row.clip.id)) + .returning(); + return updated; + } + + async function createShowcase(slug: string, input: CreateClipShowcase, actor: Actor) { + const row = await getPublicClipBySlug(slug); + if (!row) throw notFound("Clip not found"); + const revision = input.revisionNumber + ? await getRevisionByNumber(row.clip.id, input.revisionNumber) + : await getPublicCurrentRevision(row.clip); + if (!revision) throw notFound("Clip revision not found"); + await db.insert(clipShowcasePosts).values({ + clipId: row.clip.id, + revisionId: revision.id, + authorUserId: actor.userId ?? null, + authorAgentId: actor.agentId ?? null, + type: input.type ?? "community_example", + title: input.title, + body: input.body ?? null, + mediaRefs: input.mediaRefs ?? [], + validationState: input.validationState ?? "not_run", + }); + const [updated] = await db + .update(clips) + .set({ + showcaseCount: sql`${clips.showcaseCount} + 1`, + successfulFirstRunCount: input.validationState === "passed" + ? sql`${clips.successfulFirstRunCount} + 1` + : row.clip.successfulFirstRunCount, + updatedAt: new Date(), + }) + .where(eq(clips.id, row.clip.id)) + .returning(); + await rebuildRankingSnapshot(updated); + return updated; + } + + async function recordImportTelemetry(slug: string, input: CreateClipImportTelemetry, actor: Actor) { + const row = await getPublicClipBySlug(slug); + if (!row) throw notFound("Clip not found"); + const revision = input.revisionNumber + ? await getRevisionByNumber(row.clip.id, input.revisionNumber) + : await getPublicCurrentRevision(row.clip); + if (!revision) throw notFound("Clip revision not found"); + await db.insert(clipPublicMetricEvents).values({ + clipId: row.clip.id, + revisionId: revision.id, + eventType: "import_telemetry", + actorHash: actor.actorType === "anonymous" ? null : `${actor.actorType}:${actor.actorId}`, + metadata: input.metadata ?? {}, + }); + if (input.destinationCompanyId) { + await db.insert(clipImports).values({ + destinationCompanyId: input.destinationCompanyId, + clipId: row.clip.id, + revisionId: revision.id, + revisionNumber: revision.revisionNumber, + manifestChecksum: revision.manifestChecksum, + artifactChecksum: revision.artifactChecksum, + sourceUrl: input.sourceUrl ?? null, + revisionUrl: input.revisionUrl ?? null, + status: input.status ?? "previewed", + importedByUserId: actor.userId ?? null, + importedByAgentId: actor.agentId ?? null, + }); + } + const [updated] = await db + .update(clips) + .set({ + importCount: input.status === "applied" ? sql`${clips.importCount} + 1` : row.clip.importCount, + updatedAt: new Date(), + }) + .where(eq(clips.id, row.clip.id)) + .returning(); + await rebuildRankingSnapshot(updated); + return updated; + } + + async function refreshVoteCounters(clipId: string) { + const [updated] = await db + .update(clips) + .set({ + upvoteCount: sql`(select count(*)::int from ${clipVotes} where ${clipVotes.clipId} = ${clipId} and ${clipVotes.vote} = 'up')`, + downvoteCount: sql`(select count(*)::int from ${clipVotes} where ${clipVotes.clipId} = ${clipId} and ${clipVotes.vote} = 'down')`, + voteScore: sql`( + (select count(*)::int from ${clipVotes} where ${clipVotes.clipId} = ${clipId} and ${clipVotes.vote} = 'up') + - + (select count(*)::int from ${clipVotes} where ${clipVotes.clipId} = ${clipId} and ${clipVotes.vote} = 'down') + )`, + updatedAt: new Date(), + }) + .where(eq(clips.id, clipId)) + .returning(); + return updated; + } + + async function rebuildRankingSnapshot(clip: typeof clips.$inferSelect) { + const reviewBonus = clip.moderationState === "normal" ? 10 : clip.moderationState === "warning" ? -10 : -30; + const score = + reviewBonus + + clip.successfulFirstRunCount * 10 + + clip.importCount * 3 + + clip.upvoteCount * 2 + - clip.downvoteCount * 2 + - clip.reportCount * 8 + + clip.showcaseCount * 3; + await db + .insert(clipRankingSnapshots) + .values({ + clipId: clip.id, + scope: "global", + score, + factors: { + moderationState: clip.moderationState, + successfulFirstRunCount: clip.successfulFirstRunCount, + importCount: clip.importCount, + upvoteCount: clip.upvoteCount, + downvoteCount: clip.downvoteCount, + reportCount: clip.reportCount, + showcaseCount: clip.showcaseCount, + }, + }) + .onConflictDoUpdate({ + target: [clipRankingSnapshots.clipId, clipRankingSnapshots.scope], + set: { + score, + factors: { + moderationState: clip.moderationState, + successfulFirstRunCount: clip.successfulFirstRunCount, + importCount: clip.importCount, + upvoteCount: clip.upvoteCount, + downvoteCount: clip.downvoteCount, + reportCount: clip.reportCount, + showcaseCount: clip.showcaseCount, + }, + snapshotAt: new Date(), + }, + }); + } + + async function getCreatorPublicProfile(handle: string) { + const profile = first( + await db + .select() + .from(clipCreatorProfiles) + .where(ilike(clipCreatorProfiles.handle, handle)) + .limit(1), + ); + if (!profile) return null; + const publicClips = await listPublic({ limit: 100 }); + return { + profile: { + id: profile.id, + handle: profile.handle, + displayName: profile.displayName, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + websiteUrl: profile.websiteUrl, + verificationState: profile.verificationState, + reputationSummary: profile.reputationSummary, + }, + clips: publicClips.filter((clip) => clip.creatorProfileId === profile.id), + }; + } + + return { + createCreatorProfile, + getCreatorProfileById, + getCreatorProfileByHandle, + getClipById, + getClipBySlug, + listPublic, + getPublicDetail, + getPublicRevision, + getCreatorPublicProfile, + publish, + createRevision, + updateClip, + createVote, + createReport, + createComment, + createShowcase, + recordImportTelemetry, + }; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 54797e99454..5f19dbdd685 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -21,6 +21,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 { Learnings, PipelineItemDetail, PipelineItemLegacyRedirect, Pipelines, ReviewQueue } from "./pages/Pipelines"; import { PipelineSettings } from "./pages/PipelineSettings"; import { RoutineDetail } from "./pages/RoutineDetail"; @@ -146,6 +147,7 @@ function boardRoutes() { } /> ) : null} } /> + } /> } @@ -443,6 +445,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> 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/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 7e228137a70..a63bc626feb 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -39,6 +39,12 @@ export const queryKeys = { ["team-catalog", "catalog", "file", catalogRef, relativePath] as const, installed: (companyId: string) => ["team-catalog", "installed", companyId] 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..ff4d5903c44 --- /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-destructive"; + 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 }))} /> +