From 6fef0be6bfd8edd449681d33eadf8676b7ed34a0 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Fri, 9 Oct 2026 10:04:03 -0700 Subject: [PATCH 1/4] feat(sites): soft delete with configurable retention and restore Deleting a site now keeps its content for sites.cleanup.deletedRetentionDays (default 30) before the cleanup job purges it. Owners can list their restorable sites (view=deleted) and restore one at the same id and URL via POST /api/v2/sites/{siteId}/restore or the restore_site MCP tool. --- .../api/v2/sites/[siteId]/restore/route.ts | 92 +++++++++ src/app/api/v2/sites/route.test.ts | 37 ++++ src/app/api/v2/sites/route.ts | 3 +- src/server/lib/sites/config.ts | 2 + src/server/lib/sites/routeHelpers.ts | 5 +- src/server/lib/sites/storage.test.ts | 2 +- src/server/lib/v2RoutePolicyManifest.ts | 1 + .../lib/validation/sitesConfigSchemas.ts | 1 + src/server/mcp/__tests__/catalog.test.ts | 3 + .../mcp/__tests__/toolHandlers.sites.test.ts | 60 +++++- .../mcp/tools/sites/__tests__/shared.test.ts | 4 +- src/server/mcp/tools/sites/deleteSite.ts | 2 +- src/server/mcp/tools/sites/extendSite.ts | 2 +- src/server/mcp/tools/sites/index.ts | 2 + src/server/mcp/tools/sites/listSites.ts | 7 +- src/server/mcp/tools/sites/restoreSite.ts | 54 ++++++ src/server/mcp/tools/sites/schemas.ts | 31 +-- .../mcp/tools/sites/setSiteVisibility.ts | 2 +- src/server/mcp/tools/sites/shared.ts | 15 +- src/server/models/Site.ts | 2 +- .../services/__tests__/sites.behavior.test.ts | 179 +++++++++++++++--- src/server/services/sites.ts | 99 ++++++++-- src/server/services/sitesConfig.test.ts | 8 +- src/server/services/sitesConfig.ts | 10 + src/server/services/types/globalConfig.ts | 1 + src/shared/openApiSpec.test.ts | 7 +- src/shared/openApiSpec.ts | 21 +- 27 files changed, 575 insertions(+), 77 deletions(-) create mode 100644 src/app/api/v2/sites/[siteId]/restore/route.ts create mode 100644 src/server/mcp/tools/sites/restoreSite.ts diff --git a/src/app/api/v2/sites/[siteId]/restore/route.ts b/src/app/api/v2/sites/[siteId]/restore/route.ts new file mode 100644 index 00000000..89246e66 --- /dev/null +++ b/src/app/api/v2/sites/[siteId]/restore/route.ts @@ -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); diff --git a/src/app/api/v2/sites/route.test.ts b/src/app/api/v2/sites/route.test.ts index ba4fe6db..4e10c45e 100644 --- a/src/app/api/v2/sites/route.test.ts +++ b/src/app/api/v2/sites/route.test.ts @@ -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() }; @@ -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), })), }; }); @@ -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'; @@ -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); @@ -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 () => { @@ -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=')); @@ -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)); diff --git a/src/app/api/v2/sites/route.ts b/src/app/api/v2/sites/route.ts index 5d663fdd..1993bdcb 100644 --- a/src/app/api/v2/sites/route.ts +++ b/src/app/api/v2/sites/route.ts @@ -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 } diff --git a/src/server/lib/sites/config.ts b/src/server/lib/sites/config.ts index 4a8c1430..3a1f7799 100644 --- a/src/server/lib/sites/config.ts +++ b/src/server/lib/sites/config.ts @@ -86,6 +86,7 @@ export type ResolvedSitesConfig = { cleanup: { enabled: boolean; intervalMinutes: number; + deletedRetentionDays: number; }; }; @@ -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, }, }; } diff --git a/src/server/lib/sites/routeHelpers.ts b/src/server/lib/sites/routeHelpers.ts index a6a04978..d0ae34c6 100644 --- a/src/server/lib/sites/routeHelpers.ts +++ b/src/server/lib/sites/routeHelpers.ts @@ -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 }), diff --git a/src/server/lib/sites/storage.test.ts b/src/server/lib/sites/storage.test.ts index 2f5f7d7a..b747e4fc 100644 --- a/src/server/lib/sites/storage.test.ts +++ b/src/server/lib/sites/storage.test.ts @@ -51,7 +51,7 @@ function config(overrides: Partial = {}): 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', diff --git a/src/server/lib/v2RoutePolicyManifest.ts b/src/server/lib/v2RoutePolicyManifest.ts index 9e09aa57..4291580d 100644 --- a/src/server/lib/v2RoutePolicyManifest.ts +++ b/src/server/lib/v2RoutePolicyManifest.ts @@ -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' }, diff --git a/src/server/lib/validation/sitesConfigSchemas.ts b/src/server/lib/validation/sitesConfigSchemas.ts index 66af0704..613b4457 100644 --- a/src/server/lib/validation/sitesConfigSchemas.ts +++ b/src/server/lib/validation/sitesConfigSchemas.ts @@ -73,6 +73,7 @@ export const sitesConfigSchema = { properties: { enabled: { type: 'boolean' }, intervalMinutes: { type: 'integer', minimum: 1 }, + deletedRetentionDays: { type: 'integer', minimum: 0, maximum: 3650 }, }, }, }, diff --git a/src/server/mcp/__tests__/catalog.test.ts b/src/server/mcp/__tests__/catalog.test.ts index 583a944e..5d160372 100644 --- a/src/server/mcp/__tests__/catalog.test.ts +++ b/src/server/mcp/__tests__/catalog.test.ts @@ -42,6 +42,7 @@ const ALL_TOOLS = [ 'set_site_visibility', 'extend_site', 'delete_site', + 'restore_site', ]; const CHANGE_TOOLS = [ @@ -55,6 +56,7 @@ const CHANGE_TOOLS = [ 'set_site_visibility', 'extend_site', 'delete_site', + 'restore_site', ]; const SITE_TOOLS = [ @@ -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', () => { diff --git a/src/server/mcp/__tests__/toolHandlers.sites.test.ts b/src/server/mcp/__tests__/toolHandlers.sites.test.ts index d880e7e1..a9e245d8 100644 --- a/src/server/mcp/__tests__/toolHandlers.sites.test.ts +++ b/src/server/mcp/__tests__/toolHandlers.sites.test.ts @@ -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', @@ -78,6 +80,7 @@ function harness(service: Partial, policyOverrides: Partial 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, @@ -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' }); + }); +}); diff --git a/src/server/mcp/tools/sites/__tests__/shared.test.ts b/src/server/mcp/tools/sites/__tests__/shared.test.ts index a4a1ddb8..4c48bc9e 100644 --- a/src/server/mcp/tools/sites/__tests__/shared.test.ts +++ b/src/server/mcp/tools/sites/__tests__/shared.test.ts @@ -38,10 +38,12 @@ function site(overrides: Partial = {}): 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, diff --git a/src/server/mcp/tools/sites/deleteSite.ts b/src/server/mcp/tools/sites/deleteSite.ts index cd7f9392..12e3c6bf 100644 --- a/src/server/mcp/tools/sites/deleteSite.ts +++ b/src/server/mcp/tools/sites/deleteSite.ts @@ -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 { diff --git a/src/server/mcp/tools/sites/extendSite.ts b/src/server/mcp/tools/sites/extendSite.ts index 2f0a9b7e..7f48cae1 100644 --- a/src/server/mcp/tools/sites/extendSite.ts +++ b/src/server/mcp/tools/sites/extendSite.ts @@ -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 { diff --git a/src/server/mcp/tools/sites/index.ts b/src/server/mcp/tools/sites/index.ts index 4dcc4923..1fa34983 100644 --- a/src/server/mcp/tools/sites/index.ts +++ b/src/server/mcp/tools/sites/index.ts @@ -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'; @@ -36,5 +37,6 @@ export function createSiteToolDefinitions(dependencies: SiteToolDependencies = { createSetSiteVisibilityToolDefinition(resolved), createExtendSiteToolDefinition(resolved), createDeleteSiteToolDefinition(resolved), + createRestoreSiteToolDefinition(resolved), ]; } diff --git a/src/server/mcp/tools/sites/listSites.ts b/src/server/mcp/tools/sites/listSites.ts index 7cca3822..b953163a 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 owned by the authenticated principal.'; + 'Lists hosted sites. `mineOnly`: only yours. `deleted`: your deleted sites that restore_site can still bring back.'; export function createListSitesToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition { return { @@ -40,15 +40,16 @@ export function createListSitesToolDefinition(dependencies: ResolvedSiteToolDepe async handler(input, context): Promise { try { const mineOnly = input.mineOnly === true; + const deleted = input.deleted === true; const limit = typeof input.limit === 'number' ? input.limit : 25; - const cursorFilters: McpJsonObject = { mineOnly }; + const cursorFilters: McpJsonObject = { mineOnly, deleted }; const cursor = typeof input.cursor === 'string' ? decodeListCursor(input.cursor, cursorFilters, limit, dependencies.nowSeconds()) : null; const result = await dependencies.service().listSites( { - view: mineOnly ? 'mine' : 'all', + view: deleted ? 'deleted' : mineOnly ? 'mine' : 'all', page: cursor ? cursor.position + 1 : 1, limit, }, diff --git a/src/server/mcp/tools/sites/restoreSite.ts b/src/server/mcp/tools/sites/restoreSite.ts new file mode 100644 index 00000000..6ba23234 --- /dev/null +++ b/src/server/mcp/tools/sites/restoreSite.ts @@ -0,0 +1,54 @@ +/** + * 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 type { McpJsonObject, McpToolDefinition } from '../../contracts'; +import { restoreSiteInputSchema, restoreSiteOutputSchema } from './schemas'; +import { mapSiteServiceError, siteChangeSummary, type ResolvedSiteToolDependencies } from './shared'; + +const DESCRIPTION = + 'Restores a site you deleted, at the same id and URL, until its restorableUntil. Find it with list_sites deleted and pass its accessRevision as expectedAccessRevision.'; + +export function createRestoreSiteToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition { + return { + name: 'restore_site', + title: 'Restore site', + description: DESCRIPTION, + inputSchema: restoreSiteInputSchema, + outputSchema: restoreSiteOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + capabilityId: 'manage-hosted-sites', + access: 'change', + async handler(input, context): Promise { + try { + const site = await dependencies + .service() + .restoreSite( + input.siteId as string, + context.principal, + typeof input.expectedAccessRevision === 'number' ? input.expectedAccessRevision : undefined + ); + return { site: siteChangeSummary(site) }; + } catch (error) { + throw mapSiteServiceError(error); + } + }, + }; +} diff --git a/src/server/mcp/tools/sites/schemas.ts b/src/server/mcp/tools/sites/schemas.ts index 9dfdc4f9..6b521cdd 100644 --- a/src/server/mcp/tools/sites/schemas.ts +++ b/src/server/mcp/tools/sites/schemas.ts @@ -41,13 +41,15 @@ const siteSummarySchema = closedObjectSchema( canEdit: { type: 'boolean' }, canDelete: { type: 'boolean' }, canChangeVisibility: { type: 'boolean' }, + canRestore: { type: 'boolean' }, }, - ['canView', 'canEdit', 'canDelete', 'canChangeVisibility'] + ['canView', 'canEdit', 'canDelete', 'canChangeVisibility', 'canRestore'] ), status: { type: 'string', minLength: 1, maxLength: 50 }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, expiresAt: { type: 'string', format: 'date-time' }, + restorableUntil: { type: 'string', format: 'date-time' }, fileCount: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, sizeBytes: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, createdBy: { type: 'string', minLength: 1, maxLength: 512 }, @@ -73,10 +75,8 @@ const siteSummarySchema = closedObjectSchema( ); export const listSitesInputSchema = closedObjectSchema({ - mineOnly: { - type: 'boolean', - description: 'Return only sites owned by the authenticated Lifecycle principal.', - }, + mineOnly: { type: 'boolean' }, + deleted: { type: 'boolean' }, cursor: { type: 'string', maxLength: 500 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 }, }); @@ -94,7 +94,7 @@ export const getSiteInputSchema = closedObjectSchema({ siteId: siteIdSchema }, [ export const getSiteOutputSchema = successObjectSchema({ site: siteSummarySchema }, ['site']); const visibilitySchema = { type: 'string', enum: ['private', 'public'] } as const; -const revisionSchema = { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER } as const; +const revisionSchema = { type: 'integer', minimum: 1 } as const; const contentSchema = { type: 'string', @@ -114,13 +114,14 @@ const filenameSchema = { const siteChangeSchema = closedObjectSchema( { - siteId: { type: 'string', minLength: 1, maxLength: 100 }, - url: { type: 'string', format: 'uri', maxLength: 2048 }, - status: { type: 'string', maxLength: 50 }, - visibility: visibilitySchema, - accessRevision: revisionSchema, - contentRevision: revisionSchema, - expiresAt: { type: 'string', format: 'date-time' }, + siteId: { type: 'string' }, + url: { type: 'string' }, + status: { type: 'string' }, + visibility: { type: 'string' }, + accessRevision: { type: 'integer' }, + contentRevision: { type: 'integer' }, + expiresAt: { type: 'string' }, + restorableUntil: { type: 'string' }, }, ['siteId', 'url', 'status', 'visibility', 'accessRevision', 'contentRevision'] ); @@ -181,3 +182,7 @@ export const deleteSiteInputSchema = closedObjectSchema( ); export const deleteSiteOutputSchema = siteResultSchema; + +export const restoreSiteInputSchema = deleteSiteInputSchema; + +export const restoreSiteOutputSchema = siteResultSchema; diff --git a/src/server/mcp/tools/sites/setSiteVisibility.ts b/src/server/mcp/tools/sites/setSiteVisibility.ts index b6704412..2d79827d 100644 --- a/src/server/mcp/tools/sites/setSiteVisibility.ts +++ b/src/server/mcp/tools/sites/setSiteVisibility.ts @@ -19,7 +19,7 @@ import { setSiteVisibilityInputSchema, setSiteVisibilityOutputSchema } from './s import { mapSiteServiceError, siteChangeSummary, type ResolvedSiteToolDependencies } from './shared'; const DESCRIPTION = - 'Makes a hosted site you own private (only you can open it) or public (anyone with the link). Pass accessRevision from get_site as expectedAccessRevision. If acting for someone, get approval before making a site public.'; + 'Makes a site you own private (only you can open it) or public (anyone with the link). Pass accessRevision from get_site as expectedAccessRevision. If acting for someone, get approval before making it public.'; export function createSetSiteVisibilityToolDefinition(dependencies: ResolvedSiteToolDependencies): McpToolDefinition { return { diff --git a/src/server/mcp/tools/sites/shared.ts b/src/server/mcp/tools/sites/shared.ts index 5a6f56ab..29d7064e 100644 --- a/src/server/mcp/tools/sites/shared.ts +++ b/src/server/mcp/tools/sites/shared.ts @@ -41,6 +41,7 @@ export interface SiteToolService { expectedAccessRevision: number ): Promise; deleteSite(siteId: string, principal: Principal, expectedAccessRevision?: number): Promise; + restoreSite(siteId: string, principal: Principal, expectedAccessRevision?: number): Promise; } export interface SiteToolDependencies { @@ -151,6 +152,7 @@ export function siteSummary(site: SiteResponse): McpJsonObject { const createdBy = optionalString(site.createdBy, 512); const updatedBy = optionalString(site.updatedBy, 512); const expiresAt = normalizeMcpDateTime(site.expiresAt); + const restorableUntil = normalizeMcpDateTime(site.restorableUntil); return { siteId: requiredString(site.id, 100), name: requiredString(site.name, 200), @@ -162,10 +164,17 @@ export function siteSummary(site: SiteResponse): McpJsonObject { accessRevision: site.accessRevision, contentRevision: site.contentRevision, currentRole: site.currentRole, - permissions: { ...site.permissions }, + permissions: { + canView: site.permissions.canView, + canEdit: site.permissions.canEdit, + canDelete: site.permissions.canDelete, + canChangeVisibility: site.permissions.canChangeVisibility, + canRestore: site.permissions.canRestore, + }, createdAt: requiredDateTime(site.createdAt), updatedAt: requiredDateTime(site.updatedAt), ...(expiresAt ? { expiresAt } : {}), + ...(restorableUntil ? { restorableUntil } : {}), fileCount: site.fileCount, sizeBytes: site.sizeBytes, ...(createdBy ? { createdBy } : {}), @@ -174,7 +183,8 @@ export function siteSummary(site: SiteResponse): McpJsonObject { } export function siteChangeSummary(site: SiteResponse): McpJsonObject { - const { siteId, url, status, visibility, accessRevision, contentRevision, expiresAt } = siteSummary(site); + const { siteId, url, status, visibility, accessRevision, contentRevision, expiresAt, restorableUntil } = + siteSummary(site); return { siteId, url, @@ -183,5 +193,6 @@ export function siteChangeSummary(site: SiteResponse): McpJsonObject { accessRevision, contentRevision, ...(expiresAt ? { expiresAt } : {}), + ...(restorableUntil ? { restorableUntil } : {}), }; } diff --git a/src/server/models/Site.ts b/src/server/models/Site.ts index afa45ae5..897d208e 100644 --- a/src/server/models/Site.ts +++ b/src/server/models/Site.ts @@ -19,7 +19,7 @@ import Model from './_Model'; export type SiteVisibility = 'private' | 'public'; export type SiteOwnerKind = 'user' | 'service_key' | 'unresolved'; -export type SiteStatus = 'active' | 'deleted' | 'expired'; +export type SiteStatus = 'active' | 'deleted' | 'expired' | 'purged'; export default class Site extends Model { siteId!: string; diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index dc5d14f0..27b42d37 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -328,13 +328,29 @@ class SiteQuery { } if (expectedValue !== undefined) { - this.filters.push((row) => operationOrValue === '<=' && String(row[scopeOrField]) <= String(expectedValue)); + this.filters.push((row) => { + const value = row[scopeOrField]; + if (value == null) return false; + if (operationOrValue === '<=') return String(value) <= String(expectedValue); + if (operationOrValue === '>') return String(value) > String(expectedValue); + return false; + }); } else { this.filters.push((row) => row[scopeOrField] === operationOrValue); } return this; } + whereExists(versions: VersionQuery) { + this.filters.push((row) => + this.state.versions.some( + (version) => + version.siteId === row.siteId && version.versionId === row.activeVersionId && versions.matches(version) + ) + ); + return this; + } + whereRaw(sql: string, values: string[]) { if (sql.includes('createdBy') && sql.includes('updatedBy')) { const user = values[0]; @@ -407,7 +423,7 @@ class VersionQuery { 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) + site && (['purged', 'expired'].includes(site.status) || site.activeVersionId !== version.versionId) ); }); return this; @@ -415,6 +431,12 @@ class VersionQuery { join() { return this; } + whereColumn() { + return this; + } + matches(row: VersionRow) { + return this.filters.every((filter) => filter(row)); + } select() { return this; } @@ -915,18 +937,22 @@ describe('SitesService behavior', () => { }); describe('deleteSite', () => { - it('tombstones the site before cleaning remaining versions', async () => { + it('tombstones the site and keeps its content restorable', 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', principal); - expect(mockDeletePrefix.mock.calls).toEqual([['prefix/version-1']]); - expect(result).toMatchObject({ id: 'site-1', status: 'deleted' }); + expect(mockDeletePrefix).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + id: 'site-1', + status: 'deleted', + deletedAt: expect.any(String), + restorableUntil: expect.any(String), + permissions: expect.objectContaining({ canRestore: true }), + }); expect(state.sites[0]).toMatchObject({ status: 'deleted', deletedAt: expect.any(String) }); - expect(state.versions[0].deletedAt).not.toBeNull(); - expect(state.versions[1].deletedAt).toBe(CREATED_AT); + expect(state.versions[0].deletedAt).toBeNull(); }); it('returns not found without touching storage for an unknown site', async () => { @@ -936,17 +962,124 @@ describe('SitesService behavior', () => { }); expect(mockDeletePrefix).not.toHaveBeenCalled(); }); + }); + + describe('restoreSite', () => { + const deletedAt = (daysAgo: number) => new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(); + + it('restores a deleted site at the same id with a new access revision', async () => { + addSite(state, { status: 'deleted', deletedAt: deletedAt(1), accessRevision: 2 }); + addVersion(state); + + const result = await service.restoreSite('site-1', principal, 2); + + expect(result).toMatchObject({ id: 'site-1', status: 'active', deletedAt: null, accessRevision: 3 }); + expect(state.sites[0]).toMatchObject({ status: 'active', deletedAt: null, accessRevision: 3 }); + expect(mockAudit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ event: 'sites.restored', meta: expect.objectContaining({ siteId: 'site-1' }) }) + ); + }); + + it('gives a restored site whose expiry passed a fresh TTL', async () => { + addSite(state, { status: 'deleted', deletedAt: deletedAt(1), expiresAt: '2026-01-01T00:00:00.000Z' }); + addVersion(state); + + const result = await service.restoreSite('site-1', principal); + + expect(new Date(result.expiresAt!).getTime()).toBeGreaterThan(Date.now()); + }); - 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); + it.each([ + ['an active site', { status: 'active', deletedAt: null }], + ['a purged site', { status: 'purged', deletedAt: deletedAt(40) }], + ['a site past the retention window', { status: 'deleted', deletedAt: deletedAt(31) }], + ] as const)('refuses %s', async (_case, overrides) => { + addSite(state, overrides); + addVersion(state); + + await expect(service.restoreSite('site-1', principal)).rejects.toMatchObject({ + message: 'Site not found or no longer restorable.', + statusCode: 404, + }); + }); + + it('refuses a site whose content was purged before soft delete existed', async () => { + addSite(state, { status: 'deleted', deletedAt: deletedAt(1) }); + addVersion(state, { deletedAt: deletedAt(1) }); + + await expect(service.restoreSite('site-1', principal)).rejects.toMatchObject({ statusCode: 404 }); + expect(state.sites[0].status).toBe('deleted'); + }); + + it('refuses a non-owner and a stale revision', async () => { + addSite(state, { status: 'deleted', deletedAt: deletedAt(1), ownerSubject: 'someone-else' }); + addVersion(state); + await expect(service.restoreSite('site-1', principal)).rejects.toMatchObject({ code: 'site_access_denied' }); + + state.sites[0].ownerSubject = principal.userId; + await expect(service.restoreSite('site-1', principal, 99)).rejects.toMatchObject({ code: 'site_changed' }); + expect(state.sites[0].status).toBe('deleted'); + }); + + it('lists only restorable deleted sites owned by the caller', async () => { + addSite(state, { siteId: 'restorable', activeVersionId: 'v-r', status: 'deleted', deletedAt: deletedAt(2) }); + addVersion(state, { siteId: 'restorable', versionId: 'v-r' }); + addSite(state, { siteId: 'too-old', activeVersionId: 'v-o', status: 'deleted', deletedAt: deletedAt(45) }); + addVersion(state, { siteId: 'too-old', versionId: 'v-o' }); + addSite(state, { siteId: 'legacy', activeVersionId: 'v-l', status: 'deleted', deletedAt: deletedAt(2) }); + addVersion(state, { siteId: 'legacy', versionId: 'v-l', deletedAt: deletedAt(2) }); + addSite(state, { + siteId: 'others', + activeVersionId: 'v-x', + status: 'deleted', + deletedAt: deletedAt(2), + ownerSubject: 'someone-else', + }); + addVersion(state, { siteId: 'others', versionId: 'v-x' }); + addSite(state, { siteId: 'live' }); + + const result = await service.listSites({ view: 'deleted' }, principal); + + expect(result.sites.map((site) => site.id)).toEqual(['restorable']); + expect(result.sites[0].permissions.canRestore).toBe(true); + }); + }); + + describe('deleted site purge', () => { + it('purges deleted sites once the retention window passes and keeps newer ones restorable', async () => { + enabledConfig({ cleanup: { enabled: true, intervalMinutes: 15, deletedRetentionDays: 30 } }); + const old = addSite(state, { + siteId: 'old', + activeVersionId: 'old-v', + status: 'deleted', + deletedAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(), + }); + const oldVersion = addVersion(state, { siteId: 'old', versionId: 'old-v', storagePrefix: 'prefix/old' }); + const recent = addSite(state, { + siteId: 'recent', + activeVersionId: 'recent-v', + status: 'deleted', + deletedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + }); + const recentVersion = addVersion(state, { siteId: 'recent', versionId: 'recent-v' }); + + await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, purged: 1, cleaned: 1, errors: 0 }); + + expect(old.status).toBe('purged'); + expect(oldVersion.deletedAt).not.toBeNull(); + expect(mockDeletePrefix.mock.calls).toEqual([['prefix/old']]); + expect(recent.status).toBe('deleted'); + expect(recentVersion.deletedAt).toBeNull(); + }); + + it('purges on the next run when retention is zero', async () => { + enabledConfig({ cleanup: { enabled: true, intervalMinutes: 15, deletedRetentionDays: 0 } }); + addSite(state, { status: 'deleted', deletedAt: new Date(Date.now() - 1000).toISOString() }); + addVersion(state); - 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(); + await expect(service.cleanupExpiredSites()).resolves.toMatchObject({ purged: 1, cleaned: 1 }); + expect(state.sites[0].status).toBe('purged'); }); }); @@ -1077,7 +1210,7 @@ describe('SitesService behavior', () => { ])('does no work when %s', async (_case, sites) => { mockGetAllConfigs.mockResolvedValue({ sites }); - await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, cleaned: 0, errors: 0 }); + await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, purged: 0, cleaned: 0, errors: 0 }); expect(database.models.Site.query).not.toHaveBeenCalled(); expect(mockDeletePrefix).not.toHaveBeenCalled(); }); @@ -1113,7 +1246,7 @@ describe('SitesService behavior', () => { if (prefix === 'prefix/failed') throw cleanupError; }); - await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 2, cleaned: 1, errors: 1 }); + await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 2, purged: 0, 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: 'expired', deletedAt: '2026-06-10T00:00:00.000Z' }); @@ -1132,7 +1265,7 @@ describe('SitesService behavior', () => { 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 }); + await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, purged: 0, cleaned: 1, errors: 0 }); expect(state.versions[0].deletedAt).not.toBeNull(); expect(state.versions[1].deletedAt).toBeNull(); }); @@ -1140,7 +1273,7 @@ describe('SitesService behavior', () => { it('returns zero counts when no active site has elapsed', async () => { addSite(state, { expiresAt: '2099-01-01T00:00:00.000Z' }); - await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, cleaned: 0, errors: 0 }); + await expect(service.cleanupExpiredSites()).resolves.toEqual({ expired: 0, purged: 0, cleaned: 0, errors: 0 }); expect(mockDeletePrefix).not.toHaveBeenCalled(); }); }); @@ -1426,11 +1559,11 @@ describe('SitesService behavior', () => { describe('cleanup queue wiring', () => { it('processes cleanup jobs and logs the result', async () => { - const result = { expired: 3, cleaned: 2, errors: 1 }; + const result = { expired: 3, purged: 4, cleaned: 2, errors: 1 }; jest.spyOn(service, 'cleanupExpiredSites').mockResolvedValue(result); await expect(service.processSitesCleanupQueue({} as any)).resolves.toEqual(result); - expect(mockInfo).toHaveBeenCalledWith('Sites: cleanup complete expired=3 cleaned=2 errors=1'); + expect(mockInfo).toHaveBeenCalledWith('Sites: cleanup complete expired=3 purged=4 cleaned=2 errors=1'); }); it.each([ diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index cf039a8b..13944087 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -75,7 +75,7 @@ export type CreateOrReplaceSiteInput = { export type ListSitesFilters = { user?: string; - view?: 'mine' | 'public' | 'all'; + view?: 'mine' | 'public' | 'all' | 'deleted'; q?: string; page?: number; limit?: number; @@ -97,10 +97,18 @@ export type SiteResponse = { accessRevision: number; contentRevision: number; currentRole: 'owner' | null; - permissions: { canView: boolean; canEdit: boolean; canDelete: boolean; canChangeVisibility: boolean }; + permissions: { + canView: boolean; + canEdit: boolean; + canDelete: boolean; + canChangeVisibility: boolean; + canRestore: boolean; + }; createdAt: string | null; updatedAt: string | null; expiresAt: string | null; + deletedAt: string | null; + restorableUntil: string | null; fileCount: number; sizeBytes: number; createdBy: string | null; @@ -145,6 +153,8 @@ export default class SitesService extends Service { site.status === 'active' && expiresAt !== null && Number.isFinite(expiresAt) && expiresAt <= Date.now() ? 'expired' : site.status; + const restorableUntil = + site.status === 'deleted' && site.deletedAt ? this.restoreDeadline(site.deletedAt, config) : null; return { id: site.siteId, name: site.name, @@ -160,11 +170,14 @@ export default class SitesService extends Service { canEdit: writable && status === 'active', canDelete: writable && !site.deletedAt && status !== 'deleted', canChangeVisibility: writable && status === 'active' && site.ownerKind === 'user', + canRestore: writable && restorableUntil !== null && new Date(restorableUntil).getTime() > Date.now(), }, status, createdAt: site.createdAt || null, updatedAt: site.updatedAt || null, expiresAt: site.expiresAt || null, + deletedAt: site.deletedAt || null, + restorableUntil, fileCount: Number(site.fileCount || 0), sizeBytes: Number(site.sizeBytes || 0), createdBy: owner || site.visibility === 'public' ? site.createdBy || null : null, @@ -172,6 +185,10 @@ export default class SitesService extends Service { }; } + private restoreDeadline(deletedAt: string | Date, config: ResolvedSitesConfig): string { + return new Date(new Date(deletedAt).getTime() + config.cleanup.deletedRetentionDays * DAY_MS).toISOString(); + } + private defaultSiteName(siteId: string, config: ResolvedSitesConfig): string { return `${config.hostPrefix}-${siteId}`; } @@ -400,17 +417,31 @@ export default class SitesService extends Service { this.assertEnabled(config); 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); + if (!['all', 'mine', 'public', 'deleted'].includes(view) || filters.user) { + throw new SitesServiceError('Use view=mine, public, all, or deleted; 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 (view === 'mine') query.where(owner); - else if (view === 'public') query.where('visibility', 'public'); - else query.where((q) => q.where('visibility', 'public').orWhere(owner)); + const query = this.db.models.Site.query(); + if (view === 'deleted') { + // A purged active version leaves nothing to restore. + query + .where(owner) + .where('status', 'deleted') + .where('deletedAt', '>', new Date(Date.now() - config.cleanup.deletedRetentionDays * DAY_MS).toISOString()) + .whereExists( + this.db.models.SiteVersion.query() + .whereColumn('site_versions.versionId', 'sites.activeVersionId') + .whereNull('site_versions.deletedAt') + ); + } else { + query.whereNull('deletedAt'); + 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); @@ -421,7 +452,7 @@ export default class SitesService extends Service { ]); } const result = await query - .orderBy('createdAt', 'desc') + .orderBy(view === 'deleted' ? 'deletedAt' : 'createdAt', 'desc') .orderBy('siteId', 'asc') .page(page - 1, limit); return { @@ -573,14 +604,38 @@ export default class SitesService extends Service { 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); } + async restoreSite(siteId: string, principal: Principal, expectedAccessRevision?: number): Promise { + await assertSitesPrincipal(principal, 'write'); + const config = await this.getConfig(); + this.assertEnabled(config); + const notRestorable = () => new SitesServiceError('Site not found or no longer restorable.', 404); + return this.db.models.Site.transact(async (trx) => { + // Locks against the cleanup job, whose purge only claims rows still in status deleted. + const site = (await this.db.models.Site.query(trx).findOne({ siteId }).forUpdate()) as Site | undefined; + if (!site || site.status !== 'deleted' || !site.deletedAt || !site.activeVersionId) throw notRestorable(); + await assertSitesPrincipal(principal, 'write', trx); + assertSiteOwner(site, principal); + this.checkRevision(site, expectedAccessRevision); + if (new Date(this.restoreDeadline(site.deletedAt, config)).getTime() <= Date.now()) throw notRestorable(); + const activeVersion = (await this.db.models.SiteVersion.query(trx) + .findOne({ siteId, versionId: site.activeVersionId }) + .whereNull('deletedAt')) as SiteVersion | undefined; + if (!activeVersion) throw notRestorable(); + const expired = site.expiresAt && new Date(site.expiresAt).getTime() <= Date.now(); + await this.audit(trx, 'restored', siteId, principal, { accessRevision: site.accessRevision + 1 }); + const restored = await site.$query(trx).patchAndFetch({ + status: 'active', + deletedAt: null, + accessRevision: site.accessRevision + 1, + ...(expired && config.ttl.enabled ? { expiresAt: this.expirationForNewSite(config) } : {}), + } as unknown as Partial); + return this.serialize(restored as Site, config, principal); + }); + } + /** Anonymous stable-link resolver exposes only a currently public content URL. */ async resolvePublicSiteUrl(siteId: string): Promise { try { @@ -674,9 +729,9 @@ export default class SitesService extends Service { return Boolean(host && host.endsWith(`.${config.domain.toLowerCase()}`)); } - async cleanupExpiredSites(): Promise<{ expired: number; cleaned: number; errors: number }> { + async cleanupExpiredSites(): Promise<{ expired: number; purged: number; cleaned: number; errors: number }> { const config = await this.getConfig(); - if (!config.enabled || !config.cleanup.enabled) return { expired: 0, cleaned: 0, errors: 0 }; + if (!config.enabled || !config.cleanup.enabled) return { expired: 0, purged: 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() @@ -685,11 +740,17 @@ export default class SitesService extends Service { .whereNotNull('expiresAt') .where('expiresAt', '<=', now) .patch({ status: 'expired', deletedAt: now }); + const purgeBefore = new Date(Date.now() - config.cleanup.deletedRetentionDays * DAY_MS).toISOString(); + // Conditional on status, so a site a concurrent restore just reactivated is never purged. + const purged = await this.db.models.Site.query() + .where('status', 'deleted') + .where('deletedAt', '<=', purgeBefore) + .patch({ status: 'purged' }); 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', + 'purged', 'expired', ]) .select('site_versions.*') @@ -706,13 +767,13 @@ export default class SitesService extends Service { getLogger().warn({ error, siteId: version.siteId }, 'Sites: terminal storage cleanup deferred'); } } - return { expired, cleaned, errors }; + return { expired, purged, cleaned, errors }; } processSitesCleanupQueue = async (_job: Job) => { const result = await this.cleanupExpiredSites(); getLogger().info( - `Sites: cleanup complete expired=${result.expired} cleaned=${result.cleaned} errors=${result.errors}` + `Sites: cleanup complete expired=${result.expired} purged=${result.purged} cleaned=${result.cleaned} errors=${result.errors}` ); return result; }; diff --git a/src/server/services/sitesConfig.test.ts b/src/server/services/sitesConfig.test.ts index 89d2c89e..3e22e284 100644 --- a/src/server/services/sitesConfig.test.ts +++ b/src/server/services/sitesConfig.test.ts @@ -68,7 +68,7 @@ describe('SitesConfigService normalization', () => { endpoint: ' https://objects.example.test ', forcePathStyle: false, }, - cleanup: { enabled: false, intervalMinutes: 60 }, + cleanup: { enabled: false, intervalMinutes: 60, deletedRetentionDays: 0 }, }); expect(result).toEqual({ @@ -91,7 +91,7 @@ describe('SitesConfigService normalization', () => { endpoint: 'https://objects.example.test', forcePathStyle: false, }, - cleanup: { enabled: false, intervalMinutes: 60 }, + cleanup: { enabled: false, intervalMinutes: 60, deletedRetentionDays: 0 }, }); expect(mockGetConfig).toHaveBeenCalledWith('sites'); }); @@ -176,7 +176,7 @@ describe('SitesConfigService normalization', () => { maxExtractedBytes: 40, maxFiles: 2, }); - expect(result.cleanup).toEqual({ enabled: true, intervalMinutes: 60 }); + expect(result.cleanup).toEqual({ enabled: true, intervalMinutes: 60, deletedRetentionDays: 30 }); }); it('falls back to defaults for non-positive / non-integer values', async () => { @@ -191,7 +191,7 @@ describe('SitesConfigService normalization', () => { const result = await normalizeViaGetConfig({ ttl: { defaultDays: 1.5, extensionDays: 0 }, upload: { ...DEFAULT_SITES_CONFIG.upload!, maxExtractedBytes: Number.NaN }, - cleanup: { intervalMinutes: -1 }, + cleanup: { intervalMinutes: -1, deletedRetentionDays: 4000 }, }); expect(result.ttl).toEqual(DEFAULT_SITES_CONFIG.ttl); diff --git a/src/server/services/sitesConfig.ts b/src/server/services/sitesConfig.ts index d736d431..608f9db7 100644 --- a/src/server/services/sitesConfig.ts +++ b/src/server/services/sitesConfig.ts @@ -74,6 +74,7 @@ export const DEFAULT_SITES_CONFIG: SitesConfig = { cleanup: { enabled: true, intervalMinutes: 15, + deletedRetentionDays: 30, }, }; @@ -87,6 +88,11 @@ function normalizePositiveInteger(value: number | string | undefined, fallback: return Number.isInteger(parsed) && Number(parsed) > 0 ? Number(parsed) : fallback; } +function normalizeRetentionDays(value: number | string | undefined, fallback: number): number { + const parsed = typeof value === 'string' ? Number(value) : value; + return Number.isInteger(parsed) && Number(parsed) >= 0 && Number(parsed) <= 3650 ? Number(parsed) : fallback; +} + function normalizePort(value: number | string | null | undefined): number | null { if (value === null || value === undefined || value === '') { return null; @@ -179,6 +185,10 @@ function normalizeSitesConfig(config: SitesConfig | undefined): SitesConfig { merged.cleanup?.intervalMinutes, DEFAULT_SITES_CONFIG.cleanup?.intervalMinutes || 15 ), + deletedRetentionDays: normalizeRetentionDays( + merged.cleanup?.deletedRetentionDays, + DEFAULT_SITES_CONFIG.cleanup?.deletedRetentionDays ?? 30 + ), }, }; } diff --git a/src/server/services/types/globalConfig.ts b/src/server/services/types/globalConfig.ts index b954917a..827c76e0 100644 --- a/src/server/services/types/globalConfig.ts +++ b/src/server/services/types/globalConfig.ts @@ -229,6 +229,7 @@ export type SitesConfig = { cleanup?: { enabled?: boolean; intervalMinutes?: number; + deletedRetentionDays?: number; }; }; diff --git a/src/shared/openApiSpec.test.ts b/src/shared/openApiSpec.test.ts index 03c9dcfc..3ef0fc61 100644 --- a/src/shared/openApiSpec.test.ts +++ b/src/shared/openApiSpec.test.ts @@ -194,7 +194,12 @@ 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: 'view', in: 'query', schema: { type: 'string', enum: ['mine', 'public', 'all'] } }, + { + name: 'view', + in: 'query', + 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 } }, { name: 'page', diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index e838ff3d..7633e564 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -279,13 +279,21 @@ export const openApiSpecificationForV2Api: OAS3Options = { canEdit: { type: 'boolean' }, canDelete: { type: 'boolean' }, canChangeVisibility: { type: 'boolean' }, + canRestore: { type: 'boolean' }, }, - required: ['canView', 'canEdit', 'canDelete', 'canChangeVisibility'], + required: ['canView', 'canEdit', 'canDelete', 'canChangeVisibility', 'canRestore'], }, - status: { type: 'string', enum: ['active', 'deleted', 'expired'] }, + status: { type: 'string', enum: ['active', 'deleted', 'expired', 'purged'] }, createdAt: { type: 'string', format: 'date-time', nullable: true }, updatedAt: { type: 'string', format: 'date-time', nullable: true }, expiresAt: { type: 'string', format: 'date-time', nullable: true }, + deletedAt: { type: 'string', format: 'date-time', nullable: true }, + restorableUntil: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'Last moment a deleted site can be restored; null unless the site is deleted.', + }, fileCount: { type: 'integer', minimum: 0 }, sizeBytes: { type: 'integer', format: 'int64', minimum: 0 }, createdBy: { @@ -314,6 +322,8 @@ export const openApiSpecificationForV2Api: OAS3Options = { 'createdAt', 'updatedAt', 'expiresAt', + 'deletedAt', + 'restorableUntil', 'fileCount', 'sizeBytes', 'createdBy', @@ -403,6 +413,13 @@ export const openApiSpecificationForV2Api: OAS3Options = { properties: { enabled: { type: 'boolean' }, intervalMinutes: { type: 'integer', minimum: 1 }, + deletedRetentionDays: { + type: 'integer', + minimum: 0, + maximum: 3650, + description: + 'Days a deleted site stays restorable before its content is purged. 0 purges on the next cleanup run.', + }, }, required: ['enabled', 'intervalMinutes'], additionalProperties: false, From 9e4b74c77b2c87b6c78a5ab232fbaf65e5659e11 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Fri, 9 Oct 2026 11:23:50 -0700 Subject: [PATCH 2/4] feat(sites): include deleted-site retention in sites capabilities --- .../services/__tests__/sites.behavior.test.ts | 4 ++++ src/server/services/sites.ts | 1 + src/shared/openApiSpec.ts | 14 +++++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 27b42d37..96ed3857 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -1360,6 +1360,10 @@ describe('SitesService behavior', () => { }) ); }); + it('advertises how long deleted sites stay restorable', async () => { + enabledConfig({ cleanup: { enabled: true, intervalMinutes: 15, deletedRetentionDays: 14 } }); + await expect(service.getCapabilities(principal)).resolves.toMatchObject({ deletedRetentionDays: 14 }); + }); it('advertises no uploads while the existing Sites setting is disabled', async () => { mockGetAllConfigs.mockResolvedValue({ sites: { enabled: false } }); await expect(service.getCapabilities(principal)).resolves.toMatchObject({ diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index 13944087..4aa9f00f 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -279,6 +279,7 @@ export default class SitesService extends Service { return { enabled: config.enabled, upload: config.upload, + deletedRetentionDays: config.cleanup.deletedRetentionDays, defaultVisibility: machine ? ('public' as const) : ('private' as const), allowedVisibilities: !config.enabled || !hasSitesScope(principal, 'write') diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 7633e564..d70d4732 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -1104,8 +1104,20 @@ export const openApiSpecificationForV2Api: OAS3Options = { }, required: ['maxUploadBytes', 'maxExtractedBytes', 'maxFiles', 'allowedExtensions'], }, + deletedRetentionDays: { + type: 'integer', + minimum: 0, + description: 'Days a deleted site stays restorable before its content is purged.', + }, }, - required: ['enabled', 'canCreate', 'defaultVisibility', 'allowedVisibilities', 'upload'], + required: [ + 'enabled', + 'canCreate', + 'defaultVisibility', + 'allowedVisibilities', + 'upload', + 'deletedRetentionDays', + ], }, SitesCapabilitiesSuccessResponse: { allOf: [ From 2eb101ced919b997202978f39f2cdd87f290735b Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Fri, 9 Oct 2026 13:00:22 -0700 Subject: [PATCH 3/4] test(sites): deleted sites stay hidden from everyone except their owner --- .../services/__tests__/sites.behavior.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 96ed3857..3e5b2611 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -1022,6 +1022,45 @@ describe('SitesService behavior', () => { expect(state.sites[0].status).toBe('deleted'); }); + it('hides deleted public and private sites from everyone except their owner', async () => { + const other = { ...principal, userId: 'someone-else', actor: 'someone-else' } as Principal; + addSite(state, { + siteId: 'pub', + activeVersionId: 'v-pub', + visibility: 'public', + status: 'deleted', + deletedAt: deletedAt(1), + }); + addVersion(state, { siteId: 'pub', versionId: 'v-pub' }); + addSite(state, { + siteId: 'priv', + activeVersionId: 'v-priv', + visibility: 'private', + status: 'deleted', + deletedAt: deletedAt(1), + }); + addVersion(state, { siteId: 'priv', versionId: 'v-priv' }); + + for (const view of ['deleted', 'all', 'public', 'mine'] as const) { + const result = await service.listSites({ view }, other); + expect(result.sites.map((site) => site.id)).toEqual([]); + } + for (const view of ['all', 'public', 'mine'] as const) { + const result = await service.listSites({ view }, principal); + expect(result.sites.map((site) => site.id)).toEqual([]); + } + await expect(service.getSite('pub', other)).rejects.toMatchObject({ statusCode: 404 }); + await expect(service.getGatewaySite('site-pub.sites.example.com')).rejects.toMatchObject({ statusCode: 404 }); + await expect(service.restoreSite('pub', other)).rejects.toMatchObject({ code: 'site_access_denied' }); + expect(state.sites.find((site) => site.siteId === 'pub')?.status).toBe('deleted'); + + const own = await service.listSites({ view: 'deleted' }, principal); + expect(own.sites.map((site) => [site.id, site.visibility]).sort()).toEqual([ + ['priv', 'private'], + ['pub', 'public'], + ]); + }); + it('lists only restorable deleted sites owned by the caller', async () => { addSite(state, { siteId: 'restorable', activeVersionId: 'v-r', status: 'deleted', deletedAt: deletedAt(2) }); addVersion(state, { siteId: 'restorable', versionId: 'v-r' }); From e7d643a8b9146ee56bbbcec730664cae1ddcad78 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Fri, 9 Oct 2026 13:11:55 -0700 Subject: [PATCH 4/4] fix(sites): keep restored sites safe from cleanup and honour disabled cleanup Restoring a site whose expiry had passed while TTL is off now clears the expiry, so the next cleanup run cannot expire and purge it. When the cleanup job is disabled, delete purges at once as before, since nothing would purge it later. Retention 0 reports no restore window. --- src/server/mcp/tools/sites/restoreSite.ts | 2 +- .../services/__tests__/sites.behavior.test.ts | 38 +++++++++++++++++++ src/server/services/sites.ts | 16 ++++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/server/mcp/tools/sites/restoreSite.ts b/src/server/mcp/tools/sites/restoreSite.ts index 6ba23234..fd358827 100644 --- a/src/server/mcp/tools/sites/restoreSite.ts +++ b/src/server/mcp/tools/sites/restoreSite.ts @@ -31,7 +31,7 @@ export function createRestoreSiteToolDefinition(dependencies: ResolvedSiteToolDe annotations: { readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: false, }, capabilityId: 'manage-hosted-sites', diff --git a/src/server/services/__tests__/sites.behavior.test.ts b/src/server/services/__tests__/sites.behavior.test.ts index 3e5b2611..ce163285 100644 --- a/src/server/services/__tests__/sites.behavior.test.ts +++ b/src/server/services/__tests__/sites.behavior.test.ts @@ -955,6 +955,31 @@ describe('SitesService behavior', () => { expect(state.versions[0].deletedAt).toBeNull(); }); + it('purges at once when the cleanup job is disabled, since nothing would purge it later', async () => { + enabledConfig({ cleanup: { enabled: false, intervalMinutes: 15 } }); + addSite(state); + addVersion(state, { storagePrefix: 'prefix/version-1' }); + + const result = await service.deleteSite('site-1', principal); + + expect(result).toMatchObject({ status: 'purged', restorableUntil: null }); + expect(result.permissions.canRestore).toBe(false); + expect(mockDeletePrefix.mock.calls).toEqual([['prefix/version-1']]); + expect(state.versions[0].deletedAt).not.toBeNull(); + }); + + it('reports no restore window when retention is zero', async () => { + enabledConfig({ cleanup: { enabled: true, intervalMinutes: 15, deletedRetentionDays: 0 } }); + addSite(state); + addVersion(state); + + const result = await service.deleteSite('site-1', principal); + + expect(result).toMatchObject({ status: 'deleted', restorableUntil: null }); + expect(result.permissions.canRestore).toBe(false); + expect(mockDeletePrefix).not.toHaveBeenCalled(); + }); + it('returns not found without touching storage for an unknown site', async () => { await expect(service.deleteSite('missing', principal)).rejects.toMatchObject({ message: 'Site not found.', @@ -990,6 +1015,19 @@ describe('SitesService behavior', () => { expect(new Date(result.expiresAt!).getTime()).toBeGreaterThan(Date.now()); }); + it('clears a past expiry on restore when TTL is off, so cleanup cannot purge the restored site', async () => { + enabledConfig({ ttl: { enabled: false } }); + addSite(state, { status: 'deleted', deletedAt: deletedAt(1), expiresAt: '2026-01-01T00:00:00.000Z' }); + addVersion(state); + + const result = await service.restoreSite('site-1', principal); + + expect(result.expiresAt).toBeNull(); + await service.cleanupExpiredSites(); + expect(state.sites[0].status).toBe('active'); + expect(mockDeletePrefix).not.toHaveBeenCalled(); + }); + it.each([ ['an active site', { status: 'active', deletedAt: null }], ['a purged site', { status: 'purged', deletedAt: deletedAt(40) }], diff --git a/src/server/services/sites.ts b/src/server/services/sites.ts index 4aa9f00f..24a860d3 100644 --- a/src/server/services/sites.ts +++ b/src/server/services/sites.ts @@ -154,7 +154,9 @@ export default class SitesService extends Service { ? 'expired' : site.status; const restorableUntil = - site.status === 'deleted' && site.deletedAt ? this.restoreDeadline(site.deletedAt, config) : null; + site.status === 'deleted' && site.deletedAt && config.cleanup.deletedRetentionDays > 0 + ? this.restoreDeadline(site.deletedAt, config) + : null; return { id: site.siteId, name: site.name, @@ -600,11 +602,18 @@ export default class SitesService extends Service { this.checkRevision(site, expectedAccessRevision); await this.audit(trx, 'deleted', siteId, principal, { accessRevision: site.accessRevision + 1 }); return site.$query(trx).patchAndFetch({ - status: 'deleted', + // Retention relies on the cleanup job; without it nothing would ever purge the content. + status: config.cleanup.enabled ? 'deleted' : 'purged', deletedAt: new Date().toISOString(), accessRevision: site.accessRevision + 1, }) as unknown as Promise; }); + if (!config.cleanup.enabled) { + 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); } @@ -631,7 +640,8 @@ export default class SitesService extends Service { status: 'active', deletedAt: null, accessRevision: site.accessRevision + 1, - ...(expired && config.ttl.enabled ? { expiresAt: this.expirationForNewSite(config) } : {}), + // Keeping a past expiry would let the next cleanup run expire and purge the restored site. + ...(expired ? { expiresAt: config.ttl.enabled ? this.expirationForNewSite(config) : null } : {}), } as unknown as Partial); return this.serialize(restored as Site, config, principal); });