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:
Create:
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.schema.tsCreate:
APIs/backend-api/src/app/deployed-subdomains/constants/reserved-subdomains.tsCreate:
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.module.tsCreate:
APIs/backend-api/src/app/deployed-subdomains/dto/deployed-subdomains.dto.tsModify:
APIs/backend-api/src/app/app.module.tsStep 1: Create reserved subdomains constant
// 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',
]);
- Step 2: Create Mongoose schema with indexes
// 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 });
- Step 3: Create DTOs
// 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;
}
- Step 4: Create module shell
// 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 {}
- Step 5: Register module in app.module.ts
Add import and registration in APIs/backend-api/src/app/app.module.ts.
- Step 6: Commit
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:
Create:
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.service.tsStep 1: Implement claimSubdomain (atomic)
// 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;
}
}
- Step 2: Implement releaseSubdomain
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}`);
}
}
- Step 3: Implement checkAvailability
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 };
}
- Step 4: Implement suggest
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));
}
- Step 5: Implement updateDomain
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 };
}
- Step 6: Implement getBySpace and getByUniverse
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();
}
}
- Step 7: Commit
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:
Create:
APIs/backend-api/src/app/deployed-subdomains/deployed-subdomains.controller.tsStep 1: Create controller with all endpoints
// 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);
}
}
- Step 2: Commit
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:
Modify:
APIs/backend-api/src/app/deployment/deployment.service.tsModify:
APIs/backend-api/src/app/spaces/spaces.schema.tsStep 1: Add
deploymentprop to Space schema
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;
};
- Step 2: Import DeployedSubdomainsModule in deployment.module.ts and inject service
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';
- Step 3: Add helper methods for CF project naming
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);
}
- Step 4: Modify deployUniverse() to use registry
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);
- Step 5: Modify uploadToCloudflare() to accept cfProjectName as parameter
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().
- Step 6: Modify undeployUniverse() to use registry for project name lookup
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');
- Step 7: Commit
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:
Modify:
APIs/backend-api/src/app/build-pipeline/build-pipeline.service.tsModify:
APIs/backend-api/src/app/build-pipeline/build-pipeline.module.tsStep 1: Import DeployedSubdomainsModule in build-pipeline module
Step 2: Inject DeployedSubdomainsService in build-pipeline.service.ts constructor
Step 3: Modify deploy flow (around line 610)
Replace getCloudflareProjectName(deployment.universeSlug) with registry-based lookup. Before creating the CF project, call claimSubdomain(). Use the registry's cfProjectName.
- Step 4: Modify custom domain assignment (around line 636)
Use subdomain from the registry entry instead of raw deployment.universeSlug.
Step 5: Add registry release on undeploy path
Step 6: Commit
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:
Create:
tools/scripts/database/backfill-deployed-subdomains.tsStep 1: Write migration script
// 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);
- Step 2: Commit
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:
Create:
libs/libs-foundational/core-models/src/lib/interfaces/deployed-subdomain.types.tsModify:
libs/libs-foundational/core-models/src/lib/interfaces/index.tsModify:
libs/libs-foundational/core-models/src/lib/types/space.types.tsStep 1: Create frontend types
// 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;
}
- Step 2: Add barrel export
Add to libs/libs-foundational/core-models/src/lib/interfaces/index.ts:
/**
* Domain Registry - Session 234.4
*/
export * from './deployed-subdomain.types';
- Step 3: Extend SpaceDeploymentSettings
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;
- Step 4: Commit
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:
Modify:
libs/libs-foundational/core-angular/src/lib/services/space-api.service.tsModify:
libs/libs-foundational/core-angular/src/lib/services/universe-deployment.service.tsStep 1: Add subdomain methods to SpaceApiService
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,
);
}
- Step 2: Add subdomain methods to UniverseDeploymentService
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);
}
- Step 3: Commit
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:
Modify:
libs/libs-structural/launch-station-lib/src/lib/services/deployment.service.tsModify:
libs/libs-structural/launch-station-lib/src/lib/components/launch-dashboard/launch-dashboard.component.tsModify:
libs/libs-structural/launch-station-lib/src/lib/components/launch-dashboard/launch-dashboard.component.htmlStep 0: Wire availability check into launch-station deployment.service.ts
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.
- Step 1: Add signals and availability check to component TS
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);
}
}
- Step 2: Update template — add availability indicator and usesOwnDomain toggle
In the subdomain section of the HTML template, add:
A toggle switch for "I'll use my own domain"
When toggled on: show custom domain input, hide subdomain input
When toggled off: show subdomain input with availability indicator (green check / red X / spinner)
When subdomain is taken: show suggestion chips below the input
Update the preview URL display to show custom domain when
usesOwnDomainis trueStep 3: Update previewUrl computed signal
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`;
});
- Step 4: Commit
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:
Modify:
apps/gograb-space/src/app/components/features/universe-creation/universe-creation-wizard.component.tsStep 1: Add subdomain preview and availability check
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.
- Step 2: Commit
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:
Modify:
apps/gograb-space/src/app/components/space-components/space-info/space-info.component.tsModify:
libs/libs-space/universe-canvas/src/lib/components/universe-settings-modal/universe-settings-modal.component.tsModify:
libs/libs-space/universe-canvas/src/lib/components/deployment-modal/deployment-modal.component.tsModify:
apps/gograb-space/src/app/components/space-components/g-space-dashboard/g-space-dashboard.component.tsModify:
libs/libs-structural/launch-station-lib/src/lib/components/deployment-history/deployment-history.component.tsStep 1: Space Info — show deployment mode and active subdomains
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.
- Step 2: Universe Settings Modal — show current subdomain with edit action
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).
- Step 3: Deployment Modal — display correct URL
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.
- Step 4: G Space Dashboard — show deployed URL on universe cards
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.
- Step 5: Deployment History — show subdomain and custom domain in entries
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.
- Step 6: Commit
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
- Step 1: Verify backend compiles
npx nx build backend-api --skip-nx-cache
- Step 2: Verify frontend compiles
npx nx build gograb-space --skip-nx-cache
- Step 3: Manual test flow
- Start backend:
yarn serve:backend - Test availability check:
curl http://localhost:3000/api/deployed-subdomains/check?subdomain=test - Test suggest:
curl http://localhost:3000/api/deployed-subdomains/suggest?spaceSlug=myspace&universeSlug=portal - Start frontend:
yarn serve:space - Navigate to Launch Dashboard → verify subdomain input shows availability
- Navigate to Universe Creation → verify subdomain preview
- Navigate to Space Info → verify deployment mode display
- Step 4: Final commit with all remaining changes
git add -p # Stage specifically, review each hunk
git commit -m "[S234.4] feat: domain registry integration complete"