@hasna/shortlinks 0.1.21 → 0.1.23

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.
package/dist/config.d.ts CHANGED
@@ -12,7 +12,9 @@ export interface ShortlinksConfig {
12
12
  export declare function getDataDir(): string;
13
13
  export declare function ensureDataDir(): string;
14
14
  export declare function getConfigPath(): string;
15
+ export declare function getClickSaltPath(): string;
15
16
  export declare function getDatabasePath(explicitPath?: string): string;
17
+ export declare function getClickSalt(): string;
16
18
  export declare function loadConfig(): ShortlinksConfig;
17
19
  export declare function saveConfig(config: ShortlinksConfig): void;
18
20
  export declare function updateConfig(patch: ShortlinksConfig): ShortlinksConfig;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export { ShortlinksDatabase, SQLITE_MIGRATIONS, makeId, now } from "./database.js";
2
2
  export { ShortlinksStore } from "./store.js";
3
- export { PgShortlinksStore } from "./pg-store.js";
3
+ export { PgShortlinksStore, applyPostgresMigrations } from "./pg-store.js";
4
4
  export { createShortlinksHandler, serveShortlinks } from "./server.js";
5
5
  export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
6
6
  export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
7
7
  export { PG_MIGRATIONS } from "./pg-migrations.js";
8
+ export { CANONICAL_SHORTLINKS_POSTGRES_CLUSTER, CANONICAL_SHORTLINKS_POSTGRES_DATABASE, CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH, SHORTLINKS_RUNTIME_ENV, SHORTLINKS_RUNTIME_FALLBACK_ENV, assertShortlinksPostgresConfig, getCanonicalShortlinksPostgresConfig, getShortlinksDatabaseSsl, getShortlinksDatabaseUrl, getShortlinksRuntimeEnvName, getShortlinksRuntimeStatus, getShortlinksStoreMode, loadShortlinksRuntimeConfig, parseShortlinksStoreMode, redactDatabaseUrl, } from "./runtime.js";
8
9
  export { formatShortUrl, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
9
10
  export { normalizeSlug, randomToken } from "./slug.js";
10
11
  export type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
12
+ export type { CanonicalShortlinksPostgresConfig, RuntimeEnvStatus, ShortlinksPostgresConfig, ShortlinksRuntimeConfig, ShortlinksRuntimeEnv, ShortlinksRuntimeStatus, ShortlinksStoreMode, } from "./runtime.js";
package/dist/index.js CHANGED
@@ -1,13 +1,12 @@
1
1
  // @bun
2
- var __require = import.meta.require;
3
-
4
2
  // src/database.ts
5
3
  import { Database } from "bun:sqlite";
6
4
  import { mkdirSync as mkdirSync2 } from "fs";
7
5
  import { dirname as dirname2 } from "path";
8
6
 
9
7
  // src/config.ts
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
9
+ import { randomBytes } from "crypto";
11
10
  import { homedir } from "os";
12
11
  import { dirname, join, resolve } from "path";
13
12
  var SERVICE_NAME = "shortlinks";
@@ -23,6 +22,9 @@ function ensureDataDir() {
23
22
  function getConfigPath() {
24
23
  return join(ensureDataDir(), "config.json");
25
24
  }
25
+ function getClickSaltPath() {
26
+ return join(ensureDataDir(), "click-salt");
27
+ }
26
28
  function getDatabasePath(explicitPath) {
27
29
  if (explicitPath)
28
30
  return resolve(explicitPath);
@@ -30,6 +32,51 @@ function getDatabasePath(explicitPath) {
30
32
  return resolve(process.env.SHORTLINKS_DB);
31
33
  return join(ensureDataDir(), `${SERVICE_NAME}.db`);
32
34
  }
35
+ function readClickSaltFile(path) {
36
+ try {
37
+ const saved = readFileSync(path, "utf-8").trim();
38
+ return saved || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ function clickSaltError(path, error) {
44
+ const detail = error instanceof Error ? error.message : String(error);
45
+ return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
46
+ }
47
+ function getClickSalt() {
48
+ const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
49
+ if (explicit)
50
+ return explicit;
51
+ const path = getClickSaltPath();
52
+ const saved = readClickSaltFile(path);
53
+ if (saved)
54
+ return saved;
55
+ const generated = randomBytes(32).toString("hex");
56
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
57
+ try {
58
+ writeFileSync(tempPath, `${generated}
59
+ `, { flag: "wx", mode: 384 });
60
+ try {
61
+ linkSync(tempPath, path);
62
+ return generated;
63
+ } catch (error) {
64
+ const winner = readClickSaltFile(path);
65
+ if (winner)
66
+ return winner;
67
+ throw clickSaltError(path, error);
68
+ } finally {
69
+ try {
70
+ unlinkSync(tempPath);
71
+ } catch {}
72
+ }
73
+ } catch (error) {
74
+ const winner = readClickSaltFile(path);
75
+ if (winner)
76
+ return winner;
77
+ throw clickSaltError(path, error);
78
+ }
79
+ }
33
80
  function loadConfig() {
34
81
  const path = getConfigPath();
35
82
  if (!existsSync(path))
@@ -203,13 +250,13 @@ import { hostname } from "os";
203
250
  import { join as join2 } from "path";
204
251
 
205
252
  // src/slug.ts
206
- import { randomBytes } from "crypto";
253
+ import { randomBytes as randomBytes2 } from "crypto";
207
254
  var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
208
255
  var DEFAULT_SLUG_LENGTH = 7;
209
256
  function randomToken(length = DEFAULT_SLUG_LENGTH) {
210
257
  if (length < 1 || length > 128)
211
258
  throw new Error("Token length must be between 1 and 128.");
212
- const bytes = randomBytes(length);
259
+ const bytes = randomBytes2(length);
213
260
  let out = "";
214
261
  for (let i = 0;i < length; i += 1) {
215
262
  out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
@@ -529,12 +576,177 @@ class ShortlinksStore {
529
576
  throw new Error("Could not generate an unused slug after 32 attempts.");
530
577
  }
531
578
  hashIp(ip) {
532
- const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-local";
533
- return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
579
+ return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
534
580
  }
535
581
  }
536
582
  // src/pg-store.ts
537
583
  import { createHash as createHash2 } from "crypto";
584
+
585
+ // src/runtime.ts
586
+ var SHORTLINKS_RUNTIME_ENV = {
587
+ store: "HASNA_SHORTLINKS_STORE",
588
+ databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
589
+ databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
590
+ };
591
+ var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
592
+ store: "SHORTLINKS_STORE",
593
+ databaseUrl: "SHORTLINKS_DATABASE_URL",
594
+ databaseSsl: "SHORTLINKS_DATABASE_SSL"
595
+ };
596
+ var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
597
+ var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
598
+ var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
599
+ function getCanonicalShortlinksPostgresConfig() {
600
+ return {
601
+ cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
602
+ database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
603
+ runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
604
+ primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
605
+ fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
606
+ };
607
+ }
608
+ function parseShortlinksStoreMode(value) {
609
+ const normalized = clean(value)?.toLowerCase();
610
+ if (!normalized)
611
+ return "local";
612
+ if (normalized === "local" || normalized === "postgres")
613
+ return normalized;
614
+ if (normalized === "pg")
615
+ return "postgres";
616
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
617
+ }
618
+ function getShortlinksStoreMode(env = process.env) {
619
+ return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
620
+ }
621
+ function getShortlinksDatabaseUrl(env = process.env) {
622
+ return readRuntimeEnv(env, "databaseUrl").value;
623
+ }
624
+ function getShortlinksDatabaseSsl(env = process.env) {
625
+ return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
626
+ }
627
+ function getShortlinksRuntimeEnvName(env, key) {
628
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
629
+ if (clean(env[primary]))
630
+ return primary;
631
+ return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
632
+ }
633
+ function loadShortlinksRuntimeConfig(env = process.env) {
634
+ const mode = getShortlinksStoreMode(env);
635
+ const databaseUrl = getShortlinksDatabaseUrl(env);
636
+ return {
637
+ service: "shortlinks",
638
+ mode,
639
+ ...databaseUrl ? {
640
+ database: {
641
+ provider: "postgres",
642
+ url: databaseUrl,
643
+ ssl: getShortlinksDatabaseSsl(env)
644
+ }
645
+ } : {}
646
+ };
647
+ }
648
+ function assertShortlinksPostgresConfig(config) {
649
+ if (config.mode !== "postgres")
650
+ return;
651
+ if (!config.database?.url) {
652
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
653
+ }
654
+ }
655
+ function getShortlinksRuntimeStatus(env = process.env) {
656
+ const issues = [];
657
+ const warnings = [];
658
+ let config;
659
+ try {
660
+ config = loadShortlinksRuntimeConfig(env);
661
+ } catch (error) {
662
+ issues.push(error instanceof Error ? error.message : String(error));
663
+ config = { service: "shortlinks", mode: "local" };
664
+ }
665
+ try {
666
+ assertShortlinksPostgresConfig(config);
667
+ } catch (error) {
668
+ issues.push(error instanceof Error ? error.message : String(error));
669
+ }
670
+ if (config.mode === "local" && config.database?.url) {
671
+ warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
672
+ }
673
+ return {
674
+ ok: issues.length === 0,
675
+ service: "shortlinks",
676
+ mode: config.mode,
677
+ local_default: config.mode === "local",
678
+ postgres_enabled: config.mode === "postgres",
679
+ database: {
680
+ configured: Boolean(config.database?.url),
681
+ provider: config.database?.provider ?? null,
682
+ redacted_url: redactDatabaseUrl(config.database?.url),
683
+ ssl: config.database?.ssl ?? null
684
+ },
685
+ env: runtimeEnvStatus(env),
686
+ canonical: getCanonicalShortlinksPostgresConfig(),
687
+ issues,
688
+ warnings,
689
+ no_network: true
690
+ };
691
+ }
692
+ function redactDatabaseUrl(value) {
693
+ if (!value)
694
+ return null;
695
+ try {
696
+ const url = new URL(value);
697
+ if (url.username)
698
+ url.username = "***";
699
+ if (url.password)
700
+ url.password = "***";
701
+ for (const key of Array.from(url.searchParams.keys())) {
702
+ if (isSensitiveQueryKey(key))
703
+ url.searchParams.set(key, "***");
704
+ }
705
+ return url.toString();
706
+ } catch {
707
+ return "(redacted)";
708
+ }
709
+ }
710
+ function runtimeEnvStatus(env) {
711
+ return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
712
+ const activeName = getShortlinksRuntimeEnvName(env, key);
713
+ return [
714
+ key,
715
+ {
716
+ name,
717
+ active_name: activeName,
718
+ configured: Boolean(clean(env[activeName]))
719
+ }
720
+ ];
721
+ }));
722
+ }
723
+ function readRuntimeEnv(env, key) {
724
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
725
+ const primaryValue = clean(env[primary]);
726
+ if (primaryValue)
727
+ return { name: primary, value: primaryValue };
728
+ const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
729
+ return { name: fallback, value: clean(env[fallback]) };
730
+ }
731
+ function parseBoolean(value, fallback) {
732
+ const normalized = clean(value)?.toLowerCase();
733
+ if (!normalized)
734
+ return fallback;
735
+ if (["1", "true", "yes", "on"].includes(normalized))
736
+ return true;
737
+ if (["0", "false", "no", "off"].includes(normalized))
738
+ return false;
739
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
740
+ }
741
+ function isSensitiveQueryKey(key) {
742
+ return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
743
+ }
744
+ function clean(value) {
745
+ const trimmed = value?.trim();
746
+ return trimmed ? trimmed : undefined;
747
+ }
748
+
749
+ // src/pg-store.ts
538
750
  function parseJsonObject2(value) {
539
751
  if (!value)
540
752
  return {};
@@ -547,6 +759,47 @@ function parseJsonObject2(value) {
547
759
  return {};
548
760
  }
549
761
  }
762
+ async function loadPgPool() {
763
+ const importer = new Function("specifier", "return import(specifier)");
764
+ const module = await importer("pg");
765
+ return module.Pool;
766
+ }
767
+ function toPostgresSql(sql) {
768
+ let index = 0;
769
+ return sql.replace(/\?/g, () => `$${++index}`);
770
+ }
771
+ function createPgPoolConfig(connectionString, options = {}) {
772
+ const ssl = options.ssl ?? true;
773
+ return {
774
+ connectionString,
775
+ ...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
776
+ };
777
+ }
778
+
779
+ class PgPoolAdapter {
780
+ pool;
781
+ constructor(pool) {
782
+ this.pool = pool;
783
+ }
784
+ static async create(connectionString, options = {}) {
785
+ const Pool = await loadPgPool();
786
+ return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
787
+ }
788
+ async get(sql, ...params) {
789
+ const result = await this.pool.query(toPostgresSql(sql), params);
790
+ return result.rows[0] ?? null;
791
+ }
792
+ async all(sql, ...params) {
793
+ const result = await this.pool.query(toPostgresSql(sql), params);
794
+ return result.rows;
795
+ }
796
+ async run(sql, ...params) {
797
+ return this.pool.query(toPostgresSql(sql), params);
798
+ }
799
+ async close() {
800
+ await this.pool.end();
801
+ }
802
+ }
550
803
  function toIsoString(value) {
551
804
  if (value instanceof Date)
552
805
  return value.toISOString();
@@ -615,13 +868,15 @@ class PgShortlinksStore {
615
868
  constructor(pg) {
616
869
  this.pg = pg;
617
870
  }
618
- static async fromConnectionString(connectionString) {
619
- const { PgAdapterAsync } = await import("@hasna/cloud");
620
- return new PgShortlinksStore(new PgAdapterAsync(connectionString));
871
+ static async fromConnectionString(connectionString, options = {}) {
872
+ return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
621
873
  }
622
- static async fromCloud(service = "shortlinks") {
623
- const { getConnectionString } = await import("@hasna/cloud");
624
- return PgShortlinksStore.fromConnectionString(getConnectionString(service));
874
+ static async fromEnv(env = process.env) {
875
+ const connectionString = getShortlinksDatabaseUrl(env);
876
+ if (!connectionString) {
877
+ throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
878
+ }
879
+ return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
625
880
  }
626
881
  async close() {
627
882
  await this.pg.close?.();
@@ -837,8 +1092,7 @@ class PgShortlinksStore {
837
1092
  };
838
1093
  }
839
1094
  hashIp(ip) {
840
- const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-cloud";
841
- return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
1095
+ return createHash2("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
842
1096
  }
843
1097
  async generateAvailableSlug(domainId, length) {
844
1098
  for (let attempt = 0;attempt < 32; attempt += 1) {
@@ -852,6 +1106,52 @@ class PgShortlinksStore {
852
1106
  throw new Error("Could not generate an unused slug after 32 attempts.");
853
1107
  }
854
1108
  }
1109
+ async function applyPostgresMigrations(connectionString, migrations, options = {}) {
1110
+ const Pool = await loadPgPool();
1111
+ const pool = new Pool(createPgPoolConfig(connectionString, options));
1112
+ const client = await pool.connect();
1113
+ const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
1114
+ const get = async (sql, ...params) => {
1115
+ const result = await run(sql, ...params);
1116
+ return result.rows[0] ?? null;
1117
+ };
1118
+ const applied = [];
1119
+ const skipped = [];
1120
+ try {
1121
+ await run("BEGIN");
1122
+ await run(`
1123
+ SELECT pg_advisory_xact_lock(hashtext(?))
1124
+ `, "shortlinks:migrations");
1125
+ await run(`
1126
+ CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
1127
+ id INTEGER PRIMARY KEY,
1128
+ service TEXT NOT NULL DEFAULT 'shortlinks',
1129
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
1130
+ )
1131
+ `);
1132
+ for (let i = 0;i < migrations.length; i += 1) {
1133
+ const id = i + 1;
1134
+ const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
1135
+ if (existing) {
1136
+ skipped.push(id);
1137
+ continue;
1138
+ }
1139
+ await run(migrations[i]);
1140
+ await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
1141
+ applied.push(id);
1142
+ }
1143
+ await run("COMMIT");
1144
+ return { service: "shortlinks", applied, skipped };
1145
+ } catch (error) {
1146
+ try {
1147
+ await run("ROLLBACK");
1148
+ } catch {}
1149
+ throw error;
1150
+ } finally {
1151
+ client.release();
1152
+ await pool.end();
1153
+ }
1154
+ }
855
1155
  // src/server.ts
856
1156
  var REDIRECT_ALLOW_HEADER = "GET, HEAD";
857
1157
  function json(data, status = 200, headers) {
@@ -1202,23 +1502,39 @@ export {
1202
1502
  serveShortlinks,
1203
1503
  saveConfig,
1204
1504
  registerMachinesDns,
1505
+ redactDatabaseUrl,
1205
1506
  randomToken,
1507
+ parseShortlinksStoreMode,
1206
1508
  now,
1207
1509
  normalizeSlug,
1208
1510
  normalizeHostname,
1209
1511
  makeId,
1512
+ loadShortlinksRuntimeConfig,
1210
1513
  loadConfig,
1514
+ getShortlinksStoreMode,
1515
+ getShortlinksRuntimeStatus,
1516
+ getShortlinksRuntimeEnvName,
1517
+ getShortlinksDatabaseUrl,
1518
+ getShortlinksDatabaseSsl,
1211
1519
  getDatabasePath,
1212
1520
  getDataDir,
1213
1521
  getConfigPath,
1522
+ getCanonicalShortlinksPostgresConfig,
1214
1523
  generateWorkerScript,
1215
1524
  formatShortUrl,
1216
1525
  createShortlinksHandler,
1217
1526
  createLocalSetupPlan,
1218
1527
  createCloudflarePlan,
1528
+ assertShortlinksPostgresConfig,
1529
+ applyPostgresMigrations,
1219
1530
  ShortlinksStore,
1220
1531
  ShortlinksDatabase,
1221
1532
  SQLITE_MIGRATIONS,
1533
+ SHORTLINKS_RUNTIME_FALLBACK_ENV,
1534
+ SHORTLINKS_RUNTIME_ENV,
1222
1535
  PgShortlinksStore,
1223
- PG_MIGRATIONS
1536
+ PG_MIGRATIONS,
1537
+ CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
1538
+ CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
1539
+ CANONICAL_SHORTLINKS_POSTGRES_CLUSTER
1224
1540
  };
@@ -5,11 +5,14 @@ type PgAdapterLike = {
5
5
  run(sql: string, ...params: unknown[]): Promise<unknown>;
6
6
  close?: () => Promise<void>;
7
7
  };
8
+ export interface PgConnectionOptions {
9
+ ssl?: boolean;
10
+ }
8
11
  export declare class PgShortlinksStore {
9
12
  private readonly pg;
10
13
  constructor(pg: PgAdapterLike);
11
- static fromConnectionString(connectionString: string): Promise<PgShortlinksStore>;
12
- static fromCloud(service?: string): Promise<PgShortlinksStore>;
14
+ static fromConnectionString(connectionString: string, options?: PgConnectionOptions): Promise<PgShortlinksStore>;
15
+ static fromEnv(env?: NodeJS.ProcessEnv): Promise<PgShortlinksStore>;
13
16
  close(): Promise<void>;
14
17
  addDomain(input: AddDomainInput): Promise<Domain>;
15
18
  listDomains(): Promise<Domain[]>;
@@ -35,4 +38,9 @@ export declare class PgShortlinksStore {
35
38
  private hashIp;
36
39
  private generateAvailableSlug;
37
40
  }
41
+ export declare function applyPostgresMigrations(connectionString: string, migrations: string[], options?: PgConnectionOptions): Promise<{
42
+ service: "shortlinks";
43
+ applied: number[];
44
+ skipped: number[];
45
+ }>;
38
46
  export {};