Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9e82f32
feat(app-hosting): routing, wake gate and domains (ships dark)
2witstudios Aug 25, 2026
977ac3d
refactor(app-hosting): make the router's post-gate balance invariant …
2witstudios Aug 25, 2026
90dc1d5
docs(changelog): name the _fly-ownership TXT record in the domains entry
2witstudios Aug 25, 2026
847fbe4
fix(app-hosting): the middleware 401'd the router before it ever ran
2witstudios Aug 25, 2026
7472a85
fix(app-hosting): the parked page would have rendered unstyled in pro…
2witstudios Aug 25, 2026
a2bb00c
fix(app-hosting): the router carve-out has to sit above origin and OP…
2witstudios Aug 25, 2026
55e8a3b
docs(middleware): the router returns above the later CSP branch, so s…
2witstudios Aug 25, 2026
8f681cd
docs(app-hosting): record the middleware carve-out in ROUTING.md
2witstudios Aug 25, 2026
5b46b67
fix(app-hosting): address both review findings — dead token fallback,…
2witstudios Aug 25, 2026
531bbff
Merge branch 'master' into pu/pub-routing
2witstudios Aug 25, 2026
dcbb4d9
fix(app-hosting): close the four gaps CodeRabbit found, at the level …
2witstudios Aug 25, 2026
33b7b57
Merge remote-tracking branch 'origin/master' into pu/pub-routing
2witstudios Aug 25, 2026
f9813ba
docs(app-hosting): the proxy mirrors the replay cap, and the unit is …
2witstudios Aug 25, 2026
b6d304f
fix(domains): name both Fly credentials, and stop trusting a promise …
2witstudios Aug 25, 2026
825a2dd
docs(app-hosting): three ways to get the carve-out wrong, four ways i…
2witstudios Aug 25, 2026
c857a10
fix(tests): type the ownership table instead of freezing it
2witstudios Aug 25, 2026
8f1f40f
docs(fly): checkCertificate is about timing, not about a field nothin…
2witstudios Aug 25, 2026
5c4659c
fix(app-hosting): a body that fails mid-read must not 500 the serving…
2witstudios Aug 25, 2026
69efb72
docs(billing): the router and the AI gate share a rule, not an input
2witstudios Aug 25, 2026
ca0be8a
test(app-hosting): the composition root decides what actually runs — …
2witstudios Aug 25, 2026
b2f0f4f
fix(domains): show the DNS record a stuck certificate needs, in the row
2witstudios Aug 25, 2026
c1b7ba0
test(domains): pin the row that tells a customer which record to publish
2witstudios Aug 25, 2026
bab532f
docs(domains): cert detachment covers explicit removal, not cascades
2witstudios Aug 25, 2026
a65e2bc
docs(app-hosting): the metering cron is a sibling PR, not this branch
2witstudios Aug 25, 2026
cef8cb1
fix(domains): the ownership instruction is prose, so stop typesetting…
2witstudios Aug 25, 2026
e3d8e63
fix(domains): "the value A or B" reads as one value whose text is "A …
2witstudios Aug 25, 2026
5b33fab
fix(app-hosting): the unavailable page told visitors something untrue
2witstudios Aug 25, 2026
3320f3b
fix(app-hosting): the 413 named a raw byte count and no unit
2witstudios Aug 25, 2026
4d1d111
fix(domains): one phrasing helper cannot serve two sentences
2witstudios Aug 25, 2026
5ceb953
test(middleware): the carve-out constant is only tied to reality by a…
2witstudios Aug 25, 2026
4eb1926
docs(app-hosting): "chunked" is HTTP/1.1 only; the check is not
2witstudios Aug 25, 2026
a3ffaa9
docs(app-hosting): the 502 belongs with the failure, not the naming note
2witstudios Aug 25, 2026
3661a41
fix(publish): bound the NS fan-out the SSRF note already reasons about
2witstudios Aug 25, 2026
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
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,33 @@ APNS_BUNDLE_ID=ai.pagespace.ios
# FLY_MACHINES_ORG_TOKEN=your_fly_org_token_here
# PUBLISHED_APPS_NETWORK=published-apps
#
# Serving tier (the router). PUBLISHED_APPS_APEX is REQUIRED once
# APP_HOSTING_ENABLED=true — validateEnv() refuses to boot without it, on
# purpose. Published apps run customer-authored SERVER code on subdomains of
# this apex, so it must be a deliberate choice that has completed Public Suffix
# List registration, never a value inherited from a default: without a PSL entry
# one published app can set a cookie for the whole apex that every other
# published app then sends. See ROUTING.md for the submission checklist, and note
# that PSL listing is NOT retroactive — it ships inside browser releases.
# APP_ROUTER_FLY_APP_NAME is the Fly app that terminates the apex and holds
# custom-domain certs; it falls back to FLY_PROXY_APP_NAME and then to
# 'pagespace-proxy', and it MUST have been created on PUBLISHED_APPS_NETWORK
# because fly-replay cannot cross 6PN networks.
# PUBLISHED_APPS_APEX=pagespace.app
# APP_ROUTER_FLY_APP_NAME=pagespace-proxy
#
# Router secrets. BOTH must be >=32 characters when set, and both fail CLOSED:
# unset (or below the floor) means the router refuses rather than skipping the
# check. APP_ROUTER_PROXY_SECRET is what proves a request came from the edge
# proxy — the route is mounted on the web app, which also answers at
# pagespace.ai/api/..., so without it any internet caller could hand us a
# published-app hostname and collect a fly-replay header, waking and billing any
# app they can name. Set the SAME value as a secret on the proxy app.
# APP_REPLAY_SECRET derives the per-app fly-replay `state` key.
# Generate each with: openssl rand -hex 32
# APP_REPLAY_SECRET=your_app_replay_secret_here_generate_with_openssl_rand_hex_32
# APP_ROUTER_PROXY_SECRET=your_router_proxy_secret_here_generate_with_openssl_rand_hex_32
#
# Build & deploy pipeline (processor). APP_BUILD_SOURCE_ROOT is the directory
# every build context is resolved UNDER — a source ref that escapes it is
# refused — and leaving it unset means this processor is simply not a build
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ All notable user-facing changes to PageSpace are documented here. Format follows

### Fixed

- **A custom domain stuck on SSL now tells you which DNS record to add** — when a certificate is
waiting on an ownership record, domain settings name it outright: the `_fly-ownership` TXT record,
where it goes, and every value that satisfies it — Fly accepts an app-scoped or an org-scoped
value, and whichever ones it offers are the ones you are shown. Previously that domain simply sat at "provisioning"
indefinitely with nothing to act on, because through the certificate's status alone "the
certificate has not issued yet" and "you were never told to add a DNS record" look identical — and
only one of them ever resolves on its own. The domain also stays healthy while it waits instead of
being marked failed, so a site already being served keeps serving. "Check SSL" now does more than
re-read a cached answer: once the record is visible in DNS it asks the certificate authority to
look again, rather than leaving you to wait out its own polling schedule. And removing a domain
now detaches its certificate, which previously kept billing after the domain was gone. Deleting an
entire drive does not yet do this, so remove its domains individually first if you want their
certificates released.
- **An older AI conversation keeps its controls** — opening an AI page on a past conversation could
drop the whole bar above the chat: no agent name, and no "+" to start a new conversation, so the
only way to begin one was to go to the History tab and find the button there. Which of the two the
Expand Down
102 changes: 100 additions & 2 deletions apps/web/src/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,13 @@ vi.mock('@/middleware/security-headers', () => ({
// Real predicate logic — middleware passes its result as `skipCSP` so the
// handoff-bridge OAuth callbacks don't get a middleware CSP layered on top of
// their own (see the isHandoffBridgeRoute describe block below).
isHandoffBridgeRoute: (pathname: string) =>
pathname === '/api/auth/google/callback' || pathname === '/api/auth/apple/callback',
// Real predicate logic, matching security-headers.ts: the handoff-bridge
// callbacks plus the published-app router all deliver their own CSP.
APP_ROUTER_ROUTE_PATH: '/api/app-hosting/router',
routeOwnsItsOwnCsp: (pathname: string) =>
pathname === '/api/auth/google/callback' ||
pathname === '/api/auth/apple/callback' ||
pathname === '/api/app-hosting/router',
isPublicPageRoute: () => false,
isPublishedSiteHost: () => false,
isSecureRequest: () => true,
Expand Down Expand Up @@ -123,6 +128,99 @@ describe('middleware — /api/public/forms carve-outs', () => {
});
});

// Regression coverage for a real bug found while building the published-app
// routing tier: the router endpoint is called by pagespace-proxy with NO session
// and no user — it authenticates via the APP_ROUTER_PROXY_SECRET shared secret
// checked inside the route. Without a middleware carve-out, every such call is
// 401'd before route.ts ever runs, which does not fail any handler test (those
// invoke the route directly) but makes EVERY published app unreachable in a real
// deployment. The route's own tests cannot see this; only this one can.
describe('middleware — published-app router carve-out', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetSessionFromCookies.mockReturnValue(undefined);
});

it('skips the session-cookie check for a proxy call carrying no session', async () => {
mockValidateOriginForMiddleware.mockReturnValue({ valid: true, origin: null, skipped: true, reason: 'no origin' });
mockIsOriginValidationBlocking.mockReturnValue(true);

const request = buildRequest('/api/app-hosting/router');
const response = await middleware(request);

expect(response.status).not.toBe(401);
// createSecureResponse is mocked to always return 200, so the status alone
// would not catch the carve-out being removed — assert the session lookup
// was never reached.
expect(mockGetSessionFromCookies).not.toHaveBeenCalled();
});

it('lets the route own its CSP, so the parked page keeps its inline styles', async () => {
// The API CSP is `default-src 'none'`, which falls style-src back to 'none';
// browsers enforce the intersection of every delivered policy, so without
// the skip the customer-facing "app paused" page renders unstyled.
mockValidateOriginForMiddleware.mockReturnValue({ valid: true, origin: null, skipped: true, reason: 'no origin' });
mockIsOriginValidationBlocking.mockReturnValue(true);

await middleware(buildRequest('/api/app-hosting/router'));

expect(mockCreateSecureResponse).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ skipCSP: true }),
);
});

it('never runs origin validation — published apps have unbounded origins', async () => {
// A published app's own fetch carries its own origin, which is not and can
// never be in our allowlist. If origin validation ran here in blocking mode,
// every non-GET request a published app made to itself would 403.
mockValidateOriginForMiddleware.mockReturnValue({
valid: false,
origin: 'https://acme.pagespace.app',
skipped: false,
reason: 'origin not in allowlist',
});
mockIsOriginValidationBlocking.mockReturnValue(true);

const response = await middleware(
buildRequest('/api/app-hosting/router', { origin: 'https://acme.pagespace.app' }),
);

expect(response.status).not.toBe(403);
expect(mockValidateOriginForMiddleware).not.toHaveBeenCalled();
});

it('lets an OPTIONS preflight reach the route instead of answering it with our CORS policy', async () => {
// A preflight for a published app belongs to THAT app and must be replayed to
// it. The Bearer-API short-circuit would answer 204 with our own
// Access-Control-Allow-Headers, so a published app could never allow a custom
// request header on a cross-origin call.
mockValidateOriginForMiddleware.mockReturnValue({ valid: true, origin: null, skipped: true, reason: 'no origin' });
mockIsOriginValidationBlocking.mockReturnValue(true);

const response = await middleware(
buildRequest('/api/app-hosting/router', {}, 'OPTIONS'),
);

// The mocked createSecureResponse returns 200; the CORS short-circuit would
// have returned a 204 carrying Access-Control-Allow-Methods.
expect(response.headers.get('Access-Control-Allow-Methods')).toBeNull();
expect(response.status).not.toBe(204);
});

it('does not extend the carve-out to sibling app-hosting paths', async () => {
// Exact match only: a future authenticated /api/app-hosting/* route must not
// inherit an exemption meant for the one endpoint the proxy calls.
mockValidateOriginForMiddleware.mockReturnValue({ valid: true, origin: null, skipped: true, reason: 'no origin' });
mockIsOriginValidationBlocking.mockReturnValue(true);

await middleware(buildRequest('/api/app-hosting/apps'));

expect(mockGetSessionFromCookies).toHaveBeenCalled();
});
});

// Regression coverage for a real bug: middleware.ts used to hand-duplicate two
// of the three bearer prefixes `@/lib/auth` actually authenticates (mcp_,
// ps_sess_), silently missing ps_at_ (OAuth access tokens, `pagespace login`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const AUDIT_EXEMPT_ROUTES = new Map<string, string>([
['internal/*', 'Internal service-to-service endpoints'],
['cron/scheduled-backups', 'HMAC-signed internal cron job — no user session, authenticated by shared secret, executes pre-authorized backup schedules'],
['memory/cron', 'Internal memory cron job'],
['app-hosting/router', 'Published-app serving edge, called by pagespace-proxy on EVERY request to a published app (no fly-replay-cache on the metered tier, by design) — there is no user session to attribute, the caller is authenticated by the APP_ROUTER_PROXY_SECRET shared secret like the HMAC cron routes above, and one audit row per served asset would swamp the audit log with routing decisions. The security-relevant outcomes are counted at the edge instead: a refused caller answers 404 and a credit-exhausted app answers 402, both distinguishable in proxy logs.'],
['desktop-bridge/status', 'Desktop app connection status check'],
['provisioning-status/[slug]', 'Tenant provisioning status polling'],

Expand Down
Loading