Skip to content

fix: code review findings — QR size, URL normalization, enableLink, VALID_RANGES, SDK type/sentinel bugs#3

Merged
DennisAlund merged 17 commits into
mainfrom
claude/serene-lovelace-s63Xz
May 7, 2026
Merged

fix: code review findings — QR size, URL normalization, enableLink, VALID_RANGES, SDK type/sentinel bugs#3
DennisAlund merged 17 commits into
mainfrom
claude/serene-lovelace-s63Xz

Conversation

@DennisAlund
Copy link
Copy Markdown
Member

Findings and fixes

App / API

QR size param silently ignored (src/api/qr.ts)
The size query parameter was declared in the route schema but never read from the request or forwarded to renderQrSvg(). Every QR code was rendered at the hardcoded default (220 px) regardless of what the caller passed.

URL normalization missing in updateLink (src/services/link-management.ts)
createLink called normalizeUrl() before storage; updateLink did not. Trailing slashes and bare ? survived update round-trips, producing inconsistent stored URLs.

enableLink used wrong repository method (src/services/link-management.ts)
enableLink called LinkRepository.update(db, id, { expires_at: null }) instead of the purpose-built LinkRepository.enable(). This triggered an extra DB read and bypassed any enable-specific logic in the repository.

VALID_RANGES duplicated (src/api/analytics.ts, src/api/bundles.ts)
Both files hand-wrote the same range set that already exists as TIMELINE_RANGES in schemas.ts. Now derived from the canonical source.

TypeScript SDK

qr() size parameter typed as string (sdk/typescript/src/resources/links.ts)
The API schema declares size as an integer. The SDK accepted string, requiring callers to pre-stringify. Changed to number; the method converts internally before appending to the query string.

Python SDK

update() cannot clear nullable fields (sdk/python/src/shrtnr/resources/)
links.update() and bundles.update() used None as both the default and the explicit null signal for label, expires_at, description, and icon. Omitting a field was indistinguishable from clearing it. Introduced an UNSET singleton sentinel in _base.py; nullable params default to UNSET and are excluded from the request body unless explicitly provided.

Dart SDK

Same update() sentinel issue (sdk/dart/lib/src/resources/)
Applied the equivalent fix using a private const Object _unset = Object() sentinel with identical() checks.

Tests

Regression tests added for every fix:

  • src/__tests__/unit/qr.test.ts: renderQrSvg respects size, uses 220 default
  • src/__tests__/service/link-service.test.ts: updateLink strips trailing slash, strips bare ?, clears label with null
  • sdk/typescript/tests/client.test.ts: null-clearing for links.update (label, expiresAt) and bundles.update (description); size type correction
  • sdk/python/tests/test_client.py and test_async_client.py: sentinel behavior for both clients
  • sdk/dart/test/client_test.dart: null-clearing and omit-field assertions for links and bundles

https://claude.ai/code/session_019UcHThUMEX13UdmykzAeMS


Generated by Claude Code

claude added 4 commits May 1, 2026 19:18
…e() in enableLink, deduplicate VALID_RANGES

- qr.ts: size query param was parsed from the route schema but never read
  from the request or forwarded to renderQrSvg; now extracted and passed
- link-management: updateLink called normalizeUrl on create but not on
  update, so trailing slashes and bare question marks survived round-trips
- link-management: enableLink used LinkRepository.update({expires_at:null})
  instead of the purpose-built LinkRepository.enable(), adding a redundant
  DB read and bypassing enable-specific logic
- analytics.ts, bundles.ts: VALID_RANGES was a hand-written literal that
  could diverge from TIMELINE_RANGES in schemas.ts; now derived from it

Regression tests added for QR size forwarding and URL normalization in
updateLink (trailing slash, bare question mark, null label clear).

https://claude.ai/code/session_019UcHThUMEX13UdmykzAeMS
The API schema declares size as an integer. The SDK accepted string,
forcing callers to pre-stringify. Changed to number; the method now
converts to string internally before appending to the query string.

Existing test updated; null-clearing tests added for links.update
(label, expiresAt) and bundles.update (description).

https://claude.ai/code/session_019UcHThUMEX13UdmykzAeMS
links.update() and bundles.update() used None as both the default and
the explicit null signal for optional nullable fields (label, expires_at,
description, icon). A caller omitting label got the same wire payload as
one explicitly passing label=None, so fields could not be cleared without
always sending them.

Introduced UNSET sentinel in _base.py (singleton). Optional nullable
params default to UNSET; only keys explicitly provided by the caller are
included in the request body. Passing None sends the field as JSON null,
clearing the value on the server.

Sync and async resources updated. Regression tests added for both clients.

https://claude.ai/code/session_019UcHThUMEX13UdmykzAeMS
Same issue as the Python SDK: links.update() and bundles.update() could
not distinguish an omitted label/expiresAt from an explicit null. Used a
private const Object _unset = Object() sentinel (identical() check) so
omitted params are excluded from the body and null params serialize as
JSON null.

Regression tests added for null-clearing (label, description) and for
confirming omitted fields are absent from the request body.

https://claude.ai/code/session_019UcHThUMEX13UdmykzAeMS
Copilot AI review requested due to automatic review settings May 1, 2026 19:19
Copy link
Copy Markdown
Member Author

Items requiring developer judgment — not changed in this PR


validateSlugLength cap inconsistency

src/services/link-management.ts validates custom slugs against a max length of 128. The OpenAPI schema in schemas.ts caps slug at 16 characters (z.string().max(16)). A slug that passes the API layer (max 16) will always pass the service layer (max 128), so no data corruption is possible, but the service-level cap is dead code and will mislead anyone reading it. Recommend aligning both to the same value or removing the service-layer check and relying on the schema alone.


scope === null authorization semantics

Several repository methods (e.g. LinkRepository.listByOwner) treat scope === null as "all-access" (returns all rows regardless of owner). This is not documented and the condition is easy to misread as "no scope provided = error". If this is intentional (e.g. for internal admin calls), a comment stating the invariant would prevent future regressions. If it is not intentional, the paths that pass null scope should be audited.


Generated by Claude Code

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 1, 2026

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 888d18e May 07 2026, 05:26 AM

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes several API/app and multi-SDK inconsistencies around QR sizing, URL normalization, link enable semantics, timeline range constants, and “unset vs explicit null” update semantics.

Changes:

  • API/App: normalize updated URLs, honor size in QR rendering, use canonical timeline ranges, and use LinkRepository.enable().
  • SDKs: TypeScript links.qr() now accepts size: number; Python/Dart updates now distinguish omitted fields from explicit null clears via sentinels.
  • Tests: adds/extends regression coverage for QR sizing, URL normalization, and null-clearing update behavior across app + SDKs.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/services/link-management.ts Normalizes URL on update; uses repository enable() method.
src/api/qr.ts Reads size query param and forwards it into QR SVG rendering.
src/api/bundles.ts Derives VALID_RANGES from shared TIMELINE_RANGES.
src/api/analytics.ts Derives VALID_RANGES from shared TIMELINE_RANGES.
src/tests/unit/qr.test.ts Adds regression tests for renderQrSvg() sizing/defaults.
src/tests/service/link-service.test.ts Adds updateLink normalization + null-clearing tests.
sdk/typescript/src/resources/links.ts Changes qr() size option to number and stringifies internally.
sdk/typescript/tests/client.test.ts Adds tests for null-clearing update bodies and numeric QR size.
sdk/python/src/shrtnr/_base.py Introduces UNSET sentinel to distinguish omitted vs explicit None.
sdk/python/src/shrtnr/resources/links.py Uses UNSET defaults so nullable fields can be explicitly cleared.
sdk/python/src/shrtnr/resources/bundles.py Uses UNSET defaults so nullable fields can be explicitly cleared.
sdk/python/tests/test_client.py Adds sync-client tests for omit-vs-clear sentinel behavior.
sdk/python/tests/test_async_client.py Adds async-client tests for omit-vs-clear sentinel behavior.
sdk/dart/lib/src/resources/links.dart Adds sentinel-based omit-vs-clear behavior for link updates.
sdk/dart/lib/src/resources/bundles.dart Adds sentinel-based omit-vs-clear behavior for bundle updates.
sdk/dart/test/client_test.dart Adds Dart tests for omit-vs-clear update semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sdk/dart/lib/src/resources/links.dart Outdated
Comment thread sdk/dart/lib/src/resources/links.dart Outdated
Comment thread sdk/dart/lib/src/resources/bundles.dart Outdated
Comment thread sdk/dart/lib/src/resources/bundles.dart Outdated
Comment thread src/api/qr.ts Outdated
The service-layer validateSlugLength() capped at 128 while CustomSlugStringSchema
capped at 64 and slug_length capped at 16, so callers could request a custom slug
that the schema accepted but the service rejected (or vice versa). One global
MAX_SLUG_LENGTH constant in src/constants.ts is now the single source of truth,
applied to validateSlugLength, CustomSlugStringSchema, slug_length in
CreateLinkBodySchema, and the MCP create_link tool input.
Route schema previously accepted size="0" (regex /^\d+$/), and the handler
parsed it into 0, which renderQrSvg silently turned into a 0-by-0 SVG. Schema
now coerces to integer and bounds 1..2048, so Hono's validation hook returns
400 on bad input. Handler defensively re-validates size for callers that come
in via the admin route (no Zod) and renderQrSvg short-circuits to null on a
non-positive or non-integer size as a final safety net. Tests cover 0, -5,
abc, 2049 (rejected), 1, 220, 300, 2048 (accepted).
CustomSlugStringSchema max moved 64 -> 128, slug_length max moved 16 -> 128,
and QR size moved from a regex string to a coerced integer with min=1/max=2048.
None of these change the SDK surface: existing methods still take string slugs
and integer sizes, and the SDKs do not enforce server-side bounds locally.
Hash bumped to record review per CLAUDE.md spec-hash workflow.
Replaces the Object?-with-sentinel update(int id, {label, expiresAt}) signature
with update(Link link) / update(Bundle bundle). Callers now read a record,
clone-and-edit via copyWith, and pass the modified value back. The omit-vs-clear
ambiguity moves into copyWith, where it is encapsulated in the model class via
a private _Unset sentinel that external callers cannot construct or match.
This restores compile-time type checking on label/expiresAt/description/icon
that the previous Object? widening removed, and aligns with idiomatic Dart
practice (immutable models with copyWith). Bumps Dart SDK to 1.1.0; the
breaking change is intentional and documented in CHANGELOG.md.

The Python and TypeScript SDKs keep their existing partial-update shapes;
each language now implements the same set/clear/omit feature in its own idiom.
The CustomSlugStringSchema parameterized test had a 65-char rejection case
pinned to the old 64-char cap. Replace with the new 128/129 boundary so the
test continues to enforce "reject anything over the documented maximum"
against the unified MAX_SLUG_LENGTH = 128 constant.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 4 comments.

Comment thread src/api/qr.ts
Comment on lines +14 to +21
let size: number | undefined;
if (sizeParam !== null) {
const parsed = Number(sizeParam);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
return json({ error: "size must be a positive integer" }, 400);
}
size = parsed;
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, missed the admin route in the first pass. Pulled MIN_QR_SIZE/MAX_QR_SIZE/DEFAULT_QR_SIZE into src/constants.ts and wired them through the Zod schema, the handler, and the renderer so the bound is one place. Admin-route 2049 rejection now has its own test alongside the OpenAPI one.

Comment thread src/api/qr.ts Outdated
Comment thread sdk/dart/pubspec.yaml
Comment on lines 1 to +7
name: shrtnr
description: Dart client for the shrtnr URL shortener API. Create short links, manage custom slugs, and read click analytics.
version: 1.0.1
version: 1.1.0
homepage: https://oddb.it/shrtnr-website-pub
repository: https://github.com/oddbit/shrtnr
issue_tracker: https://github.com/oddbit/shrtnr/issues
# x-spec-hash: sha256:3b6e9163bac619b9bbef7ba774b2cd06a9a968f1223b85ba302a018c4bee3b57
# x-spec-hash: sha256:0d54031654375b3a269cd23d05d66ff44a4b10a894d09b55ed2c1c66405c25b8
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, breaking signature change on 1.x should bump to 2.x under SemVer. Bumped to 2.0.0 in pubspec.yaml and updated the CHANGELOG heading.

Comment on lines +259 to +276
Link copyWith({
int? id,
String? url,
Object? label = _unset,
int? createdAt,
Object? expiresAt = _unset,
Object? createdVia = _unset,
String? createdBy,
List<Slug>? slugs,
int? totalClicks,
Object? deltaPct = _unset,
}) {
return Link(
id: id ?? this.id,
url: url ?? this.url,
label: identical(label, _unset) ? this.label : label as String?,
createdAt: createdAt ?? this.createdAt,
expiresAt: identical(expiresAt, _unset) ? this.expiresAt : expiresAt as int?,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this. The Object? = _unset shape is the standard Dart idiom for omit-vs-explicit-null in copyWith: it's exactly what freezed generates, and the equivalents (Optional<T>, record-based (T,) wrappers) make every call site noisier even for the common non-null set case (copyWith(label: const Maybe('x')) vs copyWith(label: 'x')).

The model fields themselves stay typed (Link.label is String?). The Object? only leaks at the copyWith parameter, the runtime cast as String? throws on a wrong-type pass, and IDE auto-complete picks the field's declared type. That seems like a fair trade for the call-site ergonomics, and the test suite covers omit/clear/set across both classes.

The "populates KV when a link is created" test triggered the createLink
handler's autoLabelLink waitUntil branch, which does a real fetch() with
a 5s AbortSignal against the test URL. On CI that fetch hangs long
enough to push the next beforeEach past its 10s hookTimeout, failing
"populates KV when a custom slug is added" deterministically. Passing a
label gates out the autoLabelLink branch and keeps the assertion focused
on KV write-through.
The QR size upper bound (2048) was only enforced via the OpenAPI Zod
schema on /_/api/links/:id/qr. The admin route /_/admin/api/links/:id/qr
goes through the same handler but skips Zod, so a caller could ask for
arbitrarily large SVGs and force the renderer to do unbounded work.

Hoists MIN_QR_SIZE / MAX_QR_SIZE / DEFAULT_QR_SIZE into src/constants.ts
and threads them through:
- src/api/qr.ts handler validation (now rejects > MAX_QR_SIZE)
- src/api/links.ts Zod schema and OpenAPI description
- src/qr.ts renderer min check and default

OpenAPI description templated from the same constants so it cannot
drift; resulting string is byte-identical so spec hash unchanged.
Regression test covers the admin route 400 on size=2049.
The default slug-pick when no ?slug= query param is provided was
link.slugs.find(s => s.is_custom) ?? link.slugs[0]. After SlugRepository
.setPrimary or after disabling/removing a primary custom slug, the
"primary" slug can be the auto-generated one, while a custom slug sits
non-primary alongside it. The find(is_custom) pick returned that
non-primary custom slug, contradicting the documented "Defaults to the
link's primary slug" behavior.

Same pattern lived in two places, both fixed:
- src/api/qr.ts handleLinkQr default-slug pick
- src/mcp/server.ts get_link_qr default-slug pick (also corrects the
  parameter description from "custom slug or primary" to "primary slug")

Regression test: link with auto-slug primary + non-primary custom slug
must produce the same QR for /qr and /qr?slug=<auto-slug>.
Under SemVer, a breaking API change on a 1.x package should bump the
major. The 1.1.0 entry already documented LinksResource.update and
BundlesResource.update as taking a Link/Bundle object instead of an id
plus named parameters. Renames the in-progress entry to 2.0.0 and bumps
pubspec.yaml accordingly. Spec hash is unchanged so no parity work
required.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 6 comments.

Comment thread src/qr.ts
Comment on lines 206 to +211
options: { size?: number; fg?: string; bg?: string } = {},
): string | null {
if (options.size !== undefined && (!Number.isInteger(options.size) || options.size < MIN_QR_SIZE)) {
return null;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, defense-in-depth on the exported function. Imported MAX_QR_SIZE into src/qr.ts and added > MAX_QR_SIZE to the existing null-return path. Boundary tests cover 2048 (accepted), 2049 and 100000 (null).

Comment thread src/api/qr.ts
Comment on lines +11 to +13
const url = new URL(request.url);
const requestedSlug = url.searchParams.get("slug") ?? undefined;
const sizeParam = url.searchParams.get("size");
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: searchParams.get("slug") returns "" for ?slug=, the ?? undefined only catches null, and the falsy check then silently fell back to the primary. Added an explicit empty-string check that returns 400, matching the OpenAPI route. Test added on the admin route. Broader regex parity (spaces, special chars) is out of scope here.

Comment thread src/services/link-management.ts Outdated
Comment on lines 171 to 177
const link = await LinkRepository.getById(env.DB, id);
if (!link) return fail(404, "Link not found");
if (link.created_by !== identity) return fail(403, "Only the link owner can enable this link");
const enabled = await LinkRepository.update(env.DB, id, { expires_at: null });
const enabled = await LinkRepository.enable(env.DB, id);

await Promise.all(
enabled!.slugs.map((s) =>
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real race. Mapped the null to fail(404, "Link not found") and dropped the ! assertions on the subsequent reads. disableLink has the same shape but pre-dates this PR; flagging for a separate change rather than expanding scope here.

Comment thread sdk/dart/CHANGELOG.md Outdated
// 1.0
await client.links.update(42, label: null);

// 1.1
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed when bumping the version heading. Relabeled the example comments as // 1.x and // 2.0.

Comment thread sdk/dart/lib/src/resources/links.dart Outdated
/// always sent on the wire; pass a [Link] produced by [Link.copyWith] with
/// the desired changes (use `null` on a nullable field to clear it).
/// Read-only fields like [Link.id] are used for routing; server-managed
/// fields like slugs and click counts are ignored by the server.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, UpdateLinkBodySchema is .strict() so extras would be rejected, not ignored. Reworded to say the SDK doesn't include those fields in the payload.

Comment thread sdk/dart/lib/src/resources/bundles.dart Outdated
/// [Bundle.accent]) are always sent on the wire; pass a [Bundle] produced
/// by [Bundle.copyWith] with the desired changes (use `null` on a nullable
/// field to clear it). [BundleWithSummary] is also accepted because it
/// extends [Bundle]; its summary fields are ignored by the server.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue, same fix. Reworded to clarify the SDK leaves summary fields out of the body so they never reach the strict server schema.

renderQrSvg already validated MIN_QR_SIZE but not the upper bound,
relying entirely on the HTTP-facing handler to cap size. The function
is exported and called from src/api/qr.ts and src/mcp/server.ts; if a
new caller forgets to pre-validate, an oversized size silently passes
through to QR generation and produces an arbitrarily large SVG.

Adds the > MAX_QR_SIZE branch to the existing nullable-return path.
Unit tests cover 2049 and 100000 (rejected) and 2048 (accepted at the
boundary).
The OpenAPI route rejects empty slugs via Zod regex
/^[a-zA-Z0-9_-]+$/. The admin route bypasses Zod and goes straight to
handleLinkQr, where searchParams.get("slug") returns "" for ?slug=.
"" is falsy, so the handler silently fell back to the primary slug
default and returned a QR for a slug the caller didn't ask for. That
is inconsistent with the OpenAPI route's 400 and surprising on its
own merits.

Adds an explicit empty-string check that mirrors the OpenAPI behavior.
Other malformed slugs (spaces, special chars) still pass through to
the slug lookup and return 404 if not found; matching Zod's full regex
on the admin route is out of scope.
LinkRepository.enable() returns null if the link no longer exists when
the existence check fires (i.e., a concurrent delete won the race
between the service's getById and the repository's UPDATE). enableLink
asserted non-null with `!` and would crash on `enabled!.slugs`, surfacing
as a 500.

Maps the null return to fail(404, "Link not found") and removes the
non-null assertions on the subsequent reads, matching how the rest of
the service handles "link not found" responses.

disableLink has the same shape; left for a separate change rather than
expanding this PR's scope.
- CHANGELOG: example comments said "// 1.1" but the release is now 2.0.0
  after the SemVer bump; relabel as "// 1.x" and "// 2.0".
- Resource docstrings on update(): replace "ignored by the server" with
  language that reflects the actual behavior. UpdateLinkBody and
  UpdateBundleBody schemas are .strict() server-side and would reject
  unknown fields; the SDK simply doesn't include them in the payload.
@DennisAlund DennisAlund merged commit 349b3f3 into main May 7, 2026
9 checks passed
@DennisAlund DennisAlund deleted the claude/serene-lovelace-s63Xz branch May 7, 2026 05:27
DennisAlund pushed a commit that referenced this pull request May 7, 2026
Conflict was textual only: this branch added TIMELINE_RANGES to
src/constants.ts; main's #3 added MIN/MAX/DEFAULT_QR_SIZE in the same
spot. Kept both. Also re-pointed analytics.ts and bundles.ts (added by
#3) at ../constants for TIMELINE_RANGES, since it no longer lives in
api/schemas, and consolidated the auto-merged duplicate ../constants
imports in api/schemas.ts and mcp/server.ts.
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.

3 participants