@hasna/domains 0.0.27 → 0.0.28
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/README.md +28 -0
- package/dist/cli/commands/brandsight.d.ts.map +1 -1
- package/dist/cli/commands/db.d.ts +7 -0
- package/dist/cli/commands/db.d.ts.map +1 -0
- package/dist/cli/commands/dns.d.ts.map +1 -1
- package/dist/cli/commands/domain.d.ts.map +1 -1
- package/dist/cli/commands/history.d.ts.map +1 -1
- package/dist/cli/commands/monitor.d.ts.map +1 -1
- package/dist/cli/commands/owner.d.ts.map +1 -1
- package/dist/cli/commands/research.d.ts.map +1 -1
- package/dist/cli/commands/route53.d.ts.map +1 -1
- package/dist/cli/commands/sedo.d.ts.map +1 -1
- package/dist/cli/commands/ssl.d.ts.map +1 -1
- package/dist/cli/commands/storage.d.ts.map +1 -1
- package/dist/cli/commands/wallet.d.ts.map +1 -1
- package/dist/cli/commands/zone.d.ts.map +1 -1
- package/dist/cli/index.js +33412 -18337
- package/dist/generated/storage-kit/health.d.ts +20 -0
- package/dist/generated/storage-kit/health.d.ts.map +1 -0
- package/dist/generated/storage-kit/index.d.ts +8 -0
- package/dist/generated/storage-kit/index.d.ts.map +1 -0
- package/dist/generated/storage-kit/migrations.d.ts +48 -0
- package/dist/generated/storage-kit/migrations.d.ts.map +1 -0
- package/dist/generated/storage-kit/mode.d.ts +48 -0
- package/dist/generated/storage-kit/mode.d.ts.map +1 -0
- package/dist/generated/storage-kit/pool.d.ts +34 -0
- package/dist/generated/storage-kit/pool.d.ts.map +1 -0
- package/dist/generated/storage-kit/query.d.ts +36 -0
- package/dist/generated/storage-kit/query.d.ts.map +1 -0
- package/dist/generated/storage-kit/tls.d.ts +26 -0
- package/dist/generated/storage-kit/tls.d.ts.map +1 -0
- package/dist/index.js +34299 -19874
- package/dist/lib/compact-output.d.ts +31 -0
- package/dist/lib/compact-output.d.ts.map +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +23163 -3582
- package/dist/mcp/storage-tools.d.ts.map +1 -1
- package/dist/sdk/client.d.ts +198 -0
- package/dist/sdk/client.d.ts.map +1 -0
- package/dist/sdk/index.d.ts +14 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +176 -0
- package/dist/server/app.d.ts +33 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/index.d.ts +19 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1384 -0
- package/dist/server/migrations.d.ts +22 -0
- package/dist/server/migrations.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +25 -0
- package/dist/server/openapi.d.ts.map +1 -0
- package/dist/server/repo.d.ts +44 -0
- package/dist/server/repo.d.ts.map +1 -0
- package/dist/storage.js +2 -4868
- package/package.json +13 -4
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/server/index.ts
|
|
5
|
+
import { ApiKeyStore } from "@hasna/contracts/auth";
|
|
6
|
+
|
|
7
|
+
// src/generated/storage-kit/mode.ts
|
|
8
|
+
var DEPRECATED_STORAGE_MODE_ALIASES = [
|
|
9
|
+
"remote",
|
|
10
|
+
"hybrid",
|
|
11
|
+
"self_hosted"
|
|
12
|
+
];
|
|
13
|
+
function normalizeStorageMode(value) {
|
|
14
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
15
|
+
if (normalized === "local")
|
|
16
|
+
return { mode: "local", deprecatedAlias: null };
|
|
17
|
+
if (normalized === "cloud")
|
|
18
|
+
return { mode: "cloud", deprecatedAlias: null };
|
|
19
|
+
if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
|
|
20
|
+
return { mode: "cloud", deprecatedAlias: normalized };
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
|
|
23
|
+
}
|
|
24
|
+
function envToken(name) {
|
|
25
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
26
|
+
}
|
|
27
|
+
function storageEnvKeys(name) {
|
|
28
|
+
const token = envToken(name);
|
|
29
|
+
return {
|
|
30
|
+
modeKeys: [`HASNA_${token}_STORAGE_MODE`, `${token}_STORAGE_MODE`],
|
|
31
|
+
databaseUrlKeys: [`HASNA_${token}_DATABASE_URL`, `${token}_DATABASE_URL`]
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function firstEnv(env, keys) {
|
|
35
|
+
for (const key of keys) {
|
|
36
|
+
const value = env[key]?.trim();
|
|
37
|
+
if (value)
|
|
38
|
+
return { key, value };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function resolveStorageMode(name, env = process.env) {
|
|
43
|
+
const { modeKeys, databaseUrlKeys } = storageEnvKeys(name);
|
|
44
|
+
const dbHit = firstEnv(env, databaseUrlKeys);
|
|
45
|
+
const databaseUrlPresent = Boolean(dbHit);
|
|
46
|
+
const databaseUrlSource = dbHit ? dbHit.key : null;
|
|
47
|
+
const modeHit = firstEnv(env, modeKeys);
|
|
48
|
+
if (!modeHit) {
|
|
49
|
+
return {
|
|
50
|
+
mode: "local",
|
|
51
|
+
source: "default",
|
|
52
|
+
deprecatedAlias: null,
|
|
53
|
+
databaseUrlPresent,
|
|
54
|
+
databaseUrlSource,
|
|
55
|
+
warning: null
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const { mode, deprecatedAlias } = normalizeStorageMode(modeHit.value);
|
|
59
|
+
const warnings = [];
|
|
60
|
+
if (deprecatedAlias) {
|
|
61
|
+
warnings.push(`Deprecated storage mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Set ${modeKeys[0]}=cloud instead.`);
|
|
62
|
+
}
|
|
63
|
+
if (mode === "cloud" && !databaseUrlPresent) {
|
|
64
|
+
warnings.push(`cloud mode needs ${databaseUrlKeys[0]} (PURE REMOTE: reads and writes go to cloud Postgres).`);
|
|
65
|
+
}
|
|
66
|
+
if (modeHit.key !== modeKeys[0]) {
|
|
67
|
+
warnings.push(`Using alias env ${modeHit.key}; the canonical key is ${modeKeys[0]}.`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
mode,
|
|
71
|
+
source: modeHit.key,
|
|
72
|
+
deprecatedAlias,
|
|
73
|
+
databaseUrlPresent,
|
|
74
|
+
databaseUrlSource,
|
|
75
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function resolveDatabaseUrl(name, env = process.env) {
|
|
79
|
+
const { databaseUrlKeys } = storageEnvKeys(name);
|
|
80
|
+
const hit = firstEnv(env, databaseUrlKeys);
|
|
81
|
+
return hit ? hit.value : null;
|
|
82
|
+
}
|
|
83
|
+
// src/generated/storage-kit/tls.ts
|
|
84
|
+
import { readFileSync } from "fs";
|
|
85
|
+
function sslModeFromConnectionString(connectionString) {
|
|
86
|
+
const queryStart = connectionString.indexOf("?");
|
|
87
|
+
const params = new URLSearchParams(queryStart === -1 ? "" : connectionString.slice(queryStart + 1));
|
|
88
|
+
const sslmode = params.get("sslmode")?.trim().toLowerCase();
|
|
89
|
+
if (sslmode) {
|
|
90
|
+
switch (sslmode) {
|
|
91
|
+
case "disable":
|
|
92
|
+
case "prefer":
|
|
93
|
+
case "require":
|
|
94
|
+
case "verify-ca":
|
|
95
|
+
case "verify-full":
|
|
96
|
+
return sslmode;
|
|
97
|
+
case "allow":
|
|
98
|
+
return "prefer";
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`Unknown sslmode '${sslmode}' in connection string.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const ssl = params.get("ssl")?.trim().toLowerCase();
|
|
104
|
+
if (ssl && ["1", "true", "yes", "on", "require"].includes(ssl))
|
|
105
|
+
return "require";
|
|
106
|
+
return "disable";
|
|
107
|
+
}
|
|
108
|
+
function loadCaBundle(options) {
|
|
109
|
+
const env = options.env ?? process.env;
|
|
110
|
+
if (options.ca && options.ca.trim())
|
|
111
|
+
return options.ca;
|
|
112
|
+
const path = options.caCertPath ?? env.PGSSLROOTCERT ?? env.NODE_EXTRA_CA_CERTS;
|
|
113
|
+
if (path && path.trim())
|
|
114
|
+
return readFileSync(path.trim(), "utf8");
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function resolveTlsConfig(connectionString, options = {}) {
|
|
118
|
+
const mode = sslModeFromConnectionString(connectionString);
|
|
119
|
+
if (mode === "disable" || mode === "prefer") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const ca = loadCaBundle(options);
|
|
123
|
+
if (mode === "require") {
|
|
124
|
+
return ca ? { rejectUnauthorized: false, ca } : { rejectUnauthorized: false };
|
|
125
|
+
}
|
|
126
|
+
if (!ca) {
|
|
127
|
+
throw new Error(`sslmode=${mode} requires a CA bundle. Set PGSSLROOTCERT (or pass caCertPath/ca) to the ` + `Amazon RDS global bundle: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`);
|
|
128
|
+
}
|
|
129
|
+
return { rejectUnauthorized: true, ca };
|
|
130
|
+
}
|
|
131
|
+
// src/generated/storage-kit/query.ts
|
|
132
|
+
function wrapExecutor(executor) {
|
|
133
|
+
return {
|
|
134
|
+
async query(sql, params) {
|
|
135
|
+
const result = await executor.query(sql, params);
|
|
136
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length };
|
|
137
|
+
},
|
|
138
|
+
async many(sql, params) {
|
|
139
|
+
const result = await executor.query(sql, params);
|
|
140
|
+
return result.rows;
|
|
141
|
+
},
|
|
142
|
+
async get(sql, params) {
|
|
143
|
+
const result = await executor.query(sql, params);
|
|
144
|
+
return result.rows[0] ?? null;
|
|
145
|
+
},
|
|
146
|
+
async one(sql, params) {
|
|
147
|
+
const result = await executor.query(sql, params);
|
|
148
|
+
if (result.rows.length !== 1) {
|
|
149
|
+
throw new Error(`Expected exactly one row, got ${result.rows.length}.`);
|
|
150
|
+
}
|
|
151
|
+
return result.rows[0];
|
|
152
|
+
},
|
|
153
|
+
async execute(sql, params) {
|
|
154
|
+
await executor.query(sql, params);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function createQueryClient(pool) {
|
|
159
|
+
const base = wrapExecutor(pool);
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
pool,
|
|
163
|
+
async transaction(fn) {
|
|
164
|
+
const client = await pool.connect();
|
|
165
|
+
try {
|
|
166
|
+
await client.query("BEGIN");
|
|
167
|
+
const result = await fn(wrapExecutor(client));
|
|
168
|
+
await client.query("COMMIT");
|
|
169
|
+
return result;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
try {
|
|
172
|
+
await client.query("ROLLBACK");
|
|
173
|
+
} catch {}
|
|
174
|
+
throw error;
|
|
175
|
+
} finally {
|
|
176
|
+
client.release();
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
async close() {
|
|
180
|
+
await pool.end();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// src/generated/storage-kit/pool.ts
|
|
185
|
+
import pg from "pg";
|
|
186
|
+
function createPgPool(options) {
|
|
187
|
+
const ssl = resolveTlsConfig(options.connectionString, {
|
|
188
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
189
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
190
|
+
...options.env !== undefined ? { env: options.env } : {}
|
|
191
|
+
});
|
|
192
|
+
const config = { connectionString: options.connectionString };
|
|
193
|
+
if (ssl !== undefined)
|
|
194
|
+
config.ssl = ssl;
|
|
195
|
+
if (options.max !== undefined)
|
|
196
|
+
config.max = options.max;
|
|
197
|
+
if (options.idleTimeoutMillis !== undefined)
|
|
198
|
+
config.idleTimeoutMillis = options.idleTimeoutMillis;
|
|
199
|
+
if (options.connectionTimeoutMillis !== undefined)
|
|
200
|
+
config.connectionTimeoutMillis = options.connectionTimeoutMillis;
|
|
201
|
+
if (options.applicationName !== undefined)
|
|
202
|
+
config.application_name = options.applicationName;
|
|
203
|
+
return new pg.Pool(config);
|
|
204
|
+
}
|
|
205
|
+
function createCloudPoolFromEnv(appName, options = {}) {
|
|
206
|
+
const env = options.env ?? process.env;
|
|
207
|
+
const resolution = resolveStorageMode(appName, env);
|
|
208
|
+
if (resolution.mode !== "cloud") {
|
|
209
|
+
throw new Error(`createCloudPoolFromEnv requires ${appName} storage mode 'cloud', got '${resolution.mode}'. ` + `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=cloud.`);
|
|
210
|
+
}
|
|
211
|
+
const connectionString = resolveDatabaseUrl(appName, env);
|
|
212
|
+
if (!connectionString) {
|
|
213
|
+
throw new Error(`cloud mode for ${appName} needs a database URL. Set ` + `HASNA_${appName.toUpperCase().replace(/-/g, "_")}_DATABASE_URL.`);
|
|
214
|
+
}
|
|
215
|
+
const pool = createPgPool({
|
|
216
|
+
connectionString,
|
|
217
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
218
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
219
|
+
env,
|
|
220
|
+
...options.max !== undefined ? { max: options.max } : {},
|
|
221
|
+
...options.idleTimeoutMillis !== undefined ? { idleTimeoutMillis: options.idleTimeoutMillis } : {},
|
|
222
|
+
...options.connectionTimeoutMillis !== undefined ? { connectionTimeoutMillis: options.connectionTimeoutMillis } : {},
|
|
223
|
+
...options.applicationName !== undefined ? { applicationName: options.applicationName } : {}
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
client: createQueryClient(pool),
|
|
227
|
+
connectionSource: resolution.databaseUrlSource ?? "unknown"
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// src/generated/storage-kit/migrations.ts
|
|
231
|
+
import { createHash } from "crypto";
|
|
232
|
+
function checksumSql(sql) {
|
|
233
|
+
const normalized = sql.trim().replace(/\r\n/g, `
|
|
234
|
+
`);
|
|
235
|
+
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
236
|
+
}
|
|
237
|
+
function defineMigration(id, sql) {
|
|
238
|
+
return Object.freeze({ id, sql: sql.trim(), checksum: checksumSql(sql) });
|
|
239
|
+
}
|
|
240
|
+
// src/generated/storage-kit/health.ts
|
|
241
|
+
async function checkHealth(client) {
|
|
242
|
+
const start = Date.now();
|
|
243
|
+
try {
|
|
244
|
+
await client.get("SELECT 1 AS ok");
|
|
245
|
+
return { ok: true, latencyMs: Date.now() - start };
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
latencyMs: Date.now() - start,
|
|
250
|
+
error: error instanceof Error ? error.message : String(error)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// src/lib/version.ts
|
|
255
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
256
|
+
import { dirname, resolve } from "path";
|
|
257
|
+
import { fileURLToPath } from "url";
|
|
258
|
+
var cachedVersion = null;
|
|
259
|
+
function getPackageVersion() {
|
|
260
|
+
if (cachedVersion)
|
|
261
|
+
return cachedVersion;
|
|
262
|
+
try {
|
|
263
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
264
|
+
const packageJsonPath = resolve(moduleDir, "../../package.json");
|
|
265
|
+
const pkg = JSON.parse(readFileSync2(packageJsonPath, "utf8"));
|
|
266
|
+
cachedVersion = pkg.version ?? "0.0.0";
|
|
267
|
+
} catch {
|
|
268
|
+
cachedVersion = "0.0.0";
|
|
269
|
+
}
|
|
270
|
+
return cachedVersion;
|
|
271
|
+
}
|
|
272
|
+
var USER_AGENT = `open-domains/${getPackageVersion()}`;
|
|
273
|
+
|
|
274
|
+
// src/server/app.ts
|
|
275
|
+
import { verifyApiKey } from "@hasna/contracts/auth";
|
|
276
|
+
|
|
277
|
+
// src/db/domain-records.ts
|
|
278
|
+
var DOMAIN_STATUSES = [
|
|
279
|
+
"discovered",
|
|
280
|
+
"researching",
|
|
281
|
+
"offered",
|
|
282
|
+
"negotiating",
|
|
283
|
+
"purchased",
|
|
284
|
+
"active",
|
|
285
|
+
"not_available",
|
|
286
|
+
"premium_only",
|
|
287
|
+
"declined",
|
|
288
|
+
"expired",
|
|
289
|
+
"transferring",
|
|
290
|
+
"redemption"
|
|
291
|
+
];
|
|
292
|
+
var DOMAIN_OFFER_STATUSES = ["pending", "accepted", "rejected", "countered"];
|
|
293
|
+
|
|
294
|
+
// src/server/repo.ts
|
|
295
|
+
function parseJson(value, fallback) {
|
|
296
|
+
if (value === null || value === undefined)
|
|
297
|
+
return fallback;
|
|
298
|
+
try {
|
|
299
|
+
return JSON.parse(value);
|
|
300
|
+
} catch {
|
|
301
|
+
return fallback;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function rowToDomain(row) {
|
|
305
|
+
return {
|
|
306
|
+
id: row.id,
|
|
307
|
+
name: row.name,
|
|
308
|
+
registrar: row.registrar,
|
|
309
|
+
status: row.status,
|
|
310
|
+
registered_at: row.registered_at,
|
|
311
|
+
expires_at: row.expires_at,
|
|
312
|
+
auto_renew: Boolean(row.auto_renew),
|
|
313
|
+
is_premium: Boolean(row.is_premium),
|
|
314
|
+
premium_price: row.premium_price,
|
|
315
|
+
standard_price: row.standard_price,
|
|
316
|
+
purchase_price: row.purchase_price,
|
|
317
|
+
purchase_date: row.purchase_date,
|
|
318
|
+
nameservers: parseJson(row.nameservers, []),
|
|
319
|
+
whois: parseJson(row.whois, {}),
|
|
320
|
+
ssl_expires_at: row.ssl_expires_at,
|
|
321
|
+
ssl_issuer: row.ssl_issuer,
|
|
322
|
+
notes: row.notes,
|
|
323
|
+
metadata: parseJson(row.metadata, {}),
|
|
324
|
+
created_at: row.created_at,
|
|
325
|
+
updated_at: row.updated_at
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function rowToDnsRecord(row) {
|
|
329
|
+
return {
|
|
330
|
+
id: row.id,
|
|
331
|
+
domain_id: row.domain_id,
|
|
332
|
+
type: row.type,
|
|
333
|
+
name: row.name,
|
|
334
|
+
value: row.value,
|
|
335
|
+
ttl: row.ttl,
|
|
336
|
+
priority: row.priority,
|
|
337
|
+
created_at: row.created_at
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
class HttpError extends Error {
|
|
342
|
+
status;
|
|
343
|
+
constructor(status, message) {
|
|
344
|
+
super(message);
|
|
345
|
+
this.status = status;
|
|
346
|
+
this.name = "HttpError";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
var DNS_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"];
|
|
350
|
+
|
|
351
|
+
class DomainsRepo {
|
|
352
|
+
db;
|
|
353
|
+
constructor(db) {
|
|
354
|
+
this.db = db;
|
|
355
|
+
}
|
|
356
|
+
async createDomain(input) {
|
|
357
|
+
if (!input || typeof input.name !== "string" || input.name.trim() === "") {
|
|
358
|
+
throw new HttpError(400, "domain 'name' is required");
|
|
359
|
+
}
|
|
360
|
+
const status = input.status ?? "active";
|
|
361
|
+
if (!DOMAIN_STATUSES.includes(status)) {
|
|
362
|
+
throw new HttpError(400, `invalid status '${status}'`);
|
|
363
|
+
}
|
|
364
|
+
const id = crypto.randomUUID();
|
|
365
|
+
const nowIso = new Date().toISOString();
|
|
366
|
+
try {
|
|
367
|
+
const row = await this.db.get(`INSERT INTO domains (
|
|
368
|
+
id, name, registrar, status, registered_at, expires_at, auto_renew,
|
|
369
|
+
is_premium, premium_price, standard_price, purchase_price, purchase_date,
|
|
370
|
+
nameservers, whois, ssl_expires_at, ssl_issuer, notes, metadata,
|
|
371
|
+
created_at, updated_at
|
|
372
|
+
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
|
373
|
+
RETURNING *`, [
|
|
374
|
+
id,
|
|
375
|
+
input.name.trim(),
|
|
376
|
+
input.registrar ?? null,
|
|
377
|
+
status,
|
|
378
|
+
input.registered_at ?? null,
|
|
379
|
+
input.expires_at ?? null,
|
|
380
|
+
input.auto_renew !== undefined ? input.auto_renew : true,
|
|
381
|
+
input.is_premium ?? false,
|
|
382
|
+
input.premium_price ?? null,
|
|
383
|
+
input.standard_price ?? null,
|
|
384
|
+
input.purchase_price ?? null,
|
|
385
|
+
input.purchase_date ?? null,
|
|
386
|
+
JSON.stringify(input.nameservers ?? []),
|
|
387
|
+
JSON.stringify(input.whois ?? {}),
|
|
388
|
+
input.ssl_expires_at ?? null,
|
|
389
|
+
input.ssl_issuer ?? null,
|
|
390
|
+
input.notes ?? null,
|
|
391
|
+
JSON.stringify(input.metadata ?? {}),
|
|
392
|
+
nowIso,
|
|
393
|
+
nowIso
|
|
394
|
+
]);
|
|
395
|
+
return rowToDomain(row);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
398
|
+
if (/duplicate key|unique constraint/i.test(msg)) {
|
|
399
|
+
throw new HttpError(409, `domain '${input.name}' already exists`);
|
|
400
|
+
}
|
|
401
|
+
throw e;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async getDomain(id) {
|
|
405
|
+
const row = await this.db.get("SELECT * FROM domains WHERE id = $1", [id]);
|
|
406
|
+
return row ? rowToDomain(row) : null;
|
|
407
|
+
}
|
|
408
|
+
async getDomainByName(name) {
|
|
409
|
+
const row = await this.db.get("SELECT * FROM domains WHERE name = $1", [name]);
|
|
410
|
+
return row ? rowToDomain(row) : null;
|
|
411
|
+
}
|
|
412
|
+
async listDomains(opts) {
|
|
413
|
+
const clauses = [];
|
|
414
|
+
const params = [];
|
|
415
|
+
if (opts.search) {
|
|
416
|
+
params.push(`%${opts.search}%`);
|
|
417
|
+
clauses.push(`name ILIKE $${params.length}`);
|
|
418
|
+
}
|
|
419
|
+
if (opts.status) {
|
|
420
|
+
if (!DOMAIN_STATUSES.includes(opts.status)) {
|
|
421
|
+
throw new HttpError(400, `invalid status filter '${opts.status}'`);
|
|
422
|
+
}
|
|
423
|
+
params.push(opts.status);
|
|
424
|
+
clauses.push(`status = $${params.length}`);
|
|
425
|
+
}
|
|
426
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
427
|
+
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 1000);
|
|
428
|
+
const offset = Math.max(opts.offset ?? 0, 0);
|
|
429
|
+
params.push(limit, offset);
|
|
430
|
+
const rows = await this.db.many(`SELECT * FROM domains ${where} ORDER BY name ASC LIMIT $${params.length - 1} OFFSET $${params.length}`, params);
|
|
431
|
+
return rows.map(rowToDomain);
|
|
432
|
+
}
|
|
433
|
+
async updateDomain(id, patch) {
|
|
434
|
+
const existing = await this.getDomain(id);
|
|
435
|
+
if (!existing)
|
|
436
|
+
return null;
|
|
437
|
+
const sets = [];
|
|
438
|
+
const params = [];
|
|
439
|
+
const setCol = (col, val) => {
|
|
440
|
+
params.push(val);
|
|
441
|
+
sets.push(`${col} = $${params.length}`);
|
|
442
|
+
};
|
|
443
|
+
const p = patch;
|
|
444
|
+
if ("name" in p)
|
|
445
|
+
setCol("name", p["name"]);
|
|
446
|
+
if ("registrar" in p)
|
|
447
|
+
setCol("registrar", p["registrar"] ?? null);
|
|
448
|
+
if ("status" in p) {
|
|
449
|
+
if (!DOMAIN_STATUSES.includes(p["status"])) {
|
|
450
|
+
throw new HttpError(400, `invalid status '${String(p["status"])}'`);
|
|
451
|
+
}
|
|
452
|
+
setCol("status", p["status"]);
|
|
453
|
+
}
|
|
454
|
+
if ("registered_at" in p)
|
|
455
|
+
setCol("registered_at", p["registered_at"] ?? null);
|
|
456
|
+
if ("expires_at" in p)
|
|
457
|
+
setCol("expires_at", p["expires_at"] ?? null);
|
|
458
|
+
if ("auto_renew" in p)
|
|
459
|
+
setCol("auto_renew", Boolean(p["auto_renew"]));
|
|
460
|
+
if ("is_premium" in p)
|
|
461
|
+
setCol("is_premium", Boolean(p["is_premium"]));
|
|
462
|
+
if ("premium_price" in p)
|
|
463
|
+
setCol("premium_price", p["premium_price"] ?? null);
|
|
464
|
+
if ("standard_price" in p)
|
|
465
|
+
setCol("standard_price", p["standard_price"] ?? null);
|
|
466
|
+
if ("purchase_price" in p)
|
|
467
|
+
setCol("purchase_price", p["purchase_price"] ?? null);
|
|
468
|
+
if ("purchase_date" in p)
|
|
469
|
+
setCol("purchase_date", p["purchase_date"] ?? null);
|
|
470
|
+
if ("nameservers" in p)
|
|
471
|
+
setCol("nameservers", JSON.stringify(p["nameservers"] ?? []));
|
|
472
|
+
if ("whois" in p)
|
|
473
|
+
setCol("whois", JSON.stringify(p["whois"] ?? {}));
|
|
474
|
+
if ("ssl_expires_at" in p)
|
|
475
|
+
setCol("ssl_expires_at", p["ssl_expires_at"] ?? null);
|
|
476
|
+
if ("ssl_issuer" in p)
|
|
477
|
+
setCol("ssl_issuer", p["ssl_issuer"] ?? null);
|
|
478
|
+
if ("notes" in p)
|
|
479
|
+
setCol("notes", p["notes"] ?? null);
|
|
480
|
+
if ("metadata" in p)
|
|
481
|
+
setCol("metadata", JSON.stringify(p["metadata"] ?? {}));
|
|
482
|
+
if (sets.length === 0)
|
|
483
|
+
return existing;
|
|
484
|
+
setCol("updated_at", new Date().toISOString());
|
|
485
|
+
params.push(id);
|
|
486
|
+
const row = await this.db.get(`UPDATE domains SET ${sets.join(", ")} WHERE id = $${params.length} RETURNING *`, params);
|
|
487
|
+
return row ? rowToDomain(row) : null;
|
|
488
|
+
}
|
|
489
|
+
async deleteDomain(id) {
|
|
490
|
+
const result = await this.db.query("DELETE FROM domains WHERE id = $1", [id]);
|
|
491
|
+
return result.rowCount > 0;
|
|
492
|
+
}
|
|
493
|
+
async countDomains() {
|
|
494
|
+
const row = await this.db.get("SELECT count(*)::text AS n FROM domains");
|
|
495
|
+
return row ? parseInt(row.n, 10) : 0;
|
|
496
|
+
}
|
|
497
|
+
async getStats() {
|
|
498
|
+
const row = await this.db.get(`SELECT
|
|
499
|
+
count(*)::text AS total,
|
|
500
|
+
count(*) FILTER (WHERE status = 'active')::text AS active,
|
|
501
|
+
count(*) FILTER (WHERE status = 'expired')::text AS expired,
|
|
502
|
+
count(*) FILTER (WHERE status = 'transferring')::text AS transferring,
|
|
503
|
+
count(*) FILTER (WHERE status = 'redemption')::text AS redemption,
|
|
504
|
+
count(*) FILTER (WHERE auto_renew = true)::text AS auto_renew_enabled,
|
|
505
|
+
count(*) FILTER (
|
|
506
|
+
WHERE NULLIF(expires_at, '')::timestamptz
|
|
507
|
+
BETWEEN now() AND now() + interval '30 days'
|
|
508
|
+
)::text AS expiring_30_days,
|
|
509
|
+
count(*) FILTER (
|
|
510
|
+
WHERE NULLIF(ssl_expires_at, '')::timestamptz
|
|
511
|
+
BETWEEN now() AND now() + interval '30 days'
|
|
512
|
+
)::text AS ssl_expiring_30_days
|
|
513
|
+
FROM domains`);
|
|
514
|
+
const n = (k) => row && row[k] ? parseInt(row[k], 10) : 0;
|
|
515
|
+
return {
|
|
516
|
+
total: n("total"),
|
|
517
|
+
active: n("active"),
|
|
518
|
+
expired: n("expired"),
|
|
519
|
+
transferring: n("transferring"),
|
|
520
|
+
redemption: n("redemption"),
|
|
521
|
+
auto_renew_enabled: n("auto_renew_enabled"),
|
|
522
|
+
expiring_30_days: n("expiring_30_days"),
|
|
523
|
+
ssl_expiring_30_days: n("ssl_expiring_30_days")
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
async listDnsRecords(domainId) {
|
|
527
|
+
const rows = await this.db.many("SELECT * FROM dns_records WHERE domain_id = $1 ORDER BY type, name", [domainId]);
|
|
528
|
+
return rows.map(rowToDnsRecord);
|
|
529
|
+
}
|
|
530
|
+
async createDnsRecord(domainId, input) {
|
|
531
|
+
if (!input || typeof input.type !== "string" || !DNS_TYPES.includes(input.type)) {
|
|
532
|
+
throw new HttpError(400, `dns record 'type' must be one of ${DNS_TYPES.join(", ")}`);
|
|
533
|
+
}
|
|
534
|
+
if (typeof input.name !== "string" || typeof input.value !== "string") {
|
|
535
|
+
throw new HttpError(400, "dns record 'name' and 'value' are required");
|
|
536
|
+
}
|
|
537
|
+
const domain = await this.getDomain(domainId);
|
|
538
|
+
if (!domain)
|
|
539
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
540
|
+
const id = crypto.randomUUID();
|
|
541
|
+
const row = await this.db.get(`INSERT INTO dns_records (id, domain_id, type, name, value, ttl, priority, created_at)
|
|
542
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [
|
|
543
|
+
id,
|
|
544
|
+
domainId,
|
|
545
|
+
input.type,
|
|
546
|
+
input.name,
|
|
547
|
+
input.value,
|
|
548
|
+
input.ttl ?? 3600,
|
|
549
|
+
input.priority ?? null,
|
|
550
|
+
new Date().toISOString()
|
|
551
|
+
]);
|
|
552
|
+
return rowToDnsRecord(row);
|
|
553
|
+
}
|
|
554
|
+
async getDnsRecord(id) {
|
|
555
|
+
const row = await this.db.get("SELECT * FROM dns_records WHERE id = $1", [id]);
|
|
556
|
+
return row ? rowToDnsRecord(row) : null;
|
|
557
|
+
}
|
|
558
|
+
async deleteDnsRecord(id) {
|
|
559
|
+
const result = await this.db.query("DELETE FROM dns_records WHERE id = $1", [id]);
|
|
560
|
+
return result.rowCount > 0;
|
|
561
|
+
}
|
|
562
|
+
async listOffers(domainId) {
|
|
563
|
+
const rows = await this.db.many("SELECT * FROM domain_offers WHERE domain_id = $1 ORDER BY created_at DESC", [domainId]);
|
|
564
|
+
return rows;
|
|
565
|
+
}
|
|
566
|
+
async createOffer(domainId, input) {
|
|
567
|
+
const domain = await this.getDomain(domainId);
|
|
568
|
+
if (!domain)
|
|
569
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
570
|
+
const status = input.status ?? "pending";
|
|
571
|
+
if (!DOMAIN_OFFER_STATUSES.includes(status)) {
|
|
572
|
+
throw new HttpError(400, `invalid offer status '${status}'`);
|
|
573
|
+
}
|
|
574
|
+
const id = crypto.randomUUID();
|
|
575
|
+
const row = await this.db.get(`INSERT INTO domain_offers (id, domain_id, our_offer, their_ask, status, notes, created_at)
|
|
576
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [
|
|
577
|
+
id,
|
|
578
|
+
domainId,
|
|
579
|
+
input.our_offer ?? null,
|
|
580
|
+
input.their_ask ?? null,
|
|
581
|
+
status,
|
|
582
|
+
input.notes ?? null,
|
|
583
|
+
new Date().toISOString()
|
|
584
|
+
]);
|
|
585
|
+
return row;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/server/migrations.ts
|
|
590
|
+
import { apiKeyMigrations } from "@hasna/contracts/auth";
|
|
591
|
+
|
|
592
|
+
// src/db/pg-migrations.ts
|
|
593
|
+
var PG_MIGRATIONS = [
|
|
594
|
+
`CREATE TABLE IF NOT EXISTS domains (
|
|
595
|
+
id TEXT PRIMARY KEY,
|
|
596
|
+
name TEXT NOT NULL UNIQUE,
|
|
597
|
+
registrar TEXT,
|
|
598
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('discovered', 'researching', 'offered', 'negotiating', 'purchased', 'active', 'not_available', 'premium_only', 'declined', 'expired', 'transferring', 'redemption')),
|
|
599
|
+
registered_at TEXT,
|
|
600
|
+
expires_at TEXT,
|
|
601
|
+
auto_renew BOOLEAN NOT NULL DEFAULT TRUE,
|
|
602
|
+
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
|
|
603
|
+
premium_price DOUBLE PRECISION,
|
|
604
|
+
standard_price DOUBLE PRECISION,
|
|
605
|
+
purchase_price DOUBLE PRECISION,
|
|
606
|
+
purchase_date TEXT,
|
|
607
|
+
nameservers TEXT NOT NULL DEFAULT '[]',
|
|
608
|
+
whois TEXT NOT NULL DEFAULT '{}',
|
|
609
|
+
ssl_expires_at TEXT,
|
|
610
|
+
ssl_issuer TEXT,
|
|
611
|
+
notes TEXT,
|
|
612
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
613
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
614
|
+
updated_at TEXT NOT NULL DEFAULT NOW()::text
|
|
615
|
+
)`,
|
|
616
|
+
`CREATE TABLE IF NOT EXISTS dns_records (
|
|
617
|
+
id TEXT PRIMARY KEY,
|
|
618
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
619
|
+
type TEXT NOT NULL CHECK (type IN ('A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV')),
|
|
620
|
+
name TEXT NOT NULL,
|
|
621
|
+
value TEXT NOT NULL,
|
|
622
|
+
ttl INTEGER NOT NULL DEFAULT 3600,
|
|
623
|
+
priority INTEGER,
|
|
624
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
625
|
+
)`,
|
|
626
|
+
`CREATE TABLE IF NOT EXISTS alerts (
|
|
627
|
+
id TEXT PRIMARY KEY,
|
|
628
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
629
|
+
type TEXT NOT NULL CHECK (type IN ('expiry', 'ssl_expiry', 'dns_change')),
|
|
630
|
+
trigger_days_before INTEGER,
|
|
631
|
+
sent_at TEXT,
|
|
632
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
633
|
+
)`,
|
|
634
|
+
`CREATE TABLE IF NOT EXISTS domain_offers (
|
|
635
|
+
id TEXT PRIMARY KEY,
|
|
636
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
637
|
+
our_offer DOUBLE PRECISION,
|
|
638
|
+
their_ask DOUBLE PRECISION,
|
|
639
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'countered')),
|
|
640
|
+
notes TEXT,
|
|
641
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
642
|
+
owner_contact_id TEXT
|
|
643
|
+
)`,
|
|
644
|
+
`CREATE TABLE IF NOT EXISTS domain_emails (
|
|
645
|
+
id TEXT PRIMARY KEY,
|
|
646
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
647
|
+
email_id TEXT NOT NULL,
|
|
648
|
+
thread_id TEXT,
|
|
649
|
+
type TEXT NOT NULL CHECK (type IN ('inquiry', 'offer', 'counter_offer', 'confirmation', 'renewal_notice', 'transfer')),
|
|
650
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
651
|
+
UNIQUE(domain_id, email_id)
|
|
652
|
+
)`,
|
|
653
|
+
`CREATE TABLE IF NOT EXISTS domain_owners (
|
|
654
|
+
id TEXT PRIMARY KEY,
|
|
655
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
656
|
+
contact_id TEXT,
|
|
657
|
+
owner_name TEXT,
|
|
658
|
+
owner_email TEXT,
|
|
659
|
+
owner_phone TEXT,
|
|
660
|
+
owner_organization TEXT,
|
|
661
|
+
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('whois', 'manual', 'brandsight', 'import')),
|
|
662
|
+
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
663
|
+
notes TEXT,
|
|
664
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
665
|
+
updated_at TEXT NOT NULL DEFAULT NOW()::text
|
|
666
|
+
)`,
|
|
667
|
+
`CREATE TABLE IF NOT EXISTS domain_history (
|
|
668
|
+
id TEXT PRIMARY KEY,
|
|
669
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
670
|
+
snapshot_type TEXT NOT NULL CHECK (snapshot_type IN ('whois', 'rdap', 'dns', 'ssl', 'reputation', 'exa_research', 'purchase', 'renewal')),
|
|
671
|
+
raw_data TEXT NOT NULL DEFAULT '{}',
|
|
672
|
+
registrant_name TEXT,
|
|
673
|
+
registrant_email TEXT,
|
|
674
|
+
registrant_org TEXT,
|
|
675
|
+
nameservers TEXT NOT NULL DEFAULT '[]',
|
|
676
|
+
registrar TEXT,
|
|
677
|
+
status TEXT,
|
|
678
|
+
notes TEXT,
|
|
679
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
680
|
+
)`,
|
|
681
|
+
`CREATE TABLE IF NOT EXISTS domain_reputation (
|
|
682
|
+
id TEXT PRIMARY KEY,
|
|
683
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
684
|
+
is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE,
|
|
685
|
+
blacklist_sources TEXT NOT NULL DEFAULT '[]',
|
|
686
|
+
threat_score INTEGER,
|
|
687
|
+
spam_score INTEGER,
|
|
688
|
+
malware_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
|
689
|
+
phishing_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
|
690
|
+
reputation_sources TEXT NOT NULL DEFAULT '[]',
|
|
691
|
+
last_checked_at TEXT,
|
|
692
|
+
notes TEXT,
|
|
693
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
694
|
+
updated_at TEXT NOT NULL DEFAULT NOW()::text
|
|
695
|
+
)`,
|
|
696
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_name ON domains(name)`,
|
|
697
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_registrar ON domains(registrar)`,
|
|
698
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_status ON domains(status)`,
|
|
699
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_expires_at ON domains(expires_at)`,
|
|
700
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_is_premium ON domains(is_premium)`,
|
|
701
|
+
`CREATE INDEX IF NOT EXISTS idx_dns_records_domain ON dns_records(domain_id)`,
|
|
702
|
+
`CREATE INDEX IF NOT EXISTS idx_dns_records_type ON dns_records(type)`,
|
|
703
|
+
`CREATE INDEX IF NOT EXISTS idx_alerts_domain ON alerts(domain_id)`,
|
|
704
|
+
`CREATE INDEX IF NOT EXISTS idx_alerts_type ON alerts(type)`,
|
|
705
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_offers_domain ON domain_offers(domain_id)`,
|
|
706
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_offers_owner ON domain_offers(owner_contact_id)`,
|
|
707
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_emails_domain ON domain_emails(domain_id)`,
|
|
708
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_emails_unique ON domain_emails(domain_id, email_id)`,
|
|
709
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_owners_domain ON domain_owners(domain_id)`,
|
|
710
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_owners_contact ON domain_owners(contact_id)`,
|
|
711
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_owners_email ON domain_owners(owner_email)`,
|
|
712
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_domain ON domain_history(domain_id)`,
|
|
713
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_type ON domain_history(snapshot_type)`,
|
|
714
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_created ON domain_history(created_at)`,
|
|
715
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_email ON domain_history(registrant_email)`,
|
|
716
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_reputation_domain ON domain_reputation(domain_id)`,
|
|
717
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_reputation_blacklisted ON domain_reputation(is_blacklisted)`
|
|
718
|
+
];
|
|
719
|
+
|
|
720
|
+
// src/server/migrations.ts
|
|
721
|
+
function buildMigrations() {
|
|
722
|
+
const migrations2 = [];
|
|
723
|
+
PG_MIGRATIONS.forEach((sql, i) => {
|
|
724
|
+
const id = `domains_${String(i + 1).padStart(4, "0")}`;
|
|
725
|
+
migrations2.push(defineMigration(id, sql));
|
|
726
|
+
});
|
|
727
|
+
for (const m of apiKeyMigrations("api_keys")) {
|
|
728
|
+
migrations2.push(defineMigration(m.id, m.sql));
|
|
729
|
+
}
|
|
730
|
+
return migrations2;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/server/openapi.ts
|
|
734
|
+
var ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
735
|
+
function jsonResponse(schema, description) {
|
|
736
|
+
return { description, content: { "application/json": { schema } } };
|
|
737
|
+
}
|
|
738
|
+
function jsonBody(schema) {
|
|
739
|
+
return { required: true, content: { "application/json": { schema } } };
|
|
740
|
+
}
|
|
741
|
+
function buildOpenApiSpec(version) {
|
|
742
|
+
const idParam = {
|
|
743
|
+
name: "id",
|
|
744
|
+
in: "path",
|
|
745
|
+
required: true,
|
|
746
|
+
schema: { type: "string" },
|
|
747
|
+
description: "Resource identifier (UUID)."
|
|
748
|
+
};
|
|
749
|
+
return {
|
|
750
|
+
openapi: "3.1.0",
|
|
751
|
+
info: {
|
|
752
|
+
title: "domains",
|
|
753
|
+
version,
|
|
754
|
+
description: "Domain portfolio, registrar, marketplace, and DNS management HTTP API (self_hosted). API-key authenticated."
|
|
755
|
+
},
|
|
756
|
+
security: [{ apiKey: [] }],
|
|
757
|
+
paths: {
|
|
758
|
+
"/health": {
|
|
759
|
+
get: {
|
|
760
|
+
operationId: "getHealth",
|
|
761
|
+
summary: "Liveness probe (DB reachable).",
|
|
762
|
+
responses: { "200": jsonResponse(ref("HealthResponse"), "Service healthy") }
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
"/ready": {
|
|
766
|
+
get: {
|
|
767
|
+
operationId: "getReady",
|
|
768
|
+
summary: "Readiness probe (DB reachable and schema migrated).",
|
|
769
|
+
responses: {
|
|
770
|
+
"200": jsonResponse(ref("ReadyResponse"), "Service ready"),
|
|
771
|
+
"503": jsonResponse(ref("ReadyResponse"), "Not ready")
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
"/version": {
|
|
776
|
+
get: {
|
|
777
|
+
operationId: "getVersion",
|
|
778
|
+
summary: "Service version and mode.",
|
|
779
|
+
responses: { "200": jsonResponse(ref("VersionResponse"), "Version info") }
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
"/v1/domains": {
|
|
783
|
+
get: {
|
|
784
|
+
operationId: "listDomains",
|
|
785
|
+
summary: "List domains.",
|
|
786
|
+
parameters: [
|
|
787
|
+
{ name: "search", in: "query", required: false, schema: { type: "string" } },
|
|
788
|
+
{ name: "status", in: "query", required: false, schema: { type: "string" } },
|
|
789
|
+
{ name: "limit", in: "query", required: false, schema: { type: "integer" } },
|
|
790
|
+
{ name: "offset", in: "query", required: false, schema: { type: "integer" } }
|
|
791
|
+
],
|
|
792
|
+
responses: { "200": jsonResponse(ref("DomainList"), "A page of domains") }
|
|
793
|
+
},
|
|
794
|
+
post: {
|
|
795
|
+
operationId: "createDomain",
|
|
796
|
+
summary: "Create a domain.",
|
|
797
|
+
requestBody: jsonBody(ref("CreateDomainInput")),
|
|
798
|
+
responses: {
|
|
799
|
+
"201": jsonResponse(ref("Domain"), "Created"),
|
|
800
|
+
"400": jsonResponse(ref("Error"), "Invalid input"),
|
|
801
|
+
"409": jsonResponse(ref("Error"), "Already exists")
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
"/v1/domains/{id}": {
|
|
806
|
+
get: {
|
|
807
|
+
operationId: "getDomain",
|
|
808
|
+
summary: "Get a domain by id.",
|
|
809
|
+
parameters: [idParam],
|
|
810
|
+
responses: {
|
|
811
|
+
"200": jsonResponse(ref("Domain"), "The domain"),
|
|
812
|
+
"404": jsonResponse(ref("Error"), "Not found")
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
patch: {
|
|
816
|
+
operationId: "updateDomain",
|
|
817
|
+
summary: "Update a domain.",
|
|
818
|
+
parameters: [idParam],
|
|
819
|
+
requestBody: jsonBody(ref("UpdateDomainInput")),
|
|
820
|
+
responses: {
|
|
821
|
+
"200": jsonResponse(ref("Domain"), "Updated"),
|
|
822
|
+
"404": jsonResponse(ref("Error"), "Not found")
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
delete: {
|
|
826
|
+
operationId: "deleteDomain",
|
|
827
|
+
summary: "Delete a domain.",
|
|
828
|
+
parameters: [idParam],
|
|
829
|
+
responses: { "200": jsonResponse(ref("DeleteResult"), "Deleted") }
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
"/v1/stats": {
|
|
833
|
+
get: {
|
|
834
|
+
operationId: "getDomainStats",
|
|
835
|
+
summary: "Portfolio statistics.",
|
|
836
|
+
responses: { "200": jsonResponse(ref("DomainStats"), "Stats") }
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
"/v1/domains/{id}/dns": {
|
|
840
|
+
get: {
|
|
841
|
+
operationId: "listDnsRecords",
|
|
842
|
+
summary: "List DNS records for a domain.",
|
|
843
|
+
parameters: [idParam],
|
|
844
|
+
responses: { "200": jsonResponse(ref("DnsRecordList"), "DNS records") }
|
|
845
|
+
},
|
|
846
|
+
post: {
|
|
847
|
+
operationId: "createDnsRecord",
|
|
848
|
+
summary: "Create a DNS record for a domain.",
|
|
849
|
+
parameters: [idParam],
|
|
850
|
+
requestBody: jsonBody(ref("CreateDnsRecordInput")),
|
|
851
|
+
responses: {
|
|
852
|
+
"201": jsonResponse(ref("DnsRecord"), "Created"),
|
|
853
|
+
"404": jsonResponse(ref("Error"), "Domain not found")
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
"/v1/dns/{id}": {
|
|
858
|
+
get: {
|
|
859
|
+
operationId: "getDnsRecord",
|
|
860
|
+
summary: "Get a DNS record by id.",
|
|
861
|
+
parameters: [idParam],
|
|
862
|
+
responses: {
|
|
863
|
+
"200": jsonResponse(ref("DnsRecord"), "The record"),
|
|
864
|
+
"404": jsonResponse(ref("Error"), "Not found")
|
|
865
|
+
}
|
|
866
|
+
},
|
|
867
|
+
delete: {
|
|
868
|
+
operationId: "deleteDnsRecord",
|
|
869
|
+
summary: "Delete a DNS record.",
|
|
870
|
+
parameters: [idParam],
|
|
871
|
+
responses: { "200": jsonResponse(ref("DeleteResult"), "Deleted") }
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
"/v1/domains/{id}/offers": {
|
|
875
|
+
get: {
|
|
876
|
+
operationId: "listOffers",
|
|
877
|
+
summary: "List marketplace offers for a domain.",
|
|
878
|
+
parameters: [idParam],
|
|
879
|
+
responses: { "200": jsonResponse(ref("OfferList"), "Offers") }
|
|
880
|
+
},
|
|
881
|
+
post: {
|
|
882
|
+
operationId: "createOffer",
|
|
883
|
+
summary: "Create a marketplace offer for a domain.",
|
|
884
|
+
parameters: [idParam],
|
|
885
|
+
requestBody: jsonBody(ref("CreateOfferInput")),
|
|
886
|
+
responses: {
|
|
887
|
+
"201": jsonResponse(ref("DomainOffer"), "Created"),
|
|
888
|
+
"404": jsonResponse(ref("Error"), "Domain not found")
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
components: {
|
|
894
|
+
securitySchemes: {
|
|
895
|
+
apiKey: { type: "apiKey", in: "header", name: "x-api-key" }
|
|
896
|
+
},
|
|
897
|
+
schemas: {
|
|
898
|
+
Error: {
|
|
899
|
+
type: "object",
|
|
900
|
+
properties: { error: { type: "string" }, reason: { type: "string" } },
|
|
901
|
+
required: ["error"]
|
|
902
|
+
},
|
|
903
|
+
HealthResponse: {
|
|
904
|
+
type: "object",
|
|
905
|
+
properties: {
|
|
906
|
+
status: { type: "string" },
|
|
907
|
+
version: { type: "string" },
|
|
908
|
+
mode: { type: "string" },
|
|
909
|
+
latencyMs: { type: "number" }
|
|
910
|
+
},
|
|
911
|
+
required: ["status", "version", "mode"]
|
|
912
|
+
},
|
|
913
|
+
ReadyResponse: {
|
|
914
|
+
type: "object",
|
|
915
|
+
properties: {
|
|
916
|
+
status: { type: "string" },
|
|
917
|
+
version: { type: "string" },
|
|
918
|
+
mode: { type: "string" },
|
|
919
|
+
pendingMigrations: { type: "array", items: { type: "string" } }
|
|
920
|
+
},
|
|
921
|
+
required: ["status", "version", "mode"]
|
|
922
|
+
},
|
|
923
|
+
VersionResponse: {
|
|
924
|
+
type: "object",
|
|
925
|
+
properties: {
|
|
926
|
+
status: { type: "string" },
|
|
927
|
+
version: { type: "string" },
|
|
928
|
+
mode: { type: "string" }
|
|
929
|
+
},
|
|
930
|
+
required: ["status", "version", "mode"]
|
|
931
|
+
},
|
|
932
|
+
DeleteResult: {
|
|
933
|
+
type: "object",
|
|
934
|
+
properties: { id: { type: "string" }, deleted: { type: "boolean" } },
|
|
935
|
+
required: ["id", "deleted"]
|
|
936
|
+
},
|
|
937
|
+
Domain: {
|
|
938
|
+
type: "object",
|
|
939
|
+
properties: {
|
|
940
|
+
id: { type: "string" },
|
|
941
|
+
name: { type: "string" },
|
|
942
|
+
registrar: { type: "string", nullable: true },
|
|
943
|
+
status: { type: "string" },
|
|
944
|
+
registered_at: { type: "string", nullable: true },
|
|
945
|
+
expires_at: { type: "string", nullable: true },
|
|
946
|
+
auto_renew: { type: "boolean" },
|
|
947
|
+
is_premium: { type: "boolean" },
|
|
948
|
+
premium_price: { type: "number", nullable: true },
|
|
949
|
+
standard_price: { type: "number", nullable: true },
|
|
950
|
+
purchase_price: { type: "number", nullable: true },
|
|
951
|
+
purchase_date: { type: "string", nullable: true },
|
|
952
|
+
nameservers: { type: "array", items: { type: "string" } },
|
|
953
|
+
whois: { type: "object", additionalProperties: true },
|
|
954
|
+
ssl_expires_at: { type: "string", nullable: true },
|
|
955
|
+
ssl_issuer: { type: "string", nullable: true },
|
|
956
|
+
notes: { type: "string", nullable: true },
|
|
957
|
+
metadata: { type: "object", additionalProperties: true },
|
|
958
|
+
created_at: { type: "string" },
|
|
959
|
+
updated_at: { type: "string" }
|
|
960
|
+
},
|
|
961
|
+
required: ["id", "name", "status", "auto_renew", "is_premium", "created_at", "updated_at"]
|
|
962
|
+
},
|
|
963
|
+
DomainList: {
|
|
964
|
+
type: "object",
|
|
965
|
+
properties: {
|
|
966
|
+
domains: { type: "array", items: ref("Domain") },
|
|
967
|
+
count: { type: "integer" }
|
|
968
|
+
},
|
|
969
|
+
required: ["domains", "count"]
|
|
970
|
+
},
|
|
971
|
+
CreateDomainInput: {
|
|
972
|
+
type: "object",
|
|
973
|
+
properties: {
|
|
974
|
+
name: { type: "string" },
|
|
975
|
+
registrar: { type: "string" },
|
|
976
|
+
status: { type: "string" },
|
|
977
|
+
registered_at: { type: "string" },
|
|
978
|
+
expires_at: { type: "string" },
|
|
979
|
+
auto_renew: { type: "boolean" },
|
|
980
|
+
is_premium: { type: "boolean" },
|
|
981
|
+
premium_price: { type: "number" },
|
|
982
|
+
standard_price: { type: "number" },
|
|
983
|
+
purchase_price: { type: "number" },
|
|
984
|
+
purchase_date: { type: "string" },
|
|
985
|
+
nameservers: { type: "array", items: { type: "string" } },
|
|
986
|
+
whois: { type: "object", additionalProperties: true },
|
|
987
|
+
ssl_expires_at: { type: "string" },
|
|
988
|
+
ssl_issuer: { type: "string" },
|
|
989
|
+
notes: { type: "string" },
|
|
990
|
+
metadata: { type: "object", additionalProperties: true }
|
|
991
|
+
},
|
|
992
|
+
required: ["name"]
|
|
993
|
+
},
|
|
994
|
+
UpdateDomainInput: {
|
|
995
|
+
type: "object",
|
|
996
|
+
properties: {
|
|
997
|
+
name: { type: "string" },
|
|
998
|
+
registrar: { type: "string", nullable: true },
|
|
999
|
+
status: { type: "string" },
|
|
1000
|
+
registered_at: { type: "string", nullable: true },
|
|
1001
|
+
expires_at: { type: "string", nullable: true },
|
|
1002
|
+
auto_renew: { type: "boolean" },
|
|
1003
|
+
is_premium: { type: "boolean" },
|
|
1004
|
+
premium_price: { type: "number", nullable: true },
|
|
1005
|
+
standard_price: { type: "number", nullable: true },
|
|
1006
|
+
purchase_price: { type: "number", nullable: true },
|
|
1007
|
+
purchase_date: { type: "string", nullable: true },
|
|
1008
|
+
nameservers: { type: "array", items: { type: "string" } },
|
|
1009
|
+
whois: { type: "object", additionalProperties: true },
|
|
1010
|
+
ssl_expires_at: { type: "string", nullable: true },
|
|
1011
|
+
ssl_issuer: { type: "string", nullable: true },
|
|
1012
|
+
notes: { type: "string", nullable: true },
|
|
1013
|
+
metadata: { type: "object", additionalProperties: true }
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
DnsRecord: {
|
|
1017
|
+
type: "object",
|
|
1018
|
+
properties: {
|
|
1019
|
+
id: { type: "string" },
|
|
1020
|
+
domain_id: { type: "string" },
|
|
1021
|
+
type: { type: "string" },
|
|
1022
|
+
name: { type: "string" },
|
|
1023
|
+
value: { type: "string" },
|
|
1024
|
+
ttl: { type: "integer" },
|
|
1025
|
+
priority: { type: "integer", nullable: true },
|
|
1026
|
+
created_at: { type: "string" }
|
|
1027
|
+
},
|
|
1028
|
+
required: ["id", "domain_id", "type", "name", "value", "ttl", "created_at"]
|
|
1029
|
+
},
|
|
1030
|
+
DnsRecordList: {
|
|
1031
|
+
type: "object",
|
|
1032
|
+
properties: {
|
|
1033
|
+
records: { type: "array", items: ref("DnsRecord") },
|
|
1034
|
+
count: { type: "integer" }
|
|
1035
|
+
},
|
|
1036
|
+
required: ["records", "count"]
|
|
1037
|
+
},
|
|
1038
|
+
CreateDnsRecordInput: {
|
|
1039
|
+
type: "object",
|
|
1040
|
+
properties: {
|
|
1041
|
+
type: { type: "string", enum: ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"] },
|
|
1042
|
+
name: { type: "string" },
|
|
1043
|
+
value: { type: "string" },
|
|
1044
|
+
ttl: { type: "integer" },
|
|
1045
|
+
priority: { type: "integer" }
|
|
1046
|
+
},
|
|
1047
|
+
required: ["type", "name", "value"]
|
|
1048
|
+
},
|
|
1049
|
+
DomainOffer: {
|
|
1050
|
+
type: "object",
|
|
1051
|
+
properties: {
|
|
1052
|
+
id: { type: "string" },
|
|
1053
|
+
domain_id: { type: "string" },
|
|
1054
|
+
our_offer: { type: "number", nullable: true },
|
|
1055
|
+
their_ask: { type: "number", nullable: true },
|
|
1056
|
+
status: { type: "string" },
|
|
1057
|
+
notes: { type: "string", nullable: true },
|
|
1058
|
+
created_at: { type: "string" }
|
|
1059
|
+
},
|
|
1060
|
+
required: ["id", "domain_id", "status", "created_at"]
|
|
1061
|
+
},
|
|
1062
|
+
OfferList: {
|
|
1063
|
+
type: "object",
|
|
1064
|
+
properties: {
|
|
1065
|
+
offers: { type: "array", items: ref("DomainOffer") },
|
|
1066
|
+
count: { type: "integer" }
|
|
1067
|
+
},
|
|
1068
|
+
required: ["offers", "count"]
|
|
1069
|
+
},
|
|
1070
|
+
CreateOfferInput: {
|
|
1071
|
+
type: "object",
|
|
1072
|
+
properties: {
|
|
1073
|
+
our_offer: { type: "number" },
|
|
1074
|
+
their_ask: { type: "number" },
|
|
1075
|
+
status: { type: "string" },
|
|
1076
|
+
notes: { type: "string" }
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
DomainStats: {
|
|
1080
|
+
type: "object",
|
|
1081
|
+
properties: {
|
|
1082
|
+
total: { type: "integer" },
|
|
1083
|
+
active: { type: "integer" },
|
|
1084
|
+
expired: { type: "integer" },
|
|
1085
|
+
transferring: { type: "integer" },
|
|
1086
|
+
redemption: { type: "integer" },
|
|
1087
|
+
auto_renew_enabled: { type: "integer" },
|
|
1088
|
+
expiring_30_days: { type: "integer" },
|
|
1089
|
+
ssl_expiring_30_days: { type: "integer" }
|
|
1090
|
+
},
|
|
1091
|
+
required: ["total"]
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// src/server/app.ts
|
|
1099
|
+
async function readReadiness(db, knownIds) {
|
|
1100
|
+
const start = Date.now();
|
|
1101
|
+
try {
|
|
1102
|
+
const rows = await db.many("SELECT id FROM schema_migrations");
|
|
1103
|
+
const applied = new Set(rows.map((r) => r.id));
|
|
1104
|
+
const pending = knownIds.filter((id) => !applied.has(id));
|
|
1105
|
+
return { ok: pending.length === 0, pendingMigrations: pending, latencyMs: Date.now() - start };
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
return {
|
|
1108
|
+
ok: false,
|
|
1109
|
+
pendingMigrations: knownIds,
|
|
1110
|
+
latencyMs: Date.now() - start,
|
|
1111
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
var SECURITY_HEADERS = {
|
|
1116
|
+
"X-Content-Type-Options": "nosniff",
|
|
1117
|
+
"X-Frame-Options": "DENY",
|
|
1118
|
+
"Referrer-Policy": "strict-origin-when-cross-origin"
|
|
1119
|
+
};
|
|
1120
|
+
function json(data, status = 200) {
|
|
1121
|
+
return new Response(JSON.stringify(data), {
|
|
1122
|
+
status,
|
|
1123
|
+
headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
function createServeApp(options) {
|
|
1127
|
+
const { db, version } = options;
|
|
1128
|
+
const mode2 = options.mode ?? "self_hosted";
|
|
1129
|
+
const repo = new DomainsRepo(db);
|
|
1130
|
+
const migrationIds = buildMigrations().map((m) => m.id);
|
|
1131
|
+
const spec = buildOpenApiSpec(version);
|
|
1132
|
+
const verifier = verifyApiKey({
|
|
1133
|
+
app: "domains",
|
|
1134
|
+
signingSecret: options.signingSecret,
|
|
1135
|
+
...options.isRevoked ? { isRevoked: options.isRevoked } : {},
|
|
1136
|
+
...options.audit ? { audit: options.audit } : {}
|
|
1137
|
+
});
|
|
1138
|
+
async function readBody(req) {
|
|
1139
|
+
const text = await req.text();
|
|
1140
|
+
if (!text)
|
|
1141
|
+
return {};
|
|
1142
|
+
try {
|
|
1143
|
+
return JSON.parse(text);
|
|
1144
|
+
} catch {
|
|
1145
|
+
throw new HttpError(400, "invalid JSON body");
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
async function auth(req, path, scopes) {
|
|
1149
|
+
const decision = await verifier.authenticate(req.headers, {
|
|
1150
|
+
method: req.method,
|
|
1151
|
+
path,
|
|
1152
|
+
requiredScopes: scopes
|
|
1153
|
+
});
|
|
1154
|
+
if (decision.ok)
|
|
1155
|
+
return null;
|
|
1156
|
+
return json({ error: decision.message, reason: decision.reason }, decision.status);
|
|
1157
|
+
}
|
|
1158
|
+
async function handle(req) {
|
|
1159
|
+
const url = new URL(req.url);
|
|
1160
|
+
const path = url.pathname;
|
|
1161
|
+
const method = req.method;
|
|
1162
|
+
try {
|
|
1163
|
+
if (method === "GET" && path === "/health") {
|
|
1164
|
+
const h = await checkHealth(db);
|
|
1165
|
+
return json({ status: h.ok ? "ok" : "error", version, mode: mode2, latencyMs: h.latencyMs, ...h.error ? { error: h.error } : {} }, h.ok ? 200 : 503);
|
|
1166
|
+
}
|
|
1167
|
+
if (method === "GET" && path === "/ready") {
|
|
1168
|
+
const r = await readReadiness(db, migrationIds);
|
|
1169
|
+
return json({
|
|
1170
|
+
status: r.ok ? "ok" : "not_ready",
|
|
1171
|
+
version,
|
|
1172
|
+
mode: mode2,
|
|
1173
|
+
pendingMigrations: r.pendingMigrations,
|
|
1174
|
+
...r.error ? { error: r.error } : {}
|
|
1175
|
+
}, r.ok ? 200 : 503);
|
|
1176
|
+
}
|
|
1177
|
+
if (method === "GET" && (path === "/version" || path === "/v1/version")) {
|
|
1178
|
+
return json({ status: "ok", version, mode: mode2 });
|
|
1179
|
+
}
|
|
1180
|
+
if (method === "GET" && (path === "/openapi.json" || path === "/v1/openapi.json")) {
|
|
1181
|
+
return json(spec);
|
|
1182
|
+
}
|
|
1183
|
+
if (path === "/v1/domains") {
|
|
1184
|
+
if (method === "GET") {
|
|
1185
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1186
|
+
if (denied)
|
|
1187
|
+
return denied;
|
|
1188
|
+
const domains = await repo.listDomains({
|
|
1189
|
+
search: url.searchParams.get("search") ?? undefined,
|
|
1190
|
+
status: url.searchParams.get("status") ?? undefined,
|
|
1191
|
+
limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
|
1192
|
+
offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined
|
|
1193
|
+
});
|
|
1194
|
+
return json({ domains, count: domains.length });
|
|
1195
|
+
}
|
|
1196
|
+
if (method === "POST") {
|
|
1197
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1198
|
+
if (denied)
|
|
1199
|
+
return denied;
|
|
1200
|
+
const domain = await repo.createDomain(await readBody(req));
|
|
1201
|
+
return json(domain, 201);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (method === "GET" && path === "/v1/stats") {
|
|
1205
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1206
|
+
if (denied)
|
|
1207
|
+
return denied;
|
|
1208
|
+
return json(await repo.getStats());
|
|
1209
|
+
}
|
|
1210
|
+
let m = path.match(/^\/v1\/domains\/([^/]+)$/);
|
|
1211
|
+
if (m) {
|
|
1212
|
+
const id = decodeURIComponent(m[1]);
|
|
1213
|
+
if (method === "GET") {
|
|
1214
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1215
|
+
if (denied)
|
|
1216
|
+
return denied;
|
|
1217
|
+
const domain = await repo.getDomain(id);
|
|
1218
|
+
return domain ? json(domain) : json({ error: "domain not found" }, 404);
|
|
1219
|
+
}
|
|
1220
|
+
if (method === "PATCH" || method === "PUT") {
|
|
1221
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1222
|
+
if (denied)
|
|
1223
|
+
return denied;
|
|
1224
|
+
const domain = await repo.updateDomain(id, await readBody(req));
|
|
1225
|
+
return domain ? json(domain) : json({ error: "domain not found" }, 404);
|
|
1226
|
+
}
|
|
1227
|
+
if (method === "DELETE") {
|
|
1228
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1229
|
+
if (denied)
|
|
1230
|
+
return denied;
|
|
1231
|
+
const deleted = await repo.deleteDomain(id);
|
|
1232
|
+
return json({ id, deleted });
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/dns$/);
|
|
1236
|
+
if (m) {
|
|
1237
|
+
const id = decodeURIComponent(m[1]);
|
|
1238
|
+
if (method === "GET") {
|
|
1239
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1240
|
+
if (denied)
|
|
1241
|
+
return denied;
|
|
1242
|
+
const records = await repo.listDnsRecords(id);
|
|
1243
|
+
return json({ records, count: records.length });
|
|
1244
|
+
}
|
|
1245
|
+
if (method === "POST") {
|
|
1246
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1247
|
+
if (denied)
|
|
1248
|
+
return denied;
|
|
1249
|
+
const record = await repo.createDnsRecord(id, await readBody(req));
|
|
1250
|
+
return json(record, 201);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
m = path.match(/^\/v1\/dns\/([^/]+)$/);
|
|
1254
|
+
if (m) {
|
|
1255
|
+
const id = decodeURIComponent(m[1]);
|
|
1256
|
+
if (method === "GET") {
|
|
1257
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1258
|
+
if (denied)
|
|
1259
|
+
return denied;
|
|
1260
|
+
const record = await repo.getDnsRecord(id);
|
|
1261
|
+
return record ? json(record) : json({ error: "dns record not found" }, 404);
|
|
1262
|
+
}
|
|
1263
|
+
if (method === "DELETE") {
|
|
1264
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1265
|
+
if (denied)
|
|
1266
|
+
return denied;
|
|
1267
|
+
const deleted = await repo.deleteDnsRecord(id);
|
|
1268
|
+
return json({ id, deleted });
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/offers$/);
|
|
1272
|
+
if (m) {
|
|
1273
|
+
const id = decodeURIComponent(m[1]);
|
|
1274
|
+
if (method === "GET") {
|
|
1275
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1276
|
+
if (denied)
|
|
1277
|
+
return denied;
|
|
1278
|
+
const offers = await repo.listOffers(id);
|
|
1279
|
+
return json({ offers, count: offers.length });
|
|
1280
|
+
}
|
|
1281
|
+
if (method === "POST") {
|
|
1282
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1283
|
+
if (denied)
|
|
1284
|
+
return denied;
|
|
1285
|
+
const offer = await repo.createOffer(id, await readBody(req));
|
|
1286
|
+
return json(offer, 201);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return json({ error: "Not found" }, 404);
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
if (e instanceof HttpError)
|
|
1292
|
+
return json({ error: e.message }, e.status);
|
|
1293
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1294
|
+
return json({ error: message }, 500);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return { handle };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// src/server/index.ts
|
|
1301
|
+
var DEFAULT_PORT = 8080;
|
|
1302
|
+
var SIGNING_KEY_ENVS = [
|
|
1303
|
+
"HASNA_DOMAINS_API_SIGNING_KEY",
|
|
1304
|
+
"HASNA_API_SIGNING_KEY",
|
|
1305
|
+
"API_KEY_SIGNING_SECRET"
|
|
1306
|
+
];
|
|
1307
|
+
function normalizeEnv(env = process.env) {
|
|
1308
|
+
if (!env["HASNA_DOMAINS_DATABASE_URL"] && env["DATABASE_URL"]) {
|
|
1309
|
+
env["HASNA_DOMAINS_DATABASE_URL"] = env["DATABASE_URL"];
|
|
1310
|
+
}
|
|
1311
|
+
if (!env["HASNA_DOMAINS_STORAGE_MODE"] && env["HASNA_DOMAINS_DATABASE_URL"]) {
|
|
1312
|
+
env["HASNA_DOMAINS_STORAGE_MODE"] = "cloud";
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
function resolveSigningSecret(env = process.env) {
|
|
1316
|
+
for (const key of SIGNING_KEY_ENVS) {
|
|
1317
|
+
const v = env[key]?.trim();
|
|
1318
|
+
if (v)
|
|
1319
|
+
return v;
|
|
1320
|
+
}
|
|
1321
|
+
throw new Error(`Missing API-key signing secret. Set ${SIGNING_KEY_ENVS[0]} (or ${SIGNING_KEY_ENVS[2]}).`);
|
|
1322
|
+
}
|
|
1323
|
+
function parseArg(name, fallback) {
|
|
1324
|
+
const eq = process.argv.find((a) => a.startsWith(`${name}=`));
|
|
1325
|
+
if (eq)
|
|
1326
|
+
return eq.split("=")[1];
|
|
1327
|
+
const idx = process.argv.indexOf(name);
|
|
1328
|
+
if (idx >= 0 && process.argv[idx + 1])
|
|
1329
|
+
return process.argv[idx + 1];
|
|
1330
|
+
return fallback;
|
|
1331
|
+
}
|
|
1332
|
+
async function main() {
|
|
1333
|
+
if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
1334
|
+
console.log(getPackageVersion());
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
normalizeEnv();
|
|
1338
|
+
const version = getPackageVersion();
|
|
1339
|
+
const signingSecret = resolveSigningSecret();
|
|
1340
|
+
const { client, connectionSource } = createCloudPoolFromEnv("domains", {
|
|
1341
|
+
applicationName: "domains-serve",
|
|
1342
|
+
max: 5
|
|
1343
|
+
});
|
|
1344
|
+
const store = new ApiKeyStore(client);
|
|
1345
|
+
const app = createServeApp({
|
|
1346
|
+
db: client,
|
|
1347
|
+
signingSecret,
|
|
1348
|
+
version,
|
|
1349
|
+
mode: process.env["HASNA_APP_MODE"] ?? "self_hosted",
|
|
1350
|
+
isRevoked: store.isRevoked,
|
|
1351
|
+
audit: (e) => {
|
|
1352
|
+
if (e.outcome === "deny") {
|
|
1353
|
+
console.error(JSON.stringify({ level: "warn", event: "api_auth_deny", ...e }));
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
const port = Number(parseArg("--port", process.env["PORT"]) ?? DEFAULT_PORT) || DEFAULT_PORT;
|
|
1358
|
+
const host = parseArg("--host", process.env["HOST"]) ?? "0.0.0.0";
|
|
1359
|
+
Bun.serve({
|
|
1360
|
+
port,
|
|
1361
|
+
hostname: host,
|
|
1362
|
+
idleTimeout: 30,
|
|
1363
|
+
fetch: (req) => app.handle(req)
|
|
1364
|
+
});
|
|
1365
|
+
console.log(JSON.stringify({
|
|
1366
|
+
level: "info",
|
|
1367
|
+
event: "domains_serve_started",
|
|
1368
|
+
version,
|
|
1369
|
+
port,
|
|
1370
|
+
host,
|
|
1371
|
+
dsnSource: connectionSource
|
|
1372
|
+
}));
|
|
1373
|
+
}
|
|
1374
|
+
if (import.meta.main) {
|
|
1375
|
+
main().catch((err) => {
|
|
1376
|
+
console.error(JSON.stringify({ level: "error", event: "domains_serve_fatal", error: err instanceof Error ? err.message : String(err) }));
|
|
1377
|
+
process.exit(1);
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
export {
|
|
1381
|
+
resolveSigningSecret,
|
|
1382
|
+
normalizeEnv,
|
|
1383
|
+
SIGNING_KEY_ENVS
|
|
1384
|
+
};
|