diff --git a/server/src/__tests__/cloud-control-task-drain.test.ts b/server/src/__tests__/cloud-control-task-drain.test.ts index 0e500157bb..3f0ab8e195 100644 --- a/server/src/__tests__/cloud-control-task-drain.test.ts +++ b/server/src/__tests__/cloud-control-task-drain.test.ts @@ -211,6 +211,12 @@ describe("cloudControlMiddleware", () => { app.all("/api/instance/task-drain", (req, res) => { res.json({ actor: req.actor }); }); + app.all("/api/instance/lifecycle", (req, res) => { + res.json({ actor: req.actor }); + }); + app.all("/api/instance/lifecycle/unarchive-primary", (req, res) => { + res.json({ actor: req.actor }); + }); app.get("/api/instance/settings", (req, res) => { res.json({ actor: req.actor }); }); @@ -263,6 +269,38 @@ describe("cloudControlMiddleware", () => { expect(res.body.error).toBe("invalid_cloud_control_assertion"); }); + it("authorizes the lifecycle endpoints, each bound to its own action", async () => { + const app = createApp(); + const read = await request(app) + .get("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(read.status).toBe(200); + expect(read.body.actor).toMatchObject({ type: "board", isInstanceAdmin: true, source: "cloud_control" }); + + const unarchive = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:unarchive-primary")); + expect(unarchive.status).toBe(200); + expect(unarchive.body.actor).toMatchObject({ source: "cloud_control" }); + + // Cross-binding: a task-drain assertion opens no lifecycle door, a + // read assertion cannot unarchive, and lifecycle endpoints accept no + // extra methods. + const crossAction = await request(app) + .get("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:read")); + expect(crossAction.status).toBe(401); + const readAsWrite = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(readAsWrite.status).toBe(401); + const wrongMethod = await request(app) + .post("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(wrongMethod.status).toBe(400); + expect(wrongMethod.body.error).toBe("cloud_control_wrong_endpoint"); + }); + it("accepts the conventional trailing-slash form of the endpoint", async () => { const app = createApp(); const res = await request(app) diff --git a/server/src/__tests__/cloud-lifecycle-sync.test.ts b/server/src/__tests__/cloud-lifecycle-sync.test.ts new file mode 100644 index 0000000000..9a9897f086 --- /dev/null +++ b/server/src/__tests__/cloud-lifecycle-sync.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isCloudPinnedPrimaryCompany, + notifyCloudOfPrimaryCompanyLifecycleChange, +} from "../services/cloud-lifecycle-sync.js"; +import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js"; + +const STACK_ID = "stack-lifecycle-sync"; +const PRIMARY_ID = cloudTenantPrimaryCompanyId(STACK_ID); + +const CLOUD_ENV = { + PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-token-test", + PAPERCLIP_CLOUD_STACK_ID: STACK_ID, + PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example.test", +} as NodeJS.ProcessEnv; + +describe("isCloudPinnedPrimaryCompany", () => { + it("matches only the derived primary company of a cloud-managed instance", () => { + expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, CLOUD_ENV)).toBe(true); + expect(isCloudPinnedPrimaryCompany("some-other-company", CLOUD_ENV)).toBe(false); + // Self-hosted: no cloud signal, never primary. + expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, {} as NodeJS.ProcessEnv)).toBe(false); + }); +}); + +describe("notifyCloudOfPrimaryCompanyLifecycleChange", () => { + it("rings the harness doorbell with the tenant token and stack id", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ outcome: "archived" }), { status: 200 })); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit]; + expect(url).toBe("https://cloud.example.test/v1/tenant/lifecycle-changed"); + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers); + expect(headers.get("authorization")).toBe("Bearer tenant-token-test"); + expect(headers.get("x-paperclip-cloud-stack-id")).toBe(STACK_ID); + }); + + it("is a silent no-op for non-primary companies and incomplete cloud metadata", async () => { + const fetchImpl = vi.fn(); + await notifyCloudOfPrimaryCompanyLifecycleChange("some-other-company", { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: { ...CLOUD_ENV, PAPERCLIP_CLOUD_API_ORIGIN: undefined } as NodeJS.ProcessEnv, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: {} as NodeJS.ProcessEnv, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("retries transient failures a bounded number of times and never throws", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("connection refused"); + }); + const sleeps: number[] = []; + await expect( + notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async (ms) => { + sleeps.push(ms); + }, + }), + ).resolves.toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(sleeps).toEqual([2_000, 10_000]); + }); + + it("does not retry once the harness answered, even non-2xx", async () => { + // Any answer means the harness heard the ring; it does its own + // verified read-back, so a 4xx outcome is final. + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ error: "stack_not_found" }), { status: 404 })); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async () => { + throw new Error("must not sleep"); + }, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts index 40ccfc6d18..aa79a5b93a 100644 --- a/server/src/__tests__/companies-service.test.ts +++ b/server/src/__tests__/companies-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { and, eq } from "drizzle-orm"; import { activityLog, @@ -24,6 +24,15 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +// Observe the Cloud lifecycle doorbell without any network: the real +// implementation is env-gated (a no-op off Cloud), so these tests assert +// WHEN the service rings it, not what the ring does. +const notifyCloudSpy = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../services/cloud-lifecycle-sync.js", () => ({ + isCloudPinnedPrimaryCompany: () => false, + notifyCloudOfPrimaryCompanyLifecycleChange: notifyCloudSpy, +})); + import { companyService } from "../services/companies.js"; import { deriveIssuePrefixBase } from "../services/issue-prefix.js"; import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js"; @@ -809,6 +818,38 @@ describeEmbeddedPostgres("companyService", () => { expect(archiveActivity[0]).toMatchObject({ details: { agentsPaused: 1, runsCancelled: 0 } }); }); + it("rings the Cloud lifecycle doorbell exactly on archived-boundary transitions", async () => { + notifyCloudSpy.mockClear(); + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Doorbell Test Co", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + const svc = companyService(db); + const actor = { actorType: "user" as const, actorId: "test-user", agentId: null, runId: null }; + + // A plain edit never rings. + await svc.update(companyId, { name: "Doorbell Test Co (renamed)" }, actor); + expect(notifyCloudSpy).not.toHaveBeenCalled(); + + // archive() rings once; re-archiving (no transition) does not. + await svc.archive(companyId, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(1); + expect(notifyCloudSpy).toHaveBeenLastCalledWith(companyId); + await svc.archive(companyId, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(1); + + // Unarchiving through update() rings again. + await svc.update(companyId, { status: "active" }, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(2); + + // update() into archived rings too (the status-patch archive path). + await svc.update(companyId, { status: "archived" }, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(3); + }); + it("runs the archive cascade when update() transitions a paused company to archived", async () => { const companyId = randomUUID(); const idleAgentId = randomUUID(); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 7d58c1ac64..2eb7a0e96a 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -23,11 +23,16 @@ const mockEnvironmentService = vi.hoisted(() => ({ findManagedSandboxEnvironment: vi.fn(), update: vi.fn(), })); +const mockCompanyService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockPublishActivity = vi.hoisted(() => vi.fn()); function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ + companyService: () => mockCompanyService, heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, logActivity: mockLogActivity, @@ -52,8 +57,12 @@ function defaultTransactionImplementation(fn: (tx: unknown) => Promise) // Module-scoped (not rebuilt per createApp call) so a test can assert how // many times a request opened a transaction — the task-drain audit writes // for every company must share ONE transaction, not one each. +// Rows the lifecycle read-back's plain `db.select().from(companies)` sees; +// tests assign per case. +let mockCompanyRows: Array<{ id: string; status: string }> = []; const mockDb = { transaction: vi.fn(defaultTransactionImplementation), + select: vi.fn(() => ({ from: () => Promise.resolve(mockCompanyRows) })), }; describe("instance settings routes", () => { @@ -1289,4 +1298,157 @@ describe("instance settings routes", () => { expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled(); }); }); + + describe("cloud lifecycle read-back and unarchive", () => { + const STACK_ID = "stack-lifecycle-routes"; + const adminActor = { + type: "board", + userId: "paperclip-cloud", + source: "cloud_control", + isInstanceAdmin: true, + companyIds: [], + }; + const memberActor = { + type: "board", + userId: "member-1", + source: "cloud_tenant", + isInstanceAdmin: false, + companyIds: ["company-1"], + }; + // The pinned primary company id the routes derive from the stack id. + let primaryId: string; + + beforeEach(async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID; + const { cloudTenantPrimaryCompanyId } = await vi.importActual< + typeof import("../services/cloud-instance.js") + >("../services/cloud-instance.js"); + primaryId = cloudTenantPrimaryCompanyId(STACK_ID); + mockCompanyRows = []; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_CLOUD_STACK_ID; + }); + + it("reports the primary company status and how many other companies are not archived", async () => { + mockCompanyRows = [ + { id: primaryId, status: "archived" }, + { id: "company-sibling-live", status: "active" }, + { id: "company-sibling-paused", status: "paused" }, + { id: "company-sibling-archived", status: "archived" }, + ]; + const app = await createApp(adminActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + primaryCompanyId: primaryId, + primaryCompanyStatus: "archived", + // paused counts: any non-archived sibling keeps the stack alive. + otherUnarchivedCompanyCount: 2, + }); + }); + + it("reports a missing primary company without failing", async () => { + mockCompanyRows = [{ id: "company-other", status: "active" }]; + const app = await createApp(adminActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(200); + expect(res.body.primaryCompanyStatus).toBe("missing"); + }); + + it("hides the cross-company lifecycle summary from non-admin board members", async () => { + mockCompanyRows = [{ id: primaryId, status: "archived" }]; + const app = await createApp(memberActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(403); + }); + + it("answers 404 when the instance is not cloud-managed", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_CLOUD_STACK_ID; + const readRes = await request(await createApp(adminActor)).get("/api/instance/lifecycle"); + expect(readRes.status).toBe(404); + + // The admin gate runs first, so prove the 404 with an admin actor. + const unarchiveRes = await request(await createApp(adminActor)) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + expect(unarchiveRes.status).toBe(404); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + + it("unarchives an archived primary company as a system actor", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" }); + mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" })); + const app = await createApp(adminActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "active", changed: true }); + expect(mockCompanyService.update).toHaveBeenCalledWith( + primaryId, + { status: "active" }, + { actorType: "system", actorId: "paperclip-cloud", agentId: null, runId: null }, + ); + }); + + it("attributes a human admin's unarchive to that admin, not to Cloud", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" }); + mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" })); + const humanAdmin = { + type: "board", + userId: "human-admin", + source: "session", + isInstanceAdmin: true, + companyIds: ["company-1"], + }; + const app = await createApp(humanAdmin); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(mockCompanyService.update).toHaveBeenCalledWith( + primaryId, + { status: "active" }, + expect.objectContaining({ actorType: "user", actorId: "human-admin" }), + ); + }); + + it("is idempotent: an unarchived primary company reports changed:false", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "active" }); + const app = await createApp(adminActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "active", changed: false }); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + + it("requires instance admin rights to unarchive", async () => { + const app = await createApp(memberActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(403); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 22286b7b3f..60d1afe073 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -58,6 +58,7 @@ import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compa import { forbidden, unauthorized, unprocessable } from "../errors.js"; export { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -797,11 +798,7 @@ function constantTimeStringEqual(left: string, right: string): boolean { } function cloudTenantCompanyId(stackId: string): string { - const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest(); - bytes[6] = (bytes[6] & 0x0f) | 0x50; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = bytes.subarray(0, 16).toString("hex"); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; + return cloudTenantPrimaryCompanyId(stackId); } export function humanizeCloudStackSlug(stackId: string): string { diff --git a/server/src/middleware/cloud-control.ts b/server/src/middleware/cloud-control.ts index e4e490dfb8..f0e44503e8 100644 --- a/server/src/middleware/cloud-control.ts +++ b/server/src/middleware/cloud-control.ts @@ -6,17 +6,25 @@ import { type CloudControlAction, } from "../services/cloud-runtime-identity.js"; -/** Method → the one action a control assertion must name to take it. */ -const ACTION_BY_METHOD: Record = { - GET: "task-drain:read", - POST: "task-drain:start", - DELETE: "task-drain:stop", +/** Endpoint → method → the one action a control assertion must name to take it. */ +const ACTIONS_BY_ENDPOINT: Record> = { + "/api/instance/task-drain": { + GET: "task-drain:read", + POST: "task-drain:start", + DELETE: "task-drain:stop", + }, + "/api/instance/lifecycle": { + GET: "lifecycle:read", + }, + "/api/instance/lifecycle/unarchive-primary": { + POST: "lifecycle:unarchive-primary", + }, }; /** - * Accepts Cloud's signed control assertion only on the task-drain endpoint, - * so the Cloud control plane can hold new agent work and wait for quiescence - * before restarting the container for a deploy. The JWS is the entire + * Accepts Cloud's signed control assertion only on the closed endpoint set + * above — the task-drain hold the deploy path uses, and the lifecycle + * read-back/unarchive pair behind the Cloud archive sync. The JWS is the entire * authorization: a valid assertion installs a synthetic instance-admin board * actor (replacing whatever weaker actor the request carried), each assertion * is bound to exactly one method's action, and the header is rejected loudly @@ -32,11 +40,11 @@ export function cloudControlMiddleware(): RequestHandler { next(); return; } - const expectedAction = ACTION_BY_METHOD[req.method]; // Express's non-strict routing treats a trailing slash as the same // route; the endpoint check must agree with it. const normalizedPath = req.path.length > 1 && req.path.endsWith("/") ? req.path.slice(0, -1) : req.path; - if (normalizedPath !== "/api/instance/task-drain" || !expectedAction) { + const expectedAction = ACTIONS_BY_ENDPOINT[normalizedPath]?.[req.method]; + if (!expectedAction) { res.status(400).json({ error: "cloud_control_wrong_endpoint" }); return; } diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 1784d848c6..9612555278 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import type { Db } from "@paperclipai/db"; +import { companies, type Db } from "@paperclipai/db"; import { patchInstanceSettingsSchema, patchInstanceExperimentalSettingsSchema, @@ -7,11 +7,16 @@ import { startTaskDrainRequestSchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; -import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { + cloudTenantPrimaryCompanyId, + getCloudStackContext, + isCloudManagedInstance, +} from "../services/cloud-instance.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { validate } from "../middleware/validate.js"; import { logger } from "../middleware/logger.js"; import { + companyService, heartbeatService, instanceSettingsService, logActivity, @@ -403,5 +408,78 @@ export function instanceSettingsRoutes(db: Db) { res.json({ wasActive }); }); + // Cloud lifecycle read-back: the harness answers a tenant's archive + // doorbell by asking this instance what the Cloud-pinned primary + // company's status actually is, so the shared-token doorbell can stay a + // hint (see cloud-lifecycle-sync.ts). Reached by the harness with a + // `lifecycle:read` Cloud control assertion. Instance-admin only for + // humans: the response summarizes lifecycle state across EVERY company + // on the instance, which a board member scoped to one company must not + // be able to infer. + router.get("/instance/lifecycle", async (req, res) => { + assertCanManageInstanceSettings(req); + const stackId = getCloudStackContext()?.stackId; + if (!stackId) { + res.status(404).json({ error: "not_cloud_managed" }); + return; + } + const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId); + const rows = await db + .select({ id: companies.id, status: companies.status }) + .from(companies); + const primary = rows.find((row) => row.id === primaryCompanyId); + res.json({ + primaryCompanyId, + primaryCompanyStatus: primary?.status ?? "missing", + otherUnarchivedCompanyCount: rows.filter( + (row) => row.id !== primaryCompanyId && row.status !== "archived", + ).length, + }); + }); + + // Cloud restore counterpart: "Resume organization" on a stack that was + // archived because its primary company was archived must bring the + // company back too, or the tenant's next doorbell would re-archive the + // stack the customer just resumed. Idempotent: a primary company that is + // not archived reports changed:false. Reached by the harness with a + // `lifecycle:unarchive-primary` control assertion; instance admins hold + // the same power through PATCH /api/companies/:id already. + router.post("/instance/lifecycle/unarchive-primary", async (req, res) => { + assertCanManageInstanceSettings(req); + const stackId = getCloudStackContext()?.stackId; + if (!stackId) { + res.status(404).json({ error: "not_cloud_managed" }); + return; + } + const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId); + const companySvc = companyService(db); + const existing = await companySvc.getById(primaryCompanyId); + if (!existing) { + res.status(404).json({ error: "primary_company_not_found" }); + return; + } + if (existing.status !== "archived") { + res.json({ status: existing.status, changed: false }); + return; + } + // Attribution: only the harness's synthetic cloud_control actor logs + // as the Cloud system identity; a human instance admin calling this + // endpoint is recorded as themselves, exactly like an in-product + // unarchive. + const actor = req.actor.source === "cloud_control" + ? { actorType: "system" as const, actorId: "paperclip-cloud", agentId: null, runId: null } + : (() => { + const info = getActorInfo(req); + return { + actorType: info.actorType, + actorId: info.actorId, + agentId: info.agentId, + runId: info.runId, + }; + })(); + const updated = await companySvc.update(primaryCompanyId, { status: "active" }, actor); + res.json({ status: updated?.status ?? "active", changed: true }); + }); + return router; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 54e72f1d95..c7d44d0aa5 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6215,6 +6215,24 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); +registry.registerPath({ + method: "get", + path: "/api/instance/lifecycle", + tags: ["instance"], + summary: + "Read the Cloud-pinned primary company's lifecycle status and how many other companies are not archived; 404 when the instance is not Cloud-managed", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/instance/lifecycle/unarchive-primary", + tags: ["instance"], + summary: + "Unarchive the Cloud-pinned primary company (idempotent); used by the Cloud control plane while restoring an archived stack", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + // ─── Board chat (Conference Room Chat, experimental) ────────────────────────── registry.registerPath({ diff --git a/server/src/services/cloud-instance.ts b/server/src/services/cloud-instance.ts index e6825dda77..cdd3636969 100644 --- a/server/src/services/cloud-instance.ts +++ b/server/src/services/cloud-instance.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + export type CloudInstanceEnv = Record; export type CloudStackContext = { @@ -50,3 +52,17 @@ export function getCloudStackContext( cloudOrigin: normalizeOptionalEnvValue(env.PAPERCLIP_CLOUD_API_ORIGIN), }; } + +/** + * The Cloud-pinned primary company id for a stack: the deterministic + * v5-style UUID the trusted-header lane seeds and every Cloud surface pins. + * The derivation is frozen — seeded companies fleet-wide already carry + * these ids. middleware/auth.ts delegates here; this is the one definition. + */ +export function cloudTenantPrimaryCompanyId(stackId: string): string { + const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest(); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.subarray(0, 16).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} diff --git a/server/src/services/cloud-lifecycle-sync.ts b/server/src/services/cloud-lifecycle-sync.ts new file mode 100644 index 0000000000..8eb3e6e24c --- /dev/null +++ b/server/src/services/cloud-lifecycle-sync.ts @@ -0,0 +1,103 @@ +import { logger } from "../middleware/logger.js"; +import { + cloudTenantPrimaryCompanyId, + getCloudStackContext, + type CloudInstanceEnv, +} from "./cloud-instance.js"; + +/** + * Cloud lifecycle doorbell: when the Cloud-pinned primary company is + * archived or unarchived on a managed instance, ring the harness + * (`POST /v1/tenant/lifecycle-changed`) so Paperclip Cloud can converge the + * stack — an org whose only company is archived should not keep running, + * and should read "Archived" on /orgs rather than "Live". + * + * The ring is a HINT by contract: the harness never trusts it. It reads + * the primary company's status back through `GET /api/instance/lifecycle` + * (Cloud control assertion) before changing anything, so the worst a lost, + * duplicated, or forged ring can do is trigger one verified read. + * + * Fire-and-forget with a bounded retry: archiving a company must never + * block or fail on Cloud availability, and a missed ring only delays the + * badge/suspend convergence, it cannot corrupt state. + */ + +const REQUEST_TIMEOUT_MS = 10_000; +const RETRY_DELAYS_MS = [2_000, 10_000]; + +export type CloudLifecycleSyncOptions = { + env?: CloudInstanceEnv; + fetchImpl?: typeof fetch; + sleep?: (ms: number) => Promise; +}; + +/** + * True when this company is the Cloud-pinned primary company of a managed + * instance — the only company whose archive state the harness mirrors. + */ +export function isCloudPinnedPrimaryCompany( + companyId: string, + env: CloudInstanceEnv = process.env, +): boolean { + const stackId = getCloudStackContext(env)?.stackId; + return Boolean(stackId) && cloudTenantPrimaryCompanyId(stackId!) === companyId; +} + +/** + * Ring the harness about a lifecycle change of the pinned primary company. + * No-op (resolved promise) on self-hosted instances, non-primary companies, + * or incomplete Cloud metadata. Never throws. + */ +export async function notifyCloudOfPrimaryCompanyLifecycleChange( + companyId: string, + options: CloudLifecycleSyncOptions = {}, +): Promise { + const env = options.env ?? process.env; + if (!isCloudPinnedPrimaryCompany(companyId, env)) return; + const context = getCloudStackContext(env); + const token = env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); + if (!context?.stackId || !context.cloudOrigin || !token) return; + + const fetchImpl = options.fetchImpl ?? fetch; + // Unreferenced timers: this detached retry loop must never hold the + // process open — a shutdown mid-retry just drops the ring, which the + // harness's verified read-back model tolerates by design. + const sleep = + options.sleep ?? + ((ms: number) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + })); + const url = `${context.cloudOrigin}/v1/tenant/lifecycle-changed`; + + for (let attempt = 0; ; attempt += 1) { + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "x-paperclip-cloud-stack-id": context.stackId, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + // Any response means the harness heard the ring — it does its own + // verified read-back, so even a non-2xx outcome is not retried + // beyond transient server errors. + if (response.ok || response.status < 500) return; + if (attempt >= RETRY_DELAYS_MS.length) { + logger.warn( + { status: response.status, url }, + "Cloud lifecycle doorbell got a server error; giving up", + ); + return; + } + } catch (err) { + if (attempt >= RETRY_DELAYS_MS.length) { + logger.warn({ err, url }, "Cloud lifecycle doorbell unreachable; giving up"); + return; + } + } + await sleep(RETRY_DELAYS_MS[attempt]!); + } +} diff --git a/server/src/services/cloud-runtime-identity.ts b/server/src/services/cloud-runtime-identity.ts index db8f5b77bd..e6567f2cf9 100644 --- a/server/src/services/cloud-runtime-identity.ts +++ b/server/src/services/cloud-runtime-identity.ts @@ -449,6 +449,8 @@ export const CLOUD_CONTROL_ACTIONS = [ "task-drain:read", "task-drain:start", "task-drain:stop", + "lifecycle:read", + "lifecycle:unarchive-primary", ] as const; export type CloudControlAction = (typeof CLOUD_CONTROL_ACTIONS)[number]; diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index c2b27e0ebc..b0838f14ac 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -36,6 +36,7 @@ import { } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { isCloudManagedInstance } from "./cloud-instance.js"; +import { notifyCloudOfPrimaryCompanyLifecycleChange } from "./cloud-lifecycle-sync.js"; import { MAX_ISSUE_PREFIX_ATTEMPTS, deriveIssuePrefixBase, @@ -442,10 +443,20 @@ export function companyService(db: Db) { company: enrichCompany(hydrated), reactivated: shouldLogReactivation ? { agentsRestored } : null, archiveCascade, + unarchived: willReactivate && existing.status === "archived", issuePrefixRederived, }; }); if (!result) return null; + // Post-commit, fire-and-forget, and BEFORE any finalization that + // could throw: a Cloud-pinned primary company that crossed the + // archived boundary (either direction) rings the harness so the + // stack itself can converge. The status transaction has already + // committed, so a later cascade or activity-log failure must not + // leave Cloud unaware of a company that is in fact archived. + if (result.archiveCascade || result.unarchived) { + void notifyCloudOfPrimaryCompanyLifecycleChange(id); + } if (result.issuePrefixRederived) { await logActivity(db, { companyId: id, @@ -514,7 +525,10 @@ export function companyService(db: Db) { }); if (!result) return null; + // Same doorbell rule as update(): the archive is committed, so ring + // before finalization, which can throw without undoing it. if (result.cascade) { + void notifyCloudOfPrimaryCompanyLifecycleChange(id); await finalizeArchive(id, actor, result.cascade); }