diff --git a/.changeset/admin-brand-claim-verify-endpoint.md b/.changeset/admin-brand-claim-verify-endpoint.md new file mode 100644 index 0000000000..9599dcdc5f --- /dev/null +++ b/.changeset/admin-brand-claim-verify-endpoint.md @@ -0,0 +1,4 @@ +--- +--- + +Add `POST /api/admin/organizations/:orgId/brand-claim/verify` for admin-triggered brand-claim sync. Wraps the existing `verifyDomainChallenge` service with an explicit orgId so it can be invoked with `ADMIN_API_KEY` to recover from missed `organization_domain.verified` webhooks (e.g. WorkOS dashboard manual flips that didn't propagate). When WorkOS already reports the domain as verified, the service short-circuits the DNS check and runs `applyVerifiedBrandClaim` directly. diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 1084d47cb9..284f20094a 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -19,6 +19,8 @@ import { import { linkContactsByDomain } from "../../db/contacts-db.js"; import { resolveOrgsByDomains } from "../../db/domain-resolution-db.js"; import { complete, isLLMConfigured } from "../../utils/llm.js"; +import { BrandDatabase } from "../../db/brand-db.js"; +import { verifyDomainChallenge } from "../../services/brand-claim.js"; const slackDb = new SlackDatabase(); const logger = createLogger("admin-domains"); @@ -1159,6 +1161,53 @@ export function setupDomainRoutes( } ); + // POST /api/admin/organizations/:orgId/brand-claim/verify + // Force-sync the brand registry from WorkOS for an org's domain. Wraps the + // same verifyDomainChallenge service the user-facing /api/me/member-profile + // route uses, but takes orgId explicitly so it can be invoked with the + // ADMIN_API_KEY for orgs the caller doesn't belong to. + // + // Recovery path for cases where WorkOS shows the domain as `verified` but + // the local `brands` row wasn't written — e.g. a missed + // organization_domain.verified webhook. The verify service short-circuits + // on isVerifiedState and runs applyVerifiedBrandClaim directly, so no DNS + // round-trip is needed. + apiRouter.post( + "/organizations/:orgId/brand-claim/verify", + requireAuth, + requireAdmin, + async (req, res) => { + const { orgId } = req.params; + const rawDomain = (req.body?.domain as string | undefined) ?? ""; + const adoptPriorManifest = req.body?.adopt_prior_manifest === true; + if (!workos) { + return res.status(503).json({ error: "WorkOS not configured" }); + } + if (!rawDomain) { + return res.status(400).json({ error: "domain is required" }); + } + try { + const result = await verifyDomainChallenge({ + workos, + brandDb: new BrandDatabase(), + orgId, + rawDomain, + adoptPriorManifest, + }); + if (!result.ok) { + const status = result.code === "no_challenge" ? 404 + : result.code === "still_pending" ? 400 + : 500; + return res.status(status).json(result); + } + return res.json(result); + } catch (error) { + logger.error({ err: error, orgId, domain: rawDomain }, "Admin brand-claim verify failed"); + return res.status(500).json({ error: "Internal server error" }); + } + } + ); + // POST /api/admin/organizations/:orgId/domains - Add a domain to an organization // Writes to WorkOS first, then local DB is updated via webhook (or immediately for consistency) apiRouter.post(