A small, legible, and agent-operable URL shortener built with .NET 10 (Minimal API) and a Dart local steward (CLI + MCP).
Shortly prioritises correctness, observability, and deterministic lifecycle over premature scale. It features a single shared pool of links, soft TTL expiration, check-on-read validation, and complete audit history for every redirect attempt (both successes and failures).
See story why it was created and how on dev.to.
To understand principles of Engineering Stewardship and Skill Steward which were used to scaffold and work with entire project see repository GitHub.com/arenukvern/skill_steward.
- Core Principles & Architecture
- Locked Architectural Decisions
- Prerequisites
- Quick Start from Scratch
- API Reference
- Dart Local Steward & MCP Server
- Configuration & Environment Variables
- Benchmarking
- Repository Structure
- Contributing & Governance
- License
- Single Shared Pool: Every shorten request generates a new short code even if the target URL has been shortened before.
- Pure Encoder: Base62 encoding is purely mathematical and decoupled from storage.
- Soft TTL & Check-on-Read: No aggressive background deletion jobs. Expiration is evaluated dynamically upon lookup.
- Active links return
302 Found. - Expired links return
410 Gonewith a friendly HTML status page. - Non-existent or soft-deleted links return
404 Not Found.
- Active links return
- Full Attempt Audit Trail: Every redirect attempt is recorded with timestamp, IP, User-Agent, and failure reason (including attempts for unknown codes).
- SQLite + WAL Mode: High-performance single-file persistence with Write-Ahead Logging.
- Standalone Runtime vs. Developer Steward: The .NET runtime has zero dependencies on Node, Dart, or Docker. Dart and Node are strictly part of the developer/stewardship toolchain (ADR 0009).
Architectural decisions are formally recorded and immutable without an explicit charter revision:
| ADR | Title | Summary |
|---|---|---|
| ADR 0001 | Single Shared Pool | Global namespace; identical URLs yield unique codes. |
| ADR 0002 | Public Shorten, Admin Key | Public shortening and redirection; mutating/inspecting requires X-Admin-Key. |
| ADR 0003 | Pure Base-N Encoding | Sequential numeric identity mapped deterministically to Base62 alphanumeric codes. |
| ADR 0004 | Soft Expiry & Check-on-Read | In-band expiry calculation returning 410 Gone without background sweeps. |
| ADR 0005 | SQLite with WAL | Embedded storage with WAL journal mode for concurrent reads. |
| ADR 0006 | Configurable Host, Alphabet & TTL | Environment-driven settings with safe fallback defaults. |
| ADR 0007 | Dart Stewardship & Tooling | Dart CLI and MCP tool surface for local management. |
| ADR 0008 | Testable Without Docker | Standard testing and local execution run natively without containers. |
| ADR 0009 | Local Stewardship Stack | Unified just + ./install.sh + Skill Steward + GitNexus stack. |
- .NET 10 SDK (runs the core web service and unit/integration tests).
justcommand runner.- Dart SDK ^3.12 (for the Dart CLI and MCP steward).
- Node.js 18+ &
npx(for GitNexus code intelligence). - Docker & Docker Compose (optional, for containerised deployment).
Clone the repository and run the automated bootstrap script:
git clone https://github.com/arenukvern/shortly.git
cd shortly
# macOS / Linux
./install.sh
# Windows PowerShell
.\install.ps1./install.sh checks for prerequisites, installs missing components (just, .NET 10, Dart, Node, GitNexus), and initialises local agent skills.
To inspect tooling status without making modifications:
just status
# or: ./install.sh --statusVerify that the local environment, test suites, and steward contracts pass:
just check(Executes dotnet test and shortly steward validate).
Launch the service locally:
just runThe service starts at http://localhost:5080.
- Interactive Swagger UI: http://localhost:5080/swagger
- Health Check: http://localhost:5080/health
To run in a containerised environment:
just docker-upThe Docker endpoint is accessible at http://localhost:8080.
- Method:
POST /api/v1/shorten - Request Body:
{
"originalUrl": "https://example.com/very/long/url?param=1",
"customAlias": "my-alias",
"ttlDays": 30
}(Note: customAlias and ttlDays are optional).
- Response (
201 Created):
{
"code": "my-alias",
"shortUrl": "http://localhost:5080/my-alias",
"originalUrl": "https://example.com/very/long/url?param=1",
"expiresAt": "2026-09-14T12:00:00Z",
"createdAt": "2026-08-15T12:00:00Z"
}# Example curl:
curl -s -X POST 'http://localhost:5080/api/v1/shorten' \
-H 'Content-Type: application/json' \
-d '{"originalUrl": "https://xsoulspace.dev"}'- Method:
GET /{code} - Responses:
302 FoundwithLocation: <originalUrl>(Active link).410 Gonewith HTML status page (Expired link).404 Not Found(Unknown or soft-deleted link).
# Inspect redirect headers without following:
curl -sI 'http://localhost:5080/my-alias'
# Follow redirect in terminal:
curl -L 'http://localhost:5080/my-alias'GET /healthβ{"status": "ok"}GET /expired?code=abcβ HTML status page explaining the link has expired.
All administrative routes require the X-Admin-Key header (local default: change-me-in-production).
curl -s 'http://localhost:5080/api/v1/links/my-alias' \
-H 'X-Admin-Key: change-me-in-production'curl -s 'http://localhost:5080/api/v1/links?offset=0&limit=20' \
-H 'X-Admin-Key: change-me-in-production'curl -s -X PUT 'http://localhost:5080/api/v1/links/my-alias' \
-H 'Content-Type: application/json' \
-H 'X-Admin-Key: change-me-in-production' \
-d '{
"originalUrl": "https://example.com/updated-path",
"expiresAt": "2026-12-31T23:59:59Z"
}'curl -s -X DELETE 'http://localhost:5080/api/v1/links/my-alias' \
-H 'X-Admin-Key: change-me-in-production'curl -s 'http://localhost:5080/api/v1/attempts?code=my-alias&limit=50' \
-H 'X-Admin-Key: change-me-in-production'curl -s 'http://localhost:5080/api/v1/stats' \
-H 'X-Admin-Key: change-me-in-production'Shortly includes a Dart-powered steward CLI and Model Context Protocol (MCP) server under tools/shortly_dart/.
cd tools/shortly_dart
# Show help
dart run shortly --help
# Shorten a link
dart run shortly shorten https://example.com --base-url http://localhost:5080
# Inspect link information (requires admin key)
SHORTLY_ADMIN_KEY=change-me-in-production dart run shortly info my-alias --base-url http://localhost:5080
# Fetch pool stats
SHORTLY_ADMIN_KEY=change-me-in-production dart run shortly stats --base-url http://localhost:5080
# Run repository steward checks
dart run shortly steward validateTo connect Shortly to AI assistants (Zed, Claude Code, Cursor, Windsurf), run the steward in MCP mode:
dart run shortly mcpExposed MCP Tools:
shortly_shortenshortly_get_link_infoshortly_list_linksshortly_list_attemptsshortly_get_statsshortly_update_linkshortly_delete_linkshortly_steward_validateshortly_steward_status
Settings are defined in src/Shortly.Api/appsettings.json and can be overridden via environment variables:
| Setting | Environment Variable | Default | Description |
|---|---|---|---|
Shortly:BaseUrl |
Shortly__BaseUrl |
https://localhost:5001 |
Public base URL used when returning shortened links. |
Shortly:AdminApiKey |
Shortly__AdminApiKey |
change-me-in-production |
Secret key required for /api/v1 management endpoints. |
Shortly:ConnectionString |
Shortly__ConnectionString |
Data Source=shortly.db |
SQLite database connection string. |
Shortly:DefaultTtlDays |
Shortly__DefaultTtlDays |
30 |
Default expiration window in days. |
Shortly:DefaultCodeLength |
Shortly__DefaultCodeLength |
7 |
Length for auto-generated sequential codes. |
Shortly:AllowCustomAliases |
Shortly__AllowCustomAliases |
true |
Whether custom aliases are enabled. |
Shortly:CustomAliasMinLength |
Shortly__CustomAliasMinLength |
3 |
Minimum character length for custom aliases. |
Shortly:CustomAliasMaxLength |
Shortly__CustomAliasMaxLength |
32 |
Maximum character length for custom aliases. |
β οΈ Production Note: Always overrideShortly__AdminApiKeyin production deployments.just validatewill enforce that the default key is not used in production configurations.
Shortly includes a local redirect timing tool in tools/Shortly.Bench/ to measure redirect latency and compare internal database lookups against external destination hops:
# Run standard in-process benchmark:
just bench-redirect
# Run with 300 iterations:
just bench-redirect -- --iterations 300
# Benchmark a live instance and follow the destination hop:
just bench-redirect -- --base-url http://localhost:5080 --code my-alias --follow.
βββ src/
β βββ Shortly.Core/ # Pure domain models, Base62 encoder, validators
β βββ Shortly.Api/ # Minimal API host, SQLite EF Core, endpoints, middleware
βββ tests/
β βββ Shortly.Core.Tests/ # Unit tests for domain logic and encoding
β βββ Shortly.Api.Tests/ # Integration and E2E tests for API routes
βββ tools/
β βββ Shortly.Bench/ # Benchmark harness for redirect performance
β βββ shortly_dart/ # Dart CLI and MCP steward implementation
β βββ *.sh / *.ps1 # Shell and PowerShell cross-platform tool scripts
βββ docs/
β βββ NORTH_STAR.mdx # Project charter, non-goals, and vision
β βββ DESIGN_FAQ.mdx # Architectural rationale (why)
β βββ DX_FAQ.mdx # Operational guide & troubleshooting (how)
β βββ decisions/ # Architecture Decision Records (ADRs 0001β0009)
β βββ articles/ # Showcase story & documentation
βββ .agents/skills/ # Agent skills for repository maintenance
βββ AGENTS.md # Agent map and repository instructions
βββ justfile # Command hub recipes
βββ install.sh / install.ps1 # Toolchain bootstrap scripts
βββ LICENSE # MIT License
Contributions that respect the project's North Star and locked architectural decisions are welcome!
- Check Decisions First: Before proposing structural changes, consult docs/decisions/ and docs/DESIGN_FAQ.mdx.
- Adhere to Native Gates: Ensure all changes pass
just check(dotnet testandshortly steward validate). - No Unwanted Dependencies: The core service must remain runnable with only the .NET 10 SDK.
- Code Intelligence: Refresh GitNexus with
npx gitnexus analyzeafter making significant refactors.
This project is licensed under the MIT License.