@hasna/domains 0.0.27 → 0.0.30
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/outreach.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/serve.d.ts.map +1 -1
- package/dist/cli/commands/ssl.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 +62413 -40499
- package/dist/cli/tui/App.d.ts.map +1 -1
- package/dist/cli/tui/DomainDetail.d.ts +1 -1
- package/dist/cli/tui/DomainDetail.d.ts.map +1 -1
- package/dist/db/dns-tools.d.ts +10 -6
- package/dist/db/dns-tools.d.ts.map +1 -1
- package/dist/db/domain-owners.d.ts +6 -21
- package/dist/db/domain-owners.d.ts.map +1 -1
- package/dist/db/domain-reputation.d.ts +0 -11
- package/dist/db/domain-reputation.d.ts.map +1 -1
- package/dist/db/domain-research.d.ts +1 -1
- package/dist/db/domain-research.d.ts.map +1 -1
- package/dist/db/domains.d.ts +51 -8
- package/dist/db/domains.d.ts.map +1 -1
- package/dist/db/history.d.ts +27 -0
- package/dist/db/history.d.ts.map +1 -0
- package/dist/db/monitoring.d.ts +2 -2
- package/dist/db/monitoring.d.ts.map +1 -1
- package/dist/db/owners.d.ts +48 -0
- package/dist/db/owners.d.ts.map +1 -0
- package/dist/db/reputation.d.ts +28 -0
- package/dist/db/reputation.d.ts.map +1 -0
- package/dist/db/store.d.ts +249 -0
- package/dist/db/store.d.ts.map +1 -0
- 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.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47868 -26139
- package/dist/lib/brandsight.d.ts +3 -3
- package/dist/lib/brandsight.d.ts.map +1 -1
- package/dist/lib/cloudflare.js +3 -3
- package/dist/lib/compact-output.d.ts +31 -0
- package/dist/lib/compact-output.d.ts.map +1 -0
- package/dist/lib/godaddy.d.ts +3 -3
- package/dist/lib/godaddy.d.ts.map +1 -1
- package/dist/lib/namecheap.d.ts +5 -5
- package/dist/lib/namecheap.d.ts.map +1 -1
- package/dist/lib/registrar.d.ts +4 -4
- package/dist/lib/registrar.d.ts.map +1 -1
- package/dist/lib/registrar.js +17 -17
- package/dist/lib/route53.js +3 -3
- package/dist/lib/sedo.d.ts +1 -1
- package/dist/lib/sedo.d.ts.map +1 -1
- package/dist/mcp/http.d.ts +8 -3
- package/dist/mcp/http.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +32250 -10445
- 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 +2388 -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 +96 -0
- package/dist/server/repo.d.ts.map +1 -0
- package/package.json +13 -7
- package/dist/cli/commands/domains.d.ts +0 -3
- package/dist/cli/commands/domains.d.ts.map +0 -1
- package/dist/cli/commands/storage.d.ts +0 -3
- package/dist/cli/commands/storage.d.ts.map +0 -1
- package/dist/db/remote-storage.d.ts +0 -10
- package/dist/db/remote-storage.d.ts.map +0 -1
- package/dist/db/storage-sync.d.ts +0 -55
- package/dist/db/storage-sync.d.ts.map +0 -1
- package/dist/mcp/storage-tools.d.ts +0 -3
- package/dist/mcp/storage-tools.d.ts.map +0 -1
- package/dist/storage.d.ts +0 -5
- package/dist/storage.d.ts.map +0 -1
- package/dist/storage.js +0 -5691
|
@@ -0,0 +1,2388 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// node_modules/.pnpm/@hasna+contracts@0.5.2/node_modules/@hasna/contracts/dist/auth/index.js
|
|
5
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
6
|
+
var API_KEY_TOKEN_VERSION = 1;
|
|
7
|
+
var API_KEY_NAMESPACE = "hasna";
|
|
8
|
+
var TOKEN_PATTERN = /^hasna_([a-z][a-z0-9-]*)_([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/;
|
|
9
|
+
var DEFAULT_API_KEY_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
10
|
+
function toBuffer(secret) {
|
|
11
|
+
return typeof secret === "string" ? Buffer.from(secret, "utf8") : secret;
|
|
12
|
+
}
|
|
13
|
+
function hmac(signingSecret, message) {
|
|
14
|
+
return createHmac("sha256", toBuffer(signingSecret)).update(message, "utf8").digest();
|
|
15
|
+
}
|
|
16
|
+
function apiKeyPrefix(app) {
|
|
17
|
+
return `${API_KEY_NAMESPACE}_${app}_`;
|
|
18
|
+
}
|
|
19
|
+
function parseApiKey(token) {
|
|
20
|
+
if (typeof token !== "string")
|
|
21
|
+
return null;
|
|
22
|
+
const match = TOKEN_PATTERN.exec(token);
|
|
23
|
+
if (!match)
|
|
24
|
+
return null;
|
|
25
|
+
const [, app, body, sig] = match;
|
|
26
|
+
if (!app || !body || !sig)
|
|
27
|
+
return null;
|
|
28
|
+
let claims;
|
|
29
|
+
try {
|
|
30
|
+
claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (typeof claims !== "object" || claims === null || typeof claims.kid !== "string" || typeof claims.app !== "string" || !Array.isArray(claims.scopes)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return { app, body, sig, claims };
|
|
38
|
+
}
|
|
39
|
+
function verifyApiKeyToken(token, options) {
|
|
40
|
+
const parsed = parseApiKey(token);
|
|
41
|
+
if (!parsed) {
|
|
42
|
+
return { ok: false, reason: "malformed", message: "Token is malformed." };
|
|
43
|
+
}
|
|
44
|
+
const { app, body, sig, claims } = parsed;
|
|
45
|
+
if (claims.v !== API_KEY_TOKEN_VERSION) {
|
|
46
|
+
return { ok: false, reason: "unsupported_version", message: `Unsupported token version ${claims.v}.` };
|
|
47
|
+
}
|
|
48
|
+
if (claims.app !== app) {
|
|
49
|
+
return { ok: false, reason: "app_mismatch", message: "Token prefix app does not match claims." };
|
|
50
|
+
}
|
|
51
|
+
if (options.expectedApp !== undefined && app !== options.expectedApp) {
|
|
52
|
+
return { ok: false, reason: "app_mismatch", message: `Token is for app '${app}', expected '${options.expectedApp}'.` };
|
|
53
|
+
}
|
|
54
|
+
const expected = hmac(options.signingSecret, `${apiKeyPrefix(app)}${body}`);
|
|
55
|
+
let provided;
|
|
56
|
+
try {
|
|
57
|
+
provided = Buffer.from(sig, "base64url");
|
|
58
|
+
} catch {
|
|
59
|
+
return { ok: false, reason: "bad_signature", message: "Signature is not valid base64url." };
|
|
60
|
+
}
|
|
61
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
62
|
+
return { ok: false, reason: "bad_signature", message: "Signature verification failed." };
|
|
63
|
+
}
|
|
64
|
+
const now = Math.floor((options.nowMs ?? Date.now()) / 1000);
|
|
65
|
+
const leeway = options.leewaySeconds ?? 0;
|
|
66
|
+
if (typeof claims.iat === "number" && now + leeway < claims.iat) {
|
|
67
|
+
return { ok: false, reason: "not_yet_valid", message: "Token is not yet valid." };
|
|
68
|
+
}
|
|
69
|
+
if (claims.exp !== null && typeof claims.exp === "number" && now - leeway >= claims.exp) {
|
|
70
|
+
return { ok: false, reason: "expired", message: "Token has expired." };
|
|
71
|
+
}
|
|
72
|
+
if (options.requiredScopes && options.requiredScopes.length > 0) {
|
|
73
|
+
const granted = claims.scopes;
|
|
74
|
+
const satisfies = (required) => granted.some((g) => {
|
|
75
|
+
if (g === "*")
|
|
76
|
+
return true;
|
|
77
|
+
const gi = g.indexOf(":");
|
|
78
|
+
const ri = required.indexOf(":");
|
|
79
|
+
if (gi < 0 || ri < 0)
|
|
80
|
+
return false;
|
|
81
|
+
const gApp = g.slice(0, gi);
|
|
82
|
+
const gAction = g.slice(gi + 1);
|
|
83
|
+
const rApp = required.slice(0, ri);
|
|
84
|
+
const rAction = required.slice(ri + 1);
|
|
85
|
+
return (gApp === "*" || gApp === rApp) && (gAction === "*" || gAction === rAction);
|
|
86
|
+
});
|
|
87
|
+
for (const required of options.requiredScopes) {
|
|
88
|
+
if (!satisfies(required)) {
|
|
89
|
+
return { ok: false, reason: "insufficient_scope", message: `Missing required scope '${required}'.` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, claims, kid: claims.kid, app };
|
|
94
|
+
}
|
|
95
|
+
var DEFAULT_API_KEYS_TABLE = "api_keys";
|
|
96
|
+
function createTableSql(table) {
|
|
97
|
+
return `CREATE TABLE IF NOT EXISTS ${table} (
|
|
98
|
+
kid TEXT PRIMARY KEY,
|
|
99
|
+
app TEXT NOT NULL,
|
|
100
|
+
agent TEXT,
|
|
101
|
+
scopes JSONB NOT NULL,
|
|
102
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
103
|
+
issued_at TIMESTAMPTZ NOT NULL,
|
|
104
|
+
expires_at TIMESTAMPTZ,
|
|
105
|
+
revoked_at TIMESTAMPTZ,
|
|
106
|
+
revoked_reason TEXT,
|
|
107
|
+
last_used_at TIMESTAMPTZ,
|
|
108
|
+
created_by TEXT,
|
|
109
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
110
|
+
)`;
|
|
111
|
+
}
|
|
112
|
+
function apiKeyMigrations(table = DEFAULT_API_KEYS_TABLE) {
|
|
113
|
+
return [
|
|
114
|
+
{ id: `hasna_auth_0001_${table}`, sql: createTableSql(table) },
|
|
115
|
+
{
|
|
116
|
+
id: `hasna_auth_0002_${table}_indexes`,
|
|
117
|
+
sql: `CREATE INDEX IF NOT EXISTS ${table}_app_idx ON ${table} (app);
|
|
118
|
+
CREATE INDEX IF NOT EXISTS ${table}_token_hash_idx ON ${table} (token_hash);`
|
|
119
|
+
}
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
function toIso(value) {
|
|
123
|
+
if (value === null || value === undefined)
|
|
124
|
+
return null;
|
|
125
|
+
if (value instanceof Date)
|
|
126
|
+
return value.toISOString();
|
|
127
|
+
return new Date(String(value)).toISOString();
|
|
128
|
+
}
|
|
129
|
+
function parseScopes(value) {
|
|
130
|
+
if (Array.isArray(value))
|
|
131
|
+
return value.map((v) => String(v));
|
|
132
|
+
if (typeof value === "string") {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(value);
|
|
135
|
+
return Array.isArray(parsed) ? parsed.map((v) => String(v)) : [];
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
function rowToRecord(row) {
|
|
143
|
+
return {
|
|
144
|
+
kid: String(row.kid),
|
|
145
|
+
app: String(row.app),
|
|
146
|
+
agent: row.agent === null || row.agent === undefined ? null : String(row.agent),
|
|
147
|
+
scopes: parseScopes(row.scopes),
|
|
148
|
+
tokenHash: String(row.token_hash),
|
|
149
|
+
issuedAt: toIso(row.issued_at) ?? new Date(0).toISOString(),
|
|
150
|
+
expiresAt: toIso(row.expires_at),
|
|
151
|
+
revokedAt: toIso(row.revoked_at),
|
|
152
|
+
revokedReason: row.revoked_reason === null || row.revoked_reason === undefined ? null : String(row.revoked_reason),
|
|
153
|
+
lastUsedAt: toIso(row.last_used_at),
|
|
154
|
+
createdBy: row.created_by === null || row.created_by === undefined ? null : String(row.created_by)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
class ApiKeyStore {
|
|
159
|
+
client;
|
|
160
|
+
table;
|
|
161
|
+
constructor(client, options = {}) {
|
|
162
|
+
this.client = client;
|
|
163
|
+
this.table = options.table ?? DEFAULT_API_KEYS_TABLE;
|
|
164
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(this.table)) {
|
|
165
|
+
throw new Error(`Invalid api-keys table name '${this.table}'.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
migrations() {
|
|
169
|
+
return apiKeyMigrations(this.table);
|
|
170
|
+
}
|
|
171
|
+
async ensureSchema() {
|
|
172
|
+
for (const migration of this.migrations()) {
|
|
173
|
+
await this.client.execute(migration.sql);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async insert(input) {
|
|
177
|
+
await this.client.execute(`INSERT INTO ${this.table}
|
|
178
|
+
(kid, app, agent, scopes, token_hash, issued_at, expires_at, created_by)
|
|
179
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)`, [
|
|
180
|
+
input.kid,
|
|
181
|
+
input.app,
|
|
182
|
+
input.agent ?? null,
|
|
183
|
+
JSON.stringify(input.scopes),
|
|
184
|
+
input.tokenHash,
|
|
185
|
+
input.issuedAt.toISOString(),
|
|
186
|
+
input.expiresAt ? input.expiresAt.toISOString() : null,
|
|
187
|
+
input.createdBy ?? null
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
async insertMinted(minted, createdBy) {
|
|
191
|
+
const claims = minted.claims;
|
|
192
|
+
await this.insert({
|
|
193
|
+
kid: minted.kid,
|
|
194
|
+
app: claims.app,
|
|
195
|
+
agent: claims.agent ?? null,
|
|
196
|
+
scopes: claims.scopes,
|
|
197
|
+
tokenHash: minted.tokenHash,
|
|
198
|
+
issuedAt: new Date(claims.iat * 1000),
|
|
199
|
+
expiresAt: claims.exp === null ? null : new Date(claims.exp * 1000),
|
|
200
|
+
createdBy: createdBy ?? null
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async findByKid(kid) {
|
|
204
|
+
const row = await this.client.get(`SELECT * FROM ${this.table} WHERE kid = $1`, [kid]);
|
|
205
|
+
return row ? rowToRecord(row) : null;
|
|
206
|
+
}
|
|
207
|
+
async findByTokenHash(tokenHash) {
|
|
208
|
+
const row = await this.client.get(`SELECT * FROM ${this.table} WHERE token_hash = $1`, [tokenHash]);
|
|
209
|
+
return row ? rowToRecord(row) : null;
|
|
210
|
+
}
|
|
211
|
+
isRevoked = async (kid) => {
|
|
212
|
+
const row = await this.client.get(`SELECT revoked_at FROM ${this.table} WHERE kid = $1`, [kid]);
|
|
213
|
+
if (!row)
|
|
214
|
+
return false;
|
|
215
|
+
return row.revoked_at !== null && row.revoked_at !== undefined;
|
|
216
|
+
};
|
|
217
|
+
async status(kid, nowMs = Date.now()) {
|
|
218
|
+
const record = await this.findByKid(kid);
|
|
219
|
+
if (!record)
|
|
220
|
+
return "unknown";
|
|
221
|
+
if (record.revokedAt)
|
|
222
|
+
return "revoked";
|
|
223
|
+
if (record.expiresAt && new Date(record.expiresAt).getTime() <= nowMs)
|
|
224
|
+
return "expired";
|
|
225
|
+
return "active";
|
|
226
|
+
}
|
|
227
|
+
statusChecker() {
|
|
228
|
+
return async (kid) => {
|
|
229
|
+
const status = await this.status(kid);
|
|
230
|
+
return status !== "active";
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async revoke(kid, reason, atMs = Date.now()) {
|
|
234
|
+
const row = await this.client.get(`UPDATE ${this.table}
|
|
235
|
+
SET revoked_at = COALESCE(revoked_at, $2), revoked_reason = COALESCE(revoked_reason, $3)
|
|
236
|
+
WHERE kid = $1
|
|
237
|
+
RETURNING kid`, [kid, new Date(atMs).toISOString(), reason ?? null]);
|
|
238
|
+
return row !== null;
|
|
239
|
+
}
|
|
240
|
+
async touchLastUsed(kid, atMs = Date.now()) {
|
|
241
|
+
await this.client.execute(`UPDATE ${this.table} SET last_used_at = $2 WHERE kid = $1`, [
|
|
242
|
+
kid,
|
|
243
|
+
new Date(atMs).toISOString()
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
async list(options = {}) {
|
|
247
|
+
const clauses = [];
|
|
248
|
+
const params = [];
|
|
249
|
+
if (options.app) {
|
|
250
|
+
params.push(options.app);
|
|
251
|
+
clauses.push(`app = $${params.length}`);
|
|
252
|
+
}
|
|
253
|
+
if (!options.includeRevoked) {
|
|
254
|
+
clauses.push("revoked_at IS NULL");
|
|
255
|
+
}
|
|
256
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
257
|
+
const rows = await this.client.many(`SELECT * FROM ${this.table} ${where} ORDER BY issued_at DESC`);
|
|
258
|
+
return rows.map(rowToRecord);
|
|
259
|
+
}
|
|
260
|
+
async revokedKids() {
|
|
261
|
+
const rows = await this.client.many(`SELECT kid FROM ${this.table} WHERE revoked_at IS NOT NULL`);
|
|
262
|
+
return rows.map((row) => String(row.kid));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function readHeader(source, name) {
|
|
266
|
+
const lower = name.toLowerCase();
|
|
267
|
+
if (typeof source === "function") {
|
|
268
|
+
return source(name) ?? source(lower) ?? null;
|
|
269
|
+
}
|
|
270
|
+
if (typeof Headers !== "undefined" && source instanceof Headers) {
|
|
271
|
+
return source.get(name);
|
|
272
|
+
}
|
|
273
|
+
const record = source;
|
|
274
|
+
const value = record[name] ?? record[lower] ?? record[name.toUpperCase()];
|
|
275
|
+
if (Array.isArray(value))
|
|
276
|
+
return value[0] ?? null;
|
|
277
|
+
return value ?? null;
|
|
278
|
+
}
|
|
279
|
+
function extractToken(source, headerName = "x-api-key", scheme = "Bearer") {
|
|
280
|
+
const direct = readHeader(source, headerName);
|
|
281
|
+
if (direct && direct.trim().length > 0)
|
|
282
|
+
return direct.trim();
|
|
283
|
+
const authz = readHeader(source, "authorization");
|
|
284
|
+
if (authz) {
|
|
285
|
+
const prefix = `${scheme} `;
|
|
286
|
+
if (authz.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
287
|
+
const token = authz.slice(prefix.length).trim();
|
|
288
|
+
if (token.length > 0)
|
|
289
|
+
return token;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
function verifyApiKey(options) {
|
|
295
|
+
if (!options.app)
|
|
296
|
+
throw new Error("verifyApiKey requires an 'app' slug.");
|
|
297
|
+
if (!options.signingSecret) {
|
|
298
|
+
throw new Error("verifyApiKey requires a 'signingSecret'. Set it from HASNA_<APP>_API_SIGNING_KEY.");
|
|
299
|
+
}
|
|
300
|
+
const headerName = options.headerName ?? "x-api-key";
|
|
301
|
+
const scheme = options.scheme ?? "Bearer";
|
|
302
|
+
const clock = options.nowMs ?? (() => Date.now());
|
|
303
|
+
async function emit(event) {
|
|
304
|
+
if (!options.audit)
|
|
305
|
+
return;
|
|
306
|
+
try {
|
|
307
|
+
await options.audit(event);
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
async function authenticate(headers, context = {}) {
|
|
311
|
+
const method = context.method ?? null;
|
|
312
|
+
const path = context.path ?? null;
|
|
313
|
+
const requiredScopes = [...options.requiredScopes ?? [], ...context.requiredScopes ?? []];
|
|
314
|
+
const at = new Date(clock()).toISOString();
|
|
315
|
+
const token = extractToken(headers, headerName, scheme);
|
|
316
|
+
if (!token) {
|
|
317
|
+
const decision = {
|
|
318
|
+
ok: false,
|
|
319
|
+
status: 401,
|
|
320
|
+
reason: "missing_token",
|
|
321
|
+
message: `Missing API key. Send it as '${headerName}: <key>' or 'Authorization: ${scheme} <key>'.`
|
|
322
|
+
};
|
|
323
|
+
await emit({ outcome: "deny", app: options.app, kid: null, reason: "missing_token", scopesRequired: requiredScopes, method, path, status: 401, at });
|
|
324
|
+
return decision;
|
|
325
|
+
}
|
|
326
|
+
const verified = verifyApiKeyToken(token, {
|
|
327
|
+
signingSecret: options.signingSecret,
|
|
328
|
+
expectedApp: options.app,
|
|
329
|
+
nowMs: clock(),
|
|
330
|
+
...options.leewaySeconds !== undefined ? { leewaySeconds: options.leewaySeconds } : {},
|
|
331
|
+
requiredScopes
|
|
332
|
+
});
|
|
333
|
+
if (!verified.ok) {
|
|
334
|
+
const status = verified.reason === "insufficient_scope" ? 403 : 401;
|
|
335
|
+
await emit({ outcome: "deny", app: options.app, kid: null, reason: verified.reason, scopesRequired: requiredScopes, method, path, status, at });
|
|
336
|
+
return { ok: false, status, reason: verified.reason, message: verified.message };
|
|
337
|
+
}
|
|
338
|
+
if (options.isRevoked) {
|
|
339
|
+
const revoked = await options.isRevoked(verified.kid);
|
|
340
|
+
if (revoked) {
|
|
341
|
+
await emit({ outcome: "deny", app: options.app, kid: verified.kid, reason: "revoked", scopesRequired: requiredScopes, method, path, status: 401, at });
|
|
342
|
+
return { ok: false, status: 401, reason: "revoked", message: "API key has been revoked." };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const principal = {
|
|
346
|
+
kid: verified.kid,
|
|
347
|
+
app: verified.app,
|
|
348
|
+
scopes: verified.claims.scopes,
|
|
349
|
+
agent: verified.claims.agent ?? null,
|
|
350
|
+
claims: verified.claims
|
|
351
|
+
};
|
|
352
|
+
await emit({ outcome: "allow", app: options.app, kid: verified.kid, reason: null, scopesRequired: requiredScopes, method, path, status: 200, at });
|
|
353
|
+
return { ok: true, status: 200, principal };
|
|
354
|
+
}
|
|
355
|
+
return { authenticate, app: options.app };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/generated/storage-kit/mode.ts
|
|
359
|
+
var DEPRECATED_STORAGE_MODE_ALIASES = [
|
|
360
|
+
"remote",
|
|
361
|
+
"hybrid",
|
|
362
|
+
"self_hosted"
|
|
363
|
+
];
|
|
364
|
+
function normalizeStorageMode(value) {
|
|
365
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
366
|
+
if (normalized === "local")
|
|
367
|
+
return { mode: "local", deprecatedAlias: null };
|
|
368
|
+
if (normalized === "cloud")
|
|
369
|
+
return { mode: "cloud", deprecatedAlias: null };
|
|
370
|
+
if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
|
|
371
|
+
return { mode: "cloud", deprecatedAlias: normalized };
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
|
|
374
|
+
}
|
|
375
|
+
function envToken(name) {
|
|
376
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
377
|
+
}
|
|
378
|
+
function storageEnvKeys(name) {
|
|
379
|
+
const token = envToken(name);
|
|
380
|
+
return {
|
|
381
|
+
modeKeys: [`HASNA_${token}_STORAGE_MODE`, `${token}_STORAGE_MODE`],
|
|
382
|
+
databaseUrlKeys: [`HASNA_${token}_DATABASE_URL`, `${token}_DATABASE_URL`]
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function firstEnv(env, keys) {
|
|
386
|
+
for (const key of keys) {
|
|
387
|
+
const value = env[key]?.trim();
|
|
388
|
+
if (value)
|
|
389
|
+
return { key, value };
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
function resolveStorageMode(name, env = process.env) {
|
|
394
|
+
const { modeKeys, databaseUrlKeys } = storageEnvKeys(name);
|
|
395
|
+
const dbHit = firstEnv(env, databaseUrlKeys);
|
|
396
|
+
const databaseUrlPresent = Boolean(dbHit);
|
|
397
|
+
const databaseUrlSource = dbHit ? dbHit.key : null;
|
|
398
|
+
const modeHit = firstEnv(env, modeKeys);
|
|
399
|
+
if (!modeHit) {
|
|
400
|
+
return {
|
|
401
|
+
mode: "local",
|
|
402
|
+
source: "default",
|
|
403
|
+
deprecatedAlias: null,
|
|
404
|
+
databaseUrlPresent,
|
|
405
|
+
databaseUrlSource,
|
|
406
|
+
warning: null
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const { mode, deprecatedAlias } = normalizeStorageMode(modeHit.value);
|
|
410
|
+
const warnings = [];
|
|
411
|
+
if (deprecatedAlias) {
|
|
412
|
+
warnings.push(`Deprecated storage mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Set ${modeKeys[0]}=cloud instead.`);
|
|
413
|
+
}
|
|
414
|
+
if (mode === "cloud" && !databaseUrlPresent) {
|
|
415
|
+
warnings.push(`cloud mode needs ${databaseUrlKeys[0]} (PURE REMOTE: reads and writes go to cloud Postgres).`);
|
|
416
|
+
}
|
|
417
|
+
if (modeHit.key !== modeKeys[0]) {
|
|
418
|
+
warnings.push(`Using alias env ${modeHit.key}; the canonical key is ${modeKeys[0]}.`);
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
mode,
|
|
422
|
+
source: modeHit.key,
|
|
423
|
+
deprecatedAlias,
|
|
424
|
+
databaseUrlPresent,
|
|
425
|
+
databaseUrlSource,
|
|
426
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function resolveDatabaseUrl(name, env = process.env) {
|
|
430
|
+
const { databaseUrlKeys } = storageEnvKeys(name);
|
|
431
|
+
const hit = firstEnv(env, databaseUrlKeys);
|
|
432
|
+
return hit ? hit.value : null;
|
|
433
|
+
}
|
|
434
|
+
// src/generated/storage-kit/tls.ts
|
|
435
|
+
import { readFileSync } from "fs";
|
|
436
|
+
function sslModeFromConnectionString(connectionString) {
|
|
437
|
+
const queryStart = connectionString.indexOf("?");
|
|
438
|
+
const params = new URLSearchParams(queryStart === -1 ? "" : connectionString.slice(queryStart + 1));
|
|
439
|
+
const sslmode = params.get("sslmode")?.trim().toLowerCase();
|
|
440
|
+
if (sslmode) {
|
|
441
|
+
switch (sslmode) {
|
|
442
|
+
case "disable":
|
|
443
|
+
case "prefer":
|
|
444
|
+
case "require":
|
|
445
|
+
case "verify-ca":
|
|
446
|
+
case "verify-full":
|
|
447
|
+
return sslmode;
|
|
448
|
+
case "allow":
|
|
449
|
+
return "prefer";
|
|
450
|
+
default:
|
|
451
|
+
throw new Error(`Unknown sslmode '${sslmode}' in connection string.`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const ssl = params.get("ssl")?.trim().toLowerCase();
|
|
455
|
+
if (ssl && ["1", "true", "yes", "on", "require"].includes(ssl))
|
|
456
|
+
return "require";
|
|
457
|
+
return "disable";
|
|
458
|
+
}
|
|
459
|
+
function loadCaBundle(options) {
|
|
460
|
+
const env = options.env ?? process.env;
|
|
461
|
+
if (options.ca && options.ca.trim())
|
|
462
|
+
return options.ca;
|
|
463
|
+
const path = options.caCertPath ?? env.PGSSLROOTCERT ?? env.NODE_EXTRA_CA_CERTS;
|
|
464
|
+
if (path && path.trim())
|
|
465
|
+
return readFileSync(path.trim(), "utf8");
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
function resolveTlsConfig(connectionString, options = {}) {
|
|
469
|
+
const mode = sslModeFromConnectionString(connectionString);
|
|
470
|
+
if (mode === "disable" || mode === "prefer") {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const ca = loadCaBundle(options);
|
|
474
|
+
if (mode === "require") {
|
|
475
|
+
return ca ? { rejectUnauthorized: false, ca } : { rejectUnauthorized: false };
|
|
476
|
+
}
|
|
477
|
+
if (!ca) {
|
|
478
|
+
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`);
|
|
479
|
+
}
|
|
480
|
+
return { rejectUnauthorized: true, ca };
|
|
481
|
+
}
|
|
482
|
+
// src/generated/storage-kit/query.ts
|
|
483
|
+
function wrapExecutor(executor) {
|
|
484
|
+
return {
|
|
485
|
+
async query(sql, params) {
|
|
486
|
+
const result = await executor.query(sql, params);
|
|
487
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length };
|
|
488
|
+
},
|
|
489
|
+
async many(sql, params) {
|
|
490
|
+
const result = await executor.query(sql, params);
|
|
491
|
+
return result.rows;
|
|
492
|
+
},
|
|
493
|
+
async get(sql, params) {
|
|
494
|
+
const result = await executor.query(sql, params);
|
|
495
|
+
return result.rows[0] ?? null;
|
|
496
|
+
},
|
|
497
|
+
async one(sql, params) {
|
|
498
|
+
const result = await executor.query(sql, params);
|
|
499
|
+
if (result.rows.length !== 1) {
|
|
500
|
+
throw new Error(`Expected exactly one row, got ${result.rows.length}.`);
|
|
501
|
+
}
|
|
502
|
+
return result.rows[0];
|
|
503
|
+
},
|
|
504
|
+
async execute(sql, params) {
|
|
505
|
+
await executor.query(sql, params);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function createQueryClient(pool) {
|
|
510
|
+
const base = wrapExecutor(pool);
|
|
511
|
+
return {
|
|
512
|
+
...base,
|
|
513
|
+
pool,
|
|
514
|
+
async transaction(fn) {
|
|
515
|
+
const client = await pool.connect();
|
|
516
|
+
try {
|
|
517
|
+
await client.query("BEGIN");
|
|
518
|
+
const result = await fn(wrapExecutor(client));
|
|
519
|
+
await client.query("COMMIT");
|
|
520
|
+
return result;
|
|
521
|
+
} catch (error) {
|
|
522
|
+
try {
|
|
523
|
+
await client.query("ROLLBACK");
|
|
524
|
+
} catch {}
|
|
525
|
+
throw error;
|
|
526
|
+
} finally {
|
|
527
|
+
client.release();
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
async close() {
|
|
531
|
+
await pool.end();
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
// src/generated/storage-kit/pool.ts
|
|
536
|
+
import pg from "pg";
|
|
537
|
+
function createPgPool(options) {
|
|
538
|
+
const ssl = resolveTlsConfig(options.connectionString, {
|
|
539
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
540
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
541
|
+
...options.env !== undefined ? { env: options.env } : {}
|
|
542
|
+
});
|
|
543
|
+
const config = { connectionString: options.connectionString };
|
|
544
|
+
if (ssl !== undefined)
|
|
545
|
+
config.ssl = ssl;
|
|
546
|
+
if (options.max !== undefined)
|
|
547
|
+
config.max = options.max;
|
|
548
|
+
if (options.idleTimeoutMillis !== undefined)
|
|
549
|
+
config.idleTimeoutMillis = options.idleTimeoutMillis;
|
|
550
|
+
if (options.connectionTimeoutMillis !== undefined)
|
|
551
|
+
config.connectionTimeoutMillis = options.connectionTimeoutMillis;
|
|
552
|
+
if (options.applicationName !== undefined)
|
|
553
|
+
config.application_name = options.applicationName;
|
|
554
|
+
return new pg.Pool(config);
|
|
555
|
+
}
|
|
556
|
+
function createCloudPoolFromEnv(appName, options = {}) {
|
|
557
|
+
const env = options.env ?? process.env;
|
|
558
|
+
const resolution = resolveStorageMode(appName, env);
|
|
559
|
+
if (resolution.mode !== "cloud") {
|
|
560
|
+
throw new Error(`createCloudPoolFromEnv requires ${appName} storage mode 'cloud', got '${resolution.mode}'. ` + `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=cloud.`);
|
|
561
|
+
}
|
|
562
|
+
const connectionString = resolveDatabaseUrl(appName, env);
|
|
563
|
+
if (!connectionString) {
|
|
564
|
+
throw new Error(`cloud mode for ${appName} needs a database URL. Set ` + `HASNA_${appName.toUpperCase().replace(/-/g, "_")}_DATABASE_URL.`);
|
|
565
|
+
}
|
|
566
|
+
const pool = createPgPool({
|
|
567
|
+
connectionString,
|
|
568
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
569
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
570
|
+
env,
|
|
571
|
+
...options.max !== undefined ? { max: options.max } : {},
|
|
572
|
+
...options.idleTimeoutMillis !== undefined ? { idleTimeoutMillis: options.idleTimeoutMillis } : {},
|
|
573
|
+
...options.connectionTimeoutMillis !== undefined ? { connectionTimeoutMillis: options.connectionTimeoutMillis } : {},
|
|
574
|
+
...options.applicationName !== undefined ? { applicationName: options.applicationName } : {}
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
client: createQueryClient(pool),
|
|
578
|
+
connectionSource: resolution.databaseUrlSource ?? "unknown"
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
// src/generated/storage-kit/migrations.ts
|
|
582
|
+
import { createHash as createHash2 } from "crypto";
|
|
583
|
+
function checksumSql(sql) {
|
|
584
|
+
const normalized = sql.trim().replace(/\r\n/g, `
|
|
585
|
+
`);
|
|
586
|
+
return `sha256:${createHash2("sha256").update(normalized).digest("hex")}`;
|
|
587
|
+
}
|
|
588
|
+
function defineMigration(id, sql) {
|
|
589
|
+
return Object.freeze({ id, sql: sql.trim(), checksum: checksumSql(sql) });
|
|
590
|
+
}
|
|
591
|
+
// src/generated/storage-kit/health.ts
|
|
592
|
+
async function checkHealth(client) {
|
|
593
|
+
const start = Date.now();
|
|
594
|
+
try {
|
|
595
|
+
await client.get("SELECT 1 AS ok");
|
|
596
|
+
return { ok: true, latencyMs: Date.now() - start };
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
latencyMs: Date.now() - start,
|
|
601
|
+
error: error instanceof Error ? error.message : String(error)
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// src/lib/version.ts
|
|
606
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
607
|
+
import { dirname, resolve } from "path";
|
|
608
|
+
import { fileURLToPath } from "url";
|
|
609
|
+
var cachedVersion = null;
|
|
610
|
+
function getPackageVersion() {
|
|
611
|
+
if (cachedVersion)
|
|
612
|
+
return cachedVersion;
|
|
613
|
+
try {
|
|
614
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
615
|
+
const packageJsonPath = resolve(moduleDir, "../../package.json");
|
|
616
|
+
const pkg = JSON.parse(readFileSync2(packageJsonPath, "utf8"));
|
|
617
|
+
cachedVersion = pkg.version ?? "0.0.0";
|
|
618
|
+
} catch {
|
|
619
|
+
cachedVersion = "0.0.0";
|
|
620
|
+
}
|
|
621
|
+
return cachedVersion;
|
|
622
|
+
}
|
|
623
|
+
var USER_AGENT = `open-domains/${getPackageVersion()}`;
|
|
624
|
+
|
|
625
|
+
// src/db/domain-records.ts
|
|
626
|
+
var DOMAIN_STATUSES = [
|
|
627
|
+
"discovered",
|
|
628
|
+
"researching",
|
|
629
|
+
"offered",
|
|
630
|
+
"negotiating",
|
|
631
|
+
"purchased",
|
|
632
|
+
"active",
|
|
633
|
+
"not_available",
|
|
634
|
+
"premium_only",
|
|
635
|
+
"declined",
|
|
636
|
+
"expired",
|
|
637
|
+
"transferring",
|
|
638
|
+
"redemption"
|
|
639
|
+
];
|
|
640
|
+
var DOMAIN_OFFER_STATUSES = ["pending", "accepted", "rejected", "countered"];
|
|
641
|
+
var DOMAIN_EMAIL_TYPES = [
|
|
642
|
+
"inquiry",
|
|
643
|
+
"offer",
|
|
644
|
+
"counter_offer",
|
|
645
|
+
"confirmation",
|
|
646
|
+
"renewal_notice",
|
|
647
|
+
"transfer"
|
|
648
|
+
];
|
|
649
|
+
|
|
650
|
+
// src/db/domain-owners.ts
|
|
651
|
+
var DOMAIN_OWNER_SOURCES = ["whois", "manual", "brandsight", "import"];
|
|
652
|
+
|
|
653
|
+
// src/db/domain-history.ts
|
|
654
|
+
var HISTORY_TYPES = [
|
|
655
|
+
"whois",
|
|
656
|
+
"rdap",
|
|
657
|
+
"dns",
|
|
658
|
+
"ssl",
|
|
659
|
+
"reputation",
|
|
660
|
+
"exa_research",
|
|
661
|
+
"purchase",
|
|
662
|
+
"renewal"
|
|
663
|
+
];
|
|
664
|
+
|
|
665
|
+
// src/server/repo.ts
|
|
666
|
+
function parseJson(value, fallback) {
|
|
667
|
+
if (value === null || value === undefined)
|
|
668
|
+
return fallback;
|
|
669
|
+
try {
|
|
670
|
+
return JSON.parse(value);
|
|
671
|
+
} catch {
|
|
672
|
+
return fallback;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function rowToDomain(row) {
|
|
676
|
+
return {
|
|
677
|
+
id: row.id,
|
|
678
|
+
name: row.name,
|
|
679
|
+
registrar: row.registrar,
|
|
680
|
+
status: row.status,
|
|
681
|
+
registered_at: row.registered_at,
|
|
682
|
+
expires_at: row.expires_at,
|
|
683
|
+
auto_renew: Boolean(row.auto_renew),
|
|
684
|
+
is_premium: Boolean(row.is_premium),
|
|
685
|
+
premium_price: row.premium_price,
|
|
686
|
+
standard_price: row.standard_price,
|
|
687
|
+
purchase_price: row.purchase_price,
|
|
688
|
+
purchase_date: row.purchase_date,
|
|
689
|
+
nameservers: parseJson(row.nameservers, []),
|
|
690
|
+
whois: parseJson(row.whois, {}),
|
|
691
|
+
ssl_expires_at: row.ssl_expires_at,
|
|
692
|
+
ssl_issuer: row.ssl_issuer,
|
|
693
|
+
notes: row.notes,
|
|
694
|
+
metadata: parseJson(row.metadata, {}),
|
|
695
|
+
created_at: row.created_at,
|
|
696
|
+
updated_at: row.updated_at
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function rowToDnsRecord(row) {
|
|
700
|
+
return {
|
|
701
|
+
id: row.id,
|
|
702
|
+
domain_id: row.domain_id,
|
|
703
|
+
type: row.type,
|
|
704
|
+
name: row.name,
|
|
705
|
+
value: row.value,
|
|
706
|
+
ttl: row.ttl,
|
|
707
|
+
priority: row.priority,
|
|
708
|
+
created_at: row.created_at
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
class HttpError extends Error {
|
|
713
|
+
status;
|
|
714
|
+
constructor(status, message) {
|
|
715
|
+
super(message);
|
|
716
|
+
this.status = status;
|
|
717
|
+
this.name = "HttpError";
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
var DNS_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"];
|
|
721
|
+
|
|
722
|
+
class DomainsRepo {
|
|
723
|
+
db;
|
|
724
|
+
constructor(db) {
|
|
725
|
+
this.db = db;
|
|
726
|
+
}
|
|
727
|
+
async createDomain(input) {
|
|
728
|
+
if (!input || typeof input.name !== "string" || input.name.trim() === "") {
|
|
729
|
+
throw new HttpError(400, "domain 'name' is required");
|
|
730
|
+
}
|
|
731
|
+
const status = input.status ?? "active";
|
|
732
|
+
if (!DOMAIN_STATUSES.includes(status)) {
|
|
733
|
+
throw new HttpError(400, `invalid status '${status}'`);
|
|
734
|
+
}
|
|
735
|
+
const id = crypto.randomUUID();
|
|
736
|
+
const nowIso = new Date().toISOString();
|
|
737
|
+
try {
|
|
738
|
+
const row = await this.db.get(`INSERT INTO domains (
|
|
739
|
+
id, name, registrar, status, registered_at, expires_at, auto_renew,
|
|
740
|
+
is_premium, premium_price, standard_price, purchase_price, purchase_date,
|
|
741
|
+
nameservers, whois, ssl_expires_at, ssl_issuer, notes, metadata,
|
|
742
|
+
created_at, updated_at
|
|
743
|
+
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
|
744
|
+
RETURNING *`, [
|
|
745
|
+
id,
|
|
746
|
+
input.name.trim(),
|
|
747
|
+
input.registrar ?? null,
|
|
748
|
+
status,
|
|
749
|
+
input.registered_at ?? null,
|
|
750
|
+
input.expires_at ?? null,
|
|
751
|
+
input.auto_renew !== undefined ? input.auto_renew : true,
|
|
752
|
+
input.is_premium ?? false,
|
|
753
|
+
input.premium_price ?? null,
|
|
754
|
+
input.standard_price ?? null,
|
|
755
|
+
input.purchase_price ?? null,
|
|
756
|
+
input.purchase_date ?? null,
|
|
757
|
+
JSON.stringify(input.nameservers ?? []),
|
|
758
|
+
JSON.stringify(input.whois ?? {}),
|
|
759
|
+
input.ssl_expires_at ?? null,
|
|
760
|
+
input.ssl_issuer ?? null,
|
|
761
|
+
input.notes ?? null,
|
|
762
|
+
JSON.stringify(input.metadata ?? {}),
|
|
763
|
+
nowIso,
|
|
764
|
+
nowIso
|
|
765
|
+
]);
|
|
766
|
+
return rowToDomain(row);
|
|
767
|
+
} catch (e) {
|
|
768
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
769
|
+
if (/duplicate key|unique constraint/i.test(msg)) {
|
|
770
|
+
throw new HttpError(409, `domain '${input.name}' already exists`);
|
|
771
|
+
}
|
|
772
|
+
throw e;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async getDomain(id) {
|
|
776
|
+
const row = await this.db.get("SELECT * FROM domains WHERE id = $1", [id]);
|
|
777
|
+
return row ? rowToDomain(row) : null;
|
|
778
|
+
}
|
|
779
|
+
async getDomainByName(name) {
|
|
780
|
+
const row = await this.db.get("SELECT * FROM domains WHERE name = $1", [name]);
|
|
781
|
+
return row ? rowToDomain(row) : null;
|
|
782
|
+
}
|
|
783
|
+
async listDomains(opts) {
|
|
784
|
+
const clauses = [];
|
|
785
|
+
const params = [];
|
|
786
|
+
if (opts.search) {
|
|
787
|
+
params.push(`%${opts.search}%`);
|
|
788
|
+
clauses.push(`name ILIKE $${params.length}`);
|
|
789
|
+
}
|
|
790
|
+
if (opts.status) {
|
|
791
|
+
if (!DOMAIN_STATUSES.includes(opts.status)) {
|
|
792
|
+
throw new HttpError(400, `invalid status filter '${opts.status}'`);
|
|
793
|
+
}
|
|
794
|
+
params.push(opts.status);
|
|
795
|
+
clauses.push(`status = $${params.length}`);
|
|
796
|
+
}
|
|
797
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
798
|
+
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 1000);
|
|
799
|
+
const offset = Math.max(opts.offset ?? 0, 0);
|
|
800
|
+
params.push(limit, offset);
|
|
801
|
+
const rows = await this.db.many(`SELECT * FROM domains ${where} ORDER BY name ASC LIMIT $${params.length - 1} OFFSET $${params.length}`, params);
|
|
802
|
+
return rows.map(rowToDomain);
|
|
803
|
+
}
|
|
804
|
+
async updateDomain(id, patch) {
|
|
805
|
+
const existing = await this.getDomain(id);
|
|
806
|
+
if (!existing)
|
|
807
|
+
return null;
|
|
808
|
+
const sets = [];
|
|
809
|
+
const params = [];
|
|
810
|
+
const setCol = (col, val) => {
|
|
811
|
+
params.push(val);
|
|
812
|
+
sets.push(`${col} = $${params.length}`);
|
|
813
|
+
};
|
|
814
|
+
const p = patch;
|
|
815
|
+
if ("name" in p)
|
|
816
|
+
setCol("name", p["name"]);
|
|
817
|
+
if ("registrar" in p)
|
|
818
|
+
setCol("registrar", p["registrar"] ?? null);
|
|
819
|
+
if ("status" in p) {
|
|
820
|
+
if (!DOMAIN_STATUSES.includes(p["status"])) {
|
|
821
|
+
throw new HttpError(400, `invalid status '${String(p["status"])}'`);
|
|
822
|
+
}
|
|
823
|
+
setCol("status", p["status"]);
|
|
824
|
+
}
|
|
825
|
+
if ("registered_at" in p)
|
|
826
|
+
setCol("registered_at", p["registered_at"] ?? null);
|
|
827
|
+
if ("expires_at" in p)
|
|
828
|
+
setCol("expires_at", p["expires_at"] ?? null);
|
|
829
|
+
if ("auto_renew" in p)
|
|
830
|
+
setCol("auto_renew", Boolean(p["auto_renew"]));
|
|
831
|
+
if ("is_premium" in p)
|
|
832
|
+
setCol("is_premium", Boolean(p["is_premium"]));
|
|
833
|
+
if ("premium_price" in p)
|
|
834
|
+
setCol("premium_price", p["premium_price"] ?? null);
|
|
835
|
+
if ("standard_price" in p)
|
|
836
|
+
setCol("standard_price", p["standard_price"] ?? null);
|
|
837
|
+
if ("purchase_price" in p)
|
|
838
|
+
setCol("purchase_price", p["purchase_price"] ?? null);
|
|
839
|
+
if ("purchase_date" in p)
|
|
840
|
+
setCol("purchase_date", p["purchase_date"] ?? null);
|
|
841
|
+
if ("nameservers" in p)
|
|
842
|
+
setCol("nameservers", JSON.stringify(p["nameservers"] ?? []));
|
|
843
|
+
if ("whois" in p)
|
|
844
|
+
setCol("whois", JSON.stringify(p["whois"] ?? {}));
|
|
845
|
+
if ("ssl_expires_at" in p)
|
|
846
|
+
setCol("ssl_expires_at", p["ssl_expires_at"] ?? null);
|
|
847
|
+
if ("ssl_issuer" in p)
|
|
848
|
+
setCol("ssl_issuer", p["ssl_issuer"] ?? null);
|
|
849
|
+
if ("notes" in p)
|
|
850
|
+
setCol("notes", p["notes"] ?? null);
|
|
851
|
+
if ("metadata" in p)
|
|
852
|
+
setCol("metadata", JSON.stringify(p["metadata"] ?? {}));
|
|
853
|
+
if (sets.length === 0)
|
|
854
|
+
return existing;
|
|
855
|
+
setCol("updated_at", new Date().toISOString());
|
|
856
|
+
params.push(id);
|
|
857
|
+
const row = await this.db.get(`UPDATE domains SET ${sets.join(", ")} WHERE id = $${params.length} RETURNING *`, params);
|
|
858
|
+
return row ? rowToDomain(row) : null;
|
|
859
|
+
}
|
|
860
|
+
async deleteDomain(id) {
|
|
861
|
+
const result = await this.db.query("DELETE FROM domains WHERE id = $1", [id]);
|
|
862
|
+
return result.rowCount > 0;
|
|
863
|
+
}
|
|
864
|
+
async countDomains() {
|
|
865
|
+
const row = await this.db.get("SELECT count(*)::text AS n FROM domains");
|
|
866
|
+
return row ? parseInt(row.n, 10) : 0;
|
|
867
|
+
}
|
|
868
|
+
async getStats() {
|
|
869
|
+
const row = await this.db.get(`SELECT
|
|
870
|
+
count(*)::text AS total,
|
|
871
|
+
count(*) FILTER (WHERE status = 'active')::text AS active,
|
|
872
|
+
count(*) FILTER (WHERE status = 'expired')::text AS expired,
|
|
873
|
+
count(*) FILTER (WHERE status = 'transferring')::text AS transferring,
|
|
874
|
+
count(*) FILTER (WHERE status = 'redemption')::text AS redemption,
|
|
875
|
+
count(*) FILTER (WHERE auto_renew = true)::text AS auto_renew_enabled,
|
|
876
|
+
count(*) FILTER (
|
|
877
|
+
WHERE NULLIF(expires_at, '')::timestamptz
|
|
878
|
+
BETWEEN now() AND now() + interval '30 days'
|
|
879
|
+
)::text AS expiring_30_days,
|
|
880
|
+
count(*) FILTER (
|
|
881
|
+
WHERE NULLIF(ssl_expires_at, '')::timestamptz
|
|
882
|
+
BETWEEN now() AND now() + interval '30 days'
|
|
883
|
+
)::text AS ssl_expiring_30_days
|
|
884
|
+
FROM domains`);
|
|
885
|
+
const n = (k) => row && row[k] ? parseInt(row[k], 10) : 0;
|
|
886
|
+
return {
|
|
887
|
+
total: n("total"),
|
|
888
|
+
active: n("active"),
|
|
889
|
+
expired: n("expired"),
|
|
890
|
+
transferring: n("transferring"),
|
|
891
|
+
redemption: n("redemption"),
|
|
892
|
+
auto_renew_enabled: n("auto_renew_enabled"),
|
|
893
|
+
expiring_30_days: n("expiring_30_days"),
|
|
894
|
+
ssl_expiring_30_days: n("ssl_expiring_30_days")
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
async listDnsRecords(domainId) {
|
|
898
|
+
const rows = await this.db.many("SELECT * FROM dns_records WHERE domain_id = $1 ORDER BY type, name", [domainId]);
|
|
899
|
+
return rows.map(rowToDnsRecord);
|
|
900
|
+
}
|
|
901
|
+
async createDnsRecord(domainId, input) {
|
|
902
|
+
if (!input || typeof input.type !== "string" || !DNS_TYPES.includes(input.type)) {
|
|
903
|
+
throw new HttpError(400, `dns record 'type' must be one of ${DNS_TYPES.join(", ")}`);
|
|
904
|
+
}
|
|
905
|
+
if (typeof input.name !== "string" || typeof input.value !== "string") {
|
|
906
|
+
throw new HttpError(400, "dns record 'name' and 'value' are required");
|
|
907
|
+
}
|
|
908
|
+
const domain = await this.getDomain(domainId);
|
|
909
|
+
if (!domain)
|
|
910
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
911
|
+
const id = crypto.randomUUID();
|
|
912
|
+
const row = await this.db.get(`INSERT INTO dns_records (id, domain_id, type, name, value, ttl, priority, created_at)
|
|
913
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [
|
|
914
|
+
id,
|
|
915
|
+
domainId,
|
|
916
|
+
input.type,
|
|
917
|
+
input.name,
|
|
918
|
+
input.value,
|
|
919
|
+
input.ttl ?? 3600,
|
|
920
|
+
input.priority ?? null,
|
|
921
|
+
new Date().toISOString()
|
|
922
|
+
]);
|
|
923
|
+
return rowToDnsRecord(row);
|
|
924
|
+
}
|
|
925
|
+
async getDnsRecord(id) {
|
|
926
|
+
const row = await this.db.get("SELECT * FROM dns_records WHERE id = $1", [id]);
|
|
927
|
+
return row ? rowToDnsRecord(row) : null;
|
|
928
|
+
}
|
|
929
|
+
async deleteDnsRecord(id) {
|
|
930
|
+
const result = await this.db.query("DELETE FROM dns_records WHERE id = $1", [id]);
|
|
931
|
+
return result.rowCount > 0;
|
|
932
|
+
}
|
|
933
|
+
async listOffers(domainId) {
|
|
934
|
+
const rows = await this.db.many("SELECT * FROM domain_offers WHERE domain_id = $1 ORDER BY created_at DESC", [domainId]);
|
|
935
|
+
return rows;
|
|
936
|
+
}
|
|
937
|
+
async createOffer(domainId, input) {
|
|
938
|
+
const domain = await this.getDomain(domainId);
|
|
939
|
+
if (!domain)
|
|
940
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
941
|
+
const status = input.status ?? "pending";
|
|
942
|
+
if (!DOMAIN_OFFER_STATUSES.includes(status)) {
|
|
943
|
+
throw new HttpError(400, `invalid offer status '${status}'`);
|
|
944
|
+
}
|
|
945
|
+
const id = crypto.randomUUID();
|
|
946
|
+
const row = await this.db.get(`INSERT INTO domain_offers (id, domain_id, our_offer, their_ask, status, notes, created_at)
|
|
947
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [
|
|
948
|
+
id,
|
|
949
|
+
domainId,
|
|
950
|
+
input.our_offer ?? null,
|
|
951
|
+
input.their_ask ?? null,
|
|
952
|
+
status,
|
|
953
|
+
input.notes ?? null,
|
|
954
|
+
new Date().toISOString()
|
|
955
|
+
]);
|
|
956
|
+
return row;
|
|
957
|
+
}
|
|
958
|
+
async getOffer(id) {
|
|
959
|
+
const row = await this.db.get("SELECT * FROM domain_offers WHERE id = $1", [id]);
|
|
960
|
+
return row ? row : null;
|
|
961
|
+
}
|
|
962
|
+
async updateDnsRecord(id, patch) {
|
|
963
|
+
const existing = await this.getDnsRecord(id);
|
|
964
|
+
if (!existing)
|
|
965
|
+
return null;
|
|
966
|
+
const sets = [];
|
|
967
|
+
const params = [];
|
|
968
|
+
const setCol = (col, val) => {
|
|
969
|
+
params.push(val);
|
|
970
|
+
sets.push(`${col} = $${params.length}`);
|
|
971
|
+
};
|
|
972
|
+
if (patch.type !== undefined) {
|
|
973
|
+
if (!DNS_TYPES.includes(patch.type)) {
|
|
974
|
+
throw new HttpError(400, `dns record 'type' must be one of ${DNS_TYPES.join(", ")}`);
|
|
975
|
+
}
|
|
976
|
+
setCol("type", patch.type);
|
|
977
|
+
}
|
|
978
|
+
if (patch.name !== undefined)
|
|
979
|
+
setCol("name", patch.name);
|
|
980
|
+
if (patch.value !== undefined)
|
|
981
|
+
setCol("value", patch.value);
|
|
982
|
+
if (patch.ttl !== undefined)
|
|
983
|
+
setCol("ttl", patch.ttl);
|
|
984
|
+
if (patch.priority !== undefined)
|
|
985
|
+
setCol("priority", patch.priority ?? null);
|
|
986
|
+
if (sets.length === 0)
|
|
987
|
+
return existing;
|
|
988
|
+
params.push(id);
|
|
989
|
+
const row = await this.db.get(`UPDATE dns_records SET ${sets.join(", ")} WHERE id = $${params.length} RETURNING *`, params);
|
|
990
|
+
return row ? rowToDnsRecord(row) : null;
|
|
991
|
+
}
|
|
992
|
+
async listEmailLinks(domainId) {
|
|
993
|
+
const rows = await this.db.many("SELECT * FROM domain_emails WHERE domain_id = $1 ORDER BY created_at ASC", [domainId]);
|
|
994
|
+
return rows;
|
|
995
|
+
}
|
|
996
|
+
async getEmailLink(id) {
|
|
997
|
+
const row = await this.db.get("SELECT * FROM domain_emails WHERE id = $1", [id]);
|
|
998
|
+
return row ? row : null;
|
|
999
|
+
}
|
|
1000
|
+
async linkEmail(domainId, input) {
|
|
1001
|
+
if (!DOMAIN_EMAIL_TYPES.includes(input.type)) {
|
|
1002
|
+
throw new HttpError(400, `invalid email link type '${input.type}'`);
|
|
1003
|
+
}
|
|
1004
|
+
const domain = await this.getDomain(domainId);
|
|
1005
|
+
if (!domain)
|
|
1006
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
1007
|
+
const existing = await this.db.get("SELECT id, thread_id FROM domain_emails WHERE domain_id = $1 AND email_id = $2", [domainId, input.email_id]);
|
|
1008
|
+
if (existing) {
|
|
1009
|
+
const row2 = await this.db.get("UPDATE domain_emails SET thread_id = $1, type = $2 WHERE id = $3 RETURNING *", [input.thread_id ?? existing.thread_id ?? null, input.type, existing.id]);
|
|
1010
|
+
return row2;
|
|
1011
|
+
}
|
|
1012
|
+
const id = crypto.randomUUID();
|
|
1013
|
+
const row = await this.db.get(`INSERT INTO domain_emails (id, domain_id, email_id, thread_id, type, created_at)
|
|
1014
|
+
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [id, domainId, input.email_id, input.thread_id ?? null, input.type, new Date().toISOString()]);
|
|
1015
|
+
return row;
|
|
1016
|
+
}
|
|
1017
|
+
async listAlerts(domainId) {
|
|
1018
|
+
const rows = await this.db.many("SELECT * FROM alerts WHERE domain_id = $1 ORDER BY type, trigger_days_before", [domainId]);
|
|
1019
|
+
return rows;
|
|
1020
|
+
}
|
|
1021
|
+
async getAlert(id) {
|
|
1022
|
+
const row = await this.db.get("SELECT * FROM alerts WHERE id = $1", [id]);
|
|
1023
|
+
return row ? row : null;
|
|
1024
|
+
}
|
|
1025
|
+
async createAlert(domainId, input) {
|
|
1026
|
+
const domain = await this.getDomain(domainId);
|
|
1027
|
+
if (!domain)
|
|
1028
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
1029
|
+
if (!["expiry", "ssl_expiry", "dns_change"].includes(input.type)) {
|
|
1030
|
+
throw new HttpError(400, `invalid alert type '${input.type}'`);
|
|
1031
|
+
}
|
|
1032
|
+
const id = crypto.randomUUID();
|
|
1033
|
+
const row = await this.db.get(`INSERT INTO alerts (id, domain_id, type, trigger_days_before, created_at)
|
|
1034
|
+
VALUES ($1,$2,$3,$4,$5) RETURNING *`, [id, domainId, input.type, input.trigger_days_before ?? null, new Date().toISOString()]);
|
|
1035
|
+
return row;
|
|
1036
|
+
}
|
|
1037
|
+
async deleteAlert(id) {
|
|
1038
|
+
const result = await this.db.query("DELETE FROM alerts WHERE id = $1", [id]);
|
|
1039
|
+
return result.rowCount > 0;
|
|
1040
|
+
}
|
|
1041
|
+
ownerRow(row) {
|
|
1042
|
+
return {
|
|
1043
|
+
id: row["id"],
|
|
1044
|
+
domain_id: row["domain_id"],
|
|
1045
|
+
contact_id: row["contact_id"] ?? null,
|
|
1046
|
+
owner_name: row["owner_name"] ?? null,
|
|
1047
|
+
owner_email: row["owner_email"] ?? null,
|
|
1048
|
+
owner_phone: row["owner_phone"] ?? null,
|
|
1049
|
+
owner_organization: row["owner_organization"] ?? null,
|
|
1050
|
+
source: row["source"],
|
|
1051
|
+
verified: Boolean(row["verified"]),
|
|
1052
|
+
notes: row["notes"] ?? null,
|
|
1053
|
+
created_at: row["created_at"],
|
|
1054
|
+
updated_at: row["updated_at"]
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
async listOwnersForDomain(domainId) {
|
|
1058
|
+
const rows = await this.db.many("SELECT * FROM domain_owners WHERE domain_id = $1 ORDER BY created_at DESC", [domainId]);
|
|
1059
|
+
return rows.map((r) => this.ownerRow(r));
|
|
1060
|
+
}
|
|
1061
|
+
async listOwners(opts) {
|
|
1062
|
+
const clauses = [];
|
|
1063
|
+
const params = [];
|
|
1064
|
+
if (opts.search) {
|
|
1065
|
+
params.push(`%${opts.search}%`);
|
|
1066
|
+
const p = `$${params.length}`;
|
|
1067
|
+
clauses.push(`(owner_name ILIKE ${p} OR owner_email ILIKE ${p} OR owner_organization ILIKE ${p} OR notes ILIKE ${p})`);
|
|
1068
|
+
}
|
|
1069
|
+
if (opts.source) {
|
|
1070
|
+
params.push(opts.source);
|
|
1071
|
+
clauses.push(`source = $${params.length}`);
|
|
1072
|
+
}
|
|
1073
|
+
if (opts.verified !== undefined) {
|
|
1074
|
+
params.push(opts.verified);
|
|
1075
|
+
clauses.push(`verified = $${params.length}`);
|
|
1076
|
+
}
|
|
1077
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1078
|
+
const rows = await this.db.many(`SELECT * FROM domain_owners ${where} ORDER BY created_at DESC`, params);
|
|
1079
|
+
return rows.map((r) => this.ownerRow(r));
|
|
1080
|
+
}
|
|
1081
|
+
async getOwner(id) {
|
|
1082
|
+
const row = await this.db.get("SELECT * FROM domain_owners WHERE id = $1", [id]);
|
|
1083
|
+
return row ? this.ownerRow(row) : null;
|
|
1084
|
+
}
|
|
1085
|
+
async createOwner(domainId, input) {
|
|
1086
|
+
const domain = await this.getDomain(domainId);
|
|
1087
|
+
if (!domain)
|
|
1088
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
1089
|
+
const source = input.source ?? "manual";
|
|
1090
|
+
if (!DOMAIN_OWNER_SOURCES.includes(source))
|
|
1091
|
+
throw new HttpError(400, `invalid owner source '${source}'`);
|
|
1092
|
+
const id = crypto.randomUUID();
|
|
1093
|
+
const now = new Date().toISOString();
|
|
1094
|
+
const row = await this.db.get(`INSERT INTO domain_owners (id, domain_id, contact_id, owner_name, owner_email, owner_phone, owner_organization, source, verified, notes, created_at, updated_at)
|
|
1095
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`, [
|
|
1096
|
+
id,
|
|
1097
|
+
domainId,
|
|
1098
|
+
input.contact_id ?? null,
|
|
1099
|
+
input.owner_name ?? null,
|
|
1100
|
+
input.owner_email ?? null,
|
|
1101
|
+
input.owner_phone ?? null,
|
|
1102
|
+
input.owner_organization ?? null,
|
|
1103
|
+
source,
|
|
1104
|
+
input.verified ?? false,
|
|
1105
|
+
input.notes ?? null,
|
|
1106
|
+
now,
|
|
1107
|
+
now
|
|
1108
|
+
]);
|
|
1109
|
+
return this.ownerRow(row);
|
|
1110
|
+
}
|
|
1111
|
+
async updateOwner(id, patch) {
|
|
1112
|
+
const existing = await this.getOwner(id);
|
|
1113
|
+
if (!existing)
|
|
1114
|
+
return null;
|
|
1115
|
+
const sets = [];
|
|
1116
|
+
const params = [];
|
|
1117
|
+
const setCol = (col, val) => {
|
|
1118
|
+
params.push(val);
|
|
1119
|
+
sets.push(`${col} = $${params.length}`);
|
|
1120
|
+
};
|
|
1121
|
+
if (patch.contact_id !== undefined)
|
|
1122
|
+
setCol("contact_id", patch.contact_id ?? null);
|
|
1123
|
+
if (patch.owner_name !== undefined)
|
|
1124
|
+
setCol("owner_name", patch.owner_name ?? null);
|
|
1125
|
+
if (patch.owner_email !== undefined)
|
|
1126
|
+
setCol("owner_email", patch.owner_email ?? null);
|
|
1127
|
+
if (patch.owner_phone !== undefined)
|
|
1128
|
+
setCol("owner_phone", patch.owner_phone ?? null);
|
|
1129
|
+
if (patch.owner_organization !== undefined)
|
|
1130
|
+
setCol("owner_organization", patch.owner_organization ?? null);
|
|
1131
|
+
if (patch.source !== undefined)
|
|
1132
|
+
setCol("source", patch.source);
|
|
1133
|
+
if (patch.verified !== undefined)
|
|
1134
|
+
setCol("verified", Boolean(patch.verified));
|
|
1135
|
+
if (patch.notes !== undefined)
|
|
1136
|
+
setCol("notes", patch.notes ?? null);
|
|
1137
|
+
if (sets.length === 0)
|
|
1138
|
+
return existing;
|
|
1139
|
+
setCol("updated_at", new Date().toISOString());
|
|
1140
|
+
params.push(id);
|
|
1141
|
+
const row = await this.db.get(`UPDATE domain_owners SET ${sets.join(", ")} WHERE id = $${params.length} RETURNING *`, params);
|
|
1142
|
+
return row ? this.ownerRow(row) : null;
|
|
1143
|
+
}
|
|
1144
|
+
async deleteOwner(id) {
|
|
1145
|
+
const result = await this.db.query("DELETE FROM domain_owners WHERE id = $1", [id]);
|
|
1146
|
+
return result.rowCount > 0;
|
|
1147
|
+
}
|
|
1148
|
+
async listDomainsWithOwners() {
|
|
1149
|
+
const rows = await this.db.many(`SELECT d.name as domain_name, d.status as domain_status, d.is_premium, d.premium_price,
|
|
1150
|
+
o.owner_name, o.owner_email, o.owner_organization, o.contact_id, o.source, o.verified
|
|
1151
|
+
FROM domains d
|
|
1152
|
+
LEFT JOIN domain_owners o ON d.id = o.domain_id
|
|
1153
|
+
WHERE o.id IS NOT NULL OR d.is_premium = true
|
|
1154
|
+
OR d.status IN ('premium_only','not_available','negotiating','offered','researching')
|
|
1155
|
+
ORDER BY d.name`);
|
|
1156
|
+
return rows.map((r) => ({
|
|
1157
|
+
domain_name: r["domain_name"],
|
|
1158
|
+
domain_status: r["domain_status"],
|
|
1159
|
+
is_premium: Boolean(r["is_premium"]),
|
|
1160
|
+
premium_price: r["premium_price"] ?? null,
|
|
1161
|
+
owner_name: r["owner_name"] ?? null,
|
|
1162
|
+
owner_email: r["owner_email"] ?? null,
|
|
1163
|
+
owner_organization: r["owner_organization"] ?? null,
|
|
1164
|
+
contact_id: r["contact_id"] ?? null,
|
|
1165
|
+
source: r["source"] ?? null,
|
|
1166
|
+
verified: Boolean(r["verified"])
|
|
1167
|
+
}));
|
|
1168
|
+
}
|
|
1169
|
+
historyRow(row) {
|
|
1170
|
+
return {
|
|
1171
|
+
id: row["id"],
|
|
1172
|
+
domain_id: row["domain_id"],
|
|
1173
|
+
snapshot_type: row["snapshot_type"],
|
|
1174
|
+
raw_data: parseJson(row["raw_data"], {}),
|
|
1175
|
+
registrant_name: row["registrant_name"] ?? null,
|
|
1176
|
+
registrant_email: row["registrant_email"] ?? null,
|
|
1177
|
+
registrant_org: row["registrant_org"] ?? null,
|
|
1178
|
+
nameservers: parseJson(row["nameservers"], []),
|
|
1179
|
+
registrar: row["registrar"] ?? null,
|
|
1180
|
+
status: row["status"] ?? null,
|
|
1181
|
+
notes: row["notes"] ?? null,
|
|
1182
|
+
created_at: row["created_at"]
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
async createHistory(domainId, input) {
|
|
1186
|
+
const domain = await this.getDomain(domainId);
|
|
1187
|
+
if (!domain)
|
|
1188
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
1189
|
+
if (!HISTORY_TYPES.includes(input.snapshot_type))
|
|
1190
|
+
throw new HttpError(400, `invalid snapshot_type '${input.snapshot_type}'`);
|
|
1191
|
+
const id = crypto.randomUUID();
|
|
1192
|
+
const row = await this.db.get(`INSERT INTO domain_history (id, domain_id, snapshot_type, raw_data, registrant_name, registrant_email, registrant_org, nameservers, registrar, status, notes, created_at)
|
|
1193
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`, [
|
|
1194
|
+
id,
|
|
1195
|
+
domainId,
|
|
1196
|
+
input.snapshot_type,
|
|
1197
|
+
JSON.stringify(input.raw_data ?? {}),
|
|
1198
|
+
input.registrant_name ?? null,
|
|
1199
|
+
input.registrant_email ?? null,
|
|
1200
|
+
input.registrant_org ?? null,
|
|
1201
|
+
JSON.stringify(input.nameservers ?? []),
|
|
1202
|
+
input.registrar ?? null,
|
|
1203
|
+
input.status ?? null,
|
|
1204
|
+
input.notes ?? null,
|
|
1205
|
+
new Date().toISOString()
|
|
1206
|
+
]);
|
|
1207
|
+
return this.historyRow(row);
|
|
1208
|
+
}
|
|
1209
|
+
async getHistory(id) {
|
|
1210
|
+
const row = await this.db.get("SELECT * FROM domain_history WHERE id = $1", [id]);
|
|
1211
|
+
return row ? this.historyRow(row) : null;
|
|
1212
|
+
}
|
|
1213
|
+
async listHistory(domainId, opts) {
|
|
1214
|
+
const clauses = ["domain_id = $1"];
|
|
1215
|
+
const params = [domainId];
|
|
1216
|
+
if (opts.type) {
|
|
1217
|
+
params.push(opts.type);
|
|
1218
|
+
clauses.push(`snapshot_type = $${params.length}`);
|
|
1219
|
+
}
|
|
1220
|
+
let sql = `SELECT * FROM domain_history WHERE ${clauses.join(" AND ")} ORDER BY created_at DESC`;
|
|
1221
|
+
if (opts.limit) {
|
|
1222
|
+
params.push(Math.min(Math.max(opts.limit, 1), 1000));
|
|
1223
|
+
sql += ` LIMIT $${params.length}`;
|
|
1224
|
+
}
|
|
1225
|
+
const rows = await this.db.many(sql, params);
|
|
1226
|
+
return rows.map((r) => this.historyRow(r));
|
|
1227
|
+
}
|
|
1228
|
+
async listHistoryByDateRange(start, end, domainId) {
|
|
1229
|
+
const clauses = ["created_at BETWEEN $1 AND $2"];
|
|
1230
|
+
const params = [start, end];
|
|
1231
|
+
if (domainId) {
|
|
1232
|
+
params.push(domainId);
|
|
1233
|
+
clauses.push(`domain_id = $${params.length}`);
|
|
1234
|
+
}
|
|
1235
|
+
const rows = await this.db.many(`SELECT * FROM domain_history WHERE ${clauses.join(" AND ")} ORDER BY created_at DESC`, params);
|
|
1236
|
+
return rows.map((r) => this.historyRow(r));
|
|
1237
|
+
}
|
|
1238
|
+
async deleteHistory(id) {
|
|
1239
|
+
const result = await this.db.query("DELETE FROM domain_history WHERE id = $1", [id]);
|
|
1240
|
+
return result.rowCount > 0;
|
|
1241
|
+
}
|
|
1242
|
+
async deleteHistoryByDomain(domainId) {
|
|
1243
|
+
const result = await this.db.query("DELETE FROM domain_history WHERE domain_id = $1", [domainId]);
|
|
1244
|
+
return result.rowCount > 0;
|
|
1245
|
+
}
|
|
1246
|
+
async listHistoryChanges() {
|
|
1247
|
+
const rows = await this.db.many(`SELECT d.id as domain_id, d.name as domain_name,
|
|
1248
|
+
(SELECT snapshot_type FROM domain_history h2 WHERE h2.domain_id = d.id ORDER BY created_at DESC LIMIT 1) as latest_snapshot_type,
|
|
1249
|
+
MAX(h.created_at) as latest_snapshot_at,
|
|
1250
|
+
COUNT(h.id)::text as snapshot_count
|
|
1251
|
+
FROM domains d JOIN domain_history h ON d.id = h.domain_id
|
|
1252
|
+
GROUP BY d.id ORDER BY latest_snapshot_at DESC`);
|
|
1253
|
+
return rows.map((r) => ({
|
|
1254
|
+
domain_id: r["domain_id"],
|
|
1255
|
+
domain_name: r["domain_name"],
|
|
1256
|
+
latest_snapshot_type: r["latest_snapshot_type"],
|
|
1257
|
+
latest_snapshot_at: r["latest_snapshot_at"],
|
|
1258
|
+
snapshot_count: parseInt(r["snapshot_count"] ?? "0", 10)
|
|
1259
|
+
}));
|
|
1260
|
+
}
|
|
1261
|
+
reputationRow(row) {
|
|
1262
|
+
return {
|
|
1263
|
+
id: row["id"],
|
|
1264
|
+
domain_id: row["domain_id"],
|
|
1265
|
+
is_blacklisted: Boolean(row["is_blacklisted"]),
|
|
1266
|
+
blacklist_sources: parseJson(row["blacklist_sources"], []),
|
|
1267
|
+
threat_score: row["threat_score"] ?? null,
|
|
1268
|
+
spam_score: row["spam_score"] ?? null,
|
|
1269
|
+
malware_detected: Boolean(row["malware_detected"]),
|
|
1270
|
+
phishing_detected: Boolean(row["phishing_detected"]),
|
|
1271
|
+
reputation_sources: parseJson(row["reputation_sources"], []),
|
|
1272
|
+
last_checked_at: row["last_checked_at"] ?? null,
|
|
1273
|
+
notes: row["notes"] ?? null,
|
|
1274
|
+
created_at: row["created_at"],
|
|
1275
|
+
updated_at: row["updated_at"]
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
async getReputation(domainId) {
|
|
1279
|
+
const row = await this.db.get("SELECT * FROM domain_reputation WHERE domain_id = $1", [domainId]);
|
|
1280
|
+
return row ? this.reputationRow(row) : null;
|
|
1281
|
+
}
|
|
1282
|
+
async upsertReputation(domainId, input) {
|
|
1283
|
+
const domain = await this.getDomain(domainId);
|
|
1284
|
+
if (!domain)
|
|
1285
|
+
throw new HttpError(404, `domain '${domainId}' not found`);
|
|
1286
|
+
const existing = await this.getReputation(domainId);
|
|
1287
|
+
const now = new Date().toISOString();
|
|
1288
|
+
if (existing) {
|
|
1289
|
+
const sets = [];
|
|
1290
|
+
const params = [];
|
|
1291
|
+
const setCol = (col, val) => {
|
|
1292
|
+
params.push(val);
|
|
1293
|
+
sets.push(`${col} = $${params.length}`);
|
|
1294
|
+
};
|
|
1295
|
+
if (input.is_blacklisted !== undefined)
|
|
1296
|
+
setCol("is_blacklisted", Boolean(input.is_blacklisted));
|
|
1297
|
+
if (input.blacklist_sources !== undefined)
|
|
1298
|
+
setCol("blacklist_sources", JSON.stringify(input.blacklist_sources));
|
|
1299
|
+
if (input.threat_score !== undefined)
|
|
1300
|
+
setCol("threat_score", input.threat_score);
|
|
1301
|
+
if (input.spam_score !== undefined)
|
|
1302
|
+
setCol("spam_score", input.spam_score);
|
|
1303
|
+
if (input.malware_detected !== undefined)
|
|
1304
|
+
setCol("malware_detected", Boolean(input.malware_detected));
|
|
1305
|
+
if (input.phishing_detected !== undefined)
|
|
1306
|
+
setCol("phishing_detected", Boolean(input.phishing_detected));
|
|
1307
|
+
if (input.reputation_sources !== undefined)
|
|
1308
|
+
setCol("reputation_sources", JSON.stringify(input.reputation_sources));
|
|
1309
|
+
if (input.last_checked_at !== undefined)
|
|
1310
|
+
setCol("last_checked_at", input.last_checked_at);
|
|
1311
|
+
if (input.notes !== undefined)
|
|
1312
|
+
setCol("notes", input.notes);
|
|
1313
|
+
setCol("updated_at", now);
|
|
1314
|
+
params.push(existing.id);
|
|
1315
|
+
const row2 = await this.db.get(`UPDATE domain_reputation SET ${sets.join(", ")} WHERE id = $${params.length} RETURNING *`, params);
|
|
1316
|
+
return this.reputationRow(row2);
|
|
1317
|
+
}
|
|
1318
|
+
const id = crypto.randomUUID();
|
|
1319
|
+
const row = await this.db.get(`INSERT INTO domain_reputation (id, domain_id, is_blacklisted, blacklist_sources, threat_score, spam_score, malware_detected, phishing_detected, reputation_sources, last_checked_at, notes, created_at, updated_at)
|
|
1320
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, [
|
|
1321
|
+
id,
|
|
1322
|
+
domainId,
|
|
1323
|
+
input.is_blacklisted ?? false,
|
|
1324
|
+
JSON.stringify(input.blacklist_sources ?? []),
|
|
1325
|
+
input.threat_score ?? null,
|
|
1326
|
+
input.spam_score ?? null,
|
|
1327
|
+
input.malware_detected ?? false,
|
|
1328
|
+
input.phishing_detected ?? false,
|
|
1329
|
+
JSON.stringify(input.reputation_sources ?? []),
|
|
1330
|
+
input.last_checked_at ?? null,
|
|
1331
|
+
input.notes ?? null,
|
|
1332
|
+
now,
|
|
1333
|
+
now
|
|
1334
|
+
]);
|
|
1335
|
+
return this.reputationRow(row);
|
|
1336
|
+
}
|
|
1337
|
+
async updateReputation(id, patch) {
|
|
1338
|
+
const existing = await this.db.get("SELECT * FROM domain_reputation WHERE id = $1", [id]);
|
|
1339
|
+
if (!existing)
|
|
1340
|
+
return null;
|
|
1341
|
+
return this.upsertReputation(existing["domain_id"], patch);
|
|
1342
|
+
}
|
|
1343
|
+
async deleteReputation(id) {
|
|
1344
|
+
const result = await this.db.query("DELETE FROM domain_reputation WHERE id = $1", [id]);
|
|
1345
|
+
return result.rowCount > 0;
|
|
1346
|
+
}
|
|
1347
|
+
async listReputation(opts) {
|
|
1348
|
+
if (opts.blacklisted) {
|
|
1349
|
+
const rows2 = await this.db.many("SELECT * FROM domain_reputation WHERE is_blacklisted = true ORDER BY updated_at DESC");
|
|
1350
|
+
return rows2.map((r) => this.reputationRow(r));
|
|
1351
|
+
}
|
|
1352
|
+
const threshold = opts.threshold ?? 70;
|
|
1353
|
+
const rows = await this.db.many("SELECT * FROM domain_reputation WHERE threat_score >= $1 ORDER BY threat_score DESC", [threshold]);
|
|
1354
|
+
return rows.map((r) => this.reputationRow(r));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// src/db/pg-migrations.ts
|
|
1359
|
+
var PG_MIGRATIONS = [
|
|
1360
|
+
`CREATE TABLE IF NOT EXISTS domains (
|
|
1361
|
+
id TEXT PRIMARY KEY,
|
|
1362
|
+
name TEXT NOT NULL UNIQUE,
|
|
1363
|
+
registrar TEXT,
|
|
1364
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('discovered', 'researching', 'offered', 'negotiating', 'purchased', 'active', 'not_available', 'premium_only', 'declined', 'expired', 'transferring', 'redemption')),
|
|
1365
|
+
registered_at TEXT,
|
|
1366
|
+
expires_at TEXT,
|
|
1367
|
+
auto_renew BOOLEAN NOT NULL DEFAULT TRUE,
|
|
1368
|
+
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
|
|
1369
|
+
premium_price DOUBLE PRECISION,
|
|
1370
|
+
standard_price DOUBLE PRECISION,
|
|
1371
|
+
purchase_price DOUBLE PRECISION,
|
|
1372
|
+
purchase_date TEXT,
|
|
1373
|
+
nameservers TEXT NOT NULL DEFAULT '[]',
|
|
1374
|
+
whois TEXT NOT NULL DEFAULT '{}',
|
|
1375
|
+
ssl_expires_at TEXT,
|
|
1376
|
+
ssl_issuer TEXT,
|
|
1377
|
+
notes TEXT,
|
|
1378
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
1379
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
1380
|
+
updated_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1381
|
+
)`,
|
|
1382
|
+
`CREATE TABLE IF NOT EXISTS dns_records (
|
|
1383
|
+
id TEXT PRIMARY KEY,
|
|
1384
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
1385
|
+
type TEXT NOT NULL CHECK (type IN ('A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV')),
|
|
1386
|
+
name TEXT NOT NULL,
|
|
1387
|
+
value TEXT NOT NULL,
|
|
1388
|
+
ttl INTEGER NOT NULL DEFAULT 3600,
|
|
1389
|
+
priority INTEGER,
|
|
1390
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1391
|
+
)`,
|
|
1392
|
+
`CREATE TABLE IF NOT EXISTS alerts (
|
|
1393
|
+
id TEXT PRIMARY KEY,
|
|
1394
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
1395
|
+
type TEXT NOT NULL CHECK (type IN ('expiry', 'ssl_expiry', 'dns_change')),
|
|
1396
|
+
trigger_days_before INTEGER,
|
|
1397
|
+
sent_at TEXT,
|
|
1398
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1399
|
+
)`,
|
|
1400
|
+
`CREATE TABLE IF NOT EXISTS domain_offers (
|
|
1401
|
+
id TEXT PRIMARY KEY,
|
|
1402
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
1403
|
+
our_offer DOUBLE PRECISION,
|
|
1404
|
+
their_ask DOUBLE PRECISION,
|
|
1405
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'countered')),
|
|
1406
|
+
notes TEXT,
|
|
1407
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
1408
|
+
owner_contact_id TEXT
|
|
1409
|
+
)`,
|
|
1410
|
+
`CREATE TABLE IF NOT EXISTS domain_emails (
|
|
1411
|
+
id TEXT PRIMARY KEY,
|
|
1412
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
1413
|
+
email_id TEXT NOT NULL,
|
|
1414
|
+
thread_id TEXT,
|
|
1415
|
+
type TEXT NOT NULL CHECK (type IN ('inquiry', 'offer', 'counter_offer', 'confirmation', 'renewal_notice', 'transfer')),
|
|
1416
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
1417
|
+
UNIQUE(domain_id, email_id)
|
|
1418
|
+
)`,
|
|
1419
|
+
`CREATE TABLE IF NOT EXISTS domain_owners (
|
|
1420
|
+
id TEXT PRIMARY KEY,
|
|
1421
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
1422
|
+
contact_id TEXT,
|
|
1423
|
+
owner_name TEXT,
|
|
1424
|
+
owner_email TEXT,
|
|
1425
|
+
owner_phone TEXT,
|
|
1426
|
+
owner_organization TEXT,
|
|
1427
|
+
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('whois', 'manual', 'brandsight', 'import')),
|
|
1428
|
+
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
1429
|
+
notes TEXT,
|
|
1430
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
1431
|
+
updated_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1432
|
+
)`,
|
|
1433
|
+
`CREATE TABLE IF NOT EXISTS domain_history (
|
|
1434
|
+
id TEXT PRIMARY KEY,
|
|
1435
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
1436
|
+
snapshot_type TEXT NOT NULL CHECK (snapshot_type IN ('whois', 'rdap', 'dns', 'ssl', 'reputation', 'exa_research', 'purchase', 'renewal')),
|
|
1437
|
+
raw_data TEXT NOT NULL DEFAULT '{}',
|
|
1438
|
+
registrant_name TEXT,
|
|
1439
|
+
registrant_email TEXT,
|
|
1440
|
+
registrant_org TEXT,
|
|
1441
|
+
nameservers TEXT NOT NULL DEFAULT '[]',
|
|
1442
|
+
registrar TEXT,
|
|
1443
|
+
status TEXT,
|
|
1444
|
+
notes TEXT,
|
|
1445
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1446
|
+
)`,
|
|
1447
|
+
`CREATE TABLE IF NOT EXISTS domain_reputation (
|
|
1448
|
+
id TEXT PRIMARY KEY,
|
|
1449
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
1450
|
+
is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE,
|
|
1451
|
+
blacklist_sources TEXT NOT NULL DEFAULT '[]',
|
|
1452
|
+
threat_score INTEGER,
|
|
1453
|
+
spam_score INTEGER,
|
|
1454
|
+
malware_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
|
1455
|
+
phishing_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
|
1456
|
+
reputation_sources TEXT NOT NULL DEFAULT '[]',
|
|
1457
|
+
last_checked_at TEXT,
|
|
1458
|
+
notes TEXT,
|
|
1459
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text,
|
|
1460
|
+
updated_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1461
|
+
)`,
|
|
1462
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_name ON domains(name)`,
|
|
1463
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_registrar ON domains(registrar)`,
|
|
1464
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_status ON domains(status)`,
|
|
1465
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_expires_at ON domains(expires_at)`,
|
|
1466
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_is_premium ON domains(is_premium)`,
|
|
1467
|
+
`CREATE INDEX IF NOT EXISTS idx_dns_records_domain ON dns_records(domain_id)`,
|
|
1468
|
+
`CREATE INDEX IF NOT EXISTS idx_dns_records_type ON dns_records(type)`,
|
|
1469
|
+
`CREATE INDEX IF NOT EXISTS idx_alerts_domain ON alerts(domain_id)`,
|
|
1470
|
+
`CREATE INDEX IF NOT EXISTS idx_alerts_type ON alerts(type)`,
|
|
1471
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_offers_domain ON domain_offers(domain_id)`,
|
|
1472
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_offers_owner ON domain_offers(owner_contact_id)`,
|
|
1473
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_emails_domain ON domain_emails(domain_id)`,
|
|
1474
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_emails_unique ON domain_emails(domain_id, email_id)`,
|
|
1475
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_owners_domain ON domain_owners(domain_id)`,
|
|
1476
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_owners_contact ON domain_owners(contact_id)`,
|
|
1477
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_owners_email ON domain_owners(owner_email)`,
|
|
1478
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_domain ON domain_history(domain_id)`,
|
|
1479
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_type ON domain_history(snapshot_type)`,
|
|
1480
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_created ON domain_history(created_at)`,
|
|
1481
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_history_email ON domain_history(registrant_email)`,
|
|
1482
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_reputation_domain ON domain_reputation(domain_id)`,
|
|
1483
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_reputation_blacklisted ON domain_reputation(is_blacklisted)`
|
|
1484
|
+
];
|
|
1485
|
+
|
|
1486
|
+
// src/server/migrations.ts
|
|
1487
|
+
function buildMigrations() {
|
|
1488
|
+
const migrations2 = [];
|
|
1489
|
+
PG_MIGRATIONS.forEach((sql, i) => {
|
|
1490
|
+
const id = `domains_${String(i + 1).padStart(4, "0")}`;
|
|
1491
|
+
migrations2.push(defineMigration(id, sql));
|
|
1492
|
+
});
|
|
1493
|
+
for (const m of apiKeyMigrations("api_keys")) {
|
|
1494
|
+
migrations2.push(defineMigration(m.id, m.sql));
|
|
1495
|
+
}
|
|
1496
|
+
return migrations2;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// src/server/openapi.ts
|
|
1500
|
+
var ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
1501
|
+
function jsonResponse(schema, description) {
|
|
1502
|
+
return { description, content: { "application/json": { schema } } };
|
|
1503
|
+
}
|
|
1504
|
+
function jsonBody(schema) {
|
|
1505
|
+
return { required: true, content: { "application/json": { schema } } };
|
|
1506
|
+
}
|
|
1507
|
+
function buildOpenApiSpec(version) {
|
|
1508
|
+
const idParam = {
|
|
1509
|
+
name: "id",
|
|
1510
|
+
in: "path",
|
|
1511
|
+
required: true,
|
|
1512
|
+
schema: { type: "string" },
|
|
1513
|
+
description: "Resource identifier (UUID)."
|
|
1514
|
+
};
|
|
1515
|
+
return {
|
|
1516
|
+
openapi: "3.1.0",
|
|
1517
|
+
info: {
|
|
1518
|
+
title: "domains",
|
|
1519
|
+
version,
|
|
1520
|
+
description: "Domain portfolio, registrar, marketplace, and DNS management HTTP API (self_hosted). API-key authenticated."
|
|
1521
|
+
},
|
|
1522
|
+
security: [{ apiKey: [] }],
|
|
1523
|
+
paths: {
|
|
1524
|
+
"/health": {
|
|
1525
|
+
get: {
|
|
1526
|
+
operationId: "getHealth",
|
|
1527
|
+
summary: "Liveness probe (DB reachable).",
|
|
1528
|
+
responses: { "200": jsonResponse(ref("HealthResponse"), "Service healthy") }
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
"/ready": {
|
|
1532
|
+
get: {
|
|
1533
|
+
operationId: "getReady",
|
|
1534
|
+
summary: "Readiness probe (DB reachable and schema migrated).",
|
|
1535
|
+
responses: {
|
|
1536
|
+
"200": jsonResponse(ref("ReadyResponse"), "Service ready"),
|
|
1537
|
+
"503": jsonResponse(ref("ReadyResponse"), "Not ready")
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
},
|
|
1541
|
+
"/version": {
|
|
1542
|
+
get: {
|
|
1543
|
+
operationId: "getVersion",
|
|
1544
|
+
summary: "Service version and mode.",
|
|
1545
|
+
responses: { "200": jsonResponse(ref("VersionResponse"), "Version info") }
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
"/v1/domains": {
|
|
1549
|
+
get: {
|
|
1550
|
+
operationId: "listDomains",
|
|
1551
|
+
summary: "List domains.",
|
|
1552
|
+
parameters: [
|
|
1553
|
+
{ name: "search", in: "query", required: false, schema: { type: "string" } },
|
|
1554
|
+
{ name: "status", in: "query", required: false, schema: { type: "string" } },
|
|
1555
|
+
{ name: "limit", in: "query", required: false, schema: { type: "integer" } },
|
|
1556
|
+
{ name: "offset", in: "query", required: false, schema: { type: "integer" } }
|
|
1557
|
+
],
|
|
1558
|
+
responses: { "200": jsonResponse(ref("DomainList"), "A page of domains") }
|
|
1559
|
+
},
|
|
1560
|
+
post: {
|
|
1561
|
+
operationId: "createDomain",
|
|
1562
|
+
summary: "Create a domain.",
|
|
1563
|
+
requestBody: jsonBody(ref("CreateDomainInput")),
|
|
1564
|
+
responses: {
|
|
1565
|
+
"201": jsonResponse(ref("Domain"), "Created"),
|
|
1566
|
+
"400": jsonResponse(ref("Error"), "Invalid input"),
|
|
1567
|
+
"409": jsonResponse(ref("Error"), "Already exists")
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
},
|
|
1571
|
+
"/v1/domains/{id}": {
|
|
1572
|
+
get: {
|
|
1573
|
+
operationId: "getDomain",
|
|
1574
|
+
summary: "Get a domain by id.",
|
|
1575
|
+
parameters: [idParam],
|
|
1576
|
+
responses: {
|
|
1577
|
+
"200": jsonResponse(ref("Domain"), "The domain"),
|
|
1578
|
+
"404": jsonResponse(ref("Error"), "Not found")
|
|
1579
|
+
}
|
|
1580
|
+
},
|
|
1581
|
+
patch: {
|
|
1582
|
+
operationId: "updateDomain",
|
|
1583
|
+
summary: "Update a domain.",
|
|
1584
|
+
parameters: [idParam],
|
|
1585
|
+
requestBody: jsonBody(ref("UpdateDomainInput")),
|
|
1586
|
+
responses: {
|
|
1587
|
+
"200": jsonResponse(ref("Domain"), "Updated"),
|
|
1588
|
+
"404": jsonResponse(ref("Error"), "Not found")
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
delete: {
|
|
1592
|
+
operationId: "deleteDomain",
|
|
1593
|
+
summary: "Delete a domain.",
|
|
1594
|
+
parameters: [idParam],
|
|
1595
|
+
responses: { "200": jsonResponse(ref("DeleteResult"), "Deleted") }
|
|
1596
|
+
}
|
|
1597
|
+
},
|
|
1598
|
+
"/v1/stats": {
|
|
1599
|
+
get: {
|
|
1600
|
+
operationId: "getDomainStats",
|
|
1601
|
+
summary: "Portfolio statistics.",
|
|
1602
|
+
responses: { "200": jsonResponse(ref("DomainStats"), "Stats") }
|
|
1603
|
+
}
|
|
1604
|
+
},
|
|
1605
|
+
"/v1/domains/{id}/dns": {
|
|
1606
|
+
get: {
|
|
1607
|
+
operationId: "listDnsRecords",
|
|
1608
|
+
summary: "List DNS records for a domain.",
|
|
1609
|
+
parameters: [idParam],
|
|
1610
|
+
responses: { "200": jsonResponse(ref("DnsRecordList"), "DNS records") }
|
|
1611
|
+
},
|
|
1612
|
+
post: {
|
|
1613
|
+
operationId: "createDnsRecord",
|
|
1614
|
+
summary: "Create a DNS record for a domain.",
|
|
1615
|
+
parameters: [idParam],
|
|
1616
|
+
requestBody: jsonBody(ref("CreateDnsRecordInput")),
|
|
1617
|
+
responses: {
|
|
1618
|
+
"201": jsonResponse(ref("DnsRecord"), "Created"),
|
|
1619
|
+
"404": jsonResponse(ref("Error"), "Domain not found")
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
},
|
|
1623
|
+
"/v1/dns/{id}": {
|
|
1624
|
+
get: {
|
|
1625
|
+
operationId: "getDnsRecord",
|
|
1626
|
+
summary: "Get a DNS record by id.",
|
|
1627
|
+
parameters: [idParam],
|
|
1628
|
+
responses: {
|
|
1629
|
+
"200": jsonResponse(ref("DnsRecord"), "The record"),
|
|
1630
|
+
"404": jsonResponse(ref("Error"), "Not found")
|
|
1631
|
+
}
|
|
1632
|
+
},
|
|
1633
|
+
delete: {
|
|
1634
|
+
operationId: "deleteDnsRecord",
|
|
1635
|
+
summary: "Delete a DNS record.",
|
|
1636
|
+
parameters: [idParam],
|
|
1637
|
+
responses: { "200": jsonResponse(ref("DeleteResult"), "Deleted") }
|
|
1638
|
+
}
|
|
1639
|
+
},
|
|
1640
|
+
"/v1/domains/{id}/offers": {
|
|
1641
|
+
get: {
|
|
1642
|
+
operationId: "listOffers",
|
|
1643
|
+
summary: "List marketplace offers for a domain.",
|
|
1644
|
+
parameters: [idParam],
|
|
1645
|
+
responses: { "200": jsonResponse(ref("OfferList"), "Offers") }
|
|
1646
|
+
},
|
|
1647
|
+
post: {
|
|
1648
|
+
operationId: "createOffer",
|
|
1649
|
+
summary: "Create a marketplace offer for a domain.",
|
|
1650
|
+
parameters: [idParam],
|
|
1651
|
+
requestBody: jsonBody(ref("CreateOfferInput")),
|
|
1652
|
+
responses: {
|
|
1653
|
+
"201": jsonResponse(ref("DomainOffer"), "Created"),
|
|
1654
|
+
"404": jsonResponse(ref("Error"), "Domain not found")
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
},
|
|
1659
|
+
components: {
|
|
1660
|
+
securitySchemes: {
|
|
1661
|
+
apiKey: { type: "apiKey", in: "header", name: "x-api-key" }
|
|
1662
|
+
},
|
|
1663
|
+
schemas: {
|
|
1664
|
+
Error: {
|
|
1665
|
+
type: "object",
|
|
1666
|
+
properties: { error: { type: "string" }, reason: { type: "string" } },
|
|
1667
|
+
required: ["error"]
|
|
1668
|
+
},
|
|
1669
|
+
HealthResponse: {
|
|
1670
|
+
type: "object",
|
|
1671
|
+
properties: {
|
|
1672
|
+
status: { type: "string" },
|
|
1673
|
+
version: { type: "string" },
|
|
1674
|
+
mode: { type: "string" },
|
|
1675
|
+
latencyMs: { type: "number" }
|
|
1676
|
+
},
|
|
1677
|
+
required: ["status", "version", "mode"]
|
|
1678
|
+
},
|
|
1679
|
+
ReadyResponse: {
|
|
1680
|
+
type: "object",
|
|
1681
|
+
properties: {
|
|
1682
|
+
status: { type: "string" },
|
|
1683
|
+
version: { type: "string" },
|
|
1684
|
+
mode: { type: "string" },
|
|
1685
|
+
pendingMigrations: { type: "array", items: { type: "string" } }
|
|
1686
|
+
},
|
|
1687
|
+
required: ["status", "version", "mode"]
|
|
1688
|
+
},
|
|
1689
|
+
VersionResponse: {
|
|
1690
|
+
type: "object",
|
|
1691
|
+
properties: {
|
|
1692
|
+
status: { type: "string" },
|
|
1693
|
+
version: { type: "string" },
|
|
1694
|
+
mode: { type: "string" }
|
|
1695
|
+
},
|
|
1696
|
+
required: ["status", "version", "mode"]
|
|
1697
|
+
},
|
|
1698
|
+
DeleteResult: {
|
|
1699
|
+
type: "object",
|
|
1700
|
+
properties: { id: { type: "string" }, deleted: { type: "boolean" } },
|
|
1701
|
+
required: ["id", "deleted"]
|
|
1702
|
+
},
|
|
1703
|
+
Domain: {
|
|
1704
|
+
type: "object",
|
|
1705
|
+
properties: {
|
|
1706
|
+
id: { type: "string" },
|
|
1707
|
+
name: { type: "string" },
|
|
1708
|
+
registrar: { type: "string", nullable: true },
|
|
1709
|
+
status: { type: "string" },
|
|
1710
|
+
registered_at: { type: "string", nullable: true },
|
|
1711
|
+
expires_at: { type: "string", nullable: true },
|
|
1712
|
+
auto_renew: { type: "boolean" },
|
|
1713
|
+
is_premium: { type: "boolean" },
|
|
1714
|
+
premium_price: { type: "number", nullable: true },
|
|
1715
|
+
standard_price: { type: "number", nullable: true },
|
|
1716
|
+
purchase_price: { type: "number", nullable: true },
|
|
1717
|
+
purchase_date: { type: "string", nullable: true },
|
|
1718
|
+
nameservers: { type: "array", items: { type: "string" } },
|
|
1719
|
+
whois: { type: "object", additionalProperties: true },
|
|
1720
|
+
ssl_expires_at: { type: "string", nullable: true },
|
|
1721
|
+
ssl_issuer: { type: "string", nullable: true },
|
|
1722
|
+
notes: { type: "string", nullable: true },
|
|
1723
|
+
metadata: { type: "object", additionalProperties: true },
|
|
1724
|
+
created_at: { type: "string" },
|
|
1725
|
+
updated_at: { type: "string" }
|
|
1726
|
+
},
|
|
1727
|
+
required: ["id", "name", "status", "auto_renew", "is_premium", "created_at", "updated_at"]
|
|
1728
|
+
},
|
|
1729
|
+
DomainList: {
|
|
1730
|
+
type: "object",
|
|
1731
|
+
properties: {
|
|
1732
|
+
domains: { type: "array", items: ref("Domain") },
|
|
1733
|
+
count: { type: "integer" }
|
|
1734
|
+
},
|
|
1735
|
+
required: ["domains", "count"]
|
|
1736
|
+
},
|
|
1737
|
+
CreateDomainInput: {
|
|
1738
|
+
type: "object",
|
|
1739
|
+
properties: {
|
|
1740
|
+
name: { type: "string" },
|
|
1741
|
+
registrar: { type: "string" },
|
|
1742
|
+
status: { type: "string" },
|
|
1743
|
+
registered_at: { type: "string" },
|
|
1744
|
+
expires_at: { type: "string" },
|
|
1745
|
+
auto_renew: { type: "boolean" },
|
|
1746
|
+
is_premium: { type: "boolean" },
|
|
1747
|
+
premium_price: { type: "number" },
|
|
1748
|
+
standard_price: { type: "number" },
|
|
1749
|
+
purchase_price: { type: "number" },
|
|
1750
|
+
purchase_date: { type: "string" },
|
|
1751
|
+
nameservers: { type: "array", items: { type: "string" } },
|
|
1752
|
+
whois: { type: "object", additionalProperties: true },
|
|
1753
|
+
ssl_expires_at: { type: "string" },
|
|
1754
|
+
ssl_issuer: { type: "string" },
|
|
1755
|
+
notes: { type: "string" },
|
|
1756
|
+
metadata: { type: "object", additionalProperties: true }
|
|
1757
|
+
},
|
|
1758
|
+
required: ["name"]
|
|
1759
|
+
},
|
|
1760
|
+
UpdateDomainInput: {
|
|
1761
|
+
type: "object",
|
|
1762
|
+
properties: {
|
|
1763
|
+
name: { type: "string" },
|
|
1764
|
+
registrar: { type: "string", nullable: true },
|
|
1765
|
+
status: { type: "string" },
|
|
1766
|
+
registered_at: { type: "string", nullable: true },
|
|
1767
|
+
expires_at: { type: "string", nullable: true },
|
|
1768
|
+
auto_renew: { type: "boolean" },
|
|
1769
|
+
is_premium: { type: "boolean" },
|
|
1770
|
+
premium_price: { type: "number", nullable: true },
|
|
1771
|
+
standard_price: { type: "number", nullable: true },
|
|
1772
|
+
purchase_price: { type: "number", nullable: true },
|
|
1773
|
+
purchase_date: { type: "string", nullable: true },
|
|
1774
|
+
nameservers: { type: "array", items: { type: "string" } },
|
|
1775
|
+
whois: { type: "object", additionalProperties: true },
|
|
1776
|
+
ssl_expires_at: { type: "string", nullable: true },
|
|
1777
|
+
ssl_issuer: { type: "string", nullable: true },
|
|
1778
|
+
notes: { type: "string", nullable: true },
|
|
1779
|
+
metadata: { type: "object", additionalProperties: true }
|
|
1780
|
+
}
|
|
1781
|
+
},
|
|
1782
|
+
DnsRecord: {
|
|
1783
|
+
type: "object",
|
|
1784
|
+
properties: {
|
|
1785
|
+
id: { type: "string" },
|
|
1786
|
+
domain_id: { type: "string" },
|
|
1787
|
+
type: { type: "string" },
|
|
1788
|
+
name: { type: "string" },
|
|
1789
|
+
value: { type: "string" },
|
|
1790
|
+
ttl: { type: "integer" },
|
|
1791
|
+
priority: { type: "integer", nullable: true },
|
|
1792
|
+
created_at: { type: "string" }
|
|
1793
|
+
},
|
|
1794
|
+
required: ["id", "domain_id", "type", "name", "value", "ttl", "created_at"]
|
|
1795
|
+
},
|
|
1796
|
+
DnsRecordList: {
|
|
1797
|
+
type: "object",
|
|
1798
|
+
properties: {
|
|
1799
|
+
records: { type: "array", items: ref("DnsRecord") },
|
|
1800
|
+
count: { type: "integer" }
|
|
1801
|
+
},
|
|
1802
|
+
required: ["records", "count"]
|
|
1803
|
+
},
|
|
1804
|
+
CreateDnsRecordInput: {
|
|
1805
|
+
type: "object",
|
|
1806
|
+
properties: {
|
|
1807
|
+
type: { type: "string", enum: ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"] },
|
|
1808
|
+
name: { type: "string" },
|
|
1809
|
+
value: { type: "string" },
|
|
1810
|
+
ttl: { type: "integer" },
|
|
1811
|
+
priority: { type: "integer" }
|
|
1812
|
+
},
|
|
1813
|
+
required: ["type", "name", "value"]
|
|
1814
|
+
},
|
|
1815
|
+
DomainOffer: {
|
|
1816
|
+
type: "object",
|
|
1817
|
+
properties: {
|
|
1818
|
+
id: { type: "string" },
|
|
1819
|
+
domain_id: { type: "string" },
|
|
1820
|
+
our_offer: { type: "number", nullable: true },
|
|
1821
|
+
their_ask: { type: "number", nullable: true },
|
|
1822
|
+
status: { type: "string" },
|
|
1823
|
+
notes: { type: "string", nullable: true },
|
|
1824
|
+
created_at: { type: "string" }
|
|
1825
|
+
},
|
|
1826
|
+
required: ["id", "domain_id", "status", "created_at"]
|
|
1827
|
+
},
|
|
1828
|
+
OfferList: {
|
|
1829
|
+
type: "object",
|
|
1830
|
+
properties: {
|
|
1831
|
+
offers: { type: "array", items: ref("DomainOffer") },
|
|
1832
|
+
count: { type: "integer" }
|
|
1833
|
+
},
|
|
1834
|
+
required: ["offers", "count"]
|
|
1835
|
+
},
|
|
1836
|
+
CreateOfferInput: {
|
|
1837
|
+
type: "object",
|
|
1838
|
+
properties: {
|
|
1839
|
+
our_offer: { type: "number" },
|
|
1840
|
+
their_ask: { type: "number" },
|
|
1841
|
+
status: { type: "string" },
|
|
1842
|
+
notes: { type: "string" }
|
|
1843
|
+
}
|
|
1844
|
+
},
|
|
1845
|
+
DomainStats: {
|
|
1846
|
+
type: "object",
|
|
1847
|
+
properties: {
|
|
1848
|
+
total: { type: "integer" },
|
|
1849
|
+
active: { type: "integer" },
|
|
1850
|
+
expired: { type: "integer" },
|
|
1851
|
+
transferring: { type: "integer" },
|
|
1852
|
+
redemption: { type: "integer" },
|
|
1853
|
+
auto_renew_enabled: { type: "integer" },
|
|
1854
|
+
expiring_30_days: { type: "integer" },
|
|
1855
|
+
ssl_expiring_30_days: { type: "integer" }
|
|
1856
|
+
},
|
|
1857
|
+
required: ["total"]
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// src/server/app.ts
|
|
1865
|
+
async function readReadiness(db, knownIds) {
|
|
1866
|
+
const start = Date.now();
|
|
1867
|
+
try {
|
|
1868
|
+
const rows = await db.many("SELECT id FROM schema_migrations");
|
|
1869
|
+
const applied = new Set(rows.map((r) => r.id));
|
|
1870
|
+
const pending = knownIds.filter((id) => !applied.has(id));
|
|
1871
|
+
return { ok: pending.length === 0, pendingMigrations: pending, latencyMs: Date.now() - start };
|
|
1872
|
+
} catch (e) {
|
|
1873
|
+
return {
|
|
1874
|
+
ok: false,
|
|
1875
|
+
pendingMigrations: knownIds,
|
|
1876
|
+
latencyMs: Date.now() - start,
|
|
1877
|
+
error: e instanceof Error ? e.message : String(e)
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
var SECURITY_HEADERS = {
|
|
1882
|
+
"X-Content-Type-Options": "nosniff",
|
|
1883
|
+
"X-Frame-Options": "DENY",
|
|
1884
|
+
"Referrer-Policy": "strict-origin-when-cross-origin"
|
|
1885
|
+
};
|
|
1886
|
+
function json(data, status = 200) {
|
|
1887
|
+
return new Response(JSON.stringify(data), {
|
|
1888
|
+
status,
|
|
1889
|
+
headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
function createServeApp(options) {
|
|
1893
|
+
const { db, version } = options;
|
|
1894
|
+
const mode2 = options.mode ?? "self_hosted";
|
|
1895
|
+
const repo = new DomainsRepo(db);
|
|
1896
|
+
const migrationIds = buildMigrations().map((m) => m.id);
|
|
1897
|
+
const spec = buildOpenApiSpec(version);
|
|
1898
|
+
const verifier = verifyApiKey({
|
|
1899
|
+
app: "domains",
|
|
1900
|
+
signingSecret: options.signingSecret,
|
|
1901
|
+
...options.isRevoked ? { isRevoked: options.isRevoked } : {},
|
|
1902
|
+
...options.audit ? { audit: options.audit } : {}
|
|
1903
|
+
});
|
|
1904
|
+
async function readBody(req) {
|
|
1905
|
+
const text = await req.text();
|
|
1906
|
+
if (!text)
|
|
1907
|
+
return {};
|
|
1908
|
+
try {
|
|
1909
|
+
return JSON.parse(text);
|
|
1910
|
+
} catch {
|
|
1911
|
+
throw new HttpError(400, "invalid JSON body");
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
async function auth(req, path, scopes) {
|
|
1915
|
+
const decision = await verifier.authenticate(req.headers, {
|
|
1916
|
+
method: req.method,
|
|
1917
|
+
path,
|
|
1918
|
+
requiredScopes: scopes
|
|
1919
|
+
});
|
|
1920
|
+
if (decision.ok)
|
|
1921
|
+
return null;
|
|
1922
|
+
return json({ error: decision.message, reason: decision.reason }, decision.status);
|
|
1923
|
+
}
|
|
1924
|
+
async function handle(req) {
|
|
1925
|
+
const url = new URL(req.url);
|
|
1926
|
+
const path = url.pathname;
|
|
1927
|
+
const method = req.method;
|
|
1928
|
+
try {
|
|
1929
|
+
if (method === "GET" && path === "/health") {
|
|
1930
|
+
const h = await checkHealth(db);
|
|
1931
|
+
return json({ status: h.ok ? "ok" : "error", version, mode: mode2, latencyMs: h.latencyMs, ...h.error ? { error: h.error } : {} }, h.ok ? 200 : 503);
|
|
1932
|
+
}
|
|
1933
|
+
if (method === "GET" && path === "/ready") {
|
|
1934
|
+
const r = await readReadiness(db, migrationIds);
|
|
1935
|
+
return json({
|
|
1936
|
+
status: r.ok ? "ok" : "not_ready",
|
|
1937
|
+
version,
|
|
1938
|
+
mode: mode2,
|
|
1939
|
+
pendingMigrations: r.pendingMigrations,
|
|
1940
|
+
...r.error ? { error: r.error } : {}
|
|
1941
|
+
}, r.ok ? 200 : 503);
|
|
1942
|
+
}
|
|
1943
|
+
if (method === "GET" && (path === "/version" || path === "/v1/version")) {
|
|
1944
|
+
return json({ status: "ok", version, mode: mode2 });
|
|
1945
|
+
}
|
|
1946
|
+
if (method === "GET" && (path === "/openapi.json" || path === "/v1/openapi.json")) {
|
|
1947
|
+
return json(spec);
|
|
1948
|
+
}
|
|
1949
|
+
if (path === "/v1/domains") {
|
|
1950
|
+
if (method === "GET") {
|
|
1951
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1952
|
+
if (denied)
|
|
1953
|
+
return denied;
|
|
1954
|
+
const domains = await repo.listDomains({
|
|
1955
|
+
search: url.searchParams.get("search") ?? undefined,
|
|
1956
|
+
status: url.searchParams.get("status") ?? undefined,
|
|
1957
|
+
limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
|
1958
|
+
offset: url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : undefined
|
|
1959
|
+
});
|
|
1960
|
+
return json({ domains, count: domains.length });
|
|
1961
|
+
}
|
|
1962
|
+
if (method === "POST") {
|
|
1963
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1964
|
+
if (denied)
|
|
1965
|
+
return denied;
|
|
1966
|
+
const domain = await repo.createDomain(await readBody(req));
|
|
1967
|
+
return json(domain, 201);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if (method === "GET" && path === "/v1/stats") {
|
|
1971
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1972
|
+
if (denied)
|
|
1973
|
+
return denied;
|
|
1974
|
+
return json(await repo.getStats());
|
|
1975
|
+
}
|
|
1976
|
+
let m = path.match(/^\/v1\/domains\/([^/]+)$/);
|
|
1977
|
+
if (m) {
|
|
1978
|
+
const id = decodeURIComponent(m[1]);
|
|
1979
|
+
if (method === "GET") {
|
|
1980
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
1981
|
+
if (denied)
|
|
1982
|
+
return denied;
|
|
1983
|
+
const domain = await repo.getDomain(id);
|
|
1984
|
+
return domain ? json(domain) : json({ error: "domain not found" }, 404);
|
|
1985
|
+
}
|
|
1986
|
+
if (method === "PATCH" || method === "PUT") {
|
|
1987
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1988
|
+
if (denied)
|
|
1989
|
+
return denied;
|
|
1990
|
+
const domain = await repo.updateDomain(id, await readBody(req));
|
|
1991
|
+
return domain ? json(domain) : json({ error: "domain not found" }, 404);
|
|
1992
|
+
}
|
|
1993
|
+
if (method === "DELETE") {
|
|
1994
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
1995
|
+
if (denied)
|
|
1996
|
+
return denied;
|
|
1997
|
+
const deleted = await repo.deleteDomain(id);
|
|
1998
|
+
return json({ id, deleted });
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/dns$/);
|
|
2002
|
+
if (m) {
|
|
2003
|
+
const id = decodeURIComponent(m[1]);
|
|
2004
|
+
if (method === "GET") {
|
|
2005
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2006
|
+
if (denied)
|
|
2007
|
+
return denied;
|
|
2008
|
+
const records = await repo.listDnsRecords(id);
|
|
2009
|
+
return json({ records, count: records.length });
|
|
2010
|
+
}
|
|
2011
|
+
if (method === "POST") {
|
|
2012
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2013
|
+
if (denied)
|
|
2014
|
+
return denied;
|
|
2015
|
+
const record = await repo.createDnsRecord(id, await readBody(req));
|
|
2016
|
+
return json(record, 201);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
m = path.match(/^\/v1\/dns\/([^/]+)$/);
|
|
2020
|
+
if (m) {
|
|
2021
|
+
const id = decodeURIComponent(m[1]);
|
|
2022
|
+
if (method === "GET") {
|
|
2023
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2024
|
+
if (denied)
|
|
2025
|
+
return denied;
|
|
2026
|
+
const record = await repo.getDnsRecord(id);
|
|
2027
|
+
return record ? json(record) : json({ error: "dns record not found" }, 404);
|
|
2028
|
+
}
|
|
2029
|
+
if (method === "PATCH" || method === "PUT") {
|
|
2030
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2031
|
+
if (denied)
|
|
2032
|
+
return denied;
|
|
2033
|
+
const record = await repo.updateDnsRecord(id, await readBody(req));
|
|
2034
|
+
return record ? json(record) : json({ error: "dns record not found" }, 404);
|
|
2035
|
+
}
|
|
2036
|
+
if (method === "DELETE") {
|
|
2037
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2038
|
+
if (denied)
|
|
2039
|
+
return denied;
|
|
2040
|
+
const deleted = await repo.deleteDnsRecord(id);
|
|
2041
|
+
return json({ id, deleted });
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/offers$/);
|
|
2045
|
+
if (m) {
|
|
2046
|
+
const id = decodeURIComponent(m[1]);
|
|
2047
|
+
if (method === "GET") {
|
|
2048
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2049
|
+
if (denied)
|
|
2050
|
+
return denied;
|
|
2051
|
+
const offers = await repo.listOffers(id);
|
|
2052
|
+
return json({ offers, count: offers.length });
|
|
2053
|
+
}
|
|
2054
|
+
if (method === "POST") {
|
|
2055
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2056
|
+
if (denied)
|
|
2057
|
+
return denied;
|
|
2058
|
+
const offer = await repo.createOffer(id, await readBody(req));
|
|
2059
|
+
return json(offer, 201);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/alerts$/);
|
|
2063
|
+
if (m) {
|
|
2064
|
+
const id = decodeURIComponent(m[1]);
|
|
2065
|
+
if (method === "GET") {
|
|
2066
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2067
|
+
if (denied)
|
|
2068
|
+
return denied;
|
|
2069
|
+
const alerts = await repo.listAlerts(id);
|
|
2070
|
+
return json({ alerts, count: alerts.length });
|
|
2071
|
+
}
|
|
2072
|
+
if (method === "POST") {
|
|
2073
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2074
|
+
if (denied)
|
|
2075
|
+
return denied;
|
|
2076
|
+
return json(await repo.createAlert(id, await readBody(req)), 201);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
m = path.match(/^\/v1\/alerts\/([^/]+)$/);
|
|
2080
|
+
if (m) {
|
|
2081
|
+
const id = decodeURIComponent(m[1]);
|
|
2082
|
+
if (method === "GET") {
|
|
2083
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2084
|
+
if (denied)
|
|
2085
|
+
return denied;
|
|
2086
|
+
const alert = await repo.getAlert(id);
|
|
2087
|
+
return alert ? json(alert) : json({ error: "alert not found" }, 404);
|
|
2088
|
+
}
|
|
2089
|
+
if (method === "DELETE") {
|
|
2090
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2091
|
+
if (denied)
|
|
2092
|
+
return denied;
|
|
2093
|
+
return json({ id, deleted: await repo.deleteAlert(id) });
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/emails$/);
|
|
2097
|
+
if (m) {
|
|
2098
|
+
const id = decodeURIComponent(m[1]);
|
|
2099
|
+
if (method === "GET") {
|
|
2100
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2101
|
+
if (denied)
|
|
2102
|
+
return denied;
|
|
2103
|
+
const emails = await repo.listEmailLinks(id);
|
|
2104
|
+
return json({ emails, count: emails.length });
|
|
2105
|
+
}
|
|
2106
|
+
if (method === "POST") {
|
|
2107
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2108
|
+
if (denied)
|
|
2109
|
+
return denied;
|
|
2110
|
+
return json(await repo.linkEmail(id, await readBody(req)), 201);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
m = path.match(/^\/v1\/emails\/([^/]+)$/);
|
|
2114
|
+
if (m && method === "GET") {
|
|
2115
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2116
|
+
if (denied)
|
|
2117
|
+
return denied;
|
|
2118
|
+
const link = await repo.getEmailLink(decodeURIComponent(m[1]));
|
|
2119
|
+
return link ? json(link) : json({ error: "email link not found" }, 404);
|
|
2120
|
+
}
|
|
2121
|
+
m = path.match(/^\/v1\/offers\/([^/]+)$/);
|
|
2122
|
+
if (m && method === "GET") {
|
|
2123
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2124
|
+
if (denied)
|
|
2125
|
+
return denied;
|
|
2126
|
+
const offer = await repo.getOffer(decodeURIComponent(m[1]));
|
|
2127
|
+
return offer ? json(offer) : json({ error: "offer not found" }, 404);
|
|
2128
|
+
}
|
|
2129
|
+
if (path === "/v1/owners-portfolio" && method === "GET") {
|
|
2130
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2131
|
+
if (denied)
|
|
2132
|
+
return denied;
|
|
2133
|
+
const domains = await repo.listDomainsWithOwners();
|
|
2134
|
+
return json({ domains, count: domains.length });
|
|
2135
|
+
}
|
|
2136
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/owners$/);
|
|
2137
|
+
if (m) {
|
|
2138
|
+
const id = decodeURIComponent(m[1]);
|
|
2139
|
+
if (method === "GET") {
|
|
2140
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2141
|
+
if (denied)
|
|
2142
|
+
return denied;
|
|
2143
|
+
const owners = await repo.listOwnersForDomain(id);
|
|
2144
|
+
return json({ owners, count: owners.length });
|
|
2145
|
+
}
|
|
2146
|
+
if (method === "POST") {
|
|
2147
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2148
|
+
if (denied)
|
|
2149
|
+
return denied;
|
|
2150
|
+
return json(await repo.createOwner(id, await readBody(req)), 201);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
if (path === "/v1/owners" && method === "GET") {
|
|
2154
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2155
|
+
if (denied)
|
|
2156
|
+
return denied;
|
|
2157
|
+
const owners = await repo.listOwners({
|
|
2158
|
+
search: url.searchParams.get("search") ?? undefined,
|
|
2159
|
+
source: url.searchParams.get("source") ?? undefined,
|
|
2160
|
+
verified: url.searchParams.has("verified") ? url.searchParams.get("verified") === "true" : undefined
|
|
2161
|
+
});
|
|
2162
|
+
return json({ owners, count: owners.length });
|
|
2163
|
+
}
|
|
2164
|
+
m = path.match(/^\/v1\/owners\/([^/]+)$/);
|
|
2165
|
+
if (m) {
|
|
2166
|
+
const id = decodeURIComponent(m[1]);
|
|
2167
|
+
if (method === "GET") {
|
|
2168
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2169
|
+
if (denied)
|
|
2170
|
+
return denied;
|
|
2171
|
+
const owner = await repo.getOwner(id);
|
|
2172
|
+
return owner ? json(owner) : json({ error: "owner not found" }, 404);
|
|
2173
|
+
}
|
|
2174
|
+
if (method === "PATCH" || method === "PUT") {
|
|
2175
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2176
|
+
if (denied)
|
|
2177
|
+
return denied;
|
|
2178
|
+
const owner = await repo.updateOwner(id, await readBody(req));
|
|
2179
|
+
return owner ? json(owner) : json({ error: "owner not found" }, 404);
|
|
2180
|
+
}
|
|
2181
|
+
if (method === "DELETE") {
|
|
2182
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2183
|
+
if (denied)
|
|
2184
|
+
return denied;
|
|
2185
|
+
return json({ id, deleted: await repo.deleteOwner(id) });
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
if (path === "/v1/history-changes" && method === "GET") {
|
|
2189
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2190
|
+
if (denied)
|
|
2191
|
+
return denied;
|
|
2192
|
+
const domains = await repo.listHistoryChanges();
|
|
2193
|
+
return json({ domains, count: domains.length });
|
|
2194
|
+
}
|
|
2195
|
+
if (path === "/v1/history" && method === "GET") {
|
|
2196
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2197
|
+
if (denied)
|
|
2198
|
+
return denied;
|
|
2199
|
+
const start = url.searchParams.get("start");
|
|
2200
|
+
const end = url.searchParams.get("end");
|
|
2201
|
+
if (!start || !end)
|
|
2202
|
+
return json({ error: "start and end are required" }, 400);
|
|
2203
|
+
const hist = await repo.listHistoryByDateRange(start, end, url.searchParams.get("domain") ?? undefined);
|
|
2204
|
+
return json({ history: hist, count: hist.length });
|
|
2205
|
+
}
|
|
2206
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/history$/);
|
|
2207
|
+
if (m) {
|
|
2208
|
+
const id = decodeURIComponent(m[1]);
|
|
2209
|
+
if (method === "GET") {
|
|
2210
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2211
|
+
if (denied)
|
|
2212
|
+
return denied;
|
|
2213
|
+
const hist = await repo.listHistory(id, {
|
|
2214
|
+
type: url.searchParams.get("type") ?? undefined,
|
|
2215
|
+
limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined
|
|
2216
|
+
});
|
|
2217
|
+
return json({ history: hist, count: hist.length });
|
|
2218
|
+
}
|
|
2219
|
+
if (method === "POST") {
|
|
2220
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2221
|
+
if (denied)
|
|
2222
|
+
return denied;
|
|
2223
|
+
return json(await repo.createHistory(id, await readBody(req)), 201);
|
|
2224
|
+
}
|
|
2225
|
+
if (method === "DELETE") {
|
|
2226
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2227
|
+
if (denied)
|
|
2228
|
+
return denied;
|
|
2229
|
+
return json({ id, deleted: await repo.deleteHistoryByDomain(id) });
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
m = path.match(/^\/v1\/history\/([^/]+)$/);
|
|
2233
|
+
if (m) {
|
|
2234
|
+
const id = decodeURIComponent(m[1]);
|
|
2235
|
+
if (method === "GET") {
|
|
2236
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2237
|
+
if (denied)
|
|
2238
|
+
return denied;
|
|
2239
|
+
const entry = await repo.getHistory(id);
|
|
2240
|
+
return entry ? json(entry) : json({ error: "history entry not found" }, 404);
|
|
2241
|
+
}
|
|
2242
|
+
if (method === "DELETE") {
|
|
2243
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2244
|
+
if (denied)
|
|
2245
|
+
return denied;
|
|
2246
|
+
return json({ id, deleted: await repo.deleteHistory(id) });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
if (path === "/v1/reputation" && method === "GET") {
|
|
2250
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2251
|
+
if (denied)
|
|
2252
|
+
return denied;
|
|
2253
|
+
const reputation = await repo.listReputation({
|
|
2254
|
+
blacklisted: url.searchParams.get("blacklisted") === "true",
|
|
2255
|
+
threshold: url.searchParams.get("threshold") ? Number(url.searchParams.get("threshold")) : undefined
|
|
2256
|
+
});
|
|
2257
|
+
return json({ reputation, count: reputation.length });
|
|
2258
|
+
}
|
|
2259
|
+
m = path.match(/^\/v1\/domains\/([^/]+)\/reputation$/);
|
|
2260
|
+
if (m) {
|
|
2261
|
+
const id = decodeURIComponent(m[1]);
|
|
2262
|
+
if (method === "GET") {
|
|
2263
|
+
const denied = await auth(req, path, ["domains:read"]);
|
|
2264
|
+
if (denied)
|
|
2265
|
+
return denied;
|
|
2266
|
+
const rep = await repo.getReputation(id);
|
|
2267
|
+
return rep ? json(rep) : json({ error: "reputation not found" }, 404);
|
|
2268
|
+
}
|
|
2269
|
+
if (method === "PUT" || method === "PATCH") {
|
|
2270
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2271
|
+
if (denied)
|
|
2272
|
+
return denied;
|
|
2273
|
+
return json(await repo.upsertReputation(id, await readBody(req)));
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
m = path.match(/^\/v1\/reputation\/([^/]+)$/);
|
|
2277
|
+
if (m) {
|
|
2278
|
+
const id = decodeURIComponent(m[1]);
|
|
2279
|
+
if (method === "PATCH" || method === "PUT") {
|
|
2280
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2281
|
+
if (denied)
|
|
2282
|
+
return denied;
|
|
2283
|
+
const rep = await repo.updateReputation(id, await readBody(req));
|
|
2284
|
+
return rep ? json(rep) : json({ error: "reputation not found" }, 404);
|
|
2285
|
+
}
|
|
2286
|
+
if (method === "DELETE") {
|
|
2287
|
+
const denied = await auth(req, path, ["domains:write"]);
|
|
2288
|
+
if (denied)
|
|
2289
|
+
return denied;
|
|
2290
|
+
return json({ id, deleted: await repo.deleteReputation(id) });
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
return json({ error: "Not found" }, 404);
|
|
2294
|
+
} catch (e) {
|
|
2295
|
+
if (e instanceof HttpError)
|
|
2296
|
+
return json({ error: e.message }, e.status);
|
|
2297
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
2298
|
+
return json({ error: message }, 500);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
return { handle };
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// src/server/index.ts
|
|
2305
|
+
var DEFAULT_PORT = 8080;
|
|
2306
|
+
var SIGNING_KEY_ENVS = [
|
|
2307
|
+
"HASNA_DOMAINS_API_SIGNING_KEY",
|
|
2308
|
+
"HASNA_API_SIGNING_KEY",
|
|
2309
|
+
"API_KEY_SIGNING_SECRET"
|
|
2310
|
+
];
|
|
2311
|
+
function normalizeEnv(env = process.env) {
|
|
2312
|
+
if (!env["HASNA_DOMAINS_DATABASE_URL"] && env["DATABASE_URL"]) {
|
|
2313
|
+
env["HASNA_DOMAINS_DATABASE_URL"] = env["DATABASE_URL"];
|
|
2314
|
+
}
|
|
2315
|
+
if (!env["HASNA_DOMAINS_STORAGE_MODE"] && env["HASNA_DOMAINS_DATABASE_URL"]) {
|
|
2316
|
+
env["HASNA_DOMAINS_STORAGE_MODE"] = "cloud";
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
function resolveSigningSecret(env = process.env) {
|
|
2320
|
+
for (const key of SIGNING_KEY_ENVS) {
|
|
2321
|
+
const v = env[key]?.trim();
|
|
2322
|
+
if (v)
|
|
2323
|
+
return v;
|
|
2324
|
+
}
|
|
2325
|
+
throw new Error(`Missing API-key signing secret. Set ${SIGNING_KEY_ENVS[0]} (or ${SIGNING_KEY_ENVS[2]}).`);
|
|
2326
|
+
}
|
|
2327
|
+
function parseArg(name, fallback) {
|
|
2328
|
+
const eq = process.argv.find((a) => a.startsWith(`${name}=`));
|
|
2329
|
+
if (eq)
|
|
2330
|
+
return eq.split("=")[1];
|
|
2331
|
+
const idx = process.argv.indexOf(name);
|
|
2332
|
+
if (idx >= 0 && process.argv[idx + 1])
|
|
2333
|
+
return process.argv[idx + 1];
|
|
2334
|
+
return fallback;
|
|
2335
|
+
}
|
|
2336
|
+
async function main() {
|
|
2337
|
+
if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
2338
|
+
console.log(getPackageVersion());
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
normalizeEnv();
|
|
2342
|
+
const version = getPackageVersion();
|
|
2343
|
+
const signingSecret = resolveSigningSecret();
|
|
2344
|
+
const { client, connectionSource } = createCloudPoolFromEnv("domains", {
|
|
2345
|
+
applicationName: "domains-serve",
|
|
2346
|
+
max: 5
|
|
2347
|
+
});
|
|
2348
|
+
const store = new ApiKeyStore(client);
|
|
2349
|
+
const app = createServeApp({
|
|
2350
|
+
db: client,
|
|
2351
|
+
signingSecret,
|
|
2352
|
+
version,
|
|
2353
|
+
mode: process.env["HASNA_APP_MODE"] ?? "self_hosted",
|
|
2354
|
+
isRevoked: store.isRevoked,
|
|
2355
|
+
audit: (e) => {
|
|
2356
|
+
if (e.outcome === "deny") {
|
|
2357
|
+
console.error(JSON.stringify({ level: "warn", event: "api_auth_deny", ...e }));
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
});
|
|
2361
|
+
const port = Number(parseArg("--port", process.env["PORT"]) ?? DEFAULT_PORT) || DEFAULT_PORT;
|
|
2362
|
+
const host = parseArg("--host", process.env["HOST"]) ?? "0.0.0.0";
|
|
2363
|
+
Bun.serve({
|
|
2364
|
+
port,
|
|
2365
|
+
hostname: host,
|
|
2366
|
+
idleTimeout: 30,
|
|
2367
|
+
fetch: (req) => app.handle(req)
|
|
2368
|
+
});
|
|
2369
|
+
console.log(JSON.stringify({
|
|
2370
|
+
level: "info",
|
|
2371
|
+
event: "domains_serve_started",
|
|
2372
|
+
version,
|
|
2373
|
+
port,
|
|
2374
|
+
host,
|
|
2375
|
+
dsnSource: connectionSource
|
|
2376
|
+
}));
|
|
2377
|
+
}
|
|
2378
|
+
if (import.meta.main) {
|
|
2379
|
+
main().catch((err) => {
|
|
2380
|
+
console.error(JSON.stringify({ level: "error", event: "domains_serve_fatal", error: err instanceof Error ? err.message : String(err) }));
|
|
2381
|
+
process.exit(1);
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
export {
|
|
2385
|
+
resolveSigningSecret,
|
|
2386
|
+
normalizeEnv,
|
|
2387
|
+
SIGNING_KEY_ENVS
|
|
2388
|
+
};
|