@hasna/domains 0.0.39 → 0.0.43
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 +11 -15
- package/dist/cli/commands/domain.d.ts.map +1 -1
- package/dist/cli/index.js +19736 -32040
- package/dist/db/database.d.ts +24 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/domain-records.d.ts +61 -2
- package/dist/db/domain-records.d.ts.map +1 -1
- package/dist/db/domains.d.ts +14 -7
- package/dist/db/domains.d.ts.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/store.d.ts +48 -48
- package/dist/db/store.d.ts.map +1 -1
- package/dist/generated/storage-kit/backend.d.ts +19 -0
- package/dist/generated/storage-kit/backend.d.ts.map +1 -0
- package/dist/generated/storage-kit/index.d.ts +2 -2
- package/dist/generated/storage-kit/index.d.ts.map +1 -1
- package/dist/generated/storage-kit/migrations.d.ts.map +1 -1
- package/dist/generated/storage-kit/own.d.ts +11 -0
- package/dist/generated/storage-kit/own.d.ts.map +1 -0
- package/dist/generated/storage-kit/pool.d.ts +7 -6
- package/dist/generated/storage-kit/pool.d.ts.map +1 -1
- package/dist/generated/storage-kit/query.d.ts +1 -1
- package/dist/generated/storage-kit/query.d.ts.map +1 -1
- package/dist/generated/storage-kit/tls.d.ts +30 -3
- package/dist/generated/storage-kit/tls.d.ts.map +1 -1
- package/dist/index.js +15865 -28359
- package/dist/lib/brandsight.d.ts.map +1 -1
- package/dist/lib/config.d.ts +23 -3
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/freshness.d.ts +65 -0
- package/dist/lib/freshness.d.ts.map +1 -0
- package/dist/lib/godaddy.d.ts.map +1 -1
- package/dist/lib/namecheap.d.ts +2 -0
- package/dist/lib/namecheap.d.ts.map +1 -1
- package/dist/lib/registrar.js +17 -6
- package/dist/lib/route53.d.ts.map +1 -1
- package/dist/lib/route53.js +5 -2
- package/dist/mcp/index.js +13479 -25921
- package/dist/sdk/index.d.ts +3 -2
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +280 -3
- package/dist/server/app.d.ts +12 -8
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -5
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +527 -181
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/package.json +7 -4
- package/dist/generated/storage-kit/mode.d.ts +0 -48
- package/dist/generated/storage-kit/mode.d.ts.map +0 -1
package/dist/server/index.js
CHANGED
|
@@ -1,14 +1,90 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// ../contracts/dist/auth/index.js
|
|
5
5
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
6
|
+
var MAX_TENANT_ID_LENGTH = 64;
|
|
7
|
+
var TENANT_ID_PATTERN = new RegExp(`^[A-Za-z0-9][A-Za-z0-9._-]{0,${MAX_TENANT_ID_LENGTH - 1}}$`);
|
|
8
|
+
var UUID_HEX = "[0-9a-fA-F]";
|
|
9
|
+
var UUID_PATTERN = new RegExp(`^\\{?(?:${UUID_HEX}{8}-${UUID_HEX}{4}-${UUID_HEX}{4}-${UUID_HEX}{4}-${UUID_HEX}{12}|${UUID_HEX}{32})\\}?$`);
|
|
10
|
+
function isValidTenantId(value) {
|
|
11
|
+
return typeof value === "string" && TENANT_ID_PATTERN.test(value);
|
|
12
|
+
}
|
|
13
|
+
function isUuidTenantId(value) {
|
|
14
|
+
return typeof value === "string" && UUID_PATTERN.test(value);
|
|
15
|
+
}
|
|
16
|
+
function canonicalizeTenantId(value) {
|
|
17
|
+
if (!isUuidTenantId(value))
|
|
18
|
+
return value;
|
|
19
|
+
const hex = value.replace(/[{}-]/g, "").toLowerCase();
|
|
20
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
21
|
+
}
|
|
22
|
+
function normalizeTenantId(value) {
|
|
23
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
24
|
+
const canonical = canonicalizeTenantId(trimmed);
|
|
25
|
+
if (!isValidTenantId(canonical)) {
|
|
26
|
+
throw new Error(`Invalid tenant id '${value}'. Expected 1-${MAX_TENANT_ID_LENGTH} characters matching ${TENANT_ID_PATTERN} (a UUID, ULID, slug, or prefixed id).`);
|
|
27
|
+
}
|
|
28
|
+
return canonical;
|
|
29
|
+
}
|
|
30
|
+
function tenantIdsEqual(left, right) {
|
|
31
|
+
const canonical = (value) => {
|
|
32
|
+
if (typeof value !== "string")
|
|
33
|
+
return null;
|
|
34
|
+
const folded = canonicalizeTenantId(value.trim());
|
|
35
|
+
return isValidTenantId(folded) ? folded : null;
|
|
36
|
+
};
|
|
37
|
+
const a = canonical(left);
|
|
38
|
+
const b = canonical(right);
|
|
39
|
+
return a !== null && b !== null && a === b;
|
|
40
|
+
}
|
|
41
|
+
function ownTenantId(source) {
|
|
42
|
+
return Object.hasOwn(source, "tid") ? source.tid : undefined;
|
|
43
|
+
}
|
|
6
44
|
var API_KEY_TOKEN_VERSION = 1;
|
|
7
45
|
var API_KEY_NAMESPACE = "hasna";
|
|
8
|
-
var
|
|
46
|
+
var API_KEY_TOKEN_PATTERN = /^hasna_([a-z][a-z0-9-]*)_([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/;
|
|
47
|
+
var TOKEN_PATTERN = API_KEY_TOKEN_PATTERN;
|
|
9
48
|
var DEFAULT_API_KEY_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
49
|
+
function ownAgentClaim(source) {
|
|
50
|
+
return Object.hasOwn(source, "agent") && typeof source.agent === "string" ? source.agent : null;
|
|
51
|
+
}
|
|
52
|
+
function ownScopesClaim(source) {
|
|
53
|
+
return Object.hasOwn(source, "scopes") && Array.isArray(source.scopes) ? source.scopes : null;
|
|
54
|
+
}
|
|
55
|
+
function ownOption(options, name) {
|
|
56
|
+
return Object.hasOwn(options, name) ? options[name] : undefined;
|
|
57
|
+
}
|
|
58
|
+
var typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
59
|
+
var intrinsicViewBuffer = Object.getOwnPropertyDescriptor(typedArrayPrototype, "buffer").get;
|
|
60
|
+
var intrinsicViewByteOffset = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteOffset").get;
|
|
61
|
+
var intrinsicViewByteLength = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteLength").get;
|
|
62
|
+
var intrinsicDataViewBuffer = Object.getOwnPropertyDescriptor(DataView.prototype, "buffer").get;
|
|
63
|
+
var intrinsicDataViewByteOffset = Object.getOwnPropertyDescriptor(DataView.prototype, "byteOffset").get;
|
|
64
|
+
var intrinsicDataViewByteLength = Object.getOwnPropertyDescriptor(DataView.prototype, "byteLength").get;
|
|
65
|
+
function viewWindow(view) {
|
|
66
|
+
try {
|
|
67
|
+
return [
|
|
68
|
+
intrinsicViewBuffer.call(view),
|
|
69
|
+
intrinsicViewByteOffset.call(view),
|
|
70
|
+
intrinsicViewByteLength.call(view)
|
|
71
|
+
];
|
|
72
|
+
} catch {
|
|
73
|
+
return [
|
|
74
|
+
intrinsicDataViewBuffer.call(view),
|
|
75
|
+
intrinsicDataViewByteOffset.call(view),
|
|
76
|
+
intrinsicDataViewByteLength.call(view)
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
10
80
|
function toBuffer(secret) {
|
|
11
|
-
|
|
81
|
+
if (typeof secret === "string")
|
|
82
|
+
return Buffer.from(secret, "utf8");
|
|
83
|
+
if (ArrayBuffer.isView(secret)) {
|
|
84
|
+
const [store, byteOffset, byteLength] = viewWindow(secret);
|
|
85
|
+
return Buffer.from(store, byteOffset, byteLength);
|
|
86
|
+
}
|
|
87
|
+
return Buffer.from(secret);
|
|
12
88
|
}
|
|
13
89
|
function hmac(signingSecret, message) {
|
|
14
90
|
return createHmac("sha256", toBuffer(signingSecret)).update(message, "utf8").digest();
|
|
@@ -31,12 +107,23 @@ function parseApiKey(token) {
|
|
|
31
107
|
} catch {
|
|
32
108
|
return null;
|
|
33
109
|
}
|
|
34
|
-
if (typeof claims !== "object" || claims === null || typeof claims.kid !== "string" || typeof claims.app !== "string" ||
|
|
110
|
+
if (typeof claims !== "object" || claims === null || typeof claims.kid !== "string" || typeof claims.app !== "string" || ownScopesClaim(claims) === null) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const claimedTid = ownTenantId(claims);
|
|
114
|
+
if (claimedTid !== undefined && !isValidTenantId(claimedTid)) {
|
|
35
115
|
return null;
|
|
36
116
|
}
|
|
37
117
|
return { app, body, sig, claims };
|
|
38
118
|
}
|
|
39
119
|
function verifyApiKeyToken(token, options) {
|
|
120
|
+
const optSigningSecret = ownOption(options, "signingSecret");
|
|
121
|
+
const optExpectedApp = ownOption(options, "expectedApp");
|
|
122
|
+
const optNowMs = ownOption(options, "nowMs");
|
|
123
|
+
const optLeewaySeconds = ownOption(options, "leewaySeconds");
|
|
124
|
+
const optRequiredScopes = ownOption(options, "requiredScopes");
|
|
125
|
+
const optRequireTenant = ownOption(options, "requireTenant");
|
|
126
|
+
const optExpectedTid = ownOption(options, "expectedTid");
|
|
40
127
|
const parsed = parseApiKey(token);
|
|
41
128
|
if (!parsed) {
|
|
42
129
|
return { ok: false, reason: "malformed", message: "Token is malformed." };
|
|
@@ -48,10 +135,10 @@ function verifyApiKeyToken(token, options) {
|
|
|
48
135
|
if (claims.app !== app) {
|
|
49
136
|
return { ok: false, reason: "app_mismatch", message: "Token prefix app does not match claims." };
|
|
50
137
|
}
|
|
51
|
-
if (
|
|
52
|
-
return { ok: false, reason: "app_mismatch", message: `Token is for app '${app}', expected '${
|
|
138
|
+
if (optExpectedApp !== undefined && app !== optExpectedApp) {
|
|
139
|
+
return { ok: false, reason: "app_mismatch", message: `Token is for app '${app}', expected '${optExpectedApp}'.` };
|
|
53
140
|
}
|
|
54
|
-
const expected = hmac(
|
|
141
|
+
const expected = hmac(optSigningSecret, `${apiKeyPrefix(app)}${body}`);
|
|
55
142
|
let provided;
|
|
56
143
|
try {
|
|
57
144
|
provided = Buffer.from(sig, "base64url");
|
|
@@ -61,16 +148,41 @@ function verifyApiKeyToken(token, options) {
|
|
|
61
148
|
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
62
149
|
return { ok: false, reason: "bad_signature", message: "Signature verification failed." };
|
|
63
150
|
}
|
|
64
|
-
const
|
|
65
|
-
const
|
|
151
|
+
const agent = ownAgentClaim(claims);
|
|
152
|
+
const now = Math.floor((optNowMs ?? Date.now()) / 1000);
|
|
153
|
+
const leeway = optLeewaySeconds ?? 0;
|
|
66
154
|
if (typeof claims.iat === "number" && now + leeway < claims.iat) {
|
|
67
|
-
return { ok: false, reason: "not_yet_valid", message: "Token is not yet valid." };
|
|
155
|
+
return { ok: false, reason: "not_yet_valid", message: "Token is not yet valid.", agent };
|
|
68
156
|
}
|
|
69
157
|
if (claims.exp !== null && typeof claims.exp === "number" && now - leeway >= claims.exp) {
|
|
70
|
-
return { ok: false, reason: "expired", message: "Token has expired." };
|
|
158
|
+
return { ok: false, reason: "expired", message: "Token has expired.", agent };
|
|
159
|
+
}
|
|
160
|
+
const verifiedTid = ownTenantId(claims);
|
|
161
|
+
const tid = verifiedTid === undefined ? null : canonicalizeTenantId(verifiedTid);
|
|
162
|
+
const tenantRequired = Boolean(optRequireTenant) || optExpectedTid !== undefined;
|
|
163
|
+
if (tenantRequired && tid === null) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: "tenant_required",
|
|
167
|
+
message: "Token carries no tenant id ('tid') and this service requires one.",
|
|
168
|
+
kid: claims.kid,
|
|
169
|
+
tid: null,
|
|
170
|
+
agent
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (optExpectedTid !== undefined && !tenantIdsEqual(tid, optExpectedTid)) {
|
|
174
|
+
const expectationIsWellFormed = typeof optExpectedTid === "string" && isValidTenantId(optExpectedTid.trim());
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: "tenant_mismatch",
|
|
178
|
+
message: expectationIsWellFormed ? "Token is for a different tenant than the one this service accepts." : "Token tenant cannot be checked: the expected tenant id is not a valid tenant id.",
|
|
179
|
+
kid: claims.kid,
|
|
180
|
+
tid,
|
|
181
|
+
agent
|
|
182
|
+
};
|
|
71
183
|
}
|
|
72
|
-
if (
|
|
73
|
-
const granted = claims
|
|
184
|
+
if (optRequiredScopes && optRequiredScopes.length > 0) {
|
|
185
|
+
const granted = ownScopesClaim(claims) ?? [];
|
|
74
186
|
const satisfies = (required) => granted.some((g) => {
|
|
75
187
|
if (g === "*")
|
|
76
188
|
return true;
|
|
@@ -84,15 +196,16 @@ function verifyApiKeyToken(token, options) {
|
|
|
84
196
|
const rAction = required.slice(ri + 1);
|
|
85
197
|
return (gApp === "*" || gApp === rApp) && (gAction === "*" || gAction === rAction);
|
|
86
198
|
});
|
|
87
|
-
for (const required of
|
|
199
|
+
for (const required of optRequiredScopes) {
|
|
88
200
|
if (!satisfies(required)) {
|
|
89
|
-
return { ok: false, reason: "insufficient_scope", message: `Missing required scope '${required}'
|
|
201
|
+
return { ok: false, reason: "insufficient_scope", message: `Missing required scope '${required}'.`, agent };
|
|
90
202
|
}
|
|
91
203
|
}
|
|
92
204
|
}
|
|
93
|
-
return { ok: true, claims, kid: claims.kid, app };
|
|
205
|
+
return { ok: true, claims, kid: claims.kid, app, tid, agent };
|
|
94
206
|
}
|
|
95
207
|
var DEFAULT_API_KEYS_TABLE = "api_keys";
|
|
208
|
+
var API_KEY_ISSUANCE_PENDING_REASON = "credential_delivery_pending";
|
|
96
209
|
function createTableSql(table) {
|
|
97
210
|
return `CREATE TABLE IF NOT EXISTS ${table} (
|
|
98
211
|
kid TEXT PRIMARY KEY,
|
|
@@ -116,6 +229,11 @@ function apiKeyMigrations(table = DEFAULT_API_KEYS_TABLE) {
|
|
|
116
229
|
id: `hasna_auth_0002_${table}_indexes`,
|
|
117
230
|
sql: `CREATE INDEX IF NOT EXISTS ${table}_app_idx ON ${table} (app);
|
|
118
231
|
CREATE INDEX IF NOT EXISTS ${table}_token_hash_idx ON ${table} (token_hash);`
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: `hasna_auth_0003_${table}_tenant`,
|
|
235
|
+
sql: `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS tid TEXT;
|
|
236
|
+
CREATE INDEX IF NOT EXISTS ${table}_tid_idx ON ${table} (tid);`
|
|
119
237
|
}
|
|
120
238
|
];
|
|
121
239
|
}
|
|
@@ -140,10 +258,13 @@ function parseScopes(value) {
|
|
|
140
258
|
return [];
|
|
141
259
|
}
|
|
142
260
|
function rowToRecord(row) {
|
|
261
|
+
const tid = ownTenantId(row);
|
|
262
|
+
const agentValue = Object.hasOwn(row, "agent") ? row.agent : null;
|
|
143
263
|
return {
|
|
144
264
|
kid: String(row.kid),
|
|
145
265
|
app: String(row.app),
|
|
146
|
-
agent:
|
|
266
|
+
agent: agentValue === null || agentValue === undefined ? null : String(agentValue),
|
|
267
|
+
tid: tid === null || tid === undefined ? null : String(tid),
|
|
147
268
|
scopes: parseScopes(row.scopes),
|
|
148
269
|
tokenHash: String(row.token_hash),
|
|
149
270
|
issuedAt: toIso(row.issued_at) ?? new Date(0).toISOString(),
|
|
@@ -174,31 +295,63 @@ class ApiKeyStore {
|
|
|
174
295
|
}
|
|
175
296
|
}
|
|
176
297
|
async insert(input) {
|
|
298
|
+
await this.insertWithLifecycle(input, null, null);
|
|
299
|
+
}
|
|
300
|
+
async insertWithLifecycle(input, revokedAt, revokedReason) {
|
|
301
|
+
const tid = ownTenantId(input);
|
|
302
|
+
const agent = ownAgentClaim(input);
|
|
177
303
|
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
|
|
304
|
+
(kid, app, agent, tid, scopes, token_hash, issued_at, expires_at, created_by, revoked_at, revoked_reason)
|
|
305
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11)`, [
|
|
180
306
|
input.kid,
|
|
181
307
|
input.app,
|
|
182
|
-
|
|
308
|
+
agent,
|
|
309
|
+
tid === undefined || tid === null ? null : normalizeTenantId(tid),
|
|
183
310
|
JSON.stringify(input.scopes),
|
|
184
311
|
input.tokenHash,
|
|
185
312
|
input.issuedAt.toISOString(),
|
|
186
313
|
input.expiresAt ? input.expiresAt.toISOString() : null,
|
|
187
|
-
input.createdBy ?? null
|
|
314
|
+
input.createdBy ?? null,
|
|
315
|
+
revokedAt,
|
|
316
|
+
revokedReason
|
|
188
317
|
]);
|
|
189
318
|
}
|
|
190
|
-
|
|
319
|
+
mintedInput(minted, createdBy) {
|
|
191
320
|
const claims = minted.claims;
|
|
192
|
-
|
|
321
|
+
return {
|
|
193
322
|
kid: minted.kid,
|
|
194
323
|
app: claims.app,
|
|
195
|
-
agent: claims
|
|
324
|
+
agent: ownAgentClaim(claims),
|
|
325
|
+
tid: ownTenantId(claims) ?? null,
|
|
196
326
|
scopes: claims.scopes,
|
|
197
327
|
tokenHash: minted.tokenHash,
|
|
198
328
|
issuedAt: new Date(claims.iat * 1000),
|
|
199
329
|
expiresAt: claims.exp === null ? null : new Date(claims.exp * 1000),
|
|
200
330
|
createdBy: createdBy ?? null
|
|
201
|
-
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
async insertMinted(minted, createdBy) {
|
|
334
|
+
await this.insert(this.mintedInput(minted, createdBy));
|
|
335
|
+
}
|
|
336
|
+
async insertMintedPending(minted, createdBy, atMs = Date.now()) {
|
|
337
|
+
await this.insertWithLifecycle(this.mintedInput(minted, createdBy), new Date(atMs).toISOString(), API_KEY_ISSUANCE_PENDING_REASON);
|
|
338
|
+
}
|
|
339
|
+
async activatePending(kid, tokenHash) {
|
|
340
|
+
const row = await this.client.get(`UPDATE ${this.table}
|
|
341
|
+
SET revoked_at = NULL, revoked_reason = NULL
|
|
342
|
+
WHERE kid = $1
|
|
343
|
+
AND revoked_at IS NOT NULL
|
|
344
|
+
AND revoked_reason = $2
|
|
345
|
+
AND token_hash = $3
|
|
346
|
+
RETURNING kid`, [kid, API_KEY_ISSUANCE_PENDING_REASON, tokenHash]);
|
|
347
|
+
if (row)
|
|
348
|
+
return true;
|
|
349
|
+
const active = await this.client.get(`SELECT kid FROM ${this.table}
|
|
350
|
+
WHERE kid = $1
|
|
351
|
+
AND token_hash = $2
|
|
352
|
+
AND revoked_at IS NULL
|
|
353
|
+
AND revoked_reason IS NULL`, [kid, tokenHash]);
|
|
354
|
+
return active !== null;
|
|
202
355
|
}
|
|
203
356
|
async findByKid(kid) {
|
|
204
357
|
const row = await this.client.get(`SELECT * FROM ${this.table} WHERE kid = $1`, [kid]);
|
|
@@ -224,6 +377,9 @@ class ApiKeyStore {
|
|
|
224
377
|
return "expired";
|
|
225
378
|
return "active";
|
|
226
379
|
}
|
|
380
|
+
keyStatus = async (kid) => {
|
|
381
|
+
return this.status(kid);
|
|
382
|
+
};
|
|
227
383
|
statusChecker() {
|
|
228
384
|
return async (kid) => {
|
|
229
385
|
const status = await this.status(kid);
|
|
@@ -250,11 +406,16 @@ class ApiKeyStore {
|
|
|
250
406
|
params.push(options.app);
|
|
251
407
|
clauses.push(`app = $${params.length}`);
|
|
252
408
|
}
|
|
409
|
+
const tid = ownTenantId(options);
|
|
410
|
+
if (tid !== undefined) {
|
|
411
|
+
params.push(normalizeTenantId(tid));
|
|
412
|
+
clauses.push(`tid = $${params.length}`);
|
|
413
|
+
}
|
|
253
414
|
if (!options.includeRevoked) {
|
|
254
415
|
clauses.push("revoked_at IS NULL");
|
|
255
416
|
}
|
|
256
417
|
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
|
|
418
|
+
const rows = await this.client.many(`SELECT * FROM ${this.table} ${where} ORDER BY issued_at DESC`, params);
|
|
258
419
|
return rows.map(rowToRecord);
|
|
259
420
|
}
|
|
260
421
|
async revokedKids() {
|
|
@@ -291,27 +452,65 @@ function extractToken(source, headerName = "x-api-key", scheme = "Bearer") {
|
|
|
291
452
|
}
|
|
292
453
|
return null;
|
|
293
454
|
}
|
|
455
|
+
function ownOption2(bag, name) {
|
|
456
|
+
return Object.hasOwn(bag, name) ? bag[name] : undefined;
|
|
457
|
+
}
|
|
294
458
|
function verifyApiKey(options) {
|
|
295
|
-
|
|
459
|
+
const optionApp = ownOption2(options, "app");
|
|
460
|
+
const optionSigningSecret = ownOption2(options, "signingSecret");
|
|
461
|
+
const optionExpectedTid = ownOption2(options, "expectedTid");
|
|
462
|
+
const optionRequiredScopes = ownOption2(options, "requiredScopes");
|
|
463
|
+
const optionRequireTenant = ownOption2(options, "requireTenant");
|
|
464
|
+
const optionLeewaySeconds = ownOption2(options, "leewaySeconds");
|
|
465
|
+
const audit = ownOption2(options, "audit");
|
|
466
|
+
const headerName = ownOption2(options, "headerName") ?? "x-api-key";
|
|
467
|
+
const scheme = ownOption2(options, "scheme") ?? "Bearer";
|
|
468
|
+
const clock = ownOption2(options, "nowMs") ?? (() => Date.now());
|
|
469
|
+
if (!optionApp)
|
|
296
470
|
throw new Error("verifyApiKey requires an 'app' slug.");
|
|
297
|
-
if (!
|
|
471
|
+
if (!optionSigningSecret) {
|
|
298
472
|
throw new Error("verifyApiKey requires a 'signingSecret'. Set it from HASNA_<APP>_API_SIGNING_KEY.");
|
|
299
473
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
474
|
+
if (optionExpectedTid !== undefined && !isValidTenantId(optionExpectedTid)) {
|
|
475
|
+
throw new Error(`verifyApiKey received an invalid 'expectedTid': '${optionExpectedTid}'.`);
|
|
476
|
+
}
|
|
477
|
+
const app = optionApp;
|
|
478
|
+
const signingSecret = optionSigningSecret;
|
|
479
|
+
const ownKeyStatus = ownOption2(options, "keyStatus");
|
|
480
|
+
const ownIsRevoked = ownOption2(options, "isRevoked");
|
|
481
|
+
const allowUnregistered = ownOption2(options, "allowUnregisteredKeys") === true;
|
|
482
|
+
if (ownKeyStatus && ownIsRevoked) {
|
|
483
|
+
throw new Error("verifyApiKey received both 'keyStatus' and 'isRevoked'. Supply exactly one \u2014 " + "letting one silently win would hide which check is actually guarding the service. " + "Use 'keyStatus' (store.keyStatus); drop 'isRevoked'.");
|
|
484
|
+
}
|
|
485
|
+
if (!ownKeyStatus && !allowUnregistered) {
|
|
486
|
+
throw new Error(ownIsRevoked ? "verifyApiKey was given only 'isRevoked', which cannot refuse a key this service has " + "no record of: it returns false both for an active key and for one that was never " + "registered, so an unregistered key is irrevocable. Wire 'keyStatus: store.keyStatus' " + "(or 'isRevoked: store.statusChecker()'), or set 'allowUnregisteredKeys: true' to " + "accept that risk explicitly." : "verifyApiKey requires a key-status hook. Without one this service performs NO " + "revocation check and cannot turn any of its keys off. Wire " + "'keyStatus: store.keyStatus', or set 'allowUnregisteredKeys: true' to declare that " + "this service intentionally cannot revoke keys.");
|
|
487
|
+
}
|
|
303
488
|
async function emit(event) {
|
|
304
|
-
if (!
|
|
489
|
+
if (!audit)
|
|
305
490
|
return;
|
|
306
491
|
try {
|
|
307
|
-
await
|
|
492
|
+
await audit(event);
|
|
308
493
|
} catch {}
|
|
309
494
|
}
|
|
310
495
|
async function authenticate(headers, context = {}) {
|
|
311
|
-
const method = context
|
|
312
|
-
const path = context
|
|
313
|
-
const requiredScopes = [
|
|
496
|
+
const method = ownOption2(context, "method") ?? null;
|
|
497
|
+
const path = ownOption2(context, "path") ?? null;
|
|
498
|
+
const requiredScopes = [
|
|
499
|
+
...optionRequiredScopes ?? [],
|
|
500
|
+
...ownOption2(context, "requiredScopes") ?? []
|
|
501
|
+
];
|
|
314
502
|
const at = new Date(clock()).toISOString();
|
|
503
|
+
const perCallTid = ownOption2(context, "expectedTid");
|
|
504
|
+
const expectedTid = perCallTid !== undefined ? perCallTid : optionExpectedTid;
|
|
505
|
+
if (perCallTid !== undefined && optionExpectedTid !== undefined && !tenantIdsEqual(perCallTid, optionExpectedTid)) {
|
|
506
|
+
await emit({ outcome: "deny", app, kid: null, tid: null, reason: "tenant_mismatch", scopesRequired: requiredScopes, method, path, status: 403, at });
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
status: 403,
|
|
510
|
+
reason: "tenant_mismatch",
|
|
511
|
+
message: "This route addresses a tenant other than the one this service is pinned to."
|
|
512
|
+
};
|
|
513
|
+
}
|
|
315
514
|
const token = extractToken(headers, headerName, scheme);
|
|
316
515
|
if (!token) {
|
|
317
516
|
const decision = {
|
|
@@ -320,25 +519,72 @@ function verifyApiKey(options) {
|
|
|
320
519
|
reason: "missing_token",
|
|
321
520
|
message: `Missing API key. Send it as '${headerName}: <key>' or 'Authorization: ${scheme} <key>'.`
|
|
322
521
|
};
|
|
323
|
-
await emit({ outcome: "deny", app:
|
|
522
|
+
await emit({ outcome: "deny", app, kid: null, tid: null, reason: "missing_token", scopesRequired: requiredScopes, method, path, status: 401, at });
|
|
324
523
|
return decision;
|
|
325
524
|
}
|
|
326
525
|
const verified = verifyApiKeyToken(token, {
|
|
327
|
-
signingSecret
|
|
328
|
-
expectedApp:
|
|
526
|
+
signingSecret,
|
|
527
|
+
expectedApp: app,
|
|
329
528
|
nowMs: clock(),
|
|
330
|
-
...
|
|
529
|
+
...optionLeewaySeconds !== undefined ? { leewaySeconds: optionLeewaySeconds } : {},
|
|
530
|
+
...optionRequireTenant !== undefined ? { requireTenant: optionRequireTenant } : {},
|
|
531
|
+
...expectedTid !== undefined ? { expectedTid } : {},
|
|
331
532
|
requiredScopes
|
|
332
533
|
});
|
|
333
534
|
if (!verified.ok) {
|
|
334
|
-
const status = verified.reason === "insufficient_scope" ? 403 : 401;
|
|
335
|
-
await emit({
|
|
535
|
+
const status = verified.reason === "insufficient_scope" || verified.reason === "tenant_mismatch" || verified.reason === "tenant_required" ? 403 : 401;
|
|
536
|
+
await emit({
|
|
537
|
+
outcome: "deny",
|
|
538
|
+
app,
|
|
539
|
+
kid: ownOption2(verified, "kid") ?? null,
|
|
540
|
+
tid: ownTenantId(verified) ?? null,
|
|
541
|
+
...Object.hasOwn(verified, "agent") ? { agent: verified.agent } : {},
|
|
542
|
+
reason: verified.reason,
|
|
543
|
+
scopesRequired: requiredScopes,
|
|
544
|
+
method,
|
|
545
|
+
path,
|
|
546
|
+
status,
|
|
547
|
+
at
|
|
548
|
+
});
|
|
336
549
|
return { ok: false, status, reason: verified.reason, message: verified.message };
|
|
337
550
|
}
|
|
338
|
-
if (
|
|
339
|
-
|
|
551
|
+
if (ownKeyStatus) {
|
|
552
|
+
let status;
|
|
553
|
+
try {
|
|
554
|
+
status = await ownKeyStatus(verified.kid);
|
|
555
|
+
} catch {
|
|
556
|
+
await emit({ outcome: "deny", app, kid: verified.kid, tid: verified.tid, agent: verified.agent, reason: "status_unavailable", scopesRequired: requiredScopes, method, path, status: 503, at });
|
|
557
|
+
return {
|
|
558
|
+
ok: false,
|
|
559
|
+
status: 503,
|
|
560
|
+
reason: "status_unavailable",
|
|
561
|
+
message: "Could not verify API key status. Try again shortly."
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
if (status !== "active") {
|
|
565
|
+
const known = status === "revoked" || status === "expired" || status === "unknown";
|
|
566
|
+
if (!(status === "unknown" && allowUnregistered)) {
|
|
567
|
+
const reason = status === "revoked" || status === "expired" ? status : "unknown_key";
|
|
568
|
+
const message = reason === "unknown_key" ? known ? "API key is not registered with this service." : "API key status could not be recognized." : status === "expired" ? "API key has expired." : "API key has been revoked.";
|
|
569
|
+
await emit({ outcome: "deny", app, kid: verified.kid, tid: verified.tid, agent: verified.agent, reason, scopesRequired: requiredScopes, method, path, status: 401, at });
|
|
570
|
+
return { ok: false, status: 401, reason, message };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} else if (ownIsRevoked) {
|
|
574
|
+
let revoked;
|
|
575
|
+
try {
|
|
576
|
+
revoked = await ownIsRevoked(verified.kid);
|
|
577
|
+
} catch {
|
|
578
|
+
await emit({ outcome: "deny", app, kid: verified.kid, tid: verified.tid, agent: verified.agent, reason: "status_unavailable", scopesRequired: requiredScopes, method, path, status: 503, at });
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
status: 503,
|
|
582
|
+
reason: "status_unavailable",
|
|
583
|
+
message: "Could not verify API key status. Try again shortly."
|
|
584
|
+
};
|
|
585
|
+
}
|
|
340
586
|
if (revoked) {
|
|
341
|
-
await emit({ outcome: "deny", app:
|
|
587
|
+
await emit({ outcome: "deny", app, kid: verified.kid, tid: verified.tid, agent: verified.agent, reason: "revoked", scopesRequired: requiredScopes, method, path, status: 401, at });
|
|
342
588
|
return { ok: false, status: 401, reason: "revoked", message: "API key has been revoked." };
|
|
343
589
|
}
|
|
344
590
|
}
|
|
@@ -346,138 +592,202 @@ function verifyApiKey(options) {
|
|
|
346
592
|
kid: verified.kid,
|
|
347
593
|
app: verified.app,
|
|
348
594
|
scopes: verified.claims.scopes,
|
|
349
|
-
agent: verified.
|
|
595
|
+
agent: verified.agent,
|
|
596
|
+
tid: verified.tid,
|
|
350
597
|
claims: verified.claims
|
|
351
598
|
};
|
|
352
|
-
await emit({ outcome: "allow", app:
|
|
599
|
+
await emit({ outcome: "allow", app, kid: verified.kid, tid: verified.tid, agent: verified.agent, reason: null, scopesRequired: requiredScopes, method, path, status: 200, at });
|
|
353
600
|
return { ok: true, status: 200, principal };
|
|
354
601
|
}
|
|
355
|
-
return { authenticate, app
|
|
602
|
+
return { authenticate, app };
|
|
356
603
|
}
|
|
604
|
+
var MAX_FLEET_TOKEN_TTL_SECONDS = 24 * 60 * 60;
|
|
357
605
|
|
|
358
|
-
// src/generated/storage-kit/
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
|
|
606
|
+
// src/generated/storage-kit/own.ts
|
|
607
|
+
function ownProp(source, key) {
|
|
608
|
+
if (source === null || source === undefined)
|
|
609
|
+
return;
|
|
610
|
+
const kind = typeof source;
|
|
611
|
+
if (kind !== "object" && kind !== "function")
|
|
612
|
+
return;
|
|
613
|
+
if (!Object.hasOwn(source, key))
|
|
614
|
+
return;
|
|
615
|
+
return source[key];
|
|
616
|
+
}
|
|
617
|
+
function ownString(source, key) {
|
|
618
|
+
const value = ownProp(source, key);
|
|
619
|
+
return typeof value === "string" ? value : undefined;
|
|
374
620
|
}
|
|
621
|
+
|
|
622
|
+
// src/generated/storage-kit/backend.ts
|
|
375
623
|
function envToken(name) {
|
|
376
624
|
return name.toUpperCase().replace(/-/g, "_");
|
|
377
625
|
}
|
|
378
|
-
function
|
|
626
|
+
function serverDataBackendEnvKeys(name) {
|
|
379
627
|
const token = envToken(name);
|
|
380
628
|
return {
|
|
381
|
-
modeKeys: [`HASNA_${token}_STORAGE_MODE`, `${token}_STORAGE_MODE`],
|
|
382
629
|
databaseUrlKeys: [`HASNA_${token}_DATABASE_URL`, `${token}_DATABASE_URL`]
|
|
383
630
|
};
|
|
384
631
|
}
|
|
385
632
|
function firstEnv(env, keys) {
|
|
386
633
|
for (const key of keys) {
|
|
387
|
-
const value = env
|
|
634
|
+
const value = ownString(env, key)?.trim();
|
|
388
635
|
if (value)
|
|
389
636
|
return { key, value };
|
|
390
637
|
}
|
|
391
638
|
return null;
|
|
392
639
|
}
|
|
393
|
-
function
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
const databaseUrlPresent = Boolean(dbHit);
|
|
397
|
-
const databaseUrlSource = dbHit ? dbHit.key : null;
|
|
398
|
-
const modeHit = firstEnv(env, modeKeys);
|
|
399
|
-
if (!modeHit) {
|
|
640
|
+
function resolveServerDataBackend(name, env = process.env) {
|
|
641
|
+
const databaseUrl = firstEnv(env, serverDataBackendEnvKeys(name).databaseUrlKeys);
|
|
642
|
+
if (!databaseUrl) {
|
|
400
643
|
return {
|
|
401
|
-
|
|
644
|
+
backend: "sqlite",
|
|
402
645
|
source: "default",
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
databaseUrlSource,
|
|
406
|
-
warning: null
|
|
646
|
+
databaseUrlPresent: false,
|
|
647
|
+
databaseUrlSource: null
|
|
407
648
|
};
|
|
408
649
|
}
|
|
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
650
|
return {
|
|
421
|
-
|
|
422
|
-
source:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
databaseUrlSource,
|
|
426
|
-
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
651
|
+
backend: "postgresql",
|
|
652
|
+
source: databaseUrl.key,
|
|
653
|
+
databaseUrlPresent: true,
|
|
654
|
+
databaseUrlSource: databaseUrl.key
|
|
427
655
|
};
|
|
428
656
|
}
|
|
429
657
|
function resolveDatabaseUrl(name, env = process.env) {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
return hit ? hit.value : null;
|
|
658
|
+
const hit = firstEnv(env, serverDataBackendEnvKeys(name).databaseUrlKeys);
|
|
659
|
+
return hit?.value ?? null;
|
|
433
660
|
}
|
|
434
661
|
// src/generated/storage-kit/tls.ts
|
|
435
662
|
import { readFileSync } from "fs";
|
|
436
|
-
|
|
663
|
+
var PG_TLS_QUERY_PARAMETERS = new Set([
|
|
664
|
+
"ssl",
|
|
665
|
+
"sslmode",
|
|
666
|
+
"sslrootcert",
|
|
667
|
+
"sslcert",
|
|
668
|
+
"sslkey",
|
|
669
|
+
"sslpassword",
|
|
670
|
+
"sslnegotiation",
|
|
671
|
+
"uselibpqcompat"
|
|
672
|
+
]);
|
|
673
|
+
var EXPLICIT_SSL_ON_VALUES = new Set(["1", "true", "yes", "on", "require"]);
|
|
674
|
+
var EXPLICIT_SSL_OFF_VALUES = new Set(["0", "false", "no", "off", "disable"]);
|
|
675
|
+
var SSLMODE_VALUES = new Map([
|
|
676
|
+
["disable", "disable"],
|
|
677
|
+
["allow", "prefer"],
|
|
678
|
+
["prefer", "prefer"],
|
|
679
|
+
["require", "require"],
|
|
680
|
+
["verify-ca", "verify-ca"],
|
|
681
|
+
["verify-full", "verify-full"]
|
|
682
|
+
]);
|
|
683
|
+
function connectionStringParts(connectionString) {
|
|
437
684
|
const queryStart = connectionString.indexOf("?");
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
685
|
+
if (queryStart === -1) {
|
|
686
|
+
return { base: connectionString, fragment: "", params: new URLSearchParams };
|
|
687
|
+
}
|
|
688
|
+
const base = connectionString.slice(0, queryStart);
|
|
689
|
+
const queryAndFragment = connectionString.slice(queryStart + 1);
|
|
690
|
+
const fragmentStart = queryAndFragment.indexOf("#");
|
|
691
|
+
const query = fragmentStart === -1 ? queryAndFragment : queryAndFragment.slice(0, fragmentStart);
|
|
692
|
+
const fragment = fragmentStart === -1 ? "" : queryAndFragment.slice(fragmentStart);
|
|
693
|
+
return { base, fragment, params: new URLSearchParams(query) };
|
|
694
|
+
}
|
|
695
|
+
function tlsQueryValues(connectionString) {
|
|
696
|
+
const values = new Map;
|
|
697
|
+
for (const [key, value] of connectionStringParts(connectionString).params) {
|
|
698
|
+
const normalized = key.toLowerCase();
|
|
699
|
+
if (PG_TLS_QUERY_PARAMETERS.has(normalized))
|
|
700
|
+
values.set(normalized, value);
|
|
701
|
+
}
|
|
702
|
+
return values;
|
|
703
|
+
}
|
|
704
|
+
function connectionStringWithoutTlsParameters(connectionString) {
|
|
705
|
+
const { base, fragment, params } = connectionStringParts(connectionString);
|
|
706
|
+
for (const key of [...params.keys()]) {
|
|
707
|
+
if (PG_TLS_QUERY_PARAMETERS.has(key.toLowerCase()))
|
|
708
|
+
params.delete(key);
|
|
709
|
+
}
|
|
710
|
+
const query = params.toString();
|
|
711
|
+
return `${base}${query ? `?${query}` : ""}${fragment}`;
|
|
712
|
+
}
|
|
713
|
+
function rawSslMode(values) {
|
|
714
|
+
const raw = values.get("sslmode");
|
|
715
|
+
return raw === undefined ? undefined : raw.trim().toLowerCase();
|
|
716
|
+
}
|
|
717
|
+
function sslNegotiationFromConnectionString(connectionString) {
|
|
718
|
+
const value = tlsQueryValues(connectionString).get("sslnegotiation")?.trim().toLowerCase();
|
|
719
|
+
if (!value)
|
|
720
|
+
return;
|
|
721
|
+
if (value === "postgres" || value === "direct")
|
|
722
|
+
return value;
|
|
723
|
+
throw new Error(`Unknown sslnegotiation '${value}' in connection string; expected postgres or direct.`);
|
|
724
|
+
}
|
|
725
|
+
function sslModeFromConnectionString(connectionString) {
|
|
726
|
+
const values = tlsQueryValues(connectionString);
|
|
727
|
+
const sslmode = rawSslMode(values);
|
|
728
|
+
if (sslmode !== undefined) {
|
|
729
|
+
const resolved = SSLMODE_VALUES.get(sslmode);
|
|
730
|
+
if (resolved)
|
|
731
|
+
return resolved;
|
|
732
|
+
throw new Error(`Unknown sslmode '${sslmode}' in connection string; expected one of ` + `${[...SSLMODE_VALUES.keys()].join(", ")}. Remove the parameter entirely to defer to ` + `PGSSLMODE \u2014 an empty value is not how that is spelled.`);
|
|
733
|
+
}
|
|
734
|
+
if (values.has("ssl")) {
|
|
735
|
+
const ssl = values.get("ssl")?.trim().toLowerCase() ?? "";
|
|
736
|
+
if (EXPLICIT_SSL_ON_VALUES.has(ssl))
|
|
737
|
+
return "require";
|
|
738
|
+
if (!EXPLICIT_SSL_OFF_VALUES.has(ssl)) {
|
|
739
|
+
throw new Error(`Unknown ssl value '${ssl}' in connection string.`);
|
|
452
740
|
}
|
|
741
|
+
return "disable";
|
|
453
742
|
}
|
|
454
|
-
const
|
|
455
|
-
if (
|
|
743
|
+
const sslnegotiation = values.get("sslnegotiation")?.trim().toLowerCase();
|
|
744
|
+
if (sslnegotiation === "direct")
|
|
456
745
|
return "require";
|
|
457
746
|
return "disable";
|
|
458
747
|
}
|
|
459
|
-
function loadCaBundle(options) {
|
|
460
|
-
const env = options
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
748
|
+
function loadCaBundle(connectionString, options) {
|
|
749
|
+
const env = ownProp(options, "env") ?? process.env;
|
|
750
|
+
const ca = ownString(options, "ca");
|
|
751
|
+
if (ca && ca.trim())
|
|
752
|
+
return ca;
|
|
753
|
+
const sslRootCert = tlsQueryValues(connectionString).get("sslrootcert")?.trim();
|
|
754
|
+
const path = ownString(options, "caCertPath") ?? (sslRootCert ? sslRootCert : undefined) ?? ownString(env, "PGSSLROOTCERT") ?? ownString(env, "NODE_EXTRA_CA_CERTS");
|
|
464
755
|
if (path && path.trim())
|
|
465
756
|
return readFileSync(path.trim(), "utf8");
|
|
466
757
|
return null;
|
|
467
758
|
}
|
|
759
|
+
function loadClientCertificate(connectionString) {
|
|
760
|
+
const values = tlsQueryValues(connectionString);
|
|
761
|
+
const material = {};
|
|
762
|
+
const certPath = values.get("sslcert")?.trim();
|
|
763
|
+
if (certPath)
|
|
764
|
+
material.cert = readFileSync(certPath, "utf8");
|
|
765
|
+
const keyPath = values.get("sslkey")?.trim();
|
|
766
|
+
if (keyPath)
|
|
767
|
+
material.key = readFileSync(keyPath, "utf8");
|
|
768
|
+
const passphrase = values.get("sslpassword");
|
|
769
|
+
if (passphrase)
|
|
770
|
+
material.passphrase = passphrase;
|
|
771
|
+
return material;
|
|
772
|
+
}
|
|
468
773
|
function resolveTlsConfig(connectionString, options = {}) {
|
|
469
774
|
const mode = sslModeFromConnectionString(connectionString);
|
|
470
|
-
if (mode === "disable"
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
return
|
|
775
|
+
if (mode === "disable") {
|
|
776
|
+
const values = tlsQueryValues(connectionString);
|
|
777
|
+
const sslmode = rawSslMode(values);
|
|
778
|
+
const ssl = values.get("ssl")?.trim().toLowerCase();
|
|
779
|
+
const explicitlyOff = sslmode === "disable" || ssl !== undefined && EXPLICIT_SSL_OFF_VALUES.has(ssl);
|
|
780
|
+
return explicitlyOff ? false : undefined;
|
|
781
|
+
}
|
|
782
|
+
const ca = loadCaBundle(connectionString, options);
|
|
783
|
+
const clientCertificate = loadClientCertificate(connectionString);
|
|
784
|
+
if (mode === "prefer" || mode === "require") {
|
|
785
|
+
return { rejectUnauthorized: true, ...ca ? { ca } : {}, ...clientCertificate };
|
|
476
786
|
}
|
|
477
787
|
if (!ca) {
|
|
478
788
|
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
789
|
}
|
|
480
|
-
return { rejectUnauthorized: true, ca };
|
|
790
|
+
return { rejectUnauthorized: true, ca, ...clientCertificate };
|
|
481
791
|
}
|
|
482
792
|
// src/generated/storage-kit/query.ts
|
|
483
793
|
function wrapExecutor(executor) {
|
|
@@ -534,44 +844,72 @@ function createQueryClient(pool) {
|
|
|
534
844
|
}
|
|
535
845
|
// src/generated/storage-kit/pool.ts
|
|
536
846
|
import pg from "pg";
|
|
847
|
+
function ownPoolOptions(options) {
|
|
848
|
+
const own = Object.create(null);
|
|
849
|
+
const ca = ownString(options, "ca");
|
|
850
|
+
if (ca !== undefined)
|
|
851
|
+
own.ca = ca;
|
|
852
|
+
const caCertPath = ownString(options, "caCertPath");
|
|
853
|
+
if (caCertPath !== undefined)
|
|
854
|
+
own.caCertPath = caCertPath;
|
|
855
|
+
const env = ownProp(options, "env");
|
|
856
|
+
if (env !== undefined)
|
|
857
|
+
own.env = env;
|
|
858
|
+
const max = ownProp(options, "max");
|
|
859
|
+
if (max !== undefined)
|
|
860
|
+
own.max = max;
|
|
861
|
+
const idleTimeoutMillis = ownProp(options, "idleTimeoutMillis");
|
|
862
|
+
if (idleTimeoutMillis !== undefined)
|
|
863
|
+
own.idleTimeoutMillis = idleTimeoutMillis;
|
|
864
|
+
const connectionTimeoutMillis = ownProp(options, "connectionTimeoutMillis");
|
|
865
|
+
if (connectionTimeoutMillis !== undefined)
|
|
866
|
+
own.connectionTimeoutMillis = connectionTimeoutMillis;
|
|
867
|
+
const applicationName = ownString(options, "applicationName");
|
|
868
|
+
if (applicationName !== undefined)
|
|
869
|
+
own.applicationName = applicationName;
|
|
870
|
+
return own;
|
|
871
|
+
}
|
|
537
872
|
function createPgPool(options) {
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
873
|
+
const connectionString = ownString(options, "connectionString");
|
|
874
|
+
if (!connectionString || !connectionString.trim()) {
|
|
875
|
+
throw new Error("createPgPool requires an own `connectionString` on the options object.");
|
|
876
|
+
}
|
|
877
|
+
const own = ownPoolOptions(options);
|
|
878
|
+
const ssl = resolveTlsConfig(connectionString, {
|
|
879
|
+
...own.ca !== undefined ? { ca: own.ca } : {},
|
|
880
|
+
...own.caCertPath !== undefined ? { caCertPath: own.caCertPath } : {},
|
|
881
|
+
...own.env !== undefined ? { env: own.env } : {}
|
|
542
882
|
});
|
|
543
|
-
const config = {
|
|
883
|
+
const config = {
|
|
884
|
+
connectionString: connectionStringWithoutTlsParameters(connectionString)
|
|
885
|
+
};
|
|
544
886
|
if (ssl !== undefined)
|
|
545
887
|
config.ssl = ssl;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
888
|
+
const sslnegotiation = sslNegotiationFromConnectionString(connectionString);
|
|
889
|
+
if (sslnegotiation !== undefined)
|
|
890
|
+
config.sslnegotiation = sslnegotiation;
|
|
891
|
+
if (own.max !== undefined)
|
|
892
|
+
config.max = own.max;
|
|
893
|
+
if (own.idleTimeoutMillis !== undefined)
|
|
894
|
+
config.idleTimeoutMillis = own.idleTimeoutMillis;
|
|
895
|
+
if (own.connectionTimeoutMillis !== undefined)
|
|
896
|
+
config.connectionTimeoutMillis = own.connectionTimeoutMillis;
|
|
897
|
+
if (own.applicationName !== undefined)
|
|
898
|
+
config.application_name = own.applicationName;
|
|
554
899
|
return new pg.Pool(config);
|
|
555
900
|
}
|
|
556
|
-
function
|
|
557
|
-
const
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
throw new Error(`createCloudPoolFromEnv requires ${appName} storage mode 'cloud', got '${resolution.mode}'. ` + `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=cloud.`);
|
|
561
|
-
}
|
|
901
|
+
function createServerPoolFromEnv(appName, options = {}) {
|
|
902
|
+
const own = ownPoolOptions(options);
|
|
903
|
+
const env = own.env ?? process.env;
|
|
904
|
+
const resolution = resolveServerDataBackend(appName, env);
|
|
562
905
|
const connectionString = resolveDatabaseUrl(appName, env);
|
|
563
906
|
if (!connectionString) {
|
|
564
|
-
throw new Error(`
|
|
907
|
+
throw new Error(`postgresql storage for ${appName} needs a database URL. Set ` + `HASNA_${appName.toUpperCase().replace(/-/g, "_")}_DATABASE_URL.`);
|
|
565
908
|
}
|
|
566
909
|
const pool = createPgPool({
|
|
910
|
+
...own,
|
|
567
911
|
connectionString,
|
|
568
|
-
|
|
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 } : {}
|
|
912
|
+
env
|
|
575
913
|
});
|
|
576
914
|
return {
|
|
577
915
|
client: createQueryClient(pool),
|
|
@@ -693,7 +1031,8 @@ function rowToDomain(row) {
|
|
|
693
1031
|
notes: row.notes,
|
|
694
1032
|
metadata: parseJson(row.metadata, {}),
|
|
695
1033
|
created_at: row.created_at,
|
|
696
|
-
updated_at: row.updated_at
|
|
1034
|
+
updated_at: row.updated_at,
|
|
1035
|
+
expiry_synced_at: row.expiry_synced_at ?? null
|
|
697
1036
|
};
|
|
698
1037
|
}
|
|
699
1038
|
function rowToDnsRecord(row) {
|
|
@@ -739,8 +1078,8 @@ class DomainsRepo {
|
|
|
739
1078
|
id, name, registrar, status, registered_at, expires_at, auto_renew,
|
|
740
1079
|
is_premium, premium_price, standard_price, purchase_price, purchase_date,
|
|
741
1080
|
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)
|
|
1081
|
+
created_at, updated_at, expiry_synced_at
|
|
1082
|
+
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
|
|
744
1083
|
RETURNING *`, [
|
|
745
1084
|
id,
|
|
746
1085
|
input.name.trim(),
|
|
@@ -761,7 +1100,8 @@ class DomainsRepo {
|
|
|
761
1100
|
input.notes ?? null,
|
|
762
1101
|
JSON.stringify(input.metadata ?? {}),
|
|
763
1102
|
nowIso,
|
|
764
|
-
nowIso
|
|
1103
|
+
nowIso,
|
|
1104
|
+
input.expiry_synced_at ?? null
|
|
765
1105
|
]);
|
|
766
1106
|
return rowToDomain(row);
|
|
767
1107
|
} catch (e) {
|
|
@@ -850,6 +1190,8 @@ class DomainsRepo {
|
|
|
850
1190
|
setCol("notes", p["notes"] ?? null);
|
|
851
1191
|
if ("metadata" in p)
|
|
852
1192
|
setCol("metadata", JSON.stringify(p["metadata"] ?? {}));
|
|
1193
|
+
if ("expiry_synced_at" in p)
|
|
1194
|
+
setCol("expiry_synced_at", p["expiry_synced_at"] ?? null);
|
|
853
1195
|
if (sets.length === 0)
|
|
854
1196
|
return existing;
|
|
855
1197
|
setCol("updated_at", new Date().toISOString());
|
|
@@ -880,7 +1222,15 @@ class DomainsRepo {
|
|
|
880
1222
|
count(*) FILTER (
|
|
881
1223
|
WHERE NULLIF(ssl_expires_at, '')::timestamptz
|
|
882
1224
|
BETWEEN now() AND now() + interval '30 days'
|
|
883
|
-
)::text AS ssl_expiring_30_days
|
|
1225
|
+
)::text AS ssl_expiring_30_days,
|
|
1226
|
+
count(*) FILTER (
|
|
1227
|
+
WHERE status = 'active'
|
|
1228
|
+
AND NULLIF(expires_at, '')::timestamptz < now()
|
|
1229
|
+
)::text AS past_expiry,
|
|
1230
|
+
count(*) FILTER (
|
|
1231
|
+
WHERE NULLIF(ssl_expires_at, '')::timestamptz < now()
|
|
1232
|
+
)::text AS ssl_past_expiry,
|
|
1233
|
+
count(*) FILTER (WHERE expiry_synced_at IS NULL)::text AS never_synced
|
|
884
1234
|
FROM domains`);
|
|
885
1235
|
const n = (k) => row && row[k] ? parseInt(row[k], 10) : 0;
|
|
886
1236
|
return {
|
|
@@ -891,7 +1241,10 @@ class DomainsRepo {
|
|
|
891
1241
|
redemption: n("redemption"),
|
|
892
1242
|
auto_renew_enabled: n("auto_renew_enabled"),
|
|
893
1243
|
expiring_30_days: n("expiring_30_days"),
|
|
894
|
-
ssl_expiring_30_days: n("ssl_expiring_30_days")
|
|
1244
|
+
ssl_expiring_30_days: n("ssl_expiring_30_days"),
|
|
1245
|
+
past_expiry: n("past_expiry"),
|
|
1246
|
+
ssl_past_expiry: n("ssl_past_expiry"),
|
|
1247
|
+
never_synced: n("never_synced")
|
|
895
1248
|
};
|
|
896
1249
|
}
|
|
897
1250
|
async listDnsRecords(domainId) {
|
|
@@ -1480,7 +1833,9 @@ var PG_MIGRATIONS = [
|
|
|
1480
1833
|
`CREATE INDEX IF NOT EXISTS idx_domain_history_created ON domain_history(created_at)`,
|
|
1481
1834
|
`CREATE INDEX IF NOT EXISTS idx_domain_history_email ON domain_history(registrant_email)`,
|
|
1482
1835
|
`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)
|
|
1836
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_reputation_blacklisted ON domain_reputation(is_blacklisted)`,
|
|
1837
|
+
`ALTER TABLE domains ADD COLUMN IF NOT EXISTS expiry_synced_at TEXT`,
|
|
1838
|
+
`CREATE INDEX IF NOT EXISTS idx_domains_expiry_synced_at ON domains(expiry_synced_at)`
|
|
1484
1839
|
];
|
|
1485
1840
|
|
|
1486
1841
|
// src/server/migrations.ts
|
|
@@ -1517,7 +1872,7 @@ function buildOpenApiSpec(version) {
|
|
|
1517
1872
|
info: {
|
|
1518
1873
|
title: "domains",
|
|
1519
1874
|
version,
|
|
1520
|
-
description: "Domain portfolio, registrar, marketplace, and DNS management HTTP API
|
|
1875
|
+
description: "Domain portfolio, registrar, marketplace, and DNS management HTTP API. API-key authenticated."
|
|
1521
1876
|
},
|
|
1522
1877
|
security: [{ apiKey: [] }],
|
|
1523
1878
|
paths: {
|
|
@@ -1671,29 +2026,26 @@ function buildOpenApiSpec(version) {
|
|
|
1671
2026
|
properties: {
|
|
1672
2027
|
status: { type: "string" },
|
|
1673
2028
|
version: { type: "string" },
|
|
1674
|
-
mode: { type: "string" },
|
|
1675
2029
|
latencyMs: { type: "number" }
|
|
1676
2030
|
},
|
|
1677
|
-
required: ["status", "version"
|
|
2031
|
+
required: ["status", "version"]
|
|
1678
2032
|
},
|
|
1679
2033
|
ReadyResponse: {
|
|
1680
2034
|
type: "object",
|
|
1681
2035
|
properties: {
|
|
1682
2036
|
status: { type: "string" },
|
|
1683
2037
|
version: { type: "string" },
|
|
1684
|
-
mode: { type: "string" },
|
|
1685
2038
|
pendingMigrations: { type: "array", items: { type: "string" } }
|
|
1686
2039
|
},
|
|
1687
|
-
required: ["status", "version"
|
|
2040
|
+
required: ["status", "version"]
|
|
1688
2041
|
},
|
|
1689
2042
|
VersionResponse: {
|
|
1690
2043
|
type: "object",
|
|
1691
2044
|
properties: {
|
|
1692
2045
|
status: { type: "string" },
|
|
1693
|
-
version: { type: "string" }
|
|
1694
|
-
mode: { type: "string" }
|
|
2046
|
+
version: { type: "string" }
|
|
1695
2047
|
},
|
|
1696
|
-
required: ["status", "version"
|
|
2048
|
+
required: ["status", "version"]
|
|
1697
2049
|
},
|
|
1698
2050
|
DeleteResult: {
|
|
1699
2051
|
type: "object",
|
|
@@ -1891,14 +2243,13 @@ function json(data, status = 200) {
|
|
|
1891
2243
|
}
|
|
1892
2244
|
function createServeApp(options) {
|
|
1893
2245
|
const { db, version } = options;
|
|
1894
|
-
const mode2 = options.mode ?? "self_hosted";
|
|
1895
2246
|
const repo = new DomainsRepo(db);
|
|
1896
2247
|
const migrationIds = buildMigrations().map((m) => m.id);
|
|
1897
2248
|
const spec = buildOpenApiSpec(version);
|
|
1898
2249
|
const verifier = verifyApiKey({
|
|
1899
2250
|
app: "domains",
|
|
1900
2251
|
signingSecret: options.signingSecret,
|
|
1901
|
-
|
|
2252
|
+
keyStatus: options.keyStatus,
|
|
1902
2253
|
...options.audit ? { audit: options.audit } : {}
|
|
1903
2254
|
});
|
|
1904
2255
|
async function readBody(req) {
|
|
@@ -1928,20 +2279,19 @@ function createServeApp(options) {
|
|
|
1928
2279
|
try {
|
|
1929
2280
|
if (method === "GET" && path === "/health") {
|
|
1930
2281
|
const h = await checkHealth(db);
|
|
1931
|
-
return json({ status: h.ok ? "ok" : "error", version,
|
|
2282
|
+
return json({ status: h.ok ? "ok" : "error", version, latencyMs: h.latencyMs, ...h.error ? { error: h.error } : {} }, h.ok ? 200 : 503);
|
|
1932
2283
|
}
|
|
1933
2284
|
if (method === "GET" && path === "/ready") {
|
|
1934
2285
|
const r = await readReadiness(db, migrationIds);
|
|
1935
2286
|
return json({
|
|
1936
2287
|
status: r.ok ? "ok" : "not_ready",
|
|
1937
2288
|
version,
|
|
1938
|
-
mode: mode2,
|
|
1939
2289
|
pendingMigrations: r.pendingMigrations,
|
|
1940
2290
|
...r.error ? { error: r.error } : {}
|
|
1941
2291
|
}, r.ok ? 200 : 503);
|
|
1942
2292
|
}
|
|
1943
2293
|
if (method === "GET" && (path === "/version" || path === "/v1/version")) {
|
|
1944
|
-
return json({ status: "ok", version
|
|
2294
|
+
return json({ status: "ok", version });
|
|
1945
2295
|
}
|
|
1946
2296
|
if (method === "GET" && (path === "/openapi.json" || path === "/v1/openapi.json")) {
|
|
1947
2297
|
return json(spec);
|
|
@@ -2312,9 +2662,6 @@ function normalizeEnv(env = process.env) {
|
|
|
2312
2662
|
if (!env["HASNA_DOMAINS_DATABASE_URL"] && env["DATABASE_URL"]) {
|
|
2313
2663
|
env["HASNA_DOMAINS_DATABASE_URL"] = env["DATABASE_URL"];
|
|
2314
2664
|
}
|
|
2315
|
-
if (!env["HASNA_DOMAINS_STORAGE_MODE"] && env["HASNA_DOMAINS_DATABASE_URL"]) {
|
|
2316
|
-
env["HASNA_DOMAINS_STORAGE_MODE"] = "cloud";
|
|
2317
|
-
}
|
|
2318
2665
|
}
|
|
2319
2666
|
function resolveSigningSecret(env = process.env) {
|
|
2320
2667
|
for (const key of SIGNING_KEY_ENVS) {
|
|
@@ -2341,7 +2688,7 @@ async function main() {
|
|
|
2341
2688
|
normalizeEnv();
|
|
2342
2689
|
const version = getPackageVersion();
|
|
2343
2690
|
const signingSecret = resolveSigningSecret();
|
|
2344
|
-
const { client, connectionSource } =
|
|
2691
|
+
const { client, connectionSource } = createServerPoolFromEnv("domains", {
|
|
2345
2692
|
applicationName: "domains-serve",
|
|
2346
2693
|
max: 5
|
|
2347
2694
|
});
|
|
@@ -2350,8 +2697,7 @@ async function main() {
|
|
|
2350
2697
|
db: client,
|
|
2351
2698
|
signingSecret,
|
|
2352
2699
|
version,
|
|
2353
|
-
|
|
2354
|
-
isRevoked: store.isRevoked,
|
|
2700
|
+
keyStatus: store.keyStatus,
|
|
2355
2701
|
audit: (e) => {
|
|
2356
2702
|
if (e.outcome === "deny") {
|
|
2357
2703
|
console.error(JSON.stringify({ level: "warn", event: "api_auth_deny", ...e }));
|