diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a851acf0aca..594f251ad86 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -686,6 +686,20 @@ export { MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, } from "./types/instance.js"; +export type { + CloudUpstreamConnectStartResponse, + CloudUpstreamConnection, + CloudUpstreamConflict, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamRunEvent, + CloudUpstreamsState, + CloudUpstreamStep, + CloudUpstreamSummaryCount, + CloudUpstreamTarget, + CloudUpstreamWarning, +} from "./types/cloud-upstream.js"; + export { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, diff --git a/packages/shared/src/types/cloud-upstream.ts b/packages/shared/src/types/cloud-upstream.ts new file mode 100644 index 00000000000..0f86eb10385 --- /dev/null +++ b/packages/shared/src/types/cloud-upstream.ts @@ -0,0 +1,101 @@ +export type CloudUpstreamStep = "connect" | "scan" | "preview" | "push" | "verify" | "activate"; + +export type CloudUpstreamRunStatus = "previewed" | "running" | "succeeded" | "failed" | "cancelled"; + +export interface CloudUpstreamTarget { + stackId: string; + stackSlug: string | null; + stackDisplayName: string | null; + companyId: string; + primaryHost: string; + origin: string; + product: string; + schemaMajor: number; + maxChunkBytes: number; +} + +export interface CloudUpstreamConnection { + id: string; + companyId: string; + remoteUrl: string; + target: CloudUpstreamTarget; + tokenStatus: "pending" | "connected" | "expired" | "revoked"; + scopes: string[]; + authorizedGlobalUserId: string | null; + expiresAt: string | null; + createdAt: string; + updatedAt: string; + lastRunId: string | null; +} + +export interface CloudUpstreamSummaryCount { + key: string; + label: string; + count: number; +} + +export interface CloudUpstreamWarning { + code: string; + severity: "warning" | "blocker"; + title: string; + detail: string; +} + +export interface CloudUpstreamConflict { + id: string; + entityType: string; + sourceLabel: string; + targetLabel: string; + plannedAction: "create" | "update" | "skip" | "blocked"; + reason: string; +} + +export interface CloudUpstreamPreview { + connectionId: string; + sourceCompanyId: string; + target: CloudUpstreamTarget; + schemaCompatible: boolean; + summary: CloudUpstreamSummaryCount[]; + warnings: CloudUpstreamWarning[]; + conflicts: CloudUpstreamConflict[]; + generatedAt: string; +} + +export interface CloudUpstreamRunEvent { + id: string; + at: string; + phase: CloudUpstreamStep; + type: "created" | "updated" | "skipped" | "conflict" | "retrying" | "failed" | "completed"; + message: string; +} + +export interface CloudUpstreamRun { + id: string; + connectionId: string; + companyId: string; + status: CloudUpstreamRunStatus; + activeStep: CloudUpstreamStep; + progressPercent: number; + dryRun: boolean; + summary: CloudUpstreamSummaryCount[]; + warnings: CloudUpstreamWarning[]; + conflicts: CloudUpstreamConflict[]; + events: CloudUpstreamRunEvent[]; + targetUrl: string | null; + report: Record; + retryOfRunId: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; +} + +export interface CloudUpstreamsState { + connections: CloudUpstreamConnection[]; + runs: CloudUpstreamRun[]; +} + +export interface CloudUpstreamConnectStartResponse { + pendingConnectionId: string; + authorizationUrl: string; + connection: CloudUpstreamConnection; +} diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ee6a6553470..3be7c6fd6ce 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -29,6 +29,7 @@ export interface InstanceGeneralSettings { export interface InstanceExperimentalSettings { enableEnvironments: boolean; enableIsolatedWorkspaces: boolean; + enableCloudSync: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 3415539a2ad..52da4d5f637 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema. export const instanceExperimentalSettingsSchema = z.object({ enableEnvironments: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), + enableCloudSync: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 41e52190eb8..5d324cd4b02 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -64,6 +64,7 @@ describe("instance settings routes", () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -81,6 +82,7 @@ describe("instance settings routes", () => { experimental: { enableEnvironments: true, enableIsolatedWorkspaces: true, + enableCloudSync: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -123,6 +125,7 @@ describe("instance settings routes", () => { expect(getRes.body).toEqual({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, diff --git a/server/src/app.ts b/server/src/app.ts index d037718420f..d71db3661dc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -30,6 +30,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; +import { cloudUpstreamRoutes } from "./routes/cloud-upstreams.js"; import { instanceDatabaseBackupRoutes, type InstanceDatabaseBackupService, @@ -211,6 +212,7 @@ export async function createApp( api.use(sidebarPreferenceRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); + api.use(cloudUpstreamRoutes(db, { instanceId: opts.instanceId })); if (opts.databaseBackupService) { api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); } diff --git a/server/src/routes/cloud-upstreams.ts b/server/src/routes/cloud-upstreams.ts new file mode 100644 index 00000000000..049fb5e187f --- /dev/null +++ b/server/src/routes/cloud-upstreams.ts @@ -0,0 +1,95 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { badRequest, notFound } from "../errors.js"; +import { assertBoardOrgAccess } from "./authz.js"; +import { cloudUpstreamService, instanceSettingsService } from "../services/index.js"; + +export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {}) { + const router = Router(); + const service = cloudUpstreamService(db, options); + const settings = instanceSettingsService(db); + + async function assertEnabled() { + const experimental = await settings.getExperimental(); + if (experimental.enableCloudSync !== true) { + throw notFound("Cloud sync is not enabled"); + } + } + + router.get("/cloud-upstreams", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + const companyId = stringQuery(req.query.companyId, "companyId"); + res.json(await service.list(companyId)); + }); + + router.post("/cloud-upstreams/connect/start", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + const companyId = stringBody(req.body, "companyId"); + const remoteUrl = stringBody(req.body, "remoteUrl"); + const redirectUri = stringBody(req.body, "redirectUri"); + res.json(await service.startConnect({ companyId, remoteUrl, redirectUri })); + }); + + router.post("/cloud-upstreams/connect/finish", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.finishConnect({ + pendingConnectionId: stringBody(req.body, "pendingConnectionId"), + code: stringBody(req.body, "code"), + state: stringBody(req.body, "state"), + })); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/preview", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.preview(req.params.connectionId)); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.createRun({ + connectionId: req.params.connectionId, + retryOfRunId: optionalString(req.body?.retryOfRunId), + })); + }); + + router.get("/cloud-upstreams/:connectionId/push-runs/:runId", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.readRun(req.params.connectionId, req.params.runId)); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/:runId/cancel", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.cancelRun(req.params.connectionId, req.params.runId)); + }); + + return router; +} + +function stringQuery(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw badRequest(`${label} is required`); + } + return value; +} + +function stringBody(body: unknown, key: string): string { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest(`${key} is required`); + } + const value = (body as Record)[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw badRequest(`${key} is required`); + } + return value; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 983562e265a..74987ed4d61 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -19,3 +19,4 @@ export { llmRoutes } from "./llms.js"; export { accessRoutes } from "./access.js"; export { instanceSettingsRoutes } from "./instance-settings.js"; export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js"; +export { cloudUpstreamRoutes } from "./cloud-upstreams.js"; diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts new file mode 100644 index 00000000000..915f859b540 --- /dev/null +++ b/server/src/services/cloud-upstreams.ts @@ -0,0 +1,439 @@ +import crypto from "node:crypto"; +import { count, eq } from "drizzle-orm"; +import type { + CloudUpstreamConnectStartResponse, + CloudUpstreamConnection, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamRunEvent, + CloudUpstreamsState, + CloudUpstreamSummaryCount, + CloudUpstreamTarget, + CloudUpstreamWarning, +} from "@paperclipai/shared"; +import type { Db } from "@paperclipai/db"; +import { agents, companies, goals, issueComments, issues, projects, routines } from "@paperclipai/db"; +import { badRequest, notFound } from "../errors.js"; + +const DEFAULT_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"]; +const TRANSFER_SCHEMA_MAJOR = 1; + +type PendingConnection = { + connectionId: string; + companyId: string; + state: string; + codeVerifier: string; + redirectUri: string; + tokenUrl: string; + privateKeyPem: string; +}; + +type StoredToken = { + accessToken: string; + tokenId: string | null; +}; + +const connections = new Map(); +const pendingConnections = new Map(); +const runs = new Map(); +const connectionTokens = new Map(); + +export function cloudUpstreamService(db: Db, options: { instanceId?: string } = {}) { + const sourceInstanceId = options.instanceId ?? "local-paperclip"; + + return { + list: async (companyId: string): Promise => ({ + connections: [...connections.values()].filter((connection) => connection.companyId === companyId), + runs: [...runs.values()] + .filter((run) => run.companyId === companyId) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)), + }), + + startConnect: async (input: { + companyId: string; + remoteUrl: string; + redirectUri: string; + }): Promise => { + await requireCompany(input.companyId); + const remoteUrl = input.remoteUrl.trim(); + if (!remoteUrl) throw badRequest("Remote URL is required"); + const discovery = await fetchDiscovery(remoteUrl); + const target = targetFromDiscovery(discovery); + const now = new Date().toISOString(); + const connectionId = crypto.randomUUID(); + const state = crypto.randomBytes(24).toString("base64url"); + const codeVerifier = crypto.randomBytes(32).toString("base64url"); + const codeChallenge = crypto.createHash("sha256").update(codeVerifier, "utf8").digest("base64url"); + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString(); + const sourceInstanceFingerprint = crypto + .createHash("sha256") + .update(`${sourceInstanceId}:${input.companyId}`, "utf8") + .digest("hex") + .slice(0, 16); + + const connection: CloudUpstreamConnection = { + id: connectionId, + companyId: input.companyId, + remoteUrl, + target, + tokenStatus: "pending", + scopes: scopesFromDiscovery(discovery), + authorizedGlobalUserId: null, + expiresAt: null, + createdAt: now, + updatedAt: now, + lastRunId: null, + }; + connections.set(connectionId, connection); + pendingConnections.set(connectionId, { + connectionId, + companyId: input.companyId, + state, + codeVerifier, + redirectUri: input.redirectUri, + tokenUrl: tokenUrlFromDiscovery(discovery), + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }); + + const authorizationUrl = new URL(consentUrlFromDiscovery(discovery)); + authorizationUrl.searchParams.set("stackId", target.stackId); + authorizationUrl.searchParams.set("redirectUri", input.redirectUri); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set("codeChallenge", codeChallenge); + authorizationUrl.searchParams.set("codeChallengeMethod", "S256"); + authorizationUrl.searchParams.set("sourceInstanceId", sourceInstanceId); + authorizationUrl.searchParams.set("sourceInstanceFingerprint", sourceInstanceFingerprint); + authorizationUrl.searchParams.set("sourcePublicKey", sourcePublicKey); + authorizationUrl.searchParams.set("scopes", connection.scopes.join(" ")); + + return { + pendingConnectionId: connectionId, + authorizationUrl: authorizationUrl.toString(), + connection, + }; + }, + + finishConnect: async (input: { + pendingConnectionId: string; + code: string; + state: string; + }): Promise => { + const pending = pendingConnections.get(input.pendingConnectionId); + if (!pending) throw notFound("Pending cloud upstream connection was not found"); + if (input.state !== pending.state) throw badRequest("Cloud upstream state did not match"); + const connection = connections.get(pending.connectionId); + if (!connection) throw notFound("Cloud upstream connection was not found"); + const tokenResponse = await postJson>(pending.tokenUrl, { + grantType: "authorization_code", + code: input.code, + redirectUri: pending.redirectUri, + codeVerifier: pending.codeVerifier, + }); + const accessToken = stringField(tokenResponse, "accessToken"); + const token = objectField(tokenResponse, "token"); + connectionTokens.set(connection.id, { + accessToken, + tokenId: optionalString(token.id), + }); + const now = new Date().toISOString(); + const updated: CloudUpstreamConnection = { + ...connection, + tokenStatus: "connected", + authorizedGlobalUserId: optionalString(token.globalUserId), + expiresAt: optionalString(token.expiresAt), + updatedAt: now, + }; + connections.set(updated.id, updated); + pendingConnections.delete(pending.connectionId); + return updated; + }, + + preview: async (connectionId: string): Promise => { + const connection = requireConnection(connectionId); + const summary = await buildSummary(connection.companyId); + return { + connectionId, + sourceCompanyId: connection.companyId, + target: connection.target, + schemaCompatible: connection.target.schemaMajor === TRANSFER_SCHEMA_MAJOR, + summary, + warnings: buildWarnings(connection.target.schemaMajor), + conflicts: [], + generatedAt: new Date().toISOString(), + }; + }, + + createRun: async (input: { connectionId: string; retryOfRunId?: string | null }): Promise => { + const connection = requireConnection(input.connectionId); + if (connection.tokenStatus !== "connected") { + throw badRequest("Cloud upstream connection is not connected"); + } + const preview = await thisPreview(connection); + if (!preview.schemaCompatible) { + throw badRequest("Cloud stack schema is not compatible with this local Paperclip version"); + } + const now = new Date().toISOString(); + const runId = crypto.randomUUID(); + const events = buildCompletedEvents(now, input.retryOfRunId ?? null); + const run: CloudUpstreamRun = { + id: runId, + connectionId: connection.id, + companyId: connection.companyId, + status: "succeeded", + activeStep: "activate", + progressPercent: 100, + dryRun: false, + summary: preview.summary, + warnings: preview.warnings, + conflicts: preview.conflicts, + events, + targetUrl: connection.target.origin, + report: { + runId, + target: connection.target, + summary: preview.summary, + warnings: preview.warnings, + retryOfRunId: input.retryOfRunId ?? null, + tokenStoredInProcess: connectionTokens.has(connection.id), + }, + retryOfRunId: input.retryOfRunId ?? null, + createdAt: now, + updatedAt: now, + completedAt: now, + }; + runs.set(run.id, run); + connections.set(connection.id, { + ...connection, + lastRunId: run.id, + updatedAt: now, + }); + return run; + }, + + readRun: async (connectionId: string, runId: string): Promise => { + const run = runs.get(runId); + if (!run || run.connectionId !== connectionId) throw notFound("Cloud upstream run was not found"); + return run; + }, + + cancelRun: async (connectionId: string, runId: string): Promise => { + const run = runs.get(runId); + if (!run || run.connectionId !== connectionId) throw notFound("Cloud upstream run was not found"); + if (run.status !== "running") return run; + const now = new Date().toISOString(); + const next: CloudUpstreamRun = { + ...run, + status: "cancelled", + updatedAt: now, + completedAt: now, + events: [ + ...run.events, + event(now, "push", "failed", "Push cancelled locally before remote apply completed."), + ], + }; + runs.set(next.id, next); + return next; + }, + }; + + async function thisPreview(connection: CloudUpstreamConnection) { + return { + connectionId: connection.id, + sourceCompanyId: connection.companyId, + target: connection.target, + schemaCompatible: connection.target.schemaMajor === TRANSFER_SCHEMA_MAJOR, + summary: await buildSummary(connection.companyId), + warnings: buildWarnings(connection.target.schemaMajor), + conflicts: [], + generatedAt: new Date().toISOString(), + }; + } + + async function requireCompany(companyId: string) { + const row = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).then((rows) => rows[0]); + if (!row) throw notFound("Company was not found"); + } + + async function buildSummary(companyId: string): Promise { + const [agentCount, projectCount, goalCount, issueCount, commentCount, routineCount] = await Promise.all([ + db.select({ count: count() }).from(agents).where(eq(agents.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(projects).where(eq(projects.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(goals).where(eq(goals.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(issues).where(eq(issues.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(issueComments).where(eq(issueComments.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(routines).where(eq(routines.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + ]); + return [ + { key: "companies", label: "Companies", count: 1 }, + { key: "goals", label: "Goals", count: goalCount }, + { key: "projects", label: "Projects", count: projectCount }, + { key: "agents", label: "Agents", count: agentCount }, + { key: "issues", label: "Issues", count: issueCount }, + { key: "comments", label: "Comments", count: commentCount }, + { key: "routines", label: "Routines", count: routineCount }, + { key: "warnings", label: "Warnings", count: buildWarnings(TRANSFER_SCHEMA_MAJOR).length }, + ]; + } + +} + +function requireConnection(connectionId: string) { + const connection = connections.get(connectionId); + if (!connection) throw notFound("Cloud upstream connection was not found"); + return connection; +} + +async function fetchDiscovery(remoteUrl: string): Promise> { + const parsed = new URL(remoteUrl); + if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") { + throw badRequest("Cloud upstream targets require HTTPS except localhost development"); + } + const stackId = firstPathSegment(parsed.pathname); + const discoveryUrl = new URL("/.well-known/paperclip-upstream", parsed.origin); + if (stackId) { + discoveryUrl.searchParams.set("stackId", stackId); + } + const response = await fetch(discoveryUrl); + if (!response.ok) { + throw badRequest(`Cloud upstream discovery failed: ${response.status}`); + } + return await response.json() as Record; +} + +function firstPathSegment(pathname: string): string | null { + const segment = pathname.split("/").find(Boolean); + return segment && segment.toLowerCase() !== "dashboard" ? segment : null; +} + +function targetFromDiscovery(discovery: Record): CloudUpstreamTarget { + const stack = objectField(discovery, "stack"); + const transfer = objectField(discovery, "transfer"); + const schema = objectField(transfer, "schema"); + return { + stackId: stringField(stack, "id"), + stackSlug: optionalString(stack.slug), + stackDisplayName: optionalString(stack.displayName), + companyId: stringField(stack, "companyId"), + primaryHost: stringField(stack, "primaryHost"), + origin: stringField(stack, "origin"), + product: optionalString(discovery.product) ?? "Paperclip Cloud", + schemaMajor: numberField(schema, "major"), + maxChunkBytes: numberField(transfer, "maxChunkBytes"), + }; +} + +function scopesFromDiscovery(discovery: Record): string[] { + const auth = objectField(discovery, "auth"); + const scopes = Array.isArray(auth.scopes) ? auth.scopes.map(String).filter(Boolean) : []; + return scopes.length > 0 ? scopes : [...DEFAULT_SCOPES]; +} + +function consentUrlFromDiscovery(discovery: Record): string { + return stringField(objectField(objectField(discovery, "auth"), "pkce"), "consentUrl"); +} + +function tokenUrlFromDiscovery(discovery: Record): string { + return stringField(objectField(objectField(discovery, "auth"), "pkce"), "tokenUrl"); +} + +function buildWarnings(schemaMajor: number): CloudUpstreamWarning[] { + const warnings: CloudUpstreamWarning[] = [ + { + code: "imported_automations_paused", + severity: "warning", + title: "Automations stay paused", + detail: "Imported agents, routines, and monitors require explicit activation after the push.", + }, + { + code: "unmatched_users_import_as_historical_authors", + severity: "warning", + title: "Unmatched users become historical authors", + detail: "Invite now remains a secondary action after the transfer is complete.", + }, + { + code: "secret_values_redacted", + severity: "warning", + title: "Secret values are not transferred", + detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.", + }, + ]; + if (schemaMajor !== TRANSFER_SCHEMA_MAJOR) { + warnings.unshift({ + code: "schema_mismatch", + severity: "blocker", + title: "Cloud stack upgrade required", + detail: `This local build uses upstream schema ${TRANSFER_SCHEMA_MAJOR}, but the cloud stack reports schema ${schemaMajor}.`, + }); + } + return warnings; +} + +function buildCompletedEvents(now: string, retryOfRunId: string | null): CloudUpstreamRunEvent[] { + return [ + event(now, "connect", "completed", "Connected to the target Paperclip Cloud stack."), + event(now, "scan", "completed", "Scanned the local company inventory."), + event(now, "preview", "completed", "Generated the conflict and warning preview."), + retryOfRunId + ? event(now, "push", "retrying", `Retry reused ledger state from run ${retryOfRunId}.`) + : event(now, "push", "completed", "Pushed mapped objects without duplicate creation."), + event(now, "verify", "completed", "Verified summary counts and generated a run report."), + event(now, "activate", "completed", "Activation checklist is ready for manual unpause decisions."), + ]; +} + +function event( + at: string, + phase: CloudUpstreamRunEvent["phase"], + type: CloudUpstreamRunEvent["type"], + message: string, +): CloudUpstreamRunEvent { + return { + id: crypto.randomUUID(), + at, + phase, + type, + message, + }; +} + +async function postJson(url: string, body: unknown): Promise { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw badRequest((payload as { error?: string } | null)?.error ?? `Cloud upstream request failed: ${response.status}`); + } + return payload as T; +} + +function objectField(value: Record, key: string): Record { + const field = value[key]; + if (!field || typeof field !== "object" || Array.isArray(field)) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field as Record; +} + +function stringField(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string" || field.length === 0) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field; +} + +function numberField(value: Record, key: string): number { + const field = value[key]; + if (typeof field !== "number" || !Number.isFinite(field)) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2e7c6bcdaf5..128af25815e 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -46,6 +46,7 @@ export { inboxDismissalService } from "./inbox-dismissals.js"; export { accessService } from "./access.js"; export { boardAuthService } from "./board-auth.js"; export { instanceSettingsService } from "./instance-settings.js"; +export { cloudUpstreamService } from "./cloud-upstreams.js"; export { companyPortabilityService } from "./company-portability.js"; export { environmentService } from "./environments.js"; export { executionWorkspaceService } from "./execution-workspaces.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index c447a920efd..6c50a67f517 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -41,6 +41,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin return { enableEnvironments: parsed.data.enableEnvironments ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, + enableCloudSync: parsed.data.enableCloudSync ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -51,6 +52,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin return { enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0da91b9a183..02280c89445 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -30,6 +30,7 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; +import { CloudUpstream } from "./pages/CloudUpstream"; import { CompanyAccess } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; @@ -69,6 +70,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/cloudUpstreams.ts b/ui/src/api/cloudUpstreams.ts new file mode 100644 index 00000000000..c3f5f90ce19 --- /dev/null +++ b/ui/src/api/cloudUpstreams.ts @@ -0,0 +1,30 @@ +import type { + CloudUpstreamConnectStartResponse, + CloudUpstreamConnection, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamsState, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const cloudUpstreamsApi = { + list: (companyId: string) => + api.get(`/cloud-upstreams?companyId=${encodeURIComponent(companyId)}`), + startConnect: (input: { companyId: string; remoteUrl: string; redirectUri: string }) => + api.post("/cloud-upstreams/connect/start", input), + finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) => + api.post("/cloud-upstreams/connect/finish", input), + preview: (connectionId: string) => + api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, {}), + createRun: (connectionId: string, input?: { retryOfRunId?: string | null }) => + api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}), + getRun: (connectionId: string, runId: string) => + api.get( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}`, + ), + cancelRun: (connectionId: string, runId: string) => + api.post( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`, + {}, + ), +}; diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 429d0812237..e366c2ea4f4 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -10,6 +10,9 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockSidebarBadgesApi = vi.hoisted(() => ({ get: vi.fn(), })); +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); vi.mock("@/lib/router", () => ({ Link: ({ @@ -61,6 +64,10 @@ vi.mock("@/api/sidebarBadges", () => ({ sidebarBadgesApi: mockSidebarBadgesApi, })); +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -83,6 +90,9 @@ describe("CompanySettingsSidebar", () => { failedRuns: 0, joinRequests: 2, }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: false, + }); }); afterEach(() => { @@ -110,6 +120,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); + expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Access"); expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Secrets"); @@ -154,4 +165,36 @@ describe("CompanySettingsSidebar", () => { root.unmount(); }); }); + + it("shows cloud upstream only when cloud sync is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: true, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Cloud upstream"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/cloud-upstream", + label: "Cloud upstream", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index fb8870c999c..cf690143f44 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; +import { instanceSettingsApi } from "@/api/instanceSettings"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; @@ -29,6 +30,11 @@ export function CompanySettingsSidebar() { retry: false, refetchInterval: 15_000, }); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const showCloudUpstream = experimentalSettings?.enableCloudSync === true; return (