diff --git a/API.md b/API.md index 58efc16..412c3c9 100644 --- a/API.md +++ b/API.md @@ -231,3 +231,279 @@ sequentially. **Errors:** - `400` if `project_ids` is present but not an array of positive integers, or the JSON body is malformed. - `401` if `ADMIN_API_KEY` is set and the bearer token is missing/incorrect. + +--- + +## Investor Reporting Endpoints + +Endpoints designed for project investors to get dashboard data, performance reports, financial summaries, compliance status, and customized reporting. + +### `GET /v1/investor/dashboard` +Returns portfolio-wide aggregated dashboard data and recent audit logs. + +**Response `200`** +```json +{ + "portfolio_summary": { + "total_projects": 2, + "total_power_output_kw": 1150, + "avg_credit_quality": 85, + "avg_green_impact": 75, + "total_portfolio_value": 950000, + "total_carbon_offsets_tonnes": 4312.5 + }, + "recent_activities": [ + { + "id": 1, + "project_id": 1, + "credit_quality": 85, + "green_impact": 75, + "tx_hash": "tx123", + "triggered_by": "test", + "timestamp": 1718150400000 + } + ] +} +``` + +### `GET /v1/investor/performance-report` +Provides actual vs expected performance ratios and performance status for each project. + +**Response `200`** +```json +{ + "generated_at": 1718150400000, + "projects": [ + { + "project_id": 1, + "efficiency_pct": 82, + "power_output_kw": 550, + "ndvi_score": 0.75, + "actual_vs_expected_ratio": 0.55, + "performance_status": "Critical" + } + ] +} +``` + +### `GET /v1/investor/financial-summary` +Aggregates financial metrics like NPV, ROI, and payback period across the portfolio. + +**Response `200`** +```json +{ + "portfolio_financials": { + "total_installation_cost": 450000, + "total_npv": 120000, + "avg_payback_period_years": 6.8, + "avg_roi_pct": 14.5 + }, + "projects": [ + { + "project_id": 1, + "installation_cost": 150000, + "npv": 45000, + "payback_period_years": 6.2, + "roi_pct": 15.2 + } + ] +} +``` + +### `GET /v1/investor/compliance-report` +Provides ESG compliance, verified carbon credits, and audit trails. + +**Response `200`** +```json +{ + "portfolio_compliance": { + "portfolio_esg_score": 75, + "total_carbon_credits_issued": 20625, + "portfolio_status": "Compliant" + }, + "projects": [ + { + "project_id": 1, + "green_impact": 75, + "ndvi_score": 0.75, + "carbon_credits_issued": 20625, + "compliance_status": "Compliant" + } + ], + "audit_logs": [] +} +``` + +### `POST /v1/investor/custom-report` +Generates a custom report based on specific project IDs and sections. + +**Request Body** +```json +{ + "project_ids": [1], + "sections": ["performance", "scores"] +} +``` + +**Response `200`** +```json +{ + "generated_at": 1718150400000, + "project_count": 1, + "projects": [ + { + "project_id": 1, + "scores": { + "credit_quality": 85, + "green_impact": 75 + }, + "performance": { + "efficiency_pct": 82, + "power_output_kw": 550, + "actual_vs_expected_ratio": 0.55, + "performance_status": "Critical" + } + } + ] +} +``` + +--- + +## Consumer API Key Management + +Admin endpoints (protected by `ADMIN_API_KEY`) to manage credentials for external consumers. + +External consumers can authenticate to `/v1/*` endpoints using `Authorization: Bearer ` or `X-API-Key: `. + +### `POST /v1/admin/api-keys` +Generates a new consumer API key. + +**Request Body** +```json +{ + "consumer_name": "Third Party Service", + "rate_limit": 100 +} +``` + +**Response `201`** +```json +{ + "id": "e22709bf-6d60-449e-b9ef-2ea39544be6c", + "key": "hk_live_4a56ff0bc...", + "consumer_name": "Third Party Service", + "status": "active", + "rate_limit": 100, + "usage_count": 0, + "last_used_at": null, + "created_at": 1718150400000 +} +``` + +### `GET /v1/admin/api-keys` +Lists all generated consumer API keys. + +**Response `200`** +```json +{ + "count": 1, + "keys": [...] +} +``` + +### `POST /v1/admin/api-keys/:id/rotate` +Rotates a consumer's secret API key. + +**Response `200`** +```json +{ + "id": "e22709bf-6d60-449e-b9ef-2ea39544be6c", + "key": "hk_live_new_rotated_key_value...", + ... +} +``` + +### `DELETE /v1/admin/api-keys/:id` +Revokes an API key, preventing future access. + +**Response `200`** +```json +{ + "success": true, + "message": "API key revoked successfully" +} +``` + +### `GET /v1/admin/api-keys/:id/usage` +Retrieves usage metrics and rate limits. + +**Response `200`** +```json +{ + "id": "e22709bf-6d60-449e-b9ef-2ea39544be6c", + "consumer_name": "Third Party Service", + "usage_count": 42, + "last_used_at": 1718150410000, + "rate_limit": 100 +} +``` + +--- + +## GraphQL API + +Flexible querying interface served alongside the REST API. Authenticated with either admin or consumer key. + +- **HTTP Endpoint**: `/graphql` (POST requests) +- **GraphiQL Playground**: `/graphql-playground` (GET request via browser) + +### Example Query +```graphql +query { + projects(limit: 5) { + id + credit_quality + green_impact + solar { + power_output_kw + efficiency_pct + } + financials { + npv + roi_pct + } + } +} +``` + +### Example Mutation (Requires `ADMIN_API_KEY`) +```graphql +mutation { + updateProjectScores(id: "1", creditQuality: 90, greenImpact: 85) { + id + credit_quality + green_impact + } +} +``` + +--- + +## gRPC Service + +High-performance gRPC interface listening on port `50051`. Authenticates callers via metadata (headers: `authorization` or `x-api-key`). + +### Service Definition +```protobuf +service HeliobondService { + rpc GetProjectScore(ProjectRequest) returns (ProjectResponse); + rpc StreamProjectScores(StreamRequest) returns (stream ProjectResponse); + rpc ChatProjectScores(stream ProjectRequest) returns (stream ProjectResponse); +} +``` + +- **`GetProjectScore`**: Unary call to retrieve a project's latest stats and scores. +- **`StreamProjectScores`**: Server-side streaming of project updates as they occur. +- **`ChatProjectScores`**: Bidirectional stream enabling clients to send project IDs and receive live updates back. + diff --git a/package-lock.json b/package-lock.json index 7e00440..77f61d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,19 @@ "name": "heliobond-backend", "version": "1.0.0", "dependencies": { + "@grpc/grpc-js": "^1.14.4", + "@grpc/proto-loader": "^0.8.1", "@stellar/stellar-sdk": "^15.1.0", - "@types/swagger-ui-express": "^4.1.8", "cors": "^2.8.6", + "dataloader": "^2.2.3", "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^8.5.2", + "graphql": "^16.14.2", + "graphql-http": "^1.22.4", "node-cron": "^4.2.1", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "ws": "^8.21.0" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -24,6 +29,8 @@ "@types/node": "^25.9.3", "@types/node-cron": "^3.0.11", "@types/supertest": "^7.2.0", + "@types/swagger-ui-express": "^4.1.8", + "@types/ws": "^8.18.1", "jest": "^30.4.2", "supertest": "^7.2.2", "ts-jest": "^29.4.11", @@ -493,6 +500,71 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -958,6 +1030,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/curves": { "version": "1.9.7", "license": "MIT", @@ -1009,6 +1110,63 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -1101,6 +1259,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -1140,6 +1309,7 @@ }, "node_modules/@types/body-parser": { "version": "1.19.6", + "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1148,6 +1318,7 @@ }, "node_modules/@types/connect": { "version": "3.4.38", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1168,6 +1339,7 @@ }, "node_modules/@types/express": { "version": "5.0.6", + "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1177,6 +1349,7 @@ }, "node_modules/@types/express-serve-static-core": { "version": "5.1.1", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1187,6 +1360,7 @@ }, "node_modules/@types/http-errors": { "version": "2.0.5", + "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -1238,14 +1412,17 @@ }, "node_modules/@types/qs": { "version": "6.15.1", + "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", + "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.1", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1253,6 +1430,7 @@ }, "node_modules/@types/serve-static": { "version": "2.2.0", + "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1288,12 +1466,23 @@ "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, "license": "MIT", "dependencies": { "@types/express": "*", "@types/serve-static": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "dev": true, @@ -1312,6 +1501,48 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/@unrs/resolver-binding-darwin-x64": { "version": "1.12.2", "cpu": [ @@ -1324,6 +1555,263 @@ "darwin" ] }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/accepts": { "version": "2.0.0", "license": "MIT", @@ -1384,7 +1872,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1857,7 +2344,6 @@ }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -1884,7 +2370,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1895,7 +2380,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2003,6 +2487,12 @@ "node": ">= 8" } }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -2142,7 +2632,6 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -2199,7 +2688,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2548,7 +3036,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -2640,6 +3127,30 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-http": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", + "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/handlebars": { "version": "4.7.9", "dev": true, @@ -2844,7 +3355,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3563,11 +4073,23 @@ "node": ">=8" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -4016,6 +4538,29 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -4103,7 +4648,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4409,7 +4953,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4436,7 +4979,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4738,6 +5280,14 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-detect": { "version": "4.0.8", "dev": true, @@ -4973,7 +5523,6 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -5020,7 +5569,6 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5048,9 +5596,29 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -5063,7 +5631,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -5080,7 +5647,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index fbea644..7f91f05 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,16 @@ "test": "jest" }, "dependencies": { + "@grpc/grpc-js": "^1.14.4", + "@grpc/proto-loader": "^0.8.1", "@stellar/stellar-sdk": "^15.1.0", "cors": "^2.8.6", + "dataloader": "^2.2.3", "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^8.5.2", + "graphql": "^16.14.2", + "graphql-http": "^1.22.4", "node-cron": "^4.2.1", "swagger-ui-express": "^5.0.1", "ws": "^8.21.0" diff --git a/src/__tests__/apiKeys.test.ts b/src/__tests__/apiKeys.test.ts new file mode 100644 index 0000000..33f3440 --- /dev/null +++ b/src/__tests__/apiKeys.test.ts @@ -0,0 +1,142 @@ +import express from "express"; +import request from "supertest"; +import { apiKeyAuth, AuthenticatedRequest } from "../middleware/apiKeyAuth"; +import { clearApiKeys, generateApiKey, validateApiKey } from "../lib/apiKeys"; +import apiKeysRouter from "../routes/apiKeys"; + +describe("API Key Management and Authentication", () => { + let originalAdminKey: string | undefined; + + beforeAll(() => { + originalAdminKey = process.env.ADMIN_API_KEY; + process.env.ADMIN_API_KEY = "admin-secret-key"; + }); + + afterAll(() => { + process.env.ADMIN_API_KEY = originalAdminKey; + }); + + beforeEach(() => { + clearApiKeys(); + }); + + describe("API Key Admin Routes", () => { + const app = express(); + app.use(express.json()); + app.use("/admin/api-keys", apiKeysRouter); + + it("should reject unauthorized requests to key management", async () => { + const res = await request(app) + .post("/admin/api-keys") + .send({ consumer_name: "Test Consumer" }); + expect(res.status).toBe(401); + }); + + it("should allow key generation when authorized as admin", async () => { + const res = await request(app) + .post("/admin/api-keys") + .set("Authorization", "Bearer admin-secret-key") + .send({ consumer_name: "Test Consumer", rate_limit: 5 }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("id"); + expect(res.body).toHaveProperty("key"); + expect(res.body.consumer_name).toBe("Test Consumer"); + expect(res.body.rate_limit).toBe(5); + }); + + it("should list generated keys", async () => { + generateApiKey("Consumer 1"); + generateApiKey("Consumer 2"); + + const res = await request(app) + .get("/admin/api-keys") + .set("Authorization", "Bearer admin-secret-key"); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + expect(res.body.keys.length).toBe(2); + }); + + it("should rotate an existing key", async () => { + const key = generateApiKey("Consumer To Rotate"); + const oldKeyString = key.key; + + const res = await request(app) + .post(`/admin/api-keys/${key.id}/rotate`) + .set("Authorization", "Bearer admin-secret-key"); + + expect(res.status).toBe(200); + expect(res.body.key).not.toBe(oldKeyString); + expect(validateApiKey(oldKeyString)).toBeNull(); + expect(validateApiKey(res.body.key)).not.toBeNull(); + }); + + it("should revoke an existing key", async () => { + const key = generateApiKey("Consumer To Revoke"); + + const res = await request(app) + .delete(`/admin/api-keys/${key.id}`) + .set("Authorization", "Bearer admin-secret-key"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(validateApiKey(key.key)).toBeNull(); + }); + + it("should return usage stats", async () => { + const key = generateApiKey("Consumer Stats", 10); + + const res = await request(app) + .get(`/admin/api-keys/${key.id}/usage`) + .set("Authorization", "Bearer admin-secret-key"); + + expect(res.status).toBe(200); + expect(res.body.usage_count).toBe(0); + expect(res.body.rate_limit).toBe(10); + }); + }); + + describe("API Key Authentication Middleware", () => { + const app = express(); + app.use(express.json()); + app.get("/protected", apiKeyAuth as any, (req: AuthenticatedRequest, res: Response | any) => { + res.json({ success: true, consumer: req.apiKeyInfo?.consumer_name }); + }); + + it("should block requests with no key", async () => { + const res = await request(app).get("/protected"); + expect(res.status).toBe(401); + expect(res.body.error).toBe("unauthorized"); + }); + + it("should accept valid consumer key and record usage", async () => { + const key = generateApiKey("Authenticated Consumer"); + + const res = await request(app) + .get("/protected") + .set("X-API-Key", key.key); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.consumer).toBe("Authenticated Consumer"); + + expect(validateApiKey(key.key)?.usage_count).toBe(1); + }); + + it("should enforce rate limits per key", async () => { + const key = generateApiKey("Rate Limited Consumer", 3); + + // Request 1, 2, 3 -> Allowed + await request(app).get("/protected").set("X-API-Key", key.key); + await request(app).get("/protected").set("X-API-Key", key.key); + const res3 = await request(app).get("/protected").set("X-API-Key", key.key); + expect(res3.status).toBe(200); + + // Request 4 -> Blocked (429) + const res4 = await request(app).get("/protected").set("X-API-Key", key.key); + expect(res4.status).toBe(429); + expect(res4.body.error).toBe("too_many_requests"); + }); + }); +}); diff --git a/src/__tests__/graphql.test.ts b/src/__tests__/graphql.test.ts new file mode 100644 index 0000000..ce4396e --- /dev/null +++ b/src/__tests__/graphql.test.ts @@ -0,0 +1,180 @@ +import request from "supertest"; +import express, { Express } from "express"; +import { createHandler } from "graphql-http/lib/use/express"; +import { graphqlSchema, graphqlRoot, createGraphQLContext } from "../graphql/schema"; +import * as registry from "../lib/registry"; +import * as iot from "../routes/iot"; +import * as scoring from "../lib/scoring"; +import { clearApiKeys, generateApiKey } from "../lib/apiKeys"; +import { errorHandler } from "../middleware/errors"; + +jest.mock("../lib/registry", () => ({ + getTotalProjects: jest.fn(), + updateImpactScore: jest.fn(), +})); +jest.mock("../routes/iot"); +jest.mock("../lib/scoring"); + +function buildApp(): Express { + const app = express(); + app.use(express.json()); + + app.all( + "/graphql", + createHandler({ + schema: graphqlSchema, + rootValue: graphqlRoot, + context: (req: any) => createGraphQLContext(req.raw) as any, + }) + ); + + app.use(errorHandler); + return app; +} + +describe("GraphQL API Integration", () => { + let app: Express; + let consumerKey: string; + + beforeAll(() => { + process.env.ADMIN_API_KEY = "admin-secret-key"; + }); + + beforeEach(() => { + app = buildApp(); + jest.clearAllMocks(); + clearApiKeys(); + + const key = generateApiKey("GraphQL Consumer"); + consumerKey = key.key; + + (registry.getTotalProjects as jest.Mock).mockResolvedValue(2); + (registry.updateImpactScore as jest.Mock).mockResolvedValue("tx-graphql"); + (iot.getSolarData as jest.Mock).mockImplementation((id: number) => ({ + power_output_kw: 600, + efficiency_pct: 82, + max_power_kw: 1000, + timestamp: Date.now(), + })); + (iot.getSatelliteData as jest.Mock).mockImplementation((id: number) => ({ + forest_density_pct: 75, + ndvi_score: 0.75, + timestamp: Date.now(), + })); + (scoring.computeScores as jest.Mock).mockImplementation(() => ({ + credit_quality: 88, + green_impact: 78, + })); + }); + + it("should reject queries without a valid API key", async () => { + const query = ` + query { + projects { + id + } + } + `; + + const res = await request(app) + .post("/graphql") + .send({ query }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toContain("Unauthorized"); + }); + + it("should allow querying projects with a consumer API key", async () => { + const query = ` + query { + projects { + id + credit_quality + green_impact + solar { + power_output_kw + } + } + } + `; + + const res = await request(app) + .post("/graphql") + .set("X-API-Key", consumerKey) + .send({ query }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.projects.length).toBe(2); + expect(res.body.data.projects[0].credit_quality).toBe(88); + }); + + it("should allow querying portfolioSummary", async () => { + const query = ` + query { + portfolioSummary { + total_projects + avg_credit_quality + avg_green_impact + total_power_output_kw + } + } + `; + + const res = await request(app) + .post("/graphql") + .set("Authorization", `Bearer admin-secret-key`) + .send({ query }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.portfolioSummary).toEqual({ + total_projects: 2, + avg_credit_quality: 88, + avg_green_impact: 78, + total_power_output_kw: 1200, + }); + }); + + it("should reject mutations for consumer keys", async () => { + const query = ` + mutation { + updateProjectScores(id: "1", creditQuality: 90, greenImpact: 80) { + id + } + } + `; + + const res = await request(app) + .post("/graphql") + .set("X-API-Key", consumerKey) + .send({ query }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toContain("Unauthorized: Admin access required"); + }); + + it("should allow mutations for admin key", async () => { + const query = ` + mutation { + updateProjectScores(id: "1", creditQuality: 90, greenImpact: 80) { + id + credit_quality + green_impact + } + } + `; + + const res = await request(app) + .post("/graphql") + .set("Authorization", "Bearer admin-secret-key") + .send({ query }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.updateProjectScores.id).toBe("1"); + expect(registry.updateImpactScore).toHaveBeenCalledWith(1, 90, 80); + }); +}); diff --git a/src/__tests__/grpc.test.ts b/src/__tests__/grpc.test.ts new file mode 100644 index 0000000..31899e8 --- /dev/null +++ b/src/__tests__/grpc.test.ts @@ -0,0 +1,160 @@ +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import path from "path"; +import { startGrpcServer } from "../grpc/server"; +import * as registry from "../lib/registry"; +import * as iot from "../routes/iot"; +import * as scoring from "../lib/scoring"; +import { clearApiKeys, generateApiKey } from "../lib/apiKeys"; +import { scoreEvents, SCORE_UPDATE_EVENT } from "../lib/events"; + +jest.mock("../lib/registry", () => ({ + getTotalProjects: jest.fn(), +})); +jest.mock("../routes/iot"); +jest.mock("../lib/scoring"); + +const PROTO_PATH = path.join(__dirname, "../proto/heliobond.proto"); +const packageDefinition = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +}); +const heliobondProto = grpc.loadPackageDefinition(packageDefinition).heliobond as any; + +describe("gRPC Service Integration", () => { + let server: grpc.Server; + let client: any; + let consumerKey: string; + const PORT = 50055; + + beforeAll((done) => { + process.env.ADMIN_API_KEY = "grpc-admin-key"; + server = startGrpcServer(PORT); + + client = new heliobondProto.HeliobondService( + `localhost:${PORT}`, + grpc.credentials.createInsecure() + ); + // Give the server a small moment to bind + setTimeout(done, 500); + }); + + afterAll((done) => { + client.close(); + server.tryShutdown(() => { + done(); + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + clearApiKeys(); + const key = generateApiKey("gRPC Consumer"); + consumerKey = key.key; + + (registry.getTotalProjects as jest.Mock).mockResolvedValue(2); + (iot.getSolarData as jest.Mock).mockImplementation((id: number) => ({ + power_output_kw: 650, + efficiency_pct: 85, + max_power_kw: 1000, + timestamp: 12345678, + })); + (iot.getSatelliteData as jest.Mock).mockImplementation((id: number) => ({ + forest_density_pct: 80, + ndvi_score: 0.8, + timestamp: 12345678, + })); + (scoring.computeScores as jest.Mock).mockImplementation(() => ({ + credit_quality: 92, + green_impact: 82, + })); + }); + + it("should reject unary calls without authentication metadata", (done) => { + client.GetProjectScore({ project_id: 1 }, (err: any, response: any) => { + expect(err).toBeDefined(); + expect(err.code).toBe(grpc.status.UNAUTHENTICATED); + expect(response).toBeUndefined(); + done(); + }); + }); + + it("should allow unary calls with valid consumer key in metadata", (done) => { + const meta = new grpc.Metadata(); + meta.add("x-api-key", consumerKey); + + client.GetProjectScore({ project_id: 1 }, meta, (err: any, response: any) => { + expect(err).toBeNull(); + expect(response).toEqual({ + project_id: 1, + credit_quality: 92, + green_impact: 82, + power_output_kw: 650, + efficiency_pct: 85, + forest_density_pct: 80, + ndvi_score: 0.8, + timestamp: "12345678", + }); + done(); + }); + }); + + it("should stream project scores", (done) => { + const meta = new grpc.Metadata(); + meta.add("authorization", `Bearer grpc-admin-key`); + + const stream = client.StreamProjectScores({}, meta); + const received: any[] = []; + + stream.on("error", (err: any) => { + // Ignore cancellation error since we cancelled it ourselves + if (err.code !== grpc.status.CANCELLED) { + done(err); + } + }); + + stream.on("data", (data: any) => { + received.push(data); + if (received.length === 1) { + expect(received[0].project_id).toBe(2); + stream.cancel(); + done(); + } + }); + + // Simulate event emit in the background + setTimeout(() => { + scoreEvents.emit(SCORE_UPDATE_EVENT, { project_id: 2 }); + }, 100); + }); + + it("should handle bidirectional chat project scores", (done) => { + const meta = new grpc.Metadata(); + meta.add("x-api-key", consumerKey); + + const stream = client.ChatProjectScores(meta); + const received: any[] = []; + + stream.on("error", (err: any) => { + done(err); + }); + + stream.on("data", (data: any) => { + received.push(data); + if (received.length === 2) { + expect(received[0].project_id).toBe(1); + expect(received[0].credit_quality).toBe(92); + expect(received[1].project_id).toBe(2); + expect(received[1].credit_quality).toBe(92); + stream.end(); + done(); + } + }); + + stream.write({ project_id: 1 }); + stream.write({ project_id: 2 }); + }); +}); diff --git a/src/__tests__/investor.test.ts b/src/__tests__/investor.test.ts new file mode 100644 index 0000000..f95e592 --- /dev/null +++ b/src/__tests__/investor.test.ts @@ -0,0 +1,142 @@ +import request from "supertest"; +import express, { Express } from "express"; +import investorRouter from "../routes/investor"; +import { errorHandler } from "../middleware/errors"; +import * as registry from "../lib/registry"; +import * as iot from "../routes/iot"; +import * as scoring from "../lib/scoring"; +import { clearAuditLog, recordAudit } from "../lib/audit"; + +jest.mock("../lib/registry", () => ({ + getTotalProjects: jest.fn(), +})); +jest.mock("../routes/iot"); +jest.mock("../lib/scoring"); + +function buildApp(): Express { + const app = express(); + app.use(express.json()); + app.use("/api/investor", investorRouter); + app.use(errorHandler); + return app; +} + +describe("Investor Reporting Endpoints", () => { + let app: Express; + + beforeEach(() => { + app = buildApp(); + jest.clearAllMocks(); + clearAuditLog(); + + (registry.getTotalProjects as jest.Mock).mockResolvedValue(2); + (iot.getSolarData as jest.Mock).mockImplementation((id: number) => ({ + power_output_kw: 500 + id * 50, + efficiency_pct: 80 + id * 2, + max_power_kw: 1000, + timestamp: Date.now(), + })); + (iot.getSatelliteData as jest.Mock).mockImplementation((id: number) => ({ + forest_density_pct: 70 + id * 2, + ndvi_score: 0.7 + id * 0.05, + timestamp: Date.now(), + })); + (scoring.computeScores as jest.Mock).mockImplementation(() => ({ + credit_quality: 85, + green_impact: 75, + })); + }); + + describe("GET /dashboard", () => { + it("should return aggregated portfolio metrics", async () => { + recordAudit({ + project_id: 1, + credit_quality: 85, + green_impact: 75, + tx_hash: "tx123", + triggered_by: "test", + }); + + const res = await request(app).get("/api/investor/dashboard"); + expect(res.status).toBe(200); + expect(res.body.portfolio_summary).toEqual({ + total_projects: 2, + total_power_output_kw: 1150, + avg_credit_quality: 85, + avg_green_impact: 75, + total_portfolio_value: 950000, // project 1 (400000) + project 2 (550000) + total_carbon_offsets_tonnes: 4312.5, // 550 * 75 * 0.05 + 600 * 75 * 0.05 + }); + expect(res.body.recent_activities.length).toBe(1); + expect(res.body.recent_activities[0].tx_hash).toBe("tx123"); + }); + }); + + describe("GET /performance-report", () => { + it("should return project-by-project performance indicators", async () => { + const res = await request(app).get("/api/investor/performance-report"); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("generated_at"); + expect(res.body.projects.length).toBe(2); + expect(res.body.projects[0]).toEqual({ + project_id: 1, + efficiency_pct: 82, + power_output_kw: 550, + ndvi_score: 0.75, + actual_vs_expected_ratio: 0.55, // 550 / 1000 + performance_status: "Critical", + }); + }); + }); + + describe("GET /financial-summary", () => { + it("should return financial details and aggregated KPIs", async () => { + const res = await request(app).get("/api/investor/financial-summary"); + expect(res.status).toBe(200); + expect(res.body.portfolio_financials).toHaveProperty("total_installation_cost"); + expect(res.body.portfolio_financials).toHaveProperty("total_npv"); + expect(res.body.projects.length).toBe(2); + }); + }); + + describe("GET /compliance-report", () => { + it("should return ESG scores and audit logs", async () => { + const res = await request(app).get("/api/investor/compliance-report"); + expect(res.status).toBe(200); + expect(res.body.portfolio_compliance.portfolio_esg_score).toBe(75); + expect(res.body.projects.length).toBe(2); + }); + }); + + describe("POST /custom-report", () => { + it("should generate a custom filtered report", async () => { + const res = await request(app) + .post("/api/investor/custom-report") + .send({ + project_ids: [1], + sections: ["performance", "scores"], + }); + + expect(res.status).toBe(200); + expect(res.body.project_count).toBe(1); + expect(res.body.projects[0]).toHaveProperty("project_id", 1); + expect(res.body.projects[0]).toHaveProperty("scores"); + expect(res.body.projects[0]).toHaveProperty("performance"); + expect(res.body.projects[0]).not.toHaveProperty("financials"); + }); + + it("should reject invalid project_ids", async () => { + const res = await request(app) + .post("/api/investor/custom-report") + .send({ project_ids: "not-an-array" }); + expect(res.status).toBe(400); + }); + + it("should reject invalid sections", async () => { + const res = await request(app) + .post("/api/investor/custom-report") + .send({ sections: ["invalid-section"] }); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts new file mode 100644 index 0000000..aae941d --- /dev/null +++ b/src/graphql/schema.ts @@ -0,0 +1,256 @@ +import { buildSchema } from "graphql"; +import DataLoader from "dataloader"; +import { getSolarData, getSatelliteData } from "../routes/iot"; +import { computeScores } from "../lib/scoring"; +import { createDefaultFinancialInput, calculateNPV, calculatePaybackPeriod } from "../lib/financial"; +import { updateImpactScore, getTotalProjects } from "../lib/registry"; +import { recordAudit } from "../lib/audit"; +import { validateApiKey, isRateLimited, incrementUsage } from "../lib/apiKeys"; + +// 1. GraphQL SDL Schema +export const graphqlSchema = buildSchema(` + type SolarData { + power_output_kw: Float! + efficiency_pct: Float! + max_power_kw: Float! + } + + type SatelliteData { + forest_density_pct: Float! + ndvi_score: Float! + } + + type FinancialMetrics { + installation_cost: Float! + npv: Float! + payback_period_years: Float + roi_pct: Float! + } + + type Project { + id: ID! + credit_quality: Int! + green_impact: Int! + solar: SolarData! + satellite: SatelliteData! + financials: FinancialMetrics! + } + + type PortfolioSummary { + total_projects: Int! + avg_credit_quality: Float! + avg_green_impact: Float! + total_power_output_kw: Float! + highest_score_project_id: ID + lowest_score_project_id: ID + } + + type Query { + project(id: ID!): Project + projects(limit: Int, offset: Int): [Project!]! + portfolioSummary: PortfolioSummary! + } + + type Mutation { + updateProjectScores(id: ID!, creditQuality: Int!, greenImpact: Int!): Project! + } +`); + +// 2. DataLoaders for N+1 prevention +export interface GraphQLContext { + isAdmin: boolean; + isConsumer: boolean; + consumerName: string; + loaders: { + solarLoader: DataLoader; + satelliteLoader: DataLoader; + }; + [key: string]: any; +} + +export function createGraphQLContext(req: any): GraphQLContext { + const authHeader = req.headers.authorization; + const apiKeyHeader = req.headers["x-api-key"]; + let providedKey = ""; + + if (apiKeyHeader && typeof apiKeyHeader === "string") { + providedKey = apiKeyHeader; + } else if (authHeader && authHeader.startsWith("Bearer ")) { + providedKey = authHeader.substring(7); + } + + let isAdmin = false; + let isConsumer = false; + let consumerName = ""; + + const adminKey = process.env.ADMIN_API_KEY; + if (adminKey && providedKey === adminKey) { + isAdmin = true; + } else if (providedKey) { + const keyRecord = validateApiKey(providedKey); + if (keyRecord) { + if (isRateLimited(keyRecord.id, keyRecord.rate_limit)) { + throw new Error("Rate limit exceeded for this API key"); + } + incrementUsage(keyRecord.id); + isConsumer = true; + consumerName = keyRecord.consumer_name; + } + } + + const solarLoader = new DataLoader(async (keys) => { + return keys.map((id) => getSolarData(id)); + }); + + const satelliteLoader = new DataLoader(async (keys) => { + return keys.map((id) => getSatelliteData(id)); + }); + + return { + isAdmin, + isConsumer, + consumerName, + loaders: { + solarLoader, + satelliteLoader, + }, + }; +} + +// 3. Resolvers using classes to bind fields dynamically +class ProjectResolver { + id: string; + + constructor(id: string) { + this.id = id; + } + + async solar(_args: any, context: GraphQLContext) { + return context.loaders.solarLoader.load(parseInt(this.id, 10)); + } + + async satellite(_args: any, context: GraphQLContext) { + return context.loaders.satelliteLoader.load(parseInt(this.id, 10)); + } + + async credit_quality(_args: any, context: GraphQLContext) { + const solar = await context.loaders.solarLoader.load(parseInt(this.id, 10)); + const satellite = await context.loaders.satelliteLoader.load(parseInt(this.id, 10)); + const scores = computeScores({ solar, satellite }); + return scores.credit_quality; + } + + async green_impact(_args: any, context: GraphQLContext) { + const solar = await context.loaders.solarLoader.load(parseInt(this.id, 10)); + const satellite = await context.loaders.satelliteLoader.load(parseInt(this.id, 10)); + const scores = computeScores({ solar, satellite }); + return scores.green_impact; + } + + async financials(_args: any, context: GraphQLContext) { + const solar = await context.loaders.solarLoader.load(parseInt(this.id, 10)); + const input = createDefaultFinancialInput(solar.max_power_kw, solar.efficiency_pct); + const npvResult = calculateNPV(input); + const paybackResult = calculatePaybackPeriod(input); + + const totalBenefits = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.revenue, 0); + const totalOpsCosts = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.maintenance_cost, 0); + const netBenefits = totalBenefits - totalOpsCosts + (input.salvage_value ?? 0); + const roi = input.installation_cost > 0 ? (netBenefits - input.installation_cost) / input.installation_cost : 0; + + return { + installation_cost: input.installation_cost, + npv: npvResult.npv, + payback_period_years: paybackResult.reaches_payback ? paybackResult.payback_years : null, + roi_pct: roi * 100, + }; + } +} + +export const graphqlRoot = { + project: async ({ id }: { id: string }, context: GraphQLContext) => { + // Authenticate: require admin or consumer key + if (!context.isAdmin && !context.isConsumer) { + throw new Error("Unauthorized: Valid API Key is required"); + } + const projectId = parseInt(id, 10); + const total = await getTotalProjects(); + if (projectId < 1 || projectId > total) return null; + return new ProjectResolver(id); + }, + + projects: async ({ limit = 10, offset = 0 }: { limit?: number; offset?: number }, context: GraphQLContext) => { + if (!context.isAdmin && !context.isConsumer) { + throw new Error("Unauthorized: Valid API Key is required"); + } + const total = await getTotalProjects(); + const ids = Array.from({ length: total }, (_, i) => i + 1); + const paginatedIds = ids.slice(offset, offset + (limit ?? 10)); + return paginatedIds.map((id) => new ProjectResolver(String(id))); + }, + + portfolioSummary: async (_args: any, context: GraphQLContext) => { + if (!context.isAdmin && !context.isConsumer) { + throw new Error("Unauthorized: Valid API Key is required"); + } + const total = await getTotalProjects(); + const ids = Array.from({ length: total }, (_, i) => i + 1); + + let sumCq = 0; + let sumGi = 0; + let sumPower = 0; + let bestProjId: number | null = null; + let worstProjId: number | null = null; + let bestScore = -1; + let worstScore = 999; + + for (const id of ids) { + const solar = getSolarData(id); + const satellite = getSatelliteData(id); + const scores = computeScores({ solar, satellite }); + sumCq += scores.credit_quality; + sumGi += scores.green_impact; + sumPower += solar.power_output_kw; + + const score = scores.credit_quality + scores.green_impact; + if (score > bestScore) { + bestScore = score; + bestProjId = id; + } + if (score < worstScore) { + worstScore = score; + worstProjId = id; + } + } + + return { + total_projects: total, + avg_credit_quality: total > 0 ? Math.round((sumCq / total) * 100) / 100 : 0, + avg_green_impact: total > 0 ? Math.round((sumGi / total) * 100) / 100 : 0, + total_power_output_kw: Math.round(sumPower * 100) / 100, + highest_score_project_id: bestProjId, + lowest_score_project_id: worstProjId, + }; + }, + + updateProjectScores: async ( + { id, creditQuality, greenImpact }: { id: string; creditQuality: number; greenImpact: number }, + context: GraphQLContext + ) => { + if (!context.isAdmin) { + throw new Error("Unauthorized: Admin access required"); + } + const projectId = parseInt(id, 10); + const tx_hash = await updateImpactScore(projectId, creditQuality, greenImpact); + + recordAudit({ + project_id: projectId, + credit_quality: creditQuality, + green_impact: greenImpact, + tx_hash, + triggered_by: "graphql", + }); + + return new ProjectResolver(id); + }, +}; diff --git a/src/grpc/server.ts b/src/grpc/server.ts new file mode 100644 index 0000000..bbd84e3 --- /dev/null +++ b/src/grpc/server.ts @@ -0,0 +1,188 @@ +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import path from "path"; +import { getSolarData, getSatelliteData } from "../routes/iot"; +import { computeScores } from "../lib/scoring"; +import { getTotalProjects } from "../lib/registry"; +import { validateApiKey, isRateLimited, incrementUsage } from "../lib/apiKeys"; +import { scoreEvents, SCORE_UPDATE_EVENT } from "../lib/events"; + +const PROTO_PATH = path.join(__dirname, "../proto/heliobond.proto"); + +const packageDefinition = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +}); + +const heliobondProto = grpc.loadPackageDefinition(packageDefinition).heliobond as any; + +// Helper to authenticate metadata +function authenticateGrpc(metadata: grpc.Metadata): { success: boolean; error?: string } { + const authHeader = metadata.get("authorization")[0] as string | undefined; + const apiKeyHeader = metadata.get("x-api-key")[0] as string | undefined; + let providedKey = ""; + + if (apiKeyHeader) { + providedKey = apiKeyHeader; + } else if (authHeader && authHeader.startsWith("Bearer ")) { + providedKey = authHeader.substring(7); + } + + const adminKey = process.env.ADMIN_API_KEY; + if (adminKey && providedKey === adminKey) { + return { success: true }; + } + + if (!providedKey) { + return { success: false, error: "Missing API key in metadata (authorization or x-api-key)" }; + } + + const keyRecord = validateApiKey(providedKey); + if (!keyRecord) { + return { success: false, error: "Invalid or revoked API key" }; + } + + if (isRateLimited(keyRecord.id, keyRecord.rate_limit)) { + return { success: false, error: "Rate limit exceeded" }; + } + + incrementUsage(keyRecord.id); + return { success: true }; +} + +// Helper to get score details for a project +function getProjectDetails(id: number) { + const solar = getSolarData(id); + const satellite = getSatelliteData(id); + const scores = computeScores({ solar, satellite }); + return { + project_id: id, + credit_quality: scores.credit_quality, + green_impact: scores.green_impact, + power_output_kw: solar.power_output_kw, + efficiency_pct: solar.efficiency_pct, + forest_density_pct: satellite.forest_density_pct, + ndvi_score: satellite.ndvi_score, + timestamp: Math.max(solar.timestamp, satellite.timestamp), + }; +} + +// Unary handler +async function getProjectScore(call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData) { + try { + const auth = authenticateGrpc(call.metadata); + if (!auth.success) { + return callback({ + code: grpc.status.UNAUTHENTICATED, + details: auth.error, + }); + } + + const { project_id } = call.request; + const total = await getTotalProjects(); + if (project_id < 1 || project_id > total) { + return callback({ + code: grpc.status.NOT_FOUND, + details: `Project with ID ${project_id} not found`, + }); + } + + const data = getProjectDetails(project_id); + callback(null, data); + } catch (error: any) { + callback({ + code: grpc.status.INTERNAL, + details: error.message || "Internal server error", + }); + } +} + +// Server streaming handler (pushes real-time score updates) +function streamProjectScores(call: grpc.ServerWritableStream) { + const auth = authenticateGrpc(call.metadata); + if (!auth.success) { + const err = new Error(auth.error || "Authentication failed") as grpc.ServiceError; + err.code = grpc.status.UNAUTHENTICATED; + call.destroy(err); + return; + } + + const listener = (update: any) => { + try { + const details = getProjectDetails(update.project_id); + call.write(details); + } catch (err) { + console.error("[gRPC Stream] failed to send project details:", err); + } + }; + + scoreEvents.on(SCORE_UPDATE_EVENT, listener); + + call.on("cancelled", () => { + scoreEvents.off(SCORE_UPDATE_EVENT, listener); + }); +} + +// Bidirectional streaming handler +function chatProjectScores(call: grpc.ServerDuplexStream) { + const auth = authenticateGrpc(call.metadata); + if (!auth.success) { + const err = new Error(auth.error || "Authentication failed") as grpc.ServiceError; + err.code = grpc.status.UNAUTHENTICATED; + call.destroy(err); + return; + } + + call.on("data", async (request) => { + try { + const { project_id } = request; + const total = await getTotalProjects(); + if (project_id < 1 || project_id > total) { + call.write({ + project_id, + timestamp: Date.now(), + credit_quality: 0, + green_impact: 0, + }); + return; + } + + const details = getProjectDetails(project_id); + call.write(details); + } catch (err: any) { + console.error("[gRPC Chat] data processing error:", err); + } + }); + + call.on("end", () => { + call.end(); + }); +} + +export function startGrpcServer(port = 50051): grpc.Server { + const server = new grpc.Server({ + "grpc.keepalive_time_ms": 120000, + "grpc.keepalive_timeout_ms": 20000, + "grpc.keepalive_permit_without_calls": 1, + "grpc.max_connection_idle_ms": 300000, + }); + + server.addService(heliobondProto.HeliobondService.service, { + GetProjectScore: getProjectScore, + StreamProjectScores: streamProjectScores, + ChatProjectScores: chatProjectScores, + }); + + server.bindAsync(`0.0.0.0:${port}`, grpc.ServerCredentials.createInsecure(), (err, boundPort) => { + if (err) { + console.error(`[gRPC] Failed to bind to port ${port}:`, err); + return; + } + console.log(`[gRPC] Server running on 0.0.0.0:${boundPort}`); + }); + + return server; +} diff --git a/src/index.ts b/src/index.ts index 68dfc3b..6d91d97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,11 @@ import benchmarkingRouter from "./routes/benchmarking"; import financialRouter from "./routes/financial"; import forecastRouter from "./routes/forecast"; import maintenanceRouter from "./routes/maintenance"; +import investorRouter from "./routes/investor"; +import apiKeysRouter from "./routes/apiKeys"; +import { createHandler } from "graphql-http/lib/use/express"; +import { graphqlSchema, graphqlRoot, createGraphQLContext } from "./graphql/schema"; +import { startGrpcServer } from "./grpc/server"; import { getSolarData, getSatelliteData } from "./routes/iot"; import { computeScores } from "./lib/scoring"; import { updateImpactScore, getTotalProjects } from "./lib/registry"; @@ -83,6 +88,8 @@ v1.use("/benchmarking", publicLimiter, benchmarkingRouter); v1.use("/financial", publicLimiter, financialRouter); v1.use("/forecast", publicLimiter, forecastRouter); v1.use("/maintenance", publicLimiter, maintenanceRouter); +v1.use("/investor", publicLimiter, investorRouter); +v1.use("/admin/api-keys", adminLimiter, apiKeysRouter); app.use("/v1", v1); @@ -107,6 +114,8 @@ app.use("/api/benchmarking", publicLimiter, benchmarkingRouter); app.use("/api/financial", publicLimiter, financialRouter); app.use("/api/forecast", publicLimiter, forecastRouter); app.use("/api/maintenance", publicLimiter, maintenanceRouter); +app.use("/api/investor", publicLimiter, investorRouter); +app.use("/api/admin/api-keys", adminLimiter, apiKeysRouter); // JSON 404 for anything unmatched, then the structured error handler. app.use(notFoundHandler); @@ -172,4 +181,43 @@ const server = app.listen(PORT, () => { // Real-time score updates over WebSocket (ws:///ws) attachWebSocketServer(server); +// GraphQL endpoint and playground setup +app.all( + "/graphql", + createHandler({ + schema: graphqlSchema, + rootValue: graphqlRoot, + context: (req: any) => createGraphQLContext(req.raw) as any, + }) +); + +app.get("/graphql-playground", (req, res) => { + res.setHeader("Content-Type", "text/html"); + res.send(` + + + + GraphiQL + + + +
+ + + + + + + `); +}); + +// Start high-performance gRPC server +startGrpcServer(50051); + export default app; diff --git a/src/lib/apiKeys.ts b/src/lib/apiKeys.ts new file mode 100644 index 0000000..4388f1e --- /dev/null +++ b/src/lib/apiKeys.ts @@ -0,0 +1,103 @@ +import crypto from "crypto"; + +export interface ApiKey { + id: string; + key: string; + consumer_name: string; + status: "active" | "revoked"; + rate_limit: number; // requests per minute + usage_count: number; + last_used_at: number | null; + created_at: number; +} + +// In-memory store for API keys +const keysStore = new Map(); + +// In-memory rate limiting tracks: keyId -> { currentMinute, count } +const rateLimitMap = new Map(); + +export function generateApiKey(consumerName: string, rateLimit = 100): ApiKey { + const id = crypto.randomUUID(); + const key = `hk_live_${crypto.randomBytes(24).toString("hex")}`; + const apiKey: ApiKey = { + id, + key, + consumer_name: consumerName, + status: "active", + rate_limit: rateLimit, + usage_count: 0, + last_used_at: null, + created_at: Date.now(), + }; + keysStore.set(id, apiKey); + return apiKey; +} + +export function rotateApiKey(id: string): ApiKey | null { + const apiKey = keysStore.get(id); + if (!apiKey || apiKey.status === "revoked") return null; + + const newKey = `hk_live_${crypto.randomBytes(24).toString("hex")}`; + apiKey.key = newKey; + keysStore.set(id, apiKey); + return apiKey; +} + +export function revokeApiKey(id: string): boolean { + const apiKey = keysStore.get(id); + if (!apiKey) return false; + + apiKey.status = "revoked"; + keysStore.set(id, apiKey); + return true; +} + +export function listApiKeys(): ApiKey[] { + return Array.from(keysStore.values()); +} + +export function getApiKeyDetails(id: string): ApiKey | null { + return keysStore.get(id) || null; +} + +export function validateApiKey(key: string): ApiKey | null { + for (const apiKey of keysStore.values()) { + if (apiKey.key === key && apiKey.status === "active") { + return apiKey; + } + } + return null; +} + +export function incrementUsage(id: string): void { + const apiKey = keysStore.get(id); + if (apiKey) { + apiKey.usage_count++; + apiKey.last_used_at = Date.now(); + keysStore.set(id, apiKey); + } +} + +export function isRateLimited(id: string, rateLimit: number): boolean { + const now = Date.now(); + const minute = Math.floor(now / 60000); + const track = rateLimitMap.get(id); + + if (!track || track.currentMinute !== minute) { + rateLimitMap.set(id, { currentMinute: minute, count: 1 }); + return false; + } + + if (track.count >= rateLimit) { + return true; + } + + track.count++; + return false; +} + +export function clearApiKeys(): void { + keysStore.clear(); + rateLimitMap.clear(); +} diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..7d1d38c --- /dev/null +++ b/src/lib/events.ts @@ -0,0 +1,6 @@ +import { EventEmitter } from "events"; + +export const scoreEvents = new EventEmitter(); + +// Event names +export const SCORE_UPDATE_EVENT = "score_update"; diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index 2c2490b..cd750b8 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -133,8 +133,11 @@ export function attachWebSocketServer(server: HttpServer): WebSocketServer { return wss; } +import { scoreEvents, SCORE_UPDATE_EVENT } from "./events"; + /** Push a score update to every connection subscribed to that project. */ export function broadcastScoreUpdate(update: ScoreUpdate): void { + scoreEvents.emit(SCORE_UPDATE_EVENT, update); if (!wss) return; const frame = encodeScoreUpdate(update); for (const [ws, state] of clients) { diff --git a/src/middleware/apiKeyAuth.ts b/src/middleware/apiKeyAuth.ts new file mode 100644 index 0000000..2baecfe --- /dev/null +++ b/src/middleware/apiKeyAuth.ts @@ -0,0 +1,63 @@ +import { Request, Response, NextFunction } from "express"; +import { validateApiKey, incrementUsage, isRateLimited } from "../lib/apiKeys"; + +export interface AuthenticatedRequest extends Request { + apiKeyInfo?: { + id: string; + consumer_name: string; + rate_limit: number; + }; +} + +export function apiKeyAuth(req: AuthenticatedRequest, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + const apiKeyHeader = req.headers["x-api-key"]; + let providedKey = ""; + + if (apiKeyHeader && typeof apiKeyHeader === "string") { + providedKey = apiKeyHeader; + } else if (authHeader && authHeader.startsWith("Bearer ")) { + providedKey = authHeader.substring(7); + } + + // Fallback / support for existing ADMIN_API_KEY + const adminKey = process.env.ADMIN_API_KEY; + if (adminKey && providedKey === adminKey) { + return next(); + } + + if (!providedKey) { + return res.status(401).json({ + error: "unauthorized", + message: "Missing API key in Authorization bearer token or X-API-Key header", + }); + } + + const apiKeyRecord = validateApiKey(providedKey); + if (!apiKeyRecord) { + return res.status(401).json({ + error: "unauthorized", + message: "Invalid or revoked API key", + }); + } + + // Enforce rate limit + if (isRateLimited(apiKeyRecord.id, apiKeyRecord.rate_limit)) { + return res.status(429).json({ + error: "too_many_requests", + message: "Rate limit exceeded for this API key. Please retry later.", + }); + } + + // Increment usage + incrementUsage(apiKeyRecord.id); + + // Attach metadata + req.apiKeyInfo = { + id: apiKeyRecord.id, + consumer_name: apiKeyRecord.consumer_name, + rate_limit: apiKeyRecord.rate_limit, + }; + + next(); +} diff --git a/src/proto/heliobond.proto b/src/proto/heliobond.proto new file mode 100644 index 0000000..fb7d39a --- /dev/null +++ b/src/proto/heliobond.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package heliobond; + +service HeliobondService { + rpc GetProjectScore(ProjectRequest) returns (ProjectResponse); + rpc StreamProjectScores(StreamRequest) returns (stream ProjectResponse); + rpc ChatProjectScores(stream ProjectRequest) returns (stream ProjectResponse); +} + +message ProjectRequest { + int32 project_id = 1; +} + +message StreamRequest {} + +message ProjectResponse { + int32 project_id = 1; + int32 credit_quality = 2; + int32 green_impact = 3; + double power_output_kw = 4; + double efficiency_pct = 5; + double forest_density_pct = 6; + double ndvi_score = 7; + int64 timestamp = 8; +} diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 01d4f16..34ebe31 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -4,6 +4,7 @@ import { computeScores } from "../lib/scoring"; import { updateImpactScore, getTotalProjects } from "../lib/registry"; import { badRequest, parseOptionalInt } from "../middleware/errors"; import { recordAudit, getAuditLog, auditToCsv } from "../lib/audit"; +import { broadcastScoreUpdate } from "../lib/websocket"; const router = Router(); @@ -75,6 +76,12 @@ router.post("/update-scores", async (req: Request, res: Response) => { tx_hash, triggered_by: "api", }); + broadcastScoreUpdate({ + project_id: projectId, + credit_quality: scores.credit_quality, + green_impact: scores.green_impact, + timestamp: Date.now(), + }); console.log(`[oracle] project ${projectId}: cq=${scores.credit_quality} gi=${scores.green_impact} tx=${tx_hash}`); } catch (err) { console.error(`[oracle] project ${projectId} failed:`, err); diff --git a/src/routes/apiKeys.ts b/src/routes/apiKeys.ts new file mode 100644 index 0000000..1054017 --- /dev/null +++ b/src/routes/apiKeys.ts @@ -0,0 +1,104 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { + generateApiKey, + rotateApiKey, + revokeApiKey, + listApiKeys, + getApiKeyDetails, +} from "../lib/apiKeys"; +import { badRequest } from "../middleware/errors"; + +const router = Router(); + +// Enforce ADMIN_API_KEY if configured +router.use((req: Request, res: Response, next: NextFunction) => { + const apiKey = process.env.ADMIN_API_KEY; + if (!apiKey) return next(); + if (req.headers.authorization !== `Bearer ${apiKey}`) { + return res.status(401).json({ error: "unauthorized", message: "Missing or invalid bearer token" }); + } + next(); +}); + +// POST / — Generate a new API key +router.post("/", (req: Request, res: Response, next: NextFunction) => { + try { + const { consumer_name, rate_limit } = req.body; + if (!consumer_name || typeof consumer_name !== "string") { + throw badRequest("consumer_name is required and must be a string"); + } + + let parsedRateLimit: number | undefined; + if (rate_limit !== undefined) { + parsedRateLimit = Number(rate_limit); + if (!Number.isInteger(parsedRateLimit) || parsedRateLimit <= 0) { + throw badRequest("rate_limit must be a positive integer"); + } + } + + const keyInfo = generateApiKey(consumer_name, parsedRateLimit); + res.status(201).json(keyInfo); // Returning 201 Created or custom success + } catch (error) { + next(error); + } +}); + +// GET / — List all API keys +router.get("/", (_req: Request, res: Response, next: NextFunction) => { + try { + const keys = listApiKeys(); + res.json({ count: keys.length, keys }); + } catch (error) { + next(error); + } +}); + +// POST /:id/rotate — Rotate an API key +router.post("/:id/rotate", (req: Request, res: Response, next: NextFunction) => { + try { + const id = String(req.params.id); + const rotated = rotateApiKey(id); + if (!rotated) { + return res.status(404).json({ error: "not_found", message: "Active API key not found" }); + } + res.json(rotated); + } catch (error) { + next(error); + } +}); + +// DELETE /:id — Revoke an API key +router.delete("/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = String(req.params.id); + const revoked = revokeApiKey(id); + if (!revoked) { + return res.status(404).json({ error: "not_found", message: "API key not found" }); + } + res.json({ success: true, message: "API key revoked successfully" }); + } catch (error) { + next(error); + } +}); + +// GET /:id/usage — Get usage tracking statistics +router.get("/:id/usage", (req: Request, res: Response, next: NextFunction) => { + try { + const id = String(req.params.id); + const keyDetails = getApiKeyDetails(id); + if (!keyDetails) { + return res.status(404).json({ error: "not_found", message: "API key not found" }); + } + res.json({ + id: keyDetails.id, + consumer_name: keyDetails.consumer_name, + usage_count: keyDetails.usage_count, + last_used_at: keyDetails.last_used_at, + rate_limit: keyDetails.rate_limit, + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/batch.ts b/src/routes/batch.ts index b9b682d..72eda83 100644 --- a/src/routes/batch.ts +++ b/src/routes/batch.ts @@ -73,7 +73,7 @@ router.post("/score-update", async (req: Request, res: Response) => { * Returns current progress and results for a batch job. */ router.get("/:batchId/status", (req: Request, res: Response) => { - const job = getJob(req.params["batchId"]); + const job = getJob(String(req.params["batchId"])); if (!job) { res.status(404).json({ error: "not_found", message: "Batch job not found" }); return; diff --git a/src/routes/investor.ts b/src/routes/investor.ts new file mode 100644 index 0000000..7dfe934 --- /dev/null +++ b/src/routes/investor.ts @@ -0,0 +1,321 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { getTotalProjects } from "../lib/registry"; +import { getSolarData, getSatelliteData } from "./iot"; +import { computeScores } from "../lib/scoring"; +import { createDefaultFinancialInput, calculateNPV, calculatePaybackPeriod } from "../lib/financial"; +import { getAuditLog } from "../lib/audit"; +import { badRequest } from "../middleware/errors"; + +const router = Router(); + +// Helper to collect all project details deterministically +async function getPortfolioData() { + const total = await getTotalProjects(); + const ids = Array.from({ length: total }, (_, i) => i + 1); + + return ids.map((id) => { + const solar = getSolarData(id); + const satellite = getSatelliteData(id); + const scores = computeScores({ solar, satellite }); + // Deterministic funding based on project ID + const funding = 250000 + (id * 150000) % 600000; + // Expected output based on capacity factor + const expected_output = solar.max_power_kw * 24 * 365 * 0.2; // 20% capacity factor + const actual_output = solar.power_output_kw * 24 * 365 * 0.2; + const actual_vs_expected_ratio = Math.min(1.2, Math.max(0.5, actual_output / expected_output)); + + return { + id, + solar, + satellite, + scores, + funding, + actual_vs_expected_ratio, + }; + }); +} + +// GET /dashboard — Investor Dashboard Data +router.get("/dashboard", async (_req: Request, res: Response, next: NextFunction) => { + try { + const portfolio = await getPortfolioData(); + if (portfolio.length === 0) { + return res.json({ + portfolio_summary: { + total_projects: 0, + total_power_output_kw: 0, + avg_credit_quality: 0, + avg_green_impact: 0, + total_portfolio_value: 0, + total_carbon_offsets_tonnes: 0, + }, + recent_activities: [], + }); + } + + const totalPower = portfolio.reduce((acc, p) => acc + p.solar.power_output_kw, 0); + const totalCreditQuality = portfolio.reduce((acc, p) => acc + p.scores.credit_quality, 0); + const totalGreenImpact = portfolio.reduce((acc, p) => acc + p.scores.green_impact, 0); + const totalFunding = portfolio.reduce((acc, p) => acc + p.funding, 0); + // Formula for carbon offsets: power_output_kw * green_impact * constant factor + const totalCarbonOffsets = portfolio.reduce( + (acc, p) => acc + p.solar.power_output_kw * p.scores.green_impact * 0.05, + 0 + ); + + const recentAuditLogs = getAuditLog().slice(-5).reverse(); + + res.json({ + portfolio_summary: { + total_projects: portfolio.length, + total_power_output_kw: Math.round(totalPower * 100) / 100, + avg_credit_quality: Math.round((totalCreditQuality / portfolio.length) * 100) / 100, + avg_green_impact: Math.round((totalGreenImpact / portfolio.length) * 100) / 100, + total_portfolio_value: totalFunding, + total_carbon_offsets_tonnes: Math.round(totalCarbonOffsets * 100) / 100, + }, + recent_activities: recentAuditLogs, + }); + } catch (error) { + next(error); + } +}); + +// GET /performance-report — Performance Reports +router.get("/performance-report", async (_req: Request, res: Response, next: NextFunction) => { + try { + const portfolio = await getPortfolioData(); + const reports = portfolio.map((p) => { + let status: "Optimal" | "Underperforming" | "Critical" = "Optimal"; + if (p.actual_vs_expected_ratio < 0.8) { + status = "Critical"; + } else if (p.actual_vs_expected_ratio < 0.95) { + status = "Underperforming"; + } + + return { + project_id: p.id, + efficiency_pct: p.solar.efficiency_pct, + power_output_kw: p.solar.power_output_kw, + ndvi_score: p.satellite.ndvi_score, + actual_vs_expected_ratio: Math.round(p.actual_vs_expected_ratio * 100) / 100, + performance_status: status, + }; + }); + + res.json({ + generated_at: Date.now(), + projects: reports, + }); + } catch (error) { + next(error); + } +}); + +// GET /financial-summary — Financial Summaries +router.get("/financial-summary", async (_req: Request, res: Response, next: NextFunction) => { + try { + const portfolio = await getPortfolioData(); + const projectFinancials = portfolio.map((p) => { + const input = createDefaultFinancialInput(p.solar.max_power_kw, p.solar.efficiency_pct); + const npvResult = calculateNPV(input); + const paybackResult = calculatePaybackPeriod(input); + + // ROI = Net benefit over lifetime / installation cost + const lifetimeYears = input.project_lifetime_years; + const totalBenefits = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.revenue, 0); + const totalOpsCosts = npvResult.discounted_cash_flows.reduce((acc, cf) => acc + cf.maintenance_cost, 0); + const netBenefits = totalBenefits - totalOpsCosts + (input.salvage_value ?? 0); + const roi = input.installation_cost > 0 ? (netBenefits - input.installation_cost) / input.installation_cost : 0; + + return { + project_id: p.id, + installation_cost: Math.round(input.installation_cost * 100) / 100, + npv: Math.round(npvResult.npv * 100) / 100, + payback_period_years: paybackResult.reaches_payback ? Math.round(paybackResult.payback_years * 10) / 10 : null, + roi_pct: Math.round(roi * 1000) / 10, + }; + }); + + if (projectFinancials.length === 0) { + return res.json({ + portfolio_financials: { + total_installation_cost: 0, + total_npv: 0, + avg_payback_period_years: null, + avg_roi_pct: 0, + }, + projects: [], + }); + } + + const totalCost = projectFinancials.reduce((acc, p) => acc + p.installation_cost, 0); + const totalNpv = projectFinancials.reduce((acc, p) => acc + p.npv, 0); + const validPaybacks = projectFinancials.filter((p) => p.payback_period_years !== null) as Array; + const avgPayback = validPaybacks.length > 0 ? validPaybacks.reduce((acc, p) => acc + p.payback_period_years, 0) / validPaybacks.length : null; + const avgRoi = projectFinancials.reduce((acc, p) => acc + p.roi_pct, 0) / projectFinancials.length; + + res.json({ + portfolio_financials: { + total_installation_cost: Math.round(totalCost * 100) / 100, + total_npv: Math.round(totalNpv * 100) / 100, + avg_payback_period_years: avgPayback !== null ? Math.round(avgPayback * 10) / 10 : null, + avg_roi_pct: Math.round(avgRoi * 10) / 10, + }, + projects: projectFinancials, + }); + } catch (error) { + next(error); + } +}); + +// GET /compliance-report — Compliance Reports +router.get("/compliance-report", async (_req: Request, res: Response, next: NextFunction) => { + try { + const portfolio = await getPortfolioData(); + const reports = portfolio.map((p) => { + // ESG status based on credit quality and green impact + const score = (p.scores.credit_quality + p.scores.green_impact) / 2; + let status: "Compliant" | "Warning" | "Non-Compliant" = "Compliant"; + if (score < 50) { + status = "Non-Compliant"; + } else if (score < 70) { + status = "Warning"; + } + + // Carbon credits: simulated registry entry + const carbonCredits = Math.round(p.solar.power_output_kw * p.scores.green_impact * 0.5); + + return { + project_id: p.id, + green_impact: p.scores.green_impact, + ndvi_score: p.satellite.ndvi_score, + carbon_credits_issued: carbonCredits, + compliance_status: status, + }; + }); + + const totalCredits = reports.reduce((acc, r) => acc + r.carbon_credits_issued, 0); + const avgGreenImpact = portfolio.length > 0 ? portfolio.reduce((acc, p) => acc + p.scores.green_impact, 0) / portfolio.length : 0; + + let portfolioStatus: "Compliant" | "Warning" | "Non-Compliant" = "Compliant"; + if (avgGreenImpact < 50) { + portfolioStatus = "Non-Compliant"; + } else if (avgGreenImpact < 70) { + portfolioStatus = "Warning"; + } + + res.json({ + portfolio_compliance: { + portfolio_esg_score: Math.round(avgGreenImpact * 100) / 100, + total_carbon_credits_issued: totalCredits, + portfolio_status: portfolioStatus, + }, + projects: reports, + audit_logs: getAuditLog(), + }); + } catch (error) { + next(error); + } +}); + +// POST /custom-report — Custom Report Generation +router.post("/custom-report", async (req: Request, res: Response, next: NextFunction) => { + try { + const { project_ids, sections } = req.body; + + if (project_ids !== undefined) { + if (!Array.isArray(project_ids)) { + throw badRequest("project_ids must be an array of positive integers"); + } + if (!project_ids.every((n) => Number.isInteger(n) && n >= 1)) { + throw badRequest("project_ids must contain only positive integers"); + } + } + + if (sections !== undefined) { + if (!Array.isArray(sections)) { + throw badRequest("sections must be an array of strings"); + } + const validSections = ["financials", "performance", "compliance", "scores"]; + if (!sections.every((s) => typeof s === "string" && validSections.includes(s))) { + throw badRequest(`sections must contain only: ${validSections.join(", ")}`); + } + } + + const portfolio = await getPortfolioData(); + const filterIds = project_ids as number[] | undefined; + const filterSections = (sections as string[] | undefined) ?? ["scores"]; + + const filteredPortfolio = filterIds + ? portfolio.filter((p) => filterIds.includes(p.id)) + : portfolio; + + const reportProjects = filteredPortfolio.map((p) => { + const pReport: any = { project_id: p.id }; + + if (filterSections.includes("scores")) { + pReport.scores = { + credit_quality: p.scores.credit_quality, + green_impact: p.scores.green_impact, + }; + } + + if (filterSections.includes("performance")) { + let status: "Optimal" | "Underperforming" | "Critical" = "Optimal"; + if (p.actual_vs_expected_ratio < 0.8) { + status = "Critical"; + } else if (p.actual_vs_expected_ratio < 0.95) { + status = "Underperforming"; + } + + pReport.performance = { + efficiency_pct: p.solar.efficiency_pct, + power_output_kw: p.solar.power_output_kw, + actual_vs_expected_ratio: Math.round(p.actual_vs_expected_ratio * 100) / 100, + performance_status: status, + }; + } + + if (filterSections.includes("financials")) { + const input = createDefaultFinancialInput(p.solar.max_power_kw, p.solar.efficiency_pct); + const npvResult = calculateNPV(input); + const paybackResult = calculatePaybackPeriod(input); + + pReport.financials = { + installation_cost: Math.round(input.installation_cost * 100) / 100, + npv: Math.round(npvResult.npv * 100) / 100, + payback_period_years: paybackResult.reaches_payback ? Math.round(paybackResult.payback_years * 10) / 10 : null, + }; + } + + if (filterSections.includes("compliance")) { + const score = (p.scores.credit_quality + p.scores.green_impact) / 2; + let status: "Compliant" | "Warning" | "Non-Compliant" = "Compliant"; + if (score < 50) { + status = "Non-Compliant"; + } else if (score < 70) { + status = "Warning"; + } + + pReport.compliance = { + green_impact: p.scores.green_impact, + compliance_status: status, + carbon_credits_issued: Math.round(p.solar.power_output_kw * p.scores.green_impact * 0.5), + }; + } + + return pReport; + }); + + res.json({ + generated_at: Date.now(), + project_count: reportProjects.length, + projects: reportProjects, + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/roles.ts b/src/routes/roles.ts index 656b062..ec8eac2 100644 --- a/src/routes/roles.ts +++ b/src/routes/roles.ts @@ -29,7 +29,7 @@ router.get("/", requireAuth, requireRole("admin"), (_req: Request, res: Response /** DELETE /api/roles/:userId — revoke a user's role (admin only) */ router.delete("/:userId", requireAuth, requireRole("admin"), (req: Request, res: Response) => { - const removed = removeRole(req.params["userId"]); + const removed = removeRole(String(req.params["userId"])); if (!removed) { res.status(404).json({ error: "not_found", message: "User has no assigned role" }); return; diff --git a/src/routes/satellite-sources.ts b/src/routes/satellite-sources.ts index 30f99a0..60c15bf 100644 --- a/src/routes/satellite-sources.ts +++ b/src/routes/satellite-sources.ts @@ -40,7 +40,7 @@ router.patch("/:name", (req: Request, res: Response) => { throw badRequest("priority must be a positive integer"); } - const ok = configureSource(req.params.name, { enabled, priority }); + const ok = configureSource(String(req.params.name), { enabled, priority }); if (!ok) return res.status(404).json({ error: "source not found" }); res.json({ ok: true, sources: getSources() }); diff --git a/src/routes/scoring-formulas.ts b/src/routes/scoring-formulas.ts index d5b64c2..937bf3c 100644 --- a/src/routes/scoring-formulas.ts +++ b/src/routes/scoring-formulas.ts @@ -45,23 +45,23 @@ router.post("/", (req: Request, res: Response) => { /** GET /v1/scoring/formulas/:id — get a formula */ router.get("/:id", (req: Request, res: Response) => { - const formula = getFormula(req.params.id); + const formula = getFormula(String(req.params.id)); if (!formula) return res.status(404).json({ error: "formula not found" }); res.json(formula); }); /** DELETE /v1/scoring/formulas/:id — delete a formula */ router.delete("/:id", (req: Request, res: Response) => { - const deleted = deleteFormula(req.params.id); + const deleted = deleteFormula(String(req.params.id)); if (!deleted) return res.status(404).json({ error: "formula not found" }); res.json({ ok: true }); }); /** POST /v1/scoring/formulas/:id/activate — set as active formula */ router.post("/:id/activate", (req: Request, res: Response) => { - const ok = setActiveFormula(req.params.id); + const ok = setActiveFormula(String(req.params.id)); if (!ok) return res.status(404).json({ error: "formula not found" }); - res.json({ ok: true, active: req.params.id }); + res.json({ ok: true, active: String(req.params.id) }); }); /** POST /v1/scoring/formulas/validate — validate weights without saving */ @@ -78,7 +78,7 @@ router.post("/validate", (req: Request, res: Response) => { router.get("/:id/preview/:projectId", (req: Request, res: Response, next: NextFunction) => { try { const projectId = parseProjectId(req.params.projectId, "project id"); - const formula = getFormula(req.params.id); + const formula = getFormula(String(req.params.id)); if (!formula) return res.status(404).json({ error: "formula not found" }); const solar = getSolarData(projectId); diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 4a324ec..1b44ce1 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -60,7 +60,7 @@ router.get("/", (_req: Request, res: Response) => { /** GET /api/webhooks/:id — fetch one webhook (secret omitted) */ router.get("/:id", (req: Request, res: Response) => { - const wh = getWebhook(req.params["id"]); + const wh = getWebhook(String(req.params["id"])); if (!wh) { res.status(404).json({ error: "not_found", message: "Webhook not found" }); return; @@ -70,7 +70,7 @@ router.get("/:id", (req: Request, res: Response) => { /** DELETE /api/webhooks/:id — unregister a webhook */ router.delete("/:id", (req: Request, res: Response) => { - const removed = removeWebhook(req.params["id"]); + const removed = removeWebhook(String(req.params["id"])); if (!removed) { res.status(404).json({ error: "not_found", message: "Webhook not found" }); return;