From 13de0dbaeeb40ab5fdfdbec45d22b2c5de964183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 20 Apr 2026 20:49:54 +0100 Subject: [PATCH 1/3] feat(db): extend reserved org slugs to cover infrastructure subdomains Mirrors RESERVED_SUBDOMAINS in packages/owletto-backend/src/index.ts at the DB layer so names like www/mcp/static/cdn/docs/mail can never be claimed as org slugs. `app` is intentionally NOT reserved because the auth org row uses slug='app'. --- ...260420120000_extend_reserved_org_slugs.sql | 56 +++++++++++++++++++ db/schema.sql | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 db/migrations/20260420120000_extend_reserved_org_slugs.sql 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]))) ); From 56a35f8c7b48d950e547c1483b20c4a634adc8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 20 Apr 2026 20:50:08 +0100 Subject: [PATCH 2/3] feat(backend): subdomain-aware SSR + canonical 301 for redundant prefix - buildPublicPageModel now accepts an optional subdomainOrg, synthesizes the owner segment when the request path doesn't carry it, and strips the prefix from bootstrap.path so the SPA hydration matcher aligns with the post-replaceState pathname. - Subdomain middleware 301-redirects HTML GETs that hit /{sub} or /{sub}/... on the matching subdomain host so direct/bookmarked links normalize. Scoped to text/html so API clients are unaffected. Pairs with the SPA basepath rewrite in packages/owletto-web (#2 over there). --- packages/owletto-backend/src/index.ts | 23 ++++++- packages/owletto-backend/src/public-pages.ts | 70 ++++++++++++++++---- 2 files changed, 77 insertions(+), 16 deletions(-) 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 + ); } } From 5a9117b8f972a9363349d37c4ee064567eacab5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 20 Apr 2026 20:50:20 +0100 Subject: [PATCH 3/3] chore(owletto-web): bump submodule for subdomain-aware routing Pulls in lobu-ai/owletto-web#2: - feat(routing): make SPA subdomain-aware (TanStack Router basepath) - feat(connectors): sign-in CTA for unauthenticated public workspaces - chore(workspace-home): drop unused workspaceName prop --- packages/owletto-web | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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