@hasna/shortlinks 0.1.22 → 0.1.24

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,1493 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ var __create = Object.create;
4
+ var __getProtoOf = Object.getPrototypeOf;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ function __accessProp(key) {
9
+ return this[key];
10
+ }
11
+ var __toESMCache_node;
12
+ var __toESMCache_esm;
13
+ var __toESM = (mod, isNodeMode, target) => {
14
+ var canCache = mod != null && typeof mod === "object";
15
+ if (canCache) {
16
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
+ var cached = cache.get(mod);
18
+ if (cached)
19
+ return cached;
20
+ }
21
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
22
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
23
+ for (let key of __getOwnPropNames(mod))
24
+ if (!__hasOwnProp.call(to, key))
25
+ __defProp(to, key, {
26
+ get: __accessProp.bind(mod, key),
27
+ enumerable: true
28
+ });
29
+ if (canCache)
30
+ cache.set(mod, to);
31
+ return to;
32
+ };
33
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
+ var __require = import.meta.require;
35
+
36
+ // src/runtime.ts
37
+ var SHORTLINKS_RUNTIME_ENV = {
38
+ store: "HASNA_SHORTLINKS_STORE",
39
+ databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
40
+ databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
41
+ };
42
+ var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
43
+ store: "SHORTLINKS_STORE",
44
+ databaseUrl: "SHORTLINKS_DATABASE_URL",
45
+ databaseSsl: "SHORTLINKS_DATABASE_SSL"
46
+ };
47
+ var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
48
+ var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
49
+ var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
50
+ function getCanonicalShortlinksPostgresConfig() {
51
+ return {
52
+ cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
53
+ database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
54
+ runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
55
+ primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
56
+ fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
57
+ };
58
+ }
59
+ function parseShortlinksStoreMode(value) {
60
+ const normalized = clean(value)?.toLowerCase();
61
+ if (!normalized)
62
+ return "local";
63
+ if (normalized === "local" || normalized === "postgres")
64
+ return normalized;
65
+ if (normalized === "pg")
66
+ return "postgres";
67
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
68
+ }
69
+ function getShortlinksStoreMode(env = process.env) {
70
+ return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
71
+ }
72
+ function getShortlinksDatabaseUrl(env = process.env) {
73
+ return readRuntimeEnv(env, "databaseUrl").value;
74
+ }
75
+ function getShortlinksDatabaseSsl(env = process.env) {
76
+ return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
77
+ }
78
+ function getShortlinksRuntimeEnvName(env, key) {
79
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
80
+ if (clean(env[primary]))
81
+ return primary;
82
+ return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
83
+ }
84
+ function loadShortlinksRuntimeConfig(env = process.env) {
85
+ const mode = getShortlinksStoreMode(env);
86
+ const databaseUrl = getShortlinksDatabaseUrl(env);
87
+ return {
88
+ service: "shortlinks",
89
+ mode,
90
+ ...databaseUrl ? {
91
+ database: {
92
+ provider: "postgres",
93
+ url: databaseUrl,
94
+ ssl: getShortlinksDatabaseSsl(env)
95
+ }
96
+ } : {}
97
+ };
98
+ }
99
+ function assertShortlinksPostgresConfig(config) {
100
+ if (config.mode !== "postgres")
101
+ return;
102
+ if (!config.database?.url) {
103
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
104
+ }
105
+ }
106
+ function getShortlinksRuntimeStatus(env = process.env) {
107
+ const issues = [];
108
+ const warnings = [];
109
+ let config;
110
+ try {
111
+ config = loadShortlinksRuntimeConfig(env);
112
+ } catch (error) {
113
+ issues.push(error instanceof Error ? error.message : String(error));
114
+ config = { service: "shortlinks", mode: "local" };
115
+ }
116
+ try {
117
+ assertShortlinksPostgresConfig(config);
118
+ } catch (error) {
119
+ issues.push(error instanceof Error ? error.message : String(error));
120
+ }
121
+ if (config.mode === "local" && config.database?.url) {
122
+ warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
123
+ }
124
+ return {
125
+ ok: issues.length === 0,
126
+ service: "shortlinks",
127
+ mode: config.mode,
128
+ local_default: config.mode === "local",
129
+ postgres_enabled: config.mode === "postgres",
130
+ database: {
131
+ configured: Boolean(config.database?.url),
132
+ provider: config.database?.provider ?? null,
133
+ redacted_url: redactDatabaseUrl(config.database?.url),
134
+ ssl: config.database?.ssl ?? null
135
+ },
136
+ env: runtimeEnvStatus(env),
137
+ canonical: getCanonicalShortlinksPostgresConfig(),
138
+ issues,
139
+ warnings,
140
+ no_network: true
141
+ };
142
+ }
143
+ function redactDatabaseUrl(value) {
144
+ if (!value)
145
+ return null;
146
+ try {
147
+ const url = new URL(value);
148
+ if (url.username)
149
+ url.username = "***";
150
+ if (url.password)
151
+ url.password = "***";
152
+ for (const key of Array.from(url.searchParams.keys())) {
153
+ if (isSensitiveQueryKey(key))
154
+ url.searchParams.set(key, "***");
155
+ }
156
+ return url.toString();
157
+ } catch {
158
+ return "(redacted)";
159
+ }
160
+ }
161
+ function runtimeEnvStatus(env) {
162
+ return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
163
+ const activeName = getShortlinksRuntimeEnvName(env, key);
164
+ return [
165
+ key,
166
+ {
167
+ name,
168
+ active_name: activeName,
169
+ configured: Boolean(clean(env[activeName]))
170
+ }
171
+ ];
172
+ }));
173
+ }
174
+ function readRuntimeEnv(env, key) {
175
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
176
+ const primaryValue = clean(env[primary]);
177
+ if (primaryValue)
178
+ return { name: primary, value: primaryValue };
179
+ const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
180
+ return { name: fallback, value: clean(env[fallback]) };
181
+ }
182
+ function parseBoolean(value, fallback) {
183
+ const normalized = clean(value)?.toLowerCase();
184
+ if (!normalized)
185
+ return fallback;
186
+ if (["1", "true", "yes", "on"].includes(normalized))
187
+ return true;
188
+ if (["0", "false", "no", "off"].includes(normalized))
189
+ return false;
190
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
191
+ }
192
+ function isSensitiveQueryKey(key) {
193
+ return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
194
+ }
195
+ function clean(value) {
196
+ const trimmed = value?.trim();
197
+ return trimmed ? trimmed : undefined;
198
+ }
199
+
200
+ // src/pg-store.ts
201
+ import { createHash } from "crypto";
202
+
203
+ // src/config.ts
204
+ import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
205
+ import { randomBytes } from "crypto";
206
+ import { homedir } from "os";
207
+ import { dirname, join, resolve } from "path";
208
+ var SERVICE_NAME = "shortlinks";
209
+ var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
210
+ function getDataDir() {
211
+ return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
212
+ }
213
+ function ensureDataDir() {
214
+ const dir = getDataDir();
215
+ mkdirSync(dir, { recursive: true });
216
+ return dir;
217
+ }
218
+ function getConfigPath() {
219
+ return join(ensureDataDir(), "config.json");
220
+ }
221
+ function getClickSaltPath() {
222
+ return join(ensureDataDir(), "click-salt");
223
+ }
224
+ function getDatabasePath(explicitPath) {
225
+ if (explicitPath)
226
+ return resolve(explicitPath);
227
+ if (process.env.SHORTLINKS_DB)
228
+ return resolve(process.env.SHORTLINKS_DB);
229
+ return join(ensureDataDir(), `${SERVICE_NAME}.db`);
230
+ }
231
+ function readClickSaltFile(path) {
232
+ try {
233
+ const saved = readFileSync(path, "utf-8").trim();
234
+ return saved || null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+ function clickSaltError(path, error) {
240
+ const detail = error instanceof Error ? error.message : String(error);
241
+ return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
242
+ }
243
+ function getClickSalt() {
244
+ const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
245
+ if (explicit)
246
+ return explicit;
247
+ const path = getClickSaltPath();
248
+ const saved = readClickSaltFile(path);
249
+ if (saved)
250
+ return saved;
251
+ const generated = randomBytes(32).toString("hex");
252
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
253
+ try {
254
+ writeFileSync(tempPath, `${generated}
255
+ `, { flag: "wx", mode: 384 });
256
+ try {
257
+ linkSync(tempPath, path);
258
+ return generated;
259
+ } catch (error) {
260
+ const winner = readClickSaltFile(path);
261
+ if (winner)
262
+ return winner;
263
+ throw clickSaltError(path, error);
264
+ } finally {
265
+ try {
266
+ unlinkSync(tempPath);
267
+ } catch {}
268
+ }
269
+ } catch (error) {
270
+ const winner = readClickSaltFile(path);
271
+ if (winner)
272
+ return winner;
273
+ throw clickSaltError(path, error);
274
+ }
275
+ }
276
+ function loadConfig() {
277
+ const path = getConfigPath();
278
+ if (!existsSync(path))
279
+ return {};
280
+ try {
281
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
282
+ return parsed && typeof parsed === "object" ? parsed : {};
283
+ } catch {
284
+ return {};
285
+ }
286
+ }
287
+ function saveConfig(config) {
288
+ const path = getConfigPath();
289
+ mkdirSync(dirname(path), { recursive: true });
290
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}
291
+ `);
292
+ }
293
+ function updateConfig(patch) {
294
+ const next = {
295
+ ...loadConfig(),
296
+ ...patch,
297
+ cloudflare: {
298
+ ...loadConfig().cloudflare,
299
+ ...patch.cloudflare
300
+ }
301
+ };
302
+ saveConfig(next);
303
+ return next;
304
+ }
305
+ function normalizeHostname(input) {
306
+ const raw = input.trim().toLowerCase();
307
+ if (!raw)
308
+ throw new Error("Domain is required.");
309
+ const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
310
+ let hostname;
311
+ try {
312
+ hostname = new URL(withProtocol).hostname;
313
+ } catch {
314
+ throw new Error(`Invalid domain: ${input}`);
315
+ }
316
+ hostname = hostname.replace(/\.$/, "");
317
+ const labels = hostname.split(".");
318
+ const labelsAreValid = labels.every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9-]+$/.test(label) && !label.startsWith("-") && !label.endsWith("-"));
319
+ if (hostname.length > 253 || !labelsAreValid) {
320
+ throw new Error(`Invalid domain: ${input}`);
321
+ }
322
+ return hostname;
323
+ }
324
+ function formatShortUrl(hostname, slug, publicBaseUrl) {
325
+ if (publicBaseUrl) {
326
+ const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
327
+ return new URL(slug, base).toString();
328
+ }
329
+ return `https://${hostname}/${slug}`;
330
+ }
331
+
332
+ // src/database.ts
333
+ import { Database } from "bun:sqlite";
334
+ import { mkdirSync as mkdirSync2 } from "fs";
335
+ import { dirname as dirname2 } from "path";
336
+ function now() {
337
+ return new Date().toISOString();
338
+ }
339
+ function makeId(prefix) {
340
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
341
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
342
+ return `${prefix}_${hex}`;
343
+ }
344
+ var SQLITE_MIGRATIONS = [
345
+ `
346
+ CREATE TABLE IF NOT EXISTS domains (
347
+ id TEXT PRIMARY KEY,
348
+ hostname TEXT NOT NULL UNIQUE,
349
+ provider TEXT NOT NULL DEFAULT 'manual',
350
+ default_domain INTEGER NOT NULL DEFAULT 0,
351
+ cloudflare_zone_id TEXT,
352
+ cloudflare_account_id TEXT,
353
+ cloudflare_worker_name TEXT,
354
+ origin_url TEXT,
355
+ notes TEXT,
356
+ metadata TEXT NOT NULL DEFAULT '{}',
357
+ machine_id TEXT,
358
+ synced_at TEXT,
359
+ created_at TEXT NOT NULL,
360
+ updated_at TEXT NOT NULL
361
+ );
362
+
363
+ CREATE TABLE IF NOT EXISTS links (
364
+ id TEXT PRIMARY KEY,
365
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
366
+ slug TEXT NOT NULL,
367
+ destination_url TEXT NOT NULL,
368
+ title TEXT,
369
+ active INTEGER NOT NULL DEFAULT 1,
370
+ expires_at TEXT,
371
+ metadata TEXT NOT NULL DEFAULT '{}',
372
+ machine_id TEXT,
373
+ synced_at TEXT,
374
+ created_at TEXT NOT NULL,
375
+ updated_at TEXT NOT NULL,
376
+ UNIQUE(domain_id, slug)
377
+ );
378
+
379
+ CREATE TABLE IF NOT EXISTS clicks (
380
+ id TEXT PRIMARY KEY,
381
+ link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
382
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
383
+ slug TEXT NOT NULL,
384
+ clicked_at TEXT NOT NULL,
385
+ ip_hash TEXT,
386
+ user_agent TEXT,
387
+ referer TEXT,
388
+ country TEXT,
389
+ city TEXT,
390
+ metadata TEXT NOT NULL DEFAULT '{}',
391
+ machine_id TEXT,
392
+ synced_at TEXT,
393
+ created_at TEXT NOT NULL,
394
+ updated_at TEXT NOT NULL
395
+ );
396
+
397
+ CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
398
+ CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
399
+ CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
400
+ CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
401
+ CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
402
+ CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
403
+ CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
404
+ CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
405
+ CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
406
+ `
407
+ ];
408
+
409
+ class ShortlinksDatabase {
410
+ db;
411
+ path;
412
+ constructor(path) {
413
+ this.path = getDatabasePath(path);
414
+ mkdirSync2(dirname2(this.path), { recursive: true });
415
+ this.db = new Database(this.path);
416
+ this.db.exec("PRAGMA foreign_keys = ON;");
417
+ this.applyMigrations();
418
+ }
419
+ close() {
420
+ this.db.close();
421
+ }
422
+ applyMigrations() {
423
+ this.db.exec(`
424
+ CREATE TABLE IF NOT EXISTS _migrations (
425
+ id INTEGER PRIMARY KEY,
426
+ applied_at TEXT NOT NULL
427
+ );
428
+ `);
429
+ for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
430
+ const id = i + 1;
431
+ const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
432
+ if (applied)
433
+ continue;
434
+ const migration = SQLITE_MIGRATIONS[i];
435
+ const apply = this.db.transaction(() => {
436
+ this.db.exec(migration);
437
+ this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
438
+ });
439
+ apply();
440
+ }
441
+ }
442
+ }
443
+
444
+ // src/machine.ts
445
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
446
+ import { hostname } from "os";
447
+ import { join as join2 } from "path";
448
+
449
+ // src/slug.ts
450
+ import { randomBytes as randomBytes2 } from "crypto";
451
+ var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
452
+ var DEFAULT_SLUG_LENGTH = 7;
453
+ function randomToken(length = DEFAULT_SLUG_LENGTH) {
454
+ if (length < 1 || length > 128)
455
+ throw new Error("Token length must be between 1 and 128.");
456
+ const bytes = randomBytes2(length);
457
+ let out = "";
458
+ for (let i = 0;i < length; i += 1) {
459
+ out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
460
+ }
461
+ return out;
462
+ }
463
+ function normalizeSlug(slug) {
464
+ const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
465
+ if (!normalized)
466
+ throw new Error("Slug is required.");
467
+ if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
468
+ throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
469
+ }
470
+ return normalized;
471
+ }
472
+
473
+ // src/machine.ts
474
+ function getMachineId() {
475
+ const path = join2(ensureDataDir(), "machine-id");
476
+ if (existsSync2(path)) {
477
+ const existing = readFileSync2(path, "utf-8").trim();
478
+ if (existing)
479
+ return existing;
480
+ }
481
+ const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
482
+ const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
483
+ writeFileSync2(path, `${id}
484
+ `);
485
+ return id;
486
+ }
487
+
488
+ // src/pg-store.ts
489
+ function parseJsonObject(value) {
490
+ if (!value)
491
+ return {};
492
+ if (typeof value === "object" && !Array.isArray(value))
493
+ return value;
494
+ try {
495
+ const parsed = JSON.parse(String(value));
496
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
497
+ } catch {
498
+ return {};
499
+ }
500
+ }
501
+ async function loadPgPool() {
502
+ const importer = new Function("specifier", "return import(specifier)");
503
+ const module = await importer("pg");
504
+ return module.Pool;
505
+ }
506
+ function toPostgresSql(sql) {
507
+ let index = 0;
508
+ return sql.replace(/\?/g, () => `$${++index}`);
509
+ }
510
+ function createPgPoolConfig(connectionString, options = {}) {
511
+ const ssl = options.ssl ?? true;
512
+ return {
513
+ connectionString,
514
+ ...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
515
+ };
516
+ }
517
+
518
+ class PgPoolAdapter {
519
+ pool;
520
+ constructor(pool) {
521
+ this.pool = pool;
522
+ }
523
+ static async create(connectionString, options = {}) {
524
+ const Pool = await loadPgPool();
525
+ return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
526
+ }
527
+ async get(sql, ...params) {
528
+ const result = await this.pool.query(toPostgresSql(sql), params);
529
+ return result.rows[0] ?? null;
530
+ }
531
+ async all(sql, ...params) {
532
+ const result = await this.pool.query(toPostgresSql(sql), params);
533
+ return result.rows;
534
+ }
535
+ async run(sql, ...params) {
536
+ return this.pool.query(toPostgresSql(sql), params);
537
+ }
538
+ async close() {
539
+ await this.pool.end();
540
+ }
541
+ }
542
+ function toIsoString(value) {
543
+ if (value instanceof Date)
544
+ return value.toISOString();
545
+ return String(value);
546
+ }
547
+ function nullableIso(value) {
548
+ if (value === null || value === undefined)
549
+ return null;
550
+ return toIsoString(value);
551
+ }
552
+ function domainFromRow(row) {
553
+ return {
554
+ ...row,
555
+ default_domain: Boolean(row.default_domain),
556
+ synced_at: nullableIso(row.synced_at),
557
+ created_at: toIsoString(row.created_at),
558
+ updated_at: toIsoString(row.updated_at),
559
+ metadata: parseJsonObject(row.metadata)
560
+ };
561
+ }
562
+ function linkFromRow(row) {
563
+ return {
564
+ ...row,
565
+ active: Boolean(row.active),
566
+ expires_at: nullableIso(row.expires_at),
567
+ synced_at: nullableIso(row.synced_at),
568
+ created_at: toIsoString(row.created_at),
569
+ updated_at: toIsoString(row.updated_at),
570
+ metadata: parseJsonObject(row.metadata),
571
+ short_url: formatShortUrl(row.hostname, row.slug)
572
+ };
573
+ }
574
+ function validateDestinationUrl(url) {
575
+ let parsed;
576
+ try {
577
+ parsed = new URL(url);
578
+ } catch {
579
+ throw new Error(`Invalid destination URL: ${url}`);
580
+ }
581
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
582
+ throw new Error("Destination URL must start with http:// or https://.");
583
+ }
584
+ return parsed.toString();
585
+ }
586
+ function isoOrNull(input) {
587
+ if (!input)
588
+ return null;
589
+ const date = new Date(input);
590
+ if (Number.isNaN(date.getTime()))
591
+ throw new Error(`Invalid date: ${input}`);
592
+ return date.toISOString();
593
+ }
594
+ function clickFromRow(row) {
595
+ return {
596
+ ...row,
597
+ clicked_at: toIsoString(row.clicked_at),
598
+ synced_at: nullableIso(row.synced_at),
599
+ created_at: toIsoString(row.created_at),
600
+ updated_at: toIsoString(row.updated_at),
601
+ metadata: parseJsonObject(row.metadata)
602
+ };
603
+ }
604
+ function createKitPgAdapter(client) {
605
+ return {
606
+ async get(sql, ...params) {
607
+ return client.get(toPostgresSql(sql), params);
608
+ },
609
+ async all(sql, ...params) {
610
+ return client.many(toPostgresSql(sql), params);
611
+ },
612
+ async run(sql, ...params) {
613
+ return client.query(toPostgresSql(sql), params);
614
+ }
615
+ };
616
+ }
617
+
618
+ class PgShortlinksStore {
619
+ pg;
620
+ constructor(pg) {
621
+ this.pg = pg;
622
+ }
623
+ static async fromConnectionString(connectionString, options = {}) {
624
+ return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
625
+ }
626
+ static fromQueryClient(client) {
627
+ return new PgShortlinksStore(createKitPgAdapter(client));
628
+ }
629
+ static async fromEnv(env = process.env) {
630
+ const connectionString = getShortlinksDatabaseUrl(env);
631
+ if (!connectionString) {
632
+ throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
633
+ }
634
+ return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
635
+ }
636
+ async close() {
637
+ await this.pg.close?.();
638
+ }
639
+ async addDomain(input) {
640
+ const hostname2 = normalizeHostname(input.hostname);
641
+ const timestamp = now();
642
+ const machineId = getMachineId();
643
+ const existing = await this.getDomain(hostname2);
644
+ const id = existing?.id || makeId("dom");
645
+ if (input.defaultDomain) {
646
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
647
+ }
648
+ await this.pg.run(`
649
+ INSERT INTO domains (
650
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
651
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
652
+ )
653
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
654
+ ON CONFLICT(hostname) DO UPDATE SET
655
+ provider = excluded.provider,
656
+ default_domain = excluded.default_domain,
657
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
658
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
659
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
660
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
661
+ notes = COALESCE(excluded.notes, domains.notes),
662
+ metadata = excluded.metadata,
663
+ machine_id = excluded.machine_id,
664
+ synced_at = NULL,
665
+ updated_at = excluded.updated_at
666
+ `, id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
667
+ return await this.getDomain(hostname2);
668
+ }
669
+ async listDomains() {
670
+ const rows = await this.pg.all(`
671
+ SELECT * FROM domains
672
+ ORDER BY default_domain DESC, hostname ASC
673
+ `);
674
+ return rows.map(domainFromRow);
675
+ }
676
+ async getDomain(hostnameOrId) {
677
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
678
+ const row = await this.pg.get(`
679
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
680
+ `, normalized, hostnameOrId);
681
+ return row ? domainFromRow(row) : null;
682
+ }
683
+ async getDefaultDomain() {
684
+ const row = await this.pg.get(`
685
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
686
+ `);
687
+ return row ? domainFromRow(row) : null;
688
+ }
689
+ async createLink(input) {
690
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
691
+ if (!domain) {
692
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
693
+ }
694
+ const destinationUrl = validateDestinationUrl(input.destinationUrl);
695
+ const timestamp = now();
696
+ const machineId = getMachineId();
697
+ const expiresAt = isoOrNull(input.expiresAt);
698
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
699
+ try {
700
+ await this.pg.run(`
701
+ INSERT INTO links (
702
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
703
+ machine_id, synced_at, created_at, updated_at
704
+ )
705
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
706
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
707
+ } catch (error) {
708
+ const message = error instanceof Error ? error.message : String(error);
709
+ if (message.includes("unique") || message.includes("duplicate")) {
710
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
711
+ }
712
+ throw error;
713
+ }
714
+ return await this.getLink(domain.hostname, slug);
715
+ }
716
+ async listLinks(options = {}) {
717
+ const params = [];
718
+ let where = "WHERE 1 = 1";
719
+ if (options.domain) {
720
+ where += " AND d.hostname = ?";
721
+ params.push(normalizeHostname(options.domain));
722
+ }
723
+ if (options.activeOnly)
724
+ where += " AND l.active = 1";
725
+ params.push(options.limit || 100);
726
+ const rows = await this.pg.all(`
727
+ SELECT l.*, d.hostname
728
+ FROM links l
729
+ JOIN domains d ON d.id = l.domain_id
730
+ ${where}
731
+ ORDER BY l.created_at DESC
732
+ LIMIT ?
733
+ `, ...params);
734
+ return rows.map(linkFromRow);
735
+ }
736
+ async getLink(domainOrSlug, maybeSlug) {
737
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
738
+ const params = [slug];
739
+ let domainClause = "";
740
+ if (maybeSlug) {
741
+ domainClause = "AND d.hostname = ?";
742
+ params.push(normalizeHostname(domainOrSlug));
743
+ }
744
+ const row = await this.pg.get(`
745
+ SELECT l.*, d.hostname
746
+ FROM links l
747
+ JOIN domains d ON d.id = l.domain_id
748
+ WHERE l.slug = ? ${domainClause}
749
+ ORDER BY d.default_domain DESC, l.created_at ASC
750
+ LIMIT 1
751
+ `, ...params);
752
+ return row ? linkFromRow(row) : null;
753
+ }
754
+ async totalStats() {
755
+ const row = await this.pg.get(`
756
+ SELECT
757
+ (SELECT COUNT(*)::int FROM domains) AS domains,
758
+ (SELECT COUNT(*)::int FROM links) AS links,
759
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
760
+ `);
761
+ return row;
762
+ }
763
+ async resolve(hostname2, slug) {
764
+ const normalizedHost = normalizeHostname(hostname2);
765
+ const normalizedSlug = normalizeSlug(slug);
766
+ const row = await this.pg.get(`
767
+ SELECT l.*, d.hostname
768
+ FROM links l
769
+ JOIN domains d ON d.id = l.domain_id
770
+ WHERE d.hostname = ? AND l.slug = ?
771
+ LIMIT 1
772
+ `, normalizedHost, normalizedSlug);
773
+ if (row)
774
+ return linkFromRow(row);
775
+ const fallback = await this.pg.get(`
776
+ SELECT l.*, d.hostname
777
+ FROM links l
778
+ JOIN domains d ON d.id = l.domain_id
779
+ WHERE d.default_domain = 1 AND l.slug = ?
780
+ ORDER BY d.created_at ASC
781
+ LIMIT 1
782
+ `, normalizedSlug);
783
+ return fallback ? linkFromRow(fallback) : null;
784
+ }
785
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
786
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
787
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
788
+ if (!link)
789
+ throw new Error("Link not found.");
790
+ const timestamp = now();
791
+ await this.pg.run(`
792
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
793
+ `, active ? 1 : 0, timestamp, link.id);
794
+ return await this.getLink(link.hostname, link.slug);
795
+ }
796
+ async deleteLink(domainOrSlug, maybeSlug) {
797
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
798
+ if (!link)
799
+ throw new Error("Link not found.");
800
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
801
+ return link;
802
+ }
803
+ async recordClick(link, input = {}) {
804
+ const timestamp = now();
805
+ const machineId = getMachineId();
806
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
807
+ const id = makeId("clk");
808
+ await this.pg.run(`
809
+ INSERT INTO clicks (
810
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
811
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
812
+ )
813
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
814
+ `, id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
815
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
816
+ return clickFromRow(row);
817
+ }
818
+ async getStats(domainOrSlug, maybeSlug) {
819
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
820
+ if (!link)
821
+ throw new Error("Link not found.");
822
+ const summary = await this.pg.get(`
823
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
824
+ `, link.id);
825
+ const topReferrers = await this.pg.all(`
826
+ SELECT referer, COUNT(*)::int AS clicks
827
+ FROM clicks
828
+ WHERE link_id = ?
829
+ GROUP BY referer
830
+ ORDER BY clicks DESC
831
+ LIMIT 10
832
+ `, link.id);
833
+ const topUserAgents = await this.pg.all(`
834
+ SELECT user_agent, COUNT(*)::int AS clicks
835
+ FROM clicks
836
+ WHERE link_id = ?
837
+ GROUP BY user_agent
838
+ ORDER BY clicks DESC
839
+ LIMIT 10
840
+ `, link.id);
841
+ return {
842
+ link,
843
+ clicks: summary.clicks,
844
+ last_clicked_at: nullableIso(summary.last_clicked_at),
845
+ top_referrers: topReferrers,
846
+ top_user_agents: topUserAgents
847
+ };
848
+ }
849
+ hashIp(ip) {
850
+ return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
851
+ }
852
+ async generateAvailableSlug(domainId, length) {
853
+ for (let attempt = 0;attempt < 32; attempt += 1) {
854
+ const slug = randomToken(length);
855
+ const exists = await this.pg.get(`
856
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
857
+ `, domainId, slug);
858
+ if (!exists)
859
+ return slug;
860
+ }
861
+ throw new Error("Could not generate an unused slug after 32 attempts.");
862
+ }
863
+ }
864
+ async function applyPostgresMigrations(connectionString, migrations, options = {}) {
865
+ const Pool = await loadPgPool();
866
+ const pool = new Pool(createPgPoolConfig(connectionString, options));
867
+ const client = await pool.connect();
868
+ const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
869
+ const get = async (sql, ...params) => {
870
+ const result = await run(sql, ...params);
871
+ return result.rows[0] ?? null;
872
+ };
873
+ const applied = [];
874
+ const skipped = [];
875
+ try {
876
+ await run("BEGIN");
877
+ await run(`
878
+ SELECT pg_advisory_xact_lock(hashtext(?))
879
+ `, "shortlinks:migrations");
880
+ await run(`
881
+ CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
882
+ id INTEGER PRIMARY KEY,
883
+ service TEXT NOT NULL DEFAULT 'shortlinks',
884
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
885
+ )
886
+ `);
887
+ for (let i = 0;i < migrations.length; i += 1) {
888
+ const id = i + 1;
889
+ const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
890
+ if (existing) {
891
+ skipped.push(id);
892
+ continue;
893
+ }
894
+ await run(migrations[i]);
895
+ await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
896
+ applied.push(id);
897
+ }
898
+ await run("COMMIT");
899
+ return { service: "shortlinks", applied, skipped };
900
+ } catch (error) {
901
+ try {
902
+ await run("ROLLBACK");
903
+ } catch {}
904
+ throw error;
905
+ } finally {
906
+ client.release();
907
+ await pool.end();
908
+ }
909
+ }
910
+
911
+ // src/mcp/index.ts
912
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
913
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
914
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
915
+
916
+ // src/store.ts
917
+ import { createHash as createHash2 } from "crypto";
918
+ function parseJsonObject2(value) {
919
+ if (!value)
920
+ return {};
921
+ try {
922
+ const parsed = JSON.parse(value);
923
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
924
+ } catch {
925
+ return {};
926
+ }
927
+ }
928
+ function domainFromRow2(row) {
929
+ return {
930
+ ...row,
931
+ default_domain: Boolean(row.default_domain),
932
+ metadata: parseJsonObject2(row.metadata)
933
+ };
934
+ }
935
+ function linkFromRow2(row) {
936
+ const config = loadConfig();
937
+ const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
938
+ return {
939
+ ...row,
940
+ active: Boolean(row.active),
941
+ metadata: parseJsonObject2(row.metadata),
942
+ short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
943
+ };
944
+ }
945
+ function clickFromRow2(row) {
946
+ return {
947
+ ...row,
948
+ metadata: parseJsonObject2(row.metadata)
949
+ };
950
+ }
951
+ function validateDestinationUrl2(url) {
952
+ let parsed;
953
+ try {
954
+ parsed = new URL(url);
955
+ } catch {
956
+ throw new Error(`Invalid destination URL: ${url}`);
957
+ }
958
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
959
+ throw new Error("Destination URL must start with http:// or https://.");
960
+ }
961
+ return parsed.toString();
962
+ }
963
+ function isoOrNull2(input) {
964
+ if (!input)
965
+ return null;
966
+ const date = new Date(input);
967
+ if (Number.isNaN(date.getTime()))
968
+ throw new Error(`Invalid date: ${input}`);
969
+ return date.toISOString();
970
+ }
971
+
972
+ class ShortlinksStore {
973
+ database;
974
+ constructor(dbPath) {
975
+ this.database = new ShortlinksDatabase(dbPath);
976
+ }
977
+ close() {
978
+ this.database.close();
979
+ }
980
+ addDomain(input) {
981
+ const hostname2 = normalizeHostname(input.hostname);
982
+ const timestamp = now();
983
+ const machineId = getMachineId();
984
+ const existing = this.getDomain(hostname2);
985
+ const id = existing?.id || makeId("dom");
986
+ if (input.defaultDomain) {
987
+ this.database.db.query("UPDATE domains SET default_domain = 0, updated_at = ?, synced_at = NULL").run(timestamp);
988
+ updateConfig({ defaultDomain: hostname2, publicBaseUrl: `https://${hostname2}` });
989
+ }
990
+ this.database.db.query(`
991
+ INSERT INTO domains (
992
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
993
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
994
+ )
995
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
996
+ ON CONFLICT(hostname) DO UPDATE SET
997
+ provider = excluded.provider,
998
+ default_domain = excluded.default_domain,
999
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
1000
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
1001
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
1002
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
1003
+ notes = COALESCE(excluded.notes, domains.notes),
1004
+ metadata = excluded.metadata,
1005
+ machine_id = excluded.machine_id,
1006
+ synced_at = NULL,
1007
+ updated_at = excluded.updated_at
1008
+ `).run(id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
1009
+ return this.getDomain(hostname2);
1010
+ }
1011
+ listDomains() {
1012
+ const rows = this.database.db.query(`
1013
+ SELECT * FROM domains
1014
+ ORDER BY default_domain DESC, hostname ASC
1015
+ `).all();
1016
+ return rows.map(domainFromRow2);
1017
+ }
1018
+ getDomain(hostnameOrId) {
1019
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
1020
+ const row = this.database.db.query(`
1021
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
1022
+ `).get(normalized, hostnameOrId);
1023
+ return row ? domainFromRow2(row) : null;
1024
+ }
1025
+ getDefaultDomain() {
1026
+ const config = loadConfig();
1027
+ if (config.defaultDomain) {
1028
+ const configured = this.getDomain(config.defaultDomain);
1029
+ if (configured)
1030
+ return configured;
1031
+ }
1032
+ const row = this.database.db.query(`
1033
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
1034
+ `).get();
1035
+ return row ? domainFromRow2(row) : null;
1036
+ }
1037
+ createLink(input) {
1038
+ const domain = input.domain ? this.getDomain(input.domain) : this.getDefaultDomain();
1039
+ if (!domain) {
1040
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
1041
+ }
1042
+ const destinationUrl = validateDestinationUrl2(input.destinationUrl);
1043
+ const timestamp = now();
1044
+ const machineId = getMachineId();
1045
+ const expiresAt = isoOrNull2(input.expiresAt);
1046
+ const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
1047
+ try {
1048
+ this.database.db.query(`
1049
+ INSERT INTO links (
1050
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
1051
+ machine_id, synced_at, created_at, updated_at
1052
+ )
1053
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
1054
+ `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
1055
+ } catch (error) {
1056
+ const message = error instanceof Error ? error.message : String(error);
1057
+ if (message.includes("UNIQUE")) {
1058
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
1059
+ }
1060
+ throw error;
1061
+ }
1062
+ return this.getLink(domain.hostname, slug);
1063
+ }
1064
+ listLinks(options = {}) {
1065
+ const params = [];
1066
+ let where = "WHERE 1 = 1";
1067
+ if (options.domain) {
1068
+ where += " AND d.hostname = ?";
1069
+ params.push(normalizeHostname(options.domain));
1070
+ }
1071
+ if (options.activeOnly) {
1072
+ where += " AND l.active = 1";
1073
+ }
1074
+ params.push(options.limit || 100);
1075
+ const rows = this.database.db.query(`
1076
+ SELECT l.*, d.hostname
1077
+ FROM links l
1078
+ JOIN domains d ON d.id = l.domain_id
1079
+ ${where}
1080
+ ORDER BY l.created_at DESC
1081
+ LIMIT ?
1082
+ `).all(...params);
1083
+ return rows.map(linkFromRow2);
1084
+ }
1085
+ getLink(domainOrSlug, maybeSlug) {
1086
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
1087
+ const params = [slug];
1088
+ let domainClause = "";
1089
+ if (maybeSlug) {
1090
+ domainClause = "AND d.hostname = ?";
1091
+ params.push(normalizeHostname(domainOrSlug));
1092
+ }
1093
+ const row = this.database.db.query(`
1094
+ SELECT l.*, d.hostname
1095
+ FROM links l
1096
+ JOIN domains d ON d.id = l.domain_id
1097
+ WHERE l.slug = ? ${domainClause}
1098
+ ORDER BY d.default_domain DESC, l.created_at ASC
1099
+ LIMIT 1
1100
+ `).get(...params);
1101
+ return row ? linkFromRow2(row) : null;
1102
+ }
1103
+ resolve(hostname2, slug) {
1104
+ const normalizedSlug = normalizeSlug(slug);
1105
+ const normalizedHost = normalizeHostname(hostname2);
1106
+ const row = this.database.db.query(`
1107
+ SELECT l.*, d.hostname
1108
+ FROM links l
1109
+ JOIN domains d ON d.id = l.domain_id
1110
+ WHERE d.hostname = ? AND l.slug = ?
1111
+ LIMIT 1
1112
+ `).get(normalizedHost, normalizedSlug);
1113
+ if (row)
1114
+ return linkFromRow2(row);
1115
+ const fallback = this.getDefaultDomain();
1116
+ if (!fallback || fallback.hostname === normalizedHost)
1117
+ return null;
1118
+ return this.getLink(fallback.hostname, normalizedSlug);
1119
+ }
1120
+ setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
1121
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
1122
+ const link = typeof maybeSlugOrActive === "boolean" ? this.getLink(domainOrSlug) : this.getLink(domainOrSlug, maybeSlugOrActive);
1123
+ if (!link)
1124
+ throw new Error("Link not found.");
1125
+ const timestamp = now();
1126
+ this.database.db.query(`
1127
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
1128
+ `).run(active ? 1 : 0, timestamp, link.id);
1129
+ return this.getLink(link.hostname, link.slug);
1130
+ }
1131
+ deleteLink(domainOrSlug, maybeSlug) {
1132
+ const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
1133
+ if (!link)
1134
+ throw new Error("Link not found.");
1135
+ this.database.db.query("DELETE FROM links WHERE id = ?").run(link.id);
1136
+ return link;
1137
+ }
1138
+ recordClick(link, input = {}) {
1139
+ const timestamp = now();
1140
+ const machineId = getMachineId();
1141
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
1142
+ const id = makeId("clk");
1143
+ this.database.db.query(`
1144
+ INSERT INTO clicks (
1145
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
1146
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
1147
+ )
1148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
1149
+ `).run(id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
1150
+ const row = this.database.db.query("SELECT * FROM clicks WHERE id = ?").get(id);
1151
+ return clickFromRow2(row);
1152
+ }
1153
+ getStats(domainOrSlug, maybeSlug) {
1154
+ const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
1155
+ if (!link)
1156
+ throw new Error("Link not found.");
1157
+ const summary = this.database.db.query(`
1158
+ SELECT COUNT(*) AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
1159
+ `).get(link.id);
1160
+ const topReferrers = this.database.db.query(`
1161
+ SELECT referer, COUNT(*) AS clicks
1162
+ FROM clicks
1163
+ WHERE link_id = ?
1164
+ GROUP BY referer
1165
+ ORDER BY clicks DESC
1166
+ LIMIT 10
1167
+ `).all(link.id);
1168
+ const topUserAgents = this.database.db.query(`
1169
+ SELECT user_agent, COUNT(*) AS clicks
1170
+ FROM clicks
1171
+ WHERE link_id = ?
1172
+ GROUP BY user_agent
1173
+ ORDER BY clicks DESC
1174
+ LIMIT 10
1175
+ `).all(link.id);
1176
+ return {
1177
+ link,
1178
+ clicks: summary.clicks,
1179
+ last_clicked_at: summary.last_clicked_at,
1180
+ top_referrers: topReferrers,
1181
+ top_user_agents: topUserAgents
1182
+ };
1183
+ }
1184
+ totalStats() {
1185
+ const row = this.database.db.query(`
1186
+ SELECT
1187
+ (SELECT COUNT(*) FROM domains) AS domains,
1188
+ (SELECT COUNT(*) FROM links) AS links,
1189
+ (SELECT COUNT(*) FROM clicks) AS clicks
1190
+ `).get();
1191
+ return row;
1192
+ }
1193
+ generateAvailableSlug(domainId, length) {
1194
+ for (let attempt = 0;attempt < 32; attempt += 1) {
1195
+ const slug = randomToken(length);
1196
+ const exists = this.database.db.query(`
1197
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
1198
+ `).get(domainId, slug);
1199
+ if (!exists)
1200
+ return slug;
1201
+ }
1202
+ throw new Error("Could not generate an unused slug after 32 attempts.");
1203
+ }
1204
+ hashIp(ip) {
1205
+ return createHash2("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
1206
+ }
1207
+ }
1208
+
1209
+ // src/mcp/http.ts
1210
+ import { createServer } from "http";
1211
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1212
+ var MCP_HTTP_SERVICE_NAME = "shortlinks";
1213
+ var DEFAULT_MCP_HTTP_PORT = 8851;
1214
+ function isHttpMode(argv = process.argv, env = process.env) {
1215
+ return argv.includes("--http") || env.MCP_HTTP === "1";
1216
+ }
1217
+ function resolveMcpHttpPort(argv = process.argv, env = process.env) {
1218
+ const portIdx = argv.indexOf("--port");
1219
+ if (portIdx !== -1 && argv[portIdx + 1])
1220
+ return parsePort(argv[portIdx + 1], "--port");
1221
+ if (env.MCP_HTTP_PORT)
1222
+ return parsePort(env.MCP_HTTP_PORT, "MCP_HTTP_PORT");
1223
+ return DEFAULT_MCP_HTTP_PORT;
1224
+ }
1225
+ function parsePort(raw, source) {
1226
+ const parsed = Number(raw);
1227
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
1228
+ throw new Error(`Invalid ${source} value "${raw}". Expected 0-65535.`);
1229
+ }
1230
+ return parsed;
1231
+ }
1232
+ async function readJsonBody(req) {
1233
+ const chunks = [];
1234
+ for await (const chunk of req)
1235
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
1236
+ const text = Buffer.concat(chunks).toString("utf8");
1237
+ if (!text)
1238
+ return;
1239
+ return JSON.parse(text);
1240
+ }
1241
+ async function startMcpHttpServer(buildServer, options) {
1242
+ const host = options?.host ?? "127.0.0.1";
1243
+ const requestedPort = options?.port ?? resolveMcpHttpPort();
1244
+ const serviceName = options?.serviceName ?? MCP_HTTP_SERVICE_NAME;
1245
+ const httpServer = createServer(async (req, res) => {
1246
+ try {
1247
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1248
+ if (req.method === "GET" && url.pathname === "/health") {
1249
+ res.writeHead(200, { "Content-Type": "application/json" });
1250
+ res.end(JSON.stringify({ status: "ok", name: serviceName }));
1251
+ return;
1252
+ }
1253
+ if (url.pathname !== "/mcp") {
1254
+ res.writeHead(404, { "Content-Type": "text/plain" });
1255
+ res.end("Not Found");
1256
+ return;
1257
+ }
1258
+ const server = buildServer();
1259
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
1260
+ await server.connect(transport);
1261
+ let parsedBody;
1262
+ if (req.method === "POST")
1263
+ parsedBody = await readJsonBody(req);
1264
+ await transport.handleRequest(req, res, parsedBody);
1265
+ res.on("close", () => {
1266
+ transport.close();
1267
+ server.close();
1268
+ });
1269
+ } catch (error) {
1270
+ console.error(`[${serviceName}-mcp] HTTP error:`, error);
1271
+ if (!res.headersSent) {
1272
+ res.writeHead(500, { "Content-Type": "application/json" });
1273
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
1274
+ }
1275
+ }
1276
+ });
1277
+ await new Promise((resolve2, reject) => {
1278
+ httpServer.once("error", reject);
1279
+ httpServer.listen(requestedPort, host, () => resolve2());
1280
+ });
1281
+ const addr = httpServer.address();
1282
+ const port = typeof addr === "object" && addr ? addr.port : requestedPort;
1283
+ console.error(`[${serviceName}-mcp] Streamable HTTP listening on http://${host}:${port}/mcp`);
1284
+ return {
1285
+ port,
1286
+ host,
1287
+ close: () => new Promise((resolve2, reject) => httpServer.close((err) => err ? reject(err) : resolve2()))
1288
+ };
1289
+ }
1290
+
1291
+ // src/mcp/index.ts
1292
+ async function withStore(fn) {
1293
+ if (getShortlinksStoreMode() === "postgres") {
1294
+ const store2 = await PgShortlinksStore.fromEnv();
1295
+ try {
1296
+ return await fn(store2);
1297
+ } finally {
1298
+ await store2.close();
1299
+ }
1300
+ }
1301
+ const store = new ShortlinksStore;
1302
+ try {
1303
+ return await fn(store);
1304
+ } finally {
1305
+ store.close();
1306
+ }
1307
+ }
1308
+ var TOOLS = [
1309
+ {
1310
+ name: "create_link",
1311
+ description: "Create a shortlink for a destination URL.",
1312
+ inputSchema: {
1313
+ type: "object",
1314
+ properties: {
1315
+ url: { type: "string", description: "Destination URL (http/https)." },
1316
+ domain: { type: "string", description: "Hostname; defaults to the default domain." },
1317
+ slug: { type: "string", description: "Custom slug; generated when omitted." },
1318
+ title: { type: "string" },
1319
+ expires_at: { type: "string", description: "ISO date/time." },
1320
+ length: { type: "number", description: "Generated slug length." }
1321
+ },
1322
+ required: ["url"]
1323
+ }
1324
+ },
1325
+ {
1326
+ name: "list_links",
1327
+ description: "List shortlinks.",
1328
+ inputSchema: {
1329
+ type: "object",
1330
+ properties: {
1331
+ domain: { type: "string" },
1332
+ active: { type: "boolean" },
1333
+ limit: { type: "number" }
1334
+ }
1335
+ }
1336
+ },
1337
+ {
1338
+ name: "get_link",
1339
+ description: "Get a shortlink by slug.",
1340
+ inputSchema: {
1341
+ type: "object",
1342
+ properties: { slug: { type: "string" }, domain: { type: "string" } },
1343
+ required: ["slug"]
1344
+ }
1345
+ },
1346
+ {
1347
+ name: "resolve_link",
1348
+ description: "Resolve a slug to its destination URL without recording a click.",
1349
+ inputSchema: {
1350
+ type: "object",
1351
+ properties: { slug: { type: "string" }, domain: { type: "string" } },
1352
+ required: ["slug"]
1353
+ }
1354
+ },
1355
+ {
1356
+ name: "enable_link",
1357
+ description: "Enable a shortlink.",
1358
+ inputSchema: {
1359
+ type: "object",
1360
+ properties: { slug: { type: "string" }, domain: { type: "string" } },
1361
+ required: ["slug"]
1362
+ }
1363
+ },
1364
+ {
1365
+ name: "disable_link",
1366
+ description: "Disable a shortlink.",
1367
+ inputSchema: {
1368
+ type: "object",
1369
+ properties: { slug: { type: "string" }, domain: { type: "string" } },
1370
+ required: ["slug"]
1371
+ }
1372
+ },
1373
+ {
1374
+ name: "delete_link",
1375
+ description: "Delete a shortlink.",
1376
+ inputSchema: {
1377
+ type: "object",
1378
+ properties: { slug: { type: "string" }, domain: { type: "string" } },
1379
+ required: ["slug"]
1380
+ }
1381
+ },
1382
+ {
1383
+ name: "link_stats",
1384
+ description: "Click stats for a shortlink.",
1385
+ inputSchema: {
1386
+ type: "object",
1387
+ properties: { slug: { type: "string" }, domain: { type: "string" } },
1388
+ required: ["slug"]
1389
+ }
1390
+ },
1391
+ {
1392
+ name: "list_domains",
1393
+ description: "List configured shortlink domains.",
1394
+ inputSchema: { type: "object", properties: {} }
1395
+ },
1396
+ {
1397
+ name: "add_domain",
1398
+ description: "Add or update a shortlink domain.",
1399
+ inputSchema: {
1400
+ type: "object",
1401
+ properties: {
1402
+ hostname: { type: "string" },
1403
+ provider: { type: "string" },
1404
+ default: { type: "boolean" },
1405
+ origin_url: { type: "string" },
1406
+ notes: { type: "string" }
1407
+ },
1408
+ required: ["hostname"]
1409
+ }
1410
+ },
1411
+ {
1412
+ name: "stats",
1413
+ description: "Total domains/links/clicks counts.",
1414
+ inputSchema: { type: "object", properties: {} }
1415
+ }
1416
+ ];
1417
+ async function dispatch(name, args) {
1418
+ switch (name) {
1419
+ case "create_link":
1420
+ return withStore((s) => s.createLink({
1421
+ destinationUrl: args.url,
1422
+ domain: args.domain,
1423
+ slug: args.slug,
1424
+ title: args.title,
1425
+ expiresAt: args.expires_at,
1426
+ slugLength: args.length
1427
+ }));
1428
+ case "list_links":
1429
+ return withStore((s) => s.listLinks({ domain: args.domain, activeOnly: args.active, limit: args.limit ?? 100 }));
1430
+ case "get_link":
1431
+ return withStore((s) => args.domain ? s.getLink(args.domain, args.slug) : s.getLink(args.slug));
1432
+ case "resolve_link":
1433
+ return withStore((s) => args.domain ? s.getLink(args.domain, args.slug) : s.getLink(args.slug));
1434
+ case "enable_link":
1435
+ return withStore((s) => args.domain ? s.setLinkActive(args.domain, args.slug, true) : s.setLinkActive(args.slug, true));
1436
+ case "disable_link":
1437
+ return withStore((s) => args.domain ? s.setLinkActive(args.domain, args.slug, false) : s.setLinkActive(args.slug, false));
1438
+ case "delete_link":
1439
+ return withStore((s) => args.domain ? s.deleteLink(args.domain, args.slug) : s.deleteLink(args.slug));
1440
+ case "link_stats":
1441
+ return withStore((s) => args.domain ? s.getStats(args.domain, args.slug) : s.getStats(args.slug));
1442
+ case "list_domains":
1443
+ return withStore((s) => s.listDomains());
1444
+ case "add_domain":
1445
+ return withStore((s) => s.addDomain({
1446
+ hostname: args.hostname,
1447
+ provider: args.provider,
1448
+ defaultDomain: args.default,
1449
+ originUrl: args.origin_url,
1450
+ notes: args.notes
1451
+ }));
1452
+ case "stats":
1453
+ return withStore((s) => s.totalStats());
1454
+ default:
1455
+ throw new Error(`Unknown tool: ${name}`);
1456
+ }
1457
+ }
1458
+ function buildServer() {
1459
+ const server = new Server({ name: "shortlinks", version: "1.0.0" }, { capabilities: { tools: {} } });
1460
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1461
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1462
+ const { name, arguments: args } = request.params;
1463
+ try {
1464
+ const result = await dispatch(name, args ?? {});
1465
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1466
+ } catch (error) {
1467
+ const message = error instanceof Error ? error.message : String(error);
1468
+ return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
1469
+ }
1470
+ });
1471
+ return server;
1472
+ }
1473
+ async function main() {
1474
+ if (isHttpMode()) {
1475
+ await startMcpHttpServer(buildServer, { port: resolveMcpHttpPort() });
1476
+ await new Promise((resolve2) => {
1477
+ process.once("SIGINT", resolve2);
1478
+ process.once("SIGTERM", resolve2);
1479
+ });
1480
+ return;
1481
+ }
1482
+ const server = buildServer();
1483
+ const transport = new StdioServerTransport;
1484
+ await server.connect(transport);
1485
+ console.error("[shortlinks-mcp] stdio ready");
1486
+ }
1487
+ main().catch((err) => {
1488
+ console.error("[shortlinks-mcp] fatal:", err instanceof Error ? err.message : err);
1489
+ process.exit(1);
1490
+ });
1491
+ export {
1492
+ buildServer
1493
+ };