@stratum-hq/control-plane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/__tests__/api-keys.test.d.ts +2 -0
  2. package/dist/__tests__/api-keys.test.d.ts.map +1 -0
  3. package/dist/__tests__/api-keys.test.js +240 -0
  4. package/dist/__tests__/api-keys.test.js.map +1 -0
  5. package/dist/__tests__/auth.test.d.ts +2 -0
  6. package/dist/__tests__/auth.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth.test.js +227 -0
  8. package/dist/__tests__/auth.test.js.map +1 -0
  9. package/dist/__tests__/config.test.d.ts +2 -0
  10. package/dist/__tests__/config.test.d.ts.map +1 -0
  11. package/dist/__tests__/config.test.js +201 -0
  12. package/dist/__tests__/config.test.js.map +1 -0
  13. package/dist/__tests__/tenants.test.d.ts +2 -0
  14. package/dist/__tests__/tenants.test.d.ts.map +1 -0
  15. package/dist/__tests__/tenants.test.js +214 -0
  16. package/dist/__tests__/tenants.test.js.map +1 -0
  17. package/dist/__tests__/test-helpers.d.ts +71 -0
  18. package/dist/__tests__/test-helpers.d.ts.map +1 -0
  19. package/dist/__tests__/test-helpers.js +172 -0
  20. package/dist/__tests__/test-helpers.js.map +1 -0
  21. package/dist/app.d.ts +3 -0
  22. package/dist/app.d.ts.map +1 -0
  23. package/dist/app.js +114 -0
  24. package/dist/app.js.map +1 -0
  25. package/dist/config.d.ts +11 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +26 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/db/connection.d.ts +4 -0
  30. package/dist/db/connection.d.ts.map +1 -0
  31. package/dist/db/connection.js +30 -0
  32. package/dist/db/connection.js.map +1 -0
  33. package/dist/db/migrate.d.ts +3 -0
  34. package/dist/db/migrate.d.ts.map +1 -0
  35. package/dist/db/migrate.js +56 -0
  36. package/dist/db/migrate.js.map +1 -0
  37. package/dist/db/regional-pool-router.d.ts +9 -0
  38. package/dist/db/regional-pool-router.d.ts.map +1 -0
  39. package/dist/db/regional-pool-router.js +29 -0
  40. package/dist/db/regional-pool-router.js.map +1 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +36 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/middleware/auth.d.ts +21 -0
  46. package/dist/middleware/auth.d.ts.map +1 -0
  47. package/dist/middleware/auth.js +70 -0
  48. package/dist/middleware/auth.js.map +1 -0
  49. package/dist/middleware/authorize.d.ts +3 -0
  50. package/dist/middleware/authorize.d.ts.map +1 -0
  51. package/dist/middleware/authorize.js +54 -0
  52. package/dist/middleware/authorize.js.map +1 -0
  53. package/dist/middleware/error-handler.d.ts +3 -0
  54. package/dist/middleware/error-handler.d.ts.map +1 -0
  55. package/dist/middleware/error-handler.js +48 -0
  56. package/dist/middleware/error-handler.js.map +1 -0
  57. package/dist/middleware/per-key-rate-limit.d.ts +3 -0
  58. package/dist/middleware/per-key-rate-limit.d.ts.map +1 -0
  59. package/dist/middleware/per-key-rate-limit.js +62 -0
  60. package/dist/middleware/per-key-rate-limit.js.map +1 -0
  61. package/dist/middleware/rate-limit-redis.d.ts +16 -0
  62. package/dist/middleware/rate-limit-redis.d.ts.map +1 -0
  63. package/dist/middleware/rate-limit-redis.js +118 -0
  64. package/dist/middleware/rate-limit-redis.js.map +1 -0
  65. package/dist/middleware/telemetry.d.ts +14 -0
  66. package/dist/middleware/telemetry.d.ts.map +1 -0
  67. package/dist/middleware/telemetry.js +81 -0
  68. package/dist/middleware/telemetry.js.map +1 -0
  69. package/dist/middleware/tenant-scope.d.ts +20 -0
  70. package/dist/middleware/tenant-scope.d.ts.map +1 -0
  71. package/dist/middleware/tenant-scope.js +60 -0
  72. package/dist/middleware/tenant-scope.js.map +1 -0
  73. package/dist/openapi.d.ts +3 -0
  74. package/dist/openapi.d.ts.map +1 -0
  75. package/dist/openapi.js +44 -0
  76. package/dist/openapi.js.map +1 -0
  77. package/dist/redis.d.ts +16 -0
  78. package/dist/redis.d.ts.map +1 -0
  79. package/dist/redis.js +71 -0
  80. package/dist/redis.js.map +1 -0
  81. package/dist/routes/api-keys.d.ts +4 -0
  82. package/dist/routes/api-keys.d.ts.map +1 -0
  83. package/dist/routes/api-keys.js +78 -0
  84. package/dist/routes/api-keys.js.map +1 -0
  85. package/dist/routes/audit-logs.d.ts +6 -0
  86. package/dist/routes/audit-logs.d.ts.map +1 -0
  87. package/dist/routes/audit-logs.js +43 -0
  88. package/dist/routes/audit-logs.js.map +1 -0
  89. package/dist/routes/config-diff.d.ts +4 -0
  90. package/dist/routes/config-diff.d.ts.map +1 -0
  91. package/dist/routes/config-diff.js +33 -0
  92. package/dist/routes/config-diff.js.map +1 -0
  93. package/dist/routes/config.d.ts +4 -0
  94. package/dist/routes/config.d.ts.map +1 -0
  95. package/dist/routes/config.js +54 -0
  96. package/dist/routes/config.js.map +1 -0
  97. package/dist/routes/consent.d.ts +4 -0
  98. package/dist/routes/consent.d.ts.map +1 -0
  99. package/dist/routes/consent.js +45 -0
  100. package/dist/routes/consent.js.map +1 -0
  101. package/dist/routes/health.d.ts +9 -0
  102. package/dist/routes/health.d.ts.map +1 -0
  103. package/dist/routes/health.js +32 -0
  104. package/dist/routes/health.js.map +1 -0
  105. package/dist/routes/maintenance.d.ts +4 -0
  106. package/dist/routes/maintenance.d.ts.map +1 -0
  107. package/dist/routes/maintenance.js +35 -0
  108. package/dist/routes/maintenance.js.map +1 -0
  109. package/dist/routes/permissions.d.ts +4 -0
  110. package/dist/routes/permissions.d.ts.map +1 -0
  111. package/dist/routes/permissions.js +36 -0
  112. package/dist/routes/permissions.js.map +1 -0
  113. package/dist/routes/regions.d.ts +4 -0
  114. package/dist/routes/regions.d.ts.map +1 -0
  115. package/dist/routes/regions.js +38 -0
  116. package/dist/routes/regions.js.map +1 -0
  117. package/dist/routes/roles.d.ts +4 -0
  118. package/dist/routes/roles.d.ts.map +1 -0
  119. package/dist/routes/roles.js +95 -0
  120. package/dist/routes/roles.js.map +1 -0
  121. package/dist/routes/tenants.d.ts +4 -0
  122. package/dist/routes/tenants.d.ts.map +1 -0
  123. package/dist/routes/tenants.js +112 -0
  124. package/dist/routes/tenants.js.map +1 -0
  125. package/dist/routes/webhooks.d.ts +4 -0
  126. package/dist/routes/webhooks.d.ts.map +1 -0
  127. package/dist/routes/webhooks.js +116 -0
  128. package/dist/routes/webhooks.js.map +1 -0
  129. package/dist/services/isolation-service.d.ts +22 -0
  130. package/dist/services/isolation-service.d.ts.map +1 -0
  131. package/dist/services/isolation-service.js +94 -0
  132. package/dist/services/isolation-service.js.map +1 -0
  133. package/package.json +48 -0
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createAuthMiddleware = void 0;
7
+ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
8
+ const config_js_1 = require("../config.js");
9
+ const core_1 = require("@stratum-hq/core");
10
+ function createAuthMiddleware(stratum) {
11
+ return async function authMiddleware(request, reply) {
12
+ // Skip auth for health and documentation endpoints
13
+ if (request.url === "/api/v1/health" ||
14
+ request.url.startsWith("/api/v1/health?") ||
15
+ request.url.startsWith("/api/docs")) {
16
+ return;
17
+ }
18
+ // Try API key first
19
+ const apiKeyHeader = request.headers["x-api-key"];
20
+ if (apiKeyHeader && typeof apiKeyHeader === "string") {
21
+ const result = await stratum.validateApiKey(apiKeyHeader);
22
+ if (!result) {
23
+ throw new core_1.UnauthorizedError("Invalid or revoked API key");
24
+ }
25
+ request.apiKey = {
26
+ id: result.key_id,
27
+ tenant_id: result.tenant_id,
28
+ key_hash: "",
29
+ name: "",
30
+ created_at: new Date(),
31
+ scopes: result.scopes,
32
+ rate_limit_max: result.rate_limit_max,
33
+ rate_limit_window: result.rate_limit_window,
34
+ };
35
+ request.authMethod = "api_key";
36
+ return;
37
+ }
38
+ // Try JWT Bearer token
39
+ const authHeader = request.headers["authorization"];
40
+ if (authHeader && typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
41
+ const token = authHeader.slice(7);
42
+ try {
43
+ const payload = jsonwebtoken_1.default.verify(token, config_js_1.config.jwtSecret, { algorithms: ["HS256"] });
44
+ // Store a synthetic api key record from JWT claims
45
+ // Extract scopes from JWT claims; default to full access for backward compat
46
+ const jwtScopes = Array.isArray(payload.scopes)
47
+ ? payload.scopes.filter((s) => typeof s === "string" && ["read", "write", "admin"].includes(s))
48
+ : ["read"];
49
+ request.apiKey = {
50
+ id: payload.sub ?? "",
51
+ tenant_id: payload.tenant_id ?? null,
52
+ key_hash: "",
53
+ name: payload.name ?? "jwt",
54
+ created_at: new Date(),
55
+ scopes: jwtScopes,
56
+ rate_limit_max: null,
57
+ rate_limit_window: null,
58
+ };
59
+ request.authMethod = "jwt";
60
+ return;
61
+ }
62
+ catch {
63
+ throw new core_1.UnauthorizedError("Invalid or expired token");
64
+ }
65
+ }
66
+ throw new core_1.UnauthorizedError("Authentication required. Provide X-API-Key or Authorization: Bearer header");
67
+ };
68
+ }
69
+ exports.createAuthMiddleware = createAuthMiddleware;
70
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import { FastifyRequest, FastifyReply } from "fastify";
2
+ export declare function createAuthorizeMiddleware(): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
3
+ //# sourceMappingURL=authorize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authorize.d.ts","sourceRoot":"","sources":["../../src/middleware/authorize.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAqCvD,wBAAgB,yBAAyB,cAE5B,cAAc,SAChB,YAAY,KAClB,QAAQ,IAAI,CAAC,CAsBjB"}
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAuthorizeMiddleware = void 0;
4
+ const core_1 = require("@stratum-hq/core");
5
+ function methodToScope(method) {
6
+ switch (method.toUpperCase()) {
7
+ case "GET":
8
+ case "HEAD":
9
+ case "OPTIONS":
10
+ return "read";
11
+ default:
12
+ return "write";
13
+ }
14
+ }
15
+ // Route patterns that require admin scope for ALL methods
16
+ const ADMIN_ROUTES = [
17
+ /^\/api\/v1\/api-keys/,
18
+ /^\/api\/v1\/audit-logs/,
19
+ /^\/api\/v1\/maintenance/,
20
+ /^\/api\/v1\/regions/,
21
+ /^\/api\/v1\/tenants\/[^/]+\/purge$/,
22
+ /^\/api\/v1\/tenants\/[^/]+\/export$/,
23
+ /^\/api\/v1\/tenants\/[^/]+\/migrate-region$/,
24
+ /^\/api\/v1\/tenants\/[^/]+\/context$/,
25
+ ];
26
+ function getRequiredScope(method, url) {
27
+ for (const pattern of ADMIN_ROUTES) {
28
+ if (pattern.test(url)) {
29
+ return "admin";
30
+ }
31
+ }
32
+ return methodToScope(method);
33
+ }
34
+ function createAuthorizeMiddleware() {
35
+ return async function authorizeMiddleware(request, reply) {
36
+ // Skip for health and documentation endpoints
37
+ if (request.url === "/api/v1/health" ||
38
+ request.url.startsWith("/api/v1/health?") ||
39
+ request.url.startsWith("/api/docs")) {
40
+ return;
41
+ }
42
+ // If no apiKey, auth middleware should have rejected — fail closed
43
+ if (!request.apiKey) {
44
+ throw new core_1.UnauthorizedError("Authentication required");
45
+ }
46
+ const requiredScope = getRequiredScope(request.method, request.url);
47
+ const scopes = request.apiKey.scopes ?? ["read"];
48
+ if (!scopes.includes(requiredScope)) {
49
+ throw new core_1.ForbiddenError("Insufficient permissions for this operation");
50
+ }
51
+ };
52
+ }
53
+ exports.createAuthorizeMiddleware = createAuthorizeMiddleware;
54
+ //# sourceMappingURL=authorize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authorize.js","sourceRoot":"","sources":["../../src/middleware/authorize.ts"],"names":[],"mappings":";;;AACA,2CAAqE;AAIrE,SAAS,aAAa,CAAC,MAAc;IACnC,QAAQ,MAAM,CAAC,WAAW,EAAE,EAAE,CAAC;QAC7B,KAAK,KAAK,CAAC;QACX,KAAK,MAAM,CAAC;QACZ,KAAK,SAAS;YACZ,OAAO,MAAM,CAAC;QAChB;YACE,OAAO,OAAO,CAAC;IACnB,CAAC;AACH,CAAC;AAED,0DAA0D;AAC1D,MAAM,YAAY,GAAG;IACnB,sBAAsB;IACtB,wBAAwB;IACxB,yBAAyB;IACzB,qBAAqB;IACrB,oCAAoC;IACpC,qCAAqC;IACrC,6CAA6C;IAC7C,sCAAsC;CACvC,CAAC;AAEF,SAAS,gBAAgB,CAAC,MAAc,EAAE,GAAW;IACnD,KAAK,MAAM,OAAO,IAAI,YAAY,EAAE,CAAC;QACnC,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACtB,OAAO,OAAO,CAAC;QACjB,CAAC;IACH,CAAC;IACD,OAAO,aAAa,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC;AAED,SAAgB,yBAAyB;IACvC,OAAO,KAAK,UAAU,mBAAmB,CACvC,OAAuB,EACvB,KAAmB;QAEnB,8CAA8C;QAC9C,IACE,OAAO,CAAC,GAAG,KAAK,gBAAgB;YAChC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,iBAAiB,CAAC;YACzC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,WAAW,CAAC,EACnC,CAAC;YACD,OAAO;QACT,CAAC;QAED,mEAAmE;QACnE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YACpB,MAAM,IAAI,wBAAiB,CAAC,yBAAyB,CAAC,CAAC;QACzD,CAAC;QAED,MAAM,aAAa,GAAG,gBAAgB,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;QACpE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,CAAC;QAEjD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC;YACpC,MAAM,IAAI,qBAAc,CAAC,6CAA6C,CAAC,CAAC;QAC1E,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AA1BD,8DA0BC"}
@@ -0,0 +1,3 @@
1
+ import { FastifyRequest, FastifyReply, FastifyError } from "fastify";
2
+ export declare function errorHandler(error: FastifyError | Error, request: FastifyRequest, reply: FastifyReply): void;
3
+ //# sourceMappingURL=error-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-handler.d.ts","sourceRoot":"","sources":["../../src/middleware/error-handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAKrE,wBAAgB,YAAY,CAC1B,KAAK,EAAE,YAAY,GAAG,KAAK,EAC3B,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,GAClB,IAAI,CA2CN"}
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.errorHandler = void 0;
4
+ const zod_1 = require("zod");
5
+ const core_1 = require("@stratum-hq/core");
6
+ const config_js_1 = require("../config.js");
7
+ function errorHandler(error, request, reply) {
8
+ if (config_js_1.config.nodeEnv === "development") {
9
+ console.error("[error]", error);
10
+ }
11
+ if (error instanceof core_1.StratumError) {
12
+ reply.status(error.statusCode).send(error.toJSON());
13
+ return;
14
+ }
15
+ if (error instanceof zod_1.ZodError) {
16
+ reply.status(400).send({
17
+ error: {
18
+ code: "VALIDATION_ERROR",
19
+ message: "Validation failed",
20
+ issues: error.issues.map((issue) => ({
21
+ path: issue.path,
22
+ message: issue.message,
23
+ code: issue.code,
24
+ })),
25
+ },
26
+ });
27
+ return;
28
+ }
29
+ // Fastify validation errors (e.g. schema validation)
30
+ const fastifyError = error;
31
+ if (fastifyError.statusCode && fastifyError.statusCode < 500) {
32
+ reply.status(fastifyError.statusCode).send({
33
+ error: {
34
+ code: "VALIDATION_ERROR",
35
+ message: error.message,
36
+ },
37
+ });
38
+ return;
39
+ }
40
+ reply.status(500).send({
41
+ error: {
42
+ code: "INTERNAL_SERVER_ERROR",
43
+ message: "An unexpected error occurred",
44
+ },
45
+ });
46
+ }
47
+ exports.errorHandler = errorHandler;
48
+ //# sourceMappingURL=error-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-handler.js","sourceRoot":"","sources":["../../src/middleware/error-handler.ts"],"names":[],"mappings":";;;AACA,6BAA+B;AAC/B,2CAAgD;AAChD,4CAAsC;AAEtC,SAAgB,YAAY,CAC1B,KAA2B,EAC3B,OAAuB,EACvB,KAAmB;IAEnB,IAAI,kBAAM,CAAC,OAAO,KAAK,aAAa,EAAE,CAAC;QACrC,OAAO,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;IAClC,CAAC;IAED,IAAI,KAAK,YAAY,mBAAY,EAAE,CAAC;QAClC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;QACpD,OAAO;IACT,CAAC;IAED,IAAI,KAAK,YAAY,cAAQ,EAAE,CAAC;QAC9B,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACrB,KAAK,EAAE;gBACL,IAAI,EAAE,kBAAkB;gBACxB,OAAO,EAAE,mBAAmB;gBAC5B,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;oBACnC,IAAI,EAAE,KAAK,CAAC,IAAI;oBAChB,OAAO,EAAE,KAAK,CAAC,OAAO;oBACtB,IAAI,EAAE,KAAK,CAAC,IAAI;iBACjB,CAAC,CAAC;aACJ;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,qDAAqD;IACrD,MAAM,YAAY,GAAG,KAAqB,CAAC;IAC3C,IAAI,YAAY,CAAC,UAAU,IAAI,YAAY,CAAC,UAAU,GAAG,GAAG,EAAE,CAAC;QAC7D,KAAK,CAAC,MAAM,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC;YACzC,KAAK,EAAE;gBACL,IAAI,EAAE,kBAAkB;gBACxB,OAAO,EAAE,KAAK,CAAC,OAAO;aACvB;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;QACrB,KAAK,EAAE;YACL,IAAI,EAAE,uBAAuB;YAC7B,OAAO,EAAE,8BAA8B;SACxC;KACF,CAAC,CAAC;AACL,CAAC;AA/CD,oCA+CC"}
@@ -0,0 +1,3 @@
1
+ import { FastifyRequest, FastifyReply } from "fastify";
2
+ export declare function createPerKeyRateLimitMiddleware(): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
3
+ //# sourceMappingURL=per-key-rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"per-key-rate-limit.d.ts","sourceRoot":"","sources":["../../src/middleware/per-key-rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAqCvD,wBAAgB,+BAA+B,cAKlC,cAAc,SAChB,YAAY,KAClB,QAAQ,IAAI,CAAC,CA2CjB"}
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPerKeyRateLimitMiddleware = void 0;
4
+ const config_js_1 = require("../config.js");
5
+ const keyLimits = new Map();
6
+ /** Parse a duration string like "1 minute", "30 seconds", "1 hour" into milliseconds. */
7
+ function parseWindow(window) {
8
+ const match = window.match(/^(\d+)\s*(second|minute|hour|day)s?$/i);
9
+ if (!match)
10
+ return 60_000; // default 1 minute
11
+ const value = parseInt(match[1], 10);
12
+ switch (match[2].toLowerCase()) {
13
+ case "second": return value * 1_000;
14
+ case "minute": return value * 60_000;
15
+ case "hour": return value * 3_600_000;
16
+ case "day": return value * 86_400_000;
17
+ default: return 60_000;
18
+ }
19
+ }
20
+ function createPerKeyRateLimitMiddleware() {
21
+ const globalMax = config_js_1.config.rateLimitMax;
22
+ const globalWindowMs = parseWindow(config_js_1.config.rateLimitWindow);
23
+ return async function perKeyRateLimitMiddleware(request, reply) {
24
+ // Skip for unauthenticated requests (health endpoint)
25
+ if (!request.apiKey)
26
+ return;
27
+ const keyId = request.apiKey.id;
28
+ const max = request.apiKey.rate_limit_max ?? globalMax;
29
+ const windowMs = request.apiKey.rate_limit_window
30
+ ? parseWindow(request.apiKey.rate_limit_window)
31
+ : globalWindowMs;
32
+ let entry = keyLimits.get(keyId);
33
+ if (!entry || entry.max !== max || entry.windowMs !== windowMs) {
34
+ entry = { timestamps: [], windowMs, max };
35
+ keyLimits.set(keyId, entry);
36
+ }
37
+ const now = Date.now();
38
+ const cutoff = now - windowMs;
39
+ // Prune expired timestamps
40
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
41
+ if (entry.timestamps.length >= max) {
42
+ const retryAfter = Math.ceil((entry.timestamps[0] + windowMs - now) / 1000);
43
+ reply.header("Retry-After", String(retryAfter));
44
+ reply.header("X-RateLimit-Limit", String(max));
45
+ reply.header("X-RateLimit-Remaining", "0");
46
+ reply.header("X-RateLimit-Reset", new Date(entry.timestamps[0] + windowMs).toISOString());
47
+ reply.status(429).send({
48
+ error: {
49
+ code: "RATE_LIMITED",
50
+ message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
51
+ },
52
+ });
53
+ return;
54
+ }
55
+ entry.timestamps.push(now);
56
+ // Set rate limit headers on all responses
57
+ reply.header("X-RateLimit-Limit", String(max));
58
+ reply.header("X-RateLimit-Remaining", String(max - entry.timestamps.length));
59
+ };
60
+ }
61
+ exports.createPerKeyRateLimitMiddleware = createPerKeyRateLimitMiddleware;
62
+ //# sourceMappingURL=per-key-rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"per-key-rate-limit.js","sourceRoot":"","sources":["../../src/middleware/per-key-rate-limit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import { FastifyRequest, FastifyReply } from "fastify";
2
+ import type Redis from "ioredis";
3
+ export interface RedisRateLimitConfig {
4
+ /** Maximum number of requests allowed in the window. */
5
+ maxRequests: number;
6
+ /** Window duration in milliseconds. */
7
+ windowMs: number;
8
+ }
9
+ /**
10
+ * Create a Redis-backed per-key rate limiter.
11
+ *
12
+ * Returns a Fastify preHandler hook. If Redis is unavailable or slow (>500ms)
13
+ * the request is allowed through (fail-open).
14
+ */
15
+ export declare function createRedisRateLimiter(redis: Redis, defaults: RedisRateLimitConfig): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
16
+ //# sourceMappingURL=rate-limit-redis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit-redis.d.ts","sourceRoot":"","sources":["../../src/middleware/rate-limit-redis.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvD,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,MAAM,WAAW,oBAAoB;IACnC,wDAAwD;IACxD,WAAW,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAwBD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,KAAK,EACZ,QAAQ,EAAE,oBAAoB,aAcnB,cAAc,SAChB,YAAY,KAClB,QAAQ,IAAI,CAAC,CA+CjB"}
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRedisRateLimiter = void 0;
4
+ /**
5
+ * Lua script implementing a sliding-window counter.
6
+ *
7
+ * Keys[1] = the rate-limit key
8
+ * ARGV[1] = window TTL in seconds
9
+ * ARGV[2] = current timestamp in seconds (integer)
10
+ *
11
+ * The key is of the form stratum:ratelimit:{api_key_id}:{window_start}
12
+ * where window_start is computed on the caller side so the key naturally
13
+ * rotates every window.
14
+ *
15
+ * The script INCRs the key, sets a TTL on first creation, and returns the
16
+ * current count.
17
+ */
18
+ const SLIDING_WINDOW_LUA = `
19
+ local current = redis.call('INCR', KEYS[1])
20
+ if current == 1 then
21
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
22
+ end
23
+ return current
24
+ `;
25
+ /**
26
+ * Create a Redis-backed per-key rate limiter.
27
+ *
28
+ * Returns a Fastify preHandler hook. If Redis is unavailable or slow (>500ms)
29
+ * the request is allowed through (fail-open).
30
+ */
31
+ function createRedisRateLimiter(redis, defaults) {
32
+ // Pre-load the Lua script so subsequent calls use EVALSHA
33
+ let scriptSha = null;
34
+ redis
35
+ .script("LOAD", SLIDING_WINDOW_LUA)
36
+ .then((sha) => {
37
+ scriptSha = sha;
38
+ })
39
+ .catch(() => {
40
+ // Will fall back to EVAL on each call
41
+ });
42
+ return async function redisRateLimitHook(request, reply) {
43
+ // Skip for unauthenticated requests (health, docs)
44
+ if (!request.apiKey)
45
+ return;
46
+ const keyId = request.apiKey.id;
47
+ const max = request.apiKey.rate_limit_max ?? defaults.maxRequests;
48
+ const windowMs = request.apiKey.rate_limit_window
49
+ ? parseWindowStr(request.apiKey.rate_limit_window)
50
+ : defaults.windowMs;
51
+ const now = Date.now();
52
+ const windowStart = Math.floor(now / windowMs) * windowMs;
53
+ const windowTtlSec = Math.ceil(windowMs / 1000);
54
+ const redisKey = `stratum:ratelimit:${keyId}:${windowStart}`;
55
+ // The window resets at windowStart + windowMs
56
+ const resetAt = new Date(windowStart + windowMs);
57
+ let currentCount;
58
+ try {
59
+ currentCount = await withTimeout(evalScript(redis, scriptSha, redisKey, windowTtlSec), 500);
60
+ }
61
+ catch {
62
+ // Redis unavailable or slow — fail-open
63
+ return;
64
+ }
65
+ // Set rate-limit headers on every response
66
+ const remaining = Math.max(0, max - currentCount);
67
+ reply.header("X-RateLimit-Limit", String(max));
68
+ reply.header("X-RateLimit-Remaining", String(remaining));
69
+ reply.header("X-RateLimit-Reset", resetAt.toISOString());
70
+ if (currentCount > max) {
71
+ const retryAfterSec = Math.ceil((windowStart + windowMs - now) / 1000);
72
+ reply.header("Retry-After", String(retryAfterSec));
73
+ reply.status(429).send({
74
+ error: {
75
+ code: "RATE_LIMITED",
76
+ message: `Rate limit exceeded. Try again in ${retryAfterSec} seconds.`,
77
+ },
78
+ });
79
+ }
80
+ };
81
+ }
82
+ exports.createRedisRateLimiter = createRedisRateLimiter;
83
+ // ---------------------------------------------------------------------------
84
+ // Helpers
85
+ // ---------------------------------------------------------------------------
86
+ async function evalScript(redis, sha, key, ttlSec) {
87
+ const now = Math.floor(Date.now() / 1000);
88
+ try {
89
+ if (sha) {
90
+ return (await redis.evalsha(sha, 1, key, String(ttlSec), String(now)));
91
+ }
92
+ }
93
+ catch {
94
+ // NOSCRIPT or other error — fall through to EVAL
95
+ }
96
+ return (await redis.eval(SLIDING_WINDOW_LUA, 1, key, String(ttlSec), String(now)));
97
+ }
98
+ function withTimeout(promise, ms) {
99
+ return new Promise((resolve, reject) => {
100
+ const timer = setTimeout(() => reject(new Error("Redis timeout")), ms);
101
+ promise.then((val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); });
102
+ });
103
+ }
104
+ /** Parse a duration string like "1 minute" into milliseconds. */
105
+ function parseWindowStr(window) {
106
+ const match = window.match(/^(\d+)\s*(second|minute|hour|day)s?$/i);
107
+ if (!match)
108
+ return 60_000;
109
+ const value = parseInt(match[1], 10);
110
+ switch (match[2].toLowerCase()) {
111
+ case "second": return value * 1_000;
112
+ case "minute": return value * 60_000;
113
+ case "hour": return value * 3_600_000;
114
+ case "day": return value * 86_400_000;
115
+ default: return 60_000;
116
+ }
117
+ }
118
+ //# sourceMappingURL=rate-limit-redis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit-redis.js","sourceRoot":"","sources":["../../src/middleware/rate-limit-redis.ts"],"names":[],"mappings":";;;AAUA;;;;;;;;;;;;;GAaG;AACH,MAAM,kBAAkB,GAAG;;;;;;CAM1B,CAAC;AAEF;;;;;GAKG;AACH,SAAgB,sBAAsB,CACpC,KAAY,EACZ,QAA8B;IAE9B,0DAA0D;IAC1D,IAAI,SAAS,GAAkB,IAAI,CAAC;IACpC,KAAK;SACF,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC;SAClC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE;QACZ,SAAS,GAAG,GAAa,CAAC;IAC5B,CAAC,CAAC;SACD,KAAK,CAAC,GAAG,EAAE;QACV,sCAAsC;IACxC,CAAC,CAAC,CAAC;IAEL,OAAO,KAAK,UAAU,kBAAkB,CACtC,OAAuB,EACvB,KAAmB;QAEnB,mDAAmD;QACnD,IAAI,CAAC,OAAO,CAAC,MAAM;YAAE,OAAO;QAE5B,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAChC,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,cAAc,IAAI,QAAQ,CAAC,WAAW,CAAC;QAClE,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,iBAAiB;YAC/C,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC,MAAM,CAAC,iBAAiB,CAAC;YAClD,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAEtB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,QAAQ,CAAC,GAAG,QAAQ,CAAC;QAC1D,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;QAChD,MAAM,QAAQ,GAAG,qBAAqB,KAAK,IAAI,WAAW,EAAE,CAAC;QAE7D,8CAA8C;QAC9C,MAAM,OAAO,GAAG,IAAI,IAAI,CAAC,WAAW,GAAG,QAAQ,CAAC,CAAC;QAEjD,IAAI,YAAoB,CAAC;QAEzB,IAAI,CAAC;YACH,YAAY,GAAG,MAAM,WAAW,CAC9B,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,CAAC,EACpD,GAAG,CACJ,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,wCAAwC;YACxC,OAAO;QACT,CAAC;QAED,2CAA2C;QAC3C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,YAAY,CAAC,CAAC;QAClD,KAAK,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QAC/C,KAAK,CAAC,MAAM,CAAC,uBAAuB,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC;QACzD,KAAK,CAAC,MAAM,CAAC,mBAAmB,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;QAEzD,IAAI,YAAY,GAAG,GAAG,EAAE,CAAC;YACvB,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,WAAW,GAAG,QAAQ,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;YACvE,KAAK,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC;YACnD,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;gBACrB,KAAK,EAAE;oBACL,IAAI,EAAE,cAAc;oBACpB,OAAO,EAAE,qCAAqC,aAAa,WAAW;iBACvE;aACF,CAAC,CAAC;QACL,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAjED,wDAiEC;AAED,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,KAAK,UAAU,UAAU,CACvB,KAAY,EACZ,GAAkB,EAClB,GAAW,EACX,MAAc;IAEd,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAC1C,IAAI,CAAC;QACH,IAAI,GAAG,EAAE,CAAC;YACR,OAAO,CAAC,MAAM,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAW,CAAC;QACnF,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,iDAAiD;IACnD,CAAC;IACD,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAW,CAAC;AAC/F,CAAC;AAED,SAAS,WAAW,CAAI,OAAmB,EAAE,EAAU;IACrD,OAAO,IAAI,OAAO,CAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACxC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,eAAe,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACvE,OAAO,CAAC,IAAI,CACV,CAAC,GAAG,EAAE,EAAE,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAC/C,CAAC,GAAG,EAAE,EAAE,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAC/C,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAED,iEAAiE;AACjE,SAAS,cAAc,CAAC,MAAc;IACpC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;IACpE,IAAI,CAAC,KAAK;QAAE,OAAO,MAAM,CAAC;IAC1B,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACrC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;QAC/B,KAAK,QAAQ,CAAC,CAAC,OAAO,KAAK,GAAG,KAAK,CAAC;QACpC,KAAK,QAAQ,CAAC,CAAC,OAAO,KAAK,GAAG,MAAM,CAAC;QACrC,KAAK,MAAM,CAAC,CAAG,OAAO,KAAK,GAAG,SAAS,CAAC;QACxC,KAAK,KAAK,CAAC,CAAI,OAAO,KAAK,GAAG,UAAU,CAAC;QACzC,OAAO,CAAC,CAAO,OAAO,MAAM,CAAC;IAC/B,CAAC;AACH,CAAC"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OpenTelemetry Fastify middleware for the Stratum control plane.
3
+ *
4
+ * Creates a span per HTTP request and records standard HTTP + Stratum
5
+ * attributes. Gracefully no-ops when @opentelemetry/api is not installed.
6
+ */
7
+ import type { FastifyInstance } from "fastify";
8
+ /**
9
+ * Register the telemetry onRequest / onResponse hooks on a Fastify instance.
10
+ *
11
+ * If @opentelemetry/api is not installed, this is a no-op.
12
+ */
13
+ export declare function registerTelemetryHooks(app: FastifyInstance): void;
14
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../../src/middleware/telemetry.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAe7E;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CA4DjE"}
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * OpenTelemetry Fastify middleware for the Stratum control plane.
4
+ *
5
+ * Creates a span per HTTP request and records standard HTTP + Stratum
6
+ * attributes. Gracefully no-ops when @opentelemetry/api is not installed.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.registerTelemetryHooks = void 0;
10
+ // Lazy-loaded OTel API — stays `null` when the package is absent.
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ let otel = null;
13
+ try {
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ otel = require("@opentelemetry/api");
16
+ }
17
+ catch {
18
+ // @opentelemetry/api is not installed — telemetry is a no-op.
19
+ }
20
+ const TRACER_NAME = "@stratum-hq/control-plane";
21
+ /**
22
+ * Register the telemetry onRequest / onResponse hooks on a Fastify instance.
23
+ *
24
+ * If @opentelemetry/api is not installed, this is a no-op.
25
+ */
26
+ function registerTelemetryHooks(app) {
27
+ if (!otel)
28
+ return;
29
+ const tracer = otel.trace.getTracer(TRACER_NAME);
30
+ const { SpanStatusCode, SpanKind } = otel;
31
+ // Store spans keyed by request id so the onResponse hook can close them.
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ const inflightSpans = new Map();
34
+ app.addHook("onRequest", async (request, _reply) => {
35
+ const span = tracer.startSpan(`HTTP ${request.method} ${request.routeOptions?.url ?? request.url}`, {
36
+ kind: SpanKind.SERVER,
37
+ attributes: {
38
+ "http.method": request.method,
39
+ "http.url": request.url,
40
+ "http.route": request.routeOptions?.url ?? request.url,
41
+ "http.request_id": request.id,
42
+ },
43
+ });
44
+ // Attach tenant_id from the authenticated key if available
45
+ if (request.apiKey?.tenant_id) {
46
+ span.setAttribute("stratum.tenant_id", request.apiKey.tenant_id);
47
+ }
48
+ inflightSpans.set(request.id, span);
49
+ });
50
+ app.addHook("onResponse", async (request, reply) => {
51
+ const span = inflightSpans.get(request.id);
52
+ if (!span)
53
+ return;
54
+ inflightSpans.delete(request.id);
55
+ span.setAttribute("http.status_code", reply.statusCode);
56
+ if (reply.statusCode >= 400) {
57
+ span.setStatus({
58
+ code: SpanStatusCode.ERROR,
59
+ message: `HTTP ${reply.statusCode}`,
60
+ });
61
+ }
62
+ else {
63
+ span.setStatus({ code: SpanStatusCode.OK });
64
+ }
65
+ span.end();
66
+ });
67
+ // Safety net: if the request errors out before onResponse fires
68
+ app.addHook("onError", async (request, _reply, error) => {
69
+ const span = inflightSpans.get(request.id);
70
+ if (!span)
71
+ return;
72
+ span.setStatus({
73
+ code: SpanStatusCode.ERROR,
74
+ message: error.message,
75
+ });
76
+ span.recordException(error);
77
+ // Don't end here — onResponse will still fire and end the span.
78
+ });
79
+ }
80
+ exports.registerTelemetryHooks = registerTelemetryHooks;
81
+ //# sourceMappingURL=telemetry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../../src/middleware/telemetry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ import { FastifyRequest, FastifyReply } from "fastify";
2
+ import { Stratum } from "@stratum-hq/lib";
3
+ /**
4
+ * Tenant-scope enforcement middleware.
5
+ *
6
+ * Ensures that tenant-scoped API keys can only access data belonging
7
+ * to their own tenant or its descendants in the hierarchy.
8
+ *
9
+ * Global keys (tenant_id === null) have unrestricted access.
10
+ */
11
+ export declare function createTenantScopeGuard(stratum: Stratum, extractTenantId: (req: FastifyRequest) => string | null): (request: FastifyRequest, _reply: FastifyReply) => Promise<void>;
12
+ /** Extract :id param (tenant routes, config routes, permission routes) */
13
+ export declare function fromParamId(req: FastifyRequest): string | null;
14
+ /** Extract :tenantId param (consent routes) */
15
+ export declare function fromParamTenantId(req: FastifyRequest): string | null;
16
+ /** Extract tenant_id from query string */
17
+ export declare function fromQueryTenantId(req: FastifyRequest): string | null;
18
+ /** Extract tenant_id from request body */
19
+ export declare function fromBodyTenantId(req: FastifyRequest): string | null;
20
+ //# sourceMappingURL=tenant-scope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-scope.d.ts","sourceRoot":"","sources":["../../src/middleware/tenant-scope.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvD,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAE1C;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,OAAO,EAChB,eAAe,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,MAAM,GAAG,IAAI,aAG5C,cAAc,UACf,YAAY,KACnB,QAAQ,IAAI,CAAC,CA0BjB;AAID,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAE9D;AAED,+CAA+C;AAC/C,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAEpE;AAED,0CAA0C;AAC1C,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAEpE;AAED,0CAA0C;AAC1C,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CAEnE"}
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fromBodyTenantId = exports.fromQueryTenantId = exports.fromParamTenantId = exports.fromParamId = exports.createTenantScopeGuard = void 0;
4
+ const core_1 = require("@stratum-hq/core");
5
+ /**
6
+ * Tenant-scope enforcement middleware.
7
+ *
8
+ * Ensures that tenant-scoped API keys can only access data belonging
9
+ * to their own tenant or its descendants in the hierarchy.
10
+ *
11
+ * Global keys (tenant_id === null) have unrestricted access.
12
+ */
13
+ function createTenantScopeGuard(stratum, extractTenantId) {
14
+ return async function tenantScopeGuard(request, _reply) {
15
+ const apiKey = request.apiKey;
16
+ if (!apiKey || apiKey.tenant_id === null)
17
+ return;
18
+ const targetTenantId = extractTenantId(request);
19
+ if (!targetTenantId)
20
+ return;
21
+ // Fast path: exact match
22
+ if (apiKey.tenant_id === targetTenantId)
23
+ return;
24
+ // Hierarchy check: target must be a descendant of the key's tenant
25
+ try {
26
+ const target = await stratum.getTenant(targetTenantId);
27
+ const ancestorIds = target.ancestry_path.split("/").filter(Boolean);
28
+ if (ancestorIds.includes(apiKey.tenant_id))
29
+ return;
30
+ }
31
+ catch {
32
+ // Tenant not found — fail closed for scoped keys
33
+ throw new core_1.ForbiddenError("API key tenant scope does not grant access to this tenant");
34
+ }
35
+ throw new core_1.ForbiddenError("API key tenant scope does not grant access to this tenant");
36
+ };
37
+ }
38
+ exports.createTenantScopeGuard = createTenantScopeGuard;
39
+ // --- Extractors ---
40
+ /** Extract :id param (tenant routes, config routes, permission routes) */
41
+ function fromParamId(req) {
42
+ return req.params.id ?? null;
43
+ }
44
+ exports.fromParamId = fromParamId;
45
+ /** Extract :tenantId param (consent routes) */
46
+ function fromParamTenantId(req) {
47
+ return req.params.tenantId ?? null;
48
+ }
49
+ exports.fromParamTenantId = fromParamTenantId;
50
+ /** Extract tenant_id from query string */
51
+ function fromQueryTenantId(req) {
52
+ return req.query.tenant_id ?? null;
53
+ }
54
+ exports.fromQueryTenantId = fromQueryTenantId;
55
+ /** Extract tenant_id from request body */
56
+ function fromBodyTenantId(req) {
57
+ return req.body?.tenant_id ?? null;
58
+ }
59
+ exports.fromBodyTenantId = fromBodyTenantId;
60
+ //# sourceMappingURL=tenant-scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-scope.js","sourceRoot":"","sources":["../../src/middleware/tenant-scope.ts"],"names":[],"mappings":";;;AACA,2CAAkD;AAGlD;;;;;;;GAOG;AACH,SAAgB,sBAAsB,CACpC,OAAgB,EAChB,eAAuD;IAEvD,OAAO,KAAK,UAAU,gBAAgB,CACpC,OAAuB,EACvB,MAAoB;QAEpB,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;QAC9B,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,SAAS,KAAK,IAAI;YAAE,OAAO;QAEjD,MAAM,cAAc,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;QAChD,IAAI,CAAC,cAAc;YAAE,OAAO;QAE5B,yBAAyB;QACzB,IAAI,MAAM,CAAC,SAAS,KAAK,cAAc;YAAE,OAAO;QAEhD,mEAAmE;QACnE,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC;YACvD,MAAM,WAAW,GAAG,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;YACpE,IAAI,WAAW,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC;gBAAE,OAAO;QACrD,CAAC;QAAC,MAAM,CAAC;YACP,iDAAiD;YACjD,MAAM,IAAI,qBAAc,CACtB,2DAA2D,CAC5D,CAAC;QACJ,CAAC;QAED,MAAM,IAAI,qBAAc,CACtB,2DAA2D,CAC5D,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC;AAjCD,wDAiCC;AAED,qBAAqB;AAErB,0EAA0E;AAC1E,SAAgB,WAAW,CAAC,GAAmB;IAC7C,OAAQ,GAAG,CAAC,MAAiC,CAAC,EAAE,IAAI,IAAI,CAAC;AAC3D,CAAC;AAFD,kCAEC;AAED,+CAA+C;AAC/C,SAAgB,iBAAiB,CAAC,GAAmB;IACnD,OAAQ,GAAG,CAAC,MAAiC,CAAC,QAAQ,IAAI,IAAI,CAAC;AACjE,CAAC;AAFD,8CAEC;AAED,0CAA0C;AAC1C,SAAgB,iBAAiB,CAAC,GAAmB;IACnD,OAAQ,GAAG,CAAC,KAAgC,CAAC,SAAS,IAAI,IAAI,CAAC;AACjE,CAAC;AAFD,8CAEC;AAED,0CAA0C;AAC1C,SAAgB,gBAAgB,CAAC,GAAmB;IAClD,OAAQ,GAAG,CAAC,IAAsC,EAAE,SAAS,IAAI,IAAI,CAAC;AACxE,CAAC;AAFD,4CAEC"}
@@ -0,0 +1,3 @@
1
+ import { FastifyInstance } from "fastify";
2
+ export declare function registerOpenApi(app: FastifyInstance): Promise<void>;
3
+ //# sourceMappingURL=openapi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI1C,wBAAsB,eAAe,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAkCzE"}