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:
- 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 bothdeployment.service.ts:1530(universe-{subdomain}naming) andbuild-pipeline.service.ts:669(directuniverseSlugnaming) — two independent deployment paths with different CF project naming conventions. - Subdomain collision: No uniqueness check → two universes claim same
*.gograb.space - Reserved subdomain collision: Users can deploy to
api.gograb.space,admin.gograb.space, etc. - 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:
{ subdomain: 1, environment: 1 }— unique, partial filter{ status: 'active' }{ customDomain: 1, environment: 1 }— unique, partial filter{ status: 'active', customDomain: { $exists: true, $ne: null } }{ spaceId: 1, status: 1 }{ universeId: 1, status: 1 }
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/
deployed-subdomains.module.tsdeployed-subdomains.schema.tsdeployed-subdomains.service.tsdeployed-subdomains.controller.ts
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)
claimSubdomain()— called during deployreleaseSubdomain()— called during undeploy
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:
- Inject
DeployedSubdomainsService - Call
claimSubdomain()before deployment - Call
releaseSubdomain()on undeploy - Use
cfProjectNamefrom registry instead ofgetCloudflareProjectName() - Apply same
usesOwnDomain/ reserved blocklist logic
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
- Add
deployment?: { mode, defaultUniverseId }toSpaceinterface
New file: libs/libs-foundational/core-models/src/lib/interfaces/deployed-subdomain.types.ts
DeployedSubdomain,SubdomainCheckResult,SubdomainSuggestions,SubdomainClaimResult,SubdomainUpdateParams
7.2 Services
File: libs/libs-foundational/core-angular/src/lib/services/space-api.service.ts
Add methods:
checkSubdomainAvailability(subdomain: string): Observable<SubdomainCheckResult>suggestSubdomains(spaceSlug: string, universeSlug: string): Observable<SubdomainSuggestions>getSpaceSubdomains(spaceId: string): Observable<DeployedSubdomain[]>getUniverseSubdomain(universeId: string): Observable<DeployedSubdomain | null>updateUniverseDomain(universeId: string, params: SubdomainUpdateParams): Observable<SubdomainClaimResult>
File: libs/libs-foundational/core-angular/src/lib/services/universe-deployment.service.ts
Add:
checkSubdomain(subdomain: string)methodsuggestSubdomains()method- Integration with registry in
publishUniverse()flow
File: libs/libs-structural/launch-station-lib/src/lib/services/deployment.service.ts
- Wire availability check into deployment flow
7.3 Components
Launch Dashboard (libs/libs-structural/launch-station-lib/src/lib/components/launch-dashboard/launch-dashboard.component.ts)
- Add real-time subdomain availability check (debounced input →
checkSubdomainAvailability()) - Show available/taken indicator on subdomain input
- Show suggestions when subdomain is taken
- Add
usesOwnDomaintoggle that hides subdomain input when enabled - Add routing mode selector in space settings section (for future
space_brandedmode) - Update
previewUrlcomputed signal to account forusesOwnDomain
Space Info Component (apps/gograb-space/src/app/components/space-components/space-info/space-info.component.ts)
- Show current deployment mode
- Show active subdomains for the space
- Allow changing deployment mode preference
Universe Settings Modal (libs/libs-space/universe-canvas/src/lib/components/universe-settings-modal/universe-settings-modal.component.ts)
- Show current subdomain (from registry)
- Add "Change subdomain" action → calls
updateDomain() - Show custom domain if set
Deployment Modal (Success) (libs/libs-space/universe-canvas/src/lib/components/deployment-modal/deployment-modal.component.ts)
- Display correct URL (custom domain if
usesOwnDomain, else gograb.space subdomain)
Universe Creation Wizard (apps/gograb-space/src/app/components/features/universe-creation/universe-creation-wizard.component.ts)
- Add subdomain preview during creation (shows what URL will be)
- Check availability as user types universe name
- Show suggestion if default slug is taken
G Space Dashboard (apps/gograb-space/src/app/components/space-components/g-space-dashboard/g-space-dashboard.component.ts)
- Show deployed URL on universe cards (from registry, not deployment record)
- Show deployment status indicator
Deployment History (libs/libs-structural/launch-station-lib/src/lib/components/deployment-history/deployment-history.component.ts)
- Show subdomain and custom domain in history entries
8. Migration
8.1 Backfill Script
Location: tools/scripts/database/backfill-deployed-subdomains.ts
Logic:
- Query
deploymentscollection:{ isCurrent: true, status: 'ready' } - For each, parse subdomain from
urlfield - Look up space to get
spaceSlug - Insert into
deployed_subdomainswithstatus: 'active' - Log conflicts (two deployments claiming same subdomain = existing bug)
- 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
- Domain marketplace → future session
- Staging subdomain registry → if staging becomes user-facing