Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions db/migrations/20260420120000_extend_reserved_org_slugs.sql
Original file line number Diff line number Diff line change
@@ -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[])
);
2 changes: 1 addition & 1 deletion db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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])))
);


Expand Down
23 changes: 21 additions & 2 deletions packages/owletto-backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down
70 changes: 56 additions & 14 deletions packages/owletto-backend/src/public-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends PublicPageModel>(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<PublicPageModel | null> {
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;

Expand All @@ -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),
Expand All @@ -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
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/owletto-web
Loading