Skip to content

Commit 7d92a75

Browse files
HMarzbanclaude
andcommitted
refactor(hocuspocus): tighten ghost delete, DLQ summary, push gateway and document id schema
Apply the review findings for the server admin and schema code. - thermo F1, naming N1: rename assertDeletableGhost to ghostDeleteRefusal. An Auth read error other than 404 now throws. The single delete answers 500, and bulk delete counts the id as failed. A 404 is still a 409 'User not found'. The JSDoc line 'A read fault throws' is now true. - thermo F2, dry F3: summarizeDlqJob reads only j.data.failureReason. Every DLQ writer sets it, so the j.failedReason fallback was dead. - dry F2, thermo F5, naming N6: getPushGatewayAdmin is one line. getHealth cannot reject, so the 500 branch was dead. - thermo F3, naming N3: move documentIdField into document.schema.ts. The media schema and hocuspocus.server.ts import it from there. The media schema re-exports it for the OpenAPI documents path for now. Skipped: dry F5 conflicts with F1. N2 and N4 need files outside this lane. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 0fc3e7d commit 7d92a75

6 files changed

Lines changed: 19 additions & 22 deletions

File tree

‎apps/hocuspocus.server/src/api/controllers/admin-audit.controller.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ const summarizeDlqJob = (j: Job) => ({
242242
failureKind: j.data.failureKind,
243243
failureCode: j.data.failureCode,
244244
to: [j.data.payload?.to].flat().filter(Boolean).map(maskEmail),
245-
failedReason: j.data.failureReason ?? j.failedReason
245+
failedReason: j.data.failureReason
246246
})
247247

248248
export async function getDeadLetterQueueContents(c: AppContext) {

‎apps/hocuspocus.server/src/api/controllers/admin-stats.controller.ts‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -207,15 +207,8 @@ export async function getPushStatsAdmin(c: AppContext) {
207207
}
208208
}
209209

210-
/** Always 200 with the status, so the dashboard reads the VAPID and queue flags from the body. */
211-
export async function getPushGatewayAdmin(c: AppContext) {
212-
try {
213-
return c.json(await pushGateway.getHealth())
214-
} catch (error) {
215-
adminLogger.error({ err: error }, 'Failed to get push gateway status')
216-
return c.json({ error: 'Failed to fetch push gateway status' }, 500)
217-
}
218-
}
210+
/** Answers 200 even when push is down, so the dashboard reads the VAPID and queue flags from the body. */
211+
export const getPushGatewayAdmin = async (c: AppContext) => c.json(await pushGateway.getHealth())
219212

220213
export async function getPushPipelineAdmin(c: AppContext) {
221214
const supabase = getSupabaseClient()

‎apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ function classifyGhost(user: AuthUser, minAgeDays: number): GhostType | null {
9898
* who may have signed in since. Refuse admins: a hard delete cascades past the
9999
* last-admin guard. Returns the refusal reason, or null. A read fault throws.
100100
*/
101-
async function assertDeletableGhost(client: AdminClient, userId: string): Promise<string | null> {
101+
async function ghostDeleteRefusal(client: AdminClient, userId: string): Promise<string | null> {
102102
const { data, error } = await client.auth.admin.getUserById(userId)
103+
if (error && error.status !== 404) throw error
103104
if (error || !data.user) return 'User not found'
104105
if (!classifyGhost(data.user as AuthUser, 0)) return 'User is no longer a ghost account'
105106

@@ -347,7 +348,7 @@ export async function deleteGhostAccount(
347348
): Promise<DeleteGhostResult> {
348349
// Prisma compares ownerId as text, so an uppercase id would count no documents.
349350
const userId = rawUserId.toLowerCase()
350-
const refusal = await assertDeletableGhost(client, userId)
351+
const refusal = await ghostDeleteRefusal(client, userId)
351352
if (refusal) {
352353
invalidateGhostCaches()
353354
return { status: 'refused', reason: refusal }
@@ -406,7 +407,7 @@ export async function bulkDeleteGhostAccounts(
406407
const checks = await Promise.all(
407408
userIds.map(async (userId: string) => {
408409
try {
409-
const refusal = await assertDeletableGhost(client, userId)
410+
const refusal = await ghostDeleteRefusal(client, userId)
410411
if (refusal) return { userId, error: refusal }
411412
const read = await getGhostDeletionImpact(client, userId)
412413
if ('error' in read) return { userId, error: read.error }

‎apps/hocuspocus.server/src/hocuspocus.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import * as documentContent from './modules/document-content'
4949
import type { RevertOutcome, VersionFailureReason, VersionOps } from './modules/document-versions'
5050
import * as documentVersions from './modules/document-versions'
5151
import { MAX_VERSION_NUMBER } from './modules/document-versions/types'
52-
import { documentIdField } from './schemas/hypermultimedia.schema'
52+
import { documentIdField } from './schemas/document.schema'
5353
import type { HistoryPayload } from './types/document.types'
5454
import type { StoreDocumentContext } from './types/queue.types'
5555

‎apps/hocuspocus.server/src/schemas/document.schema.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import { z } from 'zod'
33
// Deep path on purpose: the module's `http/schema` is zod-only, so importing it
44
// here never drags Tiptap or the transformer into this file's consumers.
55
import { tiptapDocSchema } from '../modules/document-content/http/schema'
6-
import { documentIdField } from './hypermultimedia.schema'
6+
7+
// Strict charset blocks path-separator / traversal sequences before the id
8+
// ever reaches the filesystem (defense-in-depth with the storage-root check).
9+
export const documentIdField = z
10+
.string()
11+
.min(1, 'Document ID is required')
12+
.max(100)
13+
.regex(/^[A-Za-z0-9_-]+$/, 'Invalid document ID')
714

815
const plainTitle = (title: string): string => title.replace(/<[^>]*>/g, '')
916

‎apps/hocuspocus.server/src/schemas/hypermultimedia.schema.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import { z } from 'zod'
22

3-
// Strict charset blocks path-separator / traversal sequences before the id
4-
// ever reaches the filesystem (defense-in-depth with the storage-root check).
5-
export const documentIdField = z
6-
.string()
7-
.min(1, 'Document ID is required')
8-
.max(100)
9-
.regex(/^[A-Za-z0-9_-]+$/, 'Invalid document ID')
3+
import { documentIdField } from './document.schema'
4+
5+
export { documentIdField }
106

117
export const documentIdParamSchema = z.object({
128
documentId: documentIdField

0 commit comments

Comments
 (0)