Skip to content

Weekly review batch: D1 bind-cap crash, missing error toasts, SDK bugs - #57

Open
DennisAlund wants to merge 6 commits into
mainfrom
claude/adoring-dirac-9vzf6v
Open

Weekly review batch: D1 bind-cap crash, missing error toasts, SDK bugs#57
DennisAlund wants to merge 6 commits into
mainfrom
claude/adoring-dirac-9vzf6v

Conversation

@DennisAlund

Copy link
Copy Markdown
Member

Weekly defect-hunting review of the app, its API, and all three SDKs. Six focused passes (API/DB/services, admin UI, MCP/auth, TypeScript SDK, Python SDK, Dart SDK), each finding verified against the actual code and a failing-before/passing-after regression test before being fixed.

Fixes

Per-link analytics crash past D1's 100-bound-parameter cap (src/db/click-repository.ts)

  • Wrong: getStats, getTimeline, getLinkBreakdown, getLinkBreakdownPage, and compareLinkStats each bound one SQL parameter per slug on a link.
  • Impact: a link with more than ~99 custom slugs (nothing prevents adding that many) failed every one of its analytics queries with SQLITE_ERROR: too many SQL variables. Same class of bug 0.37.1 fixed for bundles via bundleSlugScope(), just missed for the per-link case.
  • Fix: added linkSlugScope(), the same subquery-based technique, binding one parameter (the link id) regardless of slug count.
  • Test: new describe block creates a link with 101 slugs and exercises all five methods; fails with the real D1 error before the fix, passes after.

Missing error toast on the app's two most common actions (src/client.ts)

  • Wrong: quickShorten, createLink, and createDuplicate parsed the failure body's error field with no .catch() fallback.
  • Impact: a non-JSON error response (edge-level 502/524 HTML page, bare 500) rejected the promise unhandled, so the button looked like it did nothing. 0.37.1 added this fallback to fourteen other handlers but missed these three.
  • Fix: added the same .catch() fallback used everywhere else. Also fixed a blind spot in the regression-guard test itself (it matched res.json().then( and toast( on the same source line, which is why these three, spread across multiple lines, slipped through) — replaced it with a paren-balanced scan.
  • Test: added to the existing per-handler empty-body/HTML-body/error-message test matrix; guard test now genuinely fails before the fix.

MCP add_link_to_bundle description claimed an ownership check it doesn't enforce (src/mcp/server.ts)

  • Wrong: description said "Only the bundle owner can add," but addLinkToBundle() is intentionally open to any authenticated caller (by design, per its own comment and existing test).
  • Impact: an agent trusting the description could add another user's link to a bundle it doesn't own while believing the call was caller-scoped, or reason incorrectly about the security model. Same class as the list_bundles fix in 0.37.0.
  • Fix: corrected the description text. No behavior change.
  • Test: new test mirroring the existing list_bundles description regression test.

TypeScript SDK: keysToSnake/keysToCamel silently dropped a __proto__ key (sdk/typescript/src/internal/case.ts)

  • Wrong: both built output on a plain {} and assigned via out[key] = value. When key is the literal string "__proto__", that reassigns the object's prototype instead of creating an own property.
  • Impact: a request body built from parsed/untrusted JSON containing a field named __proto__ (e.g. client.links.update(id, JSON.parse(patch))) silently lost that field with no error.
  • Fix: switched both to Object.create(null) targets.
  • Test: unit test on keysToSnake (the exploitable direction) plus a client-level integration test confirming the key survives onto the wire.

Python SDK: JSON null 2xx body crashed with a bare AttributeError (sdk/python/src/shrtnr/_base.py)

  • Wrong: a non-204 2xx response body of literal null is non-empty and valid JSON, so it passed both existing guards in parse_json_response and returned None.
  • Impact: every single-object resource method's SomeModel.from_dict(None) crashed with a bare AttributeError instead of the documented ShrtnrError — the same failure mode the 1.1.2 empty-body fix addressed, just reached from a non-empty body.
  • Fix: parse_json_response now raises ShrtnrError when the parsed body is None, scoped to that value only so list()'s legitimate array responses are unaffected.
  • Test: mirrors the existing empty-body regression test.

Doc-only corrections

  • Dart SDK README was missing BreakdownPage from its "Key types" list; TypeScript and Python's READMEs already had it. Parity fix.
  • getSparkline's doc comment said 1y buckets are "weekly"; the code (and its own test) have always used monthly, 12-point buckets.

Flagged for developer judgment (not fixed, posting as PR comments)

  • Python SDK: base_url is documented (CHANGELOG 1.0.0) as keyword-only but is actually positional-or-keyword — fixing would be a public API change.
  • TypeScript SDK: an old CHANGELOG entry (0.3.0/0.7.0) claims an X-Client: sdk header is sent on every request; it isn't sent anywhere in the current code and was apparently dropped in the 1.0.0 rewrite with no changelog note. Needs a product call on whether to reintroduce it or just correct the historical record.

Verification

  • yarn test: 1299 tests, all green.
  • yarn e2e: confirmed the 15 pre-existing failures in this sandbox reproduce identically on a clean main checkout (proxy-blocked external resources causing ERR_CONNECTION_RESET/cert errors) — unrelated to this diff.
  • TypeScript SDK: 81 tests, all green. Build verified.
  • Python SDK: 98 tests, all green. mypy/ruff clean on changed files.

Generated by Claude Code

claude added 6 commits August 28, 2026 19:42
getStats, getTimeline, getLinkBreakdown, getLinkBreakdownPage, and
compareLinkStats each fetched every slug on a link and bound the whole
list into a slug IN (?,?,...) clause. D1 caps a prepared statement at
100 bound parameters, so a link with more than ~99 custom slugs
(nothing prevents adding that many) failed every one of its analytics
queries with SQLITE_ERROR: too many SQL variables.

0.37.1 fixed the identical defect for bundle-scoped queries via
bundleSlugScope(), a subquery that resolves membership inside SQLite
and costs exactly one bound parameter regardless of member count. This
applies the same technique per link (linkSlugScope()) to the five
methods above, which had been missed in that pass.

Adds a describe block mirroring the existing bundle-cap regression
tests: a link with 101 slugs exercises all five methods and asserts
correct aggregation instead of a thrown SQLITE_ERROR.
quickShorten, createLink, and createDuplicate parsed the failure body's
error field with no .catch() fallback, so a non-JSON error response
(an edge-level 502/524 HTML page, a bare 500) rejected the promise
unhandled and left the button silent with nothing shown to the user.
0.37.1 added this same fallback to fourteen other handlers but missed
these three, the two most common actions in the app (shortening and
creating a link).

The regression-guard test that is supposed to catch a handler missing
this fallback had a blind spot: it matched res.json().then( and
toast( on the same source line, but these three handlers spread the
call across multiple lines in the project's usual style, so the guard
could never see both tokens together. Replaced it with a parser that
walks the balanced parentheses of the .then(...) call to find the
whole thing regardless of line wrapping, and narrowed it to blocks
that read a .error field so it doesn't also flag success-path
toasts. Added the three handlers to the existing per-handler
empty-body/HTML-body/error-message test matrix.
…oesn't enforce

The tool description said "Only the bundle owner can add," but
addLinkToBundle() deliberately skips the ownership gate every other
bundle-mutation function enforces — adding a link to a bundle is open
to any authenticated caller by design (see the comment in
bundle-management.ts and the existing 'any authenticated caller can
add a link to any bundle' test). An agent trusting the description
could believe the call is caller-scoped and add another user's link to
a bundle it doesn't own, or reason incorrectly about the security
model. Same class of doc/behavior mismatch as the list_bundles fix in
0.37.0.

Documentation correction only, no behavior change. Adds a regression
test mirroring the existing list_bundles description test.
Both functions built their output on a plain {} target and assigned
converted keys through bracket notation: out[key] = value. When key is
the literal string "__proto__", that assignment doesn't create an own
property on a plain object — it reassigns the object's prototype,
because {} inherits the __proto__ accessor from Object.prototype. A
request body assembled from parsed/untrusted JSON (which, unlike
object-literal syntax, can carry __proto__ as a real own enumerable
property) silently lost that key with no error, e.g.
client.links.update(id, JSON.parse(patch)).

Switched both to Object.create(null) targets, which have no such
accessor to intercept the assignment. Added a focused unit test for
keysToSnake (the exploitable direction: outbound request bodies) and a
client-level integration test showing a __proto__ key now survives
onto the wire. keysToCamel doesn't have a matching direct repro since
its camelCase transform never maps a source key to the literal string
"__proto__", but carries the same fix for symmetry and defense in
depth.
…hing

A non-204 2xx response whose body is the literal null (4 bytes) is
non-empty and valid JSON, so it passed both existing guards in
parse_json_response and returned None. Every single-object resource
method's SomeModel.from_dict(...) then crashed on it with a bare
AttributeError instead of the documented ShrtnrError — the same
failure mode the 1.1.2 empty-body fix exists to prevent, just reached
from a non-empty body that happens to parse to None.

parse_json_response now raises ShrtnrError when the parsed body is
None. Scoped to exactly that value (not every non-dict shape) so
list()'s legitimate JSON-array responses are unaffected. Adds a
regression test mirroring the existing empty-body test.
BreakdownPage is the return type of both LinksResource.breakdown and
BundlesResource.breakdown, shipped in 2.1.0 alongside the other two
SDKs, but the Dart README's Key types list never picked it up.
TypeScript and Python's READMEs already list BreakdownDimension,
BreakdownPage together; this brings Dart's list into parity.

Copy link
Copy Markdown
Member Author

Flag: Python SDK base_url isn't actually keyword-only

sdk/python/src/shrtnr/client.py — both Shrtnr.__init__ and AsyncShrtnr.__init__ declare base_url before the *:

def __init__(
    self,
    base_url: str,
    *,
    api_key: str,
    ...

CHANGELOG.md's 1.0.0 entry states: "The positional base_url argument is replaced by a keyword-only base_url parameter." As written, base_url is positional-or-keyword, so Shrtnr("https://s.example.com", api_key="sk_...") — the pre-1.0 call shape the changelog says was removed — still works today. Verified via git show that this has been the case since the 1.0.0 commit itself, so it isn't a regression, just a standing mismatch between the documented and actual signature.

Not fixing this directly: making base_url keyword-only now is a public API change (it would reject currently-working positional calls), which needs a product/semver call rather than a silent fix in a defect-hunting pass. Two ways to close the gap: enforce keyword-only (breaking, needs a major/minor bump per your SDK semver rules) or correct the CHANGELOG's historical claim (no code change). Your call.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Flag: TypeScript SDK's X-Client: sdk header is documented but no longer sent

sdk/typescript/CHANGELOG.md's 0.3.0/0.7.0 entries state: "X-Client: sdk request header sent on every request." Checked src/internal/http.ts (both request() and requestText()) and grepped the whole src/ tree and README — the header isn't sent anywhere in the current code, and no later changelog entry notes it being dropped. The exhaustive 1.0.0 "ground-up rewrite" breaking-changes list doesn't mention removing it either, so it looks like it was quietly lost in that rewrite rather than deliberately retired.

Not fixing this directly: if any server-side logic ever keyed off this header (rate-limit exemptions, SDK-vs-browser traffic segmentation), re-adding it is a behavior change that should land with an actual reason, not as an incidental defect-pass fix — and if it was intentionally dropped, the fix is just a changelog correction instead. Needs a call on which it is.


Generated by Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 28, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
shrtnr 1216f57 Aug 28 2026, 07:47 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants