From 6bd11c977f65a99b5453aed1f9148cd444c9d235 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Mon, 19 Oct 2026 10:56:32 -0400 Subject: [PATCH] feat: let a subdomain claim keep the main pipeline `extension.claimSubdomain(name, { mainPipeline: true })` serves `.` the way core's own subdomains are served: through CORS, body parsing, auth and every route, `subdomain: '*'` ones included. Routes declared for the name are ordinary routes, with no option restrictions. Gated claims (`{ authorize, checkRoute? }`) behave as before. onlyoffice's subdomain now comes from its extension's claim, so core no longer lists it among the subdomains it serves itself. Without that claim `onlyoffice.` gets the unclaimed-subdomain 404 like any other name. --- .../http/middleware/hostRedirects.test.ts | 50 ++++++++++- .../core/http/middleware/hostRedirects.ts | 47 +++++++--- src/backend/extensions.test.ts | 7 ++ src/backend/extensions.ts | 3 +- src/backend/server.test.ts | 87 +++++++++++++++++++ src/backend/server.ts | 20 +++-- 6 files changed, 191 insertions(+), 23 deletions(-) diff --git a/src/backend/core/http/middleware/hostRedirects.test.ts b/src/backend/core/http/middleware/hostRedirects.test.ts index fc5eecaaf..10f038e48 100644 --- a/src/backend/core/http/middleware/hostRedirects.test.ts +++ b/src/backend/core/http/middleware/hostRedirects.test.ts @@ -175,7 +175,7 @@ describe('createUserSubdomainNotFound', () => { it('passes through reserved subdomains (api, js, native apps, etc.)', () => { // `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`, - // `puter-app-icons`, `onlyoffice`, etc. all bypass. + // `puter-app-icons`, etc. all pass through. for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) { const { out, next } = run( createUserSubdomainNotFound(config), @@ -189,6 +189,17 @@ describe('createUserSubdomainNotFound', () => { } }); + it('404s an extension-owned subdomain nobody has claimed', () => { + const { next } = run( + createUserSubdomainNotFound(config), + makeReq({ + subdomains: ['com', 'puter', 'onlyoffice'], + host: 'onlyoffice.puter.com', + }), + ); + expectNotFound(next); + }); + it('passes through when no subdomain is present (root)', () => { const { next } = run( createUserSubdomainNotFound(config), @@ -640,6 +651,21 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => { expect(args).toEqual([]); }); + it('hands a main-pipeline claim to the rest of the stack, like a reserved name', async () => { + const claims = lookup({ office: { mainPipeline: true } }); + for (const [host, subdomains] of [ + ['office.puter.com', ['office']], + ['office.puter.com:4100', ['office']], + ['office.x.puter.com', ['x', 'office']], + ] as const) { + const args = await runClaimed( + createUserSubdomainNotFound(config, claims), + hostReq(host, [...subdomains]), + ); + expect(args, host).toEqual([]); + } + }); + it('looks the claim up on every request', async () => { const claims: Record = {}; const middleware = createUserSubdomainNotFound(config, lookup(claims)); @@ -677,7 +703,7 @@ describe('addClaimedSubdomain', () => { }); it('rejects subdomains core already handles', () => { - for (const name of ['www', 'api', 'js', 'dav', 'docs', 'onlyoffice']) { + for (const name of ['www', 'api', 'js', 'dav', 'docs']) { expect(() => addClaimedSubdomain(new Map(), name, claim)).toThrow( /reserved/, ); @@ -706,6 +732,26 @@ describe('addClaimedSubdomain', () => { ).toThrow(/invalid checkRoute/); }); + it('records a main-pipeline claim, and refuses one that also carries a gate', () => { + const claims = new Map(); + addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true }); + expect(claims.get('onlyoffice')).toEqual({ mainPipeline: true }); + expect(() => + addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true }), + ).toThrow(/already claimed/); + for (const gate of [ + { authorize: () => true }, + { checkRoute: () => undefined }, + ]) { + expect(() => + addClaimedSubdomain(new Map(), 'portal', { + mainPipeline: true, + ...gate, + } as unknown as SubdomainClaim), + ).toThrow(/main pipeline with a gate/); + } + }); + it('rejects a claim without authorize', () => { for (const bad of [undefined, {}, { authorize: true }]) { expect(() => diff --git a/src/backend/core/http/middleware/hostRedirects.ts b/src/backend/core/http/middleware/hostRedirects.ts index 835d2889a..67263d0ea 100644 --- a/src/backend/core/http/middleware/hostRedirects.ts +++ b/src/backend/core/http/middleware/hostRedirects.ts @@ -41,8 +41,9 @@ const NATIVE_APP_SUBDOMAINS = [ const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']); /** - * Subdomains that v2 serves itself. Anything NOT in this set that lives on the - * root domain is treated as a user-defined site and rejected with a 404. + * Subdomains that v2 serves itself. A root-domain subdomain neither in this set + * nor claimed by an extension is treated as a user-defined site and rejected + * with a 404. * * Kept as a plain Set so `has()` is O(1); order doesn't matter. */ @@ -55,8 +56,6 @@ const RESERVED_SUBDOMAINS = new Set([ ...NATIVE_APP_SUBDOMAINS, // App-icon serving subdomain. 'puter-app-icons', - // Extension-owned subdomains. - 'onlyoffice', ]); /** @@ -69,7 +68,8 @@ export type ClaimAuthorize = ( res: Response, ) => boolean | Promise; -export interface SubdomainClaim { +/** A claim answered on its own router, only past `authorize`. */ +export interface GatedSubdomainClaim { authorize: ClaimAuthorize; /** * Vets each route declared for the claim as the server builds it; a throw @@ -78,12 +78,23 @@ export interface SubdomainClaim { checkRoute?: (options: RouteOptions, label: string) => void; } -/** A claim as dispatch sees it: its gate, plus the routes served behind it. */ -export interface ResolvedClaim { - authorize: ClaimAuthorize; - handler?: RequestHandler; +/** Served like core's own subdomains: CORS, auth and every route. */ +export interface MainPipelineSubdomainClaim { + mainPipeline: true; } +export type SubdomainClaim = GatedSubdomainClaim | MainPipelineSubdomainClaim; + +/** A claim as dispatch sees it: a gate and the routes behind it, or neither. */ +export type ResolvedClaim = + | { authorize: ClaimAuthorize; handler?: RequestHandler } + | MainPipelineSubdomainClaim; + +export const isMainPipelineClaim = ( + claim: SubdomainClaim | ResolvedClaim | undefined, +): claim is MainPipelineSubdomainClaim => + (claim as { mainPipeline?: unknown } | undefined)?.mainPipeline === true; + export type ClaimLookup = (name: string) => ResolvedClaim | undefined; const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; @@ -106,6 +117,15 @@ export const addClaimedSubdomain = ( if (claims.has(name)) { throw new Error(`Subdomain '${name}' is already claimed`); } + if (isMainPipelineClaim(claim)) { + if ('authorize' in claim || 'checkRoute' in claim) { + throw new Error( + `Subdomain '${name}' claimed for the main pipeline with a gate`, + ); + } + claims.set(name, { mainPipeline: true }); + return; + } if (typeof claim?.authorize !== 'function') { throw new Error(`Subdomain '${name}' claimed without authorize`); } @@ -131,7 +151,7 @@ const subdomainNotFound = () => * unclaimed-subdomain 404, and errors reach the error handler. */ const dispatchClaimed = async ( - claim: ResolvedClaim, + claim: Exclude, req: Request, res: Response, next: NextFunction, @@ -190,7 +210,8 @@ export const createWwwRedirect = (config: IConfig): RequestHandler => { * Passes through when: * * - No active subdomain (root) - * - Active subdomain is reserved (api, js, native apps, …) + * - Active subdomain is reserved (api, js, native apps, …) or claimed for the + * main pipeline * - Host is on one of the hosting domains (they may nest under `config.domain`) * - Host is a local worker host and the local worker server is on * - Host isn't under `config.domain` (custom domains, other hosts) @@ -223,6 +244,8 @@ export const createUserSubdomainNotFound = ( req.subdomains?.[req.subdomains.length - 1] ?? '' ).toLowerCase(); if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next(); + const claim = lookupClaim(active); + if (isMainPipelineClaim(claim)) return next(); const hostName = (req.headers.host ?? '').toLowerCase().split(':')[0]; if ( @@ -240,7 +263,7 @@ export const createUserSubdomainNotFound = ( // included. One label deep only: `.x.` is not the claim. const claimed = req.subdomains?.length === 1 && hostName === `${active}.${domain}` - ? lookupClaim(active) + ? claim : undefined; if (claimed) { void dispatchClaimed(claimed, req, res, next); diff --git a/src/backend/extensions.test.ts b/src/backend/extensions.test.ts index adbf07a60..284678797 100644 --- a/src/backend/extensions.test.ts +++ b/src/backend/extensions.test.ts @@ -161,6 +161,13 @@ describe('extension.claimSubdomain', () => { expect(extensionStore.claimedSubdomains.get('portal')).toEqual(claim); }); + it('records a main-pipeline claim', () => { + extension.claimSubdomain('onlyoffice', { mainPipeline: true }); + expect(extensionStore.claimedSubdomains.get('onlyoffice')).toEqual({ + mainPipeline: true, + }); + }); + it('refuses a second claim, a reserved name, an invalid label and no authorize', () => { extension.claimSubdomain('portal', claim); expect(() => extension.claimSubdomain('portal', claim)).toThrow( diff --git a/src/backend/extensions.ts b/src/backend/extensions.ts index 7155f0c60..7ccf1deec 100644 --- a/src/backend/extensions.ts +++ b/src/backend/extensions.ts @@ -353,7 +353,8 @@ export const extension = { * Serve `.` in place of the 404 an unclaimed subdomain * gets. `authorize` runs first, before CORS, body parsing and auth; when it * returns true, routes declared with `subdomain: name` answer. Anything - * else gets that same 404. + * else gets that same 404. `{ mainPipeline: true }` instead serves the host + * like core's own subdomains, with ordinary routes. */ claimSubdomain: (name: string, claim: SubdomainClaim) => { addClaimedSubdomain(extensionStore.claimedSubdomains, name, claim); diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index e30838248..f1f842a6a 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -781,6 +781,93 @@ describe('PuterServer claimed subdomains', () => { }); }); +/** + * A main-pipeline claim is served like a reserved subdomain, with ordinary + * routes. + */ +describe('PuterServer main-pipeline claims', () => { + let server: PuterServer; + let port: number; + const routeCount = { before: 0 }; + + beforeAll(async () => { + extension.claimSubdomain('mainclaim', { mainPipeline: true }); + routeCount.before = extensionStore.routeHandlers.length; + extension.get( + '/hello', + { subdomain: 'mainclaim' }, + (_req: Request, res: Response) => { + res.send('main hello'); + }, + ); + extension.get( + '/signed-in', + { + subdomain: 'mainclaim', + requireAuth: true, + rateLimit: { limit: 5, window: 1000, key: 'user' }, + }, + (_req: Request, res: Response) => { + res.send('signed in'); + }, + ); + extension.get('/everywhere', { subdomain: '*' }, (_req, res) => { + res.send('everywhere'); + }); + port = await allocateEphemeralPort(); + server = await setupTestServer( + { + port, + domain: 'puter.localhost', + origin: `http://puter.localhost:${port}`, + static_hosting_domain: 'site.puter.localhost', + } as unknown as IConfig, + { listen: true }, + ); + }); + + afterAll(async () => { + extensionStore.claimedSubdomains.delete('mainclaim'); + extensionStore.routeHandlers.length = routeCount.before; + await server?.shutdown(); + }); + + it('serves its routes and the main app on the claimed host', async () => { + const host = 'mainclaim.puter.localhost'; + expect((await rawRequest(port, '/hello', { host })).body).toBe( + 'main hello', + ); + const everywhere = await rawRequest(port, '/everywhere', { + host, + origin: 'https://elsewhere.example', + }); + const reserved = await rawRequest(port, '/everywhere', { + host: 'api.puter.localhost', + origin: 'https://elsewhere.example', + }); + expect(everywhere.body).toBe('everywhere'); + for (const res of [everywhere, reserved]) { + expect(res.headers['access-control-allow-origin']).toBe( + 'https://elsewhere.example', + ); + } + }); + + it('runs auth for its routes', async () => { + const res = await rawRequest(port, '/signed-in', { + host: 'mainclaim.puter.localhost', + }); + expect(res.status).toBe(401); + }); + + it('keeps its routes off other hosts', async () => { + for (const host of ['api.puter.localhost', 'other.puter.localhost']) { + const res = await rawRequest(port, '/hello', { host }); + expect(res.status, host).toBe(404); + } + }); +}); + describe('PuterServer claimed subdomain route options', () => { const noop = ((_req: Request, res: Response) => res.end()) as unknown as RequestHandler; diff --git a/src/backend/server.ts b/src/backend/server.ts index 7d157bce4..e90418105 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -82,6 +82,7 @@ import { createWwwRedirect, createUserSubdomainNotFound, createNativeAppStatic, + isMainPipelineClaim, } from './core/http/middleware/hostRedirects'; import { createEgressMeteringMiddleware } from './core/http/middleware/egressMetering'; import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy'; @@ -451,7 +452,7 @@ export class PuterServer { this.#app.use( createUserSubdomainNotFound(this.#config, (name) => { const claim = extensionStore.claimedSubdomains.get(name); - if (!claim) return undefined; + if (!claim || isMainPipelineClaim(claim)) return claim; return { authorize: claim.authorize, handler: this.#claimRouters.get(name), @@ -1374,26 +1375,29 @@ export class PuterServer { } /** - * The claimed subdomain a route is declared for, if any. Claimed hosts - * authenticate through the claim's `authorize`, so options that rely on the - * main pipeline's actor or origin handling fail the boot instead. + * The gated claim a route is declared for, if any. Gated hosts authenticate + * through the claim's `authorize`, so options that rely on the main + * pipeline's actor or origin handling fail the boot instead. */ static #claimedSubdomainOf( opts: RouteOptions, label: string, ): string | undefined { - const claims = extensionStore.claimedSubdomains; + const gated = (name: string) => { + const claim = extensionStore.claimedSubdomains.get(name); + return isMainPipelineClaim(claim) ? undefined : claim; + }; const labels = [opts.subdomain ?? []].flat(); for (const name of labels) { const lower = name.toLowerCase(); - if (lower !== name && claims.has(lower)) { + if (lower !== name && gated(lower)) { throw new Error( `${label}: claimed subdomain '${lower}' must be written in lowercase`, ); } } if (Array.isArray(opts.subdomain)) { - const mixed = opts.subdomain.find((s) => claims.has(s)); + const mixed = opts.subdomain.find((s) => gated(s)); if (mixed !== undefined) { throw new Error( `${label}: claimed subdomain '${mixed}' must be the route's only subdomain`, @@ -1402,7 +1406,7 @@ export class PuterServer { return undefined; } if (typeof opts.subdomain !== 'string') return undefined; - const claim = claims.get(opts.subdomain); + const claim = gated(opts.subdomain); if (!claim) return undefined; for (const [key, value] of Object.entries(opts)) { if (value === undefined || value === false) continue;