@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,913 @@
1
+ // @bun
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ function __accessProp(key) {
8
+ return this[key];
9
+ }
10
+ var __toESMCache_node;
11
+ var __toESMCache_esm;
12
+ var __toESM = (mod, isNodeMode, target) => {
13
+ var canCache = mod != null && typeof mod === "object";
14
+ if (canCache) {
15
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
16
+ var cached = cache.get(mod);
17
+ if (cached)
18
+ return cached;
19
+ }
20
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
21
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
22
+ for (let key of __getOwnPropNames(mod))
23
+ if (!__hasOwnProp.call(to, key))
24
+ __defProp(to, key, {
25
+ get: __accessProp.bind(mod, key),
26
+ enumerable: true
27
+ });
28
+ if (canCache)
29
+ cache.set(mod, to);
30
+ return to;
31
+ };
32
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
33
+ var __require = import.meta.require;
34
+
35
+ // src/runtime.ts
36
+ var SHORTLINKS_RUNTIME_ENV = {
37
+ store: "HASNA_SHORTLINKS_STORE",
38
+ databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
39
+ databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
40
+ };
41
+ var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
42
+ store: "SHORTLINKS_STORE",
43
+ databaseUrl: "SHORTLINKS_DATABASE_URL",
44
+ databaseSsl: "SHORTLINKS_DATABASE_SSL"
45
+ };
46
+ var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
47
+ var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
48
+ var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
49
+ function getCanonicalShortlinksPostgresConfig() {
50
+ return {
51
+ cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
52
+ database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
53
+ runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
54
+ primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
55
+ fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
56
+ };
57
+ }
58
+ function parseShortlinksStoreMode(value) {
59
+ const normalized = clean(value)?.toLowerCase();
60
+ if (!normalized)
61
+ return "local";
62
+ if (normalized === "local" || normalized === "postgres")
63
+ return normalized;
64
+ if (normalized === "pg")
65
+ return "postgres";
66
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
67
+ }
68
+ function getShortlinksStoreMode(env = process.env) {
69
+ return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
70
+ }
71
+ function getShortlinksDatabaseUrl(env = process.env) {
72
+ return readRuntimeEnv(env, "databaseUrl").value;
73
+ }
74
+ function getShortlinksDatabaseSsl(env = process.env) {
75
+ return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
76
+ }
77
+ function getShortlinksRuntimeEnvName(env, key) {
78
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
79
+ if (clean(env[primary]))
80
+ return primary;
81
+ return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
82
+ }
83
+ function loadShortlinksRuntimeConfig(env = process.env) {
84
+ const mode = getShortlinksStoreMode(env);
85
+ const databaseUrl = getShortlinksDatabaseUrl(env);
86
+ return {
87
+ service: "shortlinks",
88
+ mode,
89
+ ...databaseUrl ? {
90
+ database: {
91
+ provider: "postgres",
92
+ url: databaseUrl,
93
+ ssl: getShortlinksDatabaseSsl(env)
94
+ }
95
+ } : {}
96
+ };
97
+ }
98
+ function assertShortlinksPostgresConfig(config) {
99
+ if (config.mode !== "postgres")
100
+ return;
101
+ if (!config.database?.url) {
102
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
103
+ }
104
+ }
105
+ function getShortlinksRuntimeStatus(env = process.env) {
106
+ const issues = [];
107
+ const warnings = [];
108
+ let config;
109
+ try {
110
+ config = loadShortlinksRuntimeConfig(env);
111
+ } catch (error) {
112
+ issues.push(error instanceof Error ? error.message : String(error));
113
+ config = { service: "shortlinks", mode: "local" };
114
+ }
115
+ try {
116
+ assertShortlinksPostgresConfig(config);
117
+ } catch (error) {
118
+ issues.push(error instanceof Error ? error.message : String(error));
119
+ }
120
+ if (config.mode === "local" && config.database?.url) {
121
+ warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
122
+ }
123
+ return {
124
+ ok: issues.length === 0,
125
+ service: "shortlinks",
126
+ mode: config.mode,
127
+ local_default: config.mode === "local",
128
+ postgres_enabled: config.mode === "postgres",
129
+ database: {
130
+ configured: Boolean(config.database?.url),
131
+ provider: config.database?.provider ?? null,
132
+ redacted_url: redactDatabaseUrl(config.database?.url),
133
+ ssl: config.database?.ssl ?? null
134
+ },
135
+ env: runtimeEnvStatus(env),
136
+ canonical: getCanonicalShortlinksPostgresConfig(),
137
+ issues,
138
+ warnings,
139
+ no_network: true
140
+ };
141
+ }
142
+ function redactDatabaseUrl(value) {
143
+ if (!value)
144
+ return null;
145
+ try {
146
+ const url = new URL(value);
147
+ if (url.username)
148
+ url.username = "***";
149
+ if (url.password)
150
+ url.password = "***";
151
+ for (const key of Array.from(url.searchParams.keys())) {
152
+ if (isSensitiveQueryKey(key))
153
+ url.searchParams.set(key, "***");
154
+ }
155
+ return url.toString();
156
+ } catch {
157
+ return "(redacted)";
158
+ }
159
+ }
160
+ function runtimeEnvStatus(env) {
161
+ return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
162
+ const activeName = getShortlinksRuntimeEnvName(env, key);
163
+ return [
164
+ key,
165
+ {
166
+ name,
167
+ active_name: activeName,
168
+ configured: Boolean(clean(env[activeName]))
169
+ }
170
+ ];
171
+ }));
172
+ }
173
+ function readRuntimeEnv(env, key) {
174
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
175
+ const primaryValue = clean(env[primary]);
176
+ if (primaryValue)
177
+ return { name: primary, value: primaryValue };
178
+ const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
179
+ return { name: fallback, value: clean(env[fallback]) };
180
+ }
181
+ function parseBoolean(value, fallback) {
182
+ const normalized = clean(value)?.toLowerCase();
183
+ if (!normalized)
184
+ return fallback;
185
+ if (["1", "true", "yes", "on"].includes(normalized))
186
+ return true;
187
+ if (["0", "false", "no", "off"].includes(normalized))
188
+ return false;
189
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
190
+ }
191
+ function isSensitiveQueryKey(key) {
192
+ return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
193
+ }
194
+ function clean(value) {
195
+ const trimmed = value?.trim();
196
+ return trimmed ? trimmed : undefined;
197
+ }
198
+
199
+ // src/pg-store.ts
200
+ import { createHash } from "crypto";
201
+
202
+ // src/config.ts
203
+ import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
204
+ import { randomBytes } from "crypto";
205
+ import { homedir } from "os";
206
+ import { dirname, join, resolve } from "path";
207
+ var SERVICE_NAME = "shortlinks";
208
+ var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
209
+ function getDataDir() {
210
+ return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
211
+ }
212
+ function ensureDataDir() {
213
+ const dir = getDataDir();
214
+ mkdirSync(dir, { recursive: true });
215
+ return dir;
216
+ }
217
+ function getConfigPath() {
218
+ return join(ensureDataDir(), "config.json");
219
+ }
220
+ function getClickSaltPath() {
221
+ return join(ensureDataDir(), "click-salt");
222
+ }
223
+ function getDatabasePath(explicitPath) {
224
+ if (explicitPath)
225
+ return resolve(explicitPath);
226
+ if (process.env.SHORTLINKS_DB)
227
+ return resolve(process.env.SHORTLINKS_DB);
228
+ return join(ensureDataDir(), `${SERVICE_NAME}.db`);
229
+ }
230
+ function readClickSaltFile(path) {
231
+ try {
232
+ const saved = readFileSync(path, "utf-8").trim();
233
+ return saved || null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+ function clickSaltError(path, error) {
239
+ const detail = error instanceof Error ? error.message : String(error);
240
+ return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
241
+ }
242
+ function getClickSalt() {
243
+ const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
244
+ if (explicit)
245
+ return explicit;
246
+ const path = getClickSaltPath();
247
+ const saved = readClickSaltFile(path);
248
+ if (saved)
249
+ return saved;
250
+ const generated = randomBytes(32).toString("hex");
251
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
252
+ try {
253
+ writeFileSync(tempPath, `${generated}
254
+ `, { flag: "wx", mode: 384 });
255
+ try {
256
+ linkSync(tempPath, path);
257
+ return generated;
258
+ } catch (error) {
259
+ const winner = readClickSaltFile(path);
260
+ if (winner)
261
+ return winner;
262
+ throw clickSaltError(path, error);
263
+ } finally {
264
+ try {
265
+ unlinkSync(tempPath);
266
+ } catch {}
267
+ }
268
+ } catch (error) {
269
+ const winner = readClickSaltFile(path);
270
+ if (winner)
271
+ return winner;
272
+ throw clickSaltError(path, error);
273
+ }
274
+ }
275
+ function loadConfig() {
276
+ const path = getConfigPath();
277
+ if (!existsSync(path))
278
+ return {};
279
+ try {
280
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
281
+ return parsed && typeof parsed === "object" ? parsed : {};
282
+ } catch {
283
+ return {};
284
+ }
285
+ }
286
+ function saveConfig(config) {
287
+ const path = getConfigPath();
288
+ mkdirSync(dirname(path), { recursive: true });
289
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}
290
+ `);
291
+ }
292
+ function updateConfig(patch) {
293
+ const next = {
294
+ ...loadConfig(),
295
+ ...patch,
296
+ cloudflare: {
297
+ ...loadConfig().cloudflare,
298
+ ...patch.cloudflare
299
+ }
300
+ };
301
+ saveConfig(next);
302
+ return next;
303
+ }
304
+ function normalizeHostname(input) {
305
+ const raw = input.trim().toLowerCase();
306
+ if (!raw)
307
+ throw new Error("Domain is required.");
308
+ const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
309
+ let hostname;
310
+ try {
311
+ hostname = new URL(withProtocol).hostname;
312
+ } catch {
313
+ throw new Error(`Invalid domain: ${input}`);
314
+ }
315
+ hostname = hostname.replace(/\.$/, "");
316
+ const labels = hostname.split(".");
317
+ const labelsAreValid = labels.every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9-]+$/.test(label) && !label.startsWith("-") && !label.endsWith("-"));
318
+ if (hostname.length > 253 || !labelsAreValid) {
319
+ throw new Error(`Invalid domain: ${input}`);
320
+ }
321
+ return hostname;
322
+ }
323
+ function formatShortUrl(hostname, slug, publicBaseUrl) {
324
+ if (publicBaseUrl) {
325
+ const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
326
+ return new URL(slug, base).toString();
327
+ }
328
+ return `https://${hostname}/${slug}`;
329
+ }
330
+
331
+ // src/database.ts
332
+ import { Database } from "bun:sqlite";
333
+ import { mkdirSync as mkdirSync2 } from "fs";
334
+ import { dirname as dirname2 } from "path";
335
+ function now() {
336
+ return new Date().toISOString();
337
+ }
338
+ function makeId(prefix) {
339
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
340
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
341
+ return `${prefix}_${hex}`;
342
+ }
343
+ var SQLITE_MIGRATIONS = [
344
+ `
345
+ CREATE TABLE IF NOT EXISTS domains (
346
+ id TEXT PRIMARY KEY,
347
+ hostname TEXT NOT NULL UNIQUE,
348
+ provider TEXT NOT NULL DEFAULT 'manual',
349
+ default_domain INTEGER NOT NULL DEFAULT 0,
350
+ cloudflare_zone_id TEXT,
351
+ cloudflare_account_id TEXT,
352
+ cloudflare_worker_name TEXT,
353
+ origin_url TEXT,
354
+ notes TEXT,
355
+ metadata TEXT NOT NULL DEFAULT '{}',
356
+ machine_id TEXT,
357
+ synced_at TEXT,
358
+ created_at TEXT NOT NULL,
359
+ updated_at TEXT NOT NULL
360
+ );
361
+
362
+ CREATE TABLE IF NOT EXISTS links (
363
+ id TEXT PRIMARY KEY,
364
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
365
+ slug TEXT NOT NULL,
366
+ destination_url TEXT NOT NULL,
367
+ title TEXT,
368
+ active INTEGER NOT NULL DEFAULT 1,
369
+ expires_at TEXT,
370
+ metadata TEXT NOT NULL DEFAULT '{}',
371
+ machine_id TEXT,
372
+ synced_at TEXT,
373
+ created_at TEXT NOT NULL,
374
+ updated_at TEXT NOT NULL,
375
+ UNIQUE(domain_id, slug)
376
+ );
377
+
378
+ CREATE TABLE IF NOT EXISTS clicks (
379
+ id TEXT PRIMARY KEY,
380
+ link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
381
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
382
+ slug TEXT NOT NULL,
383
+ clicked_at TEXT NOT NULL,
384
+ ip_hash TEXT,
385
+ user_agent TEXT,
386
+ referer TEXT,
387
+ country TEXT,
388
+ city TEXT,
389
+ metadata TEXT NOT NULL DEFAULT '{}',
390
+ machine_id TEXT,
391
+ synced_at TEXT,
392
+ created_at TEXT NOT NULL,
393
+ updated_at TEXT NOT NULL
394
+ );
395
+
396
+ CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
397
+ CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
398
+ CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
399
+ CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
400
+ CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
401
+ CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
402
+ CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
403
+ CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
404
+ CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
405
+ `
406
+ ];
407
+
408
+ class ShortlinksDatabase {
409
+ db;
410
+ path;
411
+ constructor(path) {
412
+ this.path = getDatabasePath(path);
413
+ mkdirSync2(dirname2(this.path), { recursive: true });
414
+ this.db = new Database(this.path);
415
+ this.db.exec("PRAGMA foreign_keys = ON;");
416
+ this.applyMigrations();
417
+ }
418
+ close() {
419
+ this.db.close();
420
+ }
421
+ applyMigrations() {
422
+ this.db.exec(`
423
+ CREATE TABLE IF NOT EXISTS _migrations (
424
+ id INTEGER PRIMARY KEY,
425
+ applied_at TEXT NOT NULL
426
+ );
427
+ `);
428
+ for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
429
+ const id = i + 1;
430
+ const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
431
+ if (applied)
432
+ continue;
433
+ const migration = SQLITE_MIGRATIONS[i];
434
+ const apply = this.db.transaction(() => {
435
+ this.db.exec(migration);
436
+ this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
437
+ });
438
+ apply();
439
+ }
440
+ }
441
+ }
442
+
443
+ // src/machine.ts
444
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
445
+ import { hostname } from "os";
446
+ import { join as join2 } from "path";
447
+
448
+ // src/slug.ts
449
+ import { randomBytes as randomBytes2 } from "crypto";
450
+ var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
451
+ var DEFAULT_SLUG_LENGTH = 7;
452
+ function randomToken(length = DEFAULT_SLUG_LENGTH) {
453
+ if (length < 1 || length > 128)
454
+ throw new Error("Token length must be between 1 and 128.");
455
+ const bytes = randomBytes2(length);
456
+ let out = "";
457
+ for (let i = 0;i < length; i += 1) {
458
+ out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
459
+ }
460
+ return out;
461
+ }
462
+ function normalizeSlug(slug) {
463
+ const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
464
+ if (!normalized)
465
+ throw new Error("Slug is required.");
466
+ if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
467
+ throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
468
+ }
469
+ return normalized;
470
+ }
471
+
472
+ // src/machine.ts
473
+ function getMachineId() {
474
+ const path = join2(ensureDataDir(), "machine-id");
475
+ if (existsSync2(path)) {
476
+ const existing = readFileSync2(path, "utf-8").trim();
477
+ if (existing)
478
+ return existing;
479
+ }
480
+ const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
481
+ const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
482
+ writeFileSync2(path, `${id}
483
+ `);
484
+ return id;
485
+ }
486
+
487
+ // src/pg-store.ts
488
+ function parseJsonObject(value) {
489
+ if (!value)
490
+ return {};
491
+ if (typeof value === "object" && !Array.isArray(value))
492
+ return value;
493
+ try {
494
+ const parsed = JSON.parse(String(value));
495
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
496
+ } catch {
497
+ return {};
498
+ }
499
+ }
500
+ async function loadPgPool() {
501
+ const importer = new Function("specifier", "return import(specifier)");
502
+ const module = await importer("pg");
503
+ return module.Pool;
504
+ }
505
+ function toPostgresSql(sql) {
506
+ let index = 0;
507
+ return sql.replace(/\?/g, () => `$${++index}`);
508
+ }
509
+ function createPgPoolConfig(connectionString, options = {}) {
510
+ const ssl = options.ssl ?? true;
511
+ return {
512
+ connectionString,
513
+ ...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
514
+ };
515
+ }
516
+
517
+ class PgPoolAdapter {
518
+ pool;
519
+ constructor(pool) {
520
+ this.pool = pool;
521
+ }
522
+ static async create(connectionString, options = {}) {
523
+ const Pool = await loadPgPool();
524
+ return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
525
+ }
526
+ async get(sql, ...params) {
527
+ const result = await this.pool.query(toPostgresSql(sql), params);
528
+ return result.rows[0] ?? null;
529
+ }
530
+ async all(sql, ...params) {
531
+ const result = await this.pool.query(toPostgresSql(sql), params);
532
+ return result.rows;
533
+ }
534
+ async run(sql, ...params) {
535
+ return this.pool.query(toPostgresSql(sql), params);
536
+ }
537
+ async close() {
538
+ await this.pool.end();
539
+ }
540
+ }
541
+ function toIsoString(value) {
542
+ if (value instanceof Date)
543
+ return value.toISOString();
544
+ return String(value);
545
+ }
546
+ function nullableIso(value) {
547
+ if (value === null || value === undefined)
548
+ return null;
549
+ return toIsoString(value);
550
+ }
551
+ function domainFromRow(row) {
552
+ return {
553
+ ...row,
554
+ default_domain: Boolean(row.default_domain),
555
+ synced_at: nullableIso(row.synced_at),
556
+ created_at: toIsoString(row.created_at),
557
+ updated_at: toIsoString(row.updated_at),
558
+ metadata: parseJsonObject(row.metadata)
559
+ };
560
+ }
561
+ function linkFromRow(row) {
562
+ return {
563
+ ...row,
564
+ active: Boolean(row.active),
565
+ expires_at: nullableIso(row.expires_at),
566
+ synced_at: nullableIso(row.synced_at),
567
+ created_at: toIsoString(row.created_at),
568
+ updated_at: toIsoString(row.updated_at),
569
+ metadata: parseJsonObject(row.metadata),
570
+ short_url: formatShortUrl(row.hostname, row.slug)
571
+ };
572
+ }
573
+ function validateDestinationUrl(url) {
574
+ let parsed;
575
+ try {
576
+ parsed = new URL(url);
577
+ } catch {
578
+ throw new Error(`Invalid destination URL: ${url}`);
579
+ }
580
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
581
+ throw new Error("Destination URL must start with http:// or https://.");
582
+ }
583
+ return parsed.toString();
584
+ }
585
+ function isoOrNull(input) {
586
+ if (!input)
587
+ return null;
588
+ const date = new Date(input);
589
+ if (Number.isNaN(date.getTime()))
590
+ throw new Error(`Invalid date: ${input}`);
591
+ return date.toISOString();
592
+ }
593
+ function clickFromRow(row) {
594
+ return {
595
+ ...row,
596
+ clicked_at: toIsoString(row.clicked_at),
597
+ synced_at: nullableIso(row.synced_at),
598
+ created_at: toIsoString(row.created_at),
599
+ updated_at: toIsoString(row.updated_at),
600
+ metadata: parseJsonObject(row.metadata)
601
+ };
602
+ }
603
+ function createKitPgAdapter(client) {
604
+ return {
605
+ async get(sql, ...params) {
606
+ return client.get(toPostgresSql(sql), params);
607
+ },
608
+ async all(sql, ...params) {
609
+ return client.many(toPostgresSql(sql), params);
610
+ },
611
+ async run(sql, ...params) {
612
+ return client.query(toPostgresSql(sql), params);
613
+ }
614
+ };
615
+ }
616
+
617
+ class PgShortlinksStore {
618
+ pg;
619
+ constructor(pg) {
620
+ this.pg = pg;
621
+ }
622
+ static async fromConnectionString(connectionString, options = {}) {
623
+ return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
624
+ }
625
+ static fromQueryClient(client) {
626
+ return new PgShortlinksStore(createKitPgAdapter(client));
627
+ }
628
+ static async fromEnv(env = process.env) {
629
+ const connectionString = getShortlinksDatabaseUrl(env);
630
+ if (!connectionString) {
631
+ throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
632
+ }
633
+ return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
634
+ }
635
+ async close() {
636
+ await this.pg.close?.();
637
+ }
638
+ async addDomain(input) {
639
+ const hostname2 = normalizeHostname(input.hostname);
640
+ const timestamp = now();
641
+ const machineId = getMachineId();
642
+ const existing = await this.getDomain(hostname2);
643
+ const id = existing?.id || makeId("dom");
644
+ if (input.defaultDomain) {
645
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
646
+ }
647
+ await this.pg.run(`
648
+ INSERT INTO domains (
649
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
650
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
651
+ )
652
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
653
+ ON CONFLICT(hostname) DO UPDATE SET
654
+ provider = excluded.provider,
655
+ default_domain = excluded.default_domain,
656
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
657
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
658
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
659
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
660
+ notes = COALESCE(excluded.notes, domains.notes),
661
+ metadata = excluded.metadata,
662
+ machine_id = excluded.machine_id,
663
+ synced_at = NULL,
664
+ updated_at = excluded.updated_at
665
+ `, 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);
666
+ return await this.getDomain(hostname2);
667
+ }
668
+ async listDomains() {
669
+ const rows = await this.pg.all(`
670
+ SELECT * FROM domains
671
+ ORDER BY default_domain DESC, hostname ASC
672
+ `);
673
+ return rows.map(domainFromRow);
674
+ }
675
+ async getDomain(hostnameOrId) {
676
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
677
+ const row = await this.pg.get(`
678
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
679
+ `, normalized, hostnameOrId);
680
+ return row ? domainFromRow(row) : null;
681
+ }
682
+ async getDefaultDomain() {
683
+ const row = await this.pg.get(`
684
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
685
+ `);
686
+ return row ? domainFromRow(row) : null;
687
+ }
688
+ async createLink(input) {
689
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
690
+ if (!domain) {
691
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
692
+ }
693
+ const destinationUrl = validateDestinationUrl(input.destinationUrl);
694
+ const timestamp = now();
695
+ const machineId = getMachineId();
696
+ const expiresAt = isoOrNull(input.expiresAt);
697
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
698
+ try {
699
+ await this.pg.run(`
700
+ INSERT INTO links (
701
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
702
+ machine_id, synced_at, created_at, updated_at
703
+ )
704
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
705
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
706
+ } catch (error) {
707
+ const message = error instanceof Error ? error.message : String(error);
708
+ if (message.includes("unique") || message.includes("duplicate")) {
709
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
710
+ }
711
+ throw error;
712
+ }
713
+ return await this.getLink(domain.hostname, slug);
714
+ }
715
+ async listLinks(options = {}) {
716
+ const params = [];
717
+ let where = "WHERE 1 = 1";
718
+ if (options.domain) {
719
+ where += " AND d.hostname = ?";
720
+ params.push(normalizeHostname(options.domain));
721
+ }
722
+ if (options.activeOnly)
723
+ where += " AND l.active = 1";
724
+ params.push(options.limit || 100);
725
+ const rows = await this.pg.all(`
726
+ SELECT l.*, d.hostname
727
+ FROM links l
728
+ JOIN domains d ON d.id = l.domain_id
729
+ ${where}
730
+ ORDER BY l.created_at DESC
731
+ LIMIT ?
732
+ `, ...params);
733
+ return rows.map(linkFromRow);
734
+ }
735
+ async getLink(domainOrSlug, maybeSlug) {
736
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
737
+ const params = [slug];
738
+ let domainClause = "";
739
+ if (maybeSlug) {
740
+ domainClause = "AND d.hostname = ?";
741
+ params.push(normalizeHostname(domainOrSlug));
742
+ }
743
+ const row = await this.pg.get(`
744
+ SELECT l.*, d.hostname
745
+ FROM links l
746
+ JOIN domains d ON d.id = l.domain_id
747
+ WHERE l.slug = ? ${domainClause}
748
+ ORDER BY d.default_domain DESC, l.created_at ASC
749
+ LIMIT 1
750
+ `, ...params);
751
+ return row ? linkFromRow(row) : null;
752
+ }
753
+ async totalStats() {
754
+ const row = await this.pg.get(`
755
+ SELECT
756
+ (SELECT COUNT(*)::int FROM domains) AS domains,
757
+ (SELECT COUNT(*)::int FROM links) AS links,
758
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
759
+ `);
760
+ return row;
761
+ }
762
+ async resolve(hostname2, slug) {
763
+ const normalizedHost = normalizeHostname(hostname2);
764
+ const normalizedSlug = normalizeSlug(slug);
765
+ const row = await this.pg.get(`
766
+ SELECT l.*, d.hostname
767
+ FROM links l
768
+ JOIN domains d ON d.id = l.domain_id
769
+ WHERE d.hostname = ? AND l.slug = ?
770
+ LIMIT 1
771
+ `, normalizedHost, normalizedSlug);
772
+ if (row)
773
+ return linkFromRow(row);
774
+ const fallback = await this.pg.get(`
775
+ SELECT l.*, d.hostname
776
+ FROM links l
777
+ JOIN domains d ON d.id = l.domain_id
778
+ WHERE d.default_domain = 1 AND l.slug = ?
779
+ ORDER BY d.created_at ASC
780
+ LIMIT 1
781
+ `, normalizedSlug);
782
+ return fallback ? linkFromRow(fallback) : null;
783
+ }
784
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
785
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
786
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
787
+ if (!link)
788
+ throw new Error("Link not found.");
789
+ const timestamp = now();
790
+ await this.pg.run(`
791
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
792
+ `, active ? 1 : 0, timestamp, link.id);
793
+ return await this.getLink(link.hostname, link.slug);
794
+ }
795
+ async deleteLink(domainOrSlug, maybeSlug) {
796
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
797
+ if (!link)
798
+ throw new Error("Link not found.");
799
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
800
+ return link;
801
+ }
802
+ async recordClick(link, input = {}) {
803
+ const timestamp = now();
804
+ const machineId = getMachineId();
805
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
806
+ const id = makeId("clk");
807
+ await this.pg.run(`
808
+ INSERT INTO clicks (
809
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
810
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
811
+ )
812
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
813
+ `, 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);
814
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
815
+ return clickFromRow(row);
816
+ }
817
+ async getStats(domainOrSlug, maybeSlug) {
818
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
819
+ if (!link)
820
+ throw new Error("Link not found.");
821
+ const summary = await this.pg.get(`
822
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
823
+ `, link.id);
824
+ const topReferrers = await this.pg.all(`
825
+ SELECT referer, COUNT(*)::int AS clicks
826
+ FROM clicks
827
+ WHERE link_id = ?
828
+ GROUP BY referer
829
+ ORDER BY clicks DESC
830
+ LIMIT 10
831
+ `, link.id);
832
+ const topUserAgents = await this.pg.all(`
833
+ SELECT user_agent, COUNT(*)::int AS clicks
834
+ FROM clicks
835
+ WHERE link_id = ?
836
+ GROUP BY user_agent
837
+ ORDER BY clicks DESC
838
+ LIMIT 10
839
+ `, link.id);
840
+ return {
841
+ link,
842
+ clicks: summary.clicks,
843
+ last_clicked_at: nullableIso(summary.last_clicked_at),
844
+ top_referrers: topReferrers,
845
+ top_user_agents: topUserAgents
846
+ };
847
+ }
848
+ hashIp(ip) {
849
+ return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
850
+ }
851
+ async generateAvailableSlug(domainId, length) {
852
+ for (let attempt = 0;attempt < 32; attempt += 1) {
853
+ const slug = randomToken(length);
854
+ const exists = await this.pg.get(`
855
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
856
+ `, domainId, slug);
857
+ if (!exists)
858
+ return slug;
859
+ }
860
+ throw new Error("Could not generate an unused slug after 32 attempts.");
861
+ }
862
+ }
863
+ async function applyPostgresMigrations(connectionString, migrations, options = {}) {
864
+ const Pool = await loadPgPool();
865
+ const pool = new Pool(createPgPoolConfig(connectionString, options));
866
+ const client = await pool.connect();
867
+ const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
868
+ const get = async (sql, ...params) => {
869
+ const result = await run(sql, ...params);
870
+ return result.rows[0] ?? null;
871
+ };
872
+ const applied = [];
873
+ const skipped = [];
874
+ try {
875
+ await run("BEGIN");
876
+ await run(`
877
+ SELECT pg_advisory_xact_lock(hashtext(?))
878
+ `, "shortlinks:migrations");
879
+ await run(`
880
+ CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
881
+ id INTEGER PRIMARY KEY,
882
+ service TEXT NOT NULL DEFAULT 'shortlinks',
883
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
884
+ )
885
+ `);
886
+ for (let i = 0;i < migrations.length; i += 1) {
887
+ const id = i + 1;
888
+ const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
889
+ if (existing) {
890
+ skipped.push(id);
891
+ continue;
892
+ }
893
+ await run(migrations[i]);
894
+ await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
895
+ applied.push(id);
896
+ }
897
+ await run("COMMIT");
898
+ return { service: "shortlinks", applied, skipped };
899
+ } catch (error) {
900
+ try {
901
+ await run("ROLLBACK");
902
+ } catch {}
903
+ throw error;
904
+ } finally {
905
+ client.release();
906
+ await pool.end();
907
+ }
908
+ }
909
+ export {
910
+ createKitPgAdapter,
911
+ applyPostgresMigrations,
912
+ PgShortlinksStore
913
+ };