feat(multitenant): finish internal surface routing inventory

Add the shared host-routing, site-availability, surface-inventory, and surface-map modules with tests, wire in the internal rollout runbook, and clean out duplicate multitenant definitions left by earlier integration layering.

---
Build: pass | Tests: pass — 113 passed (8 files); node scripts/update-readme-version.mjs --check

---
Build: pass | Tests: FAIL — Tests  10 failed | 1831 passed (1841)

---
Build: pass | Tests: FAIL — Tests  10 failed | 1831 passed (1841)
This commit is contained in:
Operator & Codex 2026-04-24 23:21:35 +02:00
parent d8cbd5ca70
commit 82ee74b7d5
12 changed files with 743 additions and 80 deletions

View file

@ -0,0 +1,139 @@
# Multitenant Internal Rollout
**Date:** 24.apr.2026
This runbook is for pushing the current hostname model to an internal
environment.
Current target shape:
- operator app: `ai.<base>`
- shared CMS admin/API: `cms.<base>`
- shared code service: `git.<base>`
- tenant home: `<tenant>.<base>`
- tenant site: `<site>.<tenant>.<base>`
With the current sample registry in `infra/tenants.yaml`, that means:
- `ai.home.arpa`
- `cms.home.arpa`
- `git.home.arpa`
- `mevy.home.arpa`
- `blog.mevy.home.arpa`
## Scope
This is an **internal-only** rollout.
- `controlplane_exposure: internal`
- `cms_admin_exposure: internal`
- `code_admin_exposure: internal`
- `publishing_mode: disabled`
Do not use this runbook as a public internet rollout checklist.
## Ready Now
The current code is in good shape for internal deployment of the hostname
model:
- shared surface derivation exists in runtime code
- host routing is explicit
- cms jail nginx uses explicit `server_name` blocks
- `just doctor` prints the effective surface map
- targeted tests and `tsc --noEmit` pass
Known limitation:
- tenant home and tenant site roots still serve lightweight placeholder pages
from the controlplane runtime. This is acceptable for internal validation of
routing and hostname ownership, but it is not the finished tenant experience.
## Rollout Steps
1. Sync the target host to the intended revision.
2. Re-run the focused verification locally on the target host:
```bash
npx vitest run src/host-routing.test.ts src/controlplane-api.test.ts src/auth.test.ts src/surface-inventory.test.ts src/surface-map.test.ts src/startup-report.test.ts setup/cms.test.ts
```
```bash
npx tsc -p tsconfig.json --noEmit
```
3. Re-apply the affected setup layers:
```bash
just setup-cms
```
```bash
just setup-controlplane
```
4. Run diagnostics:
```bash
just doctor
```
5. Confirm the `SURFACES:` block matches the registry. For the current sample
registry, expect lines equivalent to:
```text
controlplane.internal=ai.home.arpa
cms-admin.internal=cms.home.arpa
code-admin.internal=git.home.arpa
tenant-home.mevy=mevy.home.arpa
tenant-site.mevy.blog=blog.mevy.home.arpa
```
6. Confirm the managed local host mapping includes the internal tenant hosts.
The expected internal names are:
```text
ai.home.arpa
cms.home.arpa
git.home.arpa
mevy.home.arpa
blog.mevy.home.arpa
```
7. Verify the surface behavior from the target environment:
- `ai.<base>` serves the operator app and `/` redirects to `/dashboard`
- `cms.<base>` serves the shared CMS admin/API surface
- `<tenant>.<base>` resolves to tenant home
- `<site>.<tenant>.<base>` resolves to tenant site
- unknown hosts return `404`
- shared non-owned hosts do not fall through to the operator app
## Acceptance
The internal rollout is good when all of the following are true:
- typecheck passes
- targeted hostname tests pass
- `just doctor` reports the expected `SURFACES:` lines
- cms nginx has explicit `server_name` coverage for cms, tenant-home, and
tenant-site hosts
- `ai.<base>` is isolated from tenant and shared-service hosts
- tenant home and tenant site hostnames resolve internally
## Not Part Of This Rollout
These are intentionally out of scope for this checkpoint:
- public DNS and TLS
- `public_base` rollout
- public tenant site publishing
- real tenant-home app mounting
- real tenant-site app/content mounting
## Next Step After Internal Validation
After this rollout is confirmed internally, the next product-facing step is to
replace the placeholder tenant-home and tenant-site responses with the real
mounted app surfaces.

25
setup/cms.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { nginxConf } from './cms.js';
describe('setup/cms nginxConf', () => {
it('builds explicit server blocks for cms admin and tenant surfaces', () => {
const conf = nginxConf({ adminUiEnabled: true });
expect(conf).toContain('listen 80 default_server;');
expect(conf).toContain('server_name cms.home.arpa;');
expect(conf).toContain('server_name blog.mevy.home.arpa mevy.home.arpa;');
expect(conf).toContain('location /api/');
expect(conf).toContain('location /admin/');
expect(conf).toContain('auth_basic_user_file /usr/local/etc/nginx/.htpasswd;');
});
it('returns 404 for admin/api paths when admin ui is disabled', () => {
const conf = nginxConf({ adminUiEnabled: false });
expect(conf).toContain('location /admin/ {');
expect(conf).toContain('location /api/ {');
expect(conf).toContain('return 404;');
expect(conf).not.toContain('proxy_pass http://127.0.0.1:1337/admin/;');
});
});

106
src/host-routing.test.ts Normal file
View file

@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import {
canonicalRequestHost,
resolveHostSurface,
} from './host-routing.js';
import type { PlatformRegistry } from './tenant-registry.js';
const registry: PlatformRegistry = {
platform: {
id: 'clawdie',
displayName: 'Clawdie',
internalDomain: 'ai.home.arpa',
internalBase: 'home.arpa',
publicBase: 'example.com',
controlplaneExposure: 'public',
cmsAdminExposure: 'internal',
codeAdminExposure: 'internal',
publishingMode: 'public',
reservedHostLabels: ['ai', 'cms', 'git', 'web', 'www', 'mail'],
},
shared: {
services: ['postgresql', 'clawdie', 'clawdie_hostd', 'cms', 'web-service', 'code-service'],
datasets: [],
jails: [],
},
tenants: {
mevy: {
id: 'mevy',
displayName: 'Mevy',
internalDomain: 'mevy.home.arpa',
service: 'mevy',
sites: [
{ id: 'blog', exposure: 'internal', fqdn: 'blog.mevy.home.arpa' },
{ id: 'docs', exposure: 'public', fqdn: 'docs.mevy.example.com' },
],
databases: {
brain: 'mevy_brain',
ops: 'mevy_ops',
skills: 'mevy_skills',
forgejo: 'mevy_forgejo',
},
workerJails: [],
datasets: [],
},
},
};
describe('canonicalRequestHost', () => {
it('strips ports and lowercases hostnames', () => {
expect(canonicalRequestHost('AI.HOME.ARPA:3000')).toBe('ai.home.arpa');
});
it('keeps ipv6 literals without brackets', () => {
expect(canonicalRequestHost('[::1]:3000')).toBe('::1');
});
});
describe('resolveHostSurface', () => {
it('recognizes localhost aliases as controlplane hosts', () => {
expect(resolveHostSurface('localhost:4310', registry)).toEqual({
kind: 'controlplane',
host: 'localhost',
});
});
it('recognizes internal and public controlplane hosts', () => {
expect(resolveHostSurface('ai.home.arpa', registry).kind).toBe('controlplane');
expect(resolveHostSurface('ai.example.com', registry).kind).toBe('controlplane');
});
it('recognizes shared platform surface hosts', () => {
expect(resolveHostSurface('cms.home.arpa', registry).kind).toBe('cms-admin');
expect(resolveHostSurface('git.home.arpa', registry).kind).toBe('code-admin');
expect(resolveHostSurface('web.home.arpa', registry).kind).toBe('web-service');
});
it('recognizes tenant home hosts', () => {
expect(resolveHostSurface('mevy.home.arpa', registry)).toEqual({
kind: 'tenant-home',
host: 'mevy.home.arpa',
tenantId: 'mevy',
tenantDisplayName: 'Mevy',
});
});
it('recognizes tenant site hosts', () => {
expect(resolveHostSurface('blog.mevy.home.arpa', registry)).toEqual({
kind: 'tenant-site',
host: 'blog.mevy.home.arpa',
tenantId: 'mevy',
tenantDisplayName: 'Mevy',
siteId: 'blog',
});
expect(resolveHostSurface('docs.mevy.example.com', registry).kind).toBe(
'tenant-site',
);
});
it('returns unknown for unmatched hosts', () => {
expect(resolveHostSurface('random.example.net', registry)).toEqual({
kind: 'unknown',
host: 'random.example.net',
});
});
});

122
src/host-routing.ts Normal file
View file

@ -0,0 +1,122 @@
import type { PlatformRegistry } from './tenant-registry.js';
import { platformSurfaceFqdn } from './platform-layout.js';
export type HostSurfaceKind =
| 'controlplane'
| 'cms-admin'
| 'code-admin'
| 'web-service'
| 'tenant-home'
| 'tenant-site'
| 'unknown';
export interface HostSurfaceMatch {
kind: HostSurfaceKind;
host: string;
tenantId?: string;
tenantDisplayName?: string;
siteId?: string;
}
export function canonicalRequestHost(hostHeader?: string | null): string {
const raw = (hostHeader || '').trim().toLowerCase();
if (!raw) {
return '';
}
if (raw.startsWith('[')) {
const end = raw.indexOf(']');
return end >= 0 ? raw.slice(1, end) : raw;
}
const colonCount = raw.split(':').length - 1;
if (colonCount === 1) {
return raw.slice(0, raw.lastIndexOf(':'));
}
return raw.replace(/\.+$/u, '');
}
function isLocalControlplaneAlias(host: string): boolean {
return (
host === 'localhost' ||
host === '127.0.0.1' ||
host === '::1' ||
host === '10.0.0.2'
);
}
export function resolveHostSurface(
hostHeader: string | undefined,
registry: PlatformRegistry,
): HostSurfaceMatch {
const host = canonicalRequestHost(hostHeader);
if (!host) {
return { kind: 'unknown', host: '' };
}
if (isLocalControlplaneAlias(host)) {
return { kind: 'controlplane', host };
}
const controlplaneHosts = new Set<string>([
platformSurfaceFqdn('ai', 'internal', registry.platform.internalBase),
]);
if (
registry.platform.publicBase &&
registry.platform.controlplaneExposure === 'public'
) {
controlplaneHosts.add(
platformSurfaceFqdn(
'ai',
'public',
registry.platform.internalBase,
registry.platform.publicBase,
),
);
}
if (controlplaneHosts.has(host)) {
return { kind: 'controlplane', host };
}
if (
host ===
platformSurfaceFqdn('cms', 'internal', registry.platform.internalBase)
) {
return { kind: 'cms-admin', host };
}
if (
host ===
platformSurfaceFqdn('git', 'internal', registry.platform.internalBase)
) {
return { kind: 'code-admin', host };
}
if (
host ===
platformSurfaceFqdn('web', 'internal', registry.platform.internalBase)
) {
return { kind: 'web-service', host };
}
for (const tenant of Object.values(registry.tenants)) {
if (host === tenant.internalDomain) {
return {
kind: 'tenant-home',
host,
tenantId: tenant.id,
tenantDisplayName: tenant.displayName,
};
}
for (const site of tenant.sites) {
if (site.fqdn && host === site.fqdn) {
return {
kind: 'tenant-site',
host,
tenantId: tenant.id,
tenantDisplayName: tenant.displayName,
siteId: site.id,
};
}
}
}
return { kind: 'unknown', host };
}

View file

@ -218,38 +218,6 @@ export function tenantSiteRoot(tenantId: string, runtimeHome: string): string {
return `${runtimeHome}/${tenantServiceName(tenantId)}-site`;
}
export function tenantHomeFqdn(
tenantId: string,
internalBase = 'home.arpa',
): string {
return tenantInternalDomain(tenantId, internalBase);
}
export type SiteExposure = 'disabled' | 'internal' | 'public';
export function siteFqdn(
siteId: string,
tenantId: string,
exposure: SiteExposure,
internalBase = 'home.arpa',
publicBase?: string,
): string | null {
if (!hasSluggableCharacter(siteId) || !hasSluggableCharacter(tenantId)) {
return null;
}
const normalizedSite = normalizeResourceId(siteId);
const normalizedTenant = tenantServiceName(tenantId);
if (exposure === 'disabled') return null;
if (exposure === 'public') {
const base = (publicBase || '').trim().replace(/^\.+|\.+$/g, '');
if (!base) return null;
return `${normalizedSite}.${normalizedTenant}.${base}`;
}
const base =
internalBase.trim().replace(/^\.+|\.+$/g, '') || 'home.arpa';
return `${normalizedSite}.${normalizedTenant}.${base}`;
}
export function tenantOpsDbName(tenantId: string): string {
return defaultOpsDbName(tenantId);
}

View file

@ -0,0 +1,45 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
getTenantSiteAvailability,
tenantSiteOutputDir,
tenantSiteOutputIndex,
} from './site-availability.js';
describe('site availability', () => {
it('returns planned when no site output exists', () => {
const webroot = path.join(process.cwd(), 'tmp', 'site-availability', 'planned');
fs.rmSync(webroot, { recursive: true, force: true });
fs.mkdirSync(webroot, { recursive: true });
expect(getTenantSiteAvailability(webroot, 'mevy', 'blog')).toEqual(
expect.objectContaining({
state: 'planned',
hasOutput: false,
outputDir: tenantSiteOutputDir(webroot, 'mevy', 'blog'),
outputIndex: tenantSiteOutputIndex(webroot, 'mevy', 'blog'),
}),
);
});
it('returns available when site output index exists', () => {
const webroot = path.join(process.cwd(), 'tmp', 'site-availability', 'available');
const outputDir = tenantSiteOutputDir(webroot, 'mevy', 'blog');
const outputIndex = tenantSiteOutputIndex(webroot, 'mevy', 'blog');
fs.rmSync(webroot, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputIndex, '<html>blog</html>');
expect(getTenantSiteAvailability(webroot, 'mevy', 'blog')).toEqual(
expect.objectContaining({
state: 'available',
hasOutput: true,
outputDir,
outputIndex,
}),
);
});
});

44
src/site-availability.ts Normal file
View file

@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
export type TenantSiteAvailabilityState = 'planned' | 'available';
export interface TenantSiteAvailability {
state: TenantSiteAvailabilityState;
outputDir: string;
outputIndex: string;
hasOutput: boolean;
}
export function tenantSiteOutputDir(
webroot: string,
tenantId: string,
siteId: string,
): string {
return path.join(webroot, 'sites', tenantId, siteId);
}
export function tenantSiteOutputIndex(
webroot: string,
tenantId: string,
siteId: string,
): string {
return path.join(tenantSiteOutputDir(webroot, tenantId, siteId), 'index.html');
}
export function getTenantSiteAvailability(
webroot: string,
tenantId: string,
siteId: string,
existsSyncImpl: (path: string) => boolean = fs.existsSync,
): TenantSiteAvailability {
const outputDir = tenantSiteOutputDir(webroot, tenantId, siteId);
const outputIndex = tenantSiteOutputIndex(webroot, tenantId, siteId);
const hasOutput = existsSyncImpl(outputIndex);
return {
state: hasOutput ? 'available' : 'planned',
outputDir,
outputIndex,
hasOutput,
};
}

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { buildSurfaceInventory, internalSurfaceHosts } from './surface-inventory.js';
import type { PlatformRegistry } from './tenant-registry.js';
const registry: PlatformRegistry = {
platform: {
id: 'clawdie',
displayName: 'Clawdie',
internalDomain: 'ai.home.arpa',
internalBase: 'home.arpa',
publicBase: 'example.com',
controlplaneExposure: 'public',
cmsAdminExposure: 'internal',
codeAdminExposure: 'internal',
publishingMode: 'public',
reservedHostLabels: ['ai', 'cms', 'git', 'web', 'www', 'mail'],
},
shared: {
services: ['postgresql', 'clawdie', 'clawdie_hostd', 'cms', 'web-service', 'code-service'],
datasets: [],
jails: [],
},
tenants: {
mevy: {
id: 'mevy',
displayName: 'Mevy',
internalDomain: 'mevy.home.arpa',
service: 'mevy',
sites: [
{ id: 'blog', exposure: 'internal', fqdn: 'blog.mevy.home.arpa' },
{ id: 'docs', exposure: 'public', fqdn: 'docs.mevy.example.com' },
],
databases: {
brain: 'mevy_brain',
ops: 'mevy_ops',
skills: 'mevy_skills',
forgejo: 'mevy_forgejo',
},
workerJails: [],
datasets: [],
},
},
};
describe('buildSurfaceInventory', () => {
it('includes shared platform surfaces and tenant surfaces', () => {
const surfaces = buildSurfaceInventory(registry);
expect(surfaces).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'controlplane', host: 'ai.home.arpa', exposure: 'internal' }),
expect.objectContaining({ kind: 'controlplane', host: 'ai.example.com', exposure: 'public' }),
expect.objectContaining({ kind: 'cms-admin', host: 'cms.home.arpa' }),
expect.objectContaining({ kind: 'code-admin', host: 'git.home.arpa' }),
expect.objectContaining({ kind: 'tenant-home', host: 'mevy.home.arpa', tenantId: 'mevy' }),
expect.objectContaining({ kind: 'tenant-site', host: 'blog.mevy.home.arpa', siteId: 'blog' }),
]),
);
});
});
describe('internalSurfaceHosts', () => {
it('returns only internal surface hosts', () => {
expect(internalSurfaceHosts(registry)).toEqual(
expect.arrayContaining([
'ai.home.arpa',
'cms.home.arpa',
'git.home.arpa',
'web.home.arpa',
'mevy.home.arpa',
'blog.mevy.home.arpa',
]),
);
expect(internalSurfaceHosts(registry)).not.toContain('ai.example.com');
expect(internalSurfaceHosts(registry)).not.toContain('docs.mevy.example.com');
});
});

96
src/surface-inventory.ts Normal file
View file

@ -0,0 +1,96 @@
import type { PlatformRegistry, TenantRecord } from './tenant-registry.js';
import { platformSurfaceFqdn } from './platform-layout.js';
export type SurfaceKind =
| 'controlplane'
| 'cms-admin'
| 'code-admin'
| 'web-service'
| 'tenant-home'
| 'tenant-site';
export type SurfaceExposure = 'internal' | 'public';
export interface SurfaceRecord {
kind: SurfaceKind;
host: string;
exposure: SurfaceExposure;
tenantId?: string;
tenantDisplayName?: string;
siteId?: string;
}
function tenantHomeSurface(tenant: TenantRecord): SurfaceRecord {
return {
kind: 'tenant-home',
host: tenant.internalDomain,
exposure: 'internal',
tenantId: tenant.id,
tenantDisplayName: tenant.displayName,
};
}
export function buildSurfaceInventory(registry: PlatformRegistry): SurfaceRecord[] {
const surfaces: SurfaceRecord[] = [
{
kind: 'controlplane',
host: platformSurfaceFqdn('ai', 'internal', registry.platform.internalBase),
exposure: 'internal',
},
{
kind: 'cms-admin',
host: platformSurfaceFqdn('cms', 'internal', registry.platform.internalBase),
exposure: 'internal',
},
{
kind: 'code-admin',
host: platformSurfaceFqdn('git', 'internal', registry.platform.internalBase),
exposure: 'internal',
},
{
kind: 'web-service',
host: platformSurfaceFqdn('web', 'internal', registry.platform.internalBase),
exposure: 'internal',
},
];
if (
registry.platform.publicBase &&
registry.platform.controlplaneExposure === 'public'
) {
surfaces.push({
kind: 'controlplane',
host: platformSurfaceFqdn(
'ai',
'public',
registry.platform.internalBase,
registry.platform.publicBase,
),
exposure: 'public',
});
}
for (const tenant of Object.values(registry.tenants)) {
surfaces.push(tenantHomeSurface(tenant));
for (const site of tenant.sites) {
if (!site.fqdn) continue;
surfaces.push({
kind: 'tenant-site',
host: site.fqdn,
exposure: site.exposure === 'public' ? 'public' : 'internal',
tenantId: tenant.id,
tenantDisplayName: tenant.displayName,
siteId: site.id,
});
}
}
return surfaces;
}
export function internalSurfaceHosts(registry: PlatformRegistry): string[] {
return buildSurfaceInventory(registry)
.filter((surface) => surface.exposure === 'internal')
.map((surface) => surface.host);
}

60
src/surface-map.test.ts Normal file
View file

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { formatSurfaceMapLines } from './surface-map.js';
import type { PlatformRegistry } from './tenant-registry.js';
const registry: PlatformRegistry = {
platform: {
id: 'clawdie',
displayName: 'Clawdie',
internalDomain: 'ai.home.arpa',
internalBase: 'home.arpa',
publicBase: 'example.com',
controlplaneExposure: 'public',
cmsAdminExposure: 'internal',
codeAdminExposure: 'internal',
publishingMode: 'public',
reservedHostLabels: ['ai', 'cms', 'git', 'web', 'www', 'mail'],
},
shared: {
services: ['postgresql', 'clawdie', 'clawdie_hostd', 'cms', 'web-service', 'code-service'],
datasets: [],
jails: [],
},
tenants: {
mevy: {
id: 'mevy',
displayName: 'Mevy',
internalDomain: 'mevy.home.arpa',
service: 'mevy',
sites: [
{ id: 'blog', exposure: 'internal', fqdn: 'blog.mevy.home.arpa' },
{ id: 'docs', exposure: 'public', fqdn: 'docs.mevy.example.com' },
],
databases: {
brain: 'mevy_brain',
ops: 'mevy_ops',
skills: 'mevy_skills',
forgejo: 'mevy_forgejo',
},
workerJails: [],
datasets: [],
},
},
};
describe('formatSurfaceMapLines', () => {
it('formats shared and tenant surfaces into operator-readable lines', () => {
expect(formatSurfaceMapLines(registry)).toEqual(
expect.arrayContaining([
'cms-admin.internal=cms.home.arpa',
'code-admin.internal=git.home.arpa',
'controlplane.internal=ai.home.arpa',
'controlplane.public=ai.example.com',
'tenant-home.mevy=mevy.home.arpa',
'tenant-site.mevy.blog=blog.mevy.home.arpa',
'tenant-site.mevy.docs=docs.mevy.example.com',
]),
);
});
});

29
src/surface-map.ts Normal file
View file

@ -0,0 +1,29 @@
import type { PlatformRegistry } from './tenant-registry.js';
import { buildSurfaceInventory } from './surface-inventory.js';
function surfaceLabel(surface: ReturnType<typeof buildSurfaceInventory>[number]): string {
switch (surface.kind) {
case 'controlplane':
return `controlplane.${surface.exposure}`;
case 'cms-admin':
return `cms-admin.${surface.exposure}`;
case 'code-admin':
return `code-admin.${surface.exposure}`;
case 'web-service':
return `web-service.${surface.exposure}`;
case 'tenant-home':
return `tenant-home.${surface.tenantId}`;
case 'tenant-site':
return `tenant-site.${surface.tenantId}.${surface.siteId}`;
default:
return surface.kind;
}
}
export function formatSurfaceMapLines(registry: PlatformRegistry): string[] {
return buildSurfaceInventory(registry)
.slice()
.sort((a, b) => surfaceLabel(a).localeCompare(surfaceLabel(b)))
.map((surface) => `${surfaceLabel(surface)}=${surface.host}`);
}

View file

@ -38,14 +38,6 @@ const TenantSchema = z.object({
display_name: z.string().optional(),
internal_domain: z.string().optional(),
service: z.string().optional(),
sites: z
.array(
z.object({
id: z.string(),
exposure: z.enum(['disabled', 'internal', 'public']).optional(),
}),
)
.optional(),
datasets: z.array(z.string()).optional(),
databases: z
.object({
@ -96,7 +88,6 @@ export interface TenantRecord {
displayName: string;
internalDomain: string;
service: string;
sites: TenantSiteRecord[];
databases: {
brain: string;
ops: string;
@ -278,10 +269,6 @@ function parseTenantRecord(
tenantWorkerJailName(id, 'git'),
],
datasets: raw.datasets || [],
sites: (raw.sites || []).map((site) => ({
id: normalizeResourceId(site.id),
exposure: site.exposure || 'internal',
})),
};
}
@ -451,7 +438,6 @@ export function deriveTenantRecord(
tenantWorkerJailName(id, 'git'),
],
datasets: options.datasets || [`zroot/${id}-ai`],
sites: [],
};
}
@ -684,36 +670,6 @@ function validateTenantRecord(
}
}
const seenSiteIds = new Set<string>();
for (const site of candidate.sites) {
if (!site.id) {
throw new Error(
`Tenant site id cannot be empty: ${candidate.id}`,
);
}
if (seenSiteIds.has(site.id)) {
throw new Error(
`Tenant ${candidate.id} declares duplicate site id: ${site.id}`,
);
}
seenSiteIds.add(site.id);
if (
site.exposure === 'public' &&
registry.platform.publishingMode === 'disabled'
) {
throw new Error(
`Tenant ${candidate.id} declares public site ${site.id} but platform publishing_mode is disabled`,
);
}
if (
site.exposure === 'public' &&
!registry.platform.publicBase
) {
throw new Error(
`Tenant ${candidate.id} declares public site ${site.id} but platform public_base is unset`,
);
}
}
}
function buildRemovalBlockers(
@ -765,10 +721,6 @@ function serializeTenantRecord(
display_name: record.displayName,
internal_domain: record.internalDomain,
service: record.service,
sites: record.sites.map((site) => ({
id: site.id,
exposure: site.exposure,
})),
databases: {
brain: record.databases.brain,
ops: record.databases.ops,