@hasna/shortlinks
v0.5.0
Published
Open-source shortlink manager with local SQLite or hosted /v1 storage, custom-domain provisioning through the Domains API, click tracking, CLI, MCP, redirect server, and SDK
Maintainers
Readme
@hasna/shortlinks
Shortlink management for custom domains — CLI, MCP server, REST API, and a generated SDK.
shortlinks creates Bitly-style short URLs, supports multiple domains, records click analytics, and runs a redirect server. Custom-domain availability and purchase intent go through @hasna/domains; registrar, DNS, nameserver, certificate, and router-binding implementation stays exclusively in the Domains service. The client resolves one store through the @hasna/contracts 1.0.2 client resolver: the hosted /v1 API when a shortlinks credential resolves (the macOS Keychain, ~/.hasna/shortlinks/config/credentials, or HASNA_SHORTLINKS_API_KEY — the URL defaults to the fleet gateway https://api.hasna.com/shortlinks and never needs configuring), or the on-box SQLite database (~/.hasna/shortlinks/shortlinks.db) when the local backend is explicitly opted into with HASNA_SHORTLINKS_LOCAL=1 (alias SHORTLINKS_LOCAL=1), which is announced on stderr as shortlinks: LOCAL mode — …. That environment opt-in is the only door into local storage: --db <path> chooses the database file for such a run and, on its own, is refused. With neither, store-backed commands fail closed — the CLI never falls back to local storage on its own. The shortlinks-serve service reads/writes an app-owned PostgreSQL database when HASNA_SHORTLINKS_DATABASE_URL is configured.
Surfaces
Four surfaces share one core library:
| Surface | Bin / package | Purpose |
| --- | --- | --- |
| CLI | shortlinks | Interactive/scriptable link + domain management (--json for agents). |
| MCP | shortlinks-mcp | Model Context Protocol server (stdio or --http; --version / --help answer without starting a transport) exposing link/domain tools to agents. Fails closed at startup — exits non-zero naming the credential chain — when no credential resolves and the local backend was not opted into. |
| REST API | shortlinks-serve | HTTP service: GET /health, /ready, /version, /openapi.json, and a versioned /v1 CRUD API guarded by API-key auth (--version / --help answer without touching the database). |
| SDK | @hasna/shortlinks/sdk | Typed fetch client generated from the serve OpenAPI (bun run sdk:generate) plus the resolver-backed createShortlinksApiClient. One package, one ./sdk export subpath — there is no separate -sdk package. |
Hosted service
shortlinks-serve reads/writes PostgreSQL directly via the vendored @hasna/contracts storage kit — no sync engine or cache in the service. A configured HASNA_SHORTLINKS_DATABASE_URL selects the postgresql server data backend; the pool factory fails closed without it. API-key auth comes from @hasna/contracts/auth; mint keys with contracts issue-key --app shortlinks --scopes 'shortlinks:read,shortlinks:write'.
HASNA_SHORTLINKS_DATABASE_URL=$DATABASE_URL \
HASNA_SHORTLINKS_API_SIGNING_KEY=... \
HASNA_DOMAINS_API_KEY=... \
HASNA_DOMAINS_API_URL=https://domains.example.net \
shortlinks-serve # migrate (idempotent) then serve on :8080
shortlinks-serve migrate # one-shot migration taskClients resolve the same hosted API through the @hasna/contracts client
resolver — Keychain, ~/.hasna/shortlinks/config/credentials, or
HASNA_SHORTLINKS_API_KEY (legacy aliases SHORTLINKS_API_URL /
SHORTLINKS_API_KEY; never a DSN). The URL defaults to the fleet gateway.
Clients resolve their authority and credential through the @hasna/contracts client resolver (never a DSN, never a hand-rolled env read).
Install
bun install -g @hasna/shortlinksThe on-box SQLite database (used only under the explicit local opt-in, see Storage selection) lives at:
~/.hasna/shortlinks/shortlinks.dbClient environment
The CLI, MCP server, and @hasna/shortlinks/sdk all resolve the same way,
through the @hasna/contracts 1.0.2 client chain, resolved fresh on every
request:
| Variable | Purpose |
| --- | --- |
| HASNA_SHORTLINKS_API_KEY | Hosted API key. The credential chain also accepts the Keychain item hasna.credentials.shortlinks.api-key (macOS, account HASNA_STATION → hostname → $USER) and ~/.hasna/shortlinks/config/credentials (0600, HASNA_SHORTLINKS_API_KEY=...). Prefixed env wins over disk; the Keychain is re-read per call. |
| HASNA_SHORTLINKS_API_URL | Hosted API base URL. Optional: the authority defaults to the fleet gateway https://api.hasna.com/shortlinks (the Keychain api-url item and the credentials file's HASNA_SHORTLINKS_API_URL are also honoured). A declared-but-blank or disagreeing variable fails loudly. |
| SHORTLINKS_API_URL / SHORTLINKS_API_KEY | Legacy aliases, accepted by the resolver for one release. |
| HASNA_SHORTLINKS_API_KEY_OVERRIDE | Deliberate per-run key that outranks everything else (never populated automatically). |
| HASNA_SHORTLINKS_API_KEY_REF | Deliberate pointer to a @hasna/secrets vault item key (resolved at request time). |
| HASNA_PROFILE | Selects which identity profile's credential file to use. |
| HASNA_SHORTLINKS_LOCAL=1 | The ONLY opt-in to the on-box SQLite store (alias SHORTLINKS_LOCAL=1), announced once per process on stderr (shortlinks: LOCAL mode — …). --db <path> only names the database file for a run that already opted in; without the opt-in it is refused. |
| SHORTLINKS_HOME / HASNA_HOME | Relocate the app home (~/.hasna/shortlinks: config, click salt, machine id, and the on-box database). SHORTLINKS_HOME names the directory itself; HASNA_HOME replaces ~/.hasna exactly as it does for the credential chain. Every surface derives the home from the environment it was handed, and a hosted-mode read creates nothing there. |
| HASNA_SHORTLINKS_DATABASE_URL | Server only (shortlinks-serve): required Postgres DSN for the service's own pool. Missing configuration fails closed before listening; the hosted server never falls back to SQLite. Never read by clients. |
With no credential resolvable and no local opt-in, store-backed commands exit non-zero with an error naming the chain — never a silent fallback.
| SDK | Purpose |
| --- | --- |
| createShortlinksApiClient(options) | Build the typed /v1 client with the resolver behind it; the credential is refreshed on every request. |
| resolveShortlinksSdkTransport(options) | Read WHERE the authority and credential came from without building a client. An explicit baseUrl with no apiKey pins the authority: the ambient fleet key is never attached to a caller-chosen URL. |
Quick Start
Point the CLI at the hosted shortlinks API, or opt into the on-box store:
# Hosted API (requires a fleet API key — the URL defaults to the gateway):
export HASNA_SHORTLINKS_API_KEY=hsk_...
# Or the explicit local backend:
export SHORTLINKS_LOCAL=1
shortlinks init --domain sho.rt
shortlinks create https://example.com --slug docs
shortlinks serve --host 127.0.0.1 --port 8787With neither a resolvable credential nor SHORTLINKS_LOCAL=1, store-backed
commands exit non-zero with an error naming the required configuration — they
never silently serve local data. --db <path> is not an opt-in: passing it
alone refuses with one line naming HASNA_SHORTLINKS_LOCAL=1.
Then a request for https://sho.rt/docs redirects to https://example.com and records a click.
Agent-Friendly JSON
Every operational command supports --json:
shortlinks --json create https://example.com --domain sho.rt
shortlinks --json link list
shortlinks --json stats docs --domain sho.rt
shortlinks --json doctorErrors are emitted as:
{ "error": "message" }Compact Defaults and Details
Human output is compact by default so agent terminals do not fill with full records. List and status commands show essential fields, truncate long URLs or text, cap human rows, and print the next command to use for details.
Use these gradual disclosure paths when you need more:
shortlinks link list --limit 50
shortlinks link get home --verbose
shortlinks stats home --verbose
shortlinks doctor --verbose
shortlinks domain setup example.test --max-price 5 --auto-renew false --dry-run
shortlinks events list --limit 50
shortlinks webhooks list --limit 50
shortlinks --json link get home--json remains the machine-readable path and keeps full objects where commands
already returned them. Collection commands return at most 20 rows by default;
pass an explicit positive --limit when a larger page is required. Prefer
--json for automation and --verbose for human debugging.
Example compact output:
https://sho.rt/home -> https://example.com/landing-page-with-a-very-long-path... active
Showing 1 link(s).
Use `shortlinks link get <slug>` for details.Before this behavior, detail/status commands such as shortlinks link get home,
shortlinks stats home, and shortlinks doctor printed full JSON-like objects
by default.
CLI
shortlinks init --domain sho.rt
shortlinks domain setup go.example.com --max-price 5 --auto-renew false --years 1 --dry-run
shortlinks domain setup go.example.com --max-price 5 --auto-renew false --years 1 --wait
shortlinks create https://example.com --slug home
shortlinks link create https://example.com/docs --domain sho.rt --title Docs
shortlinks link list
shortlinks link get home --domain sho.rt
shortlinks link disable home --domain sho.rt
shortlinks link enable home --domain sho.rt
shortlinks stats home --domain sho.rt
shortlinks serve --port 8787
shortlinks doctorLocal Domain Setup
Record a local mapping with the machines CLI and print the remaining hosts/proxy setup:
shortlinks local setup sho.rt --port 8787
shortlinks local plan sho.rt --port 8787The command emits the /etc/hosts line, a Caddy reverse-proxy snippet, and certificate paths. Writing /etc/hosts still requires sudo on macOS.
Custom Domains through the Domains API
Shortlinks never talks directly to a registrar, DNS provider, or Cloudflare. It accepts only business intent and delegates it across the API boundary:
client -> Shortlinks /v1 -> Domains /v1 -> registrar + DNS + delegation + router bindingEvery purchase requires an explicit total price cap, renewal decision, and
stable idempotency key. The CLI derives shortlinks-domain:<hostname> unless a
key is supplied:
shortlinks domain setup go.example.com \
--max-price 5 \
--auto-renew false \
--years 1 \
--wait--dry-run prints only the business request and performs no write. Shortlinks
rejects registrar, DNS-provider, Cloudflare account/zone, Worker, origin, and
provider-state fields. A pending domain is non-routable and cannot become the
default; reconciliation activates it only after Domains reports ready.
sho.rt is an example operator-configured default. No hostname is built into the package. Creating it locally does not trigger a
purchase. Managed custom-domain projections cannot be locally deleted until a
Domains decommission workflow exists, preventing an orphaned edge binding.
Self-hosted infrastructure
The API boundary is not a Hasna-cloud lock-in. Run domains-serve and
shortlinks-serve in your own infrastructure, keep registrar and DNS-provider
credentials only on Domains, and configure Shortlinks with:
export HASNA_DOMAINS_API_KEY=... # domains:read + domains:purchase
export HASNA_DOMAINS_API_URL=https://domains.example.netHASNA_DOMAINS_API_URL accepts an HTTP(S) service root with or without /v1.
When unset, it defaults to https://api.hasna.com/domains, and the Domains SDK
appends /v1 exactly once.
Storage selection
The client resolves ONE Store through the @hasna/contracts client resolver — there is no DSN on any client:
- hosted
/v1HTTP API (when a credential resolves): the @hasna/contracts chain supplies the key — the Keychain itemhasna.credentials.shortlinks.api-key,~/.hasna/shortlinks/config/credentials(0400/0600), orHASNA_SHORTLINKS_API_KEY(legacy aliasSHORTLINKS_API_KEY) — and the authority followsHASNA_SHORTLINKS_API_URL(aliasSHORTLINKS_API_URL) or defaults to the fleet gatewayhttps://api.hasna.com/shortlinks, so URLs never need configuring. A URL without a credential (or a declared-but-blank or disagreeing variable) fails loudly — never silent local drift. - on-box SQLite (explicit opt-in only): the local database at
~/.hasna/shortlinks/shortlinks.dbis used ONLY when the local backend is explicitly selected withHASNA_SHORTLINKS_LOCAL=1/SHORTLINKS_LOCAL=1— and selecting it printsshortlinks: LOCAL mode — …on stderr, once per process.--db <path>(andSHORTLINKS_DB) only choose WHICH file that opted-in run uses;--dbalone is refused with a line naming the opt-in. The sqlite engine is loaded through a single gated dynamic import, so it is absent from theshortlinks,shortlinks-mcp, and./sdkentry artifacts. - fail closed (no configuration): with no resolvable credential and no
local opt-in, store-backed commands exit non-zero with an error naming the
chain and the local opt-in — the CLI never silently serves local data and
never creates
~/.hasna/shortlinks/shortlinks.dbon its own.
# Route the client to the hosted API (bearer key, never a DSN; URL optional):
export HASNA_SHORTLINKS_API_KEY=hsk_...
shortlinks doctor
# Explicit local backend (on-box SQLite — prints "LOCAL mode" on stderr):
export SHORTLINKS_LOCAL=1
shortlinks init --domain sho.rtThe hosted server (shortlinks-serve) is the only component that holds a
Postgres connection. It requires HASNA_SHORTLINKS_DATABASE_URL, opens its
pool server-side through the sanctioned storage kit, and fails closed before
listening when the DSN is absent — the raw RDS DSN is never distributed to
clients and the hosted server never falls back to SQLite.
The exported redirect helpers (createShortlinksHandler and
serveShortlinks) also refuse implicit storage. Hosted callers resolve the
canonical app-base credential authority and inject that store; an omitted store
is accepted only when HASNA_SHORTLINKS_LOCAL=1 (alias
SHORTLINKS_LOCAL=1) explicitly authorizes the on-box database. dbPath and
SHORTLINKS_DB only choose the file after that opt-in.
Development
bun install
bun test
bun run typecheck
bun run buildRepository
The OSS repository is expected to be:
hasna/shortlinksThe local workspace folder is named shortlinks; the published package and GitHub repo use bare names without the retired open- prefix.
License
Apache-2.0. See LICENSE.
