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..fd358827 --- /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: false, + 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..ce163285 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,47 @@ 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).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(); - expect(state.versions[1].deletedAt).toBe(CREATED_AT); + }); + + 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 () => { @@ -936,17 +987,176 @@ 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); - 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); + const result = await service.restoreSite('site-1', principal); - 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(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) }], + ['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('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' }); + 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.cleanupExpiredSites()).resolves.toMatchObject({ purged: 1, cleaned: 1 }); + expect(state.sites[0].status).toBe('purged'); }); }); @@ -1077,7 +1287,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 +1323,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 +1342,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 +1350,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(); }); }); @@ -1227,6 +1437,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({ @@ -1426,11 +1640,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..24a860d3 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,10 @@ 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 && config.cleanup.deletedRetentionDays > 0 + ? this.restoreDeadline(site.deletedAt, config) + : null; return { id: site.siteId, name: site.name, @@ -160,11 +172,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 +187,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}`; } @@ -262,6 +281,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') @@ -400,17 +420,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 +455,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 { @@ -568,19 +602,51 @@ 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; }); - // 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); + 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); } + 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, + // 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); + }); + } + /** Anonymous stable-link resolver exposes only a currently public content URL. */ async resolvePublicSiteUrl(siteId: string): Promise { try { @@ -674,9 +740,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 +751,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 +778,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..d70d4732 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, @@ -1087,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: [