Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions backend/src/migrations/017_portal_provisioning.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await knex.schema.dropTableIfExists('portal_provisioning')
await knex.raw('DROP TYPE IF EXISTS portal_provisioning_step_enum')
}
15 changes: 14 additions & 1 deletion backend/src/repositories/portalRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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(),
}
}
Expand Down
Loading
Loading