diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 1511b95ea11..e6656c3daf6 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -1,16 +1,56 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; import { healthRoutes } from "../routes/health.js"; import { serverVersion } from "../version.js"; -describe("GET /health", () => { - const app = express(); - app.use("/health", healthRoutes()); +vi.mock("../dev-server-status.js", async () => { + const actual = await vi.importActual( + "../dev-server-status.js", + ); + return { + ...actual, + readPersistedDevServerStatus: () => null, + }; +}); +describe("GET /health", () => { it("returns 200 with status ok", async () => { + const app = express(); + app.use("/health", healthRoutes()); const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); }); + + it("checks database connectivity before reporting healthy", async () => { + const execute = vi.fn().mockResolvedValue([]); + const app = express(); + app.use("/health", healthRoutes({ execute } as any)); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); + }); + + it("returns 503 when the database probe fails", async () => { + const app = express(); + app.use( + "/health", + healthRoutes({ + execute: vi.fn().mockRejectedValue(new Error("db offline")), + } as any), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(503); + expect(res.body).toEqual({ + status: "error", + error: "Database unavailable", + version: serverVersion, + }); + }); }); diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts new file mode 100644 index 00000000000..c23bf5f63f9 --- /dev/null +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -0,0 +1,77 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createDbStub(inviteRows: unknown[], companyRows: unknown[]) { + let selectCall = 0; + return { + select() { + selectCall += 1; + return selectCall === 1 + ? createSelectChain(inviteRows) + : createSelectChain(companyRows); + }, + }; +} + +function createApp(db: Record) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "anon" }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token", () => { + it("returns companyName in the invite summary response", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const app = createApp( + createDbStub([invite], [{ name: "Acme Robotics" }]), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyId).toBe("company-1"); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.inviteType).toBe("company_join"); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 4df973358b4..1ac76aa8f1b 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db"; import { agentApiKeys, authUsers, + companies, invites, joinRequests } from "@paperclipai/db"; @@ -870,7 +871,8 @@ export function normalizeAgentDefaultsForJoin(input: { function toInviteSummaryResponse( req: Request, token: string, - invite: typeof invites.$inferSelect + invite: typeof invites.$inferSelect, + companyName: string | null = null, ) { const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; @@ -879,6 +881,7 @@ function toInviteSummaryResponse( return { id: invite.id, companyId: invite.companyId, + companyName, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, humanRole: extractInviteHumanRole(invite.defaultsPayload), @@ -897,6 +900,18 @@ function toInviteSummaryResponse( }; } +async function resolveInviteCompanyName( + db: Db, + companyId: string | null, +): Promise { + if (!companyId) return null; + return db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0]?.name ?? null); +} + function buildOnboardingDiscoveryDiagnostics(input: { apiBaseUrl: string; deploymentMode: DeploymentMode; @@ -1012,7 +1027,8 @@ function buildInviteOnboardingManifest( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - } + }, + companyName: string | null = null, ) { const baseUrl = requestBaseUrl(req); const skillPath = "/api/skills/paperclip"; @@ -1039,7 +1055,7 @@ function buildInviteOnboardingManifest( }); return { - invite: toInviteSummaryResponse(req, token, invite), + invite: toInviteSummaryResponse(req, token, invite, companyName), onboarding: { instructions: "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", @@ -1960,7 +1976,12 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + await resolveInviteCompanyName(db, created.companyId), + ); res.status(201).json({ ...created, token, @@ -2005,7 +2026,12 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + await resolveInviteCompanyName(db, created.companyId), + ); res.status(201).json({ ...created, token, @@ -2034,7 +2060,14 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(toInviteSummaryResponse(req, token, invite)); + res.json( + toInviteSummaryResponse( + req, + token, + invite, + await resolveInviteCompanyName(db, invite.companyId), + ), + ); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2049,7 +2082,15 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(buildInviteOnboardingManifest(req, token, invite, opts)); + res.json( + buildInviteOnboardingManifest( + req, + token, + invite, + opts, + await resolveInviteCompanyName(db, invite.companyId), + ), + ); }); router.get("/invites/:token/onboarding.txt", async (req, res) => { diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 0bf6e92fe57..2cf416696a9 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -4,6 +4,7 @@ import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { logger } from "../middleware/logger.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; @@ -29,6 +30,18 @@ export function healthRoutes( return; } + try { + await db.execute(sql`select 1`); + } catch (error) { + logger.warn({ err: error }, "Health check database probe failed"); + res.status(503).json({ + status: "error", + error: "Database unavailable", + version: serverVersion, + }); + return; + } + let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; let bootstrapInviteActive = false; if (opts.deploymentMode === "authenticated") { diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index b63f8346653..46e640f0972 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -6,6 +6,7 @@ export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer"; type InviteSummary = { id: string; companyId: string | null; + companyName?: string | null; inviteType: "company_join" | "bootstrap_ceo"; allowedJoinTypes: "human" | "agent" | "both"; humanRole?: HumanCompanyRole | null; diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index a52a6e1f1ae..24de62ae4ae 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -475,7 +475,10 @@ export function CompanySettings() { {/* Invites */} -
+
Invites
@@ -491,6 +494,7 @@ export function CompanySettings() { <>