2026 03 18 Domain Registry

Documentation for Plans

Domain Registry Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Fix four deployment bugs (CF project takeover, subdomain collision, reserved name collision, no release on undeploy) by introducing a deployed_subdomains registry with atomic claims, and wire it through the entire deploy/undeploy/UI pipeline.

Architecture: New DeployedSubdomainsModule in backend-api with Mongoose schema, service (atomic claim/release/check/suggest/update), and controller. Wired into both deployment.service.ts and build-pipeline.service.ts. Frontend types, API service methods, and UI components updated to show availability checking, suggestions, and domain editing.

Tech Stack: NestJS, Mongoose, Angular 20+ (signals, standalone components, OnPush), RxJS

Spec: docs/superpowers/specs/2026-03-18-domain-registry-design.md


File Structure

New Files

File Responsibility
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.module.ts NestJS module registration
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.schema.ts Mongoose schema + indexes
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.service.ts Core logic: claim, release, check, suggest, update
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.controller.ts REST endpoints
APIs/backend-api/src/app/deployed-subdomains/dto/deployed-subdomains.dto.ts Request/response DTOs
APIs/backend-api/src/app/deployed-subdomains/constants/reserved-subdomains.ts Static blocklist
libs/libs-foundational/core-models/src/lib/interfaces/deployed-subdomain.types.ts Frontend types
tools/scripts/database/backfill-deployed-subdomains.ts One-time migration script

Modified Files

File What Changes
APIs/backend-api/src/app/app.module.ts Import DeployedSubdomainsModule
APIs/backend-api/src/app/spaces/spaces.schema.ts Add deployment prop
APIs/backend-api/src/app/deployment/deployment.service.ts Wire registry into deploy/undeploy
APIs/backend-api/src/app/build-pipeline/build-pipeline.service.ts Wire registry into second deploy path
libs/libs-foundational/core-models/src/lib/types/space.types.ts Extend SpaceDeploymentSettings
libs/libs-foundational/core-models/src/lib/interfaces/index.ts Barrel export
libs/libs-foundational/core-angular/src/lib/services/space-api.service.ts Add subdomain API 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 + usesOwnDomain
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
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
apps/gograb-space/src/app/components/space-components/g-space-dashboard/g-space-dashboard.component.ts URL on cards

Task 1: Backend — Schema, Constants & Module Shell

Files:

// APIs/backend-api/src/app/deployed-subdomains/constants/reserved-subdomains.ts
export 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',
]);
// APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Types } from 'mongoose';

export type DeployedSubdomainDocument = DeployedSubdomain & Document;

@Schema({ collection: 'deployed_subdomains', timestamps: true })
export class DeployedSubdomain {
  _id?: Types.ObjectId;

  @Prop({ required: true })
  subdomain: string;

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

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

  @Prop({ required: true })
  cfProjectName: string;

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

  @Prop()
  customDomain?: string;

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

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

  @Prop()
  releasedAt?: Date;

  createdAt?: Date;
  updatedAt?: Date;
}

export const DeployedSubdomainSchema = SchemaFactory.createForClass(DeployedSubdomain);

// Unique active subdomain per environment
DeployedSubdomainSchema.index(
  { subdomain: 1, environment: 1 },
  { unique: true, partialFilterExpression: { status: 'active' } },
);

// Unique active custom domain per environment
DeployedSubdomainSchema.index(
  { customDomain: 1, environment: 1 },
  {
    unique: true,
    partialFilterExpression: {
      status: 'active',
      customDomain: { $exists: true, $ne: null },
    },
  },
);

// Lookup indexes
DeployedSubdomainSchema.index({ spaceId: 1, status: 1 });
DeployedSubdomainSchema.index({ universeId: 1, status: 1 });
// APIs/backend-api/src/app/deployed-subdomains/dto/deployed-subdomains.dto.ts
import { IsString, IsOptional, IsBoolean, Matches, MaxLength, MinLength, IsEnum } from 'class-validator';

export class CheckSubdomainDto {
  @IsString()
  @MinLength(1)
  @MaxLength(63)
  @Matches(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, {
    message: 'Subdomain must contain only lowercase letters, numbers, and hyphens',
  })
  subdomain: string;

  @IsOptional()
  @IsEnum(['production', 'staging'])
  environment?: string;
}

export class SuggestSubdomainsDto {
  @IsString()
  spaceSlug: string;

  @IsString()
  universeSlug: string;

  @IsOptional()
  @IsEnum(['production', 'staging'])
  environment?: string;
}

export class UpdateDomainDto {
  @IsOptional()
  @IsString()
  @MinLength(1)
  @MaxLength(63)
  @Matches(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, {
    message: 'Subdomain must contain only lowercase letters, numbers, and hyphens',
  })
  newSubdomain?: string;

  @IsOptional()
  @IsString()
  @MaxLength(253)
  customDomain?: string | null;
}
// APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { DeployedSubdomain, DeployedSubdomainSchema } from './deployed-subdomains.schema';
import { DeployedSubdomainsService } from './deployed-subdomains.service';
import { DeployedSubdomainsController } from './deployed-subdomains.controller';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: DeployedSubdomain.name, schema: DeployedSubdomainSchema },
    ]),
  ],
  controllers: [DeployedSubdomainsController],
  providers: [DeployedSubdomainsService],
  exports: [DeployedSubdomainsService],
})
export class DeployedSubdomainsModule {}

Add import and registration in APIs/backend-api/src/app/app.module.ts.

git add APIs/backend-api/src/app/deployed-subdomains/
git commit -m "[S234.4] feat: add deployed-subdomains schema, constants, DTOs and module shell"

Task 2: Backend — DeployedSubdomainsService (Core Logic)

Files:

// APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.service.ts
import { Injectable, Logger, ConflictException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { DeployedSubdomain, DeployedSubdomainDocument } from './deployed-subdomains.schema';
import { RESERVED_SUBDOMAINS } from './constants/reserved-subdomains';

@Injectable()
export class DeployedSubdomainsService {
  private readonly logger = new Logger(DeployedSubdomainsService.name);

  constructor(
    @InjectModel(DeployedSubdomain.name)
    private readonly model: Model<DeployedSubdomainDocument>,
  ) {}

  async claimSubdomain(params: {
    subdomain: string;
    spaceId: string;
    universeId: string;
    cfProjectName: string;
    environment: string;
    customDomain?: string;
    usesOwnDomain?: boolean;
  }): Promise<{ success: boolean; reason?: string; suggestions?: string[] }> {
    const { subdomain, spaceId, universeId, cfProjectName, environment } = params;

    // Validate format
    if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) {
      return { success: false, reason: 'invalid' };
    }

    // Check reserved (skip for internal subdomains)
    if (!params.usesOwnDomain && RESERVED_SUBDOMAINS.has(subdomain)) {
      return { success: false, reason: 'reserved' };
    }

    try {
      // Atomic upsert — only inserts if no active entry exists
      await this.model.findOneAndUpdate(
        { subdomain, environment, status: 'active' },
        {
          $setOnInsert: {
            subdomain,
            spaceId,
            universeId,
            cfProjectName,
            environment,
            customDomain: params.customDomain || null,
            usesOwnDomain: params.usesOwnDomain || false,
            status: 'active',
          },
        },
        { upsert: true, new: true },
      );

      // Verify we own it (handles race where another request inserted first)
      const record = await this.model.findOne({ subdomain, environment, status: 'active' }).lean();
      if (record && record.universeId !== universeId) {
        return { success: false, reason: 'taken' };
      }

      this.logger.log(`Claimed subdomain: ${subdomain} for universe ${universeId}`);
      return { success: true };
    } catch (err: any) {
      if (err.code === 11000) {
        // Duplicate key — someone else claimed it
        return { success: false, reason: 'taken' };
      }
      throw err;
    }
  }
  async releaseSubdomain(universeId: string, environment: string): Promise<void> {
    const result = await this.model.updateMany(
      { universeId, environment, status: 'active' },
      { $set: { status: 'released', releasedAt: new Date() } },
    );
    if (result.modifiedCount > 0) {
      this.logger.log(`Released ${result.modifiedCount} subdomain(s) for universe ${universeId}`);
    }
  }
  async checkAvailability(
    subdomain: string,
    environment = 'production',
  ): Promise<{ available: boolean }> {
    if (RESERVED_SUBDOMAINS.has(subdomain)) {
      return { available: false };
    }
    const existing = await this.model.findOne({ subdomain, environment, status: 'active' }).lean();
    return { available: !existing };
  }
  async suggest(
    spaceSlug: string,
    universeSlug: string,
    environment = 'production',
  ): Promise<string[]> {
    const sanitize = (s: string) =>
      s.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 63);

    const base = sanitize(universeSlug);
    const candidates = [
      base,
      sanitize(`${base}-${spaceSlug}`),
      sanitize(spaceSlug),
      sanitize(`my-${base}`),
      sanitize(`${base}-app`),
      sanitize(`${base}-${Date.now().toString(36).slice(-4)}`),
    ].filter((c, i, arr) => c.length >= 3 && arr.indexOf(c) === i); // dedupe + min length

    const taken = await this.model
      .find({ subdomain: { $in: candidates }, environment, status: 'active' })
      .select('subdomain')
      .lean();
    const takenSet = new Set(taken.map((t) => t.subdomain));

    return candidates.filter((c) => !takenSet.has(c) && !RESERVED_SUBDOMAINS.has(c));
  }
  async updateDomain(
    universeId: string,
    params: { newSubdomain?: string; customDomain?: string | null },
    environment = 'production',
  ): Promise<{ success: boolean; reason?: string }> {
    const current = await this.model.findOne({ universeId, environment, status: 'active' });
    if (!current) {
      return { success: false, reason: 'no_active_entry' };
    }

    if (params.newSubdomain && params.newSubdomain !== current.subdomain) {
      // Check reserved
      if (RESERVED_SUBDOMAINS.has(params.newSubdomain)) {
        return { success: false, reason: 'reserved' };
      }
      // Check availability
      const { available } = await this.checkAvailability(params.newSubdomain, environment);
      if (!available) {
        return { success: false, reason: 'taken' };
      }
      // Release old, claim new
      await this.model.updateOne(
        { _id: current._id },
        { $set: { status: 'released', releasedAt: new Date() } },
      );
      const claim = await this.claimSubdomain({
        subdomain: params.newSubdomain,
        spaceId: current.spaceId,
        universeId: current.universeId,
        cfProjectName: current.cfProjectName,
        environment,
        customDomain: params.customDomain !== undefined ? params.customDomain : current.customDomain,
        usesOwnDomain: current.usesOwnDomain,
      });
      return claim;
    }

    // Just updating custom domain
    if (params.customDomain !== undefined) {
      await this.model.updateOne(
        { _id: current._id },
        { $set: { customDomain: params.customDomain, usesOwnDomain: !!params.customDomain } },
      );
      return { success: true };
    }

    return { success: true };
  }
  async getBySpace(spaceId: string): Promise<DeployedSubdomain[]> {
    return this.model.find({ spaceId, status: 'active' }).lean();
  }

  async getByUniverse(universeId: string): Promise<DeployedSubdomain | null> {
    return this.model.findOne({ universeId, status: 'active' }).lean();
  }
}
git add APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.service.ts
git commit -m "[S234.4] feat: implement DeployedSubdomainsService with atomic claims"

Task 3: Backend — Controller (REST Endpoints)

Files:

// APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.controller.ts
import { Controller, Get, Patch, Param, Query, Body, UsePipes, ValidationPipe, UseGuards } from '@nestjs/common';
import { DeployedSubdomainsService } from './deployed-subdomains.service';
import { CheckSubdomainDto, SuggestSubdomainsDto, UpdateDomainDto } from './dto/deployed-subdomains.dto';
import { UnifiedAuthGuard } from '../auth/guards/unified-auth.guard';

@Controller('deployed-subdomains')
export class DeployedSubdomainsController {
  constructor(private readonly service: DeployedSubdomainsService) {}

  @Get('check')
  @UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
  async check(@Query() query: CheckSubdomainDto) {
    return this.service.checkAvailability(query.subdomain, query.environment);
  }

  @Get('suggest')
  @UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
  async suggest(@Query() query: SuggestSubdomainsDto) {
    return {
      suggestions: await this.service.suggest(query.spaceSlug, query.universeSlug, query.environment),
      requested: query.universeSlug,
    };
  }

  @Get('space/:spaceId')
  async getBySpace(@Param('spaceId') spaceId: string) {
    return this.service.getBySpace(spaceId);
  }

  @Get('universe/:universeId')
  async getByUniverse(@Param('universeId') universeId: string) {
    return this.service.getByUniverse(universeId);
  }

  @Patch('universe/:universeId')
  @UseGuards(UnifiedAuthGuard)
  @UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
  async updateDomain(
    @Param('universeId') universeId: string,
    @Body() body: UpdateDomainDto,
  ) {
    return this.service.updateDomain(universeId, body);
  }
}
git add APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.controller.ts
git commit -m "[S234.4] feat: add DeployedSubdomains REST controller"

Task 4: Backend — Wire Registry into deployment.service.ts

Files:

In APIs/backend-api/src/app/spaces/spaces.schema.ts, add after the databaseFormat prop (around line 157):

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

First, add DeployedSubdomainsModule to the imports array in APIs/backend-api/src/app/deployment/deployment.module.ts so the service is available for injection.

Then add to deployment.service.ts constructor (after cloudflarePagesService):

private readonly deployedSubdomainsService: DeployedSubdomainsService,

Add import at top:

import { DeployedSubdomainsService } from '../deployed-subdomains/deployed-subdomains.service';

Add after generateUrl() method (~line 617):

  private generateCfProjectName(spaceSlug: string, universeSlug: string): string {
    return `${spaceSlug}-${universeSlug}`
      .toLowerCase()
      .replace(/[^a-z0-9-]/g, '-')
      .replace(/-+/g, '-')
      .replace(/^-|-$/g, '')
      .substring(0, 58);
  }

Replace the subdomain/URL generation block (lines ~85-87) with:

    // 2. Resolve space for slug
    const space = await this.spaceDatabaseService.getSpaceById(tenantId);
    const spaceSlug = space?.spaceSlug || tenantId;

    // 3. Determine subdomain
    const usesOwnDomain = !!config.customDomain;
    const subdomain = usesOwnDomain
      ? `s-${universeId.substring(0, 16)}`
      : (config.subdomain || this.generateSubdomain(universeData.slug));

    // 4. Deterministic CF project name
    const cfProjectName = this.generateCfProjectName(spaceSlug, universeData.slug);

    // 5. Atomic claim — fails fast if taken
    const claim = await this.deployedSubdomainsService.claimSubdomain({
      subdomain,
      spaceId: tenantId,
      universeId,
      cfProjectName,
      environment: config.environment || 'production',
      customDomain: config.customDomain,
      usesOwnDomain,
    });

    if (!claim.success) {
      throw new ConflictException(
        `Subdomain "${subdomain}.${this.domainSuffix}" is not available (${claim.reason}). ` +
        `Use GET /api/deployed-subdomains/suggest for alternatives.`,
      );
    }

    const url = usesOwnDomain
      ? `https://${config.customDomain}`
      : this.generateUrl(subdomain);

Change the method signature to accept cfProjectName and remove the local project naming derivation at line 1530. Pass cfProjectName from deployUniverse() through performDeployment() down to uploadToCloudflare().

Replace the local projectName derivation (line ~444):

    // Look up actual CF project name from registry
    const registryEntry = await this.deployedSubdomainsService.getByUniverse(universeId);
    const subdomain = currentDeployment.universeSlug || universeId;
    const projectName = registryEntry?.cfProjectName
      || `universe-${subdomain}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');

Add at end of undeploy (before final log):

    // Release subdomain
    await this.deployedSubdomainsService.releaseSubdomain(universeId, 'production');
git add APIs/backend-api/src/app/deployment/deployment.service.ts APIs/backend-api/src/app/spaces/spaces.schema.ts
git commit -m "[S234.4] feat: wire domain registry into deployment service"

Task 5: Backend — Wire Registry into build-pipeline.service.ts

Files:

Replace getCloudflareProjectName(deployment.universeSlug) with registry-based lookup. Before creating the CF project, call claimSubdomain(). Use the registry's cfProjectName.

Use subdomain from the registry entry instead of raw deployment.universeSlug.

git add APIs/backend-api/src/app/build-pipeline/
git commit -m "[S234.4] feat: wire domain registry into build-pipeline service"

Task 6: Backfill Migration Script

Files:

// tools/scripts/database/backfill-deployed-subdomains.ts
import { MongoClient } from 'mongodb';

const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017';
const DB_NAME = 'gograb-api';

async function backfill() {
  const client = new MongoClient(MONGODB_URI);
  await client.connect();
  const db = client.db(DB_NAME);
  const deployments = db.collection('deployments');
  const subdomains = db.collection('deployed_subdomains');
  const spaces = db.collection('spaces');

  const activeDeployments = await deployments
    .find({ isCurrent: true, status: 'ready' })
    .toArray();

  console.log(`Found ${activeDeployments.length} active deployments to backfill`);

  let migrated = 0;
  const conflicts: string[] = [];

  for (const dep of activeDeployments) {
    try {
      const url = new URL(dep.url);
      const subdomain = url.hostname.split('.')[0];
      const space = await spaces.findOne({ _id: dep.tenantId });
      // Preserve existing CF project name (old naming convention)
      const cfProjectName = `universe-${subdomain}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');

      const result = await subdomains.updateOne(
        { subdomain, environment: dep.environment || 'production', status: 'active' },
        {
          $setOnInsert: {
            subdomain,
            spaceId: dep.tenantId,
            universeId: dep.universeId,
            cfProjectName,
            environment: dep.environment || 'production',
            customDomain: dep.customDomain || null,
            usesOwnDomain: false,
            status: 'active',
            createdAt: new Date(),
            updatedAt: new Date(),
          },
        },
        { upsert: true },
      );

      if (result.upsertedCount > 0) {
        migrated++;
      } else {
        // Already existed — check if it's a conflict
        const existing = await subdomains.findOne({
          subdomain,
          environment: dep.environment || 'production',
          status: 'active',
        });
        if (existing && existing.universeId !== dep.universeId) {
          conflicts.push(
            `${subdomain}: deployment ${dep._id} (universe ${dep.universeId}) conflicts with existing entry (universe ${existing.universeId})`,
          );
        }
      }
    } catch (err) {
      console.error(`Error processing deployment ${dep._id}:`, err);
    }
  }

  console.log(`\nBackfill complete:`);
  console.log(`  Migrated: ${migrated}`);
  console.log(`  Conflicts: ${conflicts.length}`);
  if (conflicts.length > 0) {
    console.log(`\nConflicts (require manual resolution):`);
    conflicts.forEach((c) => console.log(`  - ${c}`));
  }

  await client.close();
}

backfill().catch(console.error);
git add tools/scripts/database/backfill-deployed-subdomains.ts
git commit -m "[S234.4] feat: add backfill migration script for deployed subdomains"

Task 7: Frontend — Types & Barrel Exports

Files:

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

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;
}

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

export interface SubdomainClaimResult {
  success: boolean;
  reason?: 'taken' | 'reserved' | 'invalid' | 'no_active_entry';
  suggestions?: string[];
}

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

Add to libs/libs-foundational/core-models/src/lib/interfaces/index.ts:

/**
 * Domain Registry - Session 234.4
 */
export * from './deployed-subdomain.types';

In libs/libs-foundational/core-models/src/lib/types/space.types.ts, add to the SpaceDeploymentSettings interface (around line 225):

  mode?: 'independent_subdomains' | 'space_branded';
  defaultUniverseId?: string;
git add libs/libs-foundational/core-models/
git commit -m "[S234.4] feat: add frontend types for domain registry"

Task 8: Frontend — SpaceApiService & UniverseDeploymentService Methods

Files:

Add these methods (follow existing pattern: inject(HttpClient), inject(API_BASE_URL)):

  checkSubdomainAvailability(subdomain: string, environment = 'production'): Observable<SubdomainCheckResult> {
    return this.http.get<SubdomainCheckResult>(
      `${this.baseUrl}/deployed-subdomains/check`,
      { params: { subdomain, environment } },
    );
  }

  suggestSubdomains(spaceSlug: string, universeSlug: string): Observable<SubdomainSuggestions> {
    return this.http.get<SubdomainSuggestions>(
      `${this.baseUrl}/deployed-subdomains/suggest`,
      { params: { spaceSlug, universeSlug } },
    );
  }

  getSpaceSubdomains(spaceId: string): Observable<DeployedSubdomain[]> {
    return this.http.get<DeployedSubdomain[]>(
      `${this.baseUrl}/deployed-subdomains/space/${spaceId}`,
    );
  }

  getUniverseSubdomain(universeId: string): Observable<DeployedSubdomain | null> {
    return this.http.get<DeployedSubdomain | null>(
      `${this.baseUrl}/deployed-subdomains/universe/${universeId}`,
    );
  }

  updateUniverseDomain(universeId: string, params: SubdomainUpdateParams): Observable<SubdomainClaimResult> {
    return this.http.patch<SubdomainClaimResult>(
      `${this.baseUrl}/deployed-subdomains/universe/${universeId}`,
      params,
    );
  }

Add convenience methods that delegate to SpaceApiService:

  private readonly spaceApi = inject(SpaceApiService);

  checkSubdomain(subdomain: string): Observable<SubdomainCheckResult> {
    return this.spaceApi.checkSubdomainAvailability(subdomain);
  }

  suggestSubdomains(spaceSlug: string, universeSlug: string): Observable<SubdomainSuggestions> {
    return this.spaceApi.suggestSubdomains(spaceSlug, universeSlug);
  }
git add libs/libs-foundational/core-angular/src/lib/services/space-api.service.ts libs/libs-foundational/core-angular/src/lib/services/universe-deployment.service.ts
git commit -m "[S234.4] feat: add subdomain API methods to frontend services"

Task 9: Frontend — Launch Station Deployment Service + Launch Dashboard

Files:

Inject SpaceApiService. Before calling the backend deploy endpoint in deployUniverse(), call checkSubdomainAvailability(). If not available, throw or return error with suggestions. This ensures the deploy button is gated on availability.

Add new signals:

  readonly subdomainAvailable = signal<boolean | null>(null); // null = not checked
  readonly subdomainSuggestions = signal<string[]>([]);
  readonly isCheckingSubdomain = signal(false);
  readonly usesOwnDomain = signal(false);
  readonly customDomainInput = signal('');

Add inject:

  private readonly spaceApi = inject(SpaceApiService);

Add debounced availability check method:

  private checkSubdomainAvailability$ = new Subject<string>();

  ngOnInit() {
    // ... existing init ...
    this.checkSubdomainAvailability$.pipe(
      debounceTime(400),
      distinctUntilChanged(),
      filter(s => s.length >= 3),
      switchMap(subdomain => {
        this.isCheckingSubdomain.set(true);
        return this.spaceApi.checkSubdomainAvailability(subdomain).pipe(
          catchError(() => of({ available: false })),
        );
      }),
    ).subscribe(result => {
      this.subdomainAvailable.set(result.available);
      this.isCheckingSubdomain.set(false);
      if (!result.available) {
        this.loadSuggestions();
      }
    });
  }

  onSubdomainInput(value: string): void {
    this.subdomainInput.set(value);
    this.subdomainAvailable.set(null);
    this.checkSubdomainAvailability$.next(value);
  }

  private loadSuggestions(): void {
    const universe = this.selectedUniverse();
    const space = this.spaceService.currentSpace();
    if (!universe || !space) return;
    this.spaceApi.suggestSubdomains(space.spaceSlug, universe.slug).subscribe(result => {
      this.subdomainSuggestions.set(result.suggestions);
    });
  }

  selectSuggestion(subdomain: string): void {
    this.subdomainInput.set(subdomain);
    this.onSubdomainInput(subdomain);
  }

  toggleUsesOwnDomain(): void {
    this.usesOwnDomain.update(v => !v);
    if (this.usesOwnDomain()) {
      this.subdomainAvailable.set(null);
      this.subdomainError.set(null);
    }
  }

In the subdomain section of the HTML template, add:

  readonly previewUrl = computed(() => {
    if (this.usesOwnDomain()) {
      const domain = this.customDomainInput();
      return domain ? `https://${domain}` : '';
    }
    const subdomain = this.subdomainInput();
    if (!subdomain) return '';
    return `https://${subdomain}.gograb.space`;
  });
git add libs/libs-structural/launch-station-lib/src/lib/components/launch-dashboard/
git commit -m "[S234.4] feat: add subdomain availability check and usesOwnDomain to launch dashboard"

Task 10: Frontend — Universe Creation Wizard (Subdomain Preview)

Files:

Add signals:

  readonly subdomainPreview = computed(() => {
    const name = this.universeName();
    if (!name) return '';
    const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
    return slug ? `${slug}.gograb.space` : '';
  });
  readonly subdomainAvailable = signal<boolean | null>(null);

Wire availability check on name input (debounced, same pattern as launch dashboard).

Add to the template: a small preview line under the universe name input showing "Your platform URL: {slug}.gograb.space" with availability indicator.

git add apps/gograb-space/src/app/components/features/universe-creation/
git commit -m "[S234.4] feat: add subdomain preview to universe creation wizard"

Task 11: Frontend — Space Info, Universe Settings, Deployment Modal, Dashboard, History

Files:

Inject SpaceApiService. Add a signal for space subdomains. Load on init. Display the list of active subdomains for the space and the current deployment mode.

Inject SpaceApiService. Load universe's current subdomain on modal open via getUniverseSubdomain(). Display current subdomain + custom domain. Add "Change subdomain" button that opens an inline editor with availability check (reuse pattern from launch dashboard).

When showing deployment success, check if usesOwnDomain — display custom domain URL if true, else display gograb.space URL. Read from registry via getUniverseSubdomain() instead of relying on the deployment record's url field.

In the universe card rendering, load subdomain data per universe. Show a small badge/link with the deployed URL. Use getSpaceSubdomains() once to batch-load all subdomains for the space, then match by universeId.

In libs/libs-structural/launch-station-lib/src/lib/components/deployment-history/deployment-history.component.ts, enrich each deployment entry with its registry data. Either join with getSpaceSubdomains() data or add the subdomain/custom domain to the deployment list display.

git add apps/gograb-space/src/app/components/space-components/ libs/libs-space/universe-canvas/src/lib/components/ libs/libs-structural/launch-station-lib/src/lib/components/deployment-history/
git commit -m "[S234.4] feat: wire domain registry into space info, universe settings, deployment modal, dashboard, and history"

Task 12: Final Integration Verification

npx nx build backend-api --skip-nx-cache
npx nx build gograb-space --skip-nx-cache
  1. Start backend: yarn serve:backend
  2. Test availability check: curl http://localhost:3000/api/deployed-subdomains/check?subdomain=test
  3. Test suggest: curl http://localhost:3000/api/deployed-subdomains/suggest?spaceSlug=myspace&universeSlug=portal
  4. Start frontend: yarn serve:space
  5. Navigate to Launch Dashboard → verify subdomain input shows availability
  6. Navigate to Universe Creation → verify subdomain preview
  7. Navigate to Space Info → verify deployment mode display
git add -p  # Stage specifically, review each hunk
git commit -m "[S234.4] feat: domain registry integration complete"