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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/app/api/v2/sites/[siteId]/restore/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Copyright 2026 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

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, sitesErrorResponse } from 'server/lib/sites/routeHelpers';
import SitesService from 'server/services/sites';

type RouteContext = {
params: Promise<{
siteId: string;
}>;
};

/**
* @openapi
* /api/v2/sites/{siteId}/restore:
* post:
* summary: Restore a deleted hosted static site
* security:
* - BearerAuth: []
* - LifecycleApiKey: []
* tags:
* - Sites
* operationId: restoreSite
* parameters:
* - in: path
* name: siteId
* required: true
* schema:
* type: string
* - in: query
* name: expectedAccessRevision
* required: false
* schema: { type: integer, minimum: 1, maximum: 2147483647 }
* responses:
* '200':
* description: Hosted static site restored at the same id and URL.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SiteSuccessResponse'
* '403':
* description: Only the site owner can restore it.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '409':
* description: Access revision changed; refetch before retrying.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '404':
* description: Site not found, past its restore window, or sites hosting is disabled.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
*/
const postHandler = async (req: NextRequest, principal: Principal, { params }: RouteContext) => {
const routeParams = await params;
try {
const service = new SitesService();
const site = await service.restoreSite(
routeParams.siteId,
principal,
readSiteRevision(req.nextUrl.searchParams.get('expectedAccessRevision'))
);
return successResponse({ site }, { status: 200 }, req);
} catch (error) {
return sitesErrorResponse(error, req);
}
};

export const POST = createPrincipalApiHandler({ scope: 'sites:write' }, postHandler);
37 changes: 37 additions & 0 deletions src/app/api/v2/sites/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const mockGetSite = jest.fn();
const mockDeleteSite = jest.fn();
const mockReplaceSiteContent = jest.fn();
const mockExtendSite = jest.fn();
const mockRestoreSite = jest.fn();
const mockGetCapabilities = jest.fn();
const mockSetVisibility = jest.fn();
const mockLogger = { error: jest.fn(), info: jest.fn() };
Expand Down Expand Up @@ -53,6 +54,7 @@ jest.mock('server/services/sites', () => {
deleteSite: (...args: unknown[]) => mockDeleteSite(...args),
replaceSiteContent: (...args: unknown[]) => mockReplaceSiteContent(...args),
extendSite: (...args: unknown[]) => mockExtendSite(...args),
restoreSite: (...args: unknown[]) => mockRestoreSite(...args),
})),
};
});
Expand All @@ -62,6 +64,7 @@ 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 { POST as restoreSite } from './[siteId]/restore/route';
import { GET as capabilities } from './capabilities/route';
import { PATCH as visibility } from './[siteId]/access/route';

Expand Down Expand Up @@ -181,6 +184,7 @@ describe('hosted sites API routes', () => {
['delete', deleteSite, 'sites:write', context()],
['replace content', replaceContent, 'sites:write', context()],
['extend', extendSite, 'sites:write', context()],
['restore', restoreSite, 'sites:write', context()],
])('denies insufficient scope before the %s operation reaches the service', async (_label, handler, scope, ctx) => {
mockResolvePrincipal.mockResolvedValue(serviceKeyPrincipal);

Expand All @@ -200,6 +204,7 @@ describe('hosted sites API routes', () => {
expect(mockDeleteSite).not.toHaveBeenCalled();
expect(mockReplaceSiteContent).not.toHaveBeenCalled();
expect(mockExtendSite).not.toHaveBeenCalled();
expect(mockRestoreSite).not.toHaveBeenCalled();
});

it('lists filtered sites with pagination metadata', async () => {
Expand All @@ -214,6 +219,13 @@ describe('hosted sites API routes', () => {
expect(body.metadata).toEqual({ pagination: { page: 2, limit: 10, total: 21, totalPages: 3 } });
});

it('forwards the deleted view', async () => {
const response = await listSites(request('http://localhost/api/v2/sites?view=deleted'));

expect(response.status).toBe(200);
expect(mockListSites).toHaveBeenCalledWith({ view: 'deleted' }, sessionPrincipal);
});

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='));

Expand Down Expand Up @@ -353,6 +365,31 @@ describe('hosted sites API routes', () => {
expect(mockExtendSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, undefined);
});

it('restores a deleted site with the access revision from the query string', async () => {
mockRestoreSite.mockResolvedValue({ ...site, status: 'active', accessRevision: 3 });

const response = await restoreSite(
request('http://localhost/api/v2/sites/docs-abc123/restore?expectedAccessRevision=2', { method: 'POST' }),
context()
);

expect(response.status).toBe(200);
expect((await response.json()).data.site).toEqual(expect.objectContaining({ id: site.id, status: 'active' }));
expect(mockRestoreSite).toHaveBeenCalledWith('docs-abc123', sessionPrincipal, 2);
});

it('maps a restore of a site past its retention window to not found', async () => {
mockRestoreSite.mockRejectedValue(new SitesServiceError('Site not found or no longer restorable.', 404));

const response = await restoreSite(
request('http://localhost/api/v2/sites/docs-abc123/restore', { method: 'POST' }),
context()
);

expect(response.status).toBe(404);
expect((await response.json()).error.message).toBe('Site not found or no longer restorable.');
});

it('maps an extension failure when TTL is disabled', async () => {
mockExtendSite.mockRejectedValue(new SitesServiceError('TTL is disabled for hosted sites.', 400));

Expand Down
3 changes: 2 additions & 1 deletion src/app/api/v2/sites/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export const runtime = 'nodejs';
* parameters:
* - name: view
* in: query
* schema: { type: string, enum: [mine, public, all] }
* description: deleted lists the caller's deleted sites that can still be restored.
* schema: { type: string, enum: [mine, public, all, deleted] }
* - name: q
* in: query
* schema: { type: string, maxLength: 200 }
Expand Down
2 changes: 2 additions & 0 deletions src/server/lib/sites/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export type ResolvedSitesConfig = {
cleanup: {
enabled: boolean;
intervalMinutes: number;
deletedRetentionDays: number;
};
};

Expand Down Expand Up @@ -176,6 +177,7 @@ export function resolveSitesConfig(config?: SitesConfig | null): ResolvedSitesCo
cleanup: {
enabled: config?.cleanup?.enabled ?? true,
intervalMinutes: config?.cleanup?.intervalMinutes ?? 15,
deletedRetentionDays: config?.cleanup?.deletedRetentionDays ?? 30,
},
};
}
Expand Down
5 changes: 3 additions & 2 deletions src/server/lib/sites/routeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,15 @@ export async function readUploadFile(

export function readSitesListFilters(searchParams: URLSearchParams): ListSitesFilters {
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);
if (view && !['mine', 'public', 'all', 'deleted'].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 {
...(view ? { view: view as 'mine' | 'public' | 'all' } : {}),
...(view ? { view: view as 'mine' | 'public' | 'all' | 'deleted' } : {}),
...(q ? { q } : {}),
...(page === undefined ? {} : { page }),
...(limit === undefined ? {} : { limit }),
Expand Down
2 changes: 1 addition & 1 deletion src/server/lib/sites/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function config(overrides: Partial<ResolvedSitesConfig['storage']> = {}): Resolv
hostPrefix: 'site',
ttl: { enabled: true, defaultDays: 7, extensionDays: 7 },
upload: { maxUploadBytes: 1024, maxExtractedBytes: 2048, maxFiles: 10, allowedExtensions: ['html'] },
cleanup: { enabled: true, intervalMinutes: 15 },
cleanup: { enabled: true, intervalMinutes: 15, deletedRetentionDays: 30 },
storage: {
backend: 'minio',
bucket: 'sites-bucket',
Expand Down
1 change: 1 addition & 0 deletions src/server/lib/v2RoutePolicyManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [
{ method: 'DELETE', route: '/api/v2/sites/{siteId}', policy: 'principal', scope: 'sites:write' },
{ method: 'GET', route: '/api/v2/sites/{siteId}', policy: 'principal', scope: 'sites:read' },
{ method: 'PUT', route: '/api/v2/sites/{siteId}/content', policy: 'principal', scope: 'sites:write' },
{ method: 'POST', route: '/api/v2/sites/{siteId}/restore', policy: 'principal', scope: 'sites:write' },
{ method: 'POST', route: '/api/v2/sites/{siteId}/extend', policy: 'principal', scope: 'sites:write' },
{ method: 'POST', route: '/api/v2/telemetry/events', policy: 'session' },
{ method: 'GET', route: '/api/v2/telemetry/stats', policy: 'session' },
Expand Down
1 change: 1 addition & 0 deletions src/server/lib/validation/sitesConfigSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const sitesConfigSchema = {
properties: {
enabled: { type: 'boolean' },
intervalMinutes: { type: 'integer', minimum: 1 },
deletedRetentionDays: { type: 'integer', minimum: 0, maximum: 3650 },
},
},
},
Expand Down
3 changes: 3 additions & 0 deletions src/server/mcp/__tests__/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const ALL_TOOLS = [
'set_site_visibility',
'extend_site',
'delete_site',
'restore_site',
];

const CHANGE_TOOLS = [
Expand All @@ -55,6 +56,7 @@ const CHANGE_TOOLS = [
'set_site_visibility',
'extend_site',
'delete_site',
'restore_site',
];

const SITE_TOOLS = [
Expand All @@ -65,6 +67,7 @@ const SITE_TOOLS = [
'set_site_visibility',
'extend_site',
'delete_site',
'restore_site',
];

it('compiles the production catalog exactly as ws-server boots it', () => {
Expand Down
60 changes: 59 additions & 1 deletion src/server/mcp/__tests__/toolHandlers.sites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ const SITE: SiteResponse = {
accessRevision: 1,
contentRevision: 1,
currentRole: 'owner',
permissions: { canView: true, canEdit: true, canDelete: true, canChangeVisibility: true },
permissions: { canView: true, canEdit: true, canDelete: true, canChangeVisibility: true, canRestore: false },
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-02T00:00:00.000Z',
expiresAt: '2026-08-01T00:00:00.000Z',
deletedAt: null,
restorableUntil: null,
fileCount: 4,
sizeBytes: 2048,
createdBy: 'user@example.com',
Expand Down Expand Up @@ -78,6 +80,7 @@ function harness(service: Partial<SiteToolService>, policyOverrides: Partial<Mcp
extendSite: () => Promise.reject(new Error('extendSite not stubbed')),
setVisibility: () => Promise.reject(new Error('setVisibility not stubbed')),
deleteSite: () => Promise.reject(new Error('deleteSite not stubbed')),
restoreSite: () => Promise.reject(new Error('restoreSite not stubbed')),
...service,
},
nowSeconds: () => 1_000,
Expand Down Expand Up @@ -381,3 +384,58 @@ describe('delete_site', () => {
expect(deleteSite).not.toHaveBeenCalled();
});
});

describe('soft delete and restore', () => {
const DELETED: SiteResponse = {
...SITE,
status: 'deleted',
accessRevision: 2,
deletedAt: '2026-07-10T00:00:00.000Z',
restorableUntil: '2026-08-09T00:00:00.000Z',
permissions: { ...SITE.permissions, canEdit: false, canRestore: true },
};

it('reports when a deleted site stops being restorable', async () => {
const { call } = harness({ deleteSite: jest.fn().mockResolvedValue(DELETED) });
const { output } = await call('delete_site', { siteId: 'site_abc123', expectedAccessRevision: 1 });
expect(output!.site).toMatchObject({ status: 'deleted', restorableUntil: '2026-08-09T00:00:00.000Z' });
});

it('lists deleted sites with their restore deadline and permission', async () => {
const listSites = jest
.fn()
.mockResolvedValue({ sites: [DELETED], pagination: { current: 1, total: 1, items: 1, limit: 25 } });
const { call } = harness({ listSites });
const { output } = await call('list_sites', { deleted: true });
expect(listSites.mock.calls[0][0]).toMatchObject({ view: 'deleted' });
expect((output!.sites as McpJsonObject[])[0]).toMatchObject({
siteId: 'site_abc123',
restorableUntil: '2026-08-09T00:00:00.000Z',
permissions: expect.objectContaining({ canRestore: true }),
});
});

it('restores with the required access revision', async () => {
const restoreSite = jest.fn().mockResolvedValue({ ...SITE, accessRevision: 3 });
const { call } = harness({ restoreSite });
const { output } = await call('restore_site', { siteId: 'site_abc123', expectedAccessRevision: 2 });
expect(output!.site).toMatchObject({ siteId: 'site_abc123', status: 'active', accessRevision: 3 });
expect(restoreSite).toHaveBeenCalledWith('site_abc123', PRINCIPAL, 2);
});

it('requires expectedAccessRevision to restore', async () => {
const restoreSite = jest.fn();
const { call } = harness({ restoreSite });
const { error } = await call('restore_site', { siteId: 'site_abc123' });
expect(error).toMatchObject({ code: 'invalid_body' });
expect(restoreSite).not.toHaveBeenCalled();
});

it('reports a site past its restore window as not found', async () => {
const { call } = harness({
restoreSite: () => Promise.reject(new SitesServiceError('Site not found or no longer restorable.', 404)),
});
const { error } = await call('restore_site', { siteId: 'site_abc123', expectedAccessRevision: 2 });
expect(error).toMatchObject({ code: 'site_not_found' });
});
});
4 changes: 3 additions & 1 deletion src/server/mcp/tools/sites/__tests__/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ function site(overrides: Partial<SiteResponse> = {}): SiteResponse {
accessRevision: 1,
contentRevision: 1,
currentRole: null,
permissions: { canView: true, canEdit: false, canDelete: false, canChangeVisibility: false },
permissions: { canView: true, canEdit: false, canDelete: false, canChangeVisibility: false, canRestore: false },
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z',
expiresAt: null,
deletedAt: null,
restorableUntil: null,
fileCount: 3,
sizeBytes: 1024,
createdBy: null,
Expand Down
2 changes: 1 addition & 1 deletion src/server/mcp/tools/sites/deleteSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { deleteSiteInputSchema, deleteSiteOutputSchema } from './schemas';
import { mapSiteServiceError, siteChangeSummary, type ResolvedSiteToolDependencies } from './shared';

const DESCRIPTION =
'Permanently deletes a hosted site you own; the link stops working and cannot be restored. Call get_site first, confirm with the user if acting for someone, and pass its accessRevision as expectedAccessRevision.';
'Deletes a site you own; its link stops working at once. restore_site can bring it back until restorableUntil, then its content is purged. Read it with get_site first, confirm with the user if acting for someone, and pass its accessRevision as expectedAccessRevision.';

export function createDeleteSiteToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition {
return {
Expand Down
2 changes: 1 addition & 1 deletion src/server/mcp/tools/sites/extendSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { extendSiteInputSchema, extendSiteOutputSchema } from './schemas';
import { mapSiteServiceError, siteChangeSummary, type ResolvedSiteToolDependencies } from './shared';

const DESCRIPTION =
"Pushes out a hosted site's expiry by the configured extension period. Each call extends again; pass expectedAccessRevision from get_site to make a retry safe. Report the new expiresAt.";
"Pushes out a site's expiry by the configured period. Each call extends again; pass expectedAccessRevision from get_site to make retries safe.";

export function createExtendSiteToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition {
return {
Expand Down
2 changes: 2 additions & 0 deletions src/server/mcp/tools/sites/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createDeleteSiteToolDefinition } from './deleteSite';
import { createExtendSiteToolDefinition } from './extendSite';
import { createGetSiteToolDefinition } from './getSite';
import { createListSitesToolDefinition } from './listSites';
import { createRestoreSiteToolDefinition } from './restoreSite';
import { createSetSiteVisibilityToolDefinition } from './setSiteVisibility';
import { createUpdateSiteContentToolDefinition } from './updateSiteContent';
import { resolveSiteToolDependencies, type SiteToolDependencies } from './shared';
Expand All @@ -36,5 +37,6 @@ export function createSiteToolDefinitions(dependencies: SiteToolDependencies = {
createSetSiteVisibilityToolDefinition(resolved),
createExtendSiteToolDefinition(resolved),
createDeleteSiteToolDefinition(resolved),
createRestoreSiteToolDefinition(resolved),
];
}
Loading
Loading