@hasna/shortlinks 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ import type { AddDomainInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
2
+ export interface ShortlinksApiClientOptions {
3
+ baseUrl?: string;
4
+ token?: string;
5
+ }
6
+ export declare class ShortlinksApiClient {
7
+ private readonly options;
8
+ constructor(options?: ShortlinksApiClientOptions);
9
+ private url;
10
+ private headers;
11
+ totalStats(): Promise<{
12
+ domains: number;
13
+ links: number;
14
+ clicks: number;
15
+ }>;
16
+ createLink(input: CreateLinkInput): Promise<Link>;
17
+ listLinks(options?: {
18
+ domain?: string;
19
+ activeOnly?: boolean;
20
+ limit?: number;
21
+ }): Promise<Link[]>;
22
+ getLink(domainOrSlug: string, maybeSlug?: string): Promise<Link | null>;
23
+ setLinkActive(domainOrSlug: string, maybeSlugOrActive: string | boolean, maybeActive?: boolean): Promise<Link>;
24
+ deleteLink(domainOrSlug: string, maybeSlug?: string): Promise<Link>;
25
+ getStats(domainOrSlug: string, maybeSlug?: string): Promise<LinkStats>;
26
+ addDomain(input: AddDomainInput): Promise<Domain>;
27
+ listDomains(): Promise<Domain[]>;
28
+ getDomain(hostname: string): Promise<Domain | null>;
29
+ close(): Promise<void>;
30
+ }
package/dist/cli/index.js CHANGED
@@ -2121,17 +2121,31 @@ function saveConfig(config) {
2121
2121
  `);
2122
2122
  }
2123
2123
  function updateConfig(patch) {
2124
+ const current = loadConfig();
2124
2125
  const next = {
2125
- ...loadConfig(),
2126
+ ...current,
2126
2127
  ...patch,
2128
+ api: {
2129
+ ...current.api,
2130
+ ...patch.api
2131
+ },
2127
2132
  cloudflare: {
2128
- ...loadConfig().cloudflare,
2133
+ ...current.cloudflare,
2129
2134
  ...patch.cloudflare
2130
2135
  }
2131
2136
  };
2132
2137
  saveConfig(next);
2133
2138
  return next;
2134
2139
  }
2140
+ function getApiBaseUrl(config = loadConfig()) {
2141
+ const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
2142
+ return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
2143
+ }
2144
+ function getApiToken(config = loadConfig()) {
2145
+ const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
2146
+ const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
2147
+ return token || null;
2148
+ }
2135
2149
  function normalizeHostname(input) {
2136
2150
  const raw = input.trim().toLowerCase();
2137
2151
  if (!raw)
@@ -2273,6 +2287,10 @@ var init_database = __esm(() => {
2273
2287
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
2274
2288
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
2275
2289
  CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
2290
+ `,
2291
+ `
2292
+ ALTER TABLE links ADD COLUMN max_uses INTEGER;
2293
+ ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
2276
2294
  `
2277
2295
  ];
2278
2296
  });
@@ -7385,6 +7403,10 @@ var init_pg_migrations = __esm(() => {
7385
7403
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
7386
7404
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
7387
7405
  CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
7406
+ `,
7407
+ `
7408
+ ALTER TABLE links ADD COLUMN IF NOT EXISTS max_uses INTEGER;
7409
+ ALTER TABLE links ADD COLUMN IF NOT EXISTS used_count INTEGER NOT NULL DEFAULT 0;
7388
7410
  `
7389
7411
  ];
7390
7412
  });
@@ -8878,6 +8900,8 @@ function linkFromRow(row) {
8878
8900
  return {
8879
8901
  ...row,
8880
8902
  active: Boolean(row.active),
8903
+ max_uses: row.max_uses ?? null,
8904
+ used_count: row.used_count ?? 0,
8881
8905
  metadata: parseJsonObject2(row.metadata),
8882
8906
  short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
8883
8907
  };
@@ -8908,6 +8932,13 @@ function isoOrNull(input) {
8908
8932
  throw new Error(`Invalid date: ${input}`);
8909
8933
  return date.toISOString();
8910
8934
  }
8935
+ function normalizeMaxUses(value) {
8936
+ if (value === null || value === undefined)
8937
+ return null;
8938
+ if (!Number.isInteger(value) || value <= 0)
8939
+ throw new Error("maxUses must be a positive integer.");
8940
+ return value;
8941
+ }
8911
8942
 
8912
8943
  class ShortlinksStore {
8913
8944
  database;
@@ -8983,15 +9014,16 @@ class ShortlinksStore {
8983
9014
  const timestamp = now2();
8984
9015
  const machineId = getMachineId();
8985
9016
  const expiresAt = isoOrNull(input.expiresAt);
9017
+ const maxUses = normalizeMaxUses(input.maxUses);
8986
9018
  const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
8987
9019
  try {
8988
9020
  this.database.db.query(`
8989
9021
  INSERT INTO links (
8990
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
9022
+ id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
8991
9023
  machine_id, synced_at, created_at, updated_at
8992
9024
  )
8993
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
8994
- `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
9025
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
9026
+ `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
8995
9027
  } catch (error) {
8996
9028
  const message = error instanceof Error ? error.message : String(error);
8997
9029
  if (message.includes("UNIQUE")) {
@@ -9001,6 +9033,19 @@ class ShortlinksStore {
9001
9033
  }
9002
9034
  return this.getLink(domain.hostname, slug);
9003
9035
  }
9036
+ consumeLinkUse(link) {
9037
+ const timestamp = now2();
9038
+ const result = this.database.db.query(`
9039
+ UPDATE links
9040
+ SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
9041
+ WHERE id = ?
9042
+ AND active = 1
9043
+ AND (max_uses IS NULL OR used_count < max_uses)
9044
+ `).run(timestamp, link.id);
9045
+ if (result.changes === 0)
9046
+ return null;
9047
+ return this.getLink(link.hostname, link.slug);
9048
+ }
9004
9049
  listLinks(options = {}) {
9005
9050
  const params = [];
9006
9051
  let where = "WHERE 1 = 1";
@@ -9187,6 +9232,8 @@ function linkFromRow2(row) {
9187
9232
  return {
9188
9233
  ...row,
9189
9234
  active: Boolean(row.active),
9235
+ max_uses: row.max_uses ?? null,
9236
+ used_count: row.used_count ?? 0,
9190
9237
  expires_at: nullableIso(row.expires_at),
9191
9238
  synced_at: nullableIso(row.synced_at),
9192
9239
  created_at: toIsoString(row.created_at),
@@ -9215,6 +9262,13 @@ function isoOrNull2(input) {
9215
9262
  throw new Error(`Invalid date: ${input}`);
9216
9263
  return date.toISOString();
9217
9264
  }
9265
+ function normalizeMaxUses2(value) {
9266
+ if (value === null || value === undefined)
9267
+ return null;
9268
+ if (!Number.isInteger(value) || value <= 0)
9269
+ throw new Error("maxUses must be a positive integer.");
9270
+ return value;
9271
+ }
9218
9272
  function clickFromRow2(row) {
9219
9273
  return {
9220
9274
  ...row,
@@ -9302,15 +9356,16 @@ class PgShortlinksStore {
9302
9356
  const timestamp = now2();
9303
9357
  const machineId = getMachineId();
9304
9358
  const expiresAt = isoOrNull2(input.expiresAt);
9359
+ const maxUses = normalizeMaxUses2(input.maxUses);
9305
9360
  const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
9306
9361
  try {
9307
9362
  await this.pg.run(`
9308
9363
  INSERT INTO links (
9309
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
9364
+ id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
9310
9365
  machine_id, synced_at, created_at, updated_at
9311
9366
  )
9312
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
9313
- `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
9367
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
9368
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
9314
9369
  } catch (error) {
9315
9370
  const message = error instanceof Error ? error.message : String(error);
9316
9371
  if (message.includes("unique") || message.includes("duplicate")) {
@@ -9320,6 +9375,22 @@ class PgShortlinksStore {
9320
9375
  }
9321
9376
  return await this.getLink(domain.hostname, slug);
9322
9377
  }
9378
+ async consumeLinkUse(link) {
9379
+ const timestamp = now2();
9380
+ await this.pg.run(`
9381
+ UPDATE links
9382
+ SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
9383
+ WHERE id = ?
9384
+ AND active = 1
9385
+ AND (max_uses IS NULL OR used_count < max_uses)
9386
+ `, timestamp, link.id);
9387
+ const refreshed = await this.getLink(link.hostname, link.slug);
9388
+ if (!refreshed)
9389
+ return null;
9390
+ if (link.max_uses !== null && refreshed.used_count === link.used_count)
9391
+ return null;
9392
+ return refreshed;
9393
+ }
9323
9394
  async listLinks(options = {}) {
9324
9395
  const params = [];
9325
9396
  let where = "WHERE 1 = 1";
@@ -9473,6 +9544,157 @@ class PgShortlinksStore {
9473
9544
  // src/cli/index.ts
9474
9545
  init_config();
9475
9546
 
9547
+ // src/api-client.ts
9548
+ init_config();
9549
+ function normalizeBaseUrl(value) {
9550
+ return value.replace(/\/+$/, "");
9551
+ }
9552
+ function requireBaseUrl(options) {
9553
+ const baseUrl = options.baseUrl || getApiBaseUrl(loadConfig());
9554
+ if (!baseUrl) {
9555
+ throw new Error("Shortlinks API URL is not configured. Run `shortlinks config set api-url <url>` or set SHORTLINKS_API_URL.");
9556
+ }
9557
+ return normalizeBaseUrl(baseUrl);
9558
+ }
9559
+ function requireToken(options) {
9560
+ const token = options.token || getApiToken(loadConfig());
9561
+ if (!token) {
9562
+ throw new Error("Shortlinks API token is not configured. Set SHORTLINKS_API_TOKEN or run `shortlinks config set api-token-env <name>`.");
9563
+ }
9564
+ return token;
9565
+ }
9566
+ async function readJson(response) {
9567
+ const text = await response.text();
9568
+ let parsed = {};
9569
+ if (text) {
9570
+ try {
9571
+ parsed = JSON.parse(text);
9572
+ } catch {
9573
+ parsed = { error: text };
9574
+ }
9575
+ }
9576
+ if (!response.ok) {
9577
+ const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}`;
9578
+ throw new Error(message);
9579
+ }
9580
+ return parsed;
9581
+ }
9582
+
9583
+ class ShortlinksApiClient {
9584
+ options;
9585
+ constructor(options = {}) {
9586
+ this.options = options;
9587
+ }
9588
+ url(path, query) {
9589
+ const url = new URL(`${requireBaseUrl(this.options)}${path}`);
9590
+ for (const [key, value] of Object.entries(query ?? {})) {
9591
+ if (value !== undefined && value !== "")
9592
+ url.searchParams.set(key, String(value));
9593
+ }
9594
+ return url.toString();
9595
+ }
9596
+ headers(extra) {
9597
+ return {
9598
+ authorization: `Bearer ${requireToken(this.options)}`,
9599
+ ...extra ?? {}
9600
+ };
9601
+ }
9602
+ async totalStats() {
9603
+ const response = await fetch(this.url("/stats"), { headers: this.headers() });
9604
+ return readJson(response);
9605
+ }
9606
+ async createLink(input) {
9607
+ const response = await fetch(this.url("/links"), {
9608
+ method: "POST",
9609
+ headers: this.headers({ "content-type": "application/json" }),
9610
+ body: JSON.stringify({
9611
+ destination_url: input.destinationUrl,
9612
+ domain: input.domain,
9613
+ slug: input.slug,
9614
+ title: input.title,
9615
+ expires_at: input.expiresAt,
9616
+ max_uses: input.maxUses,
9617
+ length: input.slugLength
9618
+ })
9619
+ });
9620
+ return readJson(response);
9621
+ }
9622
+ async listLinks(options = {}) {
9623
+ const response = await fetch(this.url("/links", {
9624
+ domain: options.domain,
9625
+ active: options.activeOnly,
9626
+ limit: options.limit
9627
+ }), { headers: this.headers() });
9628
+ return readJson(response);
9629
+ }
9630
+ async getLink(domainOrSlug, maybeSlug) {
9631
+ const slug = maybeSlug ?? domainOrSlug;
9632
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
9633
+ domain: maybeSlug ? domainOrSlug : undefined
9634
+ }), { headers: this.headers() });
9635
+ if (response.status === 404)
9636
+ return null;
9637
+ return readJson(response);
9638
+ }
9639
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
9640
+ const hasDomain = typeof maybeSlugOrActive === "string";
9641
+ const slug = hasDomain ? maybeSlugOrActive : domainOrSlug;
9642
+ const active = hasDomain ? Boolean(maybeActive) : Boolean(maybeSlugOrActive);
9643
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}/active`, {
9644
+ domain: hasDomain ? domainOrSlug : undefined
9645
+ }), {
9646
+ method: "POST",
9647
+ headers: this.headers({ "content-type": "application/json" }),
9648
+ body: JSON.stringify({ active })
9649
+ });
9650
+ return readJson(response);
9651
+ }
9652
+ async deleteLink(domainOrSlug, maybeSlug) {
9653
+ const slug = maybeSlug ?? domainOrSlug;
9654
+ const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
9655
+ domain: maybeSlug ? domainOrSlug : undefined
9656
+ }), {
9657
+ method: "DELETE",
9658
+ headers: this.headers()
9659
+ });
9660
+ return readJson(response);
9661
+ }
9662
+ async getStats(domainOrSlug, maybeSlug) {
9663
+ const slug = maybeSlug ?? domainOrSlug;
9664
+ const response = await fetch(this.url(`/stats/${encodeURIComponent(slug)}`, {
9665
+ domain: maybeSlug ? domainOrSlug : undefined
9666
+ }), { headers: this.headers() });
9667
+ return readJson(response);
9668
+ }
9669
+ async addDomain(input) {
9670
+ const response = await fetch(this.url("/domains"), {
9671
+ method: "POST",
9672
+ headers: this.headers({ "content-type": "application/json" }),
9673
+ body: JSON.stringify({
9674
+ hostname: input.hostname,
9675
+ provider: input.provider,
9676
+ default_domain: input.defaultDomain,
9677
+ origin_url: input.originUrl,
9678
+ notes: input.notes
9679
+ })
9680
+ });
9681
+ return readJson(response);
9682
+ }
9683
+ async listDomains() {
9684
+ const response = await fetch(this.url("/domains"), { headers: this.headers() });
9685
+ return readJson(response);
9686
+ }
9687
+ async getDomain(hostname2) {
9688
+ const response = await fetch(this.url(`/domains/${encodeURIComponent(hostname2)}`), { headers: this.headers() });
9689
+ if (response.status === 404)
9690
+ return null;
9691
+ return readJson(response);
9692
+ }
9693
+ async close() {
9694
+ return;
9695
+ }
9696
+ }
9697
+
9476
9698
  // src/server.ts
9477
9699
  function json(data, status = 200) {
9478
9700
  return new Response(JSON.stringify(data, null, 2), {
@@ -9480,6 +9702,121 @@ function json(data, status = 200) {
9480
9702
  headers: { "content-type": "application/json; charset=utf-8" }
9481
9703
  });
9482
9704
  }
9705
+ function apiToken(options) {
9706
+ return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
9707
+ }
9708
+ function requestToken(request) {
9709
+ const auth = request.headers.get("authorization") || "";
9710
+ const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
9711
+ return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
9712
+ }
9713
+ function isAuthorized(request, options) {
9714
+ const token = apiToken(options);
9715
+ if (!token)
9716
+ return true;
9717
+ return requestToken(request) === token;
9718
+ }
9719
+ async function readJsonBody(request) {
9720
+ try {
9721
+ const parsed = await request.json();
9722
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
9723
+ } catch {
9724
+ return {};
9725
+ }
9726
+ }
9727
+ function requireMethod(store, method) {
9728
+ const fn = store[method];
9729
+ if (typeof fn !== "function")
9730
+ throw new Error(`Store does not support ${String(method)}.`);
9731
+ return fn.bind(store);
9732
+ }
9733
+ async function handleApi(request, apiPath, store, options) {
9734
+ if (apiPath === "/health") {
9735
+ return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
9736
+ }
9737
+ if (!isAuthorized(request, options))
9738
+ return json({ error: "Unauthorized" }, 401);
9739
+ const url = new URL(request.url);
9740
+ const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
9741
+ try {
9742
+ if (apiPath === "/links" && request.method === "GET") {
9743
+ const listLinks = requireMethod(store, "listLinks");
9744
+ return json(await listLinks({
9745
+ domain: url.searchParams.get("domain") || undefined,
9746
+ activeOnly: url.searchParams.get("active") === "true",
9747
+ limit: Number(url.searchParams.get("limit") || "100")
9748
+ }));
9749
+ }
9750
+ if (apiPath === "/links" && request.method === "POST") {
9751
+ const body = await readJsonBody(request);
9752
+ const destinationUrl = String(body.destination_url || body.url || "");
9753
+ if (!destinationUrl)
9754
+ return json({ error: "destination_url is required" }, 400);
9755
+ const createLink = requireMethod(store, "createLink");
9756
+ return json(await createLink({
9757
+ destinationUrl,
9758
+ domain: typeof body.domain === "string" ? body.domain : undefined,
9759
+ slug: typeof body.slug === "string" ? body.slug : undefined,
9760
+ title: typeof body.title === "string" ? body.title : undefined,
9761
+ expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
9762
+ maxUses: typeof body.max_uses === "number" ? body.max_uses : typeof body.maxUses === "number" ? body.maxUses : undefined,
9763
+ slugLength: typeof body.length === "number" ? body.length : undefined
9764
+ }), 201);
9765
+ }
9766
+ if (segments[0] === "links" && segments[1] && request.method === "GET") {
9767
+ const getLink = requireMethod(store, "getLink");
9768
+ const domain = url.searchParams.get("domain") || undefined;
9769
+ const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
9770
+ return link ? json(link) : json({ error: "Link not found." }, 404);
9771
+ }
9772
+ if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
9773
+ const deleteLink = requireMethod(store, "deleteLink");
9774
+ const domain = url.searchParams.get("domain") || undefined;
9775
+ return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
9776
+ }
9777
+ if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
9778
+ const body = await readJsonBody(request);
9779
+ const setLinkActive = requireMethod(store, "setLinkActive");
9780
+ const domain = url.searchParams.get("domain") || undefined;
9781
+ const active = Boolean(body.active);
9782
+ return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
9783
+ }
9784
+ if (apiPath === "/stats" && request.method === "GET") {
9785
+ return json(await store.totalStats());
9786
+ }
9787
+ if (segments[0] === "stats" && segments[1] && request.method === "GET") {
9788
+ const getStats = requireMethod(store, "getStats");
9789
+ const domain = url.searchParams.get("domain") || undefined;
9790
+ return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
9791
+ }
9792
+ if (apiPath === "/domains" && request.method === "GET") {
9793
+ const listDomains = requireMethod(store, "listDomains");
9794
+ return json(await listDomains());
9795
+ }
9796
+ if (apiPath === "/domains" && request.method === "POST") {
9797
+ const body = await readJsonBody(request);
9798
+ const hostname2 = String(body.hostname || "");
9799
+ if (!hostname2)
9800
+ return json({ error: "hostname is required" }, 400);
9801
+ const addDomain = requireMethod(store, "addDomain");
9802
+ return json(await addDomain({
9803
+ hostname: hostname2,
9804
+ provider: typeof body.provider === "string" ? body.provider : "manual",
9805
+ defaultDomain: Boolean(body.default_domain || body.default),
9806
+ originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
9807
+ notes: typeof body.notes === "string" ? body.notes : undefined
9808
+ }), 201);
9809
+ }
9810
+ if (segments[0] === "domains" && segments[1] && request.method === "GET") {
9811
+ const getDomain = requireMethod(store, "getDomain");
9812
+ const domain = await getDomain(segments[1]);
9813
+ return domain ? json(domain) : json({ error: "Domain not found." }, 404);
9814
+ }
9815
+ } catch (error) {
9816
+ return json({ error: error instanceof Error ? error.message : String(error) }, 400);
9817
+ }
9818
+ return json({ error: "Not found." }, 404);
9819
+ }
9483
9820
  function getHost(request, fallback) {
9484
9821
  const forwarded = request.headers.get("x-forwarded-host");
9485
9822
  const host = forwarded || request.headers.get("host") || fallback || "";
@@ -9498,8 +9835,13 @@ function createShortlinksHandler(options = {}) {
9498
9835
  const store = options.store || new ShortlinksStore(options.dbPath);
9499
9836
  const redirectStatus = options.redirectStatus || 302;
9500
9837
  const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
9838
+ const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
9501
9839
  return async (request) => {
9502
9840
  const url = new URL(request.url);
9841
+ if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
9842
+ const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
9843
+ return handleApi(request, apiPath, store, options);
9844
+ }
9503
9845
  if (url.pathname === "/healthz") {
9504
9846
  return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
9505
9847
  }
@@ -9532,7 +9874,14 @@ function createShortlinksHandler(options = {}) {
9532
9874
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
9533
9875
  if (isExpired(link))
9534
9876
  return json({ error: "Shortlink is expired.", slug, host }, 410);
9535
- await store.recordClick(link, {
9877
+ if (link.max_uses !== null && link.used_count >= link.max_uses) {
9878
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
9879
+ }
9880
+ const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
9881
+ if (!consumed) {
9882
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
9883
+ }
9884
+ await store.recordClick(consumed, {
9536
9885
  ip: getClientIp(request),
9537
9886
  userAgent: request.headers.get("user-agent"),
9538
9887
  referer: request.headers.get("referer"),
@@ -9635,9 +9984,9 @@ SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
9635
9984
  return { workerPath, wranglerPath };
9636
9985
  }
9637
9986
  function cloudflareAuthHeaders(token) {
9638
- const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
9639
- if (apiToken)
9640
- return { authorization: `Bearer ${apiToken}` };
9987
+ const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
9988
+ if (apiToken2)
9989
+ return { authorization: `Bearer ${apiToken2}` };
9641
9990
  const apiKey = process.env.CLOUDFLARE_API_KEY;
9642
9991
  const email = process.env.CLOUDFLARE_EMAIL;
9643
9992
  if (apiKey && email) {
@@ -9823,13 +10172,19 @@ function withStore(fn) {
9823
10172
  }
9824
10173
  function storeMode() {
9825
10174
  const opts = program2.opts();
9826
- const value = String(opts.remote ? "remote" : opts.store || process.env.SHORTLINKS_STORE || "local").toLowerCase();
9827
- if (value !== "local" && value !== "remote")
10175
+ const config = loadConfig();
10176
+ const value = String(opts.remote ? "remote" : opts.store || process.env.SHORTLINKS_STORE || config.mode || "local").toLowerCase();
10177
+ if (value !== "local" && value !== "remote" && value !== "api")
9828
10178
  throw new Error(`Unknown store mode: ${value}`);
9829
10179
  return value;
9830
10180
  }
9831
10181
  async function withRuntimeStore(fn) {
9832
- if (storeMode() === "remote") {
10182
+ const mode = storeMode();
10183
+ if (mode === "api") {
10184
+ const store2 = new ShortlinksApiClient({ baseUrl: program2.opts().apiUrl });
10185
+ return await fn(store2);
10186
+ }
10187
+ if (mode === "remote") {
9833
10188
  const store2 = await PgShortlinksStore.fromStorage("shortlinks");
9834
10189
  try {
9835
10190
  return await fn(store2);
@@ -9851,7 +10206,16 @@ function commandExists(command) {
9851
10206
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
9852
10207
  return result.status === 0;
9853
10208
  }
9854
- program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and storage sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: remote or local", process.env.SHORTLINKS_STORE || "local").addOption(new Option("--remote", "Use the shortlinks PostgreSQL storage database directly")).option("-j, --json", "Output JSON for agents and scripts");
10209
+ function parseMaxUses(opts) {
10210
+ const raw = opts.maxUses ?? opts.maxClicks;
10211
+ if (!raw)
10212
+ return;
10213
+ const parsed = Number(raw);
10214
+ if (!Number.isInteger(parsed) || parsed <= 0)
10215
+ throw new Error("--max-uses must be a positive integer");
10216
+ return parsed;
10217
+ }
10218
+ program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and storage sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: local, remote, or api", process.env.SHORTLINKS_STORE || loadConfig().mode || "local").option("--api-url <url>", "Shortlinks HTTP API base URL").addOption(new Option("--remote", "Use the shortlinks PostgreSQL storage database directly")).option("-j, --json", "Output JSON for agents and scripts");
9855
10219
  program2.command("init").description("Initialize local shortlinks storage").option("--domain <hostname>", "Add a default shortlink domain").option("--public-base-url <url>", "Public URL base for generated links").option("-j, --json", "Output JSON").action(async (opts) => {
9856
10220
  try {
9857
10221
  const result = await withRuntimeStore(async (store) => {
@@ -9891,19 +10255,42 @@ program2.command("init").description("Initialize local shortlinks storage").opti
9891
10255
  });
9892
10256
  var configCmd = program2.command("config").description("View and update local config");
9893
10257
  configCmd.command("show").description("Show local config").option("-j, --json", "Output JSON").action((opts) => {
9894
- const data = { path: getConfigPath(), config: loadConfig() };
10258
+ const config = loadConfig();
10259
+ const data = {
10260
+ path: getConfigPath(),
10261
+ config: {
10262
+ ...config,
10263
+ api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
10264
+ }
10265
+ };
9895
10266
  print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
9896
10267
  });
9897
- configCmd.command("set <key> <value>").description("Set config value: default-domain, public-base-url, cloudflare-account-id, cloudflare-worker-name, cloudflare-origin").option("-j, --json", "Output JSON").action((key, value, opts) => {
10268
+ configCmd.command("set <key> <value>").description("Set config value: mode, default-domain, public-base-url, api-url, api-token, api-token-env, cloudflare-account-id, cloudflare-worker-name, cloudflare-origin").option("-j, --json", "Output JSON").action((key, value, opts) => {
9898
10269
  try {
9899
10270
  let config = loadConfig();
9900
10271
  switch (key) {
10272
+ case "mode": {
10273
+ const mode = value.toLowerCase();
10274
+ if (mode !== "local" && mode !== "remote" && mode !== "api")
10275
+ throw new Error("mode must be local, remote, or api");
10276
+ config = updateConfig({ mode });
10277
+ break;
10278
+ }
9901
10279
  case "default-domain":
9902
10280
  config = updateConfig({ defaultDomain: value, publicBaseUrl: config.publicBaseUrl || `https://${value}` });
9903
10281
  break;
9904
10282
  case "public-base-url":
9905
10283
  config = updateConfig({ publicBaseUrl: value });
9906
10284
  break;
10285
+ case "api-url":
10286
+ config = updateConfig({ api: { baseUrl: value.replace(/\/+$/, "") } });
10287
+ break;
10288
+ case "api-token":
10289
+ config = updateConfig({ api: { token: value } });
10290
+ break;
10291
+ case "api-token-env":
10292
+ config = updateConfig({ api: { tokenEnv: value } });
10293
+ break;
9907
10294
  case "cloudflare-account-id":
9908
10295
  config = updateConfig({ cloudflare: { accountId: value } });
9909
10296
  break;
@@ -9916,7 +10303,11 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
9916
10303
  default:
9917
10304
  throw new Error(`Unknown config key: ${key}`);
9918
10305
  }
9919
- print2({ path: getConfigPath(), config }, opts, () => console.log(source_default.green(`Set ${key}.`)));
10306
+ const masked = {
10307
+ ...config,
10308
+ api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
10309
+ };
10310
+ print2({ path: getConfigPath(), config: masked }, opts, () => console.log(source_default.green(`Set ${key}.`)));
9920
10311
  } catch (error) {
9921
10312
  handleError(error);
9922
10313
  }
@@ -10027,6 +10418,7 @@ async function createLinkAction(url, opts) {
10027
10418
  slug: opts.slug,
10028
10419
  title: opts.title,
10029
10420
  expiresAt: opts.expires,
10421
+ maxUses: parseMaxUses(opts),
10030
10422
  slugLength: opts.length ? Number(opts.length) : undefined
10031
10423
  }));
10032
10424
  print2(link, opts, () => console.log(formatLink(link)));
@@ -10034,8 +10426,8 @@ async function createLinkAction(url, opts) {
10034
10426
  handleError(error);
10035
10427
  }
10036
10428
  }
10037
- linkCmd.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
10038
- program2.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
10429
+ linkCmd.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--max-uses <count>", "Maximum successful redirects").option("--max-clicks <count>", "Alias for --max-uses").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
10430
+ program2.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--max-uses <count>", "Maximum successful redirects").option("--max-clicks <count>", "Alias for --max-uses").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
10039
10431
  linkCmd.command("list").description("List shortlinks").option("--domain <hostname>", "Filter by domain").option("--active", "Only active links").option("--limit <n>", "Maximum rows", "100").option("-j, --json", "Output JSON").action(async (opts) => {
10040
10432
  try {
10041
10433
  const links = await withRuntimeStore((store) => store.listLinks({
@@ -10111,7 +10503,7 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
10111
10503
  handleError(error);
10112
10504
  }
10113
10505
  });
10114
- program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").option("--remote", "Serve directly from the shortlinks PostgreSQL storage database").addOption(new Option("--cloud", "Deprecated alias for --remote").hideHelp()).action(async (opts) => {
10506
+ program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").option("--api-path-prefix <path>", "Admin API path prefix", process.env.SHORTLINKS_API_PATH_PREFIX || "/api").option("--remote", "Serve directly from the shortlinks PostgreSQL storage database").addOption(new Option("--cloud", "Deprecated alias for --remote").hideHelp()).action(async (opts) => {
10115
10507
  try {
10116
10508
  const store = opts.remote || opts.cloud || storeMode() === "remote" ? await PgShortlinksStore.fromStorage("shortlinks") : undefined;
10117
10509
  const server = serveShortlinks({
@@ -10119,7 +10511,8 @@ program2.command("serve").description("Run the redirect server that records clic
10119
10511
  dbPath: program2.opts().db,
10120
10512
  host: opts.host,
10121
10513
  port: Number(opts.port),
10122
- defaultHost: opts.defaultHost
10514
+ defaultHost: opts.defaultHost,
10515
+ apiPathPrefix: opts.apiPathPrefix
10123
10516
  });
10124
10517
  const mode = store ? "remote" : "local";
10125
10518
  console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));