Skip to content

The package door's 4xx paths still ship raw driver text — a returned failure never meets the 5xx withhold, and is mislabelled a client error #8131

Description

@hotlong

Found while implementing #8086 (the 5xx message withhold on the same door, PR #8130). Recorded rather than acted on: #8086's ruling scoped the fix to 5xx messages only, and these are 4xx, so the sanitizer landing there deliberately does not touch them. Filed unassigned; nobody is on it.

The gap

#8086 closed the disclosure on sendThrownError — the exit a thrown error leaves by. Two other paths in the same registrar report failure by returning rather than throwing, so neither resolveThrownHttpError (#8016) nor the new withhold (#8086) ever sees them. Both answer 400 and both carry raw driver text.

1. POST /api/v1/packages/publish

packageService.publish swallows the driver error and hands the message back as data (packages/services/service-package/src/index.ts):

}catch(error){logger.error('Failed to publish package',errorasError);return{success: false,error: (errorasError).message};}

and the handler puts it straight on the wire (packages/rest/src/package-routes.ts):

sendError(res,400,'PACKAGE_PUBLISH_FAILED',result.error??`Failed to publish ${manifest.id}.`);

The statement being run is INSERT INTO sys_packages (...) ON CONFLICT(id, version) DO UPDATE SET ..., so a driver failure there produces exactly the family of text #3867's predicate exists to withhold — a constraint dump naming sys_packages and its columns, or a SQLITE_ERROR / SQLSTATE line.

Two defects, not one. The disclosure is the obvious half. The other is that a driver fault is being reported as 400 — a client error, inviting the caller to fix a request that was never the problem, and hiding a real server fault from every dashboard that buckets by status. This is precisely the class #8016 fixed for the throw path (a caller who was refused was told the platform had broken), in mirror image: here the platform breaks and the caller is told they made a mistake.

2. DELETE /api/v1/packages/:id — the partial-failure detail

protocol.deletePackage collects per-item failures with their raw messages:

}catch(e: any){failed.push({type: row.type,name: row.name,error: e?.message??'delete failed', ... });}

and the handler ships that array as structured detail on a 400:

sendError(res,400,'PACKAGE_DELETE_PARTIAL',`Deleting ${packageId} left ${result.failedCount} item(s) behind.`,{details: {failed: result.failed,cleanups: result.cleanups}});

Each failed[].error is whatever deleteMetaItem threw — including its Failed to delete customization overlay: ${err.message} re-wrap, which interpolates the driver line. Note this leaks through details, not message, so even a message-level withhold at this door would not reach it.

Why this is not just "extend #8086 to 4xx"

It must not be. A 4xx message is caller-facing by design and #8086's PR pins that on purpose — the protocol's [tenant_scope_required] refusal names the exact parameter to pass, a 409 DESTRUCTIVE_CHANGE names the remedy. Blanket-sanitizing 4xx would destroy the self-correcting messages #4277 exists for.

The honest fixes are the other two:

Evidence

Read from code, with the reachability of the sibling 5xx path measured. The #8086 reproduction drove a throwingpublish through this handler (confirming the catch-all exit), and separately walked a real ObjectQL engine + real ObjectStackProtocolImplementation failure through DELETE, capturing the driver line on the wire. What was NOT driven end to end is the real service-packagepublish catch above — its return-shaped path is read from source, not reproduced. Whoever takes this should force an INSERT INTO sys_packages failure and record the actual 400 body before fixing, the same way #8086 was made to earn its premise.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions