@grapity/grapity 0.3.0 → 0.4.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.
@@ -1756,6 +1756,7 @@ var pushRoute = new Hono().post("/", async (c) => {
1756
1756
  }
1757
1757
  const store = c.get("store");
1758
1758
  const service = new RegistryService(store);
1759
+ const actor = c.get("actor") ?? body.pushedBy;
1759
1760
  try {
1760
1761
  const result = await service.pushSpec(body.content, body.name, {
1761
1762
  type: body.type,
@@ -1764,7 +1765,7 @@ var pushRoute = new Hono().post("/", async (c) => {
1764
1765
  sourceRepo: body.sourceRepo,
1765
1766
  tags: Array.isArray(body.tags) ? body.tags : void 0,
1766
1767
  gitRef: body.gitRef,
1767
- pushedBy: body.pushedBy,
1768
+ pushedBy: actor,
1768
1769
  prerelease: body.prerelease,
1769
1770
  force: body.force,
1770
1771
  reason: body.reason
@@ -1929,7 +1930,8 @@ var deleteSpecRoute = new Hono5().delete(
1929
1930
  const name = c.req.param("name");
1930
1931
  const store = c.get("store");
1931
1932
  const service = new RegistryService(store);
1932
- const deleted = await service.deleteSpec(name);
1933
+ const actor = c.get("actor");
1934
+ const deleted = await service.deleteSpec(name, actor);
1933
1935
  if (!deleted) {
1934
1936
  return c.json({ error: "not_found", message: `Spec "${name}" not found`, statusCode: 404 }, 404);
1935
1937
  }
@@ -2670,6 +2672,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c) => {
2670
2672
  }
2671
2673
  const store = c.get("store");
2672
2674
  const service = new GatewayService(store, store);
2675
+ const actor = c.get("actor") ?? body.pushedBy;
2673
2676
  try {
2674
2677
  const result = await service.pushGatewayConfig({
2675
2678
  name: body.name,
@@ -2680,7 +2683,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c) => {
2680
2683
  environments: body.environments ?? {},
2681
2684
  callerIdentification: body.callerIdentification,
2682
2685
  content: body.content,
2683
- pushedBy: body.pushedBy
2686
+ pushedBy: actor
2684
2687
  });
2685
2688
  return c.json({ data: result }, 201);
2686
2689
  } catch (err) {
@@ -2945,7 +2948,137 @@ var gatewayLogStatsRoute = new Hono21().get("/stats", async (c) => {
2945
2948
  });
2946
2949
  });
2947
2950
 
2951
+ // src/registry/auth/middleware.ts
2952
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2953
+ var AuthError = class extends Error {
2954
+ constructor(statusCode, code, message) {
2955
+ super(message);
2956
+ this.statusCode = statusCode;
2957
+ this.code = code;
2958
+ this.name = "AuthError";
2959
+ }
2960
+ statusCode;
2961
+ code;
2962
+ };
2963
+ function buildKeycloakUrls(config) {
2964
+ const base = `${config.serverUrl}/realms/${config.realm}`;
2965
+ return {
2966
+ issuer: base,
2967
+ jwksUri: `${base}/protocol/openid-connect/certs`,
2968
+ tokenUrl: `${base}/protocol/openid-connect/token`
2969
+ };
2970
+ }
2971
+ function createAuthMiddleware(config, routeScopes) {
2972
+ if (config.auth?.mode !== "keycloak") {
2973
+ return async (_c, next) => await next();
2974
+ }
2975
+ const authConfig = config.auth;
2976
+ const { issuer, jwksUri } = buildKeycloakUrls(authConfig);
2977
+ const jwks = createRemoteJWKSet(new URL(jwksUri));
2978
+ const scopeByRoute = /* @__PURE__ */ new Map();
2979
+ for (const route of routeScopes) {
2980
+ const key = `${route.method.toUpperCase()}:${route.path}`;
2981
+ scopeByRoute.set(key, {
2982
+ operationId: route.operationId,
2983
+ scopes: route.scopes
2984
+ });
2985
+ }
2986
+ return async (c, next) => {
2987
+ const matchedPath = c.req.matchedRoutes.map((r) => r.path).filter((p) => p !== "/*").pop();
2988
+ const routeKey = matchedPath ? `${c.req.method}:${matchedPath}` : `${c.req.method}:${c.req.routePath}`;
2989
+ const required = scopeByRoute.get(routeKey);
2990
+ if (!required || required.scopes.length === 0) {
2991
+ return await next();
2992
+ }
2993
+ const authHeader = c.req.header("Authorization");
2994
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
2995
+ throw new AuthError(401, "unauthorized", "Bearer token required");
2996
+ }
2997
+ const token = authHeader.slice("Bearer ".length).trim();
2998
+ if (!token) {
2999
+ throw new AuthError(401, "unauthorized", "Bearer token required");
3000
+ }
3001
+ let payload;
3002
+ try {
3003
+ const result = await jwtVerify(token, jwks, {
3004
+ issuer,
3005
+ audience: authConfig.audience
3006
+ });
3007
+ payload = result.payload;
3008
+ } catch (err) {
3009
+ const message = err instanceof Error ? err.message : "Invalid token";
3010
+ throw new AuthError(401, "unauthorized", `Invalid or expired token: ${message}`);
3011
+ }
3012
+ const subject = payload.sub;
3013
+ if (!subject) {
3014
+ throw new AuthError(401, "unauthorized", "Token missing subject claim");
3015
+ }
3016
+ const grantedScopes = extractScopes(payload, authConfig.roleSource ?? "scope");
3017
+ const missing = required.scopes.filter((s) => !grantedScopes.has(s));
3018
+ if (missing.length > 0) {
3019
+ throw new AuthError(
3020
+ 403,
3021
+ "forbidden",
3022
+ `Missing required scope${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`
3023
+ );
3024
+ }
3025
+ c.set("actor", subject);
3026
+ c.set("claims", payload);
3027
+ await next();
3028
+ };
3029
+ }
3030
+ function extractScopes(payload, source) {
3031
+ if (source === "realm_access.roles") {
3032
+ const roles = payload.realm_access?.roles;
3033
+ return new Set(roles ?? []);
3034
+ }
3035
+ const scopeValue = payload.scope;
3036
+ if (typeof scopeValue === "string") {
3037
+ return new Set(scopeValue.split(/\s+/).filter(Boolean));
3038
+ }
3039
+ return /* @__PURE__ */ new Set();
3040
+ }
3041
+ function parseRouteScopes(spec) {
3042
+ const routes = [];
3043
+ const paths = spec.paths;
3044
+ if (!paths) return routes;
3045
+ for (const [path2, operations] of Object.entries(paths)) {
3046
+ for (const [method, operation] of Object.entries(operations)) {
3047
+ if (typeof operation !== "object" || operation === null) continue;
3048
+ const op = operation;
3049
+ const operationId = op.operationId;
3050
+ if (!operationId) continue;
3051
+ const security = op.security;
3052
+ const scopes = [];
3053
+ if (security) {
3054
+ for (const sec of security) {
3055
+ for (const [name, required] of Object.entries(sec)) {
3056
+ if (name === "keycloak" && Array.isArray(required)) {
3057
+ scopes.push(...required);
3058
+ }
3059
+ }
3060
+ }
3061
+ }
3062
+ routes.push({
3063
+ method: method.toUpperCase(),
3064
+ path: path2.replace(/\{([^}]+)\}/g, ":$1"),
3065
+ operationId,
3066
+ scopes
3067
+ });
3068
+ }
3069
+ }
3070
+ return routes;
3071
+ }
3072
+
2948
3073
  // src/registry/server.ts
3074
+ import { readFileSync } from "fs";
3075
+ import { fileURLToPath } from "url";
3076
+ import yaml6 from "js-yaml";
3077
+ function loadOpenApiSpec() {
3078
+ const path2 = fileURLToPath(new URL("../../openapi.yaml", import.meta.url));
3079
+ const content = readFileSync(path2, "utf-8");
3080
+ return yaml6.load(content);
3081
+ }
2949
3082
  function createApp(config, store) {
2950
3083
  const app = new Hono22();
2951
3084
  app.use("*", logger());
@@ -2956,6 +3089,21 @@ function createApp(config, store) {
2956
3089
  c.set("config", config);
2957
3090
  await next();
2958
3091
  });
3092
+ const routeScopes = parseRouteScopes(loadOpenApiSpec());
3093
+ app.use("*", createAuthMiddleware(config, routeScopes));
3094
+ app.onError((err, c) => {
3095
+ if (err instanceof AuthError) {
3096
+ return c.json(
3097
+ { error: err.code, message: err.message, statusCode: err.statusCode },
3098
+ err.statusCode
3099
+ );
3100
+ }
3101
+ console.error("Unhandled error:", err);
3102
+ return c.json(
3103
+ { error: "internal_error", message: "Internal server error", statusCode: 500 },
3104
+ 500
3105
+ );
3106
+ });
2959
3107
  app.route("/v1/specs", pushRoute);
2960
3108
  app.route("/v1/specs", validateRoute);
2961
3109
  app.route("/v1/specs", listRoute);
@@ -2983,7 +3131,8 @@ function createApp(config, store) {
2983
3131
  // src/registry/config.ts
2984
3132
  var defaultConfig = {
2985
3133
  port: 3750,
2986
- database: "sqlite"
3134
+ database: "sqlite",
3135
+ auth: { mode: "none" }
2987
3136
  };
2988
3137
 
2989
3138
  // src/registry/storage/sqlite.ts
@@ -3596,17 +3745,63 @@ var provisions2 = pgTable("provisions", {
3596
3745
  // src/registry/storage/postgresql.ts
3597
3746
  import { v4 as uuid6 } from "uuid";
3598
3747
  var MIGRATIONS_FOLDER2 = PG_MIGRATIONS_FOLDER;
3748
+ var DatabaseConnectionError = class extends Error {
3749
+ constructor(message, postgresUrl, options) {
3750
+ super(message, options);
3751
+ this.postgresUrl = postgresUrl;
3752
+ this.name = "DatabaseConnectionError";
3753
+ }
3754
+ postgresUrl;
3755
+ };
3756
+ function isConnectionError(err) {
3757
+ if (typeof err !== "object" || err === null) return false;
3758
+ if ("code" in err) {
3759
+ const code = err.code;
3760
+ if (code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "ENOTFOUND") {
3761
+ return true;
3762
+ }
3763
+ }
3764
+ if (err instanceof AggregateError) {
3765
+ if (err.errors.some(isConnectionError)) return true;
3766
+ }
3767
+ if ("cause" in err && err.cause) {
3768
+ return isConnectionError(err.cause);
3769
+ }
3770
+ return false;
3771
+ }
3772
+ function maskPostgresPassword(url) {
3773
+ try {
3774
+ const parsed = new URL(url);
3775
+ if (parsed.password) parsed.password = "***";
3776
+ return parsed.toString();
3777
+ } catch {
3778
+ return url;
3779
+ }
3780
+ }
3599
3781
  var PostgreSQLSpecStore = class {
3600
3782
  db;
3601
3783
  pool;
3784
+ postgresUrl;
3602
3785
  constructor(postgresUrl) {
3786
+ this.postgresUrl = postgresUrl;
3603
3787
  this.pool = new Pool({ connectionString: postgresUrl });
3604
3788
  this.db = drizzle2(this.pool);
3605
3789
  }
3606
3790
  async migrate() {
3607
- await migrate2(this.db, {
3608
- migrationsFolder: MIGRATIONS_FOLDER2
3609
- });
3791
+ try {
3792
+ await migrate2(this.db, {
3793
+ migrationsFolder: MIGRATIONS_FOLDER2
3794
+ });
3795
+ } catch (err) {
3796
+ if (isConnectionError(err)) {
3797
+ throw new DatabaseConnectionError(
3798
+ `PostgreSQL is not reachable at ${maskPostgresPassword(this.postgresUrl)}`,
3799
+ this.postgresUrl,
3800
+ { cause: err }
3801
+ );
3802
+ }
3803
+ throw err;
3804
+ }
3610
3805
  }
3611
3806
  async end() {
3612
3807
  await this.pool.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grapity/grapity",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "grapity - API spec registry and compatibility guardian",
6
6
  "license": "Apache-2.0",
@@ -66,26 +66,28 @@
66
66
  "provenance": true
67
67
  },
68
68
  "dependencies": {
69
- "commander": "^13.1.0",
70
- "js-yaml": "^4.1.0",
71
- "chalk": "^5.4.0",
72
- "ora": "^8.2.0",
69
+ "@apidevtools/swagger-parser": "^10.1.0",
73
70
  "@hono/node-server": "^2.0.4",
71
+ "better-sqlite3": "^12.10.0",
72
+ "chalk": "^5.4.0",
73
+ "commander": "^13.1.0",
74
+ "drizzle-orm": "^0.44.0",
74
75
  "hono": "^4.12.22",
76
+ "jose": "^6.2.3",
77
+ "js-yaml": "^4.1.0",
75
78
  "lucide-react": "^0.475.0",
79
+ "ora": "^8.2.0",
80
+ "pg": "^8.16.0",
76
81
  "react": "^19.0.0",
77
82
  "react-dom": "^19.0.0",
78
83
  "react-router-dom": "^7.4.0",
79
84
  "shiki": "^4.2.0",
80
- "drizzle-orm": "^0.44.0",
81
- "better-sqlite3": "^12.10.0",
82
- "pg": "^8.16.0",
83
- "@apidevtools/swagger-parser": "^10.1.0",
84
85
  "uuid": "^11.1.0",
85
86
  "zod": "^3.24.0"
86
87
  },
87
88
  "devDependencies": {
88
89
  "@tailwindcss/vite": "^4.3.0",
90
+ "@testcontainers/postgresql": "^11.14.0",
89
91
  "@testing-library/dom": "^10.4.0",
90
92
  "@testing-library/jest-dom": "^6.6.0",
91
93
  "@testing-library/react": "^16.2.0",
@@ -103,10 +105,10 @@
103
105
  "openapi-typescript": "^7.13.0",
104
106
  "postcss": "^8.5.3",
105
107
  "tailwindcss": "^4.0.0",
108
+ "testcontainers": "11.14.0",
106
109
  "tsup": "^8.4.0",
107
110
  "tsx": "^4.19.0",
108
111
  "typescript": "^5.8.0",
109
- "vite": "^6.3.0",
110
- "@testcontainers/postgresql": "^11.14.0"
112
+ "vite": "^6.3.0"
111
113
  }
112
114
  }