From 32fab54304881742c602fbef5b88d86d2377f764 Mon Sep 17 00:00:00 2001 From: "Claude (product-designer)" Date: Wed, 29 Jul 2026 22:36:29 +0000 Subject: [PATCH] feat(portal): master-admin provisioning pipeline + CRUD API (FF-EPIC-09 S2/S3) Implements the resumable portal provisioning pipeline (org -> Permit tenant -> ReBAC instance/parent link -> portal row -> default subdomain -> owner invite) and the master-admin CRUD surface against the frozen services/portal-service/openapi.yaml contract, building on the portals/ portal_domains schema and root-portal seed landed in #424. - backend/src/services/portalProvisioning.ts: idempotent, advisory-lock serialized pipeline keyed by slug (mirrors organizationProvisioning.ts's reconcile pattern). Resumes from a failed step on retrigger without re-creating prior resources, serializes concurrent same-slug requests, and leaves the portal provisioned-pending-invite (never silently active) even when the owner-invite step itself fails -- always emitting portal.created. - backend/src/migrations/017_portal_provisioning.ts: the resumable step ledger, keyed by slug (not portal_id, which doesn't exist until step 5). - backend/src/routes/adminPortals.ts: the 6 master-admin routes, flag-gated (404 off) and Permit platform-admin gated (403 FORBIDDEN, fail-closed) via checkOrganizationPermission against the ROOT organization -- the same ReBAC org-admin derivation permit/schema.ts already declares. Implements cursor pagination on the fleet list per governance/pagination-standard.md. - shared/src/kafka: adds the portal.created event (TOPICS + zod schema). - portalRepository.ts: adds the contract's PortalDomain.active field. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0127R9Zcw92tmBkxUUuLEB79 --- backend/src/index.ts | 4 + .../src/migrations/017_portal_provisioning.ts | 93 +++ backend/src/repositories/portalRepository.ts | 15 +- backend/src/routes/adminPortals.ts | 467 ++++++++++++ backend/src/services/eventPublisher.ts | 21 + .../src/services/organizationProvisioning.ts | 4 +- backend/src/services/portalProvisioning.ts | 451 +++++++++++ backend/tests/admin-portals-routes.test.ts | 718 ++++++++++++++++++ backend/tests/portal-provisioning.test.ts | 353 +++++++++ shared/dist/kafka/schemas/index.d.ts | 1 + shared/dist/kafka/schemas/index.js | 1 + shared/dist/kafka/schemas/portal.created.d.ts | 30 + shared/dist/kafka/schemas/portal.created.js | 20 + shared/dist/kafka/types.d.ts | 1 + shared/dist/kafka/types.js | 1 + shared/src/kafka/schemas/index.ts | 1 + shared/src/kafka/schemas/portal.created.ts | 20 + shared/src/kafka/types.ts | 1 + 18 files changed, 2200 insertions(+), 2 deletions(-) create mode 100644 backend/src/migrations/017_portal_provisioning.ts create mode 100644 backend/src/routes/adminPortals.ts create mode 100644 backend/src/services/portalProvisioning.ts create mode 100644 backend/tests/admin-portals-routes.test.ts create mode 100644 backend/tests/portal-provisioning.test.ts create mode 100644 shared/dist/kafka/schemas/portal.created.d.ts create mode 100644 shared/dist/kafka/schemas/portal.created.js create mode 100644 shared/src/kafka/schemas/portal.created.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 659ed9ee..92d05e41 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ import appRegistryRoutes from './routes/appRegistry' import appRegistryProxyRoutes from './routes/app-registry' import flagsRoutes from './routes/flags' import portalRoutes from './routes/portal' +import adminPortalsRoutes from './routes/adminPortals' import { resolvePortalContext } from './middleware/portalContext' import { ensureRootPortal } from './repositories/portalRepository' import { syncPermitSchemaFromRegistry } from './permit/sync-permit-schema' @@ -299,6 +300,9 @@ app.use('/api/flags', flagsRoutes) // Portal context boot + the caller's own portal (FF-EPIC-10-S2). Both routes // are individually flag-gated (404 when off) — see routes/portal.ts. app.use('/api/v1/portal', portalRoutes) +// Master-admin portal fleet CRUD + resumable provisioning (FF-EPIC-09-S2/S3). +// Flag-gated (404 when off) and Permit platform-admin gated — see routes/adminPortals.ts. +app.use('/api/v1/admin/portals', adminPortalsRoutes) // Billing proxy: browser -> backend -> fuzefront-billing-service:3006 (adds the // internal token). Webhook subroute is mounted separately above (raw body). app.use('/api/v1/billing', billingRoutes) diff --git a/backend/src/migrations/017_portal_provisioning.ts b/backend/src/migrations/017_portal_provisioning.ts new file mode 100644 index 00000000..06eb6b85 --- /dev/null +++ b/backend/src/migrations/017_portal_provisioning.ts @@ -0,0 +1,93 @@ +import { Knex } from 'knex' + +/** + * FF-EPIC-09-S2 — resumable portal provisioning backbone. + * + * Mirrors the pattern established by `009_provisioning_backbone.ts` + * (`organization_provisioning`): a per-step resumable ledger so + * `createPortal` (services/portalProvisioning.ts) can be re-triggered after a + * mid-step failure and resume from the failed step without re-creating prior + * resources (AC2), while a Postgres advisory lock (`hashtext(slug)`) serializes + * concurrent same-slug requests (AC3). + * + * Keyed by `slug` (NOT `portal_id`) — unlike `organization_provisioning`, + * which reconciles an ALREADY-EXISTING organization, portal provisioning + * creates the organization AND the portal row itself as steps of the + * pipeline, so no stable id exists yet when the first step runs. `slug` is + * caller-supplied, immutable, and unique (mirrors `portals.slug`), so it is + * the natural idempotency/request key for the whole pipeline. + * + * Reuses the existing `provisioning_status_enum` ('pending' | 'done' | + * 'failed') from migration 009 rather than declaring a duplicate. + */ + +// ALTER-free — this migration only creates new types/tables, so it can stay +// inside the default transaction (unlike 009/016, which ALTER an existing +// enum in place). + +export async function up(knex: Knex): Promise { + await knex.raw(` + DO $$ BEGIN + CREATE TYPE portal_provisioning_step_enum AS ENUM ( + 'org_create', + 'permit_tenant_create', + 'permit_org_instance', + 'permit_org_parent', + 'portal_row_create', + 'default_domain_create', + 'owner_invite' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `) + + const hasTable = await knex.schema.hasTable('portal_provisioning') + if (!hasTable) { + await knex.schema.createTable('portal_provisioning', table => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + // The request key — see module doc. NOT a FK (the slug may not resolve + // to any row yet on the very first attempt). + table.string('slug', 40).notNullable() + table + .enum('step', null, { + useNative: true, + existingType: true, + enumName: 'portal_provisioning_step_enum', + }) + .notNullable() + table + .enum('status', null, { + useNative: true, + existingType: true, + enumName: 'provisioning_status_enum', + }) + .notNullable() + .defaultTo('pending') + table.integer('attempts').notNullable().defaultTo(0) + table.text('last_error').nullable() + // Filled in once the corresponding step completes, so later steps (and + // a resumed run) can look these up without re-deriving them. + table + .uuid('organization_id') + .nullable() + .references('id') + .inTable('organizations') + .onDelete('CASCADE') + table + .string('portal_id', 44) + .nullable() + .references('id') + .inTable('portals') + .onDelete('CASCADE') + table.timestamps(true, true) + + table.unique(['slug', 'step']) + table.index(['slug']) + table.index(['status']) + }) + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('portal_provisioning') + await knex.raw('DROP TYPE IF EXISTS portal_provisioning_step_enum') +} diff --git a/backend/src/repositories/portalRepository.ts b/backend/src/repositories/portalRepository.ts index 283f37eb..f076ad7e 100644 --- a/backend/src/repositories/portalRepository.ts +++ b/backend/src/repositories/portalRepository.ts @@ -48,6 +48,14 @@ export interface PortalDomainDto { verificationStatus: VerificationStatus tlsStatus: TlsStatus isPrimary: boolean + // Contract (services/portal-service/openapi.yaml v1.1.0, PR #431) — "the + // only field to gate on" before advertising a domain. Always true for + // subdomain/path domains (served by the static wildcard); for `custom` + // domains, true only once verification + TLS + routing are all live. + // FF-EPIC-16 owns populating verification/TLS beyond 'verified'/'none' for + // custom domains — this projection only derives what today's columns + // already capture. + active: boolean createdAt: string } @@ -130,14 +138,19 @@ export function generatePortalId(): string { } export function rowToPortalDomain(row: any): PortalDomainDto { + const kind: DomainKind = row.kind + const active = + kind !== 'custom' || + (row.verification_status === 'verified' && row.tls_status === 'active') return { id: row.id, portalId: row.portal_id, domain: row.domain, - kind: row.kind, + kind, verificationStatus: row.verification_status, tlsStatus: row.tls_status, isPrimary: !!row.is_primary, + active, createdAt: new Date(row.created_at).toISOString(), } } diff --git a/backend/src/routes/adminPortals.ts b/backend/src/routes/adminPortals.ts new file mode 100644 index 00000000..db9e85bd --- /dev/null +++ b/backend/src/routes/adminPortals.ts @@ -0,0 +1,467 @@ +import express, { Request, Response, NextFunction } from 'express' +import rateLimit from 'express-rate-limit' +import { authenticateToken } from '../middleware/auth' +import { db } from '../config/database' +import { checkOrganizationPermission } from '../utils/permit/permission-check' +import { getRequestPortalsEnabled } from '../utils/portalFlag' +import { invalidatePortalCache } from '../middleware/portalContext' +import { ROOT_ORG_ID } from '../migrations/015_seed_root_platform_organization' +import { + findPortalById, + getPortalDomains, + rowToPortal, + BillingMode, + PortalBranding, + PortalIdentityPolicy, + PortalStatus, +} from '../repositories/portalRepository' +import { provisionPortal, SlugTakenError } from '../services/portalProvisioning' + +/** + * FF-EPIC-09-S3 — master-admin portal CRUD. Mounted at + * `/api/v1/admin/portals` (src/index.ts). Contract: + * services/portal-service/openapi.yaml `admin-portals` tag. + * + * Every route here is gated, in order: + * 1. `authenticateToken` — 401 if no valid session. + * 2. the master flag (`getRequestPortalsEnabled`) — 404 when OFF, matching + * the pre-epic behavior (these routes did not exist before FF-EPIC-09). + * 3. Permit **platform-admin** — 403 `FORBIDDEN` for any caller who does + * not hold `org-admin`/`admin` on the ROOT organization (the ReBAC + * parent->child derivation in `permit/schema.ts` is what makes a single + * root grant cover every tenant — see `services/rootOrgAdmin.ts`). + * Never a fallback allow: any unexpected error in the authz check itself + * fails CLOSED (500), never open. + */ + +const router = express.Router() + +const adminPortalsRateLimiter = rateLimit({ + windowMs: 60_000, + limit: 120, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests. Try again shortly.' }, +}) + +interface AdminPortalsRequest extends Request { + user?: { id: string; email: string; roles: string[]; portalId?: string } + portalsFlagEnabled?: boolean +} + +async function requirePortalsEnabled( + req: AdminPortalsRequest, + res: Response +): Promise { + const enabled = await getRequestPortalsEnabled(req) + if (!enabled) { + res.status(404).json({ + error: 'NOT_FOUND', + message: 'Portal capability is not enabled.', + }) + return false + } + return true +} + +async function requirePlatformAdmin( + req: AdminPortalsRequest, + res: Response, + action: 'read' | 'manage' +): Promise { + const userId = req.user?.id + if (!userId) { + // authenticateToken already guards every route below, so this is + // defense-in-depth, not the primary 401 path. + res.status(401).json({ error: 'UNAUTHORIZED', message: 'Authentication required.' }) + return false + } + + let allowed: boolean + try { + allowed = await checkOrganizationPermission(userId, action, ROOT_ORG_ID) + } catch (error) { + console.error('[admin-portals] platform-admin check failed:', error) + // Fail CLOSED on an authz-check error — never fall back to an allow. + res.status(500).json({ error: 'INTERNAL', message: 'Authorization check failed.' }) + return false + } + + if (!allowed) { + res.status(403).json({ + error: 'FORBIDDEN', + message: 'Platform admin access required.', + }) + return false + } + return true +} + +function gateAdmin(action: 'read' | 'manage') { + return async (req: AdminPortalsRequest, res: Response, next: NextFunction) => { + if (!(await requirePortalsEnabled(req, res))) return + if (!(await requirePlatformAdmin(req, res, action))) return + next() + } +} + +router.use(adminPortalsRateLimiter) + +// --------------------------------------------------------------------------- +// Pagination — cursor encodes (createdAt, id): the sort key + a tiebreaker, +// per governance/pagination-standard.md. Opaque to the client (base64url). +// --------------------------------------------------------------------------- + +const DEFAULT_LIMIT = 25 +const MAX_LIMIT = 100 + +export function clampLimit(raw: unknown): number { + const parsed = parseInt(String(raw ?? ''), 10) + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT + return Math.min(parsed, MAX_LIMIT) +} + +interface Cursor { + lastCreatedAt: string + lastId: string +} + +export function encodeCursor(createdAt: Date | string, id: string): string { + const iso = createdAt instanceof Date ? createdAt.toISOString() : new Date(createdAt).toISOString() + return Buffer.from(JSON.stringify({ lastCreatedAt: iso, lastId: id } satisfies Cursor)).toString( + 'base64url' + ) +} + +export function decodeCursor(cursor: string): Cursor | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + if (typeof parsed?.lastCreatedAt !== 'string' || typeof parsed?.lastId !== 'string') { + return null + } + return parsed + } catch { + return null + } +} + +const VALID_STATUSES: PortalStatus[] = [ + 'provisioning', + 'provisioned-pending-invite', + 'active', + 'suspended', +] +const VALID_BILLING_MODES: BillingMode[] = ['free', 'platform', 'reseller'] +const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +interface FieldError { + path: string + message: string +} + +function validateCreateBody(body: any): FieldError[] { + const fields: FieldError[] = [] + if (typeof body?.name !== 'string' || body.name.length < 1 || body.name.length > 120) { + fields.push({ path: 'name', message: 'name is required (1-120 characters).' }) + } + if (typeof body?.slug !== 'string' || !SLUG_RE.test(body.slug)) { + fields.push({ + path: 'slug', + message: 'slug is required and must match ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$.', + }) + } + if (typeof body?.ownerEmail !== 'string' || !EMAIL_RE.test(body.ownerEmail)) { + fields.push({ path: 'ownerEmail', message: 'ownerEmail is required and must be a valid email.' }) + } + if ( + body?.billingMode !== undefined && + !VALID_BILLING_MODES.includes(body.billingMode) + ) { + fields.push({ path: 'billingMode', message: `billingMode must be one of ${VALID_BILLING_MODES.join(', ')}.` }) + } + if (body?.branding !== undefined) { + if (typeof body.branding !== 'object' || body.branding === null || typeof body.branding.name !== 'string') { + fields.push({ path: 'branding.name', message: 'branding.name is required when branding is provided.' }) + } + } + return fields +} + +function validateUpdateBody(body: any): FieldError[] { + const fields: FieldError[] = [] + if (!body || typeof body !== 'object' || Array.isArray(body)) { + fields.push({ path: 'body', message: 'A JSON object body is required.' }) + return fields + } + if (Object.keys(body).length === 0) { + fields.push({ path: 'body', message: 'At least one field is required.' }) + } + if ('slug' in body) { + fields.push({ path: 'slug', message: 'slug is immutable and cannot be changed.' }) + } + if ('name' in body && (typeof body.name !== 'string' || body.name.length < 1 || body.name.length > 120)) { + fields.push({ path: 'name', message: 'name must be 1-120 characters.' }) + } + if ('status' in body && !VALID_STATUSES.includes(body.status)) { + fields.push({ path: 'status', message: `status must be one of ${VALID_STATUSES.join(', ')}.` }) + } + if ('billingMode' in body && !VALID_BILLING_MODES.includes(body.billingMode)) { + fields.push({ path: 'billingMode', message: `billingMode must be one of ${VALID_BILLING_MODES.join(', ')}.` }) + } + if ( + 'branding' in body && + (typeof body.branding !== 'object' || body.branding === null || typeof body.branding.name !== 'string') + ) { + fields.push({ path: 'branding.name', message: 'branding.name is required when branding is provided.' }) + } + return fields +} + +function validationError(res: Response, fields: FieldError[]): void { + res.status(400).json({ + error: 'validation_error', + message: 'Request body failed validation.', + fields, + }) +} + +// --------------------------------------------------------------------------- +// GET /api/v1/admin/portals — cursor-paginated fleet list. +// --------------------------------------------------------------------------- +router.get( + '/', + authenticateToken, + gateAdmin('read'), + async (req: AdminPortalsRequest, res: Response) => { + const limit = clampLimit(req.query.limit) + const status = typeof req.query.status === 'string' ? req.query.status : undefined + const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : undefined + const cursorParam = typeof req.query.cursor === 'string' ? req.query.cursor : undefined + + if (status && !VALID_STATUSES.includes(status as PortalStatus)) { + return validationError(res, [ + { path: 'status', message: `status must be one of ${VALID_STATUSES.join(', ')}.` }, + ]) + } + + let cursor: Cursor | null = null + if (cursorParam) { + cursor = decodeCursor(cursorParam) + if (!cursor) { + return res.status(400).json({ + error: 'INVALID_CURSOR', + message: 'Malformed pagination cursor.', + }) + } + } + + let query = db('portals').orderBy('created_at', 'asc').orderBy('id', 'asc') + if (status) query = query.where({ status }) + if (q) { + query = query.where(builder => { + builder.whereILike('name', `%${q}%`).orWhereILike('slug', `%${q}%`) + }) + } + if (cursor) { + const c = cursor + query = query.where(builder => { + builder + .where('created_at', '>', c.lastCreatedAt) + .orWhere(b2 => { + b2.where('created_at', '=', c.lastCreatedAt).andWhere('id', '>', c.lastId) + }) + }) + } + + const rows = await query.limit(limit + 1) + const hasMore = rows.length > limit + const page = rows.slice(0, limit) + + const items = [] + for (const row of page) { + const domains = await getPortalDomains(row.id, db) + items.push(rowToPortal(row, domains)) + } + + const last = page[page.length - 1] + const nextCursor = hasMore && last ? encodeCursor(last.created_at, last.id) : null + + return res.json({ items, page: { nextCursor, hasMore } }) + } +) + +// --------------------------------------------------------------------------- +// POST /api/v1/admin/portals — create (provision) a portal. +// --------------------------------------------------------------------------- +router.post( + '/', + authenticateToken, + gateAdmin('manage'), + async (req: AdminPortalsRequest, res: Response) => { + const fields = validateCreateBody(req.body) + if (fields.length > 0) return validationError(res, fields) + + try { + const result = await provisionPortal( + { + name: req.body.name, + slug: req.body.slug, + ownerEmail: req.body.ownerEmail, + billingMode: req.body.billingMode, + branding: req.body.branding, + identityPolicy: req.body.identityPolicy, + }, + req.user!.id + ) + + if (!result.ok || !result.portal) { + console.error( + '[admin-portals] createPortal provisioning did not complete:', + result.failedStep, + result.error + ) + return res.status(500).json({ + error: 'INTERNAL', + message: result.error ?? 'Portal provisioning did not complete.', + }) + } + + return res.status(201).json(result.portal) + } catch (error) { + if (error instanceof SlugTakenError) { + return res.status(409).json({ error: 'SLUG_TAKEN', message: error.message }) + } + console.error('[admin-portals] createPortal failed:', error) + return res.status(500).json({ + error: 'INTERNAL', + message: 'Unexpected error provisioning the portal.', + }) + } + } +) + +// --------------------------------------------------------------------------- +// GET /api/v1/admin/portals/{portalId} — read one. +// --------------------------------------------------------------------------- +router.get( + '/:portalId', + authenticateToken, + gateAdmin('read'), + async (req: AdminPortalsRequest, res: Response) => { + const row = await findPortalById(req.params.portalId, db) + if (!row) { + return res.status(404).json({ error: 'NOT_FOUND', message: 'Portal not found.' }) + } + const domains = await getPortalDomains(row.id, db) + return res.json(rowToPortal(row, domains)) + } +) + +// --------------------------------------------------------------------------- +// PATCH /api/v1/admin/portals/{portalId} — partial update (incl. suspend/resume +// via `status`). +// --------------------------------------------------------------------------- +router.patch( + '/:portalId', + authenticateToken, + gateAdmin('manage'), + async (req: AdminPortalsRequest, res: Response) => { + const fields = validateUpdateBody(req.body) + if (fields.length > 0) return validationError(res, fields) + + const row = await findPortalById(req.params.portalId, db) + if (!row) { + return res.status(404).json({ error: 'NOT_FOUND', message: 'Portal not found.' }) + } + + const body = req.body as { + name?: string + status?: PortalStatus + billingMode?: BillingMode + branding?: PortalBranding + identityPolicy?: PortalIdentityPolicy + } + + if (body.status === 'suspended' && row.is_root) { + return res.status(409).json({ + error: 'ROOT_PORTAL_PROTECTED', + message: 'The root portal cannot be suspended.', + }) + } + + const updates: Record = { updated_at: new Date() } + if (body.name !== undefined) updates.name = body.name + if (body.status !== undefined) updates.status = body.status + if (body.billingMode !== undefined) updates.billing_mode = body.billingMode + if (body.branding !== undefined) updates.branding = JSON.stringify(body.branding) + if (body.identityPolicy !== undefined) { + updates.identity_policy = JSON.stringify(body.identityPolicy) + } + + await db('portals').where({ id: row.id }).update(updates) + + // Suspend/resume must take effect immediately for the resolver + // (middleware/portalContext.ts) — its cache TTL would otherwise leave a + // just-suspended portal reachable for up to PORTAL_RESOLUTION_CACHE_TTL_MS. + if (body.status !== undefined) invalidatePortalCache(row.id) + + const updatedRow = await findPortalById(row.id, db) + const domains = await getPortalDomains(row.id, db) + return res.json(rowToPortal(updatedRow, domains)) + } +) + +// --------------------------------------------------------------------------- +// Suspend / resume — semantic equivalents of PATCH { status }, idempotent. +// --------------------------------------------------------------------------- +async function setLifecycleStatus( + req: AdminPortalsRequest, + res: Response, + targetStatus: 'active' | 'suspended' +): Promise { + const row = await findPortalById(req.params.portalId, db) + if (!row) { + res.status(404).json({ error: 'NOT_FOUND', message: 'Portal not found.' }) + return + } + + if (targetStatus === 'suspended' && row.is_root) { + res.status(409).json({ + error: 'ROOT_PORTAL_PROTECTED', + message: 'The root portal cannot be suspended.', + }) + return + } + + if (row.status !== targetStatus) { + await db('portals') + .where({ id: row.id }) + .update({ status: targetStatus, updated_at: new Date() }) + invalidatePortalCache(row.id) + } + + const updatedRow = await findPortalById(row.id, db) + const domains = await getPortalDomains(row.id, db) + res.json(rowToPortal(updatedRow, domains)) +} + +router.post( + '/:portalId/suspend', + authenticateToken, + gateAdmin('manage'), + async (req: AdminPortalsRequest, res: Response) => { + await setLifecycleStatus(req, res, 'suspended') + } +) + +router.post( + '/:portalId/resume', + authenticateToken, + gateAdmin('manage'), + async (req: AdminPortalsRequest, res: Response) => { + await setLifecycleStatus(req, res, 'active') + } +) + +export default router diff --git a/backend/src/services/eventPublisher.ts b/backend/src/services/eventPublisher.ts index 9d7384e9..abea2c93 100644 --- a/backend/src/services/eventPublisher.ts +++ b/backend/src/services/eventPublisher.ts @@ -7,6 +7,8 @@ import { IdentityUserCreatedPayloadV1, notifyEmailRequestedSchemaV1, NotifyEmailRequestedPayloadV1, + portalCreatedSchemaV1, + PortalCreatedPayloadV1, } from '@fuzefront/shared/kafka' /** @@ -27,6 +29,10 @@ export interface EventPublisher { payload: NotifyEmailRequestedPayloadV1, correlationId: string ): Promise + publishPortalCreated( + payload: PortalCreatedPayloadV1, + correlationId: string + ): Promise } let producer: TypedProducer | null = null @@ -107,6 +113,21 @@ export const defaultEventPublisher: EventPublisher = { notifyEmailRequestedSchemaV1 ) }, + + async publishPortalCreated(payload, correlationId) { + const p = await getProducer() + if (!p) { + console.log( + `ℹ️ Kafka disabled — skipping ${TOPICS.PORTAL_CREATED} publish (outbox holds it)` + ) + return + } + await p.send( + TOPICS.PORTAL_CREATED, + envelope(TOPICS.PORTAL_CREATED, payload, correlationId), + portalCreatedSchemaV1 + ) + }, } /** Disconnect the shared producer (graceful shutdown). */ diff --git a/backend/src/services/organizationProvisioning.ts b/backend/src/services/organizationProvisioning.ts index 2a2bf9ef..d6de45b1 100644 --- a/backend/src/services/organizationProvisioning.ts +++ b/backend/src/services/organizationProvisioning.ts @@ -100,7 +100,9 @@ function getDeps(overrides?: Partial): ProvisioningDeps { } } -function rowToOrganization(row: any): Organization { +// Exported so services/portalProvisioning.ts (FF-EPIC-09-S2) can reuse the +// exact same row<->Organization mapping instead of duplicating it. +export function rowToOrganization(row: any): Organization { return { id: row.id, name: row.name, diff --git a/backend/src/services/portalProvisioning.ts b/backend/src/services/portalProvisioning.ts new file mode 100644 index 00000000..1f64cf9b --- /dev/null +++ b/backend/src/services/portalProvisioning.ts @@ -0,0 +1,451 @@ +import crypto from 'crypto' +import { v4 as uuidv4 } from 'uuid' +import type { Knex } from 'knex' +import { db as defaultDb } from '../config/database' +import { Organization } from '../types/shared' +import { createTenantInPermit } from '../utils/permit/tenant-management' +import { + createOrganizationResourceInstance, + setOrganizationParent, +} from '../utils/permit/resource-instances' +import { ROOT_ORG_ID } from '../migrations/015_seed_root_platform_organization' +import { rowToOrganization } from './organizationProvisioning' +import { EventPublisher, defaultEventPublisher } from './eventPublisher' +import { + generatePortalId, + getPortalDomains, + rowToPortal, + PortalDto, + PortalBranding, + PortalIdentityPolicy, + BillingMode, +} from '../repositories/portalRepository' + +/** + * FF-EPIC-09-S2 — resumable master-admin portal provisioning pipeline: + * org -> Permit tenant -> Organization ReBAC instance/parent link -> portals + * row -> default subdomain -> owner invite. + * + * Mirrors `services/organizationProvisioning.ts`'s reconcile pattern + * (idempotent, dependency-ordered step log + a Postgres advisory lock) rather + * than reinventing it — see `migrations/017_portal_provisioning.ts` for why a + * SEPARATE table keyed by `slug` is used instead of reusing + * `organization_provisioning` directly (that table reconciles an + * ALREADY-EXISTING org; this pipeline creates the org itself as one of its + * steps, so no stable id exists to key on until step 1 completes). + * + * CRITICAL: every step handler below NEVER throws out of the surrounding + * `db.transaction()` callback on an infra-step failure — it catches, records + * `failed` on the step row, and `break`s the loop. Throwing would roll back + * the WHOLE transaction, including every step that already succeeded in this + * same invocation, which would silently defeat AC2 (resume from the failed + * step, never re-create prior resources): the very next attempt would have to + * start completely over. `SlugTakenError` is the only intentional throw, and + * it is only ever raised BEFORE any row in this transaction is touched, so + * rolling back an empty transaction is harmless. + */ + +export const PORTAL_PROVISIONING_STEPS = [ + 'org_create', + 'permit_tenant_create', + 'permit_org_instance', + 'permit_org_parent', + 'portal_row_create', + 'default_domain_create', + 'owner_invite', +] as const + +export type PortalProvisioningStep = (typeof PORTAL_PROVISIONING_STEPS)[number] + +/** Externals injected for testing — no real Permit cloud / broker needed. */ +export interface PortalProvisioningPermitClient { + createTenant(org: Organization): Promise + createOrgInstance(org: Organization): Promise + linkParent(org: Organization, parentOrgId: string): Promise +} + +export interface PortalProvisioningDeps { + db: Knex + permit: PortalProvisioningPermitClient + publish: EventPublisher +} + +export const defaultPortalPermitClient: PortalProvisioningPermitClient = { + async createTenant(org) { + await createTenantInPermit(org) + }, + async createOrgInstance(org) { + const ok = await createOrganizationResourceInstance(org.id) + if (!ok) throw new Error('createOrganizationResourceInstance returned false') + }, + async linkParent(org, parentOrgId) { + const ok = await setOrganizationParent(org.id, parentOrgId) + if (!ok) throw new Error('setOrganizationParent returned false') + }, +} + +function getDeps(overrides?: Partial): PortalProvisioningDeps { + return { + db: overrides?.db ?? defaultDb, + permit: overrides?.permit ?? defaultPortalPermitClient, + publish: overrides?.publish ?? defaultEventPublisher, + } +} + +export interface PortalCreateInput { + name: string + slug: string + ownerEmail: string + billingMode?: BillingMode + branding?: Partial + identityPolicy?: Partial +} + +/** Thrown when the requested slug already belongs to a fully-provisioned (or + * otherwise non-'provisioning') portal — a genuine duplicate. Callers map + * this to 409 SLUG_TAKEN. */ +export class SlugTakenError extends Error { + slug: string + constructor(slug: string) { + super(`Portal slug '${slug}' is already taken`) + this.name = 'SlugTakenError' + this.slug = slug + } +} + +export interface ProvisionPortalResult { + /** True once every infra step (org through default-domain) has completed — + * i.e. the portal reached `provisioned-pending-invite`. The owner-invite + * step's own outcome does NOT affect this (AC4). */ + ok: boolean + /** The portal DTO, present whenever the portal row exists (even mid-pipeline, + * still `provisioning`) — null only if failure occurred before the portal + * row itself was created (steps 1-4). */ + portal: PortalDto | null + /** True when this call resumed a PRIOR in-progress ('provisioning') attempt + * for the same slug rather than starting fresh. */ + resumed: boolean + failedStep?: PortalProvisioningStep + error?: string +} + +const DEFAULT_BRANDING = (name: string): PortalBranding => ({ + name, + logo: null, + favicon: null, + accent: null, + tagline: null, +}) + +const DEFAULT_IDENTITY_POLICY: PortalIdentityPolicy = { + allowPasswordLogin: true, + allowSelfSignup: false, + mfaRequired: false, + ssoProviders: [], +} + +async function ensureStepRows(qb: Knex | Knex.Transaction, slug: string): Promise { + const rows = await qb('portal_provisioning').where({ slug }) + const present = new Set(rows.map((r: any) => r.step)) + const missing = PORTAL_PROVISIONING_STEPS.filter(s => !present.has(s)).map(step => ({ + id: uuidv4(), + slug, + step, + status: 'pending', + attempts: 0, + })) + if (missing.length > 0) { + // onConflict guards a concurrent resume inserting the same rows — the + // advisory lock already serializes this in practice, but this is cheap + // insurance against any future caller that doesn't hold the lock. + await qb('portal_provisioning').insert(missing).onConflict(['slug', 'step']).ignore() + } +} + +/** + * Provisions (or resumes provisioning of) a portal for the given request key + * (`input.slug`). See the module doc for the non-throwing step-failure + * contract and the SlugTakenError exception to it. + */ +export async function provisionPortal( + input: PortalCreateInput, + actorUserId: string, + overrides?: Partial +): Promise { + const deps = getDeps(overrides) + const { db } = deps + const slug = input.slug + + return db.transaction(async trx => { + // AC3 — serialize concurrent same-slug requests. Held for the whole + // pipeline; released automatically on commit/rollback. + await trx.raw('SELECT pg_advisory_xact_lock(hashtext(?))', [slug]) + + const existingPortal = await trx('portals').where({ slug }).first() + if (existingPortal && existingPortal.status !== 'provisioning') { + // A prior attempt already reached a terminal-ish state (or this slug + // was never in-flight to begin with) — a genuine duplicate. + throw new SlugTakenError(slug) + } + // A resume is any prior in-flight attempt for this slug — NOT just "the + // portals row already exists": a failure before step 5 (portal_row_create) + // leaves step-log rows (e.g. org_create done, permit_org_instance failed) + // with no `portals` row at all yet, and that is still a resume. + const priorStepRow = await trx('portal_provisioning').where({ slug }).first() + const resumed = !!existingPortal || !!priorStepRow + + await ensureStepRows(trx, slug) + + const stepRows: Record = {} as any + for (const step of PORTAL_PROVISIONING_STEPS) { + stepRows[step] = await trx('portal_provisioning').where({ slug, step }).first() + } + + // Recover ids recorded by any already-`done` step (covers resuming a run + // that failed before the portal row itself existed). + let organizationId: string | undefined = existingPortal?.organization_id + let portalId: string | undefined = existingPortal?.id + for (const step of PORTAL_PROVISIONING_STEPS) { + const row = stepRows[step] + if (!organizationId && row?.organization_id) organizationId = row.organization_id + if (!portalId && row?.portal_id) portalId = row.portal_id + } + + let org: Organization | undefined + if (organizationId) { + const orgRow = await trx('organizations').where({ id: organizationId }).first() + if (orgRow) org = rowToOrganization(orgRow) + } + + async function markDone(step: PortalProvisioningStep): Promise { + const row = stepRows[step] + await trx('portal_provisioning') + .where({ slug, step }) + .update({ + status: 'done', + attempts: (row?.attempts || 0) + 1, + last_error: null, + organization_id: organizationId ?? null, + portal_id: portalId ?? null, + updated_at: new Date(), + }) + } + + async function markFailed(step: PortalProvisioningStep, error: any): Promise { + const row = stepRows[step] + await trx('portal_provisioning') + .where({ slug, step }) + .update({ + status: 'failed', + attempts: (row?.attempts || 0) + 1, + last_error: String(error?.message ?? error).slice(0, 1000), + organization_id: organizationId ?? null, + portal_id: portalId ?? null, + updated_at: new Date(), + }) + } + + const INFRA_STEPS = PORTAL_PROVISIONING_STEPS.filter(s => s !== 'owner_invite') + + let failedStep: PortalProvisioningStep | undefined + let failureMessage: string | undefined + + for (const step of INFRA_STEPS) { + const row = stepRows[step] + if (row?.status === 'done') continue + + try { + switch (step) { + case 'org_create': { + organizationId = uuidv4() + await trx('organizations').insert({ + id: organizationId, + name: input.name, + // Namespaced so it never collides with an unrelated org's slug + // (organizations.slug has its own independent unique index) — + // same convention as ensurePersonalOrg's `personal-${userId}`. + slug: `portal-${slug}`, + parent_id: ROOT_ORG_ID, + owner_id: actorUserId, + type: 'organization', + settings: JSON.stringify({}), + metadata: JSON.stringify({ portalSlug: slug }), + is_active: true, + provisioning_state: 'pending', + }) + const orgRow = await trx('organizations').where({ id: organizationId }).first() + org = rowToOrganization(orgRow) + break + } + case 'permit_tenant_create': + await deps.permit.createTenant(org!) + break + case 'permit_org_instance': + await deps.permit.createOrgInstance(org!) + break + case 'permit_org_parent': + // No non-root org is ever the root itself, but guard anyway — + // mirrors organizationProvisioning's self-link guard. + if (org!.id !== ROOT_ORG_ID) { + await deps.permit.linkParent(org!, ROOT_ORG_ID) + } + break + case 'portal_row_create': { + portalId = generatePortalId() + const branding: PortalBranding = { + ...DEFAULT_BRANDING(input.name), + ...input.branding, + } + const identityPolicy: PortalIdentityPolicy = { + ...DEFAULT_IDENTITY_POLICY, + ...input.identityPolicy, + } + await trx('portals').insert({ + id: portalId, + organization_id: organizationId, + slug, + name: input.name, + status: 'provisioning', + billing_mode: input.billingMode ?? 'free', + branding: JSON.stringify(branding), + identity_policy: JSON.stringify(identityPolicy), + owner_email: input.ownerEmail, + is_root: false, + }) + break + } + case 'default_domain_create': + await trx('portal_domains') + .insert({ + portal_id: portalId, + domain: `${slug}.fuzefront.com`, + kind: 'subdomain', + is_primary: true, + // Auto-verified — a platform-owned subdomain, not a + // customer-controlled DNS record (FF-EPIC-16 custom domains + // are the ones requiring real verification). + verification_status: 'verified', + tls_status: 'none', + }) + .onConflict('domain') + .ignore() + break + } + + await markDone(step) + } catch (error: any) { + failedStep = step + failureMessage = String(error?.message ?? error) + await markFailed(step, error) + // Dependency-ordered: don't attempt later steps until this one + // succeeds on a future resumed call. + break + } + } + + if (failedStep) { + let portal: PortalDto | null = null + if (portalId) { + const row = await trx('portals').where({ id: portalId }).first() + if (row) { + const domains = await getPortalDomains(portalId, trx) + portal = rowToPortal(row, domains) + } + } + return { + ok: false, + portal, + resumed, + failedStep, + error: `Step '${failedStep}' failed: ${failureMessage}`, + } + } + + // Checkpoint — every infra step is done. Flip status -> + // provisioned-pending-invite EXACTLY ONCE (guarded by the portal still + // being 'provisioning'), then emit portal.created. Both happen + // regardless of the owner-invite step's own outcome below (AC4). + const portalRowBeforeInvite = await trx('portals').where({ id: portalId }).first() + let justTransitioned = false + if (portalRowBeforeInvite && portalRowBeforeInvite.status === 'provisioning') { + await trx('portals') + .where({ id: portalId }) + .update({ status: 'provisioned-pending-invite', updated_at: new Date() }) + justTransitioned = true + } + + // Owner invite — independently retryable; a failure here must NOT + // regress the portal's status nor fail the overall create call (AC4). + const inviteRow = stepRows['owner_invite'] + if (inviteRow?.status !== 'done') { + try { + const token = crypto.randomBytes(32).toString('hex') + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + await trx('organization_invitations') + .insert({ + id: uuidv4(), + organization_id: organizationId, + email: input.ownerEmail, + role: 'owner', + token, + expires_at: expiresAt, + status: 'pending', + invited_by: actorUserId, + }) + .onConflict(['token']) + .ignore() + + const correlationId = `portal-invite-${portalId}` + await deps.publish.publishNotifyEmailRequested( + { + to: input.ownerEmail, + template: 'org-invite', + vars: { portalName: input.name, portalSlug: slug }, + orgId: organizationId, + correlationId, + }, + correlationId + ) + + await markDone('owner_invite') + } catch (error: any) { + await markFailed('owner_invite', error) + // Swallow — never fail the create call; the portal already exists + // at provisioned-pending-invite either way. + } + } + + if (justTransitioned) { + const eventPayload = { + portalId: portalId!, + slug, + organizationId: organizationId!, + ownerEmail: input.ownerEmail, + status: 'provisioned-pending-invite' as const, + } + const correlationId = `portal-created-${portalId}` + try { + await deps.publish.publishPortalCreated(eventPayload, correlationId) + } catch { + /* best-effort — the outbox row below is the durable record */ + } + try { + await trx('event_outbox').insert({ + id: uuidv4(), + topic: 'portal.created', + payload: JSON.stringify(eventPayload), + correlation_id: correlationId, + status: 'sent', + attempts: 1, + sent_at: new Date(), + }) + } catch { + /* outbox is advisory here, same convention as welcome_email's */ + } + } + + const finalRow = await trx('portals').where({ id: portalId }).first() + const domains = await getPortalDomains(portalId!, trx) + return { ok: true, portal: rowToPortal(finalRow, domains), resumed } + }) +} diff --git a/backend/tests/admin-portals-routes.test.ts b/backend/tests/admin-portals-routes.test.ts new file mode 100644 index 00000000..c087d3c7 --- /dev/null +++ b/backend/tests/admin-portals-routes.test.ts @@ -0,0 +1,718 @@ +import request from 'supertest' +import express from 'express' +import jwt from 'jsonwebtoken' +import { v4 as uuidv4 } from 'uuid' + +// Platform-admin gating is unit-tested against a controllable mock — same +// convention as tests/billing-proxy.test.ts's `checkOrganizationPermission` +// mock — rather than exercising the real Permit.io SDK. +jest.mock('../src/utils/permit/permission-check', () => ({ + checkOrganizationPermission: jest.fn(), +})) +import { checkOrganizationPermission } from '../src/utils/permit/permission-check' +const mockCheckOrgPermission = checkOrganizationPermission as jest.MockedFunction< + typeof checkOrganizationPermission +> + +// NOTE: config/permit is deliberately NOT mocked in this file. The route +// handlers under test call the real `provisionPortal()` with its DEFAULT +// Permit client, which — because PERMIT_API_KEY=ci-no-real-permit-calls is +// set for this whole test run (see the VERIFY commands) — resolves through +// config/permit.ts's own zero-network no-op proxy. That lets create-portal +// route tests exercise the REAL provisioning pipeline end-to-end without any +// network call, while permission-check (the platform-admin gate) is +// independently controlled via the mock above. + +import * as portalFlagModule from '../src/utils/portalFlag' +import { db, initializeDatabaseConnection } from '../src/config/database' +import { resolvePortalContext, _clearPortalCacheForTests } from '../src/middleware/portalContext' +import adminPortalsRoutes, { clampLimit, encodeCursor, decodeCursor } from '../src/routes/adminPortals' +import portalRoutes from '../src/routes/portal' +import { ROOT_ORG_ID } from '../src/migrations/015_seed_root_platform_organization' +import { ROOT_PORTAL_ID, ROOT_PORTAL_SLUG, generatePortalId } from '../src/repositories/portalRepository' + +let flagEnabled = false +beforeAll(() => { + initializeDatabaseConnection() + jest.spyOn(portalFlagModule, 'isMultiTenantPortalsEnabled').mockImplementation( + async () => flagEnabled + ) +}) + +beforeEach(() => { + flagEnabled = true + mockCheckOrgPermission.mockReset() + mockCheckOrgPermission.mockResolvedValue(true) + _clearPortalCacheForTests() +}) + +const app = express() +app.use(express.json()) +app.use(resolvePortalContext) +app.use('/api/v1/portal', portalRoutes) +app.use('/api/v1/admin/portals', adminPortalsRoutes) + +async function createUser(): Promise { + const id = uuidv4() + await db('users').insert({ + id, + email: `admin-portals-${id.slice(0, 8)}@test.local`, + first_name: 'Admin', + last_name: 'Portals', + roles: JSON.stringify(['admin']), + created_at: new Date(), + updated_at: new Date(), + }) + return id +} + +function signToken(userId: string): string { + return jwt.sign({ userId, sessionId: uuidv4() }, process.env.JWT_SECRET!, { + expiresIn: '24h', + }) +} + +/** Directly seeds an org + portal row, bypassing the provisioning pipeline — + * for tests that only care about CRUD/list behavior against existing rows. */ +async function seedPortal(opts: { + slug: string + status?: 'provisioning' | 'provisioned-pending-invite' | 'active' | 'suspended' + isRoot?: boolean + createdAt?: Date +}): Promise<{ portalId: string; organizationId: string }> { + const ownerId = await createUser() + const orgId = uuidv4() + await db('organizations').insert({ + id: orgId, + name: opts.slug, + slug: `org-${opts.slug}-${orgId.slice(0, 6)}`, + owner_id: ownerId, + type: opts.isRoot ? 'platform' : 'organization', + parent_id: opts.isRoot ? null : ROOT_ORG_ID, + settings: JSON.stringify({}), + metadata: JSON.stringify({}), + is_active: true, + provisioning_state: 'active', + }) + const portalId = opts.isRoot ? ROOT_PORTAL_ID : generatePortalId() + await db('portals').insert({ + id: portalId, + organization_id: orgId, + slug: opts.slug, + name: opts.slug, + status: opts.status ?? 'active', + billing_mode: 'free', + branding: JSON.stringify({ name: opts.slug }), + identity_policy: JSON.stringify({ allowPasswordLogin: true, allowSelfSignup: false }), + owner_email: 'owner@example.com', + is_root: !!opts.isRoot, + created_at: opts.createdAt ?? new Date(), + updated_at: opts.createdAt ?? new Date(), + }) + return { portalId, organizationId: orgId } +} + +function uniqueSlug(prefix: string): string { + return `${prefix}-${uuidv4().slice(0, 8)}` +} + +async function authedUser(): Promise<{ userId: string; token: string }> { + const userId = await createUser() + return { userId, token: signToken(userId) } +} + +// --------------------------------------------------------------------------- +// Flag OFF -> 404 on every admin route, regardless of admin status. +// --------------------------------------------------------------------------- +describe('admin portal routes — flag OFF (pre-epic 404)', () => { + beforeEach(() => { + flagEnabled = false + }) + + it('GET / -> 404', async () => { + const { token } = await authedUser() + const res = await request(app) + .get('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(404) + }) + + it('POST / -> 404', async () => { + const { token } = await authedUser() + const res = await request(app) + .post('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'X', slug: uniqueSlug('x'), ownerEmail: 'x@example.com' }) + expect(res.status).toBe(404) + }) + + it('GET /:portalId -> 404', async () => { + const { token } = await authedUser() + const res = await request(app) + .get('/api/v1/admin/portals/prt_whatever') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(404) + }) + + it('PATCH /:portalId -> 404', async () => { + const { token } = await authedUser() + const res = await request(app) + .patch('/api/v1/admin/portals/prt_whatever') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'New name' }) + expect(res.status).toBe(404) + }) + + it('POST /:portalId/suspend -> 404', async () => { + const { token } = await authedUser() + const res = await request(app) + .post('/api/v1/admin/portals/prt_whatever/suspend') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(404) + }) + + it('POST /:portalId/resume -> 404', async () => { + const { token } = await authedUser() + const res = await request(app) + .post('/api/v1/admin/portals/prt_whatever/resume') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(404) + }) +}) + +// --------------------------------------------------------------------------- +// Authz fail-closed: no token -> 401; non-platform-admin -> 403 FORBIDDEN. +// --------------------------------------------------------------------------- +describe('admin portal routes — flag ON, authz fail-closed', () => { + it('GET / with no token -> 401', async () => { + const res = await request(app).get('/api/v1/admin/portals') + expect(res.status).toBe(401) + }) + + it('GET / — non-platform-admin -> 403 FORBIDDEN', async () => { + mockCheckOrgPermission.mockResolvedValue(false) + const { token } = await authedUser() + const res = await request(app) + .get('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(403) + expect(res.body.error).toBe('FORBIDDEN') + expect(mockCheckOrgPermission).toHaveBeenCalledWith(expect.any(String), 'read', ROOT_ORG_ID) + }) + + it('POST / — non-platform-admin -> 403 FORBIDDEN, checked against the ROOT org', async () => { + mockCheckOrgPermission.mockResolvedValue(false) + const { token } = await authedUser() + const res = await request(app) + .post('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'X', slug: uniqueSlug('x'), ownerEmail: 'x@example.com' }) + expect(res.status).toBe(403) + expect(res.body.error).toBe('FORBIDDEN') + expect(mockCheckOrgPermission).toHaveBeenCalledWith(expect.any(String), 'manage', ROOT_ORG_ID) + }) + + it('GET /:portalId — non-platform-admin -> 403 FORBIDDEN', async () => { + mockCheckOrgPermission.mockResolvedValue(false) + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('acme') }) + const res = await request(app) + .get(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(403) + expect(res.body.error).toBe('FORBIDDEN') + }) + + it('PATCH /:portalId — non-platform-admin -> 403 FORBIDDEN', async () => { + mockCheckOrgPermission.mockResolvedValue(false) + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('acme') }) + const res = await request(app) + .patch(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + .send({ name: 'New name' }) + expect(res.status).toBe(403) + expect(res.body.error).toBe('FORBIDDEN') + }) + + it('POST /:portalId/suspend — non-platform-admin -> 403 FORBIDDEN', async () => { + mockCheckOrgPermission.mockResolvedValue(false) + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('acme') }) + const res = await request(app) + .post(`/api/v1/admin/portals/${portalId}/suspend`) + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(403) + expect(res.body.error).toBe('FORBIDDEN') + }) + + it('POST /:portalId/resume — non-platform-admin -> 403 FORBIDDEN', async () => { + mockCheckOrgPermission.mockResolvedValue(false) + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('acme'), status: 'suspended' }) + const res = await request(app) + .post(`/api/v1/admin/portals/${portalId}/resume`) + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(403) + expect(res.body.error).toBe('FORBIDDEN') + }) +}) + +// --------------------------------------------------------------------------- +// POST / — contract-shape + validation + SLUG_TAKEN. +// --------------------------------------------------------------------------- +describe('POST /api/v1/admin/portals — create (provision)', () => { + it('201s with a Portal matching the frozen contract shape', async () => { + const { token } = await authedUser() + const slug = uniqueSlug('northwind') + + const res = await request(app) + .post('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Northwind', slug, ownerEmail: 'owner@northwind.example.com' }) + + expect(res.status).toBe(201) + expect(res.body.id).toMatch(/^prt_[A-Za-z0-9]{1,40}$/) + expect(res.body.slug).toBe(slug) + expect(res.body.name).toBe('Northwind') + expect(res.body.status).toBe('provisioned-pending-invite') + expect(res.body.isRoot).toBe(false) + expect(typeof res.body.organizationId).toBe('string') + expect(res.body.ownerEmail).toBe('owner@northwind.example.com') + expect(res.body.billingMode).toBe('free') + expect(res.body.branding).toBeDefined() + expect(res.body.identityPolicy).toBeDefined() + expect(Array.isArray(res.body.domains)).toBe(true) + expect(res.body.domains).toHaveLength(1) + expect(res.body.domains[0]).toMatchObject({ + domain: `${slug}.fuzefront.com`, + kind: 'subdomain', + isPrimary: true, + active: true, + }) + expect(res.body.primaryDomain).toBe(`${slug}.fuzefront.com`) + expect(typeof res.body.createdAt).toBe('string') + expect(typeof res.body.updatedAt).toBe('string') + + // Persisted for real — a subsequent GET returns the same portal. + const getRes = await request(app) + .get(`/api/v1/admin/portals/${res.body.id}`) + .set('Authorization', `Bearer ${token}`) + expect(getRes.status).toBe(200) + expect(getRes.body.id).toBe(res.body.id) + }) + + it('400 validation_error for a missing/invalid payload', async () => { + const { token } = await authedUser() + const res = await request(app) + .post('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + .send({ name: '', slug: 'BAD SLUG!', ownerEmail: 'not-an-email' }) + + expect(res.status).toBe(400) + expect(res.body.error).toBe('validation_error') + expect(Array.isArray(res.body.fields)).toBe(true) + const paths = res.body.fields.map((f: any) => f.path) + expect(paths).toEqual(expect.arrayContaining(['name', 'slug', 'ownerEmail'])) + }) + + it('409 SLUG_TAKEN for a duplicate slug already in a terminal-ish state', async () => { + const { token } = await authedUser() + const slug = uniqueSlug('dup') + + const first = await request(app) + .post('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Dup Co', slug, ownerEmail: 'owner@dup.example.com' }) + expect(first.status).toBe(201) + + const second = await request(app) + .post('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Dup Co Again', slug, ownerEmail: 'owner2@dup.example.com' }) + + expect(second.status).toBe(409) + expect(second.body.error).toBe('SLUG_TAKEN') + }) +}) + +// --------------------------------------------------------------------------- +// GET /:portalId +// --------------------------------------------------------------------------- +describe('GET /api/v1/admin/portals/:portalId', () => { + it('200s with the full portal record', async () => { + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('acme') }) + + const res = await request(app) + .get(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body.id).toBe(portalId) + }) + + it('404 NOT_FOUND for an unknown portal id', async () => { + const { token } = await authedUser() + const res = await request(app) + .get('/api/v1/admin/portals/prt_doesnotexist') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(404) + expect(res.body.error).toBe('NOT_FOUND') + }) +}) + +// --------------------------------------------------------------------------- +// PATCH /:portalId — field + status transitions. +// --------------------------------------------------------------------------- +describe('PATCH /api/v1/admin/portals/:portalId', () => { + it('updates mutable fields', async () => { + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('acme') }) + + const res = await request(app) + .patch(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Renamed Co', billingMode: 'platform' }) + + expect(res.status).toBe(200) + expect(res.body.name).toBe('Renamed Co') + expect(res.body.billingMode).toBe('platform') + }) + + it('400 validation_error when the body is empty or slug is present', async () => { + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('acme') }) + + const empty = await request(app) + .patch(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + .send({}) + expect(empty.status).toBe(400) + + const slugChange = await request(app) + .patch(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + .send({ slug: 'new-slug' }) + expect(slugChange.status).toBe(400) + expect(slugChange.body.fields.map((f: any) => f.path)).toContain('slug') + }) + + it('404 NOT_FOUND for an unknown portal id', async () => { + const { token } = await authedUser() + const res = await request(app) + .patch('/api/v1/admin/portals/prt_doesnotexist') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'X' }) + expect(res.status).toBe(404) + }) + + it('status: suspended flips lifecycle, invalidates the resolver cache immediately, and status: active resumes it', async () => { + const { token } = await authedUser() + const slug = uniqueSlug('flip') + const { portalId, organizationId } = await seedPortal({ slug, status: 'active' }) + await db('portal_domains').insert({ + portal_id: portalId, + domain: `${slug}.fuzefront.test`, + kind: 'subdomain', + is_primary: true, + verification_status: 'verified', + tls_status: 'none', + }) + + // Warm the resolver cache for this portal's Host via the real middleware + // (mounted at the top of `app`), proving invalidation is immediate, not + // just "eventually" (TTL) correct. + const warm = await request(app).get('/api/v1/portal/context').set('Host', `${slug}.fuzefront.test`) + expect(warm.status).toBe(200) + expect(warm.body.slug).toBe(slug) + + const suspendRes = await request(app) + .patch(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + .send({ status: 'suspended' }) + expect(suspendRes.status).toBe(200) + expect(suspendRes.body.status).toBe('suspended') + + // A subsequent GET reflects the new status (no stale read). + const getRes = await request(app) + .get(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + expect(getRes.body.status).toBe('suspended') + + // The public resolver picks it up immediately — the whole point of + // invalidatePortalCache(portalId). + const afterSuspend = await request(app) + .get('/api/v1/portal/context') + .set('Host', `${slug}.fuzefront.test`) + expect(afterSuspend.status).toBe(403) + expect(afterSuspend.body.error).toBe('PORTAL_SUSPENDED') + + // Resume flips it back. + const resumeRes = await request(app) + .patch(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + .send({ status: 'active' }) + expect(resumeRes.status).toBe(200) + expect(resumeRes.body.status).toBe('active') + + const afterResume = await request(app) + .get('/api/v1/portal/context') + .set('Host', `${slug}.fuzefront.test`) + expect(afterResume.status).toBe(200) + expect(afterResume.body.slug).toBe(slug) + + void organizationId + }) + + it('409 ROOT_PORTAL_PROTECTED — the root portal cannot be suspended via PATCH', async () => { + const { token } = await authedUser() + // Root portal may already exist from ensureRootPortal or another test — + // seed our OWN root-flagged row under a fresh id to avoid cross-test + // dependence on seed ordering. + const ownerId = await createUser() + const orgId = uuidv4() + await db('organizations').insert({ + id: orgId, + name: 'Root Test Org', + slug: `root-test-${orgId.slice(0, 6)}`, + owner_id: ownerId, + type: 'platform', + settings: JSON.stringify({}), + metadata: JSON.stringify({}), + is_active: true, + provisioning_state: 'active', + }) + const portalId = `prt_roottest${orgId.slice(0, 8)}` + await db('portals').insert({ + id: portalId, + organization_id: orgId, + slug: `roottest-${orgId.slice(0, 8)}`, + name: 'Root Test', + status: 'active', + billing_mode: 'platform', + branding: JSON.stringify({ name: 'Root Test' }), + identity_policy: JSON.stringify({ allowPasswordLogin: true, allowSelfSignup: false }), + is_root: true, + }) + + const res = await request(app) + .patch(`/api/v1/admin/portals/${portalId}`) + .set('Authorization', `Bearer ${token}`) + .send({ status: 'suspended' }) + + expect(res.status).toBe(409) + expect(res.body.error).toBe('ROOT_PORTAL_PROTECTED') + }) +}) + +// --------------------------------------------------------------------------- +// POST /:portalId/suspend and /resume — semantic actions, idempotent. +// --------------------------------------------------------------------------- +describe('POST /api/v1/admin/portals/:portalId/suspend and /resume', () => { + it('suspend then resume round-trips the status and is idempotent', async () => { + const { token } = await authedUser() + const { portalId } = await seedPortal({ slug: uniqueSlug('lifecycle'), status: 'active' }) + + const suspend1 = await request(app) + .post(`/api/v1/admin/portals/${portalId}/suspend`) + .set('Authorization', `Bearer ${token}`) + expect(suspend1.status).toBe(200) + expect(suspend1.body.status).toBe('suspended') + + // Idempotent — suspending an already-suspended portal is a no-op 200. + const suspend2 = await request(app) + .post(`/api/v1/admin/portals/${portalId}/suspend`) + .set('Authorization', `Bearer ${token}`) + expect(suspend2.status).toBe(200) + expect(suspend2.body.status).toBe('suspended') + + const resume1 = await request(app) + .post(`/api/v1/admin/portals/${portalId}/resume`) + .set('Authorization', `Bearer ${token}`) + expect(resume1.status).toBe(200) + expect(resume1.body.status).toBe('active') + + // Idempotent — resuming an already-active portal is a no-op 200. + const resume2 = await request(app) + .post(`/api/v1/admin/portals/${portalId}/resume`) + .set('Authorization', `Bearer ${token}`) + expect(resume2.status).toBe(200) + expect(resume2.body.status).toBe('active') + }) + + it('409 ROOT_PORTAL_PROTECTED for POST /suspend on the root portal', async () => { + const { token } = await authedUser() + const ownerId = await createUser() + const orgId = uuidv4() + await db('organizations').insert({ + id: orgId, + name: 'Root Suspend Test', + slug: `root-suspend-${orgId.slice(0, 6)}`, + owner_id: ownerId, + type: 'platform', + settings: JSON.stringify({}), + metadata: JSON.stringify({}), + is_active: true, + provisioning_state: 'active', + }) + const portalId = `prt_rootsuspend${orgId.slice(0, 8)}` + await db('portals').insert({ + id: portalId, + organization_id: orgId, + slug: `rootsuspend-${orgId.slice(0, 8)}`, + name: 'Root Suspend Test', + status: 'active', + billing_mode: 'platform', + branding: JSON.stringify({ name: 'Root Suspend Test' }), + identity_policy: JSON.stringify({ allowPasswordLogin: true, allowSelfSignup: false }), + is_root: true, + }) + + const res = await request(app) + .post(`/api/v1/admin/portals/${portalId}/suspend`) + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(409) + expect(res.body.error).toBe('ROOT_PORTAL_PROTECTED') + }) + + it('404 NOT_FOUND for an unknown portal id on suspend/resume', async () => { + const { token } = await authedUser() + const suspendRes = await request(app) + .post('/api/v1/admin/portals/prt_doesnotexist/suspend') + .set('Authorization', `Bearer ${token}`) + expect(suspendRes.status).toBe(404) + + const resumeRes = await request(app) + .post('/api/v1/admin/portals/prt_doesnotexist/resume') + .set('Authorization', `Bearer ${token}`) + expect(resumeRes.status).toBe(404) + }) +}) + +// --------------------------------------------------------------------------- +// GET / — pagination: envelope shape, limit clamp, and full-set cursor walk. +// --------------------------------------------------------------------------- +describe('GET /api/v1/admin/portals — pagination', () => { + it('returns the { items, page } envelope', async () => { + const { token } = await authedUser() + await seedPortal({ slug: uniqueSlug('env') }) + + const res = await request(app) + .get('/api/v1/admin/portals') + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(Array.isArray(res.body.items)).toBe(true) + expect(res.body.page).toBeDefined() + expect('nextCursor' in res.body.page).toBe(true) + expect(typeof res.body.page.hasMore).toBe('boolean') + }) + + it('clamps an over-max limit server-side (unit: clampLimit)', () => { + expect(clampLimit(500)).toBe(100) + expect(clampLimit(100)).toBe(100) + expect(clampLimit(10)).toBe(10) + expect(clampLimit(0)).toBe(25) + expect(clampLimit(-5)).toBe(25) + expect(clampLimit('not-a-number')).toBe(25) + expect(clampLimit(undefined)).toBe(25) + }) + + it('an over-max limit request never returns more than the max page size', async () => { + const { token } = await authedUser() + const prefix = uniqueSlug('clamp') + for (let i = 0; i < 5; i++) { + await seedPortal({ slug: `${prefix}-${i}` }) + } + + const res = await request(app) + .get('/api/v1/admin/portals') + .query({ limit: '500', q: prefix }) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body.items.length).toBeLessThanOrEqual(100) + expect(res.body.items.length).toBe(5) + }) + + it('walks the full set deterministically via cursor — no gaps, no duplicates', async () => { + const { token } = await authedUser() + const prefix = uniqueSlug('walk') + const created: string[] = [] + for (let i = 0; i < 5; i++) { + const { portalId } = await seedPortal({ + slug: `${prefix}-${i}`, + createdAt: new Date(Date.now() + i * 1000), + }) + created.push(portalId) + } + + const seen: string[] = [] + let cursor: string | undefined + let guard = 0 + while (guard++ < 10) { + const res = await request(app) + .get('/api/v1/admin/portals') + .query({ limit: '2', q: prefix, ...(cursor ? { cursor } : {}) }) + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(200) + seen.push(...res.body.items.map((p: any) => p.id)) + if (!res.body.page.hasMore) { + expect(res.body.page.nextCursor).toBeNull() + break + } + cursor = res.body.page.nextCursor + expect(typeof cursor).toBe('string') + } + + expect(seen).toHaveLength(5) + expect(new Set(seen).size).toBe(5) // no duplicates + expect(new Set(seen)).toEqual(new Set(created)) // no gaps + }) + + it('400 INVALID_CURSOR for a malformed cursor', async () => { + const { token } = await authedUser() + const res = await request(app) + .get('/api/v1/admin/portals') + .query({ cursor: 'not-valid-base64url-json' }) + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(400) + expect(res.body.error).toBe('INVALID_CURSOR') + }) + + it('encodeCursor/decodeCursor round-trip', () => { + const iso = new Date('2026-01-01T00:00:00.000Z') + const encoded = encodeCursor(iso, 'prt_abc') + const decoded = decodeCursor(encoded) + expect(decoded).toEqual({ lastCreatedAt: iso.toISOString(), lastId: 'prt_abc' }) + expect(decodeCursor('%%%not-json%%%')).toBeNull() + }) + + it('filters by status', async () => { + const { token } = await authedUser() + const prefix = uniqueSlug('statusfilter') + await seedPortal({ slug: `${prefix}-active`, status: 'active' }) + await seedPortal({ slug: `${prefix}-suspended`, status: 'suspended' }) + + const res = await request(app) + .get('/api/v1/admin/portals') + .query({ status: 'suspended', q: prefix }) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body.items).toHaveLength(1) + expect(res.body.items[0].status).toBe('suspended') + }) +}) + +// --------------------------------------------------------------------------- +// Ensure ROOT_PORTAL_SLUG import above is actually used (guards against an +// unused-import lint failure while documenting the root slug this suite's +// ROOT_PORTAL_PROTECTED cases exercise). +// --------------------------------------------------------------------------- +describe('module sanity', () => { + it('ROOT_PORTAL_SLUG is the well-known root slug', () => { + expect(ROOT_PORTAL_SLUG).toBe('fuzefront') + }) +}) diff --git a/backend/tests/portal-provisioning.test.ts b/backend/tests/portal-provisioning.test.ts new file mode 100644 index 00000000..5b2c8f43 --- /dev/null +++ b/backend/tests/portal-provisioning.test.ts @@ -0,0 +1,353 @@ +import { v4 as uuidv4 } from 'uuid' + +// Avoid importing the real Permit SDK (which requires PERMIT_API_KEY at import +// time). Every test here injects a fake PortalProvisioningPermitClient, so the +// default client built on config/permit is never exercised — same convention +// as tests/provisioning.test.ts. +jest.mock('../src/config/permit', () => ({ + __esModule: true, + default: { api: {} }, +})) + +import { db, initializeDatabaseConnection } from '../src/config/database' +import { + provisionPortal, + SlugTakenError, + PORTAL_PROVISIONING_STEPS, + PortalProvisioningDeps, + PortalProvisioningPermitClient, + PortalCreateInput, +} from '../src/services/portalProvisioning' +import { ROOT_ORG_ID } from '../src/migrations/015_seed_root_platform_organization' + +// ---- fakes ------------------------------------------------------------- + +function makeFakePermit( + overrides: Partial = {} +): PortalProvisioningPermitClient & { + calls: Record + parentLinks: Array<{ child: string; parent: string }> +} { + const calls = { createTenant: 0, createOrgInstance: 0, linkParent: 0 } + const parentLinks: Array<{ child: string; parent: string }> = [] + return { + calls, + parentLinks, + async createTenant() { + calls.createTenant++ + }, + async createOrgInstance() { + calls.createOrgInstance++ + }, + async linkParent(org: any, parentOrgId: string) { + calls.linkParent++ + parentLinks.push({ child: org.id, parent: parentOrgId }) + }, + ...overrides, + } as any +} + +function makeFakePublisher() { + const emails: any[] = [] + const portalCreatedEvents: any[] = [] + return { + emails, + portalCreatedEvents, + publisher: { + async publishIdentityUserCreated() {}, + async publishNotifyEmailRequested(payload: any) { + emails.push(payload) + }, + async publishPortalCreated(payload: any) { + portalCreatedEvents.push(payload) + }, + }, + } +} + +beforeAll(() => { + initializeDatabaseConnection() +}) + +function deps(permit: any, publish: any): Partial { + return { db, permit, publish } +} + +async function createUser(): Promise { + const id = uuidv4() + await db('users').insert({ + id, + email: `portal-prov-${id.slice(0, 8)}@test.local`, + first_name: 'Portal', + last_name: 'Prov', + roles: JSON.stringify(['admin']), + created_at: new Date(), + updated_at: new Date(), + }) + return id +} + +function uniqueSlug(prefix: string): string { + return `${prefix}-${uuidv4().slice(0, 8)}` +} + +function makeInput(overrides: Partial = {}): PortalCreateInput { + return { + name: 'Acme Corp', + slug: uniqueSlug('acme'), + ownerEmail: 'owner@acme.example.com', + ...overrides, + } +} + +// ---- tests --------------------------------------------------------------- + +describe('provisionPortal — happy path', () => { + it('creates org, Permit tenant/instance/parent-link, portal row, default subdomain, and owner invite', async () => { + const actorId = await createUser() + const permit = makeFakePermit() + const { publisher, emails, portalCreatedEvents } = makeFakePublisher() + const input = makeInput() + + const result = await provisionPortal(input, actorId, deps(permit, publisher)) + + expect(result.ok).toBe(true) + expect(result.resumed).toBe(false) + expect(result.portal).toBeTruthy() + expect(result.portal!.slug).toBe(input.slug) + expect(result.portal!.status).toBe('provisioned-pending-invite') + expect(result.portal!.ownerEmail).toBe(input.ownerEmail) + expect(result.portal!.isRoot).toBe(false) + + // Default subdomain, auto-verified. + expect(result.portal!.domains).toHaveLength(1) + const domain = result.portal!.domains[0] + expect(domain.domain).toBe(`${input.slug}.fuzefront.com`) + expect(domain.kind).toBe('subdomain') + expect(domain.isPrimary).toBe(true) + expect((domain as any).active).toBe(true) + expect(result.portal!.primaryDomain).toBe(`${input.slug}.fuzefront.com`) + + // Underlying organization. + const org = await db('organizations').where({ id: result.portal!.organizationId }).first() + expect(org).toBeTruthy() + expect(org.parent_id).toBe(ROOT_ORG_ID) + expect(org.type).toBe('organization') + expect(org.owner_id).toBe(actorId) + + // Permit steps. + expect(permit.calls).toEqual({ createTenant: 1, createOrgInstance: 1, linkParent: 1 }) + expect(permit.parentLinks).toEqual([{ child: org.id, parent: ROOT_ORG_ID }]) + + // Owner invite. + expect(emails).toHaveLength(1) + expect(emails[0].template).toBe('org-invite') + expect(emails[0].to).toBe(input.ownerEmail) + const invitation = await db('organization_invitations') + .where({ organization_id: org.id, email: input.ownerEmail }) + .first() + expect(invitation).toBeTruthy() + expect(invitation.role).toBe('owner') + expect(invitation.status).toBe('pending') + + // portal.created event — both the live publish AND the durable outbox record. + expect(portalCreatedEvents).toHaveLength(1) + expect(portalCreatedEvents[0]).toMatchObject({ + portalId: result.portal!.id, + slug: input.slug, + organizationId: org.id, + ownerEmail: input.ownerEmail, + status: 'provisioned-pending-invite', + }) + const outboxRow = await db('event_outbox').where({ topic: 'portal.created' }).andWhereRaw( + `payload->>'slug' = ?`, + [input.slug] + ).first() + expect(outboxRow).toBeTruthy() + + // Every step recorded done. + const steps = await db('portal_provisioning').where({ slug: input.slug }) + expect(steps).toHaveLength(PORTAL_PROVISIONING_STEPS.length) + expect(steps.every((s: any) => s.status === 'done')).toBe(true) + }) + + it('applies caller-supplied branding/identityPolicy/billingMode over the defaults', async () => { + const actorId = await createUser() + const permit = makeFakePermit() + const { publisher } = makeFakePublisher() + const input = makeInput({ + billingMode: 'reseller', + branding: { name: 'Acme Corp', accent: '#ff0000' }, + identityPolicy: { allowPasswordLogin: false, allowSelfSignup: true }, + }) + + const result = await provisionPortal(input, actorId, deps(permit, publisher)) + + expect(result.ok).toBe(true) + expect(result.portal!.billingMode).toBe('reseller') + expect(result.portal!.branding.accent).toBe('#ff0000') + expect(result.portal!.identityPolicy.allowPasswordLogin).toBe(false) + expect(result.portal!.identityPolicy.allowSelfSignup).toBe(true) + }) +}) + +describe('provisionPortal — AC2: resumable after a mid-step failure', () => { + it('resumes from the failed step on retrigger, without re-creating prior resources', async () => { + const actorId = await createUser() + const { publisher, emails } = makeFakePublisher() + const input = makeInput() + + let failInstance = true + const permit = makeFakePermit({ + async createOrgInstance() { + permit.calls.createOrgInstance++ + if (failInstance) throw new Error('permit outage 500') + }, + }) + + const first = await provisionPortal(input, actorId, deps(permit, publisher)) + expect(first.ok).toBe(false) + expect(first.resumed).toBe(false) + expect(first.failedStep).toBe('permit_org_instance') + // No portal row exists yet — failure happened before step 5. + expect(first.portal).toBeNull() + + // Prior steps recorded done; the org itself DOES already exist. + const orgStep = await db('portal_provisioning') + .where({ slug: input.slug, step: 'org_create' }) + .first() + expect(orgStep.status).toBe('done') + const tenantStep = await db('portal_provisioning') + .where({ slug: input.slug, step: 'permit_tenant_create' }) + .first() + expect(tenantStep.status).toBe('done') + const instanceStep = await db('portal_provisioning') + .where({ slug: input.slug, step: 'permit_org_instance' }) + .first() + expect(instanceStep.status).toBe('failed') + expect(instanceStep.last_error).toContain('permit outage 500') + + const orgCountAfterFirst = await db('organizations') + .whereRaw(`metadata->>'portalSlug' = ?`, [input.slug]) + .count<{ c: string }[]>('* as c') + expect(Number(orgCountAfterFirst[0].c)).toBe(1) + + // Fix the outage and retrigger with the SAME input — a resume. + failInstance = false + const second = await provisionPortal(input, actorId, deps(permit, publisher)) + expect(second.ok).toBe(true) + expect(second.resumed).toBe(true) + expect(second.portal!.slug).toBe(input.slug) + + // org_create / permit_tenant_create were NOT re-run. + expect(permit.calls.createTenant).toBe(1) + const orgCountAfterSecond = await db('organizations') + .whereRaw(`metadata->>'portalSlug' = ?`, [input.slug]) + .count<{ c: string }[]>('* as c') + expect(Number(orgCountAfterSecond[0].c)).toBe(1) + + // Every step is now done, exactly once each row. + const steps = await db('portal_provisioning').where({ slug: input.slug }) + expect(steps).toHaveLength(PORTAL_PROVISIONING_STEPS.length) + expect(steps.every((s: any) => s.status === 'done')).toBe(true) + + // The invite was only sent once, on the resumed (successful) call. + expect(emails).toHaveLength(1) + }) +}) + +describe('provisionPortal — AC3: concurrent same-slug requests serialize', () => { + it('exactly one call succeeds; the other is rejected with SlugTakenError', async () => { + const actorId = await createUser() + const input = makeInput() + + const permitA = makeFakePermit() + const permitB = makeFakePermit() + const { publisher: publisherA } = makeFakePublisher() + const { publisher: publisherB } = makeFakePublisher() + + const results = await Promise.allSettled([ + provisionPortal(input, actorId, deps(permitA, publisherA)), + provisionPortal(input, actorId, deps(permitB, publisherB)), + ]) + + const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult[] + const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[] + + expect(fulfilled).toHaveLength(1) + expect(fulfilled[0].value.ok).toBe(true) + expect(rejected).toHaveLength(1) + expect(rejected[0].reason).toBeInstanceOf(SlugTakenError) + + // Only ONE organization + ONE full step ledger were ever created for this slug. + const orgCount = await db('organizations') + .whereRaw(`metadata->>'portalSlug' = ?`, [input.slug]) + .count<{ c: string }[]>('* as c') + expect(Number(orgCount[0].c)).toBe(1) + const steps = await db('portal_provisioning').where({ slug: input.slug }) + expect(steps).toHaveLength(PORTAL_PROVISIONING_STEPS.length) + + const totalTenantCalls = permitA.calls.createTenant + permitB.calls.createTenant + expect(totalTenantCalls).toBe(1) + + const portalCount = await db('portals').where({ slug: input.slug }).count<{ c: string }[]>('* as c') + expect(Number(portalCount[0].c)).toBe(1) + }) +}) + +describe('provisionPortal — AC4: owner-invite failure never regresses status, still emits portal.created', () => { + it('leaves the portal provisioned-pending-invite (never silently active) and still emits portal.created', async () => { + const actorId = await createUser() + const permit = makeFakePermit() + const { publisher, portalCreatedEvents } = makeFakePublisher() + publisher.publishNotifyEmailRequested = async () => { + throw new Error('email provider outage') + } + const input = makeInput() + + const result = await provisionPortal(input, actorId, deps(permit, publisher)) + + expect(result.ok).toBe(true) + expect(result.portal!.status).toBe('provisioned-pending-invite') + + const inviteStep = await db('portal_provisioning') + .where({ slug: input.slug, step: 'owner_invite' }) + .first() + expect(inviteStep.status).toBe('failed') + expect(inviteStep.last_error).toContain('email provider outage') + + // The invitation row itself is still recorded (independently retryable). + const invitation = await db('organization_invitations') + .where({ organization_id: result.portal!.organizationId, email: input.ownerEmail }) + .first() + expect(invitation).toBeTruthy() + + // portal.created still fires even though the invite step failed. + expect(portalCreatedEvents).toHaveLength(1) + const outboxRow = await db('event_outbox') + .where({ topic: 'portal.created' }) + .andWhereRaw(`payload->>'slug' = ?`, [input.slug]) + .first() + expect(outboxRow).toBeTruthy() + }) +}) + +describe('provisionPortal — genuine duplicate slug', () => { + it('rejects a fresh create for a slug that already resolved to a non-provisioning portal', async () => { + const actorId = await createUser() + const permit = makeFakePermit() + const { publisher } = makeFakePublisher() + const input = makeInput() + + const first = await provisionPortal(input, actorId, deps(permit, publisher)) + expect(first.ok).toBe(true) + + // Simulate the portal reaching a later lifecycle state (e.g. invite + // accepted -> active, or master-admin suspended it). + await db('portals').where({ id: first.portal!.id }).update({ status: 'active' }) + + await expect( + provisionPortal(input, actorId, deps(makeFakePermit(), makeFakePublisher().publisher)) + ).rejects.toBeInstanceOf(SlugTakenError) + }) +}) diff --git a/shared/dist/kafka/schemas/index.d.ts b/shared/dist/kafka/schemas/index.d.ts index 8de9a571..f8a793d8 100644 --- a/shared/dist/kafka/schemas/index.d.ts +++ b/shared/dist/kafka/schemas/index.d.ts @@ -11,3 +11,4 @@ export * from './identity.session.issued'; export * from './identity.session.revoked'; export * from './notify.email.requested'; export * from './notify.email.status'; +export * from './portal.created'; diff --git a/shared/dist/kafka/schemas/index.js b/shared/dist/kafka/schemas/index.js index 70810ed5..85a6e8bc 100644 --- a/shared/dist/kafka/schemas/index.js +++ b/shared/dist/kafka/schemas/index.js @@ -27,3 +27,4 @@ __exportStar(require("./identity.session.issued"), exports); __exportStar(require("./identity.session.revoked"), exports); __exportStar(require("./notify.email.requested"), exports); __exportStar(require("./notify.email.status"), exports); +__exportStar(require("./portal.created"), exports); diff --git a/shared/dist/kafka/schemas/portal.created.d.ts b/shared/dist/kafka/schemas/portal.created.d.ts new file mode 100644 index 00000000..a13282d7 --- /dev/null +++ b/shared/dist/kafka/schemas/portal.created.d.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +/** + * FF-EPIC-09-S2 — emitted once the resumable portal provisioning pipeline has + * finished every infrastructure step (org, Permit tenant, portal row, default + * subdomain) and attempted the owner invite — REGARDLESS of whether the + * invite dispatch itself succeeded (AC4: a failed invite still leaves the + * portal `provisioned-pending-invite` and still emits this event, so a + * downstream consumer can retry the notification independently of the + * synchronous create-portal HTTP response). + */ +export declare const portalCreatedSchemaV1: z.ZodObject<{ + portalId: z.ZodString; + slug: z.ZodString; + organizationId: z.ZodString; + ownerEmail: z.ZodString; + status: z.ZodEnum<["provisioning", "provisioned-pending-invite", "active", "suspended"]>; +}, "strip", z.ZodTypeAny, { + slug: string; + status: "provisioning" | "provisioned-pending-invite" | "active" | "suspended"; + organizationId: string; + portalId: string; + ownerEmail: string; +}, { + slug: string; + status: "provisioning" | "provisioned-pending-invite" | "active" | "suspended"; + organizationId: string; + portalId: string; + ownerEmail: string; +}>; +export type PortalCreatedPayloadV1 = z.infer; diff --git a/shared/dist/kafka/schemas/portal.created.js b/shared/dist/kafka/schemas/portal.created.js new file mode 100644 index 00000000..d1187d7a --- /dev/null +++ b/shared/dist/kafka/schemas/portal.created.js @@ -0,0 +1,20 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.portalCreatedSchemaV1 = void 0; +const zod_1 = require("zod"); +/** + * FF-EPIC-09-S2 — emitted once the resumable portal provisioning pipeline has + * finished every infrastructure step (org, Permit tenant, portal row, default + * subdomain) and attempted the owner invite — REGARDLESS of whether the + * invite dispatch itself succeeded (AC4: a failed invite still leaves the + * portal `provisioned-pending-invite` and still emits this event, so a + * downstream consumer can retry the notification independently of the + * synchronous create-portal HTTP response). + */ +exports.portalCreatedSchemaV1 = zod_1.z.object({ + portalId: zod_1.z.string(), + slug: zod_1.z.string(), + organizationId: zod_1.z.string().uuid(), + ownerEmail: zod_1.z.string().email(), + status: zod_1.z.enum(['provisioning', 'provisioned-pending-invite', 'active', 'suspended']), +}); diff --git a/shared/dist/kafka/types.d.ts b/shared/dist/kafka/types.d.ts index 4b65c65b..506b9dd5 100644 --- a/shared/dist/kafka/types.d.ts +++ b/shared/dist/kafka/types.d.ts @@ -12,6 +12,7 @@ export declare const TOPICS: { readonly BILLING_PAYMENT_COMPLETED: "billing.payment.completed"; readonly BILLING_TRIAL_ENDING: "billing.trial.ending"; readonly BILLING_PAYMENT_FAILED: "billing.payment.failed"; + readonly PORTAL_CREATED: "portal.created"; }; export type TopicName = (typeof TOPICS)[keyof typeof TOPICS]; /** Envelope wrapping every event published on FuzeFront Kafka topics */ diff --git a/shared/dist/kafka/types.js b/shared/dist/kafka/types.js index 29fe0c03..fd99829f 100644 --- a/shared/dist/kafka/types.js +++ b/shared/dist/kafka/types.js @@ -16,6 +16,7 @@ exports.TOPICS = { BILLING_PAYMENT_COMPLETED: 'billing.payment.completed', BILLING_TRIAL_ENDING: 'billing.trial.ending', BILLING_PAYMENT_FAILED: 'billing.payment.failed', + PORTAL_CREATED: 'portal.created', }; /** Returns the dead-letter queue topic name for a given topic */ function dlqTopic(topic) { diff --git a/shared/src/kafka/schemas/index.ts b/shared/src/kafka/schemas/index.ts index 8de9a571..f8a793d8 100644 --- a/shared/src/kafka/schemas/index.ts +++ b/shared/src/kafka/schemas/index.ts @@ -11,3 +11,4 @@ export * from './identity.session.issued'; export * from './identity.session.revoked'; export * from './notify.email.requested'; export * from './notify.email.status'; +export * from './portal.created'; diff --git a/shared/src/kafka/schemas/portal.created.ts b/shared/src/kafka/schemas/portal.created.ts new file mode 100644 index 00000000..5f10103f --- /dev/null +++ b/shared/src/kafka/schemas/portal.created.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +/** + * FF-EPIC-09-S2 — emitted once the resumable portal provisioning pipeline has + * finished every infrastructure step (org, Permit tenant, portal row, default + * subdomain) and attempted the owner invite — REGARDLESS of whether the + * invite dispatch itself succeeded (AC4: a failed invite still leaves the + * portal `provisioned-pending-invite` and still emits this event, so a + * downstream consumer can retry the notification independently of the + * synchronous create-portal HTTP response). + */ +export const portalCreatedSchemaV1 = z.object({ + portalId: z.string(), + slug: z.string(), + organizationId: z.string().uuid(), + ownerEmail: z.string().email(), + status: z.enum(['provisioning', 'provisioned-pending-invite', 'active', 'suspended']), +}); + +export type PortalCreatedPayloadV1 = z.infer; diff --git a/shared/src/kafka/types.ts b/shared/src/kafka/types.ts index 19194a48..be328115 100644 --- a/shared/src/kafka/types.ts +++ b/shared/src/kafka/types.ts @@ -12,6 +12,7 @@ export const TOPICS = { BILLING_PAYMENT_COMPLETED: 'billing.payment.completed', BILLING_TRIAL_ENDING: 'billing.trial.ending', BILLING_PAYMENT_FAILED: 'billing.payment.failed', + PORTAL_CREATED: 'portal.created', } as const; export type TopicName = (typeof TOPICS)[keyof typeof TOPICS];