fix: accept digit-free opaque ids and map 404/409 to CliError - #66
Conversation
`looksLikeOpaqueCommsId` now checks that a bare token base58-decodes to 16 bytes, the server's own rule, instead of requiring a `Cb` prefix. About 3% of ids carry no digit and so miss `looksLikeRawId`; decoding is also what keeps a long single-word channel name a name. The wrapped client now maps a 404 to NOT_FOUND, the malformed-id 409 (error_code 217) to INVALID_REF, and any other 409 to a new CONFLICT code carrying the server's error_string, instead of rethrowing the raw CommsRequestError with its stack trace. Closes #65 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@doistbot /review |
doistbot
left a comment
There was a problem hiding this comment.
Replaces the Cb-prefix opaque-ID heuristic with a base58-decodes-to-16-bytes check (the server's own rule) and extends the wrapped client's error mapping from 401/403 to also translate 404 and 409 responses into typed CliErrors. Well-tested and fixes all five scenarios from #65.
Few things worth tightening:
- The decode-based check now misclassifies 21–22 character base58-only channel names (e.g.
EngineeringDiscussion) as IDs in direct-channel paths, sotdc channel update/delete/archiveskips name lookup — consider restricting the heuristic to resolvers without a name fallback or having the channel path disambiguate via the API. - Translating all 404s to
NOT_FOUNDbeforeresolveGroupRefsees them bypasses its existing catch, so missing group ids surface as genericNOT_FOUNDinstead ofGROUP_NOT_FOUNDwith thetdc groupshint — re-wrap there or drop the now-dead path. isMalformedIdrelies on prose matching rather than the stableerror_code(217) for identifying the server's malformed-id 409.- Small dedup opportunities: the
error_stringextractor duplicates logic inisInsufficientScope, and the base58 alphabet appears twice inrefs.ts.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (5)
src/lib/errors.ts:143:
isMalformedIdkeys off the free-formerror_stringinstead of the stableerror_code217 that identifies this server response. If the message is reworded or absent, a malformed id 409 falls through toCONFLICT; conversely, an unrelated 409 whose message happens to contain "must decode to" would be misclassified asINVALID_REF. Matcherror_code === 217(or include it) rather than prose.src/lib/errors.ts:131:
getCommsErrorStringduplicates theresponseData.error_stringextraction already inlined inisInsufficientScope(lines 89–97). Reuse the new helper there, e.g.return hasCommsStatusCode(error, 403) && (getCommsErrorString(error)?.includes('Insufficient scope') ?? false), so the shape checks have a single source of truth.src/lib/refs.ts:83: The base58 alphabet is now specified twice and must be kept in sync by hand: the
BASE58_ALPHABETconstant and the/^[1-9A-HJ-NP-Za-km-z]{21,22}$/character class. Reuse the constant for validation instead — e.g. checkref.lengthis 21–22, then return false inside the loop whenBASE58_ALPHABET.indexOf(char) === -1.src/lib/refs.test.ts:379: This assertion doesn't exercise the base58-alphabet rejection the comment describes:
ProductOperationsIOleadis 23 characters, so the{21,22}length check already rejects it before any character test. Use a 21/22-character name containingO/I/l/0(or adjust the comment) to actually pin down that behavior.src/lib/refs.ts:83: The
{21,22}length cap contradicts the pure-decoding rule the comment describes: a 16-byte id whose first byte is 0 base58-encodes to 23 characters (one leading1plus 21–22 body chars), and such an id (~1/256 of random ids) would be rejected here and misrouted to name lookup. Widen the quantifier to{21,23}— the byte-length check below already rejects anything that doesn't decode to exactly 16 bytes.
A 21-character base58 name like `EngineeringDiscussion` decodes to 16 bytes, so the direct-channel path was skipping name lookup for it. `getDirectChannelId` no longer uses the decode check; `resolveChannelRef` tries `getChannel` only when nothing matches by name, and a 404 or a 409 from that probe keeps the original CHANNEL_NOT_FOUND. `resolveGroupRef` re-wraps the client's NOT_FOUND as GROUP_NOT_FOUND so the `tdc groups` hint survives. `isMalformedId` keys on error_code 217 rather than prose (the server sends the same code with two messages), `isInsufficientScope` reuses the shared body reader, and the base58 validator walks the alphabet constant instead of a second character class. Tests pin both length extremes of a 16-byte id at 22 characters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixed in 92e7772. Keys on
Done.
Good catch. Replaced with a 21-character name containing a capital
Keeping as-is. 16 × |
|
@doistbot /review |
doistbot
left a comment
There was a problem hiding this comment.
This PR fixes the digit-free opaque-id rejection from #65 by switching to a base58-decode check and mapping 404/409 responses to proper CliErrors, with the channel path keeping names-first resolution. One regression is worth addressing before merge:
Few things worth tightening:
- The removal of the opaque-id fallback from
getDirectChannelIdbreaksresolveChannelId(used bytdc search --channel), which has no name fallback — a bare digit-free channel id likeCbjxNkWHJBwcaVkoTCRgMthat resolved before now throwsINVALID_REF. Consider restoring the fallback in this path or routing search channels throughresolveChannelRef, which already has the workspace id.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (4)
src/lib/refs.test.ts:1068: The 17-byte case doesn't exercise what the comment claims:
base58([0x01, ...16 × 0xFF])is 2^129 − 1, which exceeds 58^22 (≈ 2^128.87), so it base58-encodes to 23 characters and the 21–22 length check rejects it before the byte-length check runs. To pin the decode check at the same length, use a 17-byte value below 58^22 — e.g.base58([0x01, ...Array(16).fill(0x00)])(2^128, exactly 22 characters), which only the byte-length check can reject.src/lib/refs.test.ts:1043: The
base58test helper re-declares the base58 alphabet that already lives inrefs.tsasBASE58_ALPHABET, so the encoder can drift from the decoder it is meant to mirror. ExportBASE58_ALPHABETfromrefs.ts(likelooksLikeOpaqueCommsIdalready is) and reference it here, keeping a single source of truth for the alphabet.src/lib/api.ts:237:
getCommsErrorStringis typedstring | nullandisMalformedIdonly checkserror_code === 217, so this template rendersComms rejected the id: nullif a 217 response ever lackserror_string. TheCONFLICTbranch just below already guards this with?? '409 Conflict'; give this branch the same fallback for consistency.src/lib/refs.test.ts:379:
CustomerSuccessLeadershipis 25 characters, so it can't exercise the decode-to-16-bytes path this test documents —looksLikeOpaqueCommsIdbails on the length check before decoding. SincegetDirectChannelIdnow returnsnullfor every bare digit-free token, this line only restates the existing'Engineering'name case and adds nothing beside the 21-characterEngineeringDiscussioncase. Drop it, or replace it with another 21/22-character token that actually decodes to 16 bytes.
`resolveChannelId` (search --channel, thread create) has no name fallback, so it applies the decode check itself like the thread and conversation resolvers. `getDirectChannelId` stays name-safe for the mutation path. Also: a 217 without an error_string gets a readable message, the test encoder reuses the exported alphabet, and the 17-byte case uses 2^128, which is 22 characters and so is rejected by decoding rather than length. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixed in 0d1aca0. Now
Done.
Fixed. Falls back to "it does not decode to a Comms id (409)", with a test for the missing-string case.
Done. |
|
@doistbot /review |
doistbot
left a comment
There was a problem hiding this comment.
This PR fixes opaque-id detection in refs.ts to use the server's own base58-decodes-to-16-bytes rule instead of requiring a Cb prefix, so digit-free ids with other prefixes resolve correctly, while keeping names-first resolution on the channel path and the workspace check intact. It also maps 404 and 409 responses (including the malformed-id 409) to typed CliErrors with a new CONFLICT code, and re-wraps group lookups to preserve the existing hints. No inline issues were flagged; the changes align with the repo's error-handling and resolver conventions, the workspace/tenant guardrails are preserved on the new channel fallback, and the base58 implementation doesn't duplicate any existing dependency.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (4)
src/lib/refs.ts:273: The new
isChannelNotFoundand the inlineinstanceof CliError && error.code …checks inresolveChannelRef(L348) andresolveGroupRef(L542) are three copies of the same predicate. Extract one shared helper, e.g.isCliErrorCode(error, ...codes)inerrors.tsnext toisNotFound/isConflict, and reuse it:isChannelNotFound(error)becomesisCliErrorCode(error, 'CHANNEL_NOT_FOUND'), and the two catch branches read from the same predicate.src/lib/refs.ts:369:
getDirectChannelId(ref)on line 364 already callsparseRef(ref)internally, so this secondparseRef(ref)re-runs the trim/regex work for every name-typed ref — exactly the new digit-free-id path this line handles. Parse once and reuse the result (e.g. havegetDirectChannelIdaccept an already-parsedParsedRef, or inline the id/url handling here) to avoid the duplicate work.src/lib/refs.test.ts:604: These cases assert only the final
CHANNEL_NOT_FOUND, never thatmockGetChannelwas called. SinceresolveChannelRefalready throwsCHANNEL_NOT_FOUNDfrommatchByNamewhen the name lists are empty, the test would still pass if the id fallback (and itsNOT_FOUND/INVALID_REF→CHANNEL_NOT_FOUNDmapping) were removed entirely. Addexpect(mockGetChannel).toHaveBeenCalledWith('EngineeringDiscussion')so the test actually pins the fallback path it describes.src/lib/refs.test.ts:893: The
error.code !== 'NOT_FOUND'rethrow branch is not pinned by a test. A non-NOT_FOUNDCliError(e.g.FORBIDDENorINVALID_TOKENfrom a scoped token) should pass through unchanged, but the existing workspace-mismatch test throwsGROUP_NOT_FOUNDinside thetry, so it would still pass even if the catch were simplified to re-wrap everyCliError. Add a companion test that rejectsgetGroupwithnew CliError('FORBIDDEN', ...)and asserts that code is preserved, mirroringresolveChannelRef's "lets any other id-fallback failure through" case.
`isCliErrorCode` replaces three inline `instanceof CliError && code` checks. The channel-fallback tests now assert `getChannel` was called, so they fail if the fallback is removed, and the group catch's passthrough of a non-NOT_FOUND CliError has its own test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Done in aade3f4.
Keeping as-is. It is a trim and two regexes on a short string, once per command; changing
Good catch. Added the
Done. |
scottlovegrove
left a comment
There was a problem hiding this comment.
I wonder if some of these helper functions should have gone in the SDK instead, this way the MCP can also make use of them for checking IDs.
|
🎉 This PR is included in version 3.4.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Yes they should have, and they actually are in the SDK 🙈 The SDK one has an additional check this one missed, the v7 version nibble. So There are a few error helpers that don't exist in the SDK though ( I've opened a draft PR that removes some of this code (#67) and I can open another one against the SDK. Let me know if you have any preferences for how the helpers should be added, otherwise I'll read the pattern there and replicate it. |
Closes #65.
Short description
looksLikeOpaqueCommsIdinrefs.tsused to require aCbprefix, so a bare id with no digit and a different prefix (about 3% of ids) fell through to the name branch and was rejected. It now checks that the token base58-decodes to 16 bytes, which is the server's own rule. The check is only used by the resolvers that have no name fallback (thread, comment, conversation, message). A 21-character base58 name likeEngineeringDiscussionalso decodes to 16 bytes, so the channel path keeps names first:getDirectChannelIdnever treats a bare digit-free token as an id, andresolveChannelReftriesgetChannelonly when nothing matches by name, keepingCHANNEL_NOT_FOUNDif that probe 404s or 409s. One consequence: a bare digit-free channel id now needs a workspace (default or--workspace) like a name does, and is checked against it;id:<id>and URL forms stay workspace-agnostic. The id-onlyresolveChannelId(search --channel,thread create) applies the decode check directly, since it has no name to protect.The wrapped client in
api.tsonly mapped 403 and 401. It now maps a 404 toNOT_FOUND, the malformed-id 409 (error_code217, which the server sends with two different messages) toINVALID_REF, and any other 409 to a newCONFLICTcode carrying the server'serror_string. 400 and 5xx still pass through unchanged.resolveGroupRefre-wraps the newNOT_FOUNDasGROUP_NOT_FOUNDso itstdc groupshint survives.Test plan
Against
main, each of these prints the failure from #65. Against this branch:tdc conversation done CDMDzXhBNCgyQZjkDnqwG --dry-run[dry-run] Would archive conversation, noINVALID_REFtdc thread view id:nopeError: INVALID_REFwith the server's "must decode to 16 bytes" message, no stack tracetdc thread view <any-thread-id> --comment id:nopetdc conversation done CDMDzXhBNCgyQZjkDnqw1 --dry-run(well-formed id that doesn't exist)Error: NOT_FOUNDwith the "check the id" hinttdc thread view id:nope --json{"error":{"code":"INVALID_REF",...}}rather thanINTERNAL_ERRORtdc channel threads EngineeringDiscussion(21-character base58 name, no such channel)Error: CHANNEL_NOT_FOUND, not an id errortdc groups view id:CDMDzXhBNCgyQZjkDnqw1Error: GROUP_NOT_FOUNDwith thetdc groupshinttdc search x --channel CbjxNkWHJBwcaVkoTCRgM(any bare digit-free channel id)INVALID_REFControls: a thread id with a digit still resolves bare, and a 500 still passes through untranslated (existing test).
🤖 Generated with Claude Code