diff --git a/.changeset/unpublish-api-admission.md b/.changeset/unpublish-api-admission.md new file mode 100644 index 00000000..100475bd --- /dev/null +++ b/.changeset/unpublish-api-admission.md @@ -0,0 +1,6 @@ +--- +"@luzzle/web": patch +--- + +Add the authenticated Unpublish API and include it in publication admission and +job status streaming. Require a fresh check after an Unpublish request. diff --git a/apps/web/src/lib/server/workflow/publish.test.ts b/apps/web/src/lib/server/workflow/publish.test.ts index aa8f111b..5f6e438b 100644 --- a/apps/web/src/lib/server/workflow/publish.test.ts +++ b/apps/web/src/lib/server/workflow/publish.test.ts @@ -62,7 +62,9 @@ describe('findInFlightPublishRun', () => { { name: 'Publish', status: 'pending' }, { name: 'Publish', status: 'running' }, { name: 'PublishAudit', status: 'pending' }, - { name: 'PublishAudit', status: 'running' } + { name: 'PublishAudit', status: 'running' }, + { name: 'Unpublish', status: 'pending' }, + { name: 'Unpublish', status: 'running' } ])('finds an older $status $name behind a newer completed run', async ({ name, status }) => { const old = await createPendingRun(queue.backend, name) await claimNextRun(queue.backend) @@ -141,7 +143,8 @@ describe('findInFlightPublishRun', () => { expect(await findInFlightPublishRun(queue.backend)).toBeNull() expect(list.mock.calls.map(([params]) => params)).toEqual([ { workflowName: 'Publish', limit: 5 }, - { workflowName: 'PublishAudit', limit: 5 } + { workflowName: 'PublishAudit', limit: 5 }, + { workflowName: 'Unpublish', limit: 5 } ]) expect(count).not.toHaveBeenCalled() }) @@ -224,6 +227,54 @@ describe('validateAuditForPublish', () => { }) }) + test.each(['pending', 'completed', 'failed'])( + 'rejects an audit after a later %s Unpublish request', + async (status) => { + const audit = makeRun() + mocks.getWorkflowRun.mockResolvedValue(audit) + mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) => { + if (name === 'PublishAudit') return audit + if (name === 'Unpublish') { + return makeRun({ + id: 'unpublish-1', + workflow_name: 'Unpublish', + status, + created_at: '2026-06-20T01:00:00Z' + }) + } + return null + }) + expect(await validateAuditForPublish(backend, audit.id)).toEqual({ + ok: false, + reason: 'an unpublish was requested after this check; re-check before publishing' + }) + } + ) + + test('treats equal creation timestamps as stale rather than guessing request order', async () => { + const audit = makeRun() + mocks.getWorkflowRun.mockResolvedValue(audit) + mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) => { + if (name === 'PublishAudit') return audit + if (name === 'Unpublish') return makeRun({ workflow_name: 'Unpublish' }) + return null + }) + expect((await validateAuditForPublish(backend, audit.id)).ok).toBe(false) + }) + + test('accepts a fresh audit made after Unpublish', async () => { + const audit = makeRun({ created_at: '2026-06-20T02:00:00Z' }) + mocks.getWorkflowRun.mockResolvedValue(audit) + mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) => { + if (name === 'PublishAudit') return audit + if (name === 'Unpublish') { + return makeRun({ workflow_name: 'Unpublish', created_at: '2026-06-20T01:00:00Z' }) + } + return null + }) + expect(await validateAuditForPublish(backend, audit.id)).toEqual({ ok: true }) + }) + test('accepts a completed audit that is the latest with no later publish', async () => { mocks.getWorkflowRun.mockResolvedValue(makeRun({ id: 'audit-1' })) mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) => diff --git a/apps/web/src/lib/server/workflow/publish.ts b/apps/web/src/lib/server/workflow/publish.ts index 7be56401..e7e76db7 100644 --- a/apps/web/src/lib/server/workflow/publish.ts +++ b/apps/web/src/lib/server/workflow/publish.ts @@ -2,6 +2,8 @@ import type { BackendSqlite } from 'openworkflow/sqlite' import { getLatestWorkflowRun, getWorkflowRun, type PublishPieceFailure } from '@luzzle/web.jobs' import type { PiecesDiff } from '@luzzle/core' +export const publicationWorkflowNames = ['Publish', 'PublishAudit', 'Unpublish'] + const COMPLETED_STATES = new Set(['completed', 'succeeded']) let publishAdmission: Promise = Promise.resolve() @@ -22,7 +24,7 @@ export async function withPublishAdmission(operation: () => Promise): Prom export async function findInFlightPublishRun( backend: Pick ): Promise<{ id: string } | null> { - for (const workflowName of ['Publish', 'PublishAudit']) { + for (const workflowName of publicationWorkflowNames) { const { data } = await backend.listWorkflowRuns({ workflowName, limit: 5 }) const active = data.find((run) => run.status === 'pending' || run.status === 'running') if (active) return { id: active.id } @@ -65,6 +67,14 @@ export async function validateAuditForPublish( } } + const lastUnpublish = await getLatestWorkflowRun(backend, 'Unpublish') + if (lastUnpublish && lastUnpublish.created_at >= run.created_at) { + return { + ok: false, + reason: 'an unpublish was requested after this check; re-check before publishing' + } + } + if (filePaths !== undefined) { const diff = parsePiecesDiff(run.output) if (!diff) { diff --git a/apps/web/src/lib/server/workflow/stream.test.ts b/apps/web/src/lib/server/workflow/stream.test.ts index 7f26a861..b0f6ce63 100644 --- a/apps/web/src/lib/server/workflow/stream.test.ts +++ b/apps/web/src/lib/server/workflow/stream.test.ts @@ -407,6 +407,53 @@ describe('streamJobProgress', () => { ]) }) + const unpublishError = JSON.stringify({ + message: 'Unpublish cleanup incomplete: remote file.png' + }) + test.each([ + { status: 'completed', runError: null, expectedResult: 'ok', expectedErrors: null }, + { + status: 'failed', + runError: unpublishError, + expectedResult: null, + expectedErrors: [unpublishError] + } + ])( + 'reports a $status Unpublish outcome and preserves diagnostic details', + async ({ status, runError, expectedResult, expectedErrors }) => { + mocks.getWorkflowRun.mockResolvedValue({ + ...run, + workflow_name: 'Unpublish', + status, + error: runError + }) + mocks.getStepAttempts.mockResolvedValue([]) + const chunks = await readChunks( + openStream({ jobClass: ['Publish', 'PublishAudit', 'Unpublish'] }) + ) + const outcome = JSON.stringify({ + state: status, + result: expectedResult, + errors: expectedErrors, + workflowName: 'Unpublish' + }) + expect(chunks).toEqual([ + `event: state\ndata: ${outcome}\n\n`, + 'event: phase\ndata: []\n\n', + `event: done\ndata: ${outcome}\n\n` + ]) + expect(mocks.query.execute).not.toHaveBeenCalled() + } + ) + + test('does not present an Unpublish run as a Publish-only stream', async () => { + mocks.getWorkflowRun.mockResolvedValue({ ...run, workflow_name: 'Unpublish' }) + expect(await readChunks(openStream())).toEqual([ + 'event: error\ndata: {"message":"Job is not a Publish job"}\n\n' + ]) + expect(mocks.getStepAttempts).not.toHaveBeenCalled() + }) + test('reports a completed publish with failed pieces as partial, never as ok', async () => { const failedPieces = [{ filePath: 'failed.book.md', message: 'missing attachment' }] mocks.getWorkflowRun.mockResolvedValue({ ...run, output: JSON.stringify({ failedPieces }) }) @@ -426,7 +473,7 @@ describe('streamJobProgress', () => { expect(chunks.join('')).not.toContain('"result":"ok"') }) - test.each(['Preview', 'PublishAudit'])( + test.each(['Preview', 'PublishAudit', 'Unpublish'])( 'keeps non-publish completion semantics unchanged for %s', async (workflow_name) => { mocks.getWorkflowRun.mockResolvedValue({ @@ -441,7 +488,7 @@ describe('streamJobProgress', () => { state: 'completed', result: 'ok', errors: null, - ...(workflow_name === 'PublishAudit' ? { workflowName: workflow_name } : {}) + ...(workflow_name !== 'Preview' ? { workflowName: workflow_name } : {}) } expect(chunks[0]).toBe(`event: state\ndata: ${JSON.stringify(outcome)}\n\n`) expect(chunks.join('')).not.toContain('failedPieces') diff --git a/apps/web/src/lib/server/workflow/stream.ts b/apps/web/src/lib/server/workflow/stream.ts index 837ea5e5..fe4aa6dd 100644 --- a/apps/web/src/lib/server/workflow/stream.ts +++ b/apps/web/src/lib/server/workflow/stream.ts @@ -3,7 +3,7 @@ import { createEventStream } from '../sse.js' import { WORKFLOW_POLL_INTERVAL_MS } from '../constants.js' import { getOpenWorkflowBackend } from './index.js' import { getWorkflowRun, getStepAttempts, type PublishPieceFailure } from '@luzzle/web.jobs' -import { parsePublishFailures } from './publish.js' +import { parsePublishFailures, publicationWorkflowNames } from './publish.js' const TERMINAL_STATES = new Set(['completed', 'failed', 'canceled']) @@ -124,7 +124,7 @@ async function pollOnce( result: job.result, errors: job.errors } - if (job.class === 'Publish' || job.class === 'PublishAudit') { + if (publicationWorkflowNames.includes(job.class)) { outcome.workflowName = job.class } if (failedPieces.length > 0) { diff --git a/apps/web/src/routes/(auth)/api/admin/publish/[id]/stream/+server.ts b/apps/web/src/routes/(auth)/api/admin/publish/[id]/stream/+server.ts index 2e81cb04..9bd10063 100644 --- a/apps/web/src/routes/(auth)/api/admin/publish/[id]/stream/+server.ts +++ b/apps/web/src/routes/(auth)/api/admin/publish/[id]/stream/+server.ts @@ -1,10 +1,11 @@ import type { RequestHandler } from './$types' import { streamJobProgress } from '$lib/server/workflow/stream.js' +import { publicationWorkflowNames } from '$lib/server/workflow/publish.js' export const GET: RequestHandler = ({ params, request, url }) => { const jobId = params.id if (!jobId) { return new Response('Invalid job ID', { status: 400 }) } - return streamJobProgress({ jobId, jobClass: ['Publish', 'PublishAudit'], request, url }) + return streamJobProgress({ jobId, jobClass: publicationWorkflowNames, request, url }) } diff --git a/apps/web/src/routes/(auth)/api/admin/publish/admission.test.ts b/apps/web/src/routes/(auth)/api/admin/publish/admission.test.ts index c9f70b35..55c67e27 100644 --- a/apps/web/src/routes/(auth)/api/admin/publish/admission.test.ts +++ b/apps/web/src/routes/(auth)/api/admin/publish/admission.test.ts @@ -11,6 +11,7 @@ import { } from '$lib/server/workflow/publish.fixture.js' import { POST as publish } from './+server.js' import { POST as audit } from './audit/+server.js' +import { POST as unpublish } from '../unpublish/+server.js' const { runWorkflow } = vi.hoisted(() => ({ runWorkflow: vi.fn() })) vi.mock('$lib/server/workflow/index.js', () => ({ @@ -27,20 +28,23 @@ const auditDiff = { } } -function event(body: unknown = {}) { +function event(body: unknown = { filePath: 'books/one.books.md' }) { return { request: new Request('http://localhost/api/admin/publish', { method: 'POST', body: JSON.stringify(body) }) - } as Parameters[0] & Parameters[0] + } as Parameters[0] & Parameters[0] & Parameters[0] } -describe('publish/audit admission', () => { +describe('publication admission', () => { let queue: WorkflowQueue - async function enqueue(spec: { name: string }, input: { bisync: boolean; filePaths?: string[] }) { - vi.setSystemTime(new Date('2026-06-21T00:00:00Z')) + async function enqueue( + spec: { name: string }, + input: { bisync?: boolean; filePaths?: string[]; filePath?: string } + ) { + vi.setSystemTime(new Date(Date.now() + 1000)) const workflowRun = await createPendingRun(queue.backend, spec.name, input) return { workflowRun } } @@ -62,7 +66,12 @@ describe('publish/audit admission', () => { { firstName: 'Publish', firstRoute: publish, otherName: 'Publish', otherRoute: publish }, { firstName: 'Publish', firstRoute: publish, otherName: 'PublishAudit', otherRoute: audit }, { firstName: 'PublishAudit', firstRoute: audit, otherName: 'Publish', otherRoute: publish }, - { firstName: 'PublishAudit', firstRoute: audit, otherName: 'PublishAudit', otherRoute: audit } + { firstName: 'PublishAudit', firstRoute: audit, otherName: 'PublishAudit', otherRoute: audit }, + { firstName: 'Publish', firstRoute: publish, otherName: 'Unpublish', otherRoute: unpublish }, + { firstName: 'PublishAudit', firstRoute: audit, otherName: 'Unpublish', otherRoute: unpublish }, + { firstName: 'Unpublish', firstRoute: unpublish, otherName: 'Publish', otherRoute: publish }, + { firstName: 'Unpublish', firstRoute: unpublish, otherName: 'PublishAudit', otherRoute: audit }, + { firstName: 'Unpublish', firstRoute: unpublish, otherName: 'Unpublish', otherRoute: unpublish } ])( 'admits only one overlapping $firstName/$otherName request', async ({ firstRoute, otherRoute }) => { @@ -182,7 +191,7 @@ describe('publish/audit admission', () => { expect(runWorkflow).toHaveBeenCalledTimes(1) }) - test.each([publish, audit])( + test.each([publish, audit, unpublish])( 'uses persisted active runs even without a local submission', async (route) => { const active = await createPendingRun(queue.backend, 'Publish') @@ -229,6 +238,25 @@ describe('publish/audit admission', () => { expect(runWorkflow).toHaveBeenCalledTimes(1) }) + test('an Unpublish request consumes the old audit and a fresh Check permits publishing', async () => { + const oldAudit = await createCompletedRun(queue.backend, 'PublishAudit') + const accepted = await unpublish(event({ filePath: 'books/one.books.md' })) + expect(accepted.status).toBe(200) + await completeNextRun(queue.backend) + + const rejected = await publish(event({ auditRunId: oldAudit.id })) + expect(rejected.status).toBe(412) + expect(await rejected.json()).toEqual({ + message: 'an unpublish was requested after this check; re-check before publishing' + }) + expect(runWorkflow).toHaveBeenCalledTimes(1) + + vi.setSystemTime(new Date('2026-06-22T00:00:00Z')) + const freshAudit = await createCompletedRun(queue.backend, 'PublishAudit') + expect((await publish(event({ auditRunId: freshAudit.id }))).status).toBe(200) + expect(runWorkflow).toHaveBeenCalledTimes(2) + }) + test.each([false, true])( 'persists omitted selection as publish-all with bisync=%s', async (bisync) => { diff --git a/apps/web/src/routes/(auth)/api/admin/unpublish/+server.ts b/apps/web/src/routes/(auth)/api/admin/unpublish/+server.ts new file mode 100644 index 00000000..d66638d6 --- /dev/null +++ b/apps/web/src/routes/(auth)/api/admin/unpublish/+server.ts @@ -0,0 +1,29 @@ +import { json } from '@sveltejs/kit' +import type { RequestHandler } from './$types' +import { unpublishSpec } from '@luzzle/web.jobs/specs' +import { getOpenWorkflow, getOpenWorkflowBackend } from '$lib/server/workflow/index.js' +import { findInFlightPublishRun, withPublishAdmission } from '$lib/server/workflow/publish.js' + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json().catch(() => null) + const filePath = body?.filePath + if (typeof filePath !== 'string') { + return json({ message: 'filePath must be a nonempty string' }, { status: 400 }) + } + if (filePath.trim().length === 0) { + return json({ message: 'filePath must be a nonempty string' }, { status: 400 }) + } + + return await withPublishAdmission(async () => { + const inFlight = await findInFlightPublishRun(getOpenWorkflowBackend()) + if (inFlight) return json({ jobId: inFlight.id }, { status: 409 }) + + const handle = await getOpenWorkflow().runWorkflow(unpublishSpec, { filePath }) + return json({ jobId: handle.workflowRun.id }) + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return new Response(`Internal server error: ${message}`, { status: 500 }) + } +} diff --git a/apps/web/src/routes/(auth)/api/admin/unpublish/server.test.ts b/apps/web/src/routes/(auth)/api/admin/unpublish/server.test.ts new file mode 100644 index 00000000..19d0e0cf --- /dev/null +++ b/apps/web/src/routes/(auth)/api/admin/unpublish/server.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { POST } from './+server.js' + +const mocks = vi.hoisted(() => ({ + runWorkflow: vi.fn(), + listWorkflowRuns: vi.fn() +})) +vi.mock('$lib/server/workflow/index.js', () => ({ + getOpenWorkflow: () => ({ runWorkflow: mocks.runWorkflow }), + getOpenWorkflowBackend: () => ({ listWorkflowRuns: mocks.listWorkflowRuns }) +})) + +beforeEach(() => { + vi.resetAllMocks() + mocks.listWorkflowRuns.mockResolvedValue({ data: [] }) + mocks.runWorkflow.mockResolvedValue({ workflowRun: { id: 'unpublish-1' } }) +}) + +function event(body: unknown) { + return { + request: new Request('http://localhost/api/admin/unpublish', { + method: 'POST', + body: JSON.stringify(body) + }) + } as Parameters[0] +} + +test('queues exactly one file without decoding it or accepting publish options', async () => { + const filePath = 'books/源 #?%.books.md' + const response = await POST(event({ filePath, bisync: true, filePaths: ['other.books.md'] })) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ jobId: 'unpublish-1' }) + expect(mocks.runWorkflow).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: 'Unpublish' }), + { filePath } + ) +}) + +test.each([ + { body: null }, + { body: {} }, + { body: { filePath: ['books/one.books.md'] } }, + { body: { filePath: '' } }, + { body: { filePath: ' ' } } +])('rejects invalid input before admission: $body', async ({ body }) => { + const response = await POST(event(body)) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ message: 'filePath must be a nonempty string' }) + expect(mocks.listWorkflowRuns).not.toHaveBeenCalled() + expect(mocks.runWorkflow).not.toHaveBeenCalled() +}) + +test('rejects malformed JSON without queueing work', async () => { + const request = new Request('http://localhost/api/admin/unpublish', { + method: 'POST', + body: '{' + }) + const response = await POST({ request } as Parameters[0]) + expect(response.status).toBe(400) + expect(mocks.runWorkflow).not.toHaveBeenCalled() +}) + +test.each(['Publish', 'PublishAudit', 'Unpublish'])( + 'does not enqueue when a %s operation is active', + async (name) => { + mocks.listWorkflowRuns.mockImplementation( + async ({ workflowName }: { workflowName: string }) => { + if (workflowName === name) return { data: [{ id: 'busy-1', status: 'running' }] } + return { data: [] } + } + ) + const response = await POST(event({ filePath: 'books/one.books.md' })) + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ jobId: 'busy-1' }) + expect(mocks.runWorkflow).not.toHaveBeenCalled() + } +) + +test('reports enqueue errors and releases admission for a later request', async () => { + mocks.runWorkflow.mockRejectedValueOnce(new Error('queue unavailable')) + const failed = await POST(event({ filePath: 'books/one.books.md' })) + expect(failed.status).toBe(500) + expect(await failed.text()).toContain('queue unavailable') + const accepted = await POST(event({ filePath: 'books/one.books.md' })) + expect(accepted.status).toBe(200) + expect(mocks.runWorkflow).toHaveBeenCalledTimes(2) +}) diff --git a/apps/web/src/routes/auth-routing.test.ts b/apps/web/src/routes/auth-routing.test.ts index 033fd672..47a01bec 100644 --- a/apps/web/src/routes/auth-routing.test.ts +++ b/apps/web/src/routes/auth-routing.test.ts @@ -46,6 +46,7 @@ test.each([ '/admin/piece/[...path]/preview/[jobId]', '/admin/open/[piece]/[slug]', '/api/admin/generate', + '/api/admin/unpublish', '/api/admin/publish/[id]/stream', '/api/admin/preview/[id]/stream' ])('%s has an auth-grouped route ID without changing its public path', (path) => {