feat(cli+broker): structured argument validation, msg-status prefixes (v1.9.3)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds apps/cli/src/cli/validators.ts — a small module of shape
validators (pubkey, pubkey prefix, message id, mesh slug) that return
discriminated results so callers can distinguish "shape is wrong"
(INVALID_ARGS exit) from "value is well-shaped, lookup failed"
(NOT_FOUND exit). Includes renderValidationError() for a consistent
three-tier error contract: what's wrong, what would be valid, closest
valid alternative.

First adopter is `claudemesh msg-status`:
- Validates id locally before opening WS — typos return immediately.
- Accepts 8-32 char prefixes (full ids are 32). Pastes that get
  copy-truncated by the terminal still work.
- Distinct error messages for malformed input vs not-in-queue vs
  ambiguous prefix; --json emits the structured shape.

Broker side: WS message_status handler validates idStr is 8-32
base62 before querying. Prefix lookups use LIKE 'prefix%' scoped to
the caller's mesh (no cross-mesh leak). Returns ambiguous_prefix
when more than one match.

Establishes the canonical pattern; rolling out to send / grant /
revoke / topic post --reply-to in subsequent patches.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 22:40:45 +01:00
parent 82ee89d0dc
commit 80755dbf9b
4 changed files with 300 additions and 17 deletions

View File

@@ -2906,17 +2906,66 @@ function handleConnection(ws: WebSocket): void {
}
case "message_status": {
const ms = msg as Extract<WSClientMessage, { type: "message_status" }>;
// Look up the message in the queue.
const [mqRow] = await db
.select({
id: messageQueue.id,
targetSpec: messageQueue.targetSpec,
deliveredAt: messageQueue.deliveredAt,
meshId: messageQueue.meshId,
})
.from(messageQueue)
.where(eq(messageQueue.id, ms.messageId));
if (!mqRow || mqRow.meshId !== conn.meshId) {
// Validate id shape — guards against the broker running a wide
// LIKE scan for empty/malformed input. ≥8 base62 chars, ≤32.
const idStr = String(ms.messageId ?? "");
if (idStr.length < 8 || idStr.length > 32 || !/^[A-Za-z0-9]+$/.test(idStr)) {
sendError(
conn.ws,
"invalid_argument",
`messageId must be 8-32 base62 chars (got ${idStr.length})`,
undefined,
_reqId,
);
break;
}
// Look up the message in the queue. Accept a prefix when the
// caller passed <32 chars (full ids are 32) — convenient for
// pasting from a copy-truncated terminal. Mesh scope is
// enforced via the meshId WHERE clause so a prefix can't
// leak across meshes.
const isPrefix = idStr.length < 32;
const mqRows = isPrefix
? await db
.select({
id: messageQueue.id,
targetSpec: messageQueue.targetSpec,
deliveredAt: messageQueue.deliveredAt,
meshId: messageQueue.meshId,
})
.from(messageQueue)
.where(
and(
eq(messageQueue.meshId, conn.meshId),
sql`${messageQueue.id} LIKE ${idStr + "%"}`,
),
)
.limit(2)
: await db
.select({
id: messageQueue.id,
targetSpec: messageQueue.targetSpec,
deliveredAt: messageQueue.deliveredAt,
meshId: messageQueue.meshId,
})
.from(messageQueue)
.where(eq(messageQueue.id, idStr));
if (mqRows.length === 0) {
sendError(conn.ws, "not_found", "message not found", undefined, _reqId);
break;
}
if (mqRows.length > 1) {
sendError(
conn.ws,
"ambiguous_prefix",
`prefix matched ${mqRows.length} messages — use a longer id`,
undefined,
_reqId,
);
break;
}
const mqRow = mqRows[0]!;
if (mqRow.meshId !== conn.meshId) {
sendError(conn.ws, "not_found", "message not found", undefined, _reqId);
break;
}