diff --git a/db/migrations/20260420120000_extend_reserved_org_slugs.sql b/db/migrations/20260420120000_extend_reserved_org_slugs.sql new file mode 100644 index 000000000..65dbf3a55 --- /dev/null +++ b/db/migrations/20260420120000_extend_reserved_org_slugs.sql @@ -0,0 +1,56 @@ +-- migrate:up + +-- Extend reserved org slugs to cover infrastructure subdomains. +-- RESERVED_SUBDOMAINS in packages/owletto-backend/src/index.ts already +-- treats www/mcp/static/cdn/... as non-org at the routing layer; this +-- mirrors it at the DB layer so those names can never be claimed. +-- +-- `app` is intentionally NOT reserved — `app.lobu.ai` hosts the auth +-- org itself, whose DB row uses slug='app'. + +ALTER TABLE public.organization DROP CONSTRAINT IF EXISTS org_slug_not_reserved; + +ALTER TABLE public.organization ADD CONSTRAINT org_slug_not_reserved CHECK ( + slug <> ALL (ARRAY[ + 'settings', + 'auth', + 'api', + 'templates', + 'help', + 'account', + 'admin', + 'health', + 'login', + 'logout', + 'signup', + 'register', + 'www', + 'mcp', + 'static', + 'assets', + 'cdn', + 'docs', + 'mail' + ]::text[]) +); + +-- migrate:down + +ALTER TABLE public.organization DROP CONSTRAINT IF EXISTS org_slug_not_reserved; + +ALTER TABLE public.organization ADD CONSTRAINT org_slug_not_reserved CHECK ( + slug <> ALL (ARRAY[ + 'settings', + 'auth', + 'api', + 'templates', + 'help', + 'account', + 'admin', + 'health', + 'login', + 'logout', + 'signup', + 'register' + ]::text[]) +); diff --git a/db/schema.sql b/db/schema.sql index 096f8f99a..e7bfe8f48 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1765,7 +1765,7 @@ CREATE TABLE public.organization ( metadata text, visibility text DEFAULT 'private'::text NOT NULL, description text, - CONSTRAINT org_slug_not_reserved CHECK ((slug <> ALL (ARRAY['settings'::text, 'auth'::text, 'api'::text, 'templates'::text, 'help'::text, 'account'::text, 'admin'::text, 'health'::text, 'login'::text, 'logout'::text, 'signup'::text, 'register'::text]))) + CONSTRAINT org_slug_not_reserved CHECK ((slug <> ALL (ARRAY['settings'::text, 'auth'::text, 'api'::text, 'templates'::text, 'help'::text, 'account'::text, 'admin'::text, 'health'::text, 'login'::text, 'logout'::text, 'signup'::text, 'register'::text, 'www'::text, 'mcp'::text, 'static'::text, 'assets'::text, 'cdn'::text, 'docs'::text, 'mail'::text]))) ); diff --git a/packages/owletto-backend/src/index.ts b/packages/owletto-backend/src/index.ts index 64751410a..45f353022 100644 --- a/packages/owletto-backend/src/index.ts +++ b/packages/owletto-backend/src/index.ts @@ -317,7 +317,21 @@ app.use('/*', async (c, next) => { const zone = getSubdomainZone(); const sub = extractSubdomainOrg(c.req.header('host'), zone, RESERVED_SUBDOMAINS); c.set('subdomainOrg', sub); - await next(); + + // On a subdomain host, redirect HTML GETs that carry a redundant `/{sub}` + // prefix to the stripped path so direct/bookmarked links normalize to the + // SPA's expected URL. Scoped to HTML so API clients are unaffected. + if (sub && c.req.method === 'GET' && c.req.header('accept')?.includes('text/html')) { + const prefix = `/${sub}`; + const path = c.req.path; + if (path === prefix || path.startsWith(`${prefix}/`)) { + const stripped = path.slice(prefix.length) || '/'; + const url = new URL(c.req.url); + return c.redirect(`${stripped}${url.search}`, 301); + } + } + + return next(); }); app.use('/*', async (c, next) => { @@ -907,7 +921,12 @@ app.get('*', async (c) => { requestPath ); if (acceptsHtml && !hasFileExtension && !isExcludedSpaPath(requestPath)) { - const publicPageModel = await buildPublicPageModel(requestPath, c.env, c.req.url); + const publicPageModel = await buildPublicPageModel( + requestPath, + c.env, + c.req.url, + c.get('subdomainOrg') + ); if (publicPageModel) { const template = await loadAnySpaHtmlTemplate(); if (template) { diff --git a/packages/owletto-backend/src/public-pages.ts b/packages/owletto-backend/src/public-pages.ts index cdb015445..ef2c2e4e9 100644 --- a/packages/owletto-backend/src/public-pages.ts +++ b/packages/owletto-backend/src/public-pages.ts @@ -865,12 +865,36 @@ function injectIntoTemplate(templateHtml: string, model: PublicPageModel): strin : `${withRoot}${bootstrapScript}`; } +function stripSubdomainPrefix(path: string, sub: string | null | undefined): string { + if (!sub) return path; + const prefix = `/${sub}`; + if (path === prefix) return '/'; + if (path.startsWith(`${prefix}/`)) return path.slice(prefix.length); + return path; +} + +function applyBootstrapStrip(model: T, sub: string | null | undefined): T { + if (!sub) return model; + return { + ...model, + bootstrap: { ...model.bootstrap, path: stripSubdomainPrefix(model.bootstrap.path, sub) }, + }; +} + export async function buildPublicPageModel( path: string, env: Env, - requestUrl: string + requestUrl: string, + subdomainOrg?: string | null ): Promise { - const normalizedPath = `/${path.replace(/^\/+|\/+$/g, '')}`; + const rawPath = `/${path.replace(/^\/+|\/+$/g, '')}`; + // On a subdomain host, synthesize the owner segment when the request path + // doesn't already carry it, so downstream segment-based routing works + // identically to the canonical-host (`app.lobu.ai/{org}/...`) form. + const normalizedPath = + subdomainOrg && rawPath !== `/${subdomainOrg}` && !rawPath.startsWith(`/${subdomainOrg}/`) + ? `/${subdomainOrg}${rawPath === '/' ? '' : rawPath}` + : rawPath; const segments = normalizedPath.split('/').filter(Boolean); if (segments.length === 0) return null; @@ -891,25 +915,34 @@ export async function buildPublicPageModel( env, toolCtx ); - return buildWorkspaceModel(organization, resolvedPath, requestUrl); + return applyBootstrapStrip( + buildWorkspaceModel(organization, resolvedPath, requestUrl), + subdomainOrg + ); } if (segments.length === 2) { const entityType = await getPublicEntityType(organization.id, segments[1]!); if (!entityType) { - return buildNotFoundModel(organization, requestUrl, normalizedPath); + return applyBootstrapStrip( + buildNotFoundModel(organization, requestUrl, normalizedPath), + subdomainOrg + ); } const [ownerResolvedPath, entityList] = await Promise.all([ resolvePath({ path: `/${organization.slug}`, include_bootstrap: true }, env, toolCtx), getPublicEntityTypeList(organization.id, env, requestUrl, entityType.slug), ]); - return buildEntityTypeModel({ - organization, - entityType, - entityList, - ownerResolvedPath, - requestUrl, - }); + return applyBootstrapStrip( + buildEntityTypeModel({ + organization, + entityType, + entityList, + ownerResolvedPath, + requestUrl, + }), + subdomainOrg + ); } // If any segment after the owner is a known app-route prefix (e.g. watchers, agents), @@ -927,12 +960,21 @@ export async function buildPublicPageModel( env, toolCtx ); - return buildEntityModel(organization, resolvedPath, requestUrl); + return applyBootstrapStrip( + buildEntityModel(organization, resolvedPath, requestUrl), + subdomainOrg + ); } - return buildNotFoundModel(organization, requestUrl, normalizedPath); + return applyBootstrapStrip( + buildNotFoundModel(organization, requestUrl, normalizedPath), + subdomainOrg + ); } catch { - return buildNotFoundModel(organization, requestUrl, normalizedPath); + return applyBootstrapStrip( + buildNotFoundModel(organization, requestUrl, normalizedPath), + subdomainOrg + ); } } diff --git a/packages/owletto-web b/packages/owletto-web index 4e8f63168..f0236804d 160000 --- a/packages/owletto-web +++ b/packages/owletto-web @@ -1 +1 @@ -Subproject commit 4e8f6316889ee236a3f963c89562e7d0a07ad713 +Subproject commit f0236804d5d0800b2ef9342878a9f297ddfe3efd