mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 21:15:50 +00:00
Merge PUT-2110 and PUT-2111 for the admin-v2 test deploy
This commit is contained in:
commit
b0e9fc955f
6 files changed
+191
-23
No files matched your search
@@ -176,7 +176,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),
|
||||
@@ -190,6 +190,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),
|
||||
@@ -641,6 +652,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<string, ResolvedClaim> = {};
|
||||
const middleware = createUserSubdomainNotFound(config, lookup(claims));
|
||||
@@ -704,7 +730,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/,
|
||||
);
|
||||
@@ -733,6 +759,26 @@ describe('addClaimedSubdomain', () => {
|
||||
).toThrow(/invalid checkRoute/);
|
||||
});
|
||||
|
||||
it('records a main-pipeline claim, and refuses one that also carries a gate', () => {
|
||||
const claims = new Map<string, SubdomainClaim>();
|
||||
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(() =>
|
||||
|
||||
@@ -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<string>([
|
||||
...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<boolean>;
|
||||
|
||||
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])?$/;
|
||||
@@ -118,6 +129,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`);
|
||||
}
|
||||
@@ -143,7 +163,7 @@ const subdomainNotFound = () =>
|
||||
* unclaimed-subdomain 404, and errors reach the error handler.
|
||||
*/
|
||||
const dispatchClaimed = async (
|
||||
claim: ResolvedClaim,
|
||||
claim: Exclude<ResolvedClaim, MainPipelineSubdomainClaim>,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -202,7 +222,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)
|
||||
@@ -235,6 +256,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 (
|
||||
@@ -253,7 +276,7 @@ export const createUserSubdomainNotFound = (
|
||||
const claimed =
|
||||
req.subdomains?.length === 1 &&
|
||||
subdomainLabelOf(hostName, domain) === active
|
||||
? lookupClaim(active)
|
||||
? claim
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
void dispatchClaimed(claimed, req, res, next);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -353,7 +353,8 @@ export const extension = {
|
||||
* Serve `<name>.<config.domain>` 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+12
-8
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user