@hasna/domains 0.0.28 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/dns.d.ts.map +1 -1
- package/dist/cli/commands/domain.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/serve.d.ts.map +1 -1
- package/dist/cli/commands/ssl.d.ts.map +1 -1
- package/dist/cli/index.js +8262 -1400
- 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 +257 -0
- package/dist/db/store.d.ts.map +1 -0
- package/dist/generated/storage-kit/index.d.ts +1 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13312 -5984
- 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/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 +9107 -6859
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/index.js +1014 -10
- package/dist/server/repo.d.ts +53 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/package.json +4 -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 -825
package/dist/server/index.js
CHANGED
|
@@ -1,8 +1,359 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
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
|
+
}
|
|
6
357
|
|
|
7
358
|
// src/generated/storage-kit/mode.ts
|
|
8
359
|
var DEPRECATED_STORAGE_MODE_ALIASES = [
|
|
@@ -228,11 +579,11 @@ function createCloudPoolFromEnv(appName, options = {}) {
|
|
|
228
579
|
};
|
|
229
580
|
}
|
|
230
581
|
// src/generated/storage-kit/migrations.ts
|
|
231
|
-
import { createHash } from "crypto";
|
|
582
|
+
import { createHash as createHash2 } from "crypto";
|
|
232
583
|
function checksumSql(sql) {
|
|
233
584
|
const normalized = sql.trim().replace(/\r\n/g, `
|
|
234
585
|
`);
|
|
235
|
-
return `sha256:${
|
|
586
|
+
return `sha256:${createHash2("sha256").update(normalized).digest("hex")}`;
|
|
236
587
|
}
|
|
237
588
|
function defineMigration(id, sql) {
|
|
238
589
|
return Object.freeze({ id, sql: sql.trim(), checksum: checksumSql(sql) });
|
|
@@ -271,9 +622,6 @@ function getPackageVersion() {
|
|
|
271
622
|
}
|
|
272
623
|
var USER_AGENT = `open-domains/${getPackageVersion()}`;
|
|
273
624
|
|
|
274
|
-
// src/server/app.ts
|
|
275
|
-
import { verifyApiKey } from "@hasna/contracts/auth";
|
|
276
|
-
|
|
277
625
|
// src/db/domain-records.ts
|
|
278
626
|
var DOMAIN_STATUSES = [
|
|
279
627
|
"discovered",
|
|
@@ -290,6 +638,29 @@ var DOMAIN_STATUSES = [
|
|
|
290
638
|
"redemption"
|
|
291
639
|
];
|
|
292
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
|
+
];
|
|
293
664
|
|
|
294
665
|
// src/server/repo.ts
|
|
295
666
|
function parseJson(value, fallback) {
|
|
@@ -584,11 +955,406 @@ class DomainsRepo {
|
|
|
584
955
|
]);
|
|
585
956
|
return row;
|
|
586
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
|
+
}
|
|
587
1356
|
}
|
|
588
1357
|
|
|
589
|
-
// src/server/migrations.ts
|
|
590
|
-
import { apiKeyMigrations } from "@hasna/contracts/auth";
|
|
591
|
-
|
|
592
1358
|
// src/db/pg-migrations.ts
|
|
593
1359
|
var PG_MIGRATIONS = [
|
|
594
1360
|
`CREATE TABLE IF NOT EXISTS domains (
|
|
@@ -1260,6 +2026,13 @@ function createServeApp(options) {
|
|
|
1260
2026
|
const record = await repo.getDnsRecord(id);
|
|
1261
2027
|
return record ? json(record) : json({ error: "dns record not found" }, 404);
|
|
1262
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
|
+
}
|
|
1263
2036
|
if (method === "DELETE") {
|
|
1264
2037
|
const denied = await auth(req, path, ["domains:write"]);
|
|
1265
2038
|
if (denied)
|
|
@@ -1286,6 +2059,237 @@ function createServeApp(options) {
|
|
|
1286
2059
|
return json(offer, 201);
|
|
1287
2060
|
}
|
|
1288
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
|
+
}
|
|
1289
2293
|
return json({ error: "Not found" }, 404);
|
|
1290
2294
|
} catch (e) {
|
|
1291
2295
|
if (e instanceof HttpError)
|