landvex: Fixar och tester klara för alla komponenter

- Datafabrik: Dockerfile fix, agentorkestrering fungerar
- Vision: Identify-modell, FAISS, OCR alla testade
- API: Alla 7 integrationstester passerade
- Upplösare: Entitetsupplösning verifierad
This commit is contained in:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
+148 -1
View File
@@ -8,7 +8,8 @@
"name": "@landvex/infrastructure",
"version": "0.1.0",
"dependencies": {
"@landvex/domain": "file:../domain"
"@landvex/domain": "file:../domain",
"pg": "^8.22.0"
},
"devDependencies": {
"@types/jest": "^29.5.0",
@@ -3154,6 +3155,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3197,6 +3287,45 @@
"node": ">=8"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -3406,6 +3535,15 @@
"source-map": "^0.6.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -3817,6 +3955,15 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+2 -1
View File
@@ -10,7 +10,8 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@landvex/domain": "file:../domain"
"@landvex/domain": "file:../domain",
"pg": "^8.22.0"
},
"devDependencies": {
"@types/jest": "^29.5.0",
@@ -0,0 +1,125 @@
/**
* In-Memory API Key Repository
*
* For testing and development. Fast, isolated, no external dependencies.
*/
import { ApiKey, ApiKeyId } from '../../../api/src/models/api-key';
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../repositories/api-key-repository';
export class InMemoryApiKeyRepository implements ApiKeyRepository {
private keys = new Map<string, ApiKey>();
async save(apiKey: ApiKey): Promise<void> {
this.keys.set(apiKey.id, apiKey);
}
async findById(id: ApiKeyId): Promise<ApiKey | null> {
return this.keys.get(id) ?? null;
}
async findByKeyHash(keyHash: string): Promise<ApiKey | null> {
return Array.from(this.keys.values()).find(k => k.keyHash === keyHash) ?? null;
}
async findAll(options?: { limit?: number; offset?: number; status?: string }): Promise<ApiKey[]> {
let results = Array.from(this.keys.values());
if (options?.status) {
results = results.filter(k => k.status === options.status);
}
return results
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(options?.offset ?? 0, (options?.offset ?? 0) + (options?.limit ?? 100));
}
async count(options?: { status?: string }): Promise<number> {
if (options?.status) {
return Array.from(this.keys.values()).filter(k => k.status === options.status).length;
}
return this.keys.size;
}
async update(apiKey: ApiKey): Promise<void> {
this.keys.set(apiKey.id, apiKey);
}
async delete(id: ApiKeyId): Promise<void> {
this.keys.delete(id);
}
clear(): void {
this.keys.clear();
}
}
export class InMemoryApiKeyUsageRepository implements ApiKeyUsageRepository {
private usages: Array<{
apiKeyId: ApiKeyId;
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
clientIp?: string;
}> = [];
async recordUsage(usage: {
apiKeyId: ApiKeyId;
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
clientIp?: string;
}): Promise<void> {
this.usages.push(usage);
}
async getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
totalRequests: number;
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
averageResponseTimeMs: number;
errorRate: number;
}> {
const relevant = this.usages.filter(u => u.apiKeyId === apiKeyId && u.timestamp >= since);
const total = relevant.length;
const errors = relevant.filter(u => u.statusCode >= 400).length;
return {
totalRequests: total,
requestsPerMinute: total, // Simplified for in-memory
requestsPerHour: total,
requestsPerDay: total,
averageResponseTimeMs: total > 0
? Math.round(relevant.reduce((sum, u) => sum + u.responseTimeMs, 0) / total)
: 0,
errorRate: total > 0 ? Math.round((errors / total) * 100) : 0,
};
}
async getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
}>> {
return this.usages
.filter(u => u.apiKeyId === apiKeyId)
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, limit)
.map(u => ({
timestamp: u.timestamp,
endpoint: u.endpoint,
method: u.method,
statusCode: u.statusCode,
responseTimeMs: u.responseTimeMs,
}));
}
clear(): void {
this.usages = [];
}
}
@@ -0,0 +1,66 @@
/**
* PostgreSQL API Key Repository
*
* Production-ready adapter for API key persistence.
* Uses pg client for database operations.
*/
import { ApiKey, ApiKeyId } from '../../../../api/src/models/api-key';
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../repositories/api-key-repository';
export interface PostgresApiKeyConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean | object;
}
export declare class PostgresApiKeyRepository implements ApiKeyRepository {
private pool;
constructor(config: PostgresApiKeyConfig);
init(): Promise<void>;
save(apiKey: ApiKey): Promise<void>;
findById(id: ApiKeyId): Promise<ApiKey | null>;
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
findAll(options?: {
limit?: number;
offset?: number;
status?: string;
}): Promise<ApiKey[]>;
count(options?: {
status?: string;
}): Promise<number>;
update(apiKey: ApiKey): Promise<void>;
delete(id: ApiKeyId): Promise<void>;
close(): Promise<void>;
}
export declare class PostgresApiKeyUsageRepository implements ApiKeyUsageRepository {
private pool;
constructor(config: PostgresApiKeyConfig);
init(): Promise<void>;
recordUsage(usage: {
apiKeyId: ApiKeyId;
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
clientIp?: string;
}): Promise<void>;
getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
totalRequests: number;
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
averageResponseTimeMs: number;
errorRate: number;
}>;
getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
}>>;
close(): Promise<void>;
}
//# sourceMappingURL=postgres-api-key-repository.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"postgres-api-key-repository.d.ts","sourceRoot":"","sources":["postgres-api-key-repository.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAe,MAAM,oCAAoC,CAAC;AACnF,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAEhG,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CACxB;AAkCD,qBAAa,wBAAyB,YAAW,gBAAgB;IAC/D,OAAO,CAAC,IAAI,CAAO;gBAEP,MAAM,EAAE,oBAAoB;IAIlC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAwCrB,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmCnC,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAS9C,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAStD,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAqB1F,KAAK,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAarD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,MAAM,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAInC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B;AAED,qBAAa,6BAA8B,YAAW,qBAAqB;IACzE,OAAO,CAAC,IAAI,CAAO;gBAEP,MAAM,EAAE,oBAAoB;IAIlC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA+BrB,WAAW,CAAC,KAAK,EAAE;QACvB,QAAQ,EAAE,QAAQ,CAAC;QACnB,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBX,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,GAAG,OAAO,CAAC;QAC5D,aAAa,EAAE,MAAM,CAAC;QACtB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;QACvB,qBAAqB,EAAE,MAAM,CAAC;QAC9B,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IAyBI,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QACrE,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC,CAAC;IAmBG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
@@ -0,0 +1,254 @@
"use strict";
/**
* PostgreSQL API Key Repository
*
* Production-ready adapter for API key persistence.
* Uses pg client for database operations.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.PostgresApiKeyUsageRepository = exports.PostgresApiKeyRepository = void 0;
const pg_1 = require("pg");
const api_key_1 = require("../../../../api/src/models/api-key");
function createPool(config) {
return new pg_1.Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
}
function rowToApiKey(row) {
return new api_key_1.ApiKey({
id: row.id,
name: row.name,
keyHash: row.key_hash,
keyPrefix: row.key_prefix,
status: row.status,
rateLimitPerMinute: row.rate_limit_per_minute,
rateLimitPerHour: row.rate_limit_per_hour,
rateLimitPerDay: row.rate_limit_per_day,
createdAt: row.created_at,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
rotatedFromId: row.rotated_from_id,
metadata: row.metadata ?? {},
});
}
class PostgresApiKeyRepository {
pool;
constructor(config) {
this.pool = createPool(config);
}
async init() {
const client = await this.pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
key_hash VARCHAR(255) NOT NULL UNIQUE,
key_prefix VARCHAR(16) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')),
rate_limit_per_minute INTEGER NOT NULL DEFAULT 60,
rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000,
rate_limit_per_day INTEGER NOT NULL DEFAULT 10000,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
rotated_from_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}',
CONSTRAINT valid_rate_limits CHECK (
rate_limit_per_minute > 0 AND
rate_limit_per_hour >= rate_limit_per_minute AND
rate_limit_per_day >= rate_limit_per_hour
)
)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_keys_created_at ON api_keys(created_at DESC)
`);
}
finally {
client.release();
}
}
async save(apiKey) {
const client = await this.pool.connect();
try {
await client.query(`INSERT INTO api_keys (
id, name, key_hash, key_prefix, status,
rate_limit_per_minute, rate_limit_per_hour, rate_limit_per_day,
created_at, expires_at, revoked_at, rotated_from_id, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
revoked_at = EXCLUDED.revoked_at,
metadata = EXCLUDED.metadata`, [
apiKey.id,
apiKey.name,
apiKey.keyHash,
apiKey.keyPrefix,
apiKey.status,
apiKey.rateLimitPerMinute,
apiKey.rateLimitPerHour,
apiKey.rateLimitPerDay,
apiKey.createdAt,
apiKey.expiresAt,
apiKey.revokedAt,
apiKey.rotatedFromId,
JSON.stringify(apiKey.metadata),
]);
}
finally {
client.release();
}
}
async findById(id) {
const result = await this.pool.query('SELECT * FROM api_keys WHERE id = $1', [id]);
if (result.rows.length === 0)
return null;
return rowToApiKey(result.rows[0]);
}
async findByKeyHash(keyHash) {
const result = await this.pool.query('SELECT * FROM api_keys WHERE key_hash = $1', [keyHash]);
if (result.rows.length === 0)
return null;
return rowToApiKey(result.rows[0]);
}
async findAll(options) {
const limit = options?.limit ?? 100;
const offset = options?.offset ?? 0;
const status = options?.status;
let query = 'SELECT * FROM api_keys';
const params = [];
if (status) {
query += ' WHERE status = $1';
params.push(status);
}
query += ' ORDER BY created_at DESC';
query += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
params.push(limit, offset);
const result = await this.pool.query(query, params);
return result.rows.map(rowToApiKey);
}
async count(options) {
let query = 'SELECT COUNT(*) FROM api_keys';
const params = [];
if (options?.status) {
query += ' WHERE status = $1';
params.push(options.status);
}
const result = await this.pool.query(query, params);
return parseInt(result.rows[0].count, 10);
}
async update(apiKey) {
await this.save(apiKey);
}
async delete(id) {
await this.pool.query('DELETE FROM api_keys WHERE id = $1', [id]);
}
async close() {
await this.pool.end();
}
}
exports.PostgresApiKeyRepository = PostgresApiKeyRepository;
class PostgresApiKeyUsageRepository {
pool;
constructor(config) {
this.pool = createPool(config);
}
async init() {
const client = await this.pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS api_key_usage (
id BIGSERIAL PRIMARY KEY,
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
endpoint VARCHAR(512) NOT NULL,
method VARCHAR(10) NOT NULL,
status_code INTEGER NOT NULL,
response_time_ms INTEGER NOT NULL,
client_ip INET
)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_id ON api_key_usage(api_key_id)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_key_usage_timestamp ON api_key_usage(timestamp DESC)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_timestamp
ON api_key_usage(api_key_id, timestamp DESC)
`);
}
finally {
client.release();
}
}
async recordUsage(usage) {
await this.pool.query(`INSERT INTO api_key_usage (
api_key_id, timestamp, endpoint, method, status_code, response_time_ms, client_ip
) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
usage.apiKeyId,
usage.timestamp,
usage.endpoint,
usage.method,
usage.statusCode,
usage.responseTimeMs,
usage.clientIp ?? null,
]);
}
async getUsageStats(apiKeyId, since) {
const result = await this.pool.query(`SELECT
COUNT(*) as total_requests,
AVG(response_time_ms) as avg_response_time,
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::FLOAT / NULLIF(COUNT(*), 0) as error_rate,
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 minute') as requests_per_minute,
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 hour') as requests_per_hour,
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 day') as requests_per_day
FROM api_key_usage
WHERE api_key_id = $1 AND timestamp >= $2`, [apiKeyId, since]);
const row = result.rows[0];
return {
totalRequests: parseInt(row.total_requests, 10),
requestsPerMinute: parseInt(row.requests_per_minute, 10),
requestsPerHour: parseInt(row.requests_per_hour, 10),
requestsPerDay: parseInt(row.requests_per_day, 10),
averageResponseTimeMs: Math.round(parseFloat(row.avg_response_time ?? '0')),
errorRate: Math.round((parseFloat(row.error_rate ?? '0') * 100)),
};
}
async getRecentUsage(apiKeyId, limit) {
const result = await this.pool.query(`SELECT timestamp, endpoint, method, status_code, response_time_ms
FROM api_key_usage
WHERE api_key_id = $1
ORDER BY timestamp DESC
LIMIT $2`, [apiKeyId, limit]);
return result.rows.map(row => ({
timestamp: row.timestamp,
endpoint: row.endpoint,
method: row.method,
statusCode: row.status_code,
responseTimeMs: row.response_time_ms,
}));
}
async close() {
await this.pool.end();
}
}
exports.PostgresApiKeyUsageRepository = PostgresApiKeyUsageRepository;
//# sourceMappingURL=postgres-api-key-repository.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,323 @@
/**
* PostgreSQL API Key Repository
*
* Production-ready adapter for API key persistence.
* Uses pg client for database operations.
*/
import { Pool, PoolClient } from 'pg';
import { ApiKey, ApiKeyId, ApiKeyProps } from '../../../../api/src/models/api-key';
import { ApiKeyRepository, ApiKeyUsageRepository } from '../../repositories/api-key-repository';
export interface PostgresApiKeyConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean | object;
}
function createPool(config: PostgresApiKeyConfig): Pool {
return new Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
}
function rowToApiKey(row: Record<string, unknown>): ApiKey {
return new ApiKey({
id: row.id as string,
name: row.name as string,
keyHash: row.key_hash as string,
keyPrefix: row.key_prefix as string,
status: row.status as ApiKeyProps['status'],
rateLimitPerMinute: row.rate_limit_per_minute as number,
rateLimitPerHour: row.rate_limit_per_hour as number,
rateLimitPerDay: row.rate_limit_per_day as number,
createdAt: row.created_at as Date,
expiresAt: row.expires_at as Date | null,
revokedAt: row.revoked_at as Date | null,
rotatedFromId: row.rotated_from_id as string | null,
metadata: (row.metadata as Record<string, unknown>) ?? {},
});
}
export class PostgresApiKeyRepository implements ApiKeyRepository {
private pool: Pool;
constructor(config: PostgresApiKeyConfig) {
this.pool = createPool(config);
}
async init(): Promise<void> {
const client = await this.pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
key_hash VARCHAR(255) NOT NULL UNIQUE,
key_prefix VARCHAR(16) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')),
rate_limit_per_minute INTEGER NOT NULL DEFAULT 60,
rate_limit_per_hour INTEGER NOT NULL DEFAULT 1000,
rate_limit_per_day INTEGER NOT NULL DEFAULT 10000,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
rotated_from_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}',
CONSTRAINT valid_rate_limits CHECK (
rate_limit_per_minute > 0 AND
rate_limit_per_hour >= rate_limit_per_minute AND
rate_limit_per_day >= rate_limit_per_hour
)
)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_keys_created_at ON api_keys(created_at DESC)
`);
} finally {
client.release();
}
}
async save(apiKey: ApiKey): Promise<void> {
const client = await this.pool.connect();
try {
await client.query(
`INSERT INTO api_keys (
id, name, key_hash, key_prefix, status,
rate_limit_per_minute, rate_limit_per_hour, rate_limit_per_day,
created_at, expires_at, revoked_at, rotated_from_id, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
revoked_at = EXCLUDED.revoked_at,
metadata = EXCLUDED.metadata`,
[
apiKey.id,
apiKey.name,
apiKey.keyHash,
apiKey.keyPrefix,
apiKey.status,
apiKey.rateLimitPerMinute,
apiKey.rateLimitPerHour,
apiKey.rateLimitPerDay,
apiKey.createdAt,
apiKey.expiresAt,
apiKey.revokedAt,
apiKey.rotatedFromId,
JSON.stringify(apiKey.metadata),
]
);
} finally {
client.release();
}
}
async findById(id: ApiKeyId): Promise<ApiKey | null> {
const result = await this.pool.query(
'SELECT * FROM api_keys WHERE id = $1',
[id]
);
if (result.rows.length === 0) return null;
return rowToApiKey(result.rows[0]);
}
async findByKeyHash(keyHash: string): Promise<ApiKey | null> {
const result = await this.pool.query(
'SELECT * FROM api_keys WHERE key_hash = $1',
[keyHash]
);
if (result.rows.length === 0) return null;
return rowToApiKey(result.rows[0]);
}
async findAll(options?: { limit?: number; offset?: number; status?: string }): Promise<ApiKey[]> {
const limit = options?.limit ?? 100;
const offset = options?.offset ?? 0;
const status = options?.status;
let query = 'SELECT * FROM api_keys';
const params: (string | number)[] = [];
if (status) {
query += ' WHERE status = $1';
params.push(status);
}
query += ' ORDER BY created_at DESC';
query += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
params.push(limit, offset);
const result = await this.pool.query(query, params);
return result.rows.map(rowToApiKey);
}
async count(options?: { status?: string }): Promise<number> {
let query = 'SELECT COUNT(*) FROM api_keys';
const params: string[] = [];
if (options?.status) {
query += ' WHERE status = $1';
params.push(options.status);
}
const result = await this.pool.query(query, params);
return parseInt(result.rows[0].count, 10);
}
async update(apiKey: ApiKey): Promise<void> {
await this.save(apiKey);
}
async delete(id: ApiKeyId): Promise<void> {
await this.pool.query('DELETE FROM api_keys WHERE id = $1', [id]);
}
async close(): Promise<void> {
await this.pool.end();
}
}
export class PostgresApiKeyUsageRepository implements ApiKeyUsageRepository {
private pool: Pool;
constructor(config: PostgresApiKeyConfig) {
this.pool = createPool(config);
}
async init(): Promise<void> {
const client = await this.pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS api_key_usage (
id BIGSERIAL PRIMARY KEY,
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
endpoint VARCHAR(512) NOT NULL,
method VARCHAR(10) NOT NULL,
status_code INTEGER NOT NULL,
response_time_ms INTEGER NOT NULL,
client_ip INET
)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_id ON api_key_usage(api_key_id)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_key_usage_timestamp ON api_key_usage(timestamp DESC)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_api_key_usage_api_key_timestamp
ON api_key_usage(api_key_id, timestamp DESC)
`);
} finally {
client.release();
}
}
async recordUsage(usage: {
apiKeyId: ApiKeyId;
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
clientIp?: string;
}): Promise<void> {
await this.pool.query(
`INSERT INTO api_key_usage (
api_key_id, timestamp, endpoint, method, status_code, response_time_ms, client_ip
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
usage.apiKeyId,
usage.timestamp,
usage.endpoint,
usage.method,
usage.statusCode,
usage.responseTimeMs,
usage.clientIp ?? null,
]
);
}
async getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
totalRequests: number;
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
averageResponseTimeMs: number;
errorRate: number;
}> {
const result = await this.pool.query(
`SELECT
COUNT(*) as total_requests,
AVG(response_time_ms) as avg_response_time,
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::FLOAT / NULLIF(COUNT(*), 0) as error_rate,
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 minute') as requests_per_minute,
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 hour') as requests_per_hour,
COUNT(*) FILTER (WHERE timestamp >= NOW() - INTERVAL '1 day') as requests_per_day
FROM api_key_usage
WHERE api_key_id = $1 AND timestamp >= $2`,
[apiKeyId, since]
);
const row = result.rows[0];
return {
totalRequests: parseInt(row.total_requests, 10),
requestsPerMinute: parseInt(row.requests_per_minute, 10),
requestsPerHour: parseInt(row.requests_per_hour, 10),
requestsPerDay: parseInt(row.requests_per_day, 10),
averageResponseTimeMs: Math.round(parseFloat(row.avg_response_time ?? '0')),
errorRate: Math.round((parseFloat(row.error_rate ?? '0') * 100)),
};
}
async getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
}>> {
const result = await this.pool.query(
`SELECT timestamp, endpoint, method, status_code, response_time_ms
FROM api_key_usage
WHERE api_key_id = $1
ORDER BY timestamp DESC
LIMIT $2`,
[apiKeyId, limit]
);
return result.rows.map(row => ({
timestamp: row.timestamp as Date,
endpoint: row.endpoint as string,
method: row.method as string,
statusCode: row.status_code as number,
responseTimeMs: row.response_time_ms as number,
}));
}
async close(): Promise<void> {
await this.pool.end();
}
}
+17
View File
@@ -36,3 +36,20 @@ export {
PostgresArtifactRegistry,
PostgresConfig,
} from './adapters/postgresql';
// API Key repositories
export {
ApiKeyRepository,
ApiKeyUsageRepository,
} from './repositories/api-key-repository';
export {
PostgresApiKeyRepository,
PostgresApiKeyUsageRepository,
PostgresApiKeyConfig,
} from './adapters/postgresql/postgres-api-key-repository';
export {
InMemoryApiKeyRepository,
InMemoryApiKeyUsageRepository,
} from './adapters/in-memory-api-key-repository';
@@ -0,0 +1,49 @@
/**
* API Key Repository Interface
*
* Defines WHAT the domain needs from API key persistence.
* Infrastructure adapters implement HOW.
*/
import { ApiKey, ApiKeyId } from '@landvex/domain';
export interface ApiKeyRepository {
save(apiKey: ApiKey): Promise<void>;
findById(id: ApiKeyId): Promise<ApiKey | null>;
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
findAll(options?: {
limit?: number;
offset?: number;
status?: string;
}): Promise<ApiKey[]>;
count(options?: {
status?: string;
}): Promise<number>;
update(apiKey: ApiKey): Promise<void>;
delete(id: ApiKeyId): Promise<void>;
}
export interface ApiKeyUsageRepository {
recordUsage(usage: {
apiKeyId: ApiKeyId;
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
clientIp?: string;
}): Promise<void>;
getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
totalRequests: number;
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
averageResponseTimeMs: number;
errorRate: number;
}>;
getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
}>>;
}
//# sourceMappingURL=api-key-repository.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"api-key-repository.d.ts","sourceRoot":"","sources":["api-key-repository.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/C,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD,OAAO,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC3F,KAAK,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,MAAM,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,KAAK,EAAE;QACjB,QAAQ,EAAE,QAAQ,CAAC;QACnB,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAElB,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,GAAG,OAAO,CAAC;QACtD,aAAa,EAAE,MAAM,CAAC;QACtB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;QACvB,qBAAqB,EAAE,MAAM,CAAC;QAC9B,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC,CAAC;IAEH,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAC/D,SAAS,EAAE,IAAI,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC,CAAC,CAAC;CACL"}
@@ -0,0 +1,9 @@
"use strict";
/**
* API Key Repository Interface
*
* Defines WHAT the domain needs from API key persistence.
* Infrastructure adapters implement HOW.
*/
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=api-key-repository.js.map
@@ -0,0 +1 @@
{"version":3,"file":"api-key-repository.js","sourceRoot":"","sources":["api-key-repository.ts"],"names":[],"mappings":";AAAA;;;;;GAKG"}
@@ -0,0 +1,47 @@
/**
* API Key Repository Interface
*
* Defines WHAT the domain needs from API key persistence.
* Infrastructure adapters implement HOW.
*/
import { ApiKey, ApiKeyId } from '@landvex/domain';
export interface ApiKeyRepository {
save(apiKey: ApiKey): Promise<void>;
findById(id: ApiKeyId): Promise<ApiKey | null>;
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
findAll(options?: { limit?: number; offset?: number; status?: string }): Promise<ApiKey[]>;
count(options?: { status?: string }): Promise<number>;
update(apiKey: ApiKey): Promise<void>;
delete(id: ApiKeyId): Promise<void>;
}
export interface ApiKeyUsageRepository {
recordUsage(usage: {
apiKeyId: ApiKeyId;
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
clientIp?: string;
}): Promise<void>;
getUsageStats(apiKeyId: ApiKeyId, since: Date): Promise<{
totalRequests: number;
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
averageResponseTimeMs: number;
errorRate: number;
}>;
getRecentUsage(apiKeyId: ApiKeyId, limit: number): Promise<Array<{
timestamp: Date;
endpoint: string;
method: string;
statusCode: number;
responseTimeMs: number;
}>>;
}