# @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'`.

```bash
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 task
```

Clients 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.

[![npm](https://img.shields.io/npm/v/@hasna/shortlinks)](https://www.npmjs.com/package/@hasna/shortlinks)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)

Clients resolve their authority and credential through the `@hasna/contracts` client resolver (never a DSN, never a hand-rolled env read).

## Install

```bash
bun install -g @hasna/shortlinks
```

The on-box SQLite database (used only under the explicit local opt-in, see
[Storage selection](#storage-selection)) lives at:

```bash
~/.hasna/shortlinks/shortlinks.db
```

## Client 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:

```bash
# 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 8787
```

With 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`:

```bash
shortlinks --json create https://example.com --domain sho.rt
shortlinks --json link list
shortlinks --json stats docs --domain sho.rt
shortlinks --json doctor
```

Errors are emitted as:

```json
{ "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:

```bash
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:

```text
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

```bash
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 doctor
```

## Local Domain Setup

Record a local mapping with the `machines` CLI and print the remaining hosts/proxy setup:

```bash
shortlinks local setup sho.rt --port 8787
shortlinks local plan sho.rt --port 8787
```

The 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:

```text
client -> Shortlinks /v1 -> Domains /v1 -> registrar + DNS + delegation + router binding
```

Every purchase requires an explicit total price cap, renewal decision, and
stable idempotency key. The CLI derives `shortlinks-domain:<hostname>` unless a
key is supplied:

```bash
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:

```bash
export HASNA_DOMAINS_API_KEY=... # domains:read + domains:purchase
export HASNA_DOMAINS_API_URL=https://domains.example.net
```

`HASNA_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 `/v1` HTTP API** (when a credential resolves): the @hasna/contracts
  chain supplies the key — the Keychain item `hasna.credentials.shortlinks.api-key`,
  `~/.hasna/shortlinks/config/credentials` (0400/0600), or `HASNA_SHORTLINKS_API_KEY`
  (legacy alias `SHORTLINKS_API_KEY`) — and the authority follows
  `HASNA_SHORTLINKS_API_URL` (alias `SHORTLINKS_API_URL`) or defaults to the
  fleet gateway `https://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.db` is used ONLY when the local backend is
  explicitly selected with `HASNA_SHORTLINKS_LOCAL=1` / `SHORTLINKS_LOCAL=1` —
  and selecting it prints `shortlinks: LOCAL mode — …` on stderr, once per
  process. `--db <path>` (and `SHORTLINKS_DB`) only choose WHICH file that
  opted-in run uses; `--db` alone 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 the `shortlinks`, `shortlinks-mcp`, and `./sdk` entry 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.db` on its own.

```bash
# 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.rt
```

The 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

```bash
bun install
bun test
bun run typecheck
bun run build
```

## Repository

The OSS repository is expected to be:

```text
hasna/shortlinks
```

The 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](LICENSE).
