diff --git a/src/__tests__/pages-api/operationalRoutes.test.ts b/src/__tests__/pages-api/operationalRoutes.test.ts index 6dc94498..605520b6 100644 --- a/src/__tests__/pages-api/operationalRoutes.test.ts +++ b/src/__tests__/pages-api/operationalRoutes.test.ts @@ -1,6 +1,8 @@ import { request, response } from 'src/test-utils/pagesApi'; const mockGetAllConfigs = jest.fn(); +const mockVerifyBearerToken = jest.fn(); +jest.mock('server/lib/auth', () => ({ verifyBearerToken: (...args: unknown[]) => mockVerifyBearerToken(...args) })); const mockTTLQueueAdd = jest.fn(); const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); const mockNanoid = jest.fn(() => 'fixed-id'); @@ -124,20 +126,51 @@ describe('legacy operational API routes', () => { }); describe('/config/cache', () => { + const priorAuth = process.env.ENABLE_AUTH; + const priorIssuer = process.env.KEYCLOAK_ISSUER; + const adminRequest = (overrides: Parameters[0] = {}) => + request({ ...overrides, headers: { authorization: 'Bearer verified-test-token' } }); + beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = 'https://idp.test/realms/lifecycle'; + mockVerifyBearerToken.mockResolvedValue({ + success: true, + payload: { sub: 'admin-1', iss: 'https://idp.test/realms/lifecycle', realm_access: { roles: ['admin'] } }, + }); + }); + afterAll(() => { + if (priorAuth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = priorAuth; + if (priorIssuer === undefined) delete process.env.KEYCLOAK_ISSUER; + else process.env.KEYCLOAK_ISSUER = priorIssuer; + }); + it('denies ordinary/missing identity before loading configuration', async () => { + const res = response(); + await cacheHandler(request(), res); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); + }); + it('denies auth-off even with cached admin claims', async () => { + process.env.ENABLE_AUTH = 'false'; + const res = response(); + await cacheHandler(adminRequest(), res); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); + }); it.each([ ['GET', false], ['PUT', true], ])('%s returns cached configuration with the expected refresh flag', async (method, refresh) => { mockGetAllConfigs.mockResolvedValueOnce({ feature: 'value' }); const res = response(); - await cacheHandler(request({ method }), res); + await cacheHandler(adminRequest({ method }), res); expect(mockGetAllConfigs).toHaveBeenCalledWith(refresh); expect(res.body).toEqual({ configs: { feature: 'value' } }); }); it('advertises allowed methods', async () => { const res = response(); - await cacheHandler(request({ method: 'POST' }), res); + await cacheHandler(adminRequest({ method: 'POST' }), res); expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'PUT']); expect(res.statusCode).toBe(405); }); @@ -145,19 +178,38 @@ describe('legacy operational API routes', () => { it('maps config retrieval failures to the route-specific error', async () => { mockGetAllConfigs.mockRejectedValueOnce(new Error('config unavailable')); const res = response(); - await cacheHandler(request(), res); + await cacheHandler(adminRequest(), res); expect(res.statusCode).toBe(500); expect(res.body).toEqual({ error: 'Unable to retrieve global config values' }); }); - it('maps response failures to the outer stable error', async () => { + it('ignores forged x-user admin claims even with an invalid bearer', async () => { + mockVerifyBearerToken.mockResolvedValue({ success: false }); const res = response(); - (res.setHeader as jest.Mock).mockImplementationOnce(() => { - throw new Error('response unavailable'); - }); - await cacheHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); + await cacheHandler( + request({ + headers: { + authorization: 'Bearer forged', + 'x-user': Buffer.from( + JSON.stringify({ + sub: 'admin-1', + iss: 'https://idp.test/realms/lifecycle', + realm_access: { roles: ['admin'] }, + }) + ).toString('base64'), + }, + }), + res + ); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); + expect(mockVerifyBearerToken).toHaveBeenCalledWith('forged'); + }); + it('denies malformed claims before configuration reads', async () => { + const res = response(); + await cacheHandler(request({ headers: { 'x-user': 'malformed' } }), res); + expect(res.statusCode).toBe(403); + expect(mockGetAllConfigs).not.toHaveBeenCalled(); }); }); }); diff --git a/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts b/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts index be8b7c04..51b75e8b 100644 --- a/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts +++ b/src/app/api/v2/environments/__tests__/environments-api-acceptance.test.ts @@ -66,7 +66,11 @@ jest.mock('server/services/apiToken', () => { const actual = jest.requireActual('server/services/apiToken'); return { __esModule: true, ...actual, default: { verifyToken: jest.fn(), touchLastUsed: jest.fn() } }; }); -jest.mock('server/lib/get-user', () => ({ getRequestUserIdentity: jest.fn() })); +jest.mock('server/lib/get-user', () => ({ + getRequestUserIdentity: jest.fn(), + getUser: jest.fn(), + getOAuthCredentialFromClaims: jest.fn(), +})); jest.mock('server/services/globalConfig', () => { const getAllConfigs = jest.fn(); const getConfig = jest.fn(); diff --git a/src/app/api/v2/me/tokens/route.ts b/src/app/api/v2/me/tokens/route.ts index c9e2f7ae..2329860b 100644 --- a/src/app/api/v2/me/tokens/route.ts +++ b/src/app/api/v2/me/tokens/route.ts @@ -108,6 +108,7 @@ const postHandler = async (req: NextRequest) => { email: identity.email, preferredUsername: identity.preferredUsername, displayName: identity.displayName, + issuer: identity.issuer ?? null, roleAtIssue: identity.roles.includes('admin') ? 'admin' : 'user', }, }); diff --git a/src/app/api/v2/sites/[siteId]/access/route.ts b/src/app/api/v2/sites/[siteId]/access/route.ts new file mode 100644 index 00000000..5a9c348f --- /dev/null +++ b/src/app/api/v2/sites/[siteId]/access/route.ts @@ -0,0 +1,71 @@ +import { NextRequest } from 'next/server'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import type { Principal } from 'server/lib/principal'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { readSiteRevision, readSiteVisibility, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import SitesService, { SitesServiceError } from 'server/services/sites'; + +/** + * @openapi + * /api/v2/sites/{siteId}/access: + * patch: + * summary: Change Site visibility + * operationId: setSiteVisibility + * tags: [Sites] + * security: + * - BearerAuth: [] + * - LifecycleApiKey: [] + * parameters: + * - in: path + * name: siteId + * required: true + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: false + * properties: + * visibility: { type: string, enum: [private, public] } + * expectedAccessRevision: { type: integer, minimum: 1, maximum: 2147483647 } + * required: [visibility, expectedAccessRevision] + * responses: + * '200': + * description: Visibility changed; the Site ID and content URL stay the same. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SiteSuccessResponse' + * '409': + * description: Access revision changed; refetch before retrying. + */ +export const PATCH = createPrincipalApiHandler( + { scope: 'sites:write' }, + async (req: NextRequest, principal: Principal, { params }: { params: Promise<{ siteId: string }> }) => { + try { + let body: unknown; + try { + body = await req.json(); + } catch { + throw new SitesServiceError('Invalid JSON.', 400); + } + if ( + !body || + typeof body !== 'object' || + Array.isArray(body) || + Object.keys(body).some((key) => !['visibility', 'expectedAccessRevision'].includes(key)) + ) { + throw new SitesServiceError('Invalid access request.', 400); + } + const input = body as Record; + const visibility = readSiteVisibility(input.visibility); + const revision = readSiteRevision(input.expectedAccessRevision, true)!; + const site = await new SitesService().setVisibility((await params).siteId, visibility, principal, revision); + return successResponse({ site }, { status: 200 }, req); + } catch (error) { + return sitesErrorResponse(error, req); + } + } +); diff --git a/src/app/api/v2/sites/[siteId]/content/route.ts b/src/app/api/v2/sites/[siteId]/content/route.ts index a90688ed..00f24c56 100644 --- a/src/app/api/v2/sites/[siteId]/content/route.ts +++ b/src/app/api/v2/sites/[siteId]/content/route.ts @@ -17,8 +17,9 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; import { readUploadFile, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { SitesServiceError } from 'server/services/sites'; import SitesService from 'server/services/sites'; export const runtime = 'nodejs'; @@ -76,11 +77,16 @@ type RouteContext = { const putHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { - const upload = await readUploadFile(req); const service = new SitesService(); + const existing = await service.getSite(routeParams.siteId, principal); + if (!existing.permissions.canEdit) throw new SitesServiceError('Site content editing is not permitted.', 403); + const capabilities = await service.getCapabilities(principal); + const upload = await readUploadFile(req, capabilities.upload.maxUploadBytes); + if (upload.visibility !== undefined) + throw new SitesServiceError('Use the access endpoint to change visibility.', 400); const site = await service.replaceSiteContent(routeParams.siteId, { ...upload, - user: principal.identity, + principal, }); return successResponse({ site }, { status: 200 }, req); } catch (error) { diff --git a/src/app/api/v2/sites/[siteId]/extend/route.ts b/src/app/api/v2/sites/[siteId]/extend/route.ts index 8880bfb4..390a1218 100644 --- a/src/app/api/v2/sites/[siteId]/extend/route.ts +++ b/src/app/api/v2/sites/[siteId]/extend/route.ts @@ -17,8 +17,8 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; -import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; import SitesService from 'server/services/sites'; type RouteContext = { @@ -44,6 +44,10 @@ type RouteContext = { * required: true * schema: * type: string + * - in: query + * name: expectedAccessRevision + * required: false + * schema: { type: integer, minimum: 1, maximum: 2147483647 } * responses: * '200': * description: Hosted static site expiration extended. @@ -64,11 +68,15 @@ type RouteContext = { * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ -const postHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { +const postHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { const service = new SitesService(); - const site = await service.extendSite(routeParams.siteId); + const site = await service.extendSite( + routeParams.siteId, + principal, + readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision')) + ); return successResponse({ site }, { status: 200 }, req); } catch (error) { return sitesErrorResponse(error, req); diff --git a/src/app/api/v2/sites/[siteId]/route.ts b/src/app/api/v2/sites/[siteId]/route.ts index 4483414a..6e828293 100644 --- a/src/app/api/v2/sites/[siteId]/route.ts +++ b/src/app/api/v2/sites/[siteId]/route.ts @@ -17,8 +17,8 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; -import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { readSiteRevision, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; import SitesService from 'server/services/sites'; type RouteContext = { @@ -71,6 +71,10 @@ type RouteContext = { * required: true * schema: * type: string + * - in: query + * name: expectedAccessRevision + * required: false + * schema: { type: integer, minimum: 1, maximum: 2147483647 } * responses: * '200': * description: Hosted static site deleted. @@ -85,22 +89,26 @@ type RouteContext = { * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ -const getHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { +const getHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { const service = new SitesService(); - const site = await service.getSite(routeParams.siteId); + const site = await service.getSite(routeParams.siteId, principal); return successResponse({ site }, { status: 200 }, req); } catch (error) { return sitesErrorResponse(error, req); } }; -const deleteHandler = async (req: NextRequest, _principal: Principal, { params }: RouteContext) => { +const deleteHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => { const routeParams = await params; try { const service = new SitesService(); - const site = await service.deleteSite(routeParams.siteId); + const site = await service.deleteSite( + routeParams.siteId, + principal, + readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision')) + ); return successResponse({ site }, { status: 200 }, req); } catch (error) { return sitesErrorResponse(error, req); diff --git a/src/app/api/v2/sites/browser/mint/route.test.ts b/src/app/api/v2/sites/browser/mint/route.test.ts new file mode 100644 index 00000000..9e411bbf --- /dev/null +++ b/src/app/api/v2/sites/browser/mint/route.test.ts @@ -0,0 +1,137 @@ +import { NextRequest } from 'next/server'; +import type { Principal } from 'server/lib/principal'; +import { AppError } from 'server/lib/appError'; +import { SitesBrowserAuth, randomSiteToken } from 'server/lib/sites/browserAuth'; + +const mockResolvePrincipal = jest.fn(); +const mockStatus = jest.fn(); +const mockTokenStatus = jest.fn(); +const mockSite = jest.fn(); +const mockRedisRows = new Map(); +const mockRedis = { + get: async (key: string) => mockRedisRows.get(key) ?? null, + set: async (key: string, value: string) => { + mockRedisRows.set(key, value); + return 'OK'; + }, + eval: async () => 1, +}; +jest.mock('server/lib/principal', () => ({ resolvePrincipal: (...args: unknown[]) => mockResolvePrincipal(...args) })); +jest.mock('server/services/apiToken', () => ({ + scopeSatisfies: (scopes: string[], scope: string) => scopes.includes(scope), +})); +jest.mock('server/services/authRateLimit', () => ({ checkApiKeyRateLimit: async () => ({ allowed: true }) })); +jest.mock('server/services/authAudit', () => ({ recordAuthAuditEvent: jest.fn() })); +jest.mock('server/lib/logger', () => ({ getLogger: () => ({ error: jest.fn(), info: jest.fn() }) })); +jest.mock('server/models/ApiToken', () => ({ __esModule: true, default: {} })); +jest.mock('server/services/globalConfig', () => ({ __esModule: true, default: {} })); +jest.mock('server/lib/redisClient', () => ({ + __esModule: true, + default: { getInstance: () => ({ getRedis: () => mockRedis }) }, +})); +jest.mock('server/services/sites', () => ({ + __esModule: true, + default: jest.fn(() => ({ getGatewaySite: (...args: unknown[]) => mockSite(...args) })), +})); +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: (...args: unknown[]) => mockStatus(...args), + getOAuthTokenStatus: (...args: unknown[]) => mockTokenStatus(...args), +})); +import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; +import { POST } from './route'; +const issuer = 'https://identity.example/realms/lifecycle'; +const host = 'site-abc123.sites.example.net'; +const site = { + siteId: 'abc123', + ownerKind: 'user', + ownerIssuer: issuer, + ownerSubject: 'owner', + visibility: 'private', + accessRevision: 1, +}; +let principal: Principal; +const originalEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = issuer; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; + mockRedisRows.clear(); + principal = { + kind: 'user', + authMethod: 'session', + issuer, + userId: 'owner', + actor: 'owner', + roles: ['user'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + oauth: { + sessionId: 'sid', + tokenId: 'jti', + clientId: 'existing-ui', + expiresAt: Math.floor(Date.now() / 1000) + 300, + }, + }; + rememberVerifiedOAuthBearer(principal, 'verified-request-bearer'); + mockResolvePrincipal.mockReset().mockImplementation(async () => principal); + mockStatus.mockReset().mockResolvedValue('active'); + mockTokenStatus.mockReset().mockResolvedValue('active'); + mockSite.mockReset().mockResolvedValue({ site }); +}); +afterEach(() => { + process.env = { ...originalEnv }; +}); +async function mint(body?: Record) { + const challenge = await new SitesBrowserAuth(mockRedis as any).challenge(site, host, '/'); + return POST( + new NextRequest('https://api.example/api/v2/sites/browser/mint', { + method: 'POST', + headers: { authorization: 'Bearer verified-request-bearer', 'content-type': 'application/json' }, + body: JSON.stringify(body ?? { siteId: site.siteId, state: challenge.state }), + }) + ); +} +it('mints with existing JWT authentication and only siteId/state, without any bridge config', async () => { + const response = await mint(); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect((await response.json()).data).toEqual({ + ticket: expect.any(String), + consumeUrl: `https://${host}/_lfc-sites/consume`, + }); + expect(mockStatus).toHaveBeenCalledWith('owner'); + expect(mockTokenStatus).toHaveBeenCalledWith('verified-request-bearer'); +}); +it.each(['personal_key', 'service_key'] as const)('rejects %s at the real route wrapper', async (kind) => { + principal.kind = kind; + principal.authMethod = 'api_key'; + const response = await mint(); + expect(response.status).toBe(403); + expect(mockTokenStatus).not.toHaveBeenCalled(); + expect(mockSite).not.toHaveBeenCalled(); +}); +it('denies anonymous, unrelated owner, expired JWT and disabled account without issuing tickets', async () => { + mockResolvePrincipal.mockRejectedValueOnce( + new AppError({ httpStatus: 401, code: 'invalid_credential', message: 'Denied' }) + ); + expect((await mint()).status).toBe(401); + principal.userId = 'other'; + expect((await mint()).status).toBe(403); + principal.userId = 'owner'; + principal.oauth!.expiresAt = Math.floor(Date.now() / 1000); + expect((await mint()).status).toBe(401); + principal.oauth!.expiresAt += 300; + mockStatus.mockResolvedValue('disabled'); + expect((await mint()).status).toBe(403); + expect([...mockRedisRows.keys()].some((key) => key.includes(':ticket:'))).toBe(false); +}); +it('denies unknown or revoked incoming credentials and invented owner fields', async () => { + mockTokenStatus.mockResolvedValueOnce('unknown'); + expect((await mint()).status).toBe(503); + mockTokenStatus.mockResolvedValueOnce('revoked'); + expect((await mint()).status).toBe(401); + expect((await mint({ siteId: site.siteId, state: randomSiteToken(), ownerSubject: 'owner' })).status).toBe(400); +}); diff --git a/src/app/api/v2/sites/browser/mint/route.ts b/src/app/api/v2/sites/browser/mint/route.ts new file mode 100644 index 00000000..23e420c9 --- /dev/null +++ b/src/app/api/v2/sites/browser/mint/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import { + getSitesBrowserAuth, + readSitesMintBody, + assertPrivateSitesReady, + SitesBrowserError, +} from 'server/lib/sites/browserAuth'; +import { assertSitesPrincipal } from 'server/lib/sites/policy'; +import SitesService from 'server/services/sites'; + +/** + * @openapi + * /api/v2/sites/browser/mint: + * post: + * operationId: mintSitesBrowserTicket + * summary: Mint a private-site browser bootstrap ticket + * description: Requires a live owner user JWT and a matching gateway challenge. Lifecycle API keys and browser cookies are not accepted. Returns a single-use ticket valid for at most 60 seconds for POST consumption at the supplied content-host URL. The resulting Site-only grant expires at the earlier of JWT expiry and 300 seconds after mint. Renewal requires another authenticated mint. + * tags: [Sites] + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SitesBrowserMintRequest' + * responses: + * '200': + * description: Operation completed. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [data] + * properties: + * data: + * type: object + * required: [ticket, consumeUrl] + * properties: + * ticket: + * type: string + * consumeUrl: + * type: string + * format: uri + * '400': + * description: Invalid request body. + * '401': + * description: Missing, invalid or expired credentials or gateway challenge. + * '403': + * description: The authenticated principal is not permitted to use this operation. + * '404': + * description: Site is unavailable to this owner. + * '413': + * description: Request body exceeds 4096 bytes. + * '500': + * description: Unexpected server or storage dependency failure; completion could not be confirmed. + * '503': + * description: Required authorization or storage dependency is unavailable; no access is granted. + * '429': + * description: Browser ticket mint rate limit exceeded. + */ +export const POST = createPrincipalApiHandler({ scope: 'sites:read', kinds: ['user'] }, async (request, principal) => { + assertPrivateSitesReady(); + await assertSitesPrincipal(principal); + const body = await readSitesMintBody(request); + const auth = getSitesBrowserAuth(); + if (!principal.issuer || !principal.userId || !principal.oauth) throw new SitesBrowserError(401); + await auth.rateLimit(`mint:${principal.issuer}:${principal.userId}`); + const challenge = await auth.readChallenge(body.state); + const { site } = await new SitesService().getGatewaySite(challenge.host); + const result = await auth.mint( + site, + { issuer: principal.issuer, subject: principal.userId }, + body, + principal.oauth.expiresAt + ); + return NextResponse.json({ data: result }, { headers: { 'Cache-Control': 'no-store' } }); +}); diff --git a/src/app/api/v2/sites/browser/open/[siteId]/route.ts b/src/app/api/v2/sites/browser/open/[siteId]/route.ts new file mode 100644 index 00000000..250a783b --- /dev/null +++ b/src/app/api/v2/sites/browser/open/[siteId]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import SitesService from 'server/services/sites'; + +// URL resolution only, never a list or a private metadata projection. +/** + * @openapi + * /api/v2/sites/browser/open/{siteId}: + * get: + * operationId: resolvePublicSiteBrowserUrl + * summary: Resolve a public site's current content URL + * description: Anonymous stable-link resolution. Returns only the current public content URL; private, deleted, and unknown sites receive the same not-found response without private metadata. + * tags: [Sites] + * security: [] + * parameters: + * - in: path + * name: siteId + * required: true + * schema: + * type: string + * pattern: '^[a-z0-9-]{1,64}$' + * responses: + * '200': + * description: Public content URL. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [data] + * properties: + * data: + * type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * '404': + * description: Site not found or not public. Response must not be cached. + * content: + * application/json: + * schema: + * type: object + * required: [error] + * properties: + * error: + * type: object + * required: [message] + * properties: + * message: + * type: string + * enum: ['Site not found.'] + * '500': + * description: Unexpected server or storage dependency failure. + * '503': + * description: Required dependency is unavailable. + */ +export const GET = createPublicApiHandler(async (_request, context: { params: Promise<{ siteId: string }> }) => { + const { siteId } = await context.params; + const headers = { 'Cache-Control': 'no-store' }; + if (!/^[a-z0-9-]{1,64}$/.test(siteId)) + return NextResponse.json({ error: { message: 'Site not found.' } }, { status: 404, headers }); + const url = await new SitesService().resolvePublicSiteUrl(siteId); + return url + ? NextResponse.json({ data: { url } }, { headers }) + : NextResponse.json({ error: { message: 'Site not found.' } }, { status: 404, headers }); +}); diff --git a/src/app/api/v2/sites/capabilities/route.ts b/src/app/api/v2/sites/capabilities/route.ts new file mode 100644 index 00000000..c382b36e --- /dev/null +++ b/src/app/api/v2/sites/capabilities/route.ts @@ -0,0 +1,35 @@ +import { NextRequest } from 'next/server'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import type { Principal } from 'server/lib/principal'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; +import { sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import SitesService from 'server/services/sites'; + +/** + * @openapi + * /api/v2/sites/capabilities: + * get: + * summary: Get sanitized Sites upload capabilities + * operationId: getSitesCapabilities + * tags: [Sites] + * security: + * - BearerAuth: [] + * - LifecycleApiKey: [] + * responses: + * '200': + * description: Capabilities for the authenticated principal; no storage credentials or endpoints. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SitesCapabilitiesSuccessResponse' + */ +export const GET = createPrincipalApiHandler( + { scope: 'sites:read' }, + async (req: NextRequest, principal: Principal) => { + try { + return successResponse(await new SitesService().getCapabilities(principal), { status: 200 }, req); + } catch (error) { + return sitesErrorResponse(error, req); + } + } +); diff --git a/src/app/api/v2/sites/route.test.ts b/src/app/api/v2/sites/route.test.ts index 7020decd..ba4fe6db 100644 --- a/src/app/api/v2/sites/route.test.ts +++ b/src/app/api/v2/sites/route.test.ts @@ -11,6 +11,8 @@ const mockGetSite = jest.fn(); const mockDeleteSite = jest.fn(); const mockReplaceSiteContent = jest.fn(); const mockExtendSite = jest.fn(); +const mockGetCapabilities = jest.fn(); +const mockSetVisibility = jest.fn(); const mockLogger = { error: jest.fn(), info: jest.fn() }; jest.mock('server/lib/principal', () => ({ @@ -43,6 +45,8 @@ jest.mock('server/services/sites', () => { __esModule: true, SitesServiceError, default: jest.fn(() => ({ + getCapabilities: (...args: unknown[]) => mockGetCapabilities(...args), + setVisibility: (...args: unknown[]) => mockSetVisibility(...args), listSites: (...args: unknown[]) => mockListSites(...args), createSite: (...args: unknown[]) => mockCreateSite(...args), getSite: (...args: unknown[]) => mockGetSite(...args), @@ -58,6 +62,8 @@ import { GET as listSites, POST as createSite } from './route'; import { DELETE as deleteSite, GET as getSite } from './[siteId]/route'; import { PUT as replaceContent } from './[siteId]/content/route'; import { POST as extendSite } from './[siteId]/extend/route'; +import { GET as capabilities } from './capabilities/route'; +import { PATCH as visibility } from './[siteId]/access/route'; const identity: NonNullable = { userId: 'user-1', @@ -103,15 +109,28 @@ const site = { name: 'Docs', url: 'https://docs-abc123.sites.example.com', createdBy: 'octo@example.com', + permissions: { canView: true, canEdit: true, canDelete: true, canChangeVisibility: true }, }; -function request(url: string, options: { method?: string; file?: unknown; name?: unknown } = {}): NextRequest { +function request( + url: string, + options: { + method?: string; + file?: unknown; + name?: unknown; + body?: unknown; + expectedAccessRevision?: string; + expectedContentRevision?: string; + } = {} +): NextRequest { const values: Record = { + ...options, ...(options.file === undefined ? {} : { file: options.file }), ...(options.name === undefined ? {} : { name: options.name }), }; return { method: options.method ?? 'GET', + json: jest.fn().mockResolvedValue(options.body), headers: new Headers([['x-request-id', 'req-sites']]), nextUrl: new URL(url), formData: jest.fn().mockResolvedValue({ @@ -138,6 +157,14 @@ describe('hosted sites API routes', () => { sites: [site], pagination: { page: 2, limit: 10, total: 21, totalPages: 3 }, }); + mockGetCapabilities.mockResolvedValue({ + enabled: true, + canCreate: true, + defaultVisibility: 'private', + allowedVisibilities: ['private', 'public'], + upload: { maxUploadBytes: 1024, maxExtractedBytes: 1024, maxFiles: 10, allowedExtensions: ['html', 'zip'] }, + }); + mockSetVisibility.mockResolvedValue(site); mockCreateSite.mockResolvedValue(site); mockGetSite.mockResolvedValue(site); mockDeleteSite.mockResolvedValue({ ...site, deletedAt: '2026-08-27T00:00:00.000Z' }); @@ -146,6 +173,8 @@ describe('hosted sites API routes', () => { }); it.each([ + ['capabilities', capabilities, 'sites:read', undefined], + ['visibility', visibility, 'sites:write', context()], ['list', listSites, 'sites:read', undefined], ['create', createSite, 'sites:write', undefined], ['get', getSite, 'sites:read', context()], @@ -180,7 +209,7 @@ describe('hosted sites API routes', () => { const body = await response.json(); expect(response.status).toBe(200); - expect(mockListSites).toHaveBeenCalledWith({ user: 'octo@example.com', page: 2, limit: 10 }); + expect(mockListSites).toHaveBeenCalledWith({ view: 'mine', page: 2, limit: 10 }, sessionPrincipal); expect(body.data).toEqual({ sites: [site] }); expect(body.metadata).toEqual({ pagination: { page: 2, limit: 10, total: 21, totalPages: 3 } }); }); @@ -188,8 +217,8 @@ describe('hosted sites API routes', () => { it('omits blank and invalid list filters instead of forwarding sentinel values', async () => { const response = await listSites(request('http://localhost/api/v2/sites?user=%20%20&page=nope&limit=')); - expect(response.status).toBe(200); - expect(mockListSites).toHaveBeenCalledWith({}); + expect(response.status).toBe(400); + expect(mockListSites).not.toHaveBeenCalled(); }); it.each([ @@ -216,7 +245,7 @@ describe('hosted sites API routes', () => { fileName: 'site.zip', content: Buffer.from([80, 75, 3, 4]), name: 'Docs', - user: identity, + principal: sessionPrincipal, }); }); @@ -242,7 +271,7 @@ describe('hosted sites API routes', () => { expect(response.status).toBe(200); expect((await response.json()).data).toEqual({ site }); - expect(mockGetSite).toHaveBeenCalledWith('docs-abc123'); + expect(mockGetSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal); }); it('deletes a site by route id and returns its tombstoned representation', async () => { @@ -255,7 +284,7 @@ describe('hosted sites API routes', () => { expect((await response.json()).data.site).toEqual( expect.objectContaining({ id: site.id, deletedAt: expect.any(String) }) ); - expect(mockDeleteSite).toHaveBeenCalledWith('docs-abc123'); + expect(mockDeleteSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, undefined); }); it.each([ @@ -284,7 +313,7 @@ describe('hosted sites API routes', () => { fileName: 'index.html', content: Buffer.from('

Docs

'), name: undefined, - user: identity, + principal: sessionPrincipal, }); }); @@ -321,7 +350,7 @@ describe('hosted sites API routes', () => { expect((await response.json()).data.site).toEqual( expect.objectContaining({ id: site.id, expiresAt: expect.any(String) }) ); - expect(mockExtendSite).toHaveBeenCalledWith('docs-abc123'); + expect(mockExtendSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, undefined); }); it('maps an extension failure when TTL is disabled', async () => { @@ -335,4 +364,56 @@ describe('hosted sites API routes', () => { expect(response.status).toBe(400); expect((await response.json()).error.message).toBe('TTL is disabled for hosted sites.'); }); + it('denies replacement before reading an unauthorized upload', async () => { + mockGetSite.mockResolvedValue({ ...site, permissions: { canEdit: false } }); + const req = request('http://localhost/api/v2/sites/docs-abc123/content', { method: 'PUT', file: uploadFile() }); + expect((await replaceContent(req, context())).status).toBe(403); + expect(req.formData).not.toHaveBeenCalled(); + expect(mockReplaceSiteContent).not.toHaveBeenCalled(); + }); + it('returns sanitized capabilities with no-store', async () => { + const res = await capabilities(request('http://localhost/api/v2/sites/capabilities')); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect((await res.json()).data).toMatchObject({ defaultVisibility: 'private', canCreate: true }); + expect(mockGetCapabilities).toHaveBeenCalledWith(sessionPrincipal); + }); + it.each([ + {}, + { visibility: 'private' }, + { visibility: 'public', expectedAccessRevision: 0 }, + { visibility: 'public', expectedAccessRevision: 1, ownerSubject: 'attacker' }, + ])('rejects invalid access body %p before the service', async (body) => { + expect( + ( + await visibility( + request('http://localhost/api/v2/sites/docs-abc123/access', { method: 'PATCH', body }), + context() + ) + ).status + ).toBe(400); + expect(mockSetVisibility).not.toHaveBeenCalled(); + }); + it('passes visibility and required revision together with the verified principal', async () => { + const body = { visibility: 'private', expectedAccessRevision: 7 }; + const res = await visibility( + request('http://localhost/api/v2/sites/docs-abc123/access', { method: 'PATCH', body }), + context() + ); + expect(res.status).toBe(200); + expect(mockSetVisibility).toHaveBeenCalledWith('docs-abc123', 'private', sessionPrincipal, 7); + }); + it('passes deletion revision and rejects malformed revisions', async () => { + expect( + (await deleteSite(request('http://localhost/api/v2/sites/docs-abc123?expectedAccessRevision=3'), context())) + .status + ).toBe(200); + expect(mockDeleteSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, 3); + mockDeleteSite.mockClear(); + expect( + (await deleteSite(request('http://localhost/api/v2/sites/docs-abc123?expectedAccessRevision=3oops'), context())) + .status + ).toBe(400); + expect(mockDeleteSite).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/v2/sites/route.ts b/src/app/api/v2/sites/route.ts index e606752f..5d663fdd 100644 --- a/src/app/api/v2/sites/route.ts +++ b/src/app/api/v2/sites/route.ts @@ -17,8 +17,9 @@ import { NextRequest } from 'next/server'; import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; import type { Principal } from 'server/lib/principal'; -import { successResponse } from 'server/lib/response'; +import { sitesSuccessResponse as successResponse } from 'server/lib/sites/routeHelpers'; import { readSitesListFilters, readUploadFile, sitesErrorResponse } from 'server/lib/sites/routeHelpers'; +import { SitesServiceError } from 'server/services/sites'; import SitesService from 'server/services/sites'; export const runtime = 'nodejs'; @@ -31,18 +32,17 @@ export const runtime = 'nodejs'; * security: * - BearerAuth: [] * - LifecycleApiKey: [] - * description: Returns all non-deleted hosted static sites. + * description: Returns only authorized hosted sites; private metadata is visible only to its owner. * tags: * - Sites * operationId: listSites * parameters: - * - name: user + * - name: view * in: query - * required: false - * description: Filters to sites created or last updated by the supplied user email. - * schema: - * type: string - * example: user@example.com + * schema: { type: string, enum: [mine, public, all] } + * - name: q + * in: query + * schema: { type: string, maxLength: 200 } * - name: page * in: query * required: false @@ -108,10 +108,10 @@ export const runtime = 'nodejs'; * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ -const getHandler = async (req: NextRequest) => { +const getHandler = async (req: NextRequest, principal: Principal) => { try { const service = new SitesService(); - const result = await service.listSites(readSitesListFilters(req.nextUrl.searchParams)); + const result = await service.listSites(readSitesListFilters(req.nextUrl.searchParams), principal); return successResponse({ sites: result.sites }, { status: 200, metadata: { pagination: result.pagination } }, req); } catch (error) { return sitesErrorResponse(error, req); @@ -120,11 +120,13 @@ const getHandler = async (req: NextRequest) => { const postHandler = async (req: NextRequest, principal: Principal) => { try { - const upload = await readUploadFile(req); const service = new SitesService(); + const capabilities = await service.getCapabilities(principal); + if (!capabilities.canCreate) throw new SitesServiceError('Site creation is unavailable for this credential.', 403); + const upload = await readUploadFile(req, capabilities.upload.maxUploadBytes); const site = await service.createSite({ ...upload, - user: principal.identity, + principal, }); return successResponse({ site }, { status: 201 }, req); } catch (error) { diff --git a/src/pages/api/v1/config/cache.ts b/src/pages/api/v1/config/cache.ts index 7dc5f538..78c0e248 100644 --- a/src/pages/api/v1/config/cache.ts +++ b/src/pages/api/v1/config/cache.ts @@ -16,6 +16,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { getLogger } from 'server/lib/logger'; +import { verifyBearerToken } from 'server/lib/auth'; +import { getIdentityFromClaims } from 'server/lib/get-user'; import GlobalConfigService from 'server/services/globalConfig'; /** @@ -96,6 +98,18 @@ import GlobalConfigService from 'server/services/globalConfig'; */ // eslint-disable-next-line import/no-anonymous-default-export export default async (req: NextApiRequest, res: NextApiResponse) => { + res.setHeader('Cache-Control', 'no-store'); + // V1 bypasses auth middleware: verify bearer cryptographically here; x-user is attacker-controlled. + if (process.env.ENABLE_AUTH !== 'true') return res.status(403).json({ error: 'Administrator session required.' }); + const authorization = req.headers.authorization; + const bearer = + typeof authorization === 'string' && authorization.length <= 16384 ? /^Bearer\s+(\S+)$/i.exec(authorization) : null; + if (!bearer) return res.status(403).json({ error: 'Administrator session required.' }); + const verified = await verifyBearerToken(bearer[1]); + const identity = verified.success ? getIdentityFromClaims(verified.payload ?? null) : null; + if (!identity?.issuer || !identity.roles.includes('admin')) { + return res.status(403).json({ error: 'Administrator session required.' }); + } try { switch (req.method) { case 'GET': diff --git a/src/server/db/migrations/035_sites_access_control.ts b/src/server/db/migrations/035_sites_access_control.ts new file mode 100644 index 00000000..112ad0d9 --- /dev/null +++ b/src/server/db/migrations/035_sites_access_control.ts @@ -0,0 +1,66 @@ +import type { Knex } from 'knex'; + +export const config = { transaction: true }; + +/** Run only behind the Sites writer/gateway upgrade barrier. Legacy rows remain public and unassigned. */ +export async function up(knex: Knex): Promise { + await knex.raw('LOCK TABLE sites IN ACCESS EXCLUSIVE MODE'); + await knex.schema.alterTable('sites', (table) => { + table.string('visibility', 16).notNullable().defaultTo('public'); + table.string('ownerKind', 16).notNullable().defaultTo('unresolved'); + table.string('ownerIssuer', 2048).nullable(); + table.string('ownerSubject', 255).nullable(); + // No FK: deleting or rotating a key must not delete or transfer the original owner's Sites. + table.integer('creatorTokenId').nullable(); + table.integer('accessRevision').notNullable().defaultTo(1); + table.integer('contentRevision').notNullable().defaultTo(1); + table.index(['ownerIssuer', 'ownerSubject'], 'sites_owner_identity_idx'); + table.index(['creatorTokenId'], 'sites_creator_token_idx'); + table.index(['visibility', 'deletedAt'], 'sites_visibility_idx'); + }); + await knex.raw(`ALTER TABLE sites ALTER COLUMN visibility SET DEFAULT 'private'`); + await knex.raw(`ALTER TABLE sites ADD CONSTRAINT sites_access_consistency CHECK ( + visibility IN ('private', 'public') AND "accessRevision" > 0 AND "contentRevision" > 0 AND ( + ("ownerKind" = 'user' AND "ownerIssuer" IS NOT NULL AND length("ownerIssuer") > 0 AND "ownerSubject" IS NOT NULL AND length("ownerSubject") > 0 AND "creatorTokenId" IS NULL) OR + ("ownerKind" = 'service_key' AND "ownerIssuer" IS NULL AND "ownerSubject" IS NULL AND "creatorTokenId" IS NOT NULL AND "creatorTokenId" > 0) OR + ("ownerKind" = 'unresolved' AND "ownerIssuer" IS NULL AND "ownerSubject" IS NULL AND "creatorTokenId" IS NULL) + ) AND (visibility = 'public' OR "ownerKind" = 'user') + )`); + await knex.raw(`CREATE FUNCTION sites_preserve_owner() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF ROW(OLD."ownerKind", OLD."ownerIssuer", OLD."ownerSubject", OLD."creatorTokenId") IS DISTINCT FROM + ROW(NEW."ownerKind", NEW."ownerIssuer", NEW."ownerSubject", NEW."creatorTokenId") THEN + RAISE EXCEPTION 'Site ownership is immutable; use the explicit audited operator recovery transaction'; + END IF; + RETURN NEW; + END $$`); + await knex.raw( + `CREATE TRIGGER sites_preserve_owner BEFORE UPDATE ON sites FOR EACH ROW EXECUTE FUNCTION sites_preserve_owner()` + ); + await knex.schema.alterTable('api_tokens', (table) => table.string('ownerIssuer', 2048).nullable()); + // Existing personal keys remain unbound. Reissue rather than guess their historical realm. +} + +/** + * Operator-approved downgrade: the old gateway serves active, unexpired Sites publicly. + * Site rows, versions, and stored files remain; owner/visibility metadata does not. + * Stop all new core processes before running this migration so startup cannot reapply it. + */ +export async function down(knex: Knex): Promise { + await knex.raw('LOCK TABLE sites IN ACCESS EXCLUSIVE MODE'); + await knex.raw('DROP TRIGGER sites_preserve_owner ON sites'); + await knex.raw('DROP FUNCTION sites_preserve_owner()'); + await knex.raw('ALTER TABLE sites DROP CONSTRAINT sites_access_consistency'); + await knex.raw('DROP INDEX sites_owner_identity_idx'); + await knex.raw('DROP INDEX sites_creator_token_idx'); + await knex.raw('DROP INDEX sites_visibility_idx'); + await knex.raw(`ALTER TABLE sites + DROP COLUMN visibility, + DROP COLUMN "ownerKind", + DROP COLUMN "ownerIssuer", + DROP COLUMN "ownerSubject", + DROP COLUMN "creatorTokenId", + DROP COLUMN "accessRevision", + DROP COLUMN "contentRevision"`); + await knex.raw('ALTER TABLE api_tokens DROP COLUMN "ownerIssuer"'); +} diff --git a/src/server/lib/__tests__/get-user.test.ts b/src/server/lib/__tests__/get-user.test.ts index fc858663..eae10521 100644 --- a/src/server/lib/__tests__/get-user.test.ts +++ b/src/server/lib/__tests__/get-user.test.ts @@ -56,6 +56,23 @@ describe('get-user helpers', () => { restoreEnv(); }); + it('retains only the configured external issuer from verified claims', () => { + const previous = process.env.KEYCLOAK_ISSUER; + try { + process.env.KEYCLOAK_ISSUER = 'https://idp.test/realms/lifecycle'; + expect(getIdentityFromClaims({ sub: 'owner', iss: process.env.KEYCLOAK_ISSUER })?.issuer).toBe( + process.env.KEYCLOAK_ISSUER + ); + expect( + getIdentityFromClaims({ sub: 'owner', iss: 'http://internal-idp/realms/lifecycle' })?.issuer + ).toBeUndefined(); + expect(getIdentityFromClaims({ sub: 'owner' })?.issuer).toBeUndefined(); + } finally { + if (previous === undefined) delete process.env.KEYCLOAK_ISSUER; + else process.env.KEYCLOAK_ISSUER = previous; + } + }); + it('decodes x-user payloads', () => { const payload = getUser(makeRequest({ sub: 'user-123', github_username: 'sample-user' })); expect(payload?.sub).toBe('user-123'); @@ -204,3 +221,27 @@ describe('get-user helpers', () => { ); }); }); + +describe('verified OAuth credential metadata', () => { + const claims = { sid: 'sid', session_state: 'legacy-sid', jti: 'jti', azp: 'cli', exp: 2000000000 }; + test('uses sid, with legacy session_state compatibility', () => { + const { getOAuthCredentialFromClaims } = require('../get-user'); + expect(getOAuthCredentialFromClaims(claims)).toEqual({ + sessionId: 'sid', + tokenId: 'jti', + clientId: 'cli', + expiresAt: 2000000000, + }); + expect(getOAuthCredentialFromClaims({ ...claims, sid: undefined }).sessionId).toBe('legacy-sid'); + }); + test.each([ + { jti: undefined }, + { azp: undefined }, + { sid: undefined, session_state: undefined }, + { exp: Infinity }, + { exp: '2000000000' }, + ])('fails closed for missing/malformed metadata %j', (overrides) => { + const { getOAuthCredentialFromClaims } = require('../get-user'); + expect(getOAuthCredentialFromClaims({ ...claims, ...overrides })).toBeUndefined(); + }); +}); diff --git a/src/server/lib/__tests__/principal.test.ts b/src/server/lib/__tests__/principal.test.ts index 27850aba..1cdb6628 100644 --- a/src/server/lib/__tests__/principal.test.ts +++ b/src/server/lib/__tests__/principal.test.ts @@ -111,6 +111,7 @@ describe('resolvePrincipal with API keys', () => { kind: 'personal_key', authMethod: 'api_key', userId: 'sub-1', + issuer: null, actor: 'sub-1', roles: [], scopes: ['env:read', 'env:write'], @@ -119,6 +120,7 @@ describe('resolvePrincipal with API keys', () => { repositoryAllowlistRepoIds: [42], identity: { userId: 'sub-1', + issuer: null, githubUsername: 'octo', preferredUsername: 'octo-pref', email: 'owner@corp.com', @@ -132,6 +134,20 @@ describe('resolvePrincipal with API keys', () => { }); }); + it('uses only the persisted key issuer and never binds a legacy key to current config', async () => { + const originalIssuer = process.env.KEYCLOAK_ISSUER; + try { + process.env.KEYCLOAK_ISSUER = 'https://new-issuer.test/realms/lifecycle'; + query.findOne.mockResolvedValueOnce(personalRecord({ ownerIssuer: 'https://old-issuer.test/realms/lifecycle' })); + expect((await resolvePrincipal(keyRequest(PAT_TOKEN))).issuer).toBe('https://old-issuer.test/realms/lifecycle'); + query.findOne.mockResolvedValueOnce(personalRecord({ ownerIssuer: null })); + expect((await resolvePrincipal(keyRequest(PAT_TOKEN))).issuer).toBeNull(); + } finally { + if (originalIssuer === undefined) delete process.env.KEYCLOAK_ISSUER; + else process.env.KEYCLOAK_ISSUER = originalIssuer; + } + }); + it('rebuilds the display name through the snapshot chain and falls back to the sub', async () => { query.findOne.mockResolvedValueOnce(personalRecord({ ownerDisplayName: null })); expect((await resolvePrincipal(keyRequest(LEGACY_TOKEN))).identity?.displayName).toBe('octo'); @@ -325,3 +341,25 @@ describe('resolvePrincipal with sessions', () => { expect(principal.identity?.displayName).toBe('local-dev-user'); }); }); + +test('REST principal retains revocation identity only from verified middleware claims', async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 300; + const principal = await resolvePrincipal( + request({ + 'x-user': encodeUser({ + sub: 'owner', + sid: 'login-sid', + jti: 'access-token-id', + azp: 'lifecycle-ui', + exp: expiresAt, + realm_access: { roles: ['user'] }, + }), + }) + ); + expect(principal.oauth).toEqual({ + sessionId: 'login-sid', + tokenId: 'access-token-id', + clientId: 'lifecycle-ui', + expiresAt, + }); +}); diff --git a/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts b/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts index 1afde2f0..07cc252d 100644 --- a/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts +++ b/src/server/lib/__tests__/v2RoutePolicyManifest.test.ts @@ -43,9 +43,12 @@ describe('v2 route policy manifest', () => { expect({ unclassified, stale }).toEqual({ unclassified: [], stale: [] }); }); - it('declares the OAuth callback as the only public method', () => { + it('declares only the OAuth callback anonymous public-Site locator and internally HMAC-authenticated browser revocation outside platform auth', () => { const publicKeys = V2_ROUTE_POLICY_MANIFEST.filter((e) => e.policy === 'public').map(keyOf); - expect(publicKeys).toEqual(['GET /api/v2/ai/agent/mcp-connections/{slug}/oauth/callback']); + expect(publicKeys).toEqual([ + 'GET /api/v2/ai/agent/mcp-connections/{slug}/oauth/callback', + 'GET /api/v2/sites/browser/open/{siteId}', + ]); }); it('keeps every admin-guarded method session-only with the admin role', () => { diff --git a/src/server/lib/apiTokenScopes.ts b/src/server/lib/apiTokenScopes.ts new file mode 100644 index 00000000..e37c942f --- /dev/null +++ b/src/server/lib/apiTokenScopes.ts @@ -0,0 +1,22 @@ +import type { ApiTokenScope } from 'server/models/ApiToken'; + +export const API_TOKEN_SCOPES: ApiTokenScope[] = [ + 'env:read', + 'env:write', + 'env:admin', + 'sites:read', + 'sites:write', + 'repos:read', + 'repos:write', +]; + +/** write ⊃ read within one resource; legacy env:admin covers env:* only; never cross-resource. */ +export function scopeSatisfies(granted: readonly string[], required: ApiTokenScope): boolean { + return granted.some((scope) => { + if (!API_TOKEN_SCOPES.includes(scope as ApiTokenScope)) return false; + if (scope === required) return true; + if (scope === 'env:admin') return required === 'env:read' || required === 'env:write'; + const [resource, action] = scope.split(':'); + return action === 'write' && required === `${resource}:read`; + }); +} diff --git a/src/server/lib/get-user.ts b/src/server/lib/get-user.ts index fc04fb4f..324fc50e 100644 --- a/src/server/lib/get-user.ts +++ b/src/server/lib/get-user.ts @@ -66,7 +66,30 @@ function buildGitFallbackEmail(identifier: string): string { return `${identifier.replace(/[^A-Za-z0-9._-]+/g, '-') || 'local-dev-user'}@local.lifecycle`; } +/** Revocation metadata extracted only from middleware/JWKS-verified OAuth claims. */ +export interface OAuthCredential { + sessionId: string; + tokenId: string; + clientId: string; + expiresAt: number; +} + +export function getOAuthCredentialFromClaims(payload: JWTPayload | null): OAuthCredential | undefined { + const sessionId = normalizeClaim(payload?.sid) || normalizeClaim(payload?.session_state); + const tokenId = normalizeClaim(payload?.jti); + const clientId = normalizeClaim(payload?.azp); + if (!sessionId || !tokenId || !clientId || !Number.isSafeInteger(payload?.exp)) return undefined; + return { + sessionId, + tokenId, + clientId, + expiresAt: Number(payload!.exp), + }; +} + export interface RequestUserIdentity { + /** Verified external identity issuer; never an internal transport URL. */ + issuer?: string | null; userId: string; githubUsername: string | null; preferredUsername: string | null; @@ -102,6 +125,9 @@ function buildUserIdentity(payload: JWTPayload | null, userId: string): RequestU return { userId, + ...(normalizeClaim(claims.iss) && normalizeClaim(claims.iss) === process.env.KEYCLOAK_ISSUER?.trim() + ? { issuer: normalizeClaim(claims.iss) } + : {}), githubUsername, preferredUsername, email, diff --git a/src/server/lib/principal.ts b/src/server/lib/principal.ts index 10030b05..11f9f9cc 100644 --- a/src/server/lib/principal.ts +++ b/src/server/lib/principal.ts @@ -21,12 +21,22 @@ import type ApiToken from 'server/models/ApiToken'; import type { ApiTokenScope } from 'server/models/ApiToken'; import { AppError } from './appError'; import { bearerApiKey } from './apiTokenShape'; -import { getRequestUserIdentity, type RequestUserIdentity } from './get-user'; +import { + getRequestUserIdentity, + getUser, + getOAuthCredentialFromClaims, + type OAuthCredential, + type RequestUserIdentity, +} from './get-user'; import type { LifecycleRole } from './roles'; +import { rememberVerifiedOAuthBearer } from './verifiedOAuthBearer'; export type PrincipalKind = 'user' | 'personal_key' | 'service_key'; export interface Principal { + issuer?: string | null; + /** Verified OAuth token/session identity; absent on API keys and legacy claims. */ + oauth?: OAuthCredential; kind: PrincipalKind; authMethod: 'session' | 'oauth' | 'api_key'; /** OAuth subject; owner subject for personal keys; null for service keys. */ @@ -62,6 +72,7 @@ function personalKeyIdentity(record: ApiToken, ownerUserId: string): RequestUser record.ownerDisplayName ?? record.ownerGithubUsername ?? record.ownerPreferredUsername ?? ownerUserId; return { userId: ownerUserId, + issuer: record.ownerIssuer ?? null, githubUsername: record.ownerGithubUsername ?? null, preferredUsername: record.ownerPreferredUsername ?? null, email: record.ownerEmail ?? null, @@ -106,6 +117,7 @@ async function resolveKeyPrincipal(token: string): Promise { kind: 'personal_key', authMethod: 'api_key', userId: ownerUserId, + issuer: record.ownerIssuer ?? null, actor: ownerUserId, roles: [], scopes: record.scopes, @@ -140,10 +152,12 @@ export async function resolvePrincipal(req: NextRequest): Promise { const identity = getRequestUserIdentity(req); if (identity) { - return { + const principal: Principal = { kind: 'user', authMethod: 'session', userId: identity.userId, + issuer: identity.issuer ?? null, + oauth: getOAuthCredentialFromClaims(getUser(req)), actor: identity.userId, roles: identity.roles, scopes: null, @@ -152,6 +166,9 @@ export async function resolvePrincipal(req: NextRequest): Promise { repositoryAllowlistRepoIds: null, identity, }; + const bearer = /^Bearer\s+(\S+)$/i.exec(req.headers.get('authorization') || '')?.[1]; + if (bearer) rememberVerifiedOAuthBearer(principal, bearer); + return principal; } throw new AppError({ httpStatus: 401, code: 'authentication_required', message: 'Authentication is required.' }); diff --git a/src/server/lib/sites/browserAuth.test.ts b/src/server/lib/sites/browserAuth.test.ts new file mode 100644 index 00000000..217ba8c0 --- /dev/null +++ b/src/server/lib/sites/browserAuth.test.ts @@ -0,0 +1,304 @@ +import { + SitesBrowserAuth, + SitesBrowserError, + randomSiteToken, + siteTokenHash, + challengeCookieName, + assertPrivateSitesReady, + sitesUiOrigin, + assertViewerOwns, + safeSiteReturnPath, + parseSitesCookies, + type BrowserSite, +} from './browserAuth'; + +jest.mock('server/lib/redisClient', () => ({ __esModule: true, default: { getInstance: jest.fn() } })); + +// Atomic test store shared by two protocol instances; actual Redis/ingress acceptance +// remains required. Expiration is modeled so tests do not pretend cached state is live. +class Store { + rows = new Map(); + async get(key: string) { + const row = this.rows.get(key); + return row && row.until > Date.now() ? row.value : null; + } + async set(key: string, value: string, _ex: string, ttl: number, nx?: string) { + const current = this.rows.get(key); + if (nx && current && current.until > Date.now()) return null; + this.rows.set(key, { value, until: Date.now() + ttl * 1000 }); + return 'OK'; + } + async eval(script: string, _count: number, key: string) { + // Read and mutate without yielding: emulate the atomic Redis execution boundary. + const row = this.rows.get(key); + const current = row && row.until > Date.now() ? row.value : null; + if (script.includes('sites-consume')) { + this.rows.delete(key); + return current; + } + const count = Number(current || 0) + 1; + this.rows.set(key, { value: String(count), until: row?.until || Date.now() + 60_000 }); + return count; + } +} +const actor = { + issuer: 'https://identity.example.com/realms/lifecycle', + subject: 'owner', + oauth: { + sessionId: 'sid', + tokenId: 'initial-token-id', + clientId: 'lifecycle-ui', + expiresAt: Math.floor(Date.now() / 1000) + 1200, + }, +}; +const site: BrowserSite = { + siteId: 'abc123', + ownerKind: 'user', + ownerIssuer: actor.issuer, + ownerSubject: actor.subject, + visibility: 'private', + accessRevision: 1, +}; +const host = 'site-abc123.sites.example.net'; +let store: Store; +let auth: SitesBrowserAuth; +const originalEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = actor.issuer; + actor.oauth.expiresAt = Math.floor(Date.now() / 1000) + 1200; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; + store = new Store(); + auth = new SitesBrowserAuth(store as any); +}); +afterEach(() => { + process.env = { ...originalEnv }; + jest.useRealTimers(); +}); +async function setup() { + const challenge = await auth.challenge(site, host, '/docs/page.html?example=1'); + const login = { + siteId: site.siteId, + state: challenge.state, + }; + const minted = await auth.mint(site, actor, login, Math.floor(Date.now() / 1000) + 1200); + const cookies = { [challengeCookieName(challenge.state)]: challenge.secret }; + return { challenge, login, minted, cookies }; +} +it('consumes only once across two gateway instances and creates an owner-scoped session', async () => { + const { minted, cookies } = await setup(); + const second = new SitesBrowserAuth(store as any); + const authorize = jest.fn(async (viewer) => { + assertViewerOwns(site, viewer); + }); + const outcomes = await Promise.allSettled([ + auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, authorize), + second.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, authorize), + ]); + expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const value = (outcomes.find((result) => result.status === 'fulfilled') as PromiseFulfilledResult).value; + expect(value.path).toBe('/docs/page.html?example=1'); + expect(await second.viewer(value.sessionId, host)).toMatchObject({ issuer: actor.issuer, subject: actor.subject }); + expect(authorize).toHaveBeenCalledTimes(1); +}); +it.each(['wrong-origin', 'wrong-host', 'wrong-cookie'])('rejects %s before authorizing', async (scenario) => { + const { minted, cookies } = await setup(); + const authorize = jest.fn(); + await expect( + auth.consume( + minted.ticket, + scenario === 'wrong-host' ? 'another.sites.example.net' : host, + scenario === 'wrong-cookie' ? {} : cookies, + scenario === 'wrong-origin' ? 'https://evil.example.org' : process.env.LIFECYCLE_UI_URL, + authorize + ) + ).rejects.toBeInstanceOf(SitesBrowserError); + expect(authorize).not.toHaveBeenCalled(); +}); +it('expires one-use tickets after 60 seconds', async () => { + jest.useFakeTimers(); + const { minted, cookies } = await setup(); + jest.advanceTimersByTime(61_000); + await expect( + auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, jest.fn()) + ).rejects.toMatchObject({ statusCode: 401 }); +}); +it('never lets a different actor or service-key owner mint a viewer', async () => { + const { login } = await setup(); + await expect(auth.mint(site, { ...actor, subject: 'other' }, login, actor.oauth.expiresAt)).rejects.toMatchObject({ + statusCode: 403, + }); + await expect( + auth.mint({ ...site, ownerKind: 'service_key' }, actor, login, actor.oauth.expiresAt) + ).rejects.toMatchObject({ statusCode: 403 }); +}); +it('runs current authorization on consume and returns no session when ownership changes', async () => { + const { minted, cookies } = await setup(); + await expect( + auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => { + throw new SitesBrowserError(404); + }) + ).rejects.toMatchObject({ statusCode: 404 }); + expect(Array.from(store.rows.keys()).filter((key) => key.includes(':viewer:'))).toHaveLength(0); +}); +it('auth-off denies already issued cookies', async () => { + const { minted, cookies } = await setup(); + const result = await auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}); + process.env.ENABLE_AUTH = 'false'; + await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 503 }); +}); +it('fails closed on Redis failure', async () => { + const { minted, cookies } = await setup(); + store.eval = jest.fn().mockRejectedValue(new Error('redis down')); + await expect(auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, jest.fn())).rejects.toThrow( + 'redis down' + ); +}); +it('requires authentication, HTTPS and a separate registrable domain', () => { + expect(() => assertPrivateSitesReady(`https://${host}`)).not.toThrow(); + expect(() => assertPrivateSitesReady('https://sites.example.com')).toThrow(); + expect(() => assertPrivateSitesReady('http://sites.example.net')).toThrow(); + process.env.ENABLE_AUTH = 'false'; + expect(() => assertPrivateSitesReady()).toThrow(); +}); +it('the shared-apex opt-in only bypasses the same-domain check, never HTTPS', () => { + expect(() => assertPrivateSitesReady('https://sites.example.com')).toThrow(); + process.env.SITES_ALLOW_SHARED_APEX = 'true'; + expect(() => assertPrivateSitesReady('https://sites.example.com')).not.toThrow(); + expect(() => assertPrivateSitesReady('http://sites.example.com')).toThrow(); +}); +it('derives the trusted origin from the existing UI URL', () => { + process.env.LIFECYCLE_UI_URL = 'https://UI.example.com:443/app/?q=test#section'; + expect(sitesUiOrigin()).toBe('https://ui.example.com'); + expect(() => assertPrivateSitesReady(`https://${host}`)).not.toThrow(); +}); +it.each(['', 'not-a-url', 'http://ui.example.com', 'https://user:password@ui.example.com'])( + 'rejects unsafe or missing private UI URL %s', + (url) => { + process.env.LIFECYCLE_UI_URL = url; + expect(() => assertPrivateSitesReady()).toThrow(); + } +); +it('rejects unsafe return paths and ambiguous cookies', () => { + for (const path of [ + '//evil.test', + '/%2f%2fevil.test', + '/%5cevil', + '/../secret', + '/%2e%2e/secret', + '/_lfc-sites/consume', + ]) + expect(() => safeSiteReturnPath(path)).toThrow(); + expect(parseSitesCookies('a=first; a=shadow').a).toBe(''); +}); + +it('bounds chunked mint request bodies before parsing without Content-Length', async () => { + const { readSitesMintBody } = await import('./browserAuth'); + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4097)); + }, + cancel() { + cancelled = true; + }, + }); + await expect(readSitesMintBody({ headers: new Headers(), body: stream })).rejects.toMatchObject({ + statusCode: 413, + }); + expect(cancelled).toBe(true); +}); + +it('consumes at most one ticket from the same browser challenge', async () => { + const { minted, login, cookies } = await setup(); + const second = await auth.mint(site, actor, login, actor.oauth.expiresAt); + await auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}); + await expect( + auth.consume(second.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}) + ).rejects.toMatchObject({ statusCode: 401 }); +}); +it.each([30, 300, 1200])('caps fixed viewer lifetime to token expiry or 300 seconds: %s', async (tokenSeconds) => { + jest.useFakeTimers(); + const start = Math.floor(Date.now() / 1000); + actor.oauth.expiresAt = start + tokenSeconds; + const challenge = await auth.challenge(site, host, '/'); + const minted = await auth.mint(site, actor, { siteId: site.siteId, state: challenge.state }, actor.oauth.expiresAt); + const result = await auth.consume( + minted.ticket, + host, + { [challengeCookieName(challenge.state)]: challenge.secret }, + process.env.LIFECYCLE_UI_URL, + async () => {} + ); + const viewer = await auth.viewer(result.sessionId, host); + expect(viewer.expiresAt).toBe(start + Math.min(tokenSeconds, 300)); + expect(result.maxAge).toBe(Math.min(tokenSeconds, 300)); + const originalRows = JSON.stringify([...store.rows]); + jest.advanceTimersByTime(10_000); + expect(await auth.viewer(result.sessionId, host)).toEqual(viewer); + expect(JSON.stringify([...store.rows])).toBe(originalRows); + jest.advanceTimersByTime(Math.min(tokenSeconds, 300) * 1000); + await expect(auth.viewer(result.sessionId, host)).rejects.toMatchObject({ statusCode: 401 }); +}); +it('supports authenticated renewal with a fresh token but cannot renew with an expired token', async () => { + jest.useFakeTimers(); + const deadline = Math.floor(Date.now() / 1000) + 40; + const challenge = await auth.challenge(site, host, '/'); + const body = { siteId: site.siteId, state: challenge.state }; + await auth.mint(site, actor, body, deadline); + jest.advanceTimersByTime(41_000); + await expect(auth.mint(site, actor, body, deadline)).rejects.toMatchObject({ statusCode: 401 }); + const freshDeadline = Math.floor(Date.now() / 1000) + 300; + const renewed = await auth.mint(site, actor, body, freshDeadline); + const result = await auth.consume( + renewed.ticket, + host, + { [challengeCookieName(challenge.state)]: challenge.secret }, + process.env.LIFECYCLE_UI_URL, + async () => {} + ); + expect((await auth.viewer(result.sessionId, host)).expiresAt).toBe(freshDeadline); +}); +it('retains only Site claims, no OAuth login, token metadata or bearer vault', async () => { + const { minted, cookies } = await setup(); + const result = await auth.consume(minted.ticket, host, cookies, process.env.LIFECYCLE_UI_URL, async () => {}); + expect(Object.keys(await auth.viewer(result.sessionId, host)).sort()).toEqual([ + 'accessRevision', + 'expiresAt', + 'host', + 'issuer', + 'siteId', + 'subject', + ]); + expect([...store.rows.keys()].every((key) => /^sites:browser:v2:(challenge|ticket|viewer|rate):/.test(key))).toBe( + true + ); + expect(JSON.stringify([...store.rows.values()])).not.toContain(actor.oauth.tokenId); +}); +it('rejects previous-version viewers and tickets', async () => { + const token = randomSiteToken(); + for (const kind of ['viewer', 'ticket']) { + await store.set(`sites:browser:v1:${kind}:${siteTokenHash(token)}`, JSON.stringify({ ...actor, host }), 'EX', 300); + } + await expect(auth.viewer(token, host)).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.consume(token, host, {}, process.env.LIFECYCLE_UI_URL, jest.fn())).rejects.toMatchObject({ + statusCode: 401, + }); +}); +it('validates only siteId and state, denying invented owner and legacy login claims', async () => { + const { readSitesMintBody } = await import('./browserAuth'); + const body = { siteId: site.siteId, state: randomSiteToken() }; + const request = (value: unknown) => + new Request('https://api.example/mint', { method: 'POST', body: JSON.stringify(value) }); + await expect(readSitesMintBody(request(body))).resolves.toEqual(body); + for (const value of [ + null, + [], + {}, + { ...body, ownerSubject: 'owner' }, + { ...body, loginId: randomSiteToken() }, + { ...body, state: 'bad' }, + ]) { + await expect(readSitesMintBody(request(value))).rejects.toMatchObject({ statusCode: 400 }); + } +}); diff --git a/src/server/lib/sites/browserAuth.ts b/src/server/lib/sites/browserAuth.ts new file mode 100644 index 00000000..60f2cd6a --- /dev/null +++ b/src/server/lib/sites/browserAuth.ts @@ -0,0 +1,285 @@ +import { createHash, randomBytes } from 'crypto'; +import * as psl from 'psl'; +import type Redis from 'ioredis'; +import RedisClient from 'server/lib/redisClient'; +import { AppError } from 'server/lib/appError'; + +export const SITES_AUTH_PATH = '/_lfc-sites/'; +export const SITES_VIEWER_COOKIE = '__Host-lfc-sites-viewer'; +export const MAX_VIEWER_SECONDS = 300; +const TOKEN = /^[A-Za-z0-9_-]{43}$/; +// Invalidate pre-simplification login/vault-backed browser state. +const PREFIX = 'sites:browser:v2:'; +const CONSUME = `-- sites-consume +local value = redis.call('GET', KEYS[1]) +if value then redis.call('DEL', KEYS[1]) end +return value`; +const LIMIT = `-- sites-limit +local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], 60) end +return count`; + +export class SitesBrowserError extends AppError { + constructor(public statusCode: number, message = 'Site access is unavailable.') { + super({ httpStatus: statusCode, code: 'sites_browser_unavailable', message }); + } +} +export type BrowserActor = { issuer: string; subject: string }; +export type BrowserSite = { + siteId: string; + ownerKind: string; + ownerIssuer?: string | null; + ownerSubject?: string | null; + visibility: string; + accessRevision: number; +}; +type Challenge = { siteId: string; host: string; secretHash: string; path: string }; +export type Viewer = BrowserActor & { + siteId: string; + host: string; + accessRevision: number; + expiresAt: number; +}; +type Ticket = Viewer & { challengeId: string; challenge: Challenge }; +export type SitesBrowserMintBody = { siteId: string; state: string }; +export const randomSiteToken = () => randomBytes(32).toString('base64url'); +export const siteTokenHash = (value: string) => createHash('sha256').update(value).digest('hex'); +const nowSeconds = () => Math.floor(Date.now() / 1000); + +export function sitesUiOrigin(): string { + const raw = process.env.LIFECYCLE_UI_URL; + try { + const url = new URL(raw || ''); + if (url.protocol !== 'https:' || url.username || url.password) throw new Error(); + return url.origin; + } catch { + throw new SitesBrowserError(503); + } +} +export function assertPrivateSitesReady(contentUrl?: string): void { + if (process.env.ENABLE_AUTH !== 'true') throw new SitesBrowserError(503); + const ui = new URL(sitesUiOrigin()); + if (contentUrl) { + const content = new URL(contentUrl); + const uiDomain = psl.get(ui.hostname); + const contentDomain = psl.get(content.hostname); + // A shared registrable domain lets SameSite treat the UI and hosted content as one site. + // Only bypass once the deployment's gateway CSP restricts hosted-content egress and its + // auth cookies are confirmed host-only. + const sharedApexAllowed = process.env.SITES_ALLOW_SHARED_APEX === 'true'; + if ( + content.protocol !== 'https:' || + !uiDomain || + !contentDomain || + (uiDomain === contentDomain && !sharedApexAllowed) + ) + throw new SitesBrowserError(503); + } +} +export function safeSiteReturnPath(path: string): string { + if (path.length > 2048 || !path.startsWith('/') || path.startsWith('//') || /[\\\r\n\0]/.test(path)) + throw new SitesBrowserError(400); + let decoded: string; + try { + decoded = decodeURIComponent(path.split('?')[0]); + } catch { + throw new SitesBrowserError(400); + } + if ( + /[\\\r\n\0]/.test(decoded) || + decoded.startsWith('//') || + decoded.split('/').some((part) => part === '.' || part === '..') || + decoded.startsWith(SITES_AUTH_PATH) + ) + throw new SitesBrowserError(400); + return path; +} +export function challengeCookieName(state: string): string { + if (!TOKEN.test(state)) throw new SitesBrowserError(400); + return `__Host-lfc-sites-challenge-${state}`; +} +export function parseSitesCookies(raw?: string): Record { + const result: Record = Object.create(null); + for (const part of (raw || '').split(';')) { + const index = part.indexOf('='); + if (index < 0) continue; + const name = part.slice(0, index).trim(); + if (name in result) { + result[name] = ''; + continue; + } + result[name] = part.slice(index + 1).trim(); + } + return result; +} +export function assertViewerOwns(site: BrowserSite, actor: BrowserActor): void { + assertPrivateSitesReady(); + // Internal tool: 403 here (not 404) so a denied teammate knows to ask the owner. + if (site.ownerKind !== 'user' || site.ownerIssuer !== actor.issuer || site.ownerSubject !== actor.subject) + throw new SitesBrowserError(403, 'You do not have access to this Site.'); +} + +export async function readSitesMintBody(request: { + headers: Headers; + body: ReadableStream | null; +}): Promise { + const limit = 4096; + if (Number(request.headers.get('content-length') || 0) > limit) throw new SitesBrowserError(413); + if (!request.body) throw new SitesBrowserError(400); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const result = await reader.read(); + if (result.done) break; + length += result.value.byteLength; + if (length > limit) throw new SitesBrowserError(413); + chunks.push(result.value); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + let body: unknown; + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new SitesBrowserError(400); + } + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new SitesBrowserError(400); + const value = body as Record; + if ( + Object.keys(value).some((key) => key !== 'siteId' && key !== 'state') || + typeof value.siteId !== 'string' || + !/^[a-z0-9-]{1,64}$/.test(value.siteId) || + typeof value.state !== 'string' || + !TOKEN.test(value.state) + ) + throw new SitesBrowserError(400); + return { siteId: value.siteId, state: value.state }; +} + +/** Fixed authorization deadline: reads never renew the grant. */ +export function assertViewerActive(viewer: Viewer): void { + if (!Number.isSafeInteger(viewer.expiresAt) || viewer.expiresAt <= nowSeconds()) throw new SitesBrowserError(401); +} + +export class SitesBrowserAuth { + constructor(private readonly redis: Pick) {} + private key(kind: string, value: string) { + return `${PREFIX}${kind}:${siteTokenHash(value)}`; + } + async rateLimit(identity: string, maximum = 60): Promise { + if (Number(await this.redis.eval(LIMIT, 1, this.key('rate', identity))) > maximum) throw new SitesBrowserError(429); + } + async challenge(site: Pick, host: string, path: string) { + assertPrivateSitesReady(`https://${host}`); + const state = randomSiteToken(); + const secret = randomSiteToken(); + const challenge: Challenge = { + siteId: site.siteId, + host, + secretHash: siteTokenHash(secret), + path: safeSiteReturnPath(path), + }; + await this.redis.set(this.key('challenge', state), JSON.stringify(challenge), 'EX', 300); + return { state, secret }; + } + async readChallenge(state: string): Promise { + if (!TOKEN.test(state)) throw new SitesBrowserError(400); + const raw = await this.redis.get(this.key('challenge', state)); + if (!raw) throw new SitesBrowserError(401); + return JSON.parse(raw) as Challenge; + } + /** Called only after normal JWT, current credential and ownership authorization. */ + async mint( + site: BrowserSite, + actor: BrowserActor, + body: SitesBrowserMintBody, + tokenExpiresAt: number + ): Promise<{ ticket: string; consumeUrl: string }> { + assertViewerOwns(site, actor); + if (!body.state || body.siteId !== site.siteId) throw new SitesBrowserError(401); + const challenge = await this.readChallenge(body.state); + if (challenge.siteId !== site.siteId) throw new SitesBrowserError(401); + assertPrivateSitesReady(`https://${challenge.host}`); + if (!Number.isSafeInteger(tokenExpiresAt)) throw new SitesBrowserError(401); + const expiresAt = Math.min(nowSeconds() + MAX_VIEWER_SECONDS, tokenExpiresAt); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= nowSeconds()) throw new SitesBrowserError(401); + const value: Ticket = { + issuer: actor.issuer, + subject: actor.subject, + siteId: site.siteId, + host: challenge.host, + accessRevision: site.accessRevision, + expiresAt, + challengeId: body.state, + challenge, + }; + const ticket = randomSiteToken(); + await this.redis.set( + this.key('ticket', ticket), + JSON.stringify(value), + 'EX', + Math.min(60, expiresAt - nowSeconds()) + ); + return { ticket, consumeUrl: `https://${challenge.host}${SITES_AUTH_PATH}consume` }; + } + async consume( + ticket: string, + host: string, + cookies: Record, + origin: string | undefined, + authorize: (viewer: Viewer) => Promise + ) { + assertPrivateSitesReady(`https://${host}`); + if (origin !== sitesUiOrigin() || !TOKEN.test(ticket)) throw new SitesBrowserError(401); + const raw = await this.redis.eval(CONSUME, 1, this.key('ticket', ticket)); + if (typeof raw !== 'string') throw new SitesBrowserError(401); + const value = JSON.parse(raw) as Ticket; + const cookie = cookies[challengeCookieName(value.challengeId)]; + if (value.host !== host || !cookie || siteTokenHash(cookie) !== value.challenge.secretHash) + throw new SitesBrowserError(401); + assertViewerActive(value); + await authorize(value); + // Deleting challenge prevents a second separately minted ticket reusing this bootstrap. + const challenge = await this.redis.eval(CONSUME, 1, this.key('challenge', value.challengeId)); + if (typeof challenge !== 'string') throw new SitesBrowserError(401); + assertViewerActive(value); + const sessionId = randomSiteToken(); + await this.redis.set( + this.key('viewer', sessionId), + JSON.stringify({ + issuer: value.issuer, + subject: value.subject, + siteId: value.siteId, + host: value.host, + accessRevision: value.accessRevision, + expiresAt: value.expiresAt, + }), + 'EX', + Math.max(1, value.expiresAt - nowSeconds()) + ); + return { + sessionId, + path: safeSiteReturnPath(value.challenge.path), + maxAge: value.expiresAt - nowSeconds(), + challengeName: challengeCookieName(value.challengeId), + }; + } + async viewer(sessionId: string | undefined, host: string): Promise { + assertPrivateSitesReady(`https://${host}`); + if (!sessionId || !TOKEN.test(sessionId)) throw new SitesBrowserError(401); + const raw = await this.redis.get(this.key('viewer', sessionId)); + if (!raw) throw new SitesBrowserError(401); + const value = JSON.parse(raw) as Viewer; + if (value.host !== host) throw new SitesBrowserError(401); + assertViewerActive(value); + return value; + } +} +let instance: SitesBrowserAuth | undefined; +export function getSitesBrowserAuth(): SitesBrowserAuth { + return (instance ??= new SitesBrowserAuth(RedisClient.getInstance().getRedis())); +} diff --git a/src/server/lib/sites/config.test.ts b/src/server/lib/sites/config.test.ts index 2eb26a3c..8e624c22 100644 --- a/src/server/lib/sites/config.test.ts +++ b/src/server/lib/sites/config.test.ts @@ -158,3 +158,15 @@ describe('sites storage configuration', () => { expect(config.storage.secretAccessKey).toBeUndefined(); }); }); + +describe('stable site hosts', () => { + it('keeps the original host format and rejects retired generation hosts', () => { + const config = resolveSitesConfig({ domain: 'sites.example.net' }); + const generated = buildSiteUrl('abc123', config); + expect(generated).toBe('https://site-abc123.sites.example.net'); + expect(parseSiteIdFromHost(new URL(generated).host, config)).toBe('abc123'); + expect(parseSiteIdFromHost('site-abc123--g-x.sites.example.net', config)).toBeNull(); + expect(parseSiteIdFromHost('site-abc123--g-abcdef012345.sites.example.net', config)).toBeNull(); + expect(parseSiteIdFromHost('site-abc123.sites.example.net@evil.test', config)).toBeNull(); + }); +}); diff --git a/src/server/lib/sites/config.ts b/src/server/lib/sites/config.ts index 6b67080a..4a8c1430 100644 --- a/src/server/lib/sites/config.ts +++ b/src/server/lib/sites/config.ts @@ -181,32 +181,24 @@ export function resolveSitesConfig(config?: SitesConfig | null): ResolvedSitesCo } export function buildSiteUrl(siteId: string, config: ResolvedSitesConfig): string { - const protocol = config.domain === 'localhost' || config.domain.endsWith('.localhost') ? 'http' : 'https'; + const protocol = + process.env.SITES_GATEWAY_HTTPS === 'true' + ? 'https' + : config.domain === 'localhost' || config.domain.endsWith('.localhost') + ? 'http' + : 'https'; const port = config.port ? `:${config.port}` : ''; return `${protocol}://${config.hostPrefix}-${siteId}.${config.domain}${port}`; } export function parseSiteIdFromHost(hostHeader: string | undefined, config: ResolvedSitesConfig): string | null { - if (!hostHeader) { - return null; - } - - const host = hostHeader.split(':')[0]?.toLowerCase(); - if (!host) { - return null; - } - + if (!hostHeader || !/^[a-z0-9.-]+(?::[0-9]+)?$/i.test(hostHeader)) return null; + const host = hostHeader.split(':')[0].toLowerCase(); const suffix = `.${config.domain.toLowerCase()}`; - if (!host.endsWith(suffix)) { - return null; - } - + if (!host.endsWith(suffix)) return null; const label = host.slice(0, -suffix.length); const prefix = `${config.hostPrefix}-`; - if (!label.startsWith(prefix)) { - return null; - } - + if (!label.startsWith(prefix)) return null; const siteId = label.slice(prefix.length); - return /^[a-z0-9-]+$/.test(siteId) ? siteId : null; + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(siteId) ? siteId : null; } diff --git a/src/server/lib/sites/gateway.test.ts b/src/server/lib/sites/gateway.test.ts new file mode 100644 index 00000000..63544322 --- /dev/null +++ b/src/server/lib/sites/gateway.test.ts @@ -0,0 +1,243 @@ +import { PassThrough } from 'stream'; +import { handleSitesRequest, authorizeSitesViewer } from './gateway'; +import { SitesBrowserError } from './browserAuth'; +import { assertSitesPrincipal } from './policy'; + +const browser = { + viewer: jest.fn(), + challenge: jest.fn(), + rateLimit: jest.fn(), + consume: jest.fn(), +}; +jest.mock('./policy', () => ({ assertSitesPrincipal: jest.fn() })); +jest.mock('./browserAuth', () => ({ ...jest.requireActual('./browserAuth'), getSitesBrowserAuth: () => browser })); +jest.mock('server/lib/redisClient', () => ({ __esModule: true, default: { getInstance: jest.fn() } })); +const site = { + siteId: 'abc123', + ownerKind: 'user', + ownerIssuer: 'https://id.example.com', + ownerSubject: 'owner', + accessRevision: 2, + visibility: 'private', +}; +const viewer = { + issuer: site.ownerIssuer, + subject: site.ownerSubject, + siteId: site.siteId, + accessRevision: 2, + host: 'site-abc123.sites.example.net', + expiresAt: 1e12, +}; +function request(method = 'GET', headers = {}, url = '/') { + return { + method, + headers: { host: viewer.host, ...headers }, + url, + socket: { remoteAddress: 'test' }, + async *[Symbol.asyncIterator]() {}, + } as any; +} +function response() { + return { + statusCode: 200, + headers: {} as Record, + headersSent: false, + setHeader(name: string, value: unknown) { + this.headers[name] = value; + }, + writeHead(status: number, headers?: Record) { + this.statusCode = status; + Object.assign(this.headers, headers); + return this; + }, + end: jest.fn(), + } as any; +} +function service(current = site) { + return { + getGatewayLocator: jest.fn(async () => ({ siteId: current.siteId })), + getGatewaySite: jest.fn(async () => ({ site: current, config: {} })), + getGatewayObject: jest.fn(async (_host, _path, authorize) => { + if (current.visibility === 'private') await authorize(current); + return { statusCode: 200, contentType: 'text/plain', contentLength: 4, body: new PassThrough() }; + }), + } as any; +} +const originalEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; + jest.clearAllMocks(); + browser.viewer.mockResolvedValue(viewer); + (assertSitesPrincipal as jest.Mock).mockResolvedValue(undefined); +}); +afterEach(() => { + process.env = { ...originalEnv }; +}); +it.each(['/', '/style.css', '/app.js', '/private.json'])( + 'denies unauthenticated %s without fetching storage', + async (path) => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + const api = service(); + const res = response(); + await handleSitesRequest(request('GET', {}, path), res, api); + expect(res.statusCode).toBe(404); + expect(api.getGatewayObject).not.toHaveBeenCalled(); + expect(res.headers['Cache-Control']).toBe('no-store'); + } +); +it('denies another authenticated user and a stale access revision', async () => { + await expect(authorizeSitesViewer(site as any, { ...viewer, subject: 'other' })).rejects.toMatchObject({ + statusCode: 403, + }); + await expect(authorizeSitesViewer(site as any, { ...viewer, accessRevision: 1 })).rejects.toMatchObject({ + statusCode: 401, + }); +}); +it('authorizes HEAD with current Site checks and no incoming-principal or IdP policy calls', async () => { + const api = service(); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.statusCode).toBe(200); + expect(res.headers['Cross-Origin-Opener-Policy']).toBe('same-origin'); + expect(res.headers['Origin-Agent-Cluster']).toBe('?1'); + expect(res.headers['Cross-Origin-Resource-Policy']).toBe('same-origin'); + expect(res.headers['Content-Security-Policy']).toBe("worker-src 'none'; frame-ancestors 'none'"); + expect(api.getGatewayObject).toHaveBeenCalledTimes(1); + expect(assertSitesPrincipal).not.toHaveBeenCalled(); +}); +it('restricts hosted-content network egress when the shared-apex opt-in is on', async () => { + process.env.SITES_ALLOW_SHARED_APEX = 'true'; + const api = service(); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.headers['Content-Security-Policy']).toBe( + "worker-src 'none'; frame-ancestors 'none'; connect-src 'self'; form-action 'self'" + ); +}); +it('never returns a login page or redirect for an unauthenticated asset/HEAD', async () => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + const api = service(); + const res = response(); + await handleSitesRequest(request('HEAD', { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }), res, api); + expect(res.statusCode).toBe(404); + expect(res.headers.Location).toBeUndefined(); + expect(browser.challenge).not.toHaveBeenCalled(); +}); +it('only navigations get a browser-bound challenge redirect', async () => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + browser.challenge.mockResolvedValue({ state: 's'.repeat(43), secret: 'c'.repeat(43) }); + const res = response(); + await handleSitesRequest( + request('GET', { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }), + res, + service() + ); + expect(res.statusCode).toBe(303); + expect(res.headers.Location).toContain('https://ui.example.com/sites/open/abc123?state='); + expect(res.headers['Set-Cookie']).toContain('SameSite=None'); +}); +it('blocks service workers and unsupported methods without storage', async () => { + for (const req of [request('GET', { 'service-worker': 'script' }), request('POST')]) { + const api = service(); + await handleSitesRequest(req, response(), api); + expect(api.getGatewayObject).not.toHaveBeenCalled(); + } +}); +it('never falls back to public bytes when Redis fails', async () => { + browser.viewer.mockRejectedValue(new Error('Redis down')); + const api = service(); + const res = response(); + await handleSitesRequest(request(), res, api); + expect(res.statusCode).toBe(404); + expect(api.getGatewayObject).not.toHaveBeenCalled(); +}); +it('public HEAD does not inspect viewer credentials', async () => { + const api = service({ ...site, visibility: 'public' }); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.statusCode).toBe(200); + expect(browser.viewer).not.toHaveBeenCalled(); + expect(assertSitesPrincipal).not.toHaveBeenCalled(); +}); + +it('ignores forwarded addresses when rate limiting bootstrap requests', async () => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + browser.challenge.mockResolvedValue({ state: 's'.repeat(43), secret: 'c'.repeat(43) }); + for (const forwarded of ['1.1.1.1', '2.2.2.2']) { + const req = request('GET', { + 'x-forwarded-for': forwarded, + 'sec-fetch-mode': 'navigate', + 'sec-fetch-dest': 'document', + }); + req.socket.remoteAddress = '203.0.113.5'; + await handleSitesRequest(req, response(), service()); + } + expect(browser.rateLimit.mock.calls).toEqual([['challenge:203.0.113.5'], ['challenge:203.0.113.5']]); +}); + +it.each([ + ['GET', {}, '/'], + ['HEAD', {}, '/'], + ['GET', { 'service-worker': 'script' }, '/sw.js'], + ['GET', { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }, '/'], + ['POST', {}, '/_lfc-sites/consume'], + ['POST', { 'content-type': 'application/x-www-form-urlencoded' }, '/_lfc-sites/consume'], + ['POST', { origin: 'https://unrelated.example' }, '/_lfc-sites/logout'], +])('matches missing and private denial responses for %s %j %s', async (method, headers, path) => { + browser.viewer.mockRejectedValue(new SitesBrowserError(401)); + browser.challenge.mockResolvedValue({ state: 's'.repeat(43), secret: 'c'.repeat(43) }); + const existing = service(); + const missing = service(); + missing.getGatewaySite.mockRejectedValue(new SitesBrowserError(404)); + const first = response(); + const second = response(); + await handleSitesRequest(request(method, headers, path), first, existing); + await handleSitesRequest(request(method, headers, path), second, missing); + expect(first.statusCode).toBe(second.statusCode); + expect(first.headers).toEqual(second.headers); + expect(first.end.mock.calls).toEqual(second.end.mock.calls); + expect(existing.getGatewayObject).not.toHaveBeenCalled(); + expect(missing.getGatewayObject).not.toHaveBeenCalled(); +}); +it('rejects a concurrent Site change or grant expiry immediately before storage', async () => { + for (const changed of [{ accessRevision: 3 }, { ownerSubject: 'other' }, { siteId: 'other' }]) { + const api = service(); + api.getGatewayObject.mockImplementation(async (_host: string, _path: string, authorize: any) => { + await authorize({ ...site, ...changed }); + throw new Error('must not reach storage'); + }); + const res = response(); + await handleSitesRequest(request('HEAD'), res, api); + expect(res.statusCode).toBe(404); + } + await expect( + authorizeSitesViewer(site as any, { ...viewer, expiresAt: Math.floor(Date.now() / 1000) }) + ).rejects.toMatchObject({ statusCode: 401 }); + expect(assertSitesPrincipal).not.toHaveBeenCalled(); +}); + +it.each([new SitesBrowserError(401), new Error('Redis unavailable'), new SitesBrowserError(503)])( + 'keeps missing/private responses identical when viewer authorization fails: %s', + async (failure) => { + browser.viewer.mockRejectedValue(failure); + browser.challenge.mockRejectedValue(new SitesBrowserError(503)); + for (const headers of [ + { cookie: 'lfc-viewer=' + 'x'.repeat(43) }, + { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }, + ]) { + const existing = service(); + const missing = service(); + missing.getGatewaySite.mockRejectedValue(new SitesBrowserError(404)); + const a = response(); + const b = response(); + await handleSitesRequest(request('GET', headers, '/asset.js'), a, existing); + await handleSitesRequest(request('GET', headers, '/asset.js'), b, missing); + expect(a.statusCode).toBe(b.statusCode); + expect(a.headers).toEqual(b.headers); + expect(a.end.mock.calls).toEqual(b.end.mock.calls); + expect(existing.getGatewayObject).not.toHaveBeenCalled(); + expect(missing.getGatewayObject).not.toHaveBeenCalled(); + } + } +); diff --git a/src/server/lib/sites/gateway.ts b/src/server/lib/sites/gateway.ts new file mode 100644 index 00000000..0ccfa75b --- /dev/null +++ b/src/server/lib/sites/gateway.ts @@ -0,0 +1,173 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type Site from 'server/models/Site'; +import { + assertViewerOwns, + assertViewerActive, + challengeCookieName, + getSitesBrowserAuth, + parseSitesCookies, + SITES_AUTH_PATH, + SITES_VIEWER_COOKIE, + SitesBrowserError, + sitesUiOrigin, + type Viewer, +} from './browserAuth'; +import type SitesService from 'server/services/sites'; + +export async function authorizeSitesViewer(site: Site, viewer: Viewer): Promise { + assertViewerActive(viewer); + assertViewerOwns(site, viewer); + if (site.siteId !== viewer.siteId || site.accessRevision !== viewer.accessRevision) throw new SitesBrowserError(401); +} +function securityHeaders(res: ServerResponse) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); +} +export async function readSmallBody(req: IncomingMessage, limit = 4096): Promise { + const chunks: Buffer[] = []; + let length = 0; + for await (const chunk of req) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + length += bytes.length; + if (length > limit) throw new SitesBrowserError(413); + chunks.push(bytes); + } + return Buffer.concat(chunks).toString('utf8'); +} +export async function handleSitesRequest( + req: IncomingMessage, + res: ServerResponse, + service: SitesService +): Promise { + securityHeaders(res); + try { + const host = (req.headers.host || '').toLowerCase(); + const url = new URL(req.url || '/', `https://${host}`); + const navigate = + req.method === 'GET' && + req.headers['sec-fetch-mode'] === 'navigate' && + req.headers['sec-fetch-dest'] === 'document' && + !url.pathname.startsWith(SITES_AUTH_PATH); + const bootstrap = async () => { + // A locator is syntax, not proof that a Site exists. Missing and inaccessible + // private hosts take the same browser flow; mint still authorizes the real Site. + const locator = await service.getGatewayLocator(host); + const browser = getSitesBrowserAuth(); + await browser.rateLimit(`challenge:${req.socket.remoteAddress || 'unknown'}`); + const challenge = await browser.challenge(locator, host, `${url.pathname}${url.search}`); + res.setHeader( + 'Set-Cookie', + `${challengeCookieName(challenge.state)}=${ + challenge.secret + }; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=300` + ); + const open = new URL(`/sites/open/${encodeURIComponent(locator.siteId)}`, sitesUiOrigin()); + open.searchParams.set('state', challenge.state); + res.writeHead(303, { Location: open.toString() }).end(); + }; + const cookies = parseSitesCookies(req.headers.cookie); + if (url.pathname.startsWith(SITES_AUTH_PATH)) { + const auth = getSitesBrowserAuth(); + await service.getGatewayLocator(host); + if (url.pathname === `${SITES_AUTH_PATH}consume` && req.method === 'POST') { + if (!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) + throw new SitesBrowserError(415); + await auth.rateLimit(`consume:${req.socket.remoteAddress || 'unknown'}`, 240); + const values = new URLSearchParams(await readSmallBody(req)); + if (values.getAll('ticket').length !== 1) throw new SitesBrowserError(400); + const result = await auth.consume( + values.get('ticket') || '', + host, + cookies, + typeof req.headers.origin === 'string' ? req.headers.origin : undefined, + async (viewer) => authorizeSitesViewer((await service.getGatewaySite(host)).site, viewer) + ); + res.setHeader('Set-Cookie', [ + `${SITES_VIEWER_COOKIE}=${result.sessionId}; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=${result.maxAge}`, + `${result.challengeName}=; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=0`, + ]); + res.writeHead(303, { Location: result.path }); + res.end(); + return; + } + throw new SitesBrowserError(404); + } + if (req.method !== 'GET' && req.method !== 'HEAD') throw new SitesBrowserError(404); + let site: Site; + try { + ({ site } = await service.getGatewaySite(host)); + } catch (error) { + if (navigate && (error as { statusCode?: number }).statusCode === 404) { + await bootstrap(); + return; + } + throw error; + } + const auth = site.visibility === 'private' ? getSitesBrowserAuth() : undefined; + + let viewer: Viewer | undefined; + if (site.visibility === 'private') { + try { + viewer = await auth!.viewer(cookies[SITES_VIEWER_COOKIE], host); + await authorizeSitesViewer(site, viewer); + } catch { + // Until access is established, dependency failures must not distinguish a + // private host from a missing one. Navigations share the same bootstrap. + if (!navigate) throw new SitesBrowserError(404); + await bootstrap(); + return; + } + if ( + req.headers['service-worker'] === 'script' || + ['serviceworker', 'sharedworker', 'worker'].includes(String(req.headers['sec-fetch-dest'])) + ) + throw new SitesBrowserError(403); + } + const object = await service.getGatewayObject(host, url.pathname, async (currentSite) => { + if (!viewer) throw new SitesBrowserError(401); + // Recheck the fixed deadline and current Site immediately before storage. + await authorizeSitesViewer(currentSite, viewer); + }); + if (site.visibility === 'private') { + // Add private isolation only after both authorization checks, so denial headers + // cannot distinguish private hosts from missing ones. + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Origin-Agent-Cluster', '?1'); + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + const csp = + process.env.SITES_ALLOW_SHARED_APEX === 'true' + ? "worker-src 'none'; frame-ancestors 'none'; connect-src 'self'; form-action 'self'" + : "worker-src 'none'; frame-ancestors 'none'"; + res.setHeader('Content-Security-Policy', csp); + } + res.statusCode = object.statusCode; + res.setHeader('Content-Type', object.contentType); + if (object.contentLength !== undefined) res.setHeader('Content-Length', String(object.contentLength)); + if (req.method === 'HEAD') { + (object.body as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.(); + res.end(); + return; + } + object.body.on('error', () => { + if (!res.headersSent) res.statusCode = 502; + res.end(); + }); + object.body.pipe(res); + } catch (error) { + const status = + (error as { statusCode?: number; httpStatus?: number })?.statusCode ?? + (error as { httpStatus?: number })?.httpStatus ?? + 503; + res.statusCode = + (error as { code?: string }).code === 'private_sites_unavailable' + ? 404 + : [401, 403].includes(status) + ? 404 + : [400, 404, 413, 415, 429, 503].includes(status) + ? status + : 503; + // Do not log tokens, return paths, storage details or account identifiers here. + res.end(res.statusCode === 404 ? 'not found' : 'Site access is unavailable.'); + } +} diff --git a/src/server/lib/sites/oauthRevocation.test.ts b/src/server/lib/sites/oauthRevocation.test.ts new file mode 100644 index 00000000..fcf06d67 --- /dev/null +++ b/src/server/lib/sites/oauthRevocation.test.ts @@ -0,0 +1,172 @@ +/** Signed JWT integration with in-memory authorization dependencies; no servers or real accounts. */ +import type { IncomingMessage } from 'http'; +import { NextRequest } from 'next/server'; +import { createRemoteJWKSet, generateKeyPair, SignJWT } from 'jose'; +import { verifyBearerToken } from 'server/lib/auth'; +import { resolvePrincipal } from 'server/lib/principal'; +import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; +import { authenticateMcpRequest } from 'server/mcp/auth'; +import { SitesBrowserAuth, challengeCookieName } from './browserAuth'; +import { assertSitesPrincipal } from './policy'; +import { authorizeSitesViewer } from './gateway'; + +jest.mock('jose', () => ({ ...jest.requireActual('jose'), createRemoteJWKSet: jest.fn() })); +jest.mock('server/services/apiToken', () => ({ __esModule: true, default: {} })); +jest.mock('server/models/ApiToken', () => ({ __esModule: true, default: {} })); +jest.mock('server/services/globalConfig', () => ({ __esModule: true, default: {} })); +jest.mock('server/lib/logger', () => ({ getLogger: () => ({ warn: jest.fn(), error: jest.fn() }) })); +const mockDirectorySession = jest.fn(); +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: async () => 'active', + getOAuthTokenStatus: (...args: unknown[]) => mockDirectorySession(...args), +})); +const mockRows = new Map(); +const mockRedis = { + get: async (key: string) => mockRows.get(key) ?? null, + set: async (key: string, value: string, _ex: string, _ttl: number, nx?: string) => { + if (nx && mockRows.has(key)) return null; + mockRows.set(key, value); + return 'OK'; + }, + eval: async (script: string, _count: number, key: string) => { + if (script.includes('sites-consume')) { + const current = mockRows.get(key); + mockRows.delete(key); + return current ?? null; + } + return 1; + }, +}; +jest.mock('server/lib/redisClient', () => ({ + __esModule: true, + default: { getInstance: () => ({ getRedis: () => mockRedis }) }, +})); +const issuer = 'https://identity.example/realms/lifecycle'; +let privateKey: Awaited>['privateKey']; +const originalEnv = { ...process.env }; +beforeAll(async () => { + const keys = await generateKeyPair('RS256'); + privateKey = keys.privateKey; + (createRemoteJWKSet as jest.Mock).mockReturnValue(keys.publicKey); + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = issuer; + process.env.KEYCLOAK_CLIENT_ID = 'lifecycle-api'; + process.env.KEYCLOAK_JWKS_URL = 'https://identity.example/test-jwks'; + process.env.LIFECYCLE_UI_URL = 'https://ui.example.com'; + process.env.APP_HOST = 'https://api.example'; +}); +beforeEach(() => { + mockRows.clear(); + mockDirectorySession.mockResolvedValue('active'); +}); +afterAll(() => { + process.env = originalEnv; +}); + +async function sign(tokenId: string, clientId = 'lifecycle-ui', offlineAccess = false) { + return new SignJWT({ + sid: 'shared-sso-session', + azp: clientId, + scope: offlineAccess ? 'openid mcp offline_access' : 'openid mcp', + realm_access: { roles: ['user'] }, + }) + .setProtectedHeader({ alg: 'RS256' }) + .setSubject('owner') + .setIssuer(issuer) + .setAudience(['lifecycle-api', 'https://api.example/mcp']) + .setJti(tokenId) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); +} +async function principals(token: string) { + const verified = await verifyBearerToken(token); + expect(verified.success).toBe(true); + const request = new NextRequest('https://api.example/api/v2/sites', { + headers: { + authorization: `Bearer ${token}`, + 'x-user': Buffer.from(JSON.stringify(verified.payload)).toString('base64url'), + }, + }); + const rest = await resolvePrincipal(request); + const mcp = await authenticateMcpRequest({ headers: { authorization: `Bearer ${token}` } } as IncomingMessage); + if (!mcp.ok) throw new Error(`MCP authentication failed: ${mcp.message}`); + expect(mcp.principal.oauth).toEqual(rest.oauth); + expect(getVerifiedOAuthBearer(rest)).toBe(token); + expect(getVerifiedOAuthBearer(mcp.principal)).toBe(token); + expect(JSON.stringify(rest)).not.toContain(token); + expect(getVerifiedOAuthBearer({ ...rest })).toBeUndefined(); + return [rest, mcp.principal]; +} + +test('existing UI and CLI JWTs authorize REST/MCP without a separate application login', async () => { + for (const client of ['lifecycle-ui', 'lifecycle-cli']) { + for (const principal of await principals(await sign(`token-${client}`, client))) { + await assertSitesPrincipal(principal, 'write'); + expect(principal.oauth!.expiresAt - Math.floor(Date.now() / 1000)).toBeLessThanOrEqual(300); + } + } + expect(mockRows.size).toBe(0); +}); + +test('IdP session revocation denies signed CLI OAuth tokens with an otherwise enabled account', async () => { + const token = await sign('cli-token', 'lifecycle-cli'); + mockDirectorySession.mockResolvedValue('revoked'); + for (const principal of await principals(token)) + await expect(assertSitesPrincipal(principal)).rejects.toMatchObject({ + httpStatus: 401, + code: 'oauth_session_unavailable', + }); + expect(mockDirectorySession).toHaveBeenCalledWith(token); +}); + +test('offline OAuth retains incoming token status validation without login registration', async () => { + const token = await sign('offline-ui', 'lifecycle-ui', true); + for (const principal of await principals(token)) await assertSitesPrincipal(principal); + expect(mockDirectorySession).toHaveBeenCalledWith(token); +}); + +test('issued viewers use only their fixed grant deadline; new authorization still checks token status', async () => { + const bearer = await sign('viewer-token'); + const [rest] = await principals(bearer); + const auth = new SitesBrowserAuth(mockRedis as any); + const site = { + siteId: 'test-site', + ownerKind: 'user', + ownerIssuer: issuer, + ownerSubject: 'owner', + visibility: 'private', + accessRevision: 1, + }; + const host = 'site-test.sites.example.net'; + const challenge = await auth.challenge(site, host, '/'); + const actor = { issuer, subject: 'owner' }; + await assertSitesPrincipal(rest); + const minted = await auth.mint(site, actor, { state: challenge.state, siteId: site.siteId }, rest.oauth!.expiresAt); + const consumed = await auth.consume( + minted.ticket, + host, + { [challengeCookieName(challenge.state)]: challenge.secret }, + process.env.LIFECYCLE_UI_URL, + async (viewer) => authorizeSitesViewer(site as any, viewer) + ); + const viewer = await auth.viewer(consumed.sessionId, host); + expect(JSON.stringify(viewer)).not.toContain(bearer); + expect(JSON.stringify(viewer)).not.toContain('ciphertext'); + expect(Array.from(mockRows.values()).some((row) => row.includes(bearer))).toBe(false); + await authorizeSitesViewer(site as any, viewer); + // V1 accepts revocation freshness bounded by the already-issued grant deadline. + mockDirectorySession.mockImplementation(async (token) => (token === bearer ? 'revoked' : 'active')); + mockDirectorySession.mockClear(); + await authorizeSitesViewer(site as any, viewer); + expect(mockDirectorySession).not.toHaveBeenCalled(); + await expect(assertSitesPrincipal(rest)).rejects.toMatchObject({ httpStatus: 401 }); + await expect( + authorizeSitesViewer(site as any, { ...viewer, expiresAt: Math.floor(Date.now() / 1000) }) + ).rejects.toMatchObject({ httpStatus: 401 }); + for (const changed of [{ ownerSubject: 'other' }, { accessRevision: 2 }, { siteId: 'other' }]) { + await expect(authorizeSitesViewer({ ...site, ...changed } as any, viewer)).rejects.toMatchObject({ + httpStatus: expect.any(Number), + }); + } +}); diff --git a/src/server/lib/sites/policy.test.ts b/src/server/lib/sites/policy.test.ts new file mode 100644 index 00000000..f83d5c62 --- /dev/null +++ b/src/server/lib/sites/policy.test.ts @@ -0,0 +1,143 @@ +import type { Principal } from 'server/lib/principal'; +import type Site from 'server/models/Site'; +import { assertSitesPrincipal, assertSiteOwner, isSiteOwner } from './policy'; +import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; +const mockStatus = jest.fn(); +const mockToken = jest.fn(); +const mockSession = jest.fn(); +jest.mock('server/models/ApiToken', () => ({ + __esModule: true, + default: { query: () => ({ findById: (...args: unknown[]) => mockToken(...args) }) }, +})); +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: () => ({ getConfig: async () => ({ personalAuthEnabled: true, serviceAuthEnabled: true }) }), + }, +})); +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: (...args: unknown[]) => mockStatus(...args), + getOAuthTokenStatus: (...args: unknown[]) => mockSession(...args), +})); +const issuer = 'https://identity.example/realms/lifecycle'; +const human = (overrides: Partial = {}): Principal => { + const principal: Principal = { + kind: 'user', + authMethod: 'session', + issuer, + oauth: { sessionId: 'sid', tokenId: 'jti', clientId: 'ui', expiresAt: Math.floor(Date.now() / 1000) + 300 }, + userId: 'alice', + actor: 'alice', + roles: ['admin'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + ...overrides, + }; + rememberVerifiedOAuthBearer(principal, 'test-bearer'); + return principal; +}; +const site = { ownerKind: 'user', ownerIssuer: issuer, ownerSubject: 'alice', creatorTokenId: null } as Site; +const previousEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = issuer; + mockStatus.mockReset().mockResolvedValue('active'); + mockSession.mockReset().mockResolvedValue('active'); + mockToken + .mockReset() + .mockResolvedValue({ kind: 'personal', ownerUserId: 'alice', ownerIssuer: issuer, scopes: ['sites:write'] }); +}); +afterEach(() => { + process.env = { ...previousEnv }; +}); + +test('admin status, matching display email, or a different issuer never grant ownership', () => { + expect(isSiteOwner(site, human())).toBe(true); + expect(isSiteOwner(site, human({ userId: 'bob' }))).toBe(false); + expect(isSiteOwner(site, human({ issuer: 'https://other.example/realm' }))).toBe(false); + expect(() => assertSiteOwner(site, human({ userId: 'bob' }))).toThrow('You do not have access to this Site.'); + expect(isSiteOwner({ ...site, ownerKind: 'unresolved' } as Site, human())).toBe(false); +}); +test('machine ownership is the immutable token id and cannot impersonate a human', async () => { + const machine = human({ kind: 'service_key', userId: null, issuer: null, tokenId: 7, scopes: ['sites:write'] }); + mockToken.mockResolvedValue({ kind: 'service', scopes: ['sites:write'] }); + await assertSitesPrincipal(machine, 'write'); + expect(mockStatus).not.toHaveBeenCalled(); + const machineSite = { + ...site, + ownerKind: 'service_key', + ownerSubject: null, + ownerIssuer: null, + creatorTokenId: 7, + } as Site; + expect(isSiteOwner(machineSite, machine)).toBe(true); + expect(isSiteOwner(machineSite, { ...machine, tokenId: 8 })).toBe(false); + expect(isSiteOwner(site, machine)).toBe(false); +}); +test.each(['disabled', 'deleted', 'no_base_role', 'unknown'])( + 'a %s account fails closed, including personal keys', + async (status) => { + mockStatus.mockResolvedValue(status); + await expect( + assertSitesPrincipal(human({ kind: 'personal_key', tokenId: 7, scopes: ['sites:write'] }), 'write') + ).rejects.toMatchObject({ code: 'principal_unavailable', httpStatus: status === 'unknown' ? 503 : 403 }); + } +); +test('auth-off cannot authenticate a synthetic development admin or an existing viewer session', async () => { + process.env.ENABLE_AUTH = 'false'; + await expect(assertSitesPrincipal(human())).rejects.toMatchObject({ httpStatus: 401 }); + expect(mockStatus).not.toHaveBeenCalled(); +}); +test('legacy personal keys without issuer binding fail closed for Sites', async () => { + await expect(assertSitesPrincipal(human({ kind: 'personal_key', issuer: null }))).rejects.toMatchObject({ + httpStatus: 401, + }); +}); +test('scopes cap ownership rights and write implies read', async () => { + await expect(assertSitesPrincipal(human({ scopes: ['sites:read'] }), 'write')).rejects.toMatchObject({ + code: 'insufficient_scope', + }); + await expect(assertSitesPrincipal(human({ scopes: ['env:write'] }))).rejects.toMatchObject({ + code: 'insufficient_scope', + }); + await expect(assertSitesPrincipal(human({ scopes: ['sites:write'] }))).resolves.toBeUndefined(); +}); + +test('a key revoked during a staged upload cannot authorize its commit', async () => { + const key = human({ kind: 'personal_key', tokenId: 7, scopes: ['sites:write'] }); + await assertSitesPrincipal(key, 'write'); + mockToken.mockResolvedValue({ + kind: 'personal', + ownerUserId: 'alice', + ownerIssuer: issuer, + scopes: ['sites:write'], + revokedAt: new Date().toISOString(), + }); + await expect(assertSitesPrincipal(key, 'write')).rejects.toMatchObject({ code: 'invalid_credential' }); +}); + +test.each(['revoked', 'unknown'])('a %s OAuth session fails closed despite an enabled account', async (status) => { + mockSession.mockResolvedValue(status); + await expect(assertSitesPrincipal(human())).rejects.toMatchObject({ + code: 'oauth_session_unavailable', + httpStatus: status === 'unknown' ? 503 : 401, + }); +}); +test('a legacy OAuth token missing revocation claims cannot access Sites', async () => { + await expect(assertSitesPrincipal(human({ oauth: undefined }))).rejects.toMatchObject({ httpStatus: 401 }); +}); +test('personal API keys keep their independent lifetime after application or IdP logout', async () => { + mockSession.mockResolvedValue('revoked'); + await expect(assertSitesPrincipal(human({ kind: 'personal_key', tokenId: 7 }))).resolves.toBeUndefined(); + expect(mockSession).not.toHaveBeenCalled(); +}); + +test('REST/MCP principals missing their request-only bearer association fail closed', async () => { + await expect(assertSitesPrincipal({ ...human({ authMethod: 'oauth' }) })).rejects.toMatchObject({ httpStatus: 401 }); + await expect(assertSitesPrincipal({ ...human({ authMethod: 'session' }) })).rejects.toMatchObject({ + httpStatus: 401, + }); +}); diff --git a/src/server/lib/sites/policy.ts b/src/server/lib/sites/policy.ts new file mode 100644 index 00000000..880fefba --- /dev/null +++ b/src/server/lib/sites/policy.ts @@ -0,0 +1,123 @@ +/** Sites ownership is independent of realm administration and display attribution. */ +import type { Transaction } from 'objection'; +import ApiToken from 'server/models/ApiToken'; +import GlobalConfigService from 'server/services/globalConfig'; +import type Site from 'server/models/Site'; +import type { Principal } from 'server/lib/principal'; +import { AppError } from 'server/lib/appError'; +import { scopeSatisfies } from 'server/lib/apiTokenScopes'; +import { getUserStatus, getOAuthTokenStatus } from 'server/services/keycloak/principalStatus'; +import { getVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; + +export function hasSitesScope(principal: Principal, operation: 'read' | 'write'): boolean { + return principal.scopes === null || scopeSatisfies(principal.scopes, `sites:${operation}`); +} + +export async function assertSitesPrincipal( + principal: Principal | null | undefined, + operation: 'read' | 'write' = 'read', + trx?: Transaction +): Promise { + if (process.env.ENABLE_AUTH !== 'true' || !principal) { + throw new AppError({ + httpStatus: 401, + code: 'authentication_required', + message: 'Sites management requires authentication.', + }); + } + if (!hasSitesScope(principal, operation)) { + throw new AppError({ + httpStatus: 403, + code: 'insufficient_scope', + message: `The credential requires sites:${operation}.`, + }); + } + if (principal.kind !== 'user') { + const tokenQuery = principal.tokenId ? ApiToken.query(trx).findById(principal.tokenId) : undefined; + if (trx) tokenQuery?.forShare(); + const token = await tokenQuery; + const config = (await GlobalConfigService.getInstance().getConfig('api_keys')) as + | { personalAuthEnabled?: boolean; serviceAuthEnabled?: boolean } + | undefined; + const personal = principal.kind === 'personal_key'; + if ( + !token || + token.revokedAt || + (token.expiresAt && new Date(token.expiresAt).getTime() <= Date.now()) || + token.kind !== (personal ? 'personal' : 'service') || + !(personal ? config?.personalAuthEnabled : config?.serviceAuthEnabled) || + (personal && (token.ownerUserId !== principal.userId || token.ownerIssuer !== principal.issuer)) || + !hasSitesScope({ ...principal, scopes: token.scopes }, operation) + ) { + throw new AppError({ + httpStatus: 401, + code: 'invalid_credential', + message: 'The API key is no longer valid for this operation.', + }); + } + } + if (principal.kind === 'service_key') { + if (!Number.isSafeInteger(principal.tokenId) || Number(principal.tokenId) <= 0) { + throw new AppError({ httpStatus: 401, code: 'invalid_credential', message: 'A valid service key is required.' }); + } + return; + } + const issuer = process.env.KEYCLOAK_ISSUER?.trim(); + if (!issuer || !principal.issuer || principal.issuer !== issuer || !principal.userId) { + throw new AppError({ + httpStatus: 401, + code: 'invalid_credential', + message: 'A verified identity from the configured issuer is required.', + }); + } + if (principal.kind === 'user') { + const oauth = principal.oauth; + if (!oauth || oauth.expiresAt <= Math.floor(Date.now() / 1000)) { + throw new AppError({ httpStatus: 401, code: 'invalid_credential', message: 'A live OAuth session is required.' }); + } + const bearer = getVerifiedOAuthBearer(principal); + const session = bearer ? await getOAuthTokenStatus(bearer) : 'revoked'; + if (session !== 'active') { + throw new AppError({ + httpStatus: session === 'unknown' ? 503 : 401, + code: 'oauth_session_unavailable', + message: 'The OAuth session cannot currently access Sites.', + retryable: session === 'unknown', + }); + } + } + const status = await getUserStatus(principal.userId); + if (status !== 'active') { + throw new AppError({ + httpStatus: status === 'unknown' ? 503 : 403, + code: 'principal_unavailable', + message: 'The account cannot currently access Sites.', + retryable: status === 'unknown', + }); + } +} + +export function isSiteOwner(site: Site, principal: Principal | null | undefined): boolean { + if (!principal) return false; + if (site.ownerKind === 'service_key') { + return principal.kind === 'service_key' && site.creatorTokenId != null && site.creatorTokenId === principal.tokenId; + } + return ( + site.ownerKind === 'user' && + principal.kind !== 'service_key' && + Boolean(site.ownerIssuer && site.ownerSubject) && + site.ownerIssuer === principal.issuer && + site.ownerSubject === principal.userId + ); +} + +export function assertSiteOwner(site: Site, principal: Principal): void { + if (!isSiteOwner(site, principal)) { + // Internal tool: 403 here (not 404) so a denied teammate knows to ask the owner. + throw new AppError({ + httpStatus: 403, + code: 'site_access_denied', + message: 'You do not have access to this Site.', + }); + } +} diff --git a/src/server/lib/sites/routeHelpers.test.ts b/src/server/lib/sites/routeHelpers.test.ts index ee8fd084..ad29df36 100644 --- a/src/server/lib/sites/routeHelpers.test.ts +++ b/src/server/lib/sites/routeHelpers.test.ts @@ -22,7 +22,7 @@ jest.mock('server/lib/logger', () => ({ import { NextRequest } from 'next/server'; import { SitesServiceError } from 'server/services/sites'; -import { readSitesListFilters, readUploadFile, sitesErrorResponse } from './routeHelpers'; +import { readSiteRevision, readSitesListFilters, readUploadFile, sitesErrorResponse } from './routeHelpers'; function uploadRequest(values: Record): NextRequest { return { @@ -38,6 +38,41 @@ describe('Sites route helpers', () => { }); describe('readUploadFile', () => { + it.each([undefined, '1'])('cancels an oversized stream before parsing with content-length %p', async (length) => { + const cancel = jest.fn(); + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024 + 11)); + }, + cancel, + }); + const req = new NextRequest('http://localhost/api/v2/sites', { + method: 'POST', + body, + duplex: 'half', + headers: { + 'content-type': 'multipart/form-data; boundary=fixture', + ...(length ? { 'content-length': length } : {}), + }, + } as ConstructorParameters[1]); + const parse = jest.spyOn(req, 'formData'); + await expect(readUploadFile(req, 10)).rejects.toMatchObject({ statusCode: 400 }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(parse).not.toHaveBeenCalled(); + }); + + it('parses a bounded multipart stream without a content-length', async () => { + const body = new FormData(); + body.set('file', new Blob(['hello']), 'index.html'); + body.set('visibility', 'private'); + const req = new NextRequest('http://localhost/api/v2/sites', { method: 'POST', body }); + await expect(readUploadFile(req, 10)).resolves.toMatchObject({ + fileName: 'index.html', + content: Buffer.from('hello'), + visibility: 'private', + }); + }); + it('reads the uploaded bytes, filename, and optional display name', async () => { const bytes = new Uint8Array([0, 1, 2, 255]); const file = { @@ -77,14 +112,14 @@ describe('Sites route helpers', () => { describe('readSitesListFilters', () => { it('trims the user filter and parses integer pagination values', () => { expect(readSitesListFilters(new URLSearchParams('user=%20Alice%40Example.com%20&page=2&limit=50'))).toEqual({ - user: 'Alice@Example.com', + view: 'mine', page: 2, limit: 50, }); }); it('omits blank and non-numeric filters', () => { - expect(readSitesListFilters(new URLSearchParams('user=%20%20&page=not-a-page&limit='))).toEqual({}); + expect(readSitesListFilters(new URLSearchParams('user=%20%20&limit='))).toEqual({}); }); it('returns no filters when the query string is empty', () => { @@ -92,6 +127,29 @@ describe('Sites route helpers', () => { }); }); + it.each(['2oops', '-1', '0', '1.5', '2147483648', {}, true])('rejects malformed revision %p', (value) => { + expect(() => readSiteRevision(value)).toThrow(); + }); + it('requires an explicit access precondition for visibility changes', () => { + expect(() => readSiteRevision(undefined, true)).toThrow(); + expect(readSiteRevision('12', true)).toBe(12); + }); + it.each(['view=shared', 'page=2oops', 'limit=101'])('rejects invalid filters %s', (query) => { + expect(() => readSitesListFilters(new URLSearchParams(query))).toThrow(); + }); + it('checks upload file size before reading bytes', async () => { + const file = { name: 'large.zip', size: 50, arrayBuffer: jest.fn() }; + await expect(readUploadFile(uploadRequest({ file }), 10)).rejects.toMatchObject({ statusCode: 400 }); + expect(file.arrayBuffer).not.toHaveBeenCalled(); + }); + it('rejects invalid visibility before reading upload bytes', async () => { + const file = { name: 'site.zip', arrayBuffer: jest.fn() }; + await expect(readUploadFile(uploadRequest({ file, visibility: 'shared' }))).rejects.toMatchObject({ + statusCode: 400, + }); + expect(file.arrayBuffer).not.toHaveBeenCalled(); + }); + describe('sitesErrorResponse', () => { const request = () => new NextRequest('http://localhost/api/v2/sites', { diff --git a/src/server/lib/sites/routeHelpers.ts b/src/server/lib/sites/routeHelpers.ts index 0bebddae..a6a04978 100644 --- a/src/server/lib/sites/routeHelpers.ts +++ b/src/server/lib/sites/routeHelpers.ts @@ -15,39 +15,122 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { errorResponse } from 'server/lib/response'; +import { errorResponse, successResponse } from 'server/lib/response'; import { SitesServiceError } from 'server/services/sites'; import type { ListSitesFilters } from 'server/services/sites'; -export async function readUploadFile(req: NextRequest): Promise<{ fileName: string; content: Buffer; name?: string }> { - const formData = await req.formData(); - const file = formData.get('file'); - if (!file || typeof file === 'string') { - throw new SitesServiceError('A file upload is required.', 400); +export function readSiteVisibility(value: unknown): 'private' | 'public' { + if (value !== 'private' && value !== 'public') + throw new SitesServiceError('visibility must be private or public.', 400); + return value; +} + +export function readSiteRevision(value: unknown, required = false): number | undefined { + if (value === null || value === undefined || value === '') { + if (required) throw new SitesServiceError('expectedAccessRevision is required.', 400); + return undefined; } + if ((typeof value !== 'string' && typeof value !== 'number') || !/^[1-9][0-9]*$/.test(String(value))) { + throw new SitesServiceError('Revision must be a positive integer.', 400); + } + const revision = Number(value); + if (!Number.isSafeInteger(revision) || revision > 2147483647) throw new SitesServiceError('Invalid revision.', 400); + return revision; +} +export async function readUploadFile( + req: NextRequest, + maxUploadBytes?: number +): Promise<{ + fileName: string; + content: Buffer; + name?: string; + visibility?: 'private' | 'public'; + expectedAccessRevision?: number; + expectedContentRevision?: number; +}> { + // Bound bytes while reading: Content-Length can be absent or dishonest, and formData buffers the whole body. + const length = Number(req.headers?.get('content-length')); + if (maxUploadBytes && Number.isFinite(length) && length > maxUploadBytes + 1024 * 1024) { + throw new SitesServiceError('Upload exceeds the configured size limit.', 400); + } + let formData: FormData; + if (maxUploadBytes && req.body) { + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let finished = false; + try { + while (!finished) { + const chunk = await reader.read(); + if (chunk.done) { + finished = true; + continue; + } + total += chunk.value.byteLength; + if (total > maxUploadBytes + 1024 * 1024) { + await reader.cancel(); + throw new SitesServiceError('Upload exceeds the configured size limit.', 400); + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + formData = await new Response(Buffer.concat(chunks), { + headers: { 'Content-Type': req.headers.get('content-type') || '' }, + }).formData(); + } else { + formData = await req.formData(); + } + const file = formData.get('file'); + if (!file || typeof file === 'string') throw new SitesServiceError('A file upload is required.', 400); + if (maxUploadBytes && file.size > maxUploadBytes) + throw new SitesServiceError('Upload exceeds the configured size limit.', 400); const nameValue = formData.get('name'); - const name = typeof nameValue === 'string' ? nameValue : undefined; - const arrayBuffer = await file.arrayBuffer(); + const visibilityValue = formData.get('visibility'); + const expectedAccessRevision = readSiteRevision(formData.get('expectedAccessRevision')); + const expectedContentRevision = readSiteRevision(formData.get('expectedContentRevision')); + const visibility = + visibilityValue === null || visibilityValue === undefined ? undefined : readSiteVisibility(visibilityValue); return { fileName: file.name || 'upload', - content: Buffer.from(arrayBuffer), - name, + content: Buffer.from(await file.arrayBuffer()), + name: typeof nameValue === 'string' ? nameValue : undefined, + ...(visibility ? { visibility } : {}), + ...(expectedAccessRevision === undefined ? {} : { expectedAccessRevision }), + ...(expectedContentRevision === undefined ? {} : { expectedContentRevision }), }; } export function readSitesListFilters(searchParams: URLSearchParams): ListSitesFilters { - const user = searchParams.get('user')?.trim(); - const page = Number.parseInt(searchParams.get('page') || '', 10); - const limit = Number.parseInt(searchParams.get('limit') || '', 10); - + const view = searchParams.get('view') || (searchParams.get('user')?.trim() ? 'mine' : undefined); + if (view && !['mine', 'public', 'all'].includes(view)) throw new SitesServiceError('Invalid Sites view.', 400); + const q = searchParams.get('q')?.trim(); + if (q && q.length > 200) throw new SitesServiceError('Search is too long.', 400); + const page = readSiteRevision(searchParams.get('page')); + const limit = readSiteRevision(searchParams.get('limit')); + if (limit && limit > 100) throw new SitesServiceError('limit must be at most 100.', 400); return { - ...(user ? { user } : {}), - ...(Number.isNaN(page) ? {} : { page }), - ...(Number.isNaN(limit) ? {} : { limit }), + ...(view ? { view: view as 'mine' | 'public' | 'all' } : {}), + ...(q ? { q } : {}), + ...(page === undefined ? {} : { page }), + ...(limit === undefined ? {} : { limit }), }; } export function sitesErrorResponse(error: unknown, req: NextRequest): NextResponse { - return errorResponse(error, { status: error instanceof SitesServiceError ? error.statusCode : 500 }, req); + const response = errorResponse(error, { status: error instanceof SitesServiceError ? error.statusCode : 500 }, req); + response.headers.set('Cache-Control', 'no-store'); + return response; +} + +export function sitesSuccessResponse( + data: T, + options: Parameters[1], + req: NextRequest +): NextResponse { + const response = successResponse(data, options, req); + response.headers.set('Cache-Control', 'no-store'); + return response; } diff --git a/src/server/lib/sites/validation.test.ts b/src/server/lib/sites/validation.test.ts index 6686907b..1e60584a 100644 --- a/src/server/lib/sites/validation.test.ts +++ b/src/server/lib/sites/validation.test.ts @@ -413,3 +413,21 @@ describe('normalizeGatewayPath', () => { expect(normalizeGatewayPath('///docs/My%20Page.html?preview=true')).toBe('docs/My Page.html'); }); }); + +describe('reserved Sites authentication paths', () => { + it.each(['_lfc-sites/consume.html', 'folder/../_lfc-sites/consume.html'])('rejects archive entry %s', (entry) => { + expect(() => + validateSiteUpload({ + ...DEFAULT_OPTIONS, + fileName: 'site.zip', + content: zip({ 'index.html': 'ok', [entry]: 'bad' }), + }) + ).toThrow('reserved for site authentication'); + }); + it.each(['/_lfc-sites/consume', '/%5flfc-sites/consume', '/_lfc-sites%2fconsume'])( + 'rejects normalized object lookup %s', + (path) => { + expect(() => normalizeGatewayPath(path)).toThrow('reserved for site authentication'); + } + ); +}); diff --git a/src/server/lib/sites/validation.ts b/src/server/lib/sites/validation.ts index 75832026..1cec951a 100644 --- a/src/server/lib/sites/validation.ts +++ b/src/server/lib/sites/validation.ts @@ -79,6 +79,9 @@ function normalizeArchivePath(input: string): string { reject('Archive contains a path traversal entry.'); } + if (normalized === '_lfc-sites' || normalized.startsWith('_lfc-sites/')) { + reject('The _lfc-sites path is reserved for site authentication.'); + } return normalized; } diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index 47c039c6..0478c036 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -24,7 +24,13 @@ export type V2RoutePolicyEntry = | { method: V2HttpMethod; route: string; policy: 'public' } | { method: V2HttpMethod; route: string; policy: 'session'; roles?: readonly ['admin'] } /* scope null = any authenticated principal, reserved for pure functions with no data access */ - | { method: V2HttpMethod; route: string; policy: 'principal'; scope: V2KeyScope | null }; + | { + method: V2HttpMethod; + route: string; + policy: 'principal'; + scope: V2KeyScope | null; + kinds?: readonly ('user' | 'personal_key' | 'service_key')[]; + }; export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'GET', route: '/api/v2/ai/admin/agent/capabilities', policy: 'session', roles: ['admin'] }, @@ -250,6 +256,10 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [ { method: 'POST', route: '/api/v2/repositories', policy: 'principal', scope: 'repos:write' }, { method: 'DELETE', route: '/api/v2/repositories/{fullName+}', policy: 'principal', scope: 'repos:write' }, { method: 'GET', route: '/api/v2/schema/validate', policy: 'principal', scope: 'repos:read' }, + { method: 'POST', route: '/api/v2/sites/browser/mint', policy: 'principal', scope: 'sites:read', kinds: ['user'] }, + { method: 'GET', route: '/api/v2/sites/browser/open/{siteId}', policy: 'public' }, + { method: 'GET', route: '/api/v2/sites/capabilities', policy: 'principal', scope: 'sites:read' }, + { method: 'PATCH', route: '/api/v2/sites/{siteId}/access', policy: 'principal', scope: 'sites:write' }, { method: 'GET', route: '/api/v2/sites', policy: 'principal', scope: 'sites:read' }, { method: 'POST', route: '/api/v2/sites', policy: 'principal', scope: 'sites:write' }, { method: 'DELETE', route: '/api/v2/sites/{siteId}', policy: 'principal', scope: 'sites:write' }, diff --git a/src/server/lib/verifiedOAuthBearer.ts b/src/server/lib/verifiedOAuthBearer.ts new file mode 100644 index 00000000..06664d01 --- /dev/null +++ b/src/server/lib/verifiedOAuthBearer.ts @@ -0,0 +1,14 @@ +import type { Principal } from './principal'; + +// Request-lifetime only. Bearers must never become enumerable principal fields, +// audit data, or serialized browser Viewer records. Only incoming-request +// authorization uses this association; Site grants never retain OAuth tokens. +const requestBearers = new WeakMap(); + +export function rememberVerifiedOAuthBearer(principal: Principal, bearer: string): void { + requestBearers.set(principal, bearer); +} + +export function getVerifiedOAuthBearer(principal: Principal): string | undefined { + return requestBearers.get(principal); +} diff --git a/src/server/mcp/__tests__/toolHandlers.sites.test.ts b/src/server/mcp/__tests__/toolHandlers.sites.test.ts index 08d37797..f5b8d873 100644 --- a/src/server/mcp/__tests__/toolHandlers.sites.test.ts +++ b/src/server/mcp/__tests__/toolHandlers.sites.test.ts @@ -26,6 +26,13 @@ const SITE: SiteResponse = { name: 'launch-page', url: 'https://sites.example.com/launch-page', status: 'active', + visibility: 'public', + contentUrl: 'https://sites.example.com/launch-page', + openUrl: 'https://ui.example.com/sites/site_abc123', + accessRevision: 1, + contentRevision: 1, + currentRole: 'owner', + permissions: { canView: true, canEdit: true, canDelete: true, canChangeVisibility: true }, createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', expiresAt: '2026-08-01T00:00:00.000Z', @@ -108,7 +115,8 @@ describe('list_sites', () => { }), ]); expect(typeof output!.nextCursor).toBe('string'); - expect(listSites.mock.calls[0][0]).toEqual({ page: 1, limit: 25 }); + expect(listSites.mock.calls[0][1]).toBe(PRINCIPAL); + expect(listSites.mock.calls[0][0]).toEqual({ view: 'all', page: 1, limit: 25 }); }); it('filters to the signed-in user for mineOnly', async () => { @@ -118,18 +126,23 @@ describe('list_sites', () => { }); const { call } = harness({ listSites }); await call('list_sites', { mineOnly: true }); - expect(listSites.mock.calls[0][0]).toMatchObject({ user: 'user@example.com' }); + expect(listSites.mock.calls[0][0]).toMatchObject({ view: 'mine' }); }); - it('returns nothing for mineOnly without a known email', async () => { - const listSites = jest.fn(); + it('delegates immutable ownership even without an email', async () => { + const listSites = jest + .fn() + .mockResolvedValue({ sites: [], pagination: { current: 1, total: 1, items: 0, limit: 25 } }); const { call } = harness({ listSites }); const { output } = await call('list_sites', { mineOnly: true }, { ...PRINCIPAL, identity: null, } as unknown as Principal); expect(output).toMatchObject({ sites: [] }); - expect(listSites.mock.calls).toHaveLength(0); + expect(listSites).toHaveBeenCalledWith( + { view: 'mine', page: 1, limit: 25 }, + expect.objectContaining({ userId: 'user-1', identity: null }) + ); }); it('reports storage outages as retryable', async () => { @@ -143,9 +156,11 @@ describe('list_sites', () => { describe('get_site', () => { it('returns one site', async () => { - const { call } = harness({ getSite: async () => SITE }); + const getSite = jest.fn().mockResolvedValue(SITE); + const { call } = harness({ getSite }); const { output } = await call('get_site', { siteId: 'site_abc123' }); expect(output!.site).toMatchObject({ siteId: 'site_abc123', status: 'active' }); + expect(getSite).toHaveBeenCalledWith('site_abc123', PRINCIPAL); }); it('normalizes Date-backed site timestamps before output validation', async () => { diff --git a/src/server/mcp/auth.ts b/src/server/mcp/auth.ts index 82a9fa30..073b3ee9 100644 --- a/src/server/mcp/auth.ts +++ b/src/server/mcp/auth.ts @@ -17,9 +17,10 @@ import type { IncomingMessage } from 'http'; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; import { AppError, isAppError } from 'server/lib/appError'; -import { getIdentityFromClaims } from 'server/lib/get-user'; +import { getIdentityFromClaims, getOAuthCredentialFromClaims } from 'server/lib/get-user'; import { getLogger } from 'server/lib/logger'; import type { Principal } from 'server/lib/principal'; +import { rememberVerifiedOAuthBearer } from 'server/lib/verifiedOAuthBearer'; import { getMcpResourceMetadataUrl, getMcpResourceUrl, isAuthEnabled, MCP_SCOPE } from './config'; export interface McpAuthSuccess { @@ -125,6 +126,8 @@ function oauthPrincipal(payload: JWTPayload, roles: Array<'user' | 'admin'>): Pr kind: 'user', authMethod: 'oauth', userId: identity.userId, + issuer: identity.issuer ?? null, + oauth: getOAuthCredentialFromClaims(payload), actor: identity.userId, roles, scopes: null, @@ -206,6 +209,7 @@ export async function authenticateMcpRequest(req: IncomingMessage): Promise = {}): SiteResponse { name: 'docs', url: 'https://docs.example.test', status: 'ready', + visibility: 'public', + contentUrl: 'https://docs.example.test', + openUrl: 'https://ui.example.test/sites/site-1', + accessRevision: 1, + contentRevision: 1, + currentRole: null, + permissions: { canView: true, canEdit: false, canDelete: false, canChangeVisibility: false }, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-02T00:00:00.000Z', expiresAt: null, diff --git a/src/server/mcp/tools/sites/getSite.ts b/src/server/mcp/tools/sites/getSite.ts index 4c5effb3..a8673c1d 100644 --- a/src/server/mcp/tools/sites/getSite.ts +++ b/src/server/mcp/tools/sites/getSite.ts @@ -19,7 +19,7 @@ import { getSiteInputSchema, getSiteOutputSchema } from './schemas'; import { mapSiteServiceError, siteSummary, type ResolvedSiteToolDependencies } from './shared'; const DESCRIPTION = - 'Gets one hosted site, including its public URL, status, size, and expiry. Use a `siteId` returned by list_sites.'; + 'Gets one hosted site, including its visibility and authenticated opening URL, status, size, and expiry. Use a `siteId` returned by list_sites.'; export function createGetSiteToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition { return { @@ -36,9 +36,9 @@ export function createGetSiteToolDefinition(dependencies: ResolvedSiteToolDepend }, capabilityId: 'view-hosted-sites', access: 'read', - async handler(input): Promise { + async handler(input, context): Promise { try { - const site = await dependencies.service().getSite(input.siteId as string); + const site = await dependencies.service().getSite(input.siteId as string, context.principal); return { site: siteSummary(site), }; diff --git a/src/server/mcp/tools/sites/listSites.ts b/src/server/mcp/tools/sites/listSites.ts index 2e1b380b..7cca3822 100644 --- a/src/server/mcp/tools/sites/listSites.ts +++ b/src/server/mcp/tools/sites/listSites.ts @@ -20,7 +20,7 @@ import { listSitesInputSchema, listSitesOutputSchema } from './schemas'; import { mapSiteServiceError, siteSummary, type ResolvedSiteToolDependencies } from './shared'; const DESCRIPTION = - 'Lists hosted sites in Lifecycle. Use `mineOnly` to return only sites created or updated by the authenticated user.'; + 'Lists hosted sites in Lifecycle. Use `mineOnly` to return only sites owned by the authenticated principal.'; export function createListSitesToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition { return { @@ -46,15 +46,14 @@ export function createListSitesToolDefinition(dependencies: ResolvedSiteToolDepe typeof input.cursor === 'string' ? decodeListCursor(input.cursor, cursorFilters, limit, dependencies.nowSeconds()) : null; - const user = mineOnly ? context.principal.identity?.email : undefined; - if (mineOnly && !user) { - return { sites: [] }; - } - const result = await dependencies.service().listSites({ - ...(user ? { user } : {}), - page: cursor ? cursor.position + 1 : 1, - limit, - }); + const result = await dependencies.service().listSites( + { + view: mineOnly ? 'mine' : 'all', + page: cursor ? cursor.position + 1 : 1, + limit, + }, + context.principal + ); const nextCursor = result.pagination.current < result.pagination.total ? encodeListCursor( diff --git a/src/server/mcp/tools/sites/schemas.ts b/src/server/mcp/tools/sites/schemas.ts index e9db7078..62eed45b 100644 --- a/src/server/mcp/tools/sites/schemas.ts +++ b/src/server/mcp/tools/sites/schemas.ts @@ -29,6 +29,21 @@ const siteSummarySchema = closedObjectSchema( siteId: siteIdSchema, name: { type: 'string', minLength: 1, maxLength: 200 }, url: { type: 'string', format: 'uri', minLength: 1, maxLength: 2048 }, + visibility: { type: 'string', enum: ['private', 'public'] }, + contentUrl: { type: 'string', format: 'uri', minLength: 1, maxLength: 2048 }, + openUrl: { type: 'string', format: 'uri', minLength: 1, maxLength: 2048 }, + accessRevision: { type: 'integer', minimum: 1 }, + contentRevision: { type: 'integer', minimum: 1 }, + currentRole: { type: ['string', 'null'], enum: ['owner', null] }, + permissions: closedObjectSchema( + { + canView: { type: 'boolean' }, + canEdit: { type: 'boolean' }, + canDelete: { type: 'boolean' }, + canChangeVisibility: { type: 'boolean' }, + }, + ['canView', 'canEdit', 'canDelete', 'canChangeVisibility'] + ), status: { type: 'string', minLength: 1, maxLength: 50 }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, @@ -38,13 +53,29 @@ const siteSummarySchema = closedObjectSchema( createdBy: { type: 'string', minLength: 1, maxLength: 512 }, updatedBy: { type: 'string', minLength: 1, maxLength: 512 }, }, - ['siteId', 'name', 'url', 'status', 'createdAt', 'updatedAt', 'fileCount', 'sizeBytes'] + [ + 'siteId', + 'name', + 'url', + 'status', + 'createdAt', + 'updatedAt', + 'fileCount', + 'sizeBytes', + 'visibility', + 'contentUrl', + 'openUrl', + 'accessRevision', + 'contentRevision', + 'currentRole', + 'permissions', + ] ); export const listSitesInputSchema = closedObjectSchema({ mineOnly: { type: 'boolean', - description: 'Return only sites created or updated by the authenticated Lifecycle user.', + description: 'Return only sites owned by the authenticated Lifecycle principal.', }, cursor: { type: 'string', maxLength: 500 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 }, diff --git a/src/server/mcp/tools/sites/shared.ts b/src/server/mcp/tools/sites/shared.ts index 0635a73c..55a6d747 100644 --- a/src/server/mcp/tools/sites/shared.ts +++ b/src/server/mcp/tools/sites/shared.ts @@ -23,11 +23,12 @@ import SitesService, { type ListSitesResult, type SiteResponse, } from 'server/services/sites'; +import type { Principal } from 'server/lib/principal'; import { safeCoreText } from '../core/listRepositories'; export interface SiteToolService { - listSites(filters?: ListSitesFilters): Promise; - getSite(siteId: string): Promise; + listSites(filters: ListSitesFilters, principal: Principal): Promise; + getSite(siteId: string, principal: Principal): Promise; } export interface SiteToolDependencies { @@ -95,6 +96,13 @@ export function siteSummary(site: SiteResponse): McpJsonObject { name: requiredString(site.name, 200), url: requiredString(site.url, 2048), status: requiredString(site.status, 50), + visibility: site.visibility, + contentUrl: requiredString(site.contentUrl, 2048), + openUrl: requiredString(site.openUrl, 2048), + accessRevision: site.accessRevision, + contentRevision: site.contentRevision, + currentRole: site.currentRole, + permissions: { ...site.permissions }, createdAt: requiredDateTime(site.createdAt), updatedAt: requiredDateTime(site.updatedAt), ...(expiresAt ? { expiresAt } : {}), diff --git a/src/server/middlewares/auth.test.ts b/src/server/middlewares/auth.test.ts index 759ee72d..70bf358d 100644 --- a/src/server/middlewares/auth.test.ts +++ b/src/server/middlewares/auth.test.ts @@ -51,6 +51,18 @@ afterAll(() => { }); describe('authMiddleware x-user stripping', () => { + it('does not bypass normal JWT authentication for the retired revoke route', async () => { + mockVerifyAuth.mockResolvedValue({ success: false, error: { status: 401, message: 'Unauthorized' } }); + const request = new NextRequest('https://example.test/api/v2/sites/browser/revoke', { + method: 'POST', + body: '{}', + headers: { 'x-user': 'spoofed' }, + }); + const { next, result } = await runMiddleware(request); + expect(mockVerifyAuth).toHaveBeenCalledTimes(1); + expect(next).not.toHaveBeenCalled(); + expect(result.status).toBe(401); + }); it('strips a crafted x-user when ENABLE_AUTH is off', async () => { process.env.ENABLE_AUTH = 'false'; const req = makeRequest('http://localhost/api/v2/repositories', { 'x-user': 'spoofed' }); diff --git a/src/server/middlewares/auth.ts b/src/server/middlewares/auth.ts index fecce6f7..61561bb2 100644 --- a/src/server/middlewares/auth.ts +++ b/src/server/middlewares/auth.ts @@ -26,7 +26,7 @@ const MCP_OAUTH_CALLBACK_PATH = /^\/api\/v2\/ai\/agent\/mcp-connections\/[^/]+\/ const MAX_AUTHORIZATION_HEADER_LENGTH = 16384; function authFailure(request: NextRequest, status: 401 | 500, message: string, code?: string): NextResponse { - const headers: Record = { 'Content-Type': 'application/json' }; + const headers: Record = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }; if (status === 401) { headers['WWW-Authenticate'] = bearerChallenge(code); } @@ -45,7 +45,9 @@ function forwardWithoutUser(request: NextRequest, next: NextMiddleware, mutate?: const headers = new Headers(request.headers); headers.delete('x-user'); // prevent spoofing mutate?.(headers); - return next(new NextRequest(request.url, { ...request, headers })); + return next( + new NextRequest(request, { method: request.method, body: request.body, signal: request.signal, headers }) + ); } export const authMiddleware: Middleware = async (request, next) => { @@ -62,6 +64,10 @@ export const authMiddleware: Middleware = async (request, next) => { return forwardWithoutUser(request, next); } + if (request.method === 'GET' && /^\/api\/v2\/sites\/browser\/open\/[a-z0-9-]{1,64}$/.test(request.nextUrl.pathname)) { + return forwardWithoutUser(request, next); + } + if (MCP_OAUTH_CALLBACK_PATH.test(request.nextUrl.pathname)) { return forwardWithoutUser(request, next); } diff --git a/src/server/middlewares/requestId.test.ts b/src/server/middlewares/requestId.test.ts index 3917bbb5..9fc4bbf6 100644 --- a/src/server/middlewares/requestId.test.ts +++ b/src/server/middlewares/requestId.test.ts @@ -60,4 +60,39 @@ describe('requestIdMiddleware', () => { expect((next.mock.calls[0][0] as NextRequest).headers.get('x-request-id')).toBe(`toke_${UUID}`); expect(response.headers.get('x-request-id')).toBe(`toke_${UUID}`); }); + + it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])('preserves native %s request semantics', async (method) => { + const body = ['GET', 'HEAD'].includes(method) ? undefined : '{"action":"payload"}'; + const request = new NextRequest('https://example.test/api/v2/sites/browser/mint', { + method, + body, + headers: { 'content-type': 'application/json' }, + }); + const next = jest.fn().mockResolvedValue(NextResponse.next()); + await requestIdMiddleware(request, next); + const forwarded = next.mock.calls[0][0] as NextRequest; + expect(forwarded.method).toBe(method); + expect(await forwarded.text()).toBe(body || ''); + expect(forwarded.headers.get('content-type')).toBe('application/json'); + }); + + it('preserves a POST arriving from the installed Edge Request constructor realm', async () => { + // NextRequest's constructor branches on instanceof Request. Node-only probes + // miss Edge instances that otherwise silently become GET without a body. + const { EdgeRuntime } = require('next/dist/compiled/edge-runtime'); + const runtime = new EdgeRuntime(); + const body = '{"action":"cross-realm-payload"}'; + const foreignRequest = new runtime.context.Request('https://example.test/api/v2/sites/browser/mint', { + method: 'POST', + body, + headers: { 'content-type': 'application/json' }, + }); + expect(foreignRequest instanceof Request).toBe(false); + const next = jest.fn().mockResolvedValue(NextResponse.next()); + await requestIdMiddleware(foreignRequest as NextRequest, next); + const forwarded = next.mock.calls[0][0] as NextRequest; + expect(forwarded.method).toBe('POST'); + expect(await forwarded.text()).toBe(body); + expect(forwarded.headers.get('content-type')).toBe('application/json'); + }); }); diff --git a/src/server/middlewares/requestId.ts b/src/server/middlewares/requestId.ts index 2b742f61..4e8e3394 100644 --- a/src/server/middlewares/requestId.ts +++ b/src/server/middlewares/requestId.ts @@ -40,8 +40,12 @@ export const requestIdMiddleware: Middleware = async (request, next) => { const requestHeaders = new Headers(request.headers); requestHeaders.set('x-request-id', xRequestId); - const newRequest = new NextRequest(request.url, { - ...request, + // Request properties are native getters, so object spread loses method/body. + // Pass them explicitly too: Edge Request instances can cross constructor realms. + const newRequest = new NextRequest(request, { + method: request.method, + body: request.body, + signal: request.signal, headers: requestHeaders, }); diff --git a/src/server/models/ApiToken.ts b/src/server/models/ApiToken.ts index 82509395..8fa3ffad 100644 --- a/src/server/models/ApiToken.ts +++ b/src/server/models/ApiToken.ts @@ -39,6 +39,7 @@ export default class ApiToken extends Model { /* Identity binding for user-provisioned tokens; all null for admin-minted org tokens. */ ownerUserId!: string | null; + ownerIssuer!: string | null; ownerGithubUsername!: string | null; ownerEmail!: string | null; ownerPreferredUsername!: string | null; @@ -79,6 +80,7 @@ export default class ApiToken extends Model { expiresAt: { type: ['string', 'null'] }, revokedAt: { type: ['string', 'null'] }, ownerUserId: { type: ['string', 'null'] }, + ownerIssuer: { type: ['string', 'null'], maxLength: 2048 }, ownerGithubUsername: { type: ['string', 'null'] }, ownerEmail: { type: ['string', 'null'] }, ownerPreferredUsername: { type: ['string', 'null'] }, diff --git a/src/server/models/Site.ts b/src/server/models/Site.ts index 34383ae0..afa45ae5 100644 --- a/src/server/models/Site.ts +++ b/src/server/models/Site.ts @@ -16,12 +16,22 @@ import Model from './_Model'; +export type SiteVisibility = 'private' | 'public'; +export type SiteOwnerKind = 'user' | 'service_key' | 'unresolved'; + export type SiteStatus = 'active' | 'deleted' | 'expired'; export default class Site extends Model { siteId!: string; name!: string; status!: SiteStatus; + visibility!: SiteVisibility; + ownerKind!: SiteOwnerKind; + ownerIssuer!: string | null; + ownerSubject!: string | null; + creatorTokenId!: number | null; + accessRevision!: number; + contentRevision!: number; activeVersionId?: string | null; fileCount!: number; sizeBytes!: number | string; diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 2de9a60c..dc5d14f0 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -17,6 +17,26 @@ import { Readable } from 'stream'; const mockGetAllConfigs = jest.fn(); +const mockTokenState = { id: 7, kind: 'service', scopes: ['sites:write'], revokedAt: null as string | null }; +jest.mock('server/models/ApiToken', () => ({ + __esModule: true, + default: { + query: () => ({ + findById: (id: number) => ({ + forShare() { + return this; + }, + then(resolve: (v: unknown) => unknown) { + return Promise.resolve(id === mockTokenState.id ? mockTokenState : undefined).then(resolve); + }, + }), + }), + }, +})); +const mockAudit = jest.fn, unknown[]>(async () => undefined); +jest.mock('server/services/authAudit', () => ({ + recordAuthAuditEventInTransaction: (...args: unknown[]) => mockAudit(...args), +})); const mockPutFiles = jest.fn(); const mockDeletePrefix = jest.fn(); const mockGetObject = jest.fn(); @@ -68,6 +88,7 @@ jest.mock('server/services/globalConfig', () => ({ default: { getInstance: jest.fn(() => ({ getAllConfigs: (...args: unknown[]) => mockGetAllConfigs(...args), + getConfig: async () => ({ personalAuthEnabled: true, serviceAuthEnabled: true }), })), }, })); @@ -97,11 +118,52 @@ jest.mock('server/lib/sites/validation', () => { }; }); +import type { Principal } from 'server/lib/principal'; +const principal: Principal = { + kind: 'user', + authMethod: 'session', + userId: 'owner', + issuer: 'https://identity.example/realms/lifecycle', + actor: 'owner', + roles: ['user'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + oauth: { sessionId: 'session', tokenId: 'jwt', clientId: 'cli', expiresAt: 1e12 }, +}; +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: jest.fn(async () => 'active'), + getUserSessionStatus: jest.fn(async () => 'active'), + getOAuthTokenStatus: jest.fn(async () => 'active'), +})); +jest.mock('server/lib/verifiedOAuthBearer', () => ({ getVerifiedOAuthBearer: jest.fn(() => 'fixture-bearer') })); +jest.mock('server/lib/sites/browserAuth', () => ({ + ...jest.requireActual('server/lib/sites/browserAuth'), +})); +const originalSitesEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = principal.issuer!; + process.env.LIFECYCLE_UI_URL = 'https://lifecycle.example.net'; +}); +afterEach(() => { + process.env = { ...originalSitesEnv }; +}); + import SitesService, { SitesServiceError } from 'server/services/sites'; import { SitesObjectNotFoundError } from 'server/lib/sites/storage'; import { SiteUploadValidationError } from 'server/lib/sites/validation'; type SiteData = { + visibility: 'private' | 'public'; + ownerKind: 'user' | 'service_key' | 'unresolved'; + ownerIssuer: string | null; + ownerSubject: string | null; + creatorTokenId: number | null; + accessRevision: number; + contentRevision: number; siteId: string; name: string; status: string; @@ -131,7 +193,7 @@ type VersionData = { deletedAt: string | null; }; -type VersionRow = VersionData; +type VersionRow = VersionData & { $query: jest.Mock }; type FakeState = { sites: SiteRow[]; @@ -169,6 +231,13 @@ function attachSite(state: FakeState, data: SiteData): SiteRow { function addSite(state: FakeState, overrides: Partial = {}): SiteRow { const row = attachSite(state, { + visibility: 'public', + ownerKind: 'user', + ownerIssuer: principal.issuer!, + ownerSubject: principal.userId, + creatorTokenId: null, + accessRevision: 1, + contentRevision: 1, siteId: 'site-1', name: 'site', status: 'active', @@ -199,8 +268,15 @@ function addVersion(state: FakeState, overrides: Partial = {}): Ver deletedAt: null, ...overrides, }; - state.versions.push(version); - return version; + const row = version as VersionRow; + row.$query = jest.fn(() => ({ + patch: async (patch: Partial) => { + Object.assign(row, patch); + return 1; + }, + })); + state.versions.push(row); + return row; } class SiteQuery { @@ -211,6 +287,18 @@ class SiteQuery { constructor(private readonly state: FakeState) {} + forUpdate() { + return this; + } + orWhere(scope: Partial) { + const existing = [...this.filters]; + this.filters = [ + (row) => + existing.every((f) => f(row)) || + Object.entries(scope).every(([key, value]) => row[key as keyof SiteRow] === value), + ]; + return this; + } whereNull(field: keyof SiteData) { this.filters.push((row) => row[field] == null); return this; @@ -221,7 +309,17 @@ class SiteQuery { return this; } - where(scopeOrField: Partial | keyof SiteData, operationOrValue?: unknown, expectedValue?: unknown) { + where( + scopeOrField: Partial | keyof SiteData | ((query: SiteQuery) => unknown), + operationOrValue?: unknown, + expectedValue?: unknown + ) { + if (typeof scopeOrField === 'function') { + const group = new SiteQuery(this.state); + scopeOrField(group); + this.filters.push((row) => group.filters.every((f) => f(row))); + return this; + } if (typeof scopeOrField === 'object') { this.filters.push((row) => Object.entries(scopeOrField).every(([key, value]) => row[key as keyof SiteData] === value) @@ -251,7 +349,7 @@ class SiteQuery { } orderBy(field: keyof SiteData, direction: string) { - this.sortBy = { field, direction }; + if (!this.sortBy) this.sortBy = { field, direction }; return this; } @@ -266,6 +364,11 @@ class SiteQuery { return { results: rows.slice(start, start + pageSize), total: rows.length }; } + async patch(patch: Partial) { + const rows = this.filteredRows(); + rows.forEach((row) => Object.assign(row, patch)); + return rows.length; + } async insert(input: Partial) { if (this.state.siteInsertError) throw this.state.siteInsertError; return addSite(this.state, { @@ -300,18 +403,40 @@ class VersionQuery { constructor(private readonly state: FakeState) {} + whereRaw() { + this.filters.push((version) => { + const site = this.state.sites.find((site) => site.siteId === version.siteId); + return Boolean( + site && (['deleted', 'expired'].includes(site.status) || site.activeVersionId !== version.versionId) + ); + }); + return this; + } + join() { + return this; + } + select() { + return this; + } + limit() { + return this; + } where(scope: Partial) { this.filters.push((row) => Object.entries(scope).every(([key, value]) => row[key as keyof VersionData] === value)); return this; } - whereNull(field: keyof VersionData) { - this.filters.push((row) => row[field] == null); + whereNull(field: keyof VersionData | 'site_versions.deletedAt') { + this.filters.push((row) => row[field === 'site_versions.deletedAt' ? 'deletedAt' : field] == null); return this; } - whereIn(field: keyof VersionData, values: unknown[]) { - this.filters.push((row) => values.includes(row[field])); + whereIn(field: keyof VersionData | 'sites.status', values: unknown[]) { + this.filters.push((row) => + values.includes( + field === 'sites.status' ? this.state.sites.find((site) => site.siteId === row.siteId)?.status : row[field] + ) + ); return this; } @@ -397,6 +522,8 @@ describe('SitesService behavior', () => { beforeEach(() => { state = { sites: [], versions: [] }; + mockAudit.mockReset().mockResolvedValue(undefined); + mockTokenState.revokedAt = null; database = createDatabase(state); queueAdd = jest.fn(); queueManager = { registerQueue: jest.fn(() => ({ add: queueAdd })) }; @@ -430,7 +557,9 @@ describe('SitesService behavior', () => { it('fails closed when Sites is disabled without touching validation, storage, or the database', async () => { mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); - await expect(service.createSite({ fileName: 'index.html', content: Buffer.from('site') })).rejects.toMatchObject({ + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'index.html', content: Buffer.from('site') }) + ).rejects.toMatchObject({ message: 'Sites hosting is disabled.', statusCode: 404, }); @@ -445,12 +574,14 @@ describe('SitesService behavior', () => { await expect( service.createSite({ + principal: { ...principal, identity: { email: 'author@example.com' } as any }, + visibility: 'public', fileName: 'docs.html', content: Buffer.from('source upload'), name: ' Product docs ', user: { email: 'author@example.com' } as any, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ id: 'site000001', name: 'Product docs', url: 'https://site-site000001.sites.example.com', @@ -502,6 +633,8 @@ describe('SitesService behavior', () => { enabledConfig({ ttl: { enabled: false } }); const result = await service.createSite({ + principal, + visibility: 'public', fileName: 'index.html', content: Buffer.from('site'), name: ' ', @@ -522,7 +655,9 @@ describe('SitesService behavior', () => { throw new SiteUploadValidationError('Only HTML uploads are supported.'); }); - await expect(service.createSite({ fileName: 'site.exe', content: Buffer.from('bad') })).rejects.toMatchObject({ + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.exe', content: Buffer.from('bad') }) + ).rejects.toMatchObject({ message: 'Only HTML uploads are supported.', statusCode: 400, }); @@ -536,9 +671,9 @@ describe('SitesService behavior', () => { throw unexpected; }); - await expect(service.createSite({ fileName: 'site.html', content: Buffer.from('site') })).rejects.toBe( - unexpected - ); + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.html', content: Buffer.from('site') }) + ).rejects.toBe(unexpected); expect(database.models.Site.transact).not.toHaveBeenCalled(); }); @@ -546,9 +681,9 @@ describe('SitesService behavior', () => { const insertError = new Error('version insert failed'); state.versionInsertError = insertError; - await expect(service.createSite({ fileName: 'site.html', content: Buffer.from('site') })).rejects.toBe( - insertError - ); + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.html', content: Buffer.from('site') }) + ).rejects.toBe(insertError); expect(mockDeletePrefix).toHaveBeenCalledWith('sites/site000001/versions/version00001'); expect(state.sites).toEqual([]); @@ -561,9 +696,9 @@ describe('SitesService behavior', () => { mockPutFiles.mockRejectedValueOnce(uploadError); mockDeletePrefix.mockRejectedValueOnce(cleanupError); - await expect(service.createSite({ fileName: 'site.html', content: Buffer.from('site') })).rejects.toBe( - uploadError - ); + await expect( + service.createSite({ principal, visibility: 'public', fileName: 'site.html', content: Buffer.from('site') }) + ).rejects.toBe(uploadError); expect(state.sites).toEqual([]); expect(mockWarn).toHaveBeenCalledWith( @@ -579,8 +714,8 @@ describe('SitesService behavior', () => { addSite(state, { siteId: `site-${String(index).padStart(3, '0')}`, updatedAt: `2026-06-${index}` }); } - const defaults = await service.listSites({ page: Number.NaN, limit: -1 }); - const capped = await service.listSites({ page: 1.9, limit: 101.8 }); + const defaults = await service.listSites({ page: Number.NaN, limit: -1 }, principal); + const capped = await service.listSites({ page: 1.9, limit: 101.8 }, principal); expect(defaults.pagination).toEqual({ current: 1, total: 5, items: 105, limit: 25 }); expect(defaults.sites).toHaveLength(25); @@ -600,7 +735,7 @@ describe('SitesService behavior', () => { updatedBy: '', }); - await expect(service.getSite('site-nullables')).resolves.toEqual({ + await expect(service.getSite('site-nullables', principal)).resolves.toMatchObject({ id: 'site-nullables', name: 'Nullable row', url: 'https://site-site-nullables.sites.example.com', @@ -618,20 +753,29 @@ describe('SitesService behavior', () => { it('returns not found for a missing or soft-deleted site', async () => { addSite(state, { siteId: 'deleted', deletedAt: UPDATED_AT }); - await expect(service.getSite('missing')).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404 }); - await expect(service.getSite('deleted')).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404 }); + await expect(service.getSite('missing', principal)).rejects.toMatchObject({ + message: 'Site not found.', + statusCode: 404, + }); + await expect(service.getSite('deleted', principal)).rejects.toMatchObject({ + message: 'Site not found.', + statusCode: 404, + }); }); it('does not classify an invalid expiry timestamp as elapsed', async () => { addSite(state, { expiresAt: 'not-a-date' }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ status: 'active', expiresAt: 'not-a-date' }); + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ + status: 'active', + expiresAt: 'not-a-date', + }); }); it('reports zero counters for a new empty site record', async () => { addSite(state, { fileCount: 0, sizeBytes: 0 }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ fileCount: 0, sizeBytes: 0 }); + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ fileCount: 0, sizeBytes: 0 }); }); }); @@ -645,7 +789,7 @@ describe('SitesService behavior', () => { if (overrides) addSite(state, overrides); await expect( - service.replaceSiteContent('site-1', { fileName: 'site.html', content: Buffer.from('site') }) + service.replaceSiteContent('site-1', { principal, fileName: 'site.html', content: Buffer.from('site') }) ).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404 }); expect(mockValidateSiteUpload).not.toHaveBeenCalled(); expect(mockPutFiles).not.toHaveBeenCalled(); @@ -661,6 +805,7 @@ describe('SitesService behavior', () => { }); const result = await service.replaceSiteContent('site-1', { + principal, fileName: 'replacement.html', content: Buffer.from('replacement'), }); @@ -669,7 +814,7 @@ describe('SitesService behavior', () => { id: 'site-1', fileCount: 1, sizeBytes: 14, - updatedBy: 'previous@example.com', + updatedBy: null, }); expect(site.activeVersionId).toBe('version00001'); expect(state.versions.find((version) => version.versionId === 'old-1')?.deletedAt).toEqual(expect.any(String)); @@ -689,9 +834,9 @@ describe('SitesService behavior', () => { addSite(state, { updatedBy: null }); const result = await service.replaceSiteContent('site-1', { + principal: { ...principal, identity: { email: 'editor@example.com' } as any }, fileName: 'replacement.html', content: Buffer.from('replacement'), - user: { email: 'editor@example.com' } as any, }); expect(result.updatedBy).toBe('editor@example.com'); @@ -702,6 +847,7 @@ describe('SitesService behavior', () => { addSite(state, { updatedBy: null }); const result = await service.replaceSiteContent('site-1', { + principal, fileName: 'replacement.html', content: Buffer.from('replacement'), }); @@ -718,7 +864,11 @@ describe('SitesService behavior', () => { state.sitePatchAndFetchError = patchError; await expect( - service.replaceSiteContent('site-1', { fileName: 'replacement.html', content: Buffer.from('replacement') }) + service.replaceSiteContent('site-1', { + principal, + fileName: 'replacement.html', + content: Buffer.from('replacement'), + }) ).rejects.toBe(patchError); expect(mockDeletePrefix).toHaveBeenCalledWith('sites/site-1/versions/version00001'); @@ -736,7 +886,7 @@ describe('SitesService behavior', () => { const now = jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-06-10T00:00:00.000Z')); addSite(state, { expiresAt: '2026-06-20T00:00:00.000Z' }); - const result = await service.extendSite('site-1'); + const result = await service.extendSite('site-1', principal); expect(result.expiresAt).toBe('2026-06-27T00:00:00.000Z'); expect(state.sites[0].expiresAt).toBe('2026-06-27T00:00:00.000Z'); @@ -747,7 +897,7 @@ describe('SitesService behavior', () => { const now = jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-06-10T00:00:00.000Z')); addSite(state, { expiresAt: null }); - await expect(service.extendSite('site-1')).resolves.toMatchObject({ + await expect(service.extendSite('site-1', principal)).resolves.toMatchObject({ expiresAt: '2026-06-17T00:00:00.000Z', }); now.mockRestore(); @@ -757,7 +907,7 @@ describe('SitesService behavior', () => { enabledConfig({ ttl: { enabled: false } }); addSite(state, { expiresAt: null }); - await expect(service.extendSite('site-1')).rejects.toMatchObject({ + await expect(service.extendSite('site-1', principal)).rejects.toMatchObject({ message: 'TTL is disabled for hosted sites.', statusCode: 400, }); @@ -765,40 +915,38 @@ describe('SitesService behavior', () => { }); describe('deleteSite', () => { - it('deletes stored versions before atomically soft-deleting site records', async () => { + it('tombstones the site before cleaning remaining versions', async () => { addSite(state); addVersion(state, { versionId: 'version-1', storagePrefix: 'prefix/version-1' }); addVersion(state, { versionId: 'version-2', storagePrefix: 'prefix/version-2', deletedAt: CREATED_AT }); - const result = await service.deleteSite('site-1'); + const result = await service.deleteSite('site-1', principal); - expect(mockDeletePrefix.mock.calls).toEqual([['prefix/version-1'], ['prefix/version-2']]); + expect(mockDeletePrefix.mock.calls).toEqual([['prefix/version-1']]); expect(result).toMatchObject({ id: 'site-1', status: 'deleted' }); expect(state.sites[0]).toMatchObject({ status: 'deleted', deletedAt: expect.any(String) }); - expect(state.versions[0].deletedAt).toBe(state.sites[0].deletedAt); + expect(state.versions[0].deletedAt).not.toBeNull(); expect(state.versions[1].deletedAt).toBe(CREATED_AT); }); it('returns not found without touching storage for an unknown site', async () => { - await expect(service.deleteSite('missing')).rejects.toMatchObject({ + await expect(service.deleteSite('missing', principal)).rejects.toMatchObject({ message: 'Site not found.', statusCode: 404, }); expect(mockDeletePrefix).not.toHaveBeenCalled(); - expect(database.models.Site.transact).not.toHaveBeenCalled(); }); - it('does not mark database rows deleted when object cleanup fails', async () => { + it('keeps access tombstoned and leaves storage for retry when cleanup fails', async () => { const site = addSite(state); const version = addVersion(state, { storagePrefix: 'prefix/version-1' }); const deleteError = new Error('delete failed'); mockDeletePrefix.mockRejectedValueOnce(deleteError); - await expect(service.deleteSite('site-1')).rejects.toBe(deleteError); - expect(site.status).toBe('active'); - expect(site.deletedAt).toBeNull(); + await expect(service.deleteSite('site-1', principal)).resolves.toMatchObject({ status: 'deleted' }); + expect(site.status).toBe('deleted'); + expect(site.deletedAt).not.toBeNull(); expect(version.deletedAt).toBeNull(); - expect(database.models.Site.transact).not.toHaveBeenCalled(); }); }); @@ -912,9 +1060,9 @@ describe('SitesService behavior', () => { await expect(service.getGatewayObject('site-site-1.sites.example.com', '/index.html')).rejects.toBe(outage); }); - it('matches only configured gateway hosts while Sites is enabled', async () => { + it('intercepts the configured namespace, including unknown hosts', async () => { await expect(service.matchesGatewayHost('site-abc123.sites.example.com')).resolves.toBe(true); - await expect(service.matchesGatewayHost('other-abc123.sites.example.com')).resolves.toBe(false); + await expect(service.matchesGatewayHost('other-abc123.sites.example.com')).resolves.toBe(true); await expect(service.matchesGatewayHost(undefined)).resolves.toBe(false); mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); @@ -925,7 +1073,6 @@ describe('SitesService behavior', () => { describe('expiration cleanup', () => { it.each([ ['Sites is disabled', { enabled: false }], - ['TTL is disabled', { enabled: true, ttl: { enabled: false } }], ['cleanup is disabled', { enabled: true, ttl: { enabled: true }, cleanup: { enabled: false } }], ])('does no work when %s', async (_case, sites) => { mockGetAllConfigs.mockResolvedValue({ sites }); @@ -969,15 +1116,27 @@ describe('SitesService behavior', () => { await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 2, cleaned: 1, errors: 1 }); expect(cleanedSite).toMatchObject({ status: 'expired', deletedAt: '2026-06-10T00:00:00.000Z' }); expect(cleanedVersion.deletedAt).toBe('2026-06-10T00:00:00.000Z'); - expect(failedSite).toMatchObject({ status: 'active', deletedAt: null }); + expect(failedSite).toMatchObject({ status: 'expired', deletedAt: '2026-06-10T00:00:00.000Z' }); expect(failedVersion.deletedAt).toBeNull(); - expect(mockError).toHaveBeenCalledWith( + expect(mockWarn).toHaveBeenCalledWith( { error: cleanupError, siteId: 'expired-failed' }, - 'Sites: cleanup failed' + 'Sites: terminal storage cleanup deferred' ); toISOString.mockRestore(); }); + it('retries superseded storage cleanup even while the active site has no expiry', async () => { + enabledConfig({ ttl: { enabled: false } }); + addSite(state); + addVersion(state); + mockDeletePrefix.mockRejectedValueOnce(new Error('storage unavailable')); + await service.replaceSiteContent('site-1', { principal, fileName: 'index.html', content: Buffer.from('new') }); + expect(state.versions[0].deletedAt).toBeNull(); + await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, cleaned: 1, errors: 0 }); + expect(state.versions[0].deletedAt).not.toBeNull(); + expect(state.versions[1].deletedAt).toBeNull(); + }); + it('returns zero counts when no active site has elapsed', async () => { addSite(state, { expiresAt: '2099-01-01T00:00:00.000Z' }); @@ -986,6 +1145,285 @@ describe('SitesService behavior', () => { }); }); + describe('V1 access enforcement', () => { + const stranger = { ...principal, userId: 'stranger', actor: 'stranger', roles: ['admin'] } as Principal; + it('creates user sites private by default with stable issuer ownership', async () => { + const result = await service.createSite({ principal, fileName: 'index.html', content: Buffer.from('site') }); + expect(result).toMatchObject({ + visibility: 'private', + currentRole: 'owner', + accessRevision: 1, + contentRevision: 1, + permissions: { canEdit: true }, + }); + expect(result.url).toBe('https://site-site000001.sites.example.com'); + expect(result.openUrl).toBe('https://lifecycle.example.net/sites/open/site000001'); + expect(state.sites[0]).toMatchObject({ ownerKind: 'user', ownerSubject: 'owner', ownerIssuer: principal.issuer }); + }); + it('creates public service-key sites and binds writes to that token only', async () => { + const machine = { + ...principal, + kind: 'service_key', + userId: null, + issuer: null, + tokenId: 7, + scopes: ['sites:write'], + } as Principal; + const result = await service.createSite({ + principal: machine, + fileName: 'index.html', + content: Buffer.from('site'), + }); + expect(result).toMatchObject({ + visibility: 'public', + currentRole: 'owner', + permissions: { canEdit: true, canChangeVisibility: false }, + }); + expect(state.sites[0]).toMatchObject({ + ownerKind: 'service_key', + creatorTokenId: 7, + ownerSubject: null, + ownerIssuer: null, + }); + await expect(service.deleteSite(result.id, principal)).rejects.toMatchObject({ httpStatus: 403 }); + await expect( + service.createSite({ + principal: machine, + visibility: 'private', + fileName: 'index.html', + content: Buffer.from('site'), + }) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + it('does not commit a machine upload if its token was revoked during storage staging', async () => { + const machine = { + ...principal, + kind: 'service_key', + userId: null, + issuer: null, + tokenId: 7, + scopes: ['sites:write'], + } as Principal; + mockPutFiles.mockImplementationOnce(async () => { + mockTokenState.revokedAt = new Date().toISOString(); + }); + await expect( + service.createSite({ principal: machine, fileName: 'index.html', content: Buffer.from('site') }) + ).rejects.toMatchObject({ code: 'invalid_credential' }); + expect(state.sites).toHaveLength(0); + expect(mockDeletePrefix).toHaveBeenCalled(); + }); + it('commits visibility audit with the mutation and rolls back if auditing fails', async () => { + addSite(state); + mockAudit.mockRejectedValueOnce(new Error('audit unavailable')); + await expect(service.setVisibility('site-1', 'private', principal, 1)).rejects.toThrow('audit unavailable'); + expect(state.sites[0].visibility).toBe('public'); + await service.setVisibility('site-1', 'private', principal, 1); + expect(mockAudit).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + event: 'sites.visibility_changed', + meta: expect.objectContaining({ siteId: 'site-1', from: 'public', to: 'private' }), + }) + ); + }); + it('advertises no uploads while the existing Sites setting is disabled', async () => { + mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); + await expect(service.getCapabilities(principal)).resolves.toMatchObject({ + defaultVisibility: 'private', + canCreate: false, + allowedVisibilities: [], + }); + }); + it('denies public and private gateway reads when the existing Sites setting is disabled', async () => { + addSite(state, { siteId: 'private', visibility: 'private' }); + addSite(state, { siteId: 'public', visibility: 'public' }); + mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false, domain: 'sites.example.com' } }); + for (const id of ['private', 'public']) { + const host = `site-${id}.sites.example.com`; + await expect(service.getGatewaySite(host)).rejects.toMatchObject({ statusCode: 404 }); + await expect(service.getGatewayLocator(host)).rejects.toMatchObject({ statusCode: 404 }); + await expect(service.getGatewayObject(host, '/index.html', async () => {})).rejects.toMatchObject({ + statusCode: 404, + }); + } + expect(mockGetObject).not.toHaveBeenCalled(); + }); + it('resolves valid missing host locators without a database Site for the anonymous bootstrap', async () => { + await expect(service.getGatewayLocator('site-missing.sites.example.com')).resolves.toEqual({ siteId: 'missing' }); + await expect(service.getGatewayLocator('site-missing.sites.example.com:443')).resolves.toEqual({ + siteId: 'missing', + }); + await expect(service.getGatewayLocator('site-missing--g-abcdef012345.sites.example.com')).rejects.toMatchObject({ + statusCode: 404, + }); + await expect(service.getGatewayLocator('site-missing.sites.example.com:9443')).rejects.toMatchObject({ + statusCode: 404, + }); + await expect(service.getGatewayLocator('site-missing.other.example.com')).rejects.toMatchObject({ + statusCode: 404, + }); + }); + it.each([undefined, 'public'] as const)( + 'refuses human creation (%s) before storage if the existing Sites setting is disabled', + async (visibility) => { + mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); + await expect( + service.createSite({ principal, visibility, fileName: 'index.html', content: Buffer.from('site') }) + ).rejects.toMatchObject({ statusCode: 404 }); + expect(mockPutFiles).not.toHaveBeenCalled(); + } + ); + it('gives a nonowner a distinct deletion error for a private Site versus a missing one', async () => { + addSite(state, { visibility: 'private', ownerSubject: 'other' }); + const errors = []; + for (const id of ['site-1', 'missing']) { + try { + await service.deleteSite(id, principal, 1); + } catch (error) { + errors.push({ + message: (error as Error).message, + code: (error as { code?: string }).code, + status: (error as { httpStatus?: number }).httpStatus, + }); + } + } + expect(errors).toEqual([ + { message: 'You do not have access to this Site.', code: 'site_access_denied', status: 403 }, + { message: 'Site not found.', code: 'site_not_found', status: 404 }, + ]); + }); + it('filters unauthorized private rows before page totals and shows public creator attribution', async () => { + addSite(state, { siteId: 'secret', visibility: 'private', ownerSubject: 'other' }); + addSite(state, { siteId: 'public', ownerSubject: 'other', createdBy: 'private-email@example.com' }); + addSite(state, { siteId: 'mine', visibility: 'private' }); + const result = await service.listSites({}, principal); + expect(result.pagination.items).toBe(2); + expect(result.sites.map((site) => site.id).sort()).toEqual(['mine', 'public']); + expect(result.sites.find((site) => site.id === 'public')).toMatchObject({ + createdBy: 'private-email@example.com', + updatedBy: null, + currentRole: null, + permissions: { canEdit: false }, + }); + const mine = await service.listSites({ view: 'mine' }, principal); + expect(mine.sites.map((site) => site.id)).toEqual(['mine']); + }); + it('denies all nonowner mutations including realm admins before storage', async () => { + addSite(state, { visibility: 'private' }); + await expect(service.getSite('site-1', stranger)).rejects.toMatchObject({ statusCode: 403 }); + await expect( + service.replaceSiteContent('site-1', { + principal: stranger, + fileName: 'index.html', + content: Buffer.from('bad'), + }) + ).rejects.toMatchObject({ httpStatus: 403 }); + await expect(service.extendSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 403 }); + await expect(service.setVisibility('site-1', 'public', stranger, 1)).rejects.toMatchObject({ httpStatus: 403 }); + await expect(service.deleteSite('site-1', stranger)).rejects.toMatchObject({ httpStatus: 403 }); + expect(mockPutFiles).not.toHaveBeenCalled(); + expect(mockDeletePrefix).not.toHaveBeenCalled(); + }); + it('requires a gateway authorizer for every private object before any storage call', async () => { + addSite(state, { visibility: 'private' }); + addVersion(state); + await expect(service.getGatewayObject('site-site-1.sites.example.com', '/')).rejects.toMatchObject({ + statusCode: 404, + }); + const authorize = jest.fn(async () => { + throw new Error('denied'); + }); + await expect(service.getGatewayObject('site-site-1.sites.example.com', '/asset.css', authorize)).rejects.toThrow( + 'denied' + ); + expect(authorize).toHaveBeenCalledTimes(1); + expect(mockGetObject).not.toHaveBeenCalled(); + }); + it('keeps the content URL and Site ID while changing visibility', async () => { + addSite(state); + addVersion(state); + const hidden = await service.setVisibility('site-1', 'private', principal, 1); + expect(hidden.url).toBe('https://site-site-1.sites.example.com'); + expect(hidden.openUrl).toBe('https://lifecycle.example.net/sites/open/site-1'); + await expect(service.getGatewaySite('site-site-1.sites.example.com')).resolves.toMatchObject({ + site: expect.objectContaining({ visibility: 'private' }), + }); + const published = await service.setVisibility('site-1', 'public', principal, 2); + expect(published.url).toBe(hidden.url); + expect(published.openUrl).toBe(hidden.openUrl); + }); + it('rejects a stale visibility revision without modifying the site', async () => { + const row = addSite(state); + await expect(service.setVisibility('site-1', 'private', principal, 2)).rejects.toMatchObject({ + code: 'site_changed', + }); + expect(state.sites[0].visibility).toBe(row.visibility); + }); + it('does not resurrect content deleted while its replacement uploads', async () => { + addSite(state); + addVersion(state); + mockPutFiles.mockImplementationOnce(async () => { + await service.deleteSite('site-1', principal); + }); + await expect( + service.replaceSiteContent('site-1', { principal, fileName: 'index.html', content: Buffer.from('replacement') }) + ).rejects.toMatchObject({ statusCode: 404 }); + expect(state.sites[0].status).toBe('deleted'); + expect(mockDeletePrefix).toHaveBeenCalledWith('sites/site-1/versions/version00001'); + }); + it('rejects publishing or deleting content replaced since the owner prepared the action', async () => { + addSite(state, { visibility: 'private' }); + addVersion(state); + const before = await service.getSite('site-1', principal); + const replaced = await service.replaceSiteContent('site-1', { + principal, + fileName: 'index.html', + content: Buffer.from('new private content'), + expectedAccessRevision: before.accessRevision, + expectedContentRevision: before.contentRevision, + }); + expect(replaced).toMatchObject({ visibility: 'private', accessRevision: 2, contentRevision: 2 }); + expect(replaced.url).toBe(before.url); + await expect(service.setVisibility('site-1', 'public', principal, before.accessRevision)).rejects.toMatchObject({ + code: 'site_changed', + }); + await expect(service.deleteSite('site-1', principal, before.accessRevision)).rejects.toMatchObject({ + code: 'site_changed', + }); + expect(state.sites[0]).toMatchObject({ visibility: 'private', status: 'active', deletedAt: null }); + const published = await service.setVisibility('site-1', 'public', principal, replaced.accessRevision); + expect(published).toMatchObject({ visibility: 'public', accessRevision: 3, contentRevision: 2 }); + }); + it('rejects replacement when a private site is published during the upload', async () => { + addSite(state, { visibility: 'private' }); + addVersion(state); + mockPutFiles.mockImplementationOnce(async () => { + await service.setVisibility('site-1', 'public', principal, 1); + }); + await expect( + service.replaceSiteContent('site-1', { + principal, + fileName: 'index.html', + content: Buffer.from('private draft'), + }) + ).rejects.toMatchObject({ code: 'site_changed' }); + expect(state.sites[0]).toMatchObject({ activeVersionId: 'version-1', visibility: 'public', contentRevision: 1 }); + }); + it('rejects replacement after visibility changed during upload', async () => { + addSite(state); + addVersion(state); + mockPutFiles.mockImplementationOnce(async () => { + await service.setVisibility('site-1', 'private', principal, 1); + }); + await expect( + service.replaceSiteContent('site-1', { principal, fileName: 'index.html', content: Buffer.from('replacement') }) + ).rejects.toMatchObject({ code: 'site_changed' }); + expect(state.sites[0].activeVersionId).toBe('version-1'); + expect(state.sites[0].visibility).toBe('private'); + }); + }); + describe('cleanup queue wiring', () => { it('processes cleanup jobs and logs the result', async () => { const result = { expired: 3, cleaned: 2, errors: 1 }; @@ -997,7 +1435,6 @@ describe('SitesService behavior', () => { it.each([ ['Sites is disabled', { enabled: false }], - ['TTL is disabled', { enabled: true, ttl: { enabled: false } }], ['cleanup is disabled', { enabled: true, ttl: { enabled: true }, cleanup: { enabled: false } }], ])('does not schedule when %s', async (_case, sites) => { mockGetAllConfigs.mockResolvedValue({ sites }); @@ -1008,6 +1445,12 @@ describe('SitesService behavior', () => { expect(mockDebug).toHaveBeenCalledWith('Sites: cleanup disabled'); }); + it('schedules storage retries even when new sites have no expiry', async () => { + enabledConfig({ ttl: { enabled: false } }); + await service.setupSitesCleanupJob(); + expect(queueAdd).toHaveBeenCalledWith('sites-cleanup', {}, expect.objectContaining({ jobId: 'sites-cleanup' })); + }); + it('schedules one stable repeating cleanup job at the configured interval', async () => { enabledConfig({ cleanup: { enabled: true, intervalMinutes: 12 } }); diff --git a/src/server/services/__tests__/sites.test.ts b/src/server/services/__tests__/sites.test.ts index 291cdb4d..3c752133 100644 --- a/src/server/services/__tests__/sites.test.ts +++ b/src/server/services/__tests__/sites.test.ts @@ -19,6 +19,10 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; mockRedisClient(); const mockGetAllConfigs = jest.fn(); +const mockAudit = jest.fn, unknown[]>(async () => undefined); +jest.mock('server/services/authAudit', () => ({ + recordAuthAuditEventInTransaction: (...args: unknown[]) => mockAudit(...args), +})); jest.mock('shared/config', () => ({ QUEUE_NAMES: { @@ -62,9 +66,50 @@ jest.mock('server/services/globalConfig', () => ({ }, })); +import type { Principal } from 'server/lib/principal'; +const principal: Principal = { + kind: 'user', + authMethod: 'session', + userId: 'owner', + issuer: 'https://identity.example/realms/lifecycle', + actor: 'owner', + roles: ['user'], + scopes: null, + tokenId: null, + repositoryAllowlist: null, + repositoryAllowlistRepoIds: null, + identity: null, + oauth: { sessionId: 'session', tokenId: 'jwt', clientId: 'cli', expiresAt: 1e12 }, +}; +jest.mock('server/services/keycloak/principalStatus', () => ({ + getUserStatus: jest.fn(async () => 'active'), + getUserSessionStatus: jest.fn(async () => 'active'), + getOAuthTokenStatus: jest.fn(async () => 'active'), +})); +jest.mock('server/lib/verifiedOAuthBearer', () => ({ getVerifiedOAuthBearer: jest.fn(() => 'fixture-bearer') })); +jest.mock('server/lib/sites/browserAuth', () => ({ + ...jest.requireActual('server/lib/sites/browserAuth'), +})); +const originalSitesEnv = { ...process.env }; +beforeEach(() => { + process.env.ENABLE_AUTH = 'true'; + process.env.KEYCLOAK_ISSUER = principal.issuer!; + process.env.LIFECYCLE_UI_URL = 'https://lifecycle.example.net'; +}); +afterEach(() => { + process.env = { ...originalSitesEnv }; +}); + import SitesService from 'server/services/sites'; type SiteRow = { + visibility: 'private' | 'public'; + ownerKind: 'user' | 'service_key' | 'unresolved'; + ownerIssuer: string | null; + ownerSubject: string | null; + creatorTokenId: number | null; + accessRevision: number; + contentRevision: number; siteId: string; name: string; status: string; @@ -85,6 +130,27 @@ class SiteQuery { constructor(private readonly rows: SiteRow[]) {} + forUpdate() { + return this; + } + orWhere(scope: Partial) { + const existing = [...this.filters]; + this.filters = [ + (row) => + existing.every((f) => f(row)) || + Object.entries(scope).every(([key, value]) => row[key as keyof SiteRow] === value), + ]; + return this; + } + where(scope: Partial | keyof SiteRow | ((query: SiteQuery) => unknown), value?: unknown) { + if (typeof scope === 'function') { + const group = new SiteQuery(this.rows); + scope(group); + this.filters.push((row) => group.filters.every((f) => f(row))); + } else if (typeof scope === 'string') this.filters.push((row) => row[scope] === value); + else this.filters.push((row) => Object.entries(scope).every(([key, value]) => row[key as keyof SiteRow] === value)); + return this; + } whereNull(field: keyof SiteRow) { this.filters.push((row) => row[field] == null); return this; @@ -105,7 +171,7 @@ class SiteQuery { } orderBy(field: keyof SiteRow, direction: string) { - this.sortBy = { field, direction }; + if (!this.sortBy) this.sortBy = { field, direction }; return this; } @@ -137,6 +203,13 @@ class SiteQuery { function createSiteRow(overrides: Partial = {}): SiteRow { return { + visibility: 'public', + ownerKind: 'user', + ownerIssuer: principal.issuer!, + ownerSubject: principal.userId, + creatorTokenId: null, + accessRevision: 1, + contentRevision: 1, siteId: 'site-1', name: 'site', status: 'active', @@ -187,16 +260,16 @@ describe('SitesService', () => { describe('listSites', () => { it('returns all non-deleted sites without a user filter', async () => { rows.push( - createSiteRow({ siteId: 'old', updatedAt: '2026-05-01T00:00:00.000Z' }), + createSiteRow({ siteId: 'old', createdAt: '2026-05-01T00:00:00.000Z' }), createSiteRow({ siteId: 'deleted', - updatedAt: '2026-05-03T00:00:00.000Z', + createdAt: '2026-05-03T00:00:00.000Z', deletedAt: '2026-05-04T00:00:00.000Z', }), - createSiteRow({ siteId: 'new', updatedAt: '2026-05-02T00:00:00.000Z' }) + createSiteRow({ siteId: 'new', createdAt: '2026-05-02T00:00:00.000Z' }) ); - await expect(service.listSites()).resolves.toMatchObject({ + await expect(service.listSites({}, principal)).resolves.toMatchObject({ sites: [{ id: 'new' }, { id: 'old' }], pagination: { current: 1, @@ -207,47 +280,20 @@ describe('SitesService', () => { }); }); - it('filters to sites created or last updated by the supplied user email', async () => { - rows.push( - createSiteRow({ - siteId: 'created-by-user', - createdBy: 'ALICE@example.com', - updatedBy: 'other@example.com', - updatedAt: '2026-05-01T00:00:00.000Z', - }), - createSiteRow({ - siteId: 'updated-by-user', - createdBy: 'other@example.com', - updatedBy: 'alice@example.com', - updatedAt: '2026-05-03T00:00:00.000Z', - }), - createSiteRow({ - siteId: 'not-touched-by-user', - createdBy: 'other@example.com', - updatedBy: 'other@example.com', - updatedAt: '2026-05-04T00:00:00.000Z', - }) - ); - - await expect(service.listSites({ user: ' Alice@Example.com ' })).resolves.toMatchObject({ - sites: [{ id: 'updated-by-user' }, { id: 'created-by-user' }], - pagination: { - current: 1, - total: 1, - items: 2, - limit: 25, - }, + it('rejects legacy email filters instead of treating mutable attribution as ownership', async () => { + await expect(service.listSites({ user: 'Alice@Example.com' }, principal)).rejects.toMatchObject({ + statusCode: 400, }); }); it('paginates sites after sorting and filtering', async () => { rows.push( - createSiteRow({ siteId: 'oldest', updatedAt: '2026-05-01T00:00:00.000Z' }), - createSiteRow({ siteId: 'middle', updatedAt: '2026-05-02T00:00:00.000Z' }), - createSiteRow({ siteId: 'newest', updatedAt: '2026-05-03T00:00:00.000Z' }) + createSiteRow({ siteId: 'oldest', createdAt: '2026-05-01T00:00:00.000Z' }), + createSiteRow({ siteId: 'middle', createdAt: '2026-05-02T00:00:00.000Z' }), + createSiteRow({ siteId: 'newest', createdAt: '2026-05-03T00:00:00.000Z' }) ); - await expect(service.listSites({ page: 2, limit: 1 })).resolves.toMatchObject({ + await expect(service.listSites({ page: 2, limit: 1 }, principal)).resolves.toMatchObject({ sites: [{ id: 'middle' }], pagination: { current: 2, @@ -263,10 +309,10 @@ describe('SitesService', () => { const now = jest.spyOn(Date, 'now').mockReturnValue(new Date(expiresAt).getTime()); rows.push(createSiteRow({ expiresAt })); - await expect(service.listSites()).resolves.toMatchObject({ + await expect(service.listSites({}, principal)).resolves.toMatchObject({ sites: [{ id: 'site-1', status: 'expired', expiresAt }], }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ id: 'site-1', status: 'expired', expiresAt, @@ -274,7 +320,7 @@ describe('SitesService', () => { now.mockRestore(); }); - it('keeps an elapsed expiresAt active when site TTL is disabled', async () => { + it('does not resurrect elapsed sites when TTL defaults are disabled', async () => { mockGetAllConfigs.mockResolvedValue({ sites: { enabled: true, @@ -285,12 +331,12 @@ describe('SitesService', () => { }); rows.push(createSiteRow({ expiresAt: '2000-01-01T00:00:00.000Z' })); - await expect(service.listSites()).resolves.toMatchObject({ - sites: [{ id: 'site-1', status: 'active' }], + await expect(service.listSites({}, principal)).resolves.toMatchObject({ + sites: [{ id: 'site-1', status: 'expired' }], }); - await expect(service.getSite('site-1')).resolves.toMatchObject({ + await expect(service.getSite('site-1', principal)).resolves.toMatchObject({ id: 'site-1', - status: 'active', + status: 'expired', }); }); }); diff --git a/src/server/services/apiToken.ts b/src/server/services/apiToken.ts index 59c543b8..a1c5d162 100644 --- a/src/server/services/apiToken.ts +++ b/src/server/services/apiToken.ts @@ -27,15 +27,8 @@ import { getLogger } from 'server/lib/logger'; import { GITHUB_APP_INSTALLATION_ID } from 'shared/config'; import { paginate, type PaginationMetadata, type PaginationParams } from 'server/lib/paginate'; -export const API_TOKEN_SCOPES: ApiTokenScope[] = [ - 'env:read', - 'env:write', - 'env:admin', - 'sites:read', - 'sites:write', - 'repos:read', - 'repos:write', -]; +import { API_TOKEN_SCOPES } from 'server/lib/apiTokenScopes'; +export { API_TOKEN_SCOPES, scopeSatisfies } from 'server/lib/apiTokenScopes'; export { API_TOKEN_PATTERN }; const LAST_USED_WRITE_INTERVAL_MS = 60_000; @@ -86,6 +79,7 @@ export interface IssueTokenInput { } export interface UserTokenOwner { + issuer?: string | null; userId: string; githubUsername: string | null; email: string | null; @@ -105,17 +99,6 @@ export interface IssueUserTokenInput { export type OwnerSelectorField = 'ownerUserId' | 'ownerEmail' | 'ownerPreferredUsername'; -/** write ⊃ read within one resource; legacy env:admin covers env:* only; never cross-resource. */ -export function scopeSatisfies(granted: ApiTokenScope[], required: ApiTokenScope): boolean { - return granted.some((scope) => { - if (!API_TOKEN_SCOPES.includes(scope)) return false; - if (scope === required) return true; - if (scope === 'env:admin') return required === 'env:read' || required === 'env:write'; - const [resource, action] = scope.split(':'); - return action === 'write' && required === `${resource}:read`; - }); -} - /** SECURITY: only a null/undefined allowlist is unrestricted; an explicit empty one fails closed. */ export function isRepositoryAllowed(allowlist: string[] | null | undefined, fullName: string): boolean { if (!allowlist) return true; @@ -605,6 +588,7 @@ export default class ApiTokenService { expiresAt: input.expiresAt, createdBy: input.owner.userId, ownerUserId: input.owner.userId, + ownerIssuer: input.owner.issuer ?? null, ownerGithubUsername: input.owner.githubUsername, ownerEmail: input.owner.email ? input.owner.email.trim().toLowerCase() : null, ownerPreferredUsername: input.owner.preferredUsername diff --git a/src/server/services/keycloak/adminClient.test.ts b/src/server/services/keycloak/adminClient.test.ts index d11a6c37..fba652df 100644 --- a/src/server/services/keycloak/adminClient.test.ts +++ b/src/server/services/keycloak/adminClient.test.ts @@ -572,3 +572,33 @@ it('keeps its timeout active until the response body is consumed', async () => { expect.objectContaining({ kind: 'unavailable' } satisfies Partial) ); }); + +describe('OAuth access-token introspection', () => { + it('uses confidential client authentication directly with no admin token or client lookup', async () => { + const fetcher = jest.fn().mockResolvedValue(json({ active: true })); + await expect(client(fetcher).introspectAccessToken('user-access-token')).resolves.toBe(true); + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, request] = fetcher.mock.calls[0]; + expect(url).toBe('https://auth.example.com/realms/lifecycle/protocol/openid-connect/token/introspect'); + const body = new URLSearchParams(request.body); + expect(body.get('token')).toBe('user-access-token'); + expect(body.get('client_id')).toBe('management-client'); + expect(body.get('client_secret')).toBe('management-secret'); + expect(request.redirect).toBe('error'); + }); + it('accepts active=false as revoked, rather than transport failure', async () => { + await expect( + client(jest.fn().mockResolvedValue(json({ active: false }))).introspectAccessToken('token') + ).resolves.toBe(false); + }); + it.each([{}, { active: 'true' }, [], null])('fails closed for malformed status %j', async (value) => { + await expect(client(jest.fn().mockResolvedValue(json(value))).introspectAccessToken('token')).rejects.toMatchObject( + { kind: 'invalid_response' } + ); + }); + it('fails closed on forbidden introspection transport', async () => { + await expect( + client(jest.fn().mockResolvedValue(json({}, 403))).introspectAccessToken('token') + ).rejects.toMatchObject({ kind: 'forbidden' }); + }); +}); diff --git a/src/server/services/keycloak/adminClient.ts b/src/server/services/keycloak/adminClient.ts index 8755559a..a92ef39d 100644 --- a/src/server/services/keycloak/adminClient.ts +++ b/src/server/services/keycloak/adminClient.ts @@ -183,6 +183,30 @@ export class KeycloakAdminClient { await this.requestJson('DELETE', path, body); } + /** RFC 7662, authenticated as this confidential client; no Admin API role expansion. */ + async introspectAccessToken(token: string): Promise { + return this.fetchWithTimeout( + `${this.issuer}/protocol/openid-connect/token/introspect`, + { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: new URLSearchParams({ + token, + token_type_hint: 'access_token', + client_id: this.clientId, + client_secret: this.clientSecret, + }).toString(), + }, + async (response) => { + if (!response.ok) throw errorForStatus(response.status); + const result = await this.readJson(response); + if (!isRecord(result) || typeof result.active !== 'boolean') + throw new KeycloakAdminError('invalid_response', response.status, 'Keycloak returned invalid token status.'); + return result.active; + } + ); + } + private async accessToken(): Promise { const now = Date.now(); if (this.token && now < this.token.expiresAtMs - TOKEN_EXPIRY_MARGIN_MS) { diff --git a/src/server/services/keycloak/principalStatus.test.ts b/src/server/services/keycloak/principalStatus.test.ts index ec441a31..ca1d2b74 100644 --- a/src/server/services/keycloak/principalStatus.test.ts +++ b/src/server/services/keycloak/principalStatus.test.ts @@ -312,3 +312,18 @@ describe('principal status configuration', () => { afterAll(() => { restorePrincipalStatusConfig(); }); + +describe('standard OAuth token introspection', () => { + it.each([true, false])('maps live introspection active=%s without client-list privileges', async (active) => { + const introspectAccessToken = jest.fn().mockResolvedValue(active); + const status = new KeycloakPrincipalStatus({ introspectAccessToken } as unknown as KeycloakAdminClient); + await expect(status.getOAuthTokenStatus('test-bearer')).resolves.toBe(active ? 'active' : 'revoked'); + expect(introspectAccessToken).toHaveBeenCalledWith('test-bearer'); + }); + it('fails closed on unavailable/forbidden/malformed introspection without logging bearer data', async () => { + const introspectAccessToken = jest.fn().mockRejectedValue(new Error('unavailable')); + const status = new KeycloakPrincipalStatus({ introspectAccessToken } as unknown as KeycloakAdminClient); + await expect(status.getOAuthTokenStatus('secret-test-bearer')).resolves.toBe('unknown'); + expect(JSON.stringify(mockWarn.mock.calls)).not.toContain('secret-test-bearer'); + }); +}); diff --git a/src/server/services/keycloak/principalStatus.ts b/src/server/services/keycloak/principalStatus.ts index ee7cf671..e50d41d4 100644 --- a/src/server/services/keycloak/principalStatus.ts +++ b/src/server/services/keycloak/principalStatus.ts @@ -93,6 +93,19 @@ export class KeycloakPrincipalStatus { } } + async getOAuthTokenStatus(token: string): Promise<'active' | 'revoked' | 'unknown'> { + try { + return (await this.client.introspectAccessToken(token)) ? 'active' : 'revoked'; + } catch (error) { + // Never log the introspection request, response claims, or bearer. + getLogger().warn( + { error: error instanceof Error ? error.name : 'unknown' }, + 'Keycloak token status lookup failed' + ); + return 'unknown'; + } + } + private async resolveBaseRoleStatus(encodedSub: string): Promise<'active' | 'no_base_role' | 'unknown'> { // Composite endpoint: expands default-roles- and any other composite grants. const userRoles = await this.client.get(`/users/${encodedSub}/role-mappings/realm/composite`); @@ -139,3 +152,7 @@ function configuredService(): KeycloakPrincipalStatus | null { export async function getUserStatus(sub: string): Promise { return (await configuredService()?.getUserStatus(sub)) ?? 'unknown'; } + +export async function getOAuthTokenStatus(token: string): Promise<'active' | 'revoked' | 'unknown'> { + return (await configuredService()?.getOAuthTokenStatus(token)) ?? 'unknown'; +} diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index ab9dabb8..cf039a8b 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -35,6 +35,11 @@ import type Site from 'server/models/Site'; import type SiteVersion from 'server/models/SiteVersion'; import type { PaginationMetadata } from 'server/lib/paginate'; import type { RequestUserIdentity } from 'server/lib/get-user'; +import type { Principal } from 'server/lib/principal'; +import { recordAuthAuditEventInTransaction } from 'server/services/authAudit'; +import { AppError } from 'server/lib/appError'; +import { assertPrivateSitesReady } from 'server/lib/sites/browserAuth'; +import { assertSitesPrincipal, assertSiteOwner, isSiteOwner, hasSitesScope } from 'server/lib/sites/policy'; const createSiteId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10); const createVersionId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 12); @@ -45,8 +50,15 @@ const MAX_LIST_LIMIT = 100; type SitesErrorStatusCode = 400 | 401 | 403 | 404 | 409 | 500 | 502 | 503; export class SitesServiceError extends Error { + readonly httpStatus?: number; + readonly code?: string; constructor(message: string, public statusCode: SitesErrorStatusCode = 500) { super(message); + // Match the ownership policy's not-found contract without changing other legacy errors. + if (statusCode === 404) { + this.httpStatus = 404; + this.code = 'site_not_found'; + } } } @@ -55,10 +67,16 @@ export type CreateOrReplaceSiteInput = { content: Buffer; name?: string | null; user?: RequestUserIdentity | null; + principal: Principal; + visibility?: 'private' | 'public'; + expectedAccessRevision?: number; + expectedContentRevision?: number; }; export type ListSitesFilters = { user?: string; + view?: 'mine' | 'public' | 'all'; + q?: string; page?: number; limit?: number; }; @@ -73,6 +91,13 @@ export type SiteResponse = { name: string; url: string; status: string; + visibility: 'private' | 'public'; + contentUrl: string; + openUrl: string; + accessRevision: number; + contentRevision: number; + currentRole: 'owner' | null; + permissions: { canView: boolean; canEdit: boolean; canDelete: boolean; canChangeVisibility: boolean }; createdAt: string | null; updatedAt: string | null; expiresAt: string | null; @@ -110,28 +135,40 @@ export default class SitesService extends Service { } } - private serialize(site: Site, config: ResolvedSitesConfig): SiteResponse { + private serialize(site: Site, config: ResolvedSitesConfig, principal?: Principal): SiteResponse { + const owner = isSiteOwner(site, principal); + const writable = owner && Boolean(principal && hasSitesScope(principal, 'write')); + const contentUrl = buildSiteUrl(site.siteId, config); + const uiUrl = process.env.LIFECYCLE_UI_URL; const expiresAt = site.expiresAt ? new Date(site.expiresAt).getTime() : null; const status = - site.status === 'active' && - config.ttl.enabled && - expiresAt !== null && - Number.isFinite(expiresAt) && - expiresAt <= Date.now() + site.status === 'active' && expiresAt !== null && Number.isFinite(expiresAt) && expiresAt <= Date.now() ? 'expired' : site.status; return { id: site.siteId, name: site.name, - url: buildSiteUrl(site.siteId, config), + url: contentUrl, + contentUrl, + openUrl: uiUrl ? new URL(`/sites/open/${site.siteId}`, uiUrl).toString() : contentUrl, + visibility: site.visibility, + accessRevision: site.accessRevision, + contentRevision: site.contentRevision, + currentRole: owner ? 'owner' : null, + permissions: { + canView: true, + canEdit: writable && status === 'active', + canDelete: writable && !site.deletedAt && status !== 'deleted', + canChangeVisibility: writable && status === 'active' && site.ownerKind === 'user', + }, status, createdAt: site.createdAt || null, updatedAt: site.updatedAt || null, expiresAt: site.expiresAt || null, fileCount: Number(site.fileCount || 0), sizeBytes: Number(site.sizeBytes || 0), - createdBy: site.createdBy || null, - updatedBy: site.updatedBy || null, + createdBy: owner || site.visibility === 'public' ? site.createdBy || null : null, + updatedBy: owner ? site.updatedBy || null : null, }; } @@ -146,21 +183,18 @@ export default class SitesService extends Service { return new Date(Date.now() + config.ttl.defaultDays * DAY_MS).toISOString(); } - private async createVersion( + private async stageVersion( siteId: string, upload: ValidatedSiteUpload, config: ResolvedSitesConfig, - uploadedStoragePrefixes: string[], - trx?: Transaction - ): Promise { + uploadedStoragePrefixes: string[] + ) { const versionId = createVersionId(); const storage = new SitesStorage(config); const storagePrefix = storage.versionPrefix(siteId, versionId); - uploadedStoragePrefixes.push(storagePrefix); await storage.putFiles(storagePrefix, upload.files); - - return this.db.models.SiteVersion.query(trx).insert({ + return { siteId, versionId, storagePrefix, @@ -168,7 +202,77 @@ export default class SitesService extends Service { fileCount: upload.fileCount, sizeBytes: upload.sizeBytes, manifest: upload.files.map(({ path, sizeBytes, contentType }) => ({ path, sizeBytes, contentType })), - }) as unknown as Promise; + }; + } + + private assertPrivateReady(config: ResolvedSitesConfig) { + try { + assertPrivateSitesReady(buildSiteUrl('readiness', config)); + } catch { + throw new AppError({ + httpStatus: 503, + code: 'private_sites_unavailable', + message: 'Private Sites are not securely configured on this installation.', + }); + } + } + + private async audit( + trx: Transaction, + event: string, + siteId: string, + principal: Principal, + meta: Record = {} + ) { + await recordAuthAuditEventInTransaction(trx, { + event: `sites.${event}`, + principalKind: principal.kind, + principalId: principal.userId, + actorId: principal.actor, + tokenId: principal.tokenId, + outcome: 'success', + meta: { siteId, issuer: principal.issuer ?? null, ...meta }, + }); + } + + private checkRevision(site: Site, access?: number, content?: number) { + if ( + (access !== undefined && access !== site.accessRevision) || + (content !== undefined && content !== site.contentRevision) + ) { + throw new AppError({ + httpStatus: 409, + code: 'site_changed', + message: 'The site changed. Refresh it before trying again.', + }); + } + } + + async getCapabilities(principal: Principal) { + await assertSitesPrincipal(principal); + const config = await this.getConfig(); + const machine = principal.kind === 'service_key'; + let privateReady = false; + try { + this.assertPrivateReady(config); + privateReady = true; + } catch { + /* Report unavailable, never silently publish. */ + } + return { + enabled: config.enabled, + upload: config.upload, + defaultVisibility: machine ? ('public' as const) : ('private' as const), + allowedVisibilities: + !config.enabled || !hasSitesScope(principal, 'write') + ? [] + : machine + ? ['public'] + : privateReady + ? ['private', 'public'] + : [], + canCreate: config.enabled && hasSitesScope(principal, 'write') && (machine || privateReady), + }; } private validateUpload(input: CreateOrReplaceSiteInput, config: ResolvedSitesConfig): ValidatedSiteUpload { @@ -247,173 +351,289 @@ export default class SitesService extends Service { } async createSite(input: CreateOrReplaceSiteInput): Promise { + await assertSitesPrincipal(input.principal, 'write'); const config = await this.getConfig(); this.assertEnabled(config); + const principal = input.principal; + const machine = principal.kind === 'service_key'; + const visibility = input.visibility ?? (machine ? 'public' : 'private'); + if (!['private', 'public'].includes(visibility) || (machine && visibility !== 'public')) { + throw new SitesServiceError('Service-key sites must be public; visibility must be private or public.', 400); + } + if (principal.kind !== 'service_key' || visibility === 'private') this.assertPrivateReady(config); const upload = this.validateUpload(input, config); const siteId = createSiteId(); - const siteName = input.name?.trim() || this.defaultSiteName(siteId, config); - const expiresAt = this.expirationForNewSite(config); - - const site = await this.withUploadRollback(config, (uploadedStoragePrefixes) => - this.db.models.Site.transact(async (trx) => { + const site = await this.withUploadRollback(config, async (prefixes) => { + const version = await this.stageVersion(siteId, upload, config, prefixes); + await assertSitesPrincipal(principal, 'write'); + return this.db.models.Site.transact(async (trx) => { + await assertSitesPrincipal(principal, 'write', trx); const created = (await this.db.models.Site.query(trx).insert({ siteId, - name: siteName, + name: input.name?.trim() || this.defaultSiteName(siteId, config), status: 'active', activeVersionId: null, - fileCount: 0, - sizeBytes: 0, - expiresAt, - createdBy: input.user?.email || null, - updatedBy: input.user?.email || null, - })) as Site; - - const version = await this.createVersion(siteId, upload, config, uploadedStoragePrefixes, trx); - return created.$query(trx).patchAndFetch({ - activeVersionId: version.versionId, fileCount: upload.fileCount, sizeBytes: upload.sizeBytes, - }) as unknown as Promise; - }) - ); - - return this.serialize(site, config); + expiresAt: this.expirationForNewSite(config), + visibility, + ownerKind: machine ? 'service_key' : 'user', + ownerIssuer: machine ? null : principal.issuer, + ownerSubject: machine ? null : principal.userId, + creatorTokenId: machine ? principal.tokenId : null, + accessRevision: 1, + contentRevision: 1, + createdBy: principal.identity?.email || null, + updatedBy: principal.identity?.email || null, + })) as Site; + await this.db.models.SiteVersion.query(trx).insert(version); + await this.audit(trx, 'created', siteId, principal, { visibility }); + return created.$query(trx).patchAndFetch({ activeVersionId: version.versionId }) as unknown as Promise; + }); + }); + return this.serialize(site, config, principal); } - async listSites(filters: ListSitesFilters = {}): Promise { + async listSites(filters: ListSitesFilters = {}, principal: Principal): Promise { + await assertSitesPrincipal(principal); const config = await this.getConfig(); this.assertEnabled(config); - const user = filters.user?.trim().toLowerCase(); const { page, limit } = this.normalizePagination(filters); - + const view = filters.view ?? 'all'; + if (!['all', 'mine', 'public'].includes(view) || filters.user) { + throw new SitesServiceError('Use view=mine, public, or all; email filters are not supported.', 400); + } + const owner = + principal.kind === 'service_key' + ? { ownerKind: 'service_key', creatorTokenId: principal.tokenId } + : { ownerKind: 'user', ownerIssuer: principal.issuer, ownerSubject: principal.userId }; const query = this.db.models.Site.query().whereNull('deletedAt'); - if (user) { - query.whereRaw('(lower("createdBy") = ? or lower("updatedBy") = ?)', [user, user]); + if (view === 'mine') query.where(owner); + else if (view === 'public') query.where('visibility', 'public'); + else query.where((q) => q.where('visibility', 'public').orWhere(owner)); + const search = filters.q?.trim(); + if (search) { + if (search.length > 200) throw new SitesServiceError('Search is too long.', 400); + // Literal substring matching: user input never controls SQL wildcards. + query.whereRaw('(strpos(lower("name"), lower(?)) > 0 or strpos(lower("siteId"), lower(?)) > 0)', [ + search, + search, + ]); } - - const result = await query.orderBy('updatedAt', 'desc').page(page - 1, limit); + const result = await query + .orderBy('createdAt', 'desc') + .orderBy('siteId', 'asc') + .page(page - 1, limit); return { - sites: (result.results as Site[]).map((site) => this.serialize(site, config)), - pagination: { - current: page, - total: Math.max(Math.ceil(result.total / limit), 1), - items: result.total, - limit, - }, + sites: (result.results as Site[]).map((site) => this.serialize(site, config, principal)), + pagination: { current: page, total: Math.max(Math.ceil(result.total / limit), 1), items: result.total, limit }, }; } - async getSite(siteId: string): Promise { + async getSite(siteId: string, principal: Principal): Promise { + await assertSitesPrincipal(principal); const config = await this.getConfig(); this.assertEnabled(config); - - const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as unknown as - | Site - | undefined; - if (!site) { - throw new SitesServiceError('Site not found.', 404); - } - - return this.serialize(site, config); + const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as Site | undefined; + if (!site) throw new SitesServiceError('Site not found.', 404); + // Internal tool: 403 here (not 404) so a denied teammate knows to ask the owner. + if (site.visibility !== 'public' && !isSiteOwner(site, principal)) + throw new SitesServiceError('You do not have access to this Site.', 403); + return this.serialize(site, config, principal); } - private async getActiveSite(siteId: string): Promise<{ site: Site; config: ResolvedSitesConfig }> { + private async getActiveSite(siteId: string, trx?: Transaction): Promise<{ site: Site; config: ResolvedSitesConfig }> { const config = await this.getConfig(); this.assertEnabled(config); - - const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as unknown as - | Site - | undefined; - if (!site || site.status !== 'active' || !site.activeVersionId) { - throw new SitesServiceError('Site not found.', 404); - } - - if (config.ttl.enabled && site.expiresAt && new Date(site.expiresAt).getTime() <= Date.now()) { + const query = this.db.models.Site.query(trx).findOne({ siteId }).whereNull('deletedAt'); + if (trx) query.forUpdate(); + const site = (await query) as Site | undefined; + if ( + !site || + site.status !== 'active' || + !site.activeVersionId || + (site.expiresAt && new Date(site.expiresAt).getTime() <= Date.now()) + ) { throw new SitesServiceError('Site not found.', 404); } - return { site, config }; } async replaceSiteContent(siteId: string, input: CreateOrReplaceSiteInput): Promise { + await assertSitesPrincipal(input.principal, 'write'); const { site, config } = await this.getActiveSite(siteId); + assertSiteOwner(site, input.principal); + this.checkRevision(site, input.expectedAccessRevision, input.expectedContentRevision); + const expectedAccess = input.expectedAccessRevision ?? site.accessRevision; + const expectedContent = input.expectedContentRevision ?? site.contentRevision; const upload = this.validateUpload(input, config); - let previousVersions: SiteVersion[] = []; - const updated = await this.withUploadRollback(config, (uploadedStoragePrefixes) => - this.db.models.Site.transact(async (trx) => { + const updated = await this.withUploadRollback(config, async (prefixes) => { + const version = await this.stageVersion(siteId, upload, config, prefixes); + await assertSitesPrincipal(input.principal, 'write'); + return this.db.models.Site.transact(async (trx) => { + const { site: current } = await this.getActiveSite(siteId, trx); + await assertSitesPrincipal(input.principal, 'write', trx); + assertSiteOwner(current, input.principal); + // Even old clients cannot overwrite a concurrent mutation after a long upload. + this.checkRevision(current, expectedAccess, expectedContent); previousVersions = (await this.db.models.SiteVersion.query(trx) .where({ siteId }) - .whereNull('deletedAt')) as unknown as SiteVersion[]; - const version = await this.createVersion(siteId, upload, config, uploadedStoragePrefixes, trx); - const patched = (await site.$query(trx).patchAndFetch({ + .whereNull('deletedAt')) as SiteVersion[]; + await this.db.models.SiteVersion.query(trx).insert(version); + await this.audit(trx, 'content_replaced', siteId, input.principal, { + accessRevision: current.accessRevision + 1, + contentRevision: current.contentRevision + 1, + }); + return current.$query(trx).patchAndFetch({ activeVersionId: version.versionId, fileCount: upload.fileCount, sizeBytes: upload.sizeBytes, - updatedBy: input.user?.email || site.updatedBy || null, - })) as Site; - - return patched; - }) - ); - + // Publishing/deleting must conflict if the content changed after confirmation was prepared. + accessRevision: current.accessRevision + 1, + contentRevision: current.contentRevision + 1, + updatedBy: input.principal.identity?.email || null, + }) as unknown as Promise; + }); + }); await this.cleanupSupersededVersions(config, siteId, previousVersions); + return this.serialize(updated, config, input.principal); + } - return this.serialize(updated, config); + async extendSite(siteId: string, principal: Principal, expectedAccessRevision?: number): Promise { + await assertSitesPrincipal(principal, 'write'); + const result = await this.db.models.Site.transact(async (trx) => { + const { site, config } = await this.getActiveSite(siteId, trx); + await assertSitesPrincipal(principal, 'write', trx); + assertSiteOwner(site, principal); + this.checkRevision(site, expectedAccessRevision); + if (!config.ttl.enabled) throw new SitesServiceError('TTL is disabled for hosted sites.', 400); + const base = site.expiresAt ? Math.max(new Date(site.expiresAt).getTime(), Date.now()) : Date.now(); + await this.audit(trx, 'extended', siteId, principal, { accessRevision: site.accessRevision + 1 }); + const updated = await site.$query(trx).patchAndFetch({ + expiresAt: new Date(base + config.ttl.extensionDays * DAY_MS).toISOString(), + accessRevision: site.accessRevision + 1, + }); + return this.serialize(updated as Site, config, principal); + }); + return result; } - async extendSite(siteId: string): Promise { - const { site, config } = await this.getActiveSite(siteId); - if (!config.ttl.enabled) { - throw new SitesServiceError('TTL is disabled for hosted sites.', 400); + async setVisibility( + siteId: string, + visibility: 'private' | 'public', + principal: Principal, + expectedAccessRevision: number + ): Promise { + await assertSitesPrincipal(principal, 'write'); + if ( + !['private', 'public'].includes(visibility) || + !Number.isSafeInteger(expectedAccessRevision) || + expectedAccessRevision < 1 + ) { + throw new SitesServiceError('Visibility and expectedAccessRevision are required.', 400); } - - const base = site.expiresAt ? Math.max(new Date(site.expiresAt).getTime(), Date.now()) : Date.now(); - const expiresAt = new Date(base + config.ttl.extensionDays * DAY_MS).toISOString(); - const updated = (await site.$query().patchAndFetch({ expiresAt })) as Site; - return this.serialize(updated, config); + if (visibility === 'private') this.assertPrivateReady(await this.getConfig()); + return this.db.models.Site.transact(async (trx) => { + const { site, config } = await this.getActiveSite(siteId, trx); + await assertSitesPrincipal(principal, 'write', trx); + assertSiteOwner(site, principal); + if (site.ownerKind !== 'user') throw new SitesServiceError('Only human-owned sites can change visibility.', 403); + this.checkRevision(site, expectedAccessRevision); + if (site.visibility === visibility) return this.serialize(site, config, principal); + await this.audit(trx, 'visibility_changed', siteId, principal, { + from: site.visibility, + to: visibility, + accessRevision: site.accessRevision + 1, + }); + const updated = await site.$query(trx).patchAndFetch({ + visibility, + accessRevision: site.accessRevision + 1, + }); + return this.serialize(updated as Site, config, principal); + }); } - async deleteSite(siteId: string): Promise { + async deleteSite(siteId: string, principal: Principal, expectedAccessRevision?: number): Promise { + await assertSitesPrincipal(principal, 'write'); const config = await this.getConfig(); this.assertEnabled(config); - - const site = (await this.db.models.Site.query().findOne({ siteId }).whereNull('deletedAt')) as unknown as - | Site - | undefined; - if (!site) { - throw new SitesServiceError('Site not found.', 404); - } - - const versions = (await this.db.models.SiteVersion.query().where({ siteId })) as unknown as SiteVersion[]; - await Promise.all(versions.map((version) => new SitesStorage(config).deletePrefix(version.storagePrefix))); - - const deleted = (await this.db.models.Site.transact(async (trx) => { - const timestamp = new Date().toISOString(); - const patched = (await site.$query(trx).patchAndFetch({ + const deleted = await this.db.models.Site.transact(async (trx) => { + const site = (await this.db.models.Site.query(trx).findOne({ siteId }).whereNull('deletedAt').forUpdate()) as + | Site + | undefined; + if (!site) throw new SitesServiceError('Site not found.', 404); + await assertSitesPrincipal(principal, 'write', trx); + assertSiteOwner(site, principal); + this.checkRevision(site, expectedAccessRevision); + await this.audit(trx, 'deleted', siteId, principal, { accessRevision: site.accessRevision + 1 }); + return site.$query(trx).patchAndFetch({ status: 'deleted', - deletedAt: timestamp, - })) as Site; - await this.db.models.SiteVersion.query(trx) - .where({ siteId }) - .whereNull('deletedAt') - .patch({ deletedAt: timestamp }); - return patched; - })) as Site; + deletedAt: new Date().toISOString(), + accessRevision: site.accessRevision + 1, + }) as unknown as Promise; + }); + // Authorization is removed before storage. Failed cleanup is retried by the job. + const versions = (await this.db.models.SiteVersion.query() + .where({ siteId }) + .whereNull('deletedAt')) as SiteVersion[]; + await this.cleanupSupersededVersions(config, siteId, versions); + return this.serialize(deleted, config, principal); + } - return this.serialize(deleted, config); + /** Anonymous stable-link resolver exposes only a currently public content URL. */ + async resolvePublicSiteUrl(siteId: string): Promise { + try { + const { site, config } = await this.getActiveSite(siteId); + return site.visibility === 'public' ? buildSiteUrl(siteId, config) : null; + } catch (error) { + if (error instanceof SitesServiceError && error.statusCode === 404) return null; + throw error; + } } - async getGatewayObject(hostHeader: string | undefined, pathname: string): Promise { + async getGatewayLocator(hostHeader: string | undefined): Promise> { const config = await this.getConfig(); - this.assertEnabled(config); + const siteId = parseSiteIdFromHost(hostHeader, config); + if (!config.enabled || !siteId) throw new SitesServiceError('Site not found.', 404); + const expected = new URL(buildSiteUrl(siteId, config)); + const requested = new URL(`${expected.protocol}//${hostHeader}`); + if (requested.host.toLowerCase() !== expected.host.toLowerCase()) + throw new SitesServiceError('Site not found.', 404); + this.assertPrivateReady(config); + return { siteId }; + } + async getGatewaySite(hostHeader: string | undefined): Promise<{ site: Site; config: ResolvedSitesConfig }> { + const config = await this.getConfig(); const siteId = parseSiteIdFromHost(hostHeader, config); - if (!siteId) { + if (!siteId) throw new SitesServiceError('Site not found.', 404); + const result = await this.getActiveSite(siteId); + const expectedHost = new URL(buildSiteUrl(siteId, result.config)).host.toLowerCase(); + let requestedHost: string; + try { + requestedHost = new URL( + `${new URL(buildSiteUrl(siteId, result.config)).protocol}//${hostHeader}` + ).host.toLowerCase(); + } catch { throw new SitesServiceError('Site not found.', 404); } + if (requestedHost !== expectedHost) throw new SitesServiceError('Site not found.', 404); + if (result.site.visibility === 'private') this.assertPrivateReady(result.config); + return result; + } - const { site } = await this.getActiveSite(siteId); + async getGatewayObject( + hostHeader: string | undefined, + pathname: string, + authorize?: (site: Site) => Promise + ): Promise { + const { site, config } = await this.getGatewaySite(hostHeader); + const siteId = site.siteId; + if (site.visibility === 'private') { + if (!authorize) throw new SitesServiceError('Site not found.', 404); + await authorize(site); + } const version = (await this.db.models.SiteVersion.query().findOne({ siteId, versionId: site.activeVersionId, @@ -450,51 +670,43 @@ export default class SitesService extends Service { async matchesGatewayHost(hostHeader: string | undefined): Promise { const config = await this.getConfig(); - if (!config.enabled) { - return false; - } - return Boolean(parseSiteIdFromHost(hostHeader, config)); + const host = hostHeader?.split(':')[0].toLowerCase(); + return Boolean(host && host.endsWith(`.${config.domain.toLowerCase()}`)); } async cleanupExpiredSites(): Promise<{ expired: number; cleaned: number; errors: number }> { const config = await this.getConfig(); - if (!config.enabled || !config.ttl.enabled || !config.cleanup.enabled) { - return { expired: 0, cleaned: 0, errors: 0 }; - } - - const expiredSites = (await this.db.models.Site.query() + if (!config.enabled || !config.cleanup.enabled) return { expired: 0, cleaned: 0, errors: 0 }; + const now = new Date().toISOString(); + // Atomic predicate cannot expire an extension that won the row lock first. + const expired = await this.db.models.Site.query() .whereNull('deletedAt') .where('status', 'active') .whereNotNull('expiresAt') - .where('expiresAt', '<=', new Date().toISOString()) - .limit(100)) as unknown as Site[]; - + .where('expiresAt', '<=', now) + .patch({ status: 'expired', deletedAt: now }); + const pending = (await this.db.models.SiteVersion.query() + .whereNull('site_versions.deletedAt') + .join('sites', 'sites.siteId', 'site_versions.siteId') + .whereRaw('(sites.status IN (?, ?) OR sites."activeVersionId" IS DISTINCT FROM site_versions."versionId")', [ + 'deleted', + 'expired', + ]) + .select('site_versions.*') + .limit(100)) as SiteVersion[]; let cleaned = 0; let errors = 0; - - for (const site of expiredSites) { + for (const version of pending) { try { - const versions = (await this.db.models.SiteVersion.query().where({ - siteId: site.siteId, - })) as unknown as SiteVersion[]; - await Promise.all(versions.map((version) => new SitesStorage(config).deletePrefix(version.storagePrefix))); - - const timestamp = new Date().toISOString(); - await this.db.models.Site.transact(async (trx) => { - await site.$query(trx).patch({ status: 'expired', deletedAt: timestamp }); - await this.db.models.SiteVersion.query(trx) - .where({ siteId: site.siteId }) - .whereNull('deletedAt') - .patch({ deletedAt: timestamp }); - }); + await new SitesStorage(config).deletePrefix(version.storagePrefix); + await version.$query().patch({ deletedAt: now }); cleaned++; } catch (error) { errors++; - getLogger().error({ error, siteId: site.siteId }, 'Sites: cleanup failed'); + getLogger().warn({ error, siteId: version.siteId }, 'Sites: terminal storage cleanup deferred'); } } - - return { expired: expiredSites.length, cleaned, errors }; + return { expired, cleaned, errors }; } processSitesCleanupQueue = async (_job: Job) => { @@ -507,7 +719,7 @@ export default class SitesService extends Service { async setupSitesCleanupJob() { const config = await this.getConfig(); - if (!config.enabled || !config.ttl.enabled || !config.cleanup.enabled) { + if (!config.enabled || !config.cleanup.enabled) { getLogger().debug('Sites: cleanup disabled'); return; } diff --git a/src/shared/__tests__/openApiSecurity.test.ts b/src/shared/__tests__/openApiSecurity.test.ts index f4add521..b4197400 100644 --- a/src/shared/__tests__/openApiSecurity.test.ts +++ b/src/shared/__tests__/openApiSecurity.test.ts @@ -61,10 +61,12 @@ function isBearerAuthOrLifecycleKey(security: unknown): boolean { ); } -function hasAndCombinedSchemes(security: unknown): boolean { - return requirementObjects(security).some( - (req) => ('KeycloakBearer' in req || 'BearerAuth' in req) && 'LifecycleApiKey' in req - ); +function principalSecurity(entry: Extract) { + const kinds = entry.kinds ?? ['user', 'personal_key', 'service_key']; + return [ + ...(kinds.includes('user') ? [{ BearerAuth: [] }] : []), + ...(kinds.some((kind) => kind === 'personal_key' || kind === 'service_key') ? [{ LifecycleApiKey: [] }] : []), + ]; } describe('OpenAPI v2 security contract', () => { @@ -77,7 +79,6 @@ describe('OpenAPI v2 security contract', () => { expect(schemes.LifecycleApiKey.description).toMatch(/lfc_pat_/); expect(schemes.LifecycleApiKey.description).toMatch(/lfc_svc_/); }); - it('defaults globally to the Keycloak session only', () => { expect(spec.security).toEqual([{ BearerAuth: [] }]); }); @@ -88,10 +89,9 @@ describe('OpenAPI v2 security contract', () => { expect(spec.components.schemas.ApiTokenGrantableScope.enum).not.toContain('env:admin'); }); - it('offers both schemes (OR, never AND) on every principal operation', () => { + it('matches allowed principal kinds and requires handler credentials on every principal operation', () => { const missing: string[] = []; - const notOr: string[] = []; - const andCombined: string[] = []; + const mismatched: string[] = []; for (const entry of V2_ROUTE_POLICY_MANIFEST) { if (entry.policy !== 'principal') continue; @@ -100,12 +100,17 @@ describe('OpenAPI v2 security contract', () => { missing.push(keyOf(entry)); continue; } - if (hasAndCombinedSchemes(op.security)) andCombined.push(keyOf(entry)); - if (!isBearerAuthOrLifecycleKey(op.security)) notOr.push(`${keyOf(entry)} -> ${JSON.stringify(op.security)}`); + // The array contains credential alternatives (OR); each object contains mandatory credentials (AND). + // Compare complete objects, including scopes, so an extra anonymous or signature-only alternative fails. + try { + expect(op.security).toEqual(principalSecurity(entry)); + } catch { + mismatched.push(`${keyOf(entry)} -> ${JSON.stringify(op.security)}`); + } } // Every principal route must be documented: a future one that isn't fails here by name. - expect({ missing, notOr, andCombined }).toEqual({ missing: [], notOr: [], andCombined: [] }); + expect({ missing, mismatched }).toEqual({ missing: [], mismatched: [] }); }); it('never offers LifecycleApiKey on a session operation', () => { @@ -119,10 +124,33 @@ describe('OpenAPI v2 security contract', () => { expect(leaked).toEqual([]); }); - it('exposes the OAuth callback with no platform security', () => { - const entry = V2_ROUTE_POLICY_MANIFEST.find((e) => e.policy === 'public'); - expect(entry).toBeDefined(); - expect(operationFor(entry as V2RoutePolicyEntry).security).toEqual([]); + it('covers every public operation, including anonymous public-link resolution', () => { + for (const entry of V2_ROUTE_POLICY_MANIFEST) { + if (entry.policy !== 'public') continue; + expect({ operation: keyOf(entry), security: operationFor(entry)?.security }).toEqual({ + operation: keyOf(entry), + security: [], + }); + } + }); + + it('documents only JWT-authenticated mint and its minimal body', () => { + const op = spec.paths['/api/v2/sites/browser/mint'].post; + expect(op.security).toEqual([{ BearerAuth: [] }]); + expect(op.requestBody.content['application/json'].schema).toEqual({ + $ref: '#/components/schemas/SitesBrowserMintRequest', + }); + expect(spec.components.schemas.SitesBrowserMintRequest.required).toEqual(['siteId', 'state']); + expect(spec.components.schemas.SitesBrowserMintRequest.additionalProperties).toBe(false); + expect( + Object.keys(op.responses['200'].content['application/json'].schema.properties.data.properties).sort() + ).toEqual(['consumeUrl', 'ticket']); + expect(spec.paths['/api/v2/sites/browser/open/{siteId}'].get.security).toEqual([]); + for (const action of ['bind', 'revoke']) { + expect(spec.paths[`/api/v2/sites/browser/${action}`]).toBeUndefined(); + } + expect(spec.components.schemas.SitesBrowserLoginRequest).toBeUndefined(); + expect(spec.components.schemas.SitesBrowserBindRequest).toBeUndefined(); }); it('documents auth/context with the OR form under operationId getAuthContext', () => { diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index 05d2b78c..f923acc6 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -194,16 +194,8 @@ describe('OpenAPI v2 environment contract', () => { describe('OpenAPI v2 sites contract', () => { it('documents sites list filters and pagination', () => { expect(getOperation('/api/v2/sites', 'get')?.parameters).toEqual([ - { - name: 'user', - in: 'query', - required: false, - description: 'Filters to sites created or last updated by the supplied user email.', - schema: { - type: 'string', - }, - example: 'user@example.com', - }, + { name: 'view', in: 'query', schema: { type: 'string', enum: ['mine', 'public', 'all'] } }, + { name: 'q', in: 'query', schema: { type: 'string', maxLength: 200 } }, { name: 'page', in: 'query', diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index db16337c..24e648f1 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -149,8 +149,8 @@ export const openApiSpecificationForV2Api: OAS3Options = { description: 'JWT token issued by a Keycloak identity provider. ' + 'Pass it in the Authorization header as "Bearer ". ' + - 'Authentication is only enforced when the ENABLE_AUTH environment variable is set to "true". ' + - 'When disabled, all requests are allowed without a token.', + 'Operation-specific authorization still applies; Sites browser bridge operations require ' + + 'their documented credentials even when general authentication is disabled.', }, KeycloakBearer: { type: 'http', @@ -169,6 +169,23 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, }, schemas: { + SitesBrowserMintRequest: { + type: 'object', + additionalProperties: false, + required: ['siteId', 'state'], + properties: { + siteId: { + type: 'string', + pattern: '^[a-z0-9-]{1,64}$', + description: 'Site identifier matching the bootstrap challenge.', + }, + state: { + type: 'string', + pattern: '^[A-Za-z0-9_-]{43}$', + description: 'Opaque, unexpired bootstrap challenge issued by the content gateway.', + }, + }, + }, // =================================================================== // Core Reusable Schemas // =================================================================== @@ -236,6 +253,27 @@ export const openApiSpecificationForV2Api: OAS3Options = { id: { type: 'string', example: 'abc123def4' }, name: { type: 'string', example: 'sample-site' }, url: { type: 'string', format: 'uri', example: 'http://site-abc123def4.localhost:5002' }, + visibility: { type: 'string', enum: ['private', 'public'] }, + contentUrl: { type: 'string', format: 'uri' }, + openUrl: { type: 'string', format: 'uri' }, + accessRevision: { + type: 'integer', + minimum: 1, + description: + 'Site mutation revision; advances on content replacement, visibility, expiry extension and deletion.', + }, + contentRevision: { type: 'integer', minimum: 1 }, + currentRole: { type: 'string', enum: ['owner'], nullable: true }, + permissions: { + type: 'object', + properties: { + canView: { type: 'boolean' }, + canEdit: { type: 'boolean' }, + canDelete: { type: 'boolean' }, + canChangeVisibility: { type: 'boolean' }, + }, + required: ['canView', 'canEdit', 'canDelete', 'canChangeVisibility'], + }, status: { type: 'string', enum: ['active', 'deleted', 'expired'] }, createdAt: { type: 'string', format: 'date-time', nullable: true }, updatedAt: { type: 'string', format: 'date-time', nullable: true }, @@ -254,6 +292,13 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, }, required: [ + 'visibility', + 'contentUrl', + 'openUrl', + 'accessRevision', + 'contentRevision', + 'currentRole', + 'permissions', 'id', 'name', 'url', @@ -271,6 +316,14 @@ export const openApiSpecificationForV2Api: OAS3Options = { SiteUploadRequest: { type: 'object', properties: { + visibility: { + type: 'string', + enum: ['private', 'public'], + description: + 'Creation only; omitted defaults private for users. Service-key creation always remains public.', + }, + expectedAccessRevision: { type: 'integer', minimum: 1, maximum: 2147483647 }, + expectedContentRevision: { type: 'integer', minimum: 1, maximum: 2147483647 }, file: { type: 'string', format: 'binary', @@ -1003,6 +1056,37 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + SitesCapabilities: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + canCreate: { type: 'boolean' }, + defaultVisibility: { type: 'string', enum: ['private', 'public'] }, + allowedVisibilities: { type: 'array', items: { type: 'string', enum: ['private', 'public'] } }, + upload: { + type: 'object', + properties: { + maxUploadBytes: { type: 'integer', minimum: 1 }, + maxExtractedBytes: { type: 'integer', minimum: 1 }, + maxFiles: { type: 'integer', minimum: 1 }, + allowedExtensions: { type: 'array', items: { type: 'string' } }, + }, + required: ['maxUploadBytes', 'maxExtractedBytes', 'maxFiles', 'allowedExtensions'], + }, + }, + required: ['enabled', 'canCreate', 'defaultVisibility', 'allowedVisibilities', 'upload'], + }, + SitesCapabilitiesSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { data: { $ref: '#/components/schemas/SitesCapabilities' } }, + required: ['data'], + }, + ], + }, + SiteSuccessResponse: { allOf: [ { $ref: '#/components/schemas/SuccessApiResponse' }, diff --git a/ws-server.test.ts b/ws-server.test.ts index 9561e45f..c1d30602 100644 --- a/ws-server.test.ts +++ b/ws-server.test.ts @@ -25,7 +25,7 @@ const mockStreamK8sLogs = jest.fn(); const mockParseChatPreviewHost = jest.fn(); const mockResolveChatPreviewSessionForHost = jest.fn(); const mockMatchesGatewayHost = jest.fn(); -const mockGetGatewayObject = jest.fn(); +const mockHandleSitesRequest = jest.fn(); const mockAgentSessionGetSession = jest.fn(); const mockVerifyBearerToken = jest.fn(); const mockAgentSessionFindOne = jest.fn(); @@ -128,10 +128,10 @@ jest.mock('./src/server/lib/k8sStreamer', () => ({ streamK8sLogs: mockStreamK8sL jest.mock('./src/server/services/sites', () => ({ __esModule: true, default: jest.fn(() => ({ - getGatewayObject: mockGetGatewayObject, matchesGatewayHost: mockMatchesGatewayHost, })), })); +jest.mock('./src/server/lib/sites/gateway', () => ({ handleSitesRequest: mockHandleSitesRequest })); jest.mock('./src/server/lib/agentSession/workspaceEditorProxy', () => ({ EDITOR_PROXY_PING_INTERVAL_MS: 10_000, EDITOR_PROXY_PONG_DEADLINE_MS: 5_000, @@ -390,7 +390,7 @@ describe('ws-server public dispatch', () => { mockParseChatPreviewHost.mockReturnValue(null); mockResolveChatPreviewSessionForHost.mockResolvedValue(null); mockMatchesGatewayHost.mockResolvedValue(false); - mockGetGatewayObject.mockReset(); + mockHandleSitesRequest.mockResolvedValue(undefined); mockHttpRequest.mockReset(); mockHttpsRequest.mockReset(); mockLifecycleMode = 'web'; @@ -528,76 +528,15 @@ describe('ws-server public dispatch', () => { expect(mockNextHandler).not.toHaveBeenCalled(); }); - it('serves a gateway HEAD request without forwarding its body or falling through', async () => { + it.each(['HEAD', 'GET'])('delegates gateway %s requests without falling through', async (method) => { mockLifecycleMode = 'gateway'; mockMatchesGatewayHost.mockResolvedValue(true); - const body = { destroy: jest.fn(), on: jest.fn(), pipe: jest.fn() }; - mockGetGatewayObject.mockResolvedValue({ - body, - contentLength: 42, - contentType: 'text/html; charset=utf-8', - statusCode: 200, - }); const handler = await bootServer(); - const req = { ...request('/docs/index.html'), method: 'HEAD' }; + const req = { ...request('/docs/index.html'), method }; const res = response(); - await handler(req, res); - expect(mockMatchesGatewayHost).toHaveBeenCalledWith('lifecycle.test'); - expect(mockGetGatewayObject).toHaveBeenCalledWith('lifecycle.test', '/docs/index.html'); - expect(res.statusCode).toBe(200); - expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); - expect(res.setHeader).toHaveBeenCalledWith('Content-Length', '42'); - expect(body.destroy).toHaveBeenCalledTimes(1); - expect(body.pipe).not.toHaveBeenCalled(); - expect(res.end).toHaveBeenCalledWith(); - expect(mockMcpHttpRequestHandler).not.toHaveBeenCalled(); - expect(mockNextHandler).not.toHaveBeenCalled(); - }); - - it('pipes a gateway GET response and converts a body-stream failure into 502', async () => { - mockLifecycleMode = 'gateway'; - mockMatchesGatewayHost.mockResolvedValue(true); - const bodyListeners: Record = {}; - const body = { - on: jest.fn((event: string, listener: Listener) => { - bodyListeners[event] = listener; - }), - pipe: jest.fn(), - }; - mockGetGatewayObject.mockResolvedValue({ - body, - contentType: 'application/javascript', - statusCode: 200, - }); - const handler = await bootServer(); - const res = response(); - - await handler(request('/assets/app.js'), res); - - expect(body.pipe).toHaveBeenCalledWith(res); - expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'private, max-age=60'); - expect(mockMcpHttpRequestHandler).not.toHaveBeenCalled(); - bodyListeners.error(new Error('object store disconnected')); - expect(res.statusCode).toBe(502); - expect(res.end).toHaveBeenCalledWith(); - }); - - it.each([ - ['not-found storage failures', Object.assign(new Error('missing'), { statusCode: 404 }), 404, 'not found'], - ['unexpected storage failures', new Error('object store unavailable'), 500, 'internal server error'], - ])('maps %s without falling through to another handler', async (_label, error, status, body) => { - mockLifecycleMode = 'gateway'; - mockMatchesGatewayHost.mockResolvedValue(true); - mockGetGatewayObject.mockRejectedValue(error); - const handler = await bootServer(); - const res = response(); - - await handler(request('/missing.html'), res); - - expect(res.statusCode).toBe(status); - expect(res.end).toHaveBeenCalledWith(body); + expect(mockHandleSitesRequest).toHaveBeenCalledWith(req, res, expect.any(Object)); expect(mockMcpHttpRequestHandler).not.toHaveBeenCalled(); expect(mockNextHandler).not.toHaveBeenCalled(); }); diff --git a/ws-server.ts b/ws-server.ts index eb5a5cd1..7520c0e1 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +// Initialize Next's Node globals before server helpers import next/server or next/headers. +import 'next/dist/server/node-environment'; import 'module-alias/register'; import { join } from 'path'; import moduleAlias from 'module-alias'; @@ -40,6 +42,7 @@ import { handleMcpHttpRequest as mcpHttpRequestHandler } from './src/server/mcp/ import { createLifecycleMcpRegistry } from './src/server/mcp/tools'; import { streamK8sLogs, AbortHandle } from './src/server/lib/k8sStreamer'; import SitesService from './src/server/services/sites'; +import { handleSitesRequest } from './src/server/lib/sites/gateway'; import { serializeSocketHttpResponse, EDITOR_PROXY_TIMEOUT_MS, @@ -1230,7 +1233,7 @@ async function handleChatPreviewHttp( } } -async function handleSitesGatewayHttp(req: IncomingMessage, res: ServerResponse, pathname: string) { +async function handleSitesGatewayHttp(req: IncomingMessage, res: ServerResponse) { if (LIFECYCLE_MODE !== 'gateway' && LIFECYCLE_MODE !== 'all') { return false; } @@ -1240,45 +1243,8 @@ async function handleSitesGatewayHttp(req: IncomingMessage, res: ServerResponse, return false; } - if (!req.method || !['GET', 'HEAD'].includes(req.method.toUpperCase())) { - res.statusCode = 404; - res.end('not found'); - return true; - } - - try { - const object = await service.getGatewayObject(req.headers.host, pathname); - - res.statusCode = object.statusCode; - res.setHeader('Content-Type', object.contentType); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Cache-Control', 'private, max-age=60'); - if (object.contentLength !== undefined) { - res.setHeader('Content-Length', object.contentLength.toString()); - } - - if (req.method.toUpperCase() === 'HEAD') { - (object.body as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.(); - res.end(); - return true; - } - - object.body.on('error', (error) => { - logger.error({ error, path: pathname }, 'SitesGateway: stream failed'); - if (!res.headersSent) { - res.statusCode = 502; - } - res.end(); - }); - object.body.pipe(res); - return true; - } catch (error: any) { - const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : 500; - logger.warn({ error, path: pathname, statusCode }, 'SitesGateway: request failed'); - res.statusCode = statusCode === 404 ? 404 : 500; - res.end(statusCode === 404 ? 'not found' : 'internal server error'); - return true; - } + await handleSitesRequest(req, res, service); + return true; } app.prepare().then(() => { @@ -1306,7 +1272,7 @@ app.prepare().then(() => { res.end('Preview is unavailable'); return; } - if (parsedUrl.pathname && (await handleSitesGatewayHttp(req, res, parsedUrl.pathname))) { + if (parsedUrl.pathname && (await handleSitesGatewayHttp(req, res))) { return; } if (handleMcpHttpRequest && (await handleMcpHttpRequest(req, res, parsedUrl.pathname))) {