2026 03 18 Domain Registry Design

Documentation for Plans

Domain Registry & Subdomain Management — Design Spec

Date: 2026-03-18 Session: S234.4 Status: Approved Branch: session-234-235


1. Problem Statement

Four bugs exist in the current deployment pipeline:

  1. CF project takeover (security): Two spaces with same universe slug → second silently overwrites first's CF project via 409 fallback in cloudflare-pages.service.ts:209-211. Affects both deployment.service.ts:1530 (universe-{subdomain} naming) and build-pipeline.service.ts:669 (direct universeSlug naming) — two independent deployment paths with different CF project naming conventions.
  2. Subdomain collision: No uniqueness check → two universes claim same *.gograb.space
  3. Reserved subdomain collision: Users can deploy to api.gograb.space, admin.gograb.space, etc.
  4. No release on undeploy: Subdomains never freed — no registry to mark availability

2. Design Decisions

Decision Rationale
Flat subdomain registry (deployed_subdomains collection) Single source of truth. No host/path/fullKey complexity.
First-come-first-served subdomains Brand-friendly. {universeSlug} is default — short, clean.
CF project names = {spaceSlug}-{universeSlug} Deterministic, collision-free, invisible to users.
usesOwnDomain flag When true, auto-assign throwaway internal subdomain.
Space-level deployment.mode config independent_subdomains (now) or space_branded (future).
Atomic claim via findOneAndUpdate No TOCTOU race. Partial unique index enforces uniqueness.
Reserved subdomain blocklist Static set of platform infrastructure names.
Soft-delete on release status: 'released' + releasedAt. Audit trail preserved.

3. Schema

3.1 New Collection: deployed_subdomains (in gograb-api database)

@Schema({ collection: 'deployed_subdomains', timestamps: true })
export class DeployedSubdomain {
  @Prop({ required: true })
  subdomain: string;  // "acme" or "s-67890abcd1234567" (internal, 16-char prefix)

  @Prop({ required: true, index: true })
  spaceId: string;

  @Prop({ required: true, index: true })
  universeId: string;

  @Prop({ required: true })
  cfProjectName: string;  // "acme-portal" (deterministic, invisible)

  @Prop({ type: String, enum: ['production', 'staging'], default: 'production' })
  environment: string;

  @Prop()
  customDomain?: string;  // "app.mybrand.com"

  @Prop({ default: false })
  usesOwnDomain: boolean;

  @Prop({ type: String, enum: ['active', 'released'], default: 'active' })
  status: string;

  @Prop()
  releasedAt?: Date;

  // timestamps: true adds createdAt, updatedAt
}

Indexes:

3.2 Space Schema Addition

In APIs/backend-api/src/app/spaces/spaces.schema.ts, add to existing schema:

@Prop({
  type: {
    mode: { type: String, enum: ['independent_subdomains', 'space_branded'], default: 'independent_subdomains' },
    defaultUniverseId: { type: String },
  },
  _id: false,
})
deployment?: {
  mode: 'independent_subdomains' | 'space_branded';
  defaultUniverseId?: string;
};

3.3 Frontend Type Updates

In libs/libs-foundational/core-models/src/lib/types/space.types.ts, extend the existing SpaceDeploymentSettings interface (do NOT create a separate deployment field — there's already a deploymentSettings?: SpaceDeploymentSettings at line 112):

export interface SpaceDeploymentSettings {
  autoSubdomainGeneration: boolean;
  allowCustomDomains: boolean;
  maxPublishedUniverses?: number;
  // NEW fields:
  mode: 'independent_subdomains' | 'space_branded';
  defaultUniverseId?: string;
  // existing SEO fields remain
  defaultSEO?: { ... };
}

Note: tenantId in existing deploy code maps 1:1 to spaceId. To obtain spaceSlug from tenantId, use SpaceDatabaseService.resolveSpaceDatabaseName() or query the spaces collection directly.

New types file for domain registry:

export interface DeployedSubdomain {
  _id: string;
  subdomain: string;
  spaceId: string;
  universeId: string;
  cfProjectName: string;
  environment: 'production' | 'staging';
  customDomain?: string;
  usesOwnDomain: boolean;
  status: 'active' | 'released';
  releasedAt?: string;
  createdAt: string;
  updatedAt: string;
}

export interface SubdomainCheckResult {
  available: boolean;
  subdomain: string;
  // Note: Do NOT expose owner spaceId/universeId to public API — information leak.
  // Only return availability + suggestions. Owner info is internal/admin only.
}

export interface SubdomainSuggestions {
  suggestions: string[];
  requested: string;
  available: boolean;
}

export interface SubdomainClaimResult {
  success: boolean;
  subdomain?: string;
  url?: string;
  reason?: 'taken' | 'reserved' | 'invalid';
  suggestions?: string[];  // Offer alternatives on failure
}

export interface SubdomainUpdateParams {
  newSubdomain?: string;
  customDomain?: string | null;
}

4. Backend Service: DeployedSubdomainsService

4.1 Location

New module: APIs/backend-api/src/app/deployed-subdomains/

4.2 Methods

claimSubdomain(params: {
  subdomain: string;
  spaceId: string;
  universeId: string;
  cfProjectName: string;
  environment: string;
  customDomain?: string;
  usesOwnDomain?: boolean;
}): Promise<{ success: boolean; conflict?: DeployedSubdomain; reason?: string }>

releaseSubdomain(universeId: string, environment: string): Promise<void>

checkAvailability(subdomain: string, environment?: string): Promise<{
  available: boolean;
  owner?: { spaceId: string; universeId: string };
}>

suggest(spaceSlug: string, universeSlug: string, environment?: string): Promise<string[]>

updateDomain(universeId: string, params: {
  newSubdomain?: string;
  customDomain?: string | null;
}): Promise<{ success: boolean; url: string }>

getBySpace(spaceId: string): Promise<DeployedSubdomain[]>

getByUniverse(universeId: string): Promise<DeployedSubdomain | null>

4.3 Reserved Subdomains

const RESERVED_SUBDOMAINS = new Set([
  // Platform infrastructure
  'api', 'ai-api', 'docs-api', 'vitto-api',
  'app', 'admin', 'dev', 'ai', 'docs', 'pulse',
  'space-admin', 'vitto-updater',
  // Common/generic
  'www', 'mail', 'ftp', 'ssh', 'ns1', 'ns2',
  'staging', 'preview', 'test', 'demo',
  'dashboard', 'console', 'portal', 'login',
  'status', 'health', 'cdn', 'assets', 'static',
]);

4.4 Atomic Claim Logic

Uses findOneAndUpdate with $setOnInsert + upsert. If document already exists for that subdomain+environment with status: 'active', checks ownership. If owned by same space, returns success (idempotent). If owned by different space, returns conflict.

5. API Endpoints

5.1 Public (Read-Only)

Method Path Purpose
GET /api/deployed-subdomains/check ?subdomain=X&environment=production → availability
GET /api/deployed-subdomains/suggest ?spaceSlug=X&universeSlug=Y → ranked suggestions
GET /api/deployed-subdomains/space/:spaceId List active entries for space
GET /api/deployed-subdomains/universe/:universeId Get active entry for universe

5.2 Protected (Auth Required)

Method Path Purpose
PATCH /api/deployed-subdomains/universe/:universeId Update subdomain or custom domain

5.3 Internal (Called by deployment.service.ts, not exposed as REST)

6. Deploy Flow Changes

6.1 deployment.service.ts — deployUniverse()

Before (current lines 74-149):

1. Fetch universe data
2. subdomain = config.subdomain || generateSubdomain(universeData.slug)
3. url = generateUrl(subdomain)
4. Create deployment record
5. performDeployment (async)

After:

1. Fetch universe data + space data
2. Determine subdomain:
   - usesOwnDomain (config.customDomain present)? → "s-{universeId.substring(0,16)}"
   - config.subdomain? → use override
   - default → universeData.slug
3. cfProjectName = generateCfProjectName(space.spaceSlug, universeData.slug)
4. Atomic claim in registry → fails fast with ConflictException if taken
5. url = usesOwnDomain ? customDomain URL : gograb.space URL
6. Create deployment record
7. performDeployment (async, uses cfProjectName not subdomain for CF project)

6.2 deployment.service.ts — uploadToCloudflare()

Change (line 1530):

// BEFORE:
const projectName = `universe-${subdomain}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');

// AFTER:
// projectName passed from deployUniverse(), derived from space+universe not subdomain

6.3 deployment.service.ts — undeployUniverse()

Change: Instead of deriving projectName locally (line 444: universe-${subdomain}), look up the actual CF project name from the registry first:

// Look up actual CF project name from registry (handles old + new naming)
const registryEntry = await this.deployedSubdomainsService.getByUniverse(universeId);
const projectName = registryEntry?.cfProjectName
  || `universe-${subdomain}`.toLowerCase().replace(/[^a-z0-9-]/g, '-'); // fallback for pre-migration

// ... existing CF cleanup using projectName ...

// Release subdomain after CF cleanup
await this.deployedSubdomainsService.releaseSubdomain(universeId, 'production');

6.4 build-pipeline.service.ts — Second Deployment Path

Critical: This is a separate deployment path with its own CF project naming at line 669 (getCloudflareProjectName() uses bare universeSlug). Must also be wired to the registry:

6.5 CF Project 409 Handling

Change cloudflare-pages.service.ts (lines 209-211): The 409 handler is now safe because project names are space-scoped ({spaceSlug}-{universeSlug}). A 409 means re-deploying the same universe (correct behavior). No cross-tenant collision possible.

7. Frontend Changes

7.1 Types

File: libs/libs-foundational/core-models/src/lib/types/space.types.ts

New file: libs/libs-foundational/core-models/src/lib/interfaces/deployed-subdomain.types.ts

7.2 Services

File: libs/libs-foundational/core-angular/src/lib/services/space-api.service.ts Add methods:

File: libs/libs-foundational/core-angular/src/lib/services/universe-deployment.service.ts Add:

File: libs/libs-structural/launch-station-lib/src/lib/services/deployment.service.ts

7.3 Components

Launch Dashboard (libs/libs-structural/launch-station-lib/src/lib/components/launch-dashboard/launch-dashboard.component.ts)

Space Info Component (apps/gograb-space/src/app/components/space-components/space-info/space-info.component.ts)

Universe Settings Modal (libs/libs-space/universe-canvas/src/lib/components/universe-settings-modal/universe-settings-modal.component.ts)

Deployment Modal (Success) (libs/libs-space/universe-canvas/src/lib/components/deployment-modal/deployment-modal.component.ts)

Universe Creation Wizard (apps/gograb-space/src/app/components/features/universe-creation/universe-creation-wizard.component.ts)

G Space Dashboard (apps/gograb-space/src/app/components/space-components/g-space-dashboard/g-space-dashboard.component.ts)

Deployment History (libs/libs-structural/launch-station-lib/src/lib/components/deployment-history/deployment-history.component.ts)

8. Migration

8.1 Backfill Script

Location: tools/scripts/database/backfill-deployed-subdomains.ts

Logic:

  1. Query deployments collection: { isCurrent: true, status: 'ready' }
  2. For each, parse subdomain from url field
  3. Look up space to get spaceSlug
  4. Insert into deployed_subdomains with status: 'active'
  5. Log conflicts (two deployments claiming same subdomain = existing bug)
  6. Report: migrated count, conflict count, conflict details

8.2 Existing CF Project Names

Existing CF projects keep their old names (universe-{subdomain}). The registry records the actual CF project name used. Only new deployments use the new naming scheme ({spaceSlug}-{universeSlug}).

9. Implementation Phases

Phase What Effort
1 Backend: Schema + indexes + DeployedSubdomainsService + module ~1 day
2 Backend: Backfill migration script ~0.5 day
3 Backend: Wire into deployment.service.ts AND build-pipeline.service.ts (claim, release, CF naming) ~1 day
4 Backend: API endpoints (check, suggest, list, update) ~0.5 day
5 Backend: Reserved blocklist + integration tests ~0.5 day
6 Frontend: Types + service methods in SpaceApiService & UniverseDeploymentService ~0.5 day
7 Frontend: Launch Dashboard — availability check, suggestions, usesOwnDomain toggle ~1 day
8 Frontend: Universe Creation Wizard — subdomain preview + availability ~0.5 day
9 Frontend: Space Info, Universe Settings, Deployment Modal, Dashboard cards ~1 day
10 Future: Space-branded path routing (CF Worker + base href parameterization) ~2-3 days
11 Future: Custom domain ownership verification (DNS TXT + validation) ~1-2 days

10. Files to Create

File Purpose
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.module.ts NestJS module
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.schema.ts Mongoose schema
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.service.ts Core service
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.controller.ts REST endpoints
APIs/backend-api/src/app/deployed-subdomains/constants/reserved-subdomains.ts Blocklist
APIs/backend-api/src/app/deployed-subdomains/dto/deployed-subdomains.dto.ts Request/response DTOs
libs/libs-foundational/core-models/src/lib/interfaces/deployed-subdomain.types.ts Frontend types
tools/scripts/database/backfill-deployed-subdomains.ts Migration script

11. Files to Modify

File Change
APIs/backend-api/src/app/spaces/spaces.schema.ts Add deployment field
APIs/backend-api/src/app/deployment/deployment.service.ts Wire registry (claim/release/naming)
APIs/backend-api/src/app/build-pipeline/build-pipeline.service.ts Wire registry (second deploy path)
APIs/backend-api/src/app/app.module.ts Import DeployedSubdomainsModule
libs/libs-foundational/core-models/src/lib/types/space.types.ts Extend SpaceDeploymentSettings with mode + defaultUniverseId
libs/libs-foundational/core-models/src/lib/interfaces/index.ts Add barrel export for new types
libs/libs-foundational/core-angular/src/lib/services/space-api.service.ts Add subdomain methods
libs/libs-foundational/core-angular/src/lib/services/universe-deployment.service.ts Add subdomain check/suggest
libs/libs-structural/launch-station-lib/src/lib/services/deployment.service.ts Wire availability check
libs/libs-structural/launch-station-lib/src/lib/components/launch-dashboard/launch-dashboard.component.ts Availability UI
libs/libs-structural/launch-station-lib/src/lib/components/launch-dashboard/launch-dashboard.component.html Template updates
apps/gograb-space/src/app/components/features/universe-creation/universe-creation-wizard.component.ts Subdomain preview
apps/gograb-space/src/app/components/space-components/space-info/space-info.component.ts Deployment mode display
libs/libs-space/universe-canvas/src/lib/components/universe-settings-modal/universe-settings-modal.component.ts Subdomain edit
libs/libs-space/universe-canvas/src/lib/components/deployment-modal/deployment-modal.component.ts Correct URL display
apps/gograb-space/src/app/components/space-components/g-space-dashboard/g-space-dashboard.component.ts URL on cards

12. Explicitly Deferred