@hasna/shortlinks 0.1.22 → 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.
@@ -0,0 +1,181 @@
1
+ // @bun
2
+ // src/runtime.ts
3
+ var SHORTLINKS_RUNTIME_ENV = {
4
+ store: "HASNA_SHORTLINKS_STORE",
5
+ databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
6
+ databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
7
+ };
8
+ var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
9
+ store: "SHORTLINKS_STORE",
10
+ databaseUrl: "SHORTLINKS_DATABASE_URL",
11
+ databaseSsl: "SHORTLINKS_DATABASE_SSL"
12
+ };
13
+ var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
14
+ var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
15
+ var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
16
+ function getCanonicalShortlinksPostgresConfig() {
17
+ return {
18
+ cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
19
+ database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
20
+ runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
21
+ primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
22
+ fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
23
+ };
24
+ }
25
+ function parseShortlinksStoreMode(value) {
26
+ const normalized = clean(value)?.toLowerCase();
27
+ if (!normalized)
28
+ return "local";
29
+ if (normalized === "local" || normalized === "postgres")
30
+ return normalized;
31
+ if (normalized === "pg")
32
+ return "postgres";
33
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
34
+ }
35
+ function getShortlinksStoreMode(env = process.env) {
36
+ return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
37
+ }
38
+ function getShortlinksDatabaseUrl(env = process.env) {
39
+ return readRuntimeEnv(env, "databaseUrl").value;
40
+ }
41
+ function getShortlinksDatabaseSsl(env = process.env) {
42
+ return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
43
+ }
44
+ function getShortlinksRuntimeEnvName(env, key) {
45
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
46
+ if (clean(env[primary]))
47
+ return primary;
48
+ return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
49
+ }
50
+ function loadShortlinksRuntimeConfig(env = process.env) {
51
+ const mode = getShortlinksStoreMode(env);
52
+ const databaseUrl = getShortlinksDatabaseUrl(env);
53
+ return {
54
+ service: "shortlinks",
55
+ mode,
56
+ ...databaseUrl ? {
57
+ database: {
58
+ provider: "postgres",
59
+ url: databaseUrl,
60
+ ssl: getShortlinksDatabaseSsl(env)
61
+ }
62
+ } : {}
63
+ };
64
+ }
65
+ function assertShortlinksPostgresConfig(config) {
66
+ if (config.mode !== "postgres")
67
+ return;
68
+ if (!config.database?.url) {
69
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
70
+ }
71
+ }
72
+ function getShortlinksRuntimeStatus(env = process.env) {
73
+ const issues = [];
74
+ const warnings = [];
75
+ let config;
76
+ try {
77
+ config = loadShortlinksRuntimeConfig(env);
78
+ } catch (error) {
79
+ issues.push(error instanceof Error ? error.message : String(error));
80
+ config = { service: "shortlinks", mode: "local" };
81
+ }
82
+ try {
83
+ assertShortlinksPostgresConfig(config);
84
+ } catch (error) {
85
+ issues.push(error instanceof Error ? error.message : String(error));
86
+ }
87
+ if (config.mode === "local" && config.database?.url) {
88
+ warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
89
+ }
90
+ return {
91
+ ok: issues.length === 0,
92
+ service: "shortlinks",
93
+ mode: config.mode,
94
+ local_default: config.mode === "local",
95
+ postgres_enabled: config.mode === "postgres",
96
+ database: {
97
+ configured: Boolean(config.database?.url),
98
+ provider: config.database?.provider ?? null,
99
+ redacted_url: redactDatabaseUrl(config.database?.url),
100
+ ssl: config.database?.ssl ?? null
101
+ },
102
+ env: runtimeEnvStatus(env),
103
+ canonical: getCanonicalShortlinksPostgresConfig(),
104
+ issues,
105
+ warnings,
106
+ no_network: true
107
+ };
108
+ }
109
+ function redactDatabaseUrl(value) {
110
+ if (!value)
111
+ return null;
112
+ try {
113
+ const url = new URL(value);
114
+ if (url.username)
115
+ url.username = "***";
116
+ if (url.password)
117
+ url.password = "***";
118
+ for (const key of Array.from(url.searchParams.keys())) {
119
+ if (isSensitiveQueryKey(key))
120
+ url.searchParams.set(key, "***");
121
+ }
122
+ return url.toString();
123
+ } catch {
124
+ return "(redacted)";
125
+ }
126
+ }
127
+ function runtimeEnvStatus(env) {
128
+ return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
129
+ const activeName = getShortlinksRuntimeEnvName(env, key);
130
+ return [
131
+ key,
132
+ {
133
+ name,
134
+ active_name: activeName,
135
+ configured: Boolean(clean(env[activeName]))
136
+ }
137
+ ];
138
+ }));
139
+ }
140
+ function readRuntimeEnv(env, key) {
141
+ const primary = SHORTLINKS_RUNTIME_ENV[key];
142
+ const primaryValue = clean(env[primary]);
143
+ if (primaryValue)
144
+ return { name: primary, value: primaryValue };
145
+ const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
146
+ return { name: fallback, value: clean(env[fallback]) };
147
+ }
148
+ function parseBoolean(value, fallback) {
149
+ const normalized = clean(value)?.toLowerCase();
150
+ if (!normalized)
151
+ return fallback;
152
+ if (["1", "true", "yes", "on"].includes(normalized))
153
+ return true;
154
+ if (["0", "false", "no", "off"].includes(normalized))
155
+ return false;
156
+ throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
157
+ }
158
+ function isSensitiveQueryKey(key) {
159
+ return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
160
+ }
161
+ function clean(value) {
162
+ const trimmed = value?.trim();
163
+ return trimmed ? trimmed : undefined;
164
+ }
165
+ export {
166
+ redactDatabaseUrl,
167
+ parseShortlinksStoreMode,
168
+ loadShortlinksRuntimeConfig,
169
+ getShortlinksStoreMode,
170
+ getShortlinksRuntimeStatus,
171
+ getShortlinksRuntimeEnvName,
172
+ getShortlinksDatabaseUrl,
173
+ getShortlinksDatabaseSsl,
174
+ getCanonicalShortlinksPostgresConfig,
175
+ assertShortlinksPostgresConfig,
176
+ SHORTLINKS_RUNTIME_FALLBACK_ENV,
177
+ SHORTLINKS_RUNTIME_ENV,
178
+ CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
179
+ CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
180
+ CANONICAL_SHORTLINKS_POSTGRES_CLUSTER
181
+ };
@@ -4,9 +4,7 @@ set -euo pipefail
4
4
  export AWS_REGION="${AWS_REGION:-us-east-1}"
5
5
  export SHORTLINKS_HOME="/var/lib/shortlinks"
6
6
  export SHORTLINKS_PACKAGE="@hasna/shortlinks@latest"
7
- export RDS_SECRET_ID="rds!db-7a451ce6-83a9-40fa-b24a-81e5d5943511"
8
- export RDS_HOST="hasnaxyz-prod-opensource.c4limg0qgqvk.us-east-1.rds.amazonaws.com"
9
- export RDS_USERNAME="hasna_admin"
7
+ export SHORTLINKS_DATABASE_SECRET_ID="${SHORTLINKS_DATABASE_SECRET_ID:-hasna/xyz/opensource/shortlinks/prod/postgres}"
10
8
 
11
9
  dnf update -y
12
10
  dnf install -y awscli jq tar gzip shadow-utils libcap
@@ -15,28 +13,14 @@ if ! id shortlinks >/dev/null 2>&1; then
15
13
  useradd --system --create-home --home-dir "${SHORTLINKS_HOME}" --shell /sbin/nologin shortlinks
16
14
  fi
17
15
 
18
- install -d -o shortlinks -g shortlinks "${SHORTLINKS_HOME}/.hasna/cloud"
19
16
  install -d -o shortlinks -g shortlinks "${SHORTLINKS_HOME}/.hasna/shortlinks"
20
-
21
- cat > "${SHORTLINKS_HOME}/.hasna/cloud/config.json" <<CLOUD_CONFIG
22
- {
23
- "rds": {
24
- "host": "${RDS_HOST}",
25
- "port": 5432,
26
- "username": "${RDS_USERNAME}",
27
- "password_env": "HASNA_RDS_PASSWORD",
28
- "ssl": true
29
- },
30
- "mode": "hybrid",
31
- "feedback_endpoint": "https://feedback.hasna.com/api/v1/feedback",
32
- "auto_sync_interval_minutes": 0,
33
- "sync": {
34
- "schedule_minutes": 0
35
- }
36
- }
37
- CLOUD_CONFIG
38
- chown shortlinks:shortlinks "${SHORTLINKS_HOME}/.hasna/cloud/config.json"
39
- chmod 600 "${SHORTLINKS_HOME}/.hasna/cloud/config.json"
17
+ cat > /etc/shortlinks.env <<ENV
18
+ SHORTLINKS_DATABASE_SECRET_ID=${SHORTLINKS_DATABASE_SECRET_ID}
19
+ HASNA_SHORTLINKS_STORE=postgres
20
+ HASNA_SHORTLINKS_DATABASE_SSL=true
21
+ ENV
22
+ chown root:shortlinks /etc/shortlinks.env
23
+ chmod 640 /etc/shortlinks.env
40
24
 
41
25
  su -s /bin/bash shortlinks -c 'curl -fsSL https://bun.sh/install | bash'
42
26
  su -s /bin/bash shortlinks -c "${SHORTLINKS_HOME}/.bun/bin/bun install -g ${SHORTLINKS_PACKAGE} --no-cache"
@@ -48,16 +32,32 @@ set -euo pipefail
48
32
  export AWS_REGION="${AWS_REGION:-us-east-1}"
49
33
  export HOME="/var/lib/shortlinks"
50
34
  export PATH="/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin"
51
- export NODE_TLS_REJECT_UNAUTHORIZED="0"
35
+ export HASNA_SHORTLINKS_STORE="postgres"
36
+ export HASNA_SHORTLINKS_DATABASE_SSL="${HASNA_SHORTLINKS_DATABASE_SSL:-true}"
52
37
 
53
38
  secret_json="$(aws secretsmanager get-secret-value \
54
39
  --region "${AWS_REGION}" \
55
- --secret-id "rds!db-7a451ce6-83a9-40fa-b24a-81e5d5943511" \
40
+ --secret-id "${SHORTLINKS_DATABASE_SECRET_ID:-hasna/xyz/opensource/shortlinks/prod/postgres}" \
56
41
  --query SecretString \
57
42
  --output text)"
58
43
 
59
- export HASNA_RDS_PASSWORD
60
- HASNA_RDS_PASSWORD="$(jq -r '.password' <<<"${secret_json}")"
44
+ connection_url="$(jq -r '(.connectionString // .connection_string // .url // .database_url // empty)' <<<"${secret_json}")"
45
+ if [[ -z "${connection_url}" ]]; then
46
+ db_host="$(jq -r '.host // empty' <<<"${secret_json}")"
47
+ db_port="$(jq -r '.port // 5432' <<<"${secret_json}")"
48
+ db_name="$(jq -r '.database // .dbname // "shortlinks"' <<<"${secret_json}")"
49
+ db_user="$(jq -r '.username // .user // empty' <<<"${secret_json}")"
50
+ db_password="$(jq -r '.password // empty' <<<"${secret_json}")"
51
+ if [[ -z "${db_host}" || -z "${db_user}" || -z "${db_password}" ]]; then
52
+ echo "Shortlinks database secret must include connectionString/url or host, username, and password." >&2
53
+ exit 1
54
+ fi
55
+ db_user_encoded="$(jq -nr --arg value "${db_user}" '$value|@uri')"
56
+ db_password_encoded="$(jq -nr --arg value "${db_password}" '$value|@uri')"
57
+ connection_url="postgres://${db_user_encoded}:${db_password_encoded}@${db_host}:${db_port}/${db_name}"
58
+ fi
59
+
60
+ export HASNA_SHORTLINKS_DATABASE_URL="${connection_url}"
61
61
 
62
62
  exec "$@"
63
63
  RUNNER
@@ -90,8 +90,9 @@ Group=shortlinks
90
90
  WorkingDirectory=/var/lib/shortlinks
91
91
  Environment=HOME=/var/lib/shortlinks
92
92
  Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
93
- Environment=SHORTLINKS_STORE=cloud
94
- ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --cloud --host 127.0.0.1 --port 8787 --default-host has.na
93
+ EnvironmentFile=/etc/shortlinks.env
94
+ ExecStartPre=/usr/local/bin/shortlinks-env-exec shortlinks postgres migrate
95
+ ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks --store postgres serve --host 127.0.0.1 --port 8787 --default-host has.na
95
96
  Restart=always
96
97
  RestartSec=5
97
98
 
@@ -133,4 +134,4 @@ CADDY
133
134
  systemctl daemon-reload
134
135
  systemctl enable shortlinks.service caddy.service
135
136
  systemctl start shortlinks.service
136
- systemctl start caddy.service || true
137
+ systemctl start caddy.service
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.22",
4
- "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and @hasna cloud sync",
3
+ "version": "0.1.23",
4
+ "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and app-owned Postgres runtime support",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -20,6 +20,14 @@
20
20
  "./cloudflare": {
21
21
  "types": "./dist/cloudflare.d.ts",
22
22
  "import": "./dist/cloudflare.js"
23
+ },
24
+ "./postgres": {
25
+ "types": "./dist/pg-store.d.ts",
26
+ "import": "./dist/pg-store.js"
27
+ },
28
+ "./runtime": {
29
+ "types": "./dist/runtime.d.ts",
30
+ "import": "./dist/runtime.js"
23
31
  }
24
32
  },
25
33
  "files": [
@@ -31,11 +39,11 @@
31
39
  "SECURITY.md"
32
40
  ],
33
41
  "scripts": {
34
- "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun --external @hasna/cloud && bun build src/index.ts --outdir dist --target bun --external @hasna/cloud && bun build src/server.ts --outdir dist --target bun --external @hasna/cloud && bun build src/cloudflare.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
42
+ "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts --outdir dist --target bun && bun build src/server.ts --outdir dist --target bun && bun build src/cloudflare.ts --outdir dist --target bun && bun build src/runtime.ts --outdir dist --target bun && bun build src/pg-store.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
35
43
  "typecheck": "tsc --noEmit",
36
44
  "test": "bun test",
37
45
  "dev:cli": "bun run src/cli/index.ts",
38
- "prepublishOnly": "bun run test && bun run build"
46
+ "prepublishOnly": "bun run typecheck && bun run test && bun run build"
39
47
  },
40
48
  "keywords": [
41
49
  "shortlinks",
@@ -66,12 +74,13 @@
66
74
  "author": "Andrei Hasna <andrei@hasna.com>",
67
75
  "license": "Apache-2.0",
68
76
  "dependencies": {
69
- "@hasna/cloud": "0.1.30",
70
77
  "@hasna/events": "^0.1.6",
71
78
  "chalk": "^5.4.1",
72
- "commander": "^13.1.0"
79
+ "commander": "^13.1.0",
80
+ "pg": "^8.13.3"
73
81
  },
74
82
  "devDependencies": {
83
+ "@types/pg": "^8.11.11",
75
84
  "@types/bun": "^1.2.4",
76
85
  "typescript": "^5.7.3"
77
86
  }