Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Each SDK records the SHA-256 of the OpenAPI spec it was last regenerated against
| Python | `sdk/python/pyproject.toml` | `[tool.shrtnr]` `spec_hash` |
| Dart | `sdk/dart/pubspec.yaml` | leading comment `# x-spec-hash:` (top-level keys would draw a pana warning) |

Spec changes (edits to `src/api/router.ts`, `src/api/schemas.ts`, or any resource sub-app affecting the generated doc) stale all three hashes. A root `package.json` version bump also drifts the hash because the spec embeds `info.version`. CI enforces via `.github/workflows/sdk-spec-drift.yml`.
Spec changes (edits to `src/api/router.ts`, `src/api/schemas.ts`, or any resource sub-app affecting the generated doc) stale all three hashes. A root `package.json` version bump also drifts the hash because the spec embeds `info.version`. CI enforces via the `sdk-spec-drift` job in `.github/workflows/ci.yml`.

Workflow on API change:

Expand Down
5 changes: 3 additions & 2 deletions scripts/bump-sdk-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,9 @@ echo " 1. edit $CHANGELOG and replace the TODO placeholder"
echo " 2. git add $MANIFEST $CHANGELOG"
echo " 3. git commit -m \"Release $SDK $NEW_VERSION: <short summary>\""
if [ "$SDK" = "pub" ]; then
echo " 4. git tag ${TAG_PREFIX}${NEW_VERSION}"
echo " 5. git push origin main ${TAG_PREFIX}${NEW_VERSION}"
echo " 4. git tag ${TAG_PREFIX}${NEW_VERSION} (create locally, do not push yet)"
echo " pub.dev publishes on tag push, so push the tag only when you are"
echo " ready to release: git push origin ${TAG_PREFIX}${NEW_VERSION}"
else
echo " 4. gh pr create (or push to main — CI tags after publish)"
fi
15 changes: 8 additions & 7 deletions sdk/dart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ closed by `client.close()`.
| `get(id, {range?})` | Get a link with click count |
| `list({owner?, range?})` | List all links |
| `create({url, label?, slugLength?, expiresAt?, allowDuplicate?})` | Create a short link |
| `update(id, {url?, label?, expiresAt?})` | Update URL, label, or expiry |
| `update(link)` | Update URL, label, or expiry (pass a `Link` from `copyWith`) |
| `disable(id)` | Stop redirecting |
| `enable(id)` | Resume redirecting |
| `delete(id)` | Permanently delete |
Expand All @@ -65,10 +65,10 @@ closed by `client.close()`.
final link = await client.links.create(url: 'https://example.com', label: 'Landing page');

// Get a 7-day click count
final fresh = await client.links.get(link.id, range: '7d');
final fresh = await client.links.get(link.id, range: TimelineRange.last7d);

// Full analytics for the last 30 days
final stats = await client.links.analytics(link.id, range: '30d');
final stats = await client.links.analytics(link.id, range: TimelineRange.last30d);
print('${stats.totalClicks} clicks, ${stats.numCountries} countries');
```

Expand Down Expand Up @@ -100,7 +100,7 @@ Groups of related links with combined analytics.
| `get(id, {range?})` | Get a bundle with click summary |
| `list({archived?, range?})` | List bundles |
| `create({name, description?, icon?, accent?})` | Create a bundle |
| `update(id, {name?, description?, icon?, accent?})` | Update metadata |
| `update(bundle)` | Update metadata (pass a `Bundle` from `copyWith`) |
| `delete(id)` | Permanently delete |
| `archive(id)` | Hide from default listing |
| `unarchive(id)` | Restore an archived bundle |
Expand All @@ -112,12 +112,12 @@ Groups of related links with combined analytics.

```dart
// Create a bundle and add links to it
final bundle = await client.bundles.create(name: 'Spring 2026', accent: 'green');
final bundle = await client.bundles.create(name: 'Spring 2026', accent: BundleAccent.green);
await client.bundles.addLink(bundle.id, linkA.id);
await client.bundles.addLink(bundle.id, linkB.id);

// Combined analytics for the last 7 days
final stats = await client.bundles.analytics(bundle.id, range: '7d');
final stats = await client.bundles.analytics(bundle.id, range: TimelineRange.last7d);
print(stats.totalClicks);
```

Expand All @@ -130,8 +130,9 @@ Key types exported from `package:shrtnr/shrtnr.dart`:

- `Link`, `Slug`, `Bundle`, `BundleWithSummary`, `BundleTopLink`
- `ClickStats`, `TimelineData`, `TimelineBucket`, `TimelineSummary`, `NameCount`
- `DateClickCount`, `SlugClickCount`
- `DateCount`, `SlugCount`
- `DeletedResult`, `AddedResult`, `RemovedResult`
- Enums: `TimelineRange`, `BundleAccent`, `BreakdownDimension`, `BundleArchivedFilter`

Timestamp fields (`createdAt`, `expiresAt`, `disabledAt`, `archivedAt`, `updatedAt`) are
plain `int` Unix seconds, matching the wire format exactly.
Expand Down
2 changes: 1 addition & 1 deletion sdk/dart/lib/src/errors.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ class ShrtnrError implements Exception {
final String serverMessage;

@override
String toString() => 'ShrtnrError(HTTP $status): $serverMessage';
String toString() => 'shrtnr API error (HTTP $status): $serverMessage';
}
11 changes: 7 additions & 4 deletions sdk/dart/lib/src/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,17 @@ enum TimelineRange {
/// uses [trueValue] and [activeOnly]. The wire value `"1"` is omitted because
/// it is a semantic alias for `"true"`; use [trueValue] for both.
enum BundleArchivedFilter {
/// Include archived bundles alongside active ones (wire: `"true"`). Also
/// covers the `"1"` alias from the spec; prefer this member for both.
/// Return only archived bundles (wire: `"true"`). Also covers the `"1"`
/// alias from the spec; prefer this member for both.
trueValue,

/// Return only archived bundles (wire: `"only"`).
/// Return only archived bundles (wire: `"only"`). Despite the name, the
/// server's `"only"` value returns archived bundles only, the same result
/// as [trueValue]. The name is misleading and is kept for wire
/// compatibility; a rename awaits the next major version.
activeOnly,

/// Return all bundles regardless of archived status (wire: `"all"`).
/// Include archived bundles alongside active ones (wire: `"all"`).
all;

static const _wireValues = {
Expand Down
4 changes: 4 additions & 0 deletions sdk/python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to the SDK are documented in this file.

## 1.1.1

- `links.qr()` now takes `size` as an `int` instead of a `str`, matching the API schema (which validates an integer) and the TypeScript and Dart SDKs. Callers who passed an int already got the correct behavior; the annotation was the only thing out of step.

## 1.1.0 (2026-06-19)

- Add `links.breakdown` and `bundles.breakdown` (sync and async) for paging through the countries, sources and domains analytics panels (offset/limit, returns items + total).
Expand Down
4 changes: 2 additions & 2 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ with Shrtnr(base_url="...", api_key="sk_...") as client:
| `get(id, *, range=None)` | Get a link with click count |
| `list(*, owner=None, range=None)` | List all links |
| `create(*, url, label=None, slug_length=None, expires_at=None, allow_duplicate=None)` | Create a short link |
| `update(id, *, url=None, label=None, expires_at=None)` | Update URL, label, or expiry |
| `update(id, *, url=None, label=UNSET, expires_at=UNSET)` | Update URL, label, or expiry. Omit a field to leave it unchanged; pass `None` to clear it |
| `disable(id)` | Stop redirecting |
| `enable(id)` | Resume redirecting |
| `delete(id)` | Permanently delete |
Expand Down Expand Up @@ -115,7 +115,7 @@ Groups of related links with combined analytics.
| `get(id, *, range=None)` | Get a bundle with click summary |
| `list(*, archived=None, range=None)` | List bundles |
| `create(*, name, description=None, icon=None, accent=None)` | Create a bundle |
| `update(id, *, name=None, description=None, icon=None, accent=None)` | Update metadata |
| `update(id, *, name=None, description=UNSET, icon=UNSET, accent=None)` | Update metadata. Omit a field to leave it unchanged; pass `None` to clear `description` or `icon` |
| `delete(id)` | Permanently delete |
| `archive(id)` | Hide from default listing |
| `unarchive(id)` | Restore an archived bundle |
Expand Down
2 changes: 1 addition & 1 deletion sdk/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "shrtnr"
version = "1.1.0"
version = "1.1.1"
description = "SDK for the shrtnr URL shortener API"
readme = "README.md"
license = "Apache-2.0"
Expand Down
8 changes: 4 additions & 4 deletions sdk/python/src/shrtnr/resources/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,13 @@ def timeline(self, id: int, *, range: TimelineRange | None = None) -> TimelineDa
url = self._url(f"/_/api/links/{id}/timeline", {"range": range})
return TimelineData.from_dict(self._request("GET", url, headers=self._headers()))

def qr(self, id: int, *, slug: str | None = None, size: str | None = None) -> str:
def qr(self, id: int, *, slug: str | None = None, size: int | None = None) -> str:
"""Get the QR code SVG for a link. Returns the SVG string."""
query: dict[str, str | None] = {}
if slug is not None:
query["slug"] = slug
if size is not None:
query["size"] = size
query["size"] = str(size)
url = self._url(f"/_/api/links/{id}/qr", query or None)
return self._request_text("GET", url, headers=self._headers())

Expand Down Expand Up @@ -327,13 +327,13 @@ async def timeline(self, id: int, *, range: TimelineRange | None = None) -> Time
url = self._url(f"/_/api/links/{id}/timeline", {"range": range})
return TimelineData.from_dict(await self._request("GET", url, headers=self._headers()))

async def qr(self, id: int, *, slug: str | None = None, size: str | None = None) -> str:
async def qr(self, id: int, *, slug: str | None = None, size: int | None = None) -> str:
"""Get the QR code SVG for a link. Returns the SVG string."""
query: dict[str, str | None] = {}
if slug is not None:
query["slug"] = slug
if size is not None:
query["size"] = size
query["size"] = str(size)
url = self._url(f"/_/api/links/{id}/qr", query or None)
return await self._request_text("GET", url, headers=self._headers())

Expand Down
2 changes: 1 addition & 1 deletion sdk/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ def test_links_qr_with_slug_and_size(client: Shrtnr) -> None:
route = respx.get(url__regex=rf"^{BASE_URL}/_/api/links/5/qr\?").mock(
return_value=httpx.Response(200, text="<svg/>"),
)
client.links.qr(5, slug="promo", size="200")
client.links.qr(5, slug="promo", size=200)
url = str(route.calls[0].request.url)
assert "slug=promo" in url
assert "size=200" in url
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/handler/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ describe("MCP tool behavior (service layer)", () => {

const result = await updateLink(env as never, created.data.id, {
url: "https://example.com/updated",
});
}, "anonymous");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.url).toBe("https://example.com/updated");
Expand Down
9 changes: 8 additions & 1 deletion src/__tests__/page/keys-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ describe("Keys page table", () => {
it("keeps the delete action for each key", async () => {
await seedApiKey();
const html = await fetchKeysHtml();
expect(html).toContain("deleteKey(");
// The delete action rides on data-* attributes read by a delegated handler
// rather than an inline onclick, so a key title cannot break out into
// executable script. The title is carried verbatim on data-delete-key-title.
expect(html).toMatch(/data-delete-key="\d+"/);
expect(html).toContain('data-delete-key-title=');
// The vulnerable inline onclick handler must be gone from the markup. The
// client script still defines deleteKey(), so target the attribute form.
expect(html).not.toContain('onclick="deleteKey(');
});
});
21 changes: 21 additions & 0 deletions src/__tests__/page/link-detail-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ describe("Link detail page server render", () => {
expect(slugCount![1].trim()).toBe("2");
});

it("renders the duplicate action without interpolating the URL into an inline handler", async () => {
// A URL with a literal single quote must not break out of the duplicate
// button. The URL rides on a data-* attribute (HTML-escaped by JSX) and a
// delegated handler reads it, so no inline onclick carries the raw URL.
const link = await LinkRepository.create(env.DB, {
url: "https://example.com/'-alert(document.cookie)-'",
slug: "abc",
});

const res = await SELF.fetch(req(`/_/admin/links/${link.id}`));
const html = await res.text();

expect(html).toContain("data-duplicate-url=");
// The client script still defines showDuplicateModal(); the fix is that no
// inline onclick attribute carries the raw URL.
expect(html).not.toContain('onclick="showDuplicateModal(');
// The quote survives only in escaped form, never as a raw breakout.
expect(html).not.toContain("'-alert(document.cookie)-'");
expect(html).toContain("&#39;-alert(document.cookie)-&#39;");
});

it("hero total_clicks honors a user's default_range setting", async () => {
const link = await LinkRepository.create(env.DB, { url: "https://example.com", slug: "abc" });
const slug = link.slugs[0].slug;
Expand Down
16 changes: 8 additions & 8 deletions src/__tests__/service/link-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe("link-management service", () => {
expect(created.ok).toBe(true);
if (!created.ok) return;

const result = await updateLink(env as any, created.data.id, { url: "javascript:alert(1)" });
const result = await updateLink(env as any, created.data.id, { url: "javascript:alert(1)" }, "anonymous");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(400);
Expand All @@ -135,7 +135,7 @@ describe("link-management service", () => {
const fetched = await getLink(env as any, created.data.id);
expect(fetched.ok).toBe(true);

const updated = await updateLink(env as any, created.data.id, { label: "Updated" });
const updated = await updateLink(env as any, created.data.id, { label: "Updated" }, "anonymous");
expect(updated.ok).toBe(true);
if (updated.ok) {
expect(updated.data.label).toBe("Updated");
Expand Down Expand Up @@ -307,7 +307,7 @@ describe("URL normalization in updateLink", () => {
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/new-path/" });
const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/new-path/" }, "anonymous");
expect(updated.ok).toBe(true);
if (updated.ok) {
expect(updated.data.url).toBe("https://example.com/new-path");
Expand All @@ -319,7 +319,7 @@ describe("URL normalization in updateLink", () => {
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/page?" });
const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/page?" }, "anonymous");
expect(updated.ok).toBe(true);
if (updated.ok) {
expect(updated.data.url).toBe("https://example.com/page");
Expand All @@ -331,7 +331,7 @@ describe("URL normalization in updateLink", () => {
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { label: null });
const updated = await updateLink(env as any, created.data.id, { label: null }, "anonymous");
expect(updated.ok).toBe(true);
if (updated.ok) {
expect(updated.data.label).toBeNull();
Expand Down Expand Up @@ -389,7 +389,7 @@ describe("field type validation in updateLink", () => {
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { expires_at: "never" as any });
const updated = await updateLink(env as any, created.data.id, { expires_at: "never" as any }, "anonymous");
expect(updated.ok).toBe(false);
if (!updated.ok) expect(updated.status).toBe(400);
});
Expand All @@ -399,7 +399,7 @@ describe("field type validation in updateLink", () => {
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { label: 42 as any });
const updated = await updateLink(env as any, created.data.id, { label: 42 as any }, "anonymous");
expect(updated.ok).toBe(false);
if (!updated.ok) expect(updated.status).toBe(400);
});
Expand All @@ -409,7 +409,7 @@ describe("field type validation in updateLink", () => {
expect(created.ok).toBe(true);
if (!created.ok) return;

const updated = await updateLink(env as any, created.data.id, { expires_at: null });
const updated = await updateLink(env as any, created.data.id, { expires_at: null }, "anonymous");
expect(updated.ok).toBe(true);
if (updated.ok) expect(updated.data.expires_at).toBeNull();
});
Expand Down
44 changes: 44 additions & 0 deletions src/__tests__/service/ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
enableSlug,
removeSlug,
addCustomSlugToLink,
updateLink,
setSlugPrimary,
searchLinks,
listLinksByOwner,
} from "../../services/link-management";
Expand Down Expand Up @@ -91,6 +93,48 @@ describe("Link ownership: delete", () => {
});
});

describe("Link ownership: update", () => {
it("owner can update their link", async () => {
const link = await createOwnedLink();
const result = await updateLink(env as any, link.id, { url: "https://example.com/moved" }, OWNER);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.url).toBe("https://example.com/moved");
}
});

it("non-owner cannot update another user's link", async () => {
const link = await createOwnedLink();
const result = await updateLink(env as any, link.id, { url: "https://evil.example" }, OTHER);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(403);
}
// The destination must be untouched after a rejected update.
const after = await LinkRepository.getById(env.DB, link.id);
expect(after!.url).toBe("https://example.com");
});
});

describe("Slug ownership: set primary", () => {
it("link owner can change the primary slug", async () => {
const link = await createOwnedLink();
await addCustomSlugToLink(env as any, link.id, { slug: "primary-pick" });
const result = await setSlugPrimary(env as any, link.id, "primary-pick", OWNER);
expect(result.ok).toBe(true);
});

it("non-owner cannot change the primary slug on another user's link", async () => {
const link = await createOwnedLink();
await addCustomSlugToLink(env as any, link.id, { slug: "primary-pick" });
const result = await setSlugPrimary(env as any, link.id, "primary-pick", OTHER);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(403);
}
});
});

describe("Slug ownership: disable", () => {
it("link owner can disable a custom slug", async () => {
const link = await createOwnedLink();
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/unit/normalize-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,22 @@ describe("normalizeUrl", () => {
"https://example.com#section",
);
});

it("preserves a trailing slash inside a query value", () => {
expect(normalizeUrl("https://example.com/search?q=cats/")).toBe(
"https://example.com/search?q=cats/",
);
});

it("preserves a trailing slash inside an embedded URL parameter", () => {
expect(normalizeUrl("https://example.com/a?next=https://b.com/")).toBe(
"https://example.com/a?next=https://b.com/",
);
});

it("preserves a hash-router route that ends in a slash", () => {
expect(normalizeUrl("https://example.com/#/spa/route/")).toBe(
"https://example.com/#/spa/route/",
);
});
});
Loading