@grapity/grapity 0.3.0 → 0.4.1

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,6 +2948,283 @@ 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, routeScopes2) {
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 routeScopes2) {
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
+
3042
+ // src/registry/generated/route-scopes.ts
3043
+ var routeScopes = [
3044
+ {
3045
+ "method": "GET",
3046
+ "path": "/v1/health",
3047
+ "operationId": "getHealth",
3048
+ "scopes": []
3049
+ },
3050
+ {
3051
+ "method": "GET",
3052
+ "path": "/v1/specs",
3053
+ "operationId": "listSpecs",
3054
+ "scopes": [
3055
+ "specs:read"
3056
+ ]
3057
+ },
3058
+ {
3059
+ "method": "POST",
3060
+ "path": "/v1/specs",
3061
+ "operationId": "pushSpec",
3062
+ "scopes": [
3063
+ "specs:write"
3064
+ ]
3065
+ },
3066
+ {
3067
+ "method": "GET",
3068
+ "path": "/v1/specs/:name",
3069
+ "operationId": "getSpec",
3070
+ "scopes": [
3071
+ "specs:read"
3072
+ ]
3073
+ },
3074
+ {
3075
+ "method": "DELETE",
3076
+ "path": "/v1/specs/:name",
3077
+ "operationId": "deleteSpec",
3078
+ "scopes": [
3079
+ "specs:write"
3080
+ ]
3081
+ },
3082
+ {
3083
+ "method": "POST",
3084
+ "path": "/v1/specs/:name/validate",
3085
+ "operationId": "validateSpec",
3086
+ "scopes": [
3087
+ "specs:write"
3088
+ ]
3089
+ },
3090
+ {
3091
+ "method": "GET",
3092
+ "path": "/v1/specs/:name/versions",
3093
+ "operationId": "listVersions",
3094
+ "scopes": [
3095
+ "specs:read"
3096
+ ]
3097
+ },
3098
+ {
3099
+ "method": "GET",
3100
+ "path": "/v1/specs/:name/versions/:semver",
3101
+ "operationId": "getVersion",
3102
+ "scopes": [
3103
+ "specs:read"
3104
+ ]
3105
+ },
3106
+ {
3107
+ "method": "GET",
3108
+ "path": "/v1/specs/:name/spec.json",
3109
+ "operationId": "getSpecJson",
3110
+ "scopes": [
3111
+ "specs:read"
3112
+ ]
3113
+ },
3114
+ {
3115
+ "method": "GET",
3116
+ "path": "/v1/specs/:name/spec.yaml",
3117
+ "operationId": "getSpecYaml",
3118
+ "scopes": [
3119
+ "specs:read"
3120
+ ]
3121
+ },
3122
+ {
3123
+ "method": "GET",
3124
+ "path": "/v1/specs/:name/versions/:semver/spec.json",
3125
+ "operationId": "getVersionSpecJson",
3126
+ "scopes": [
3127
+ "specs:read"
3128
+ ]
3129
+ },
3130
+ {
3131
+ "method": "GET",
3132
+ "path": "/v1/specs/:name/versions/:semver/spec.yaml",
3133
+ "operationId": "getVersionSpecYaml",
3134
+ "scopes": [
3135
+ "specs:read"
3136
+ ]
3137
+ },
3138
+ {
3139
+ "method": "GET",
3140
+ "path": "/v1/specs/:name/compat/:semver",
3141
+ "operationId": "getCompatReport",
3142
+ "scopes": [
3143
+ "specs:read"
3144
+ ]
3145
+ },
3146
+ {
3147
+ "method": "GET",
3148
+ "path": "/v1/specs/:name/compare",
3149
+ "operationId": "compareVersions",
3150
+ "scopes": [
3151
+ "specs:read"
3152
+ ]
3153
+ },
3154
+ {
3155
+ "method": "GET",
3156
+ "path": "/v1/gateway-configs",
3157
+ "operationId": "listGatewayConfigs",
3158
+ "scopes": [
3159
+ "gateway-configs:read"
3160
+ ]
3161
+ },
3162
+ {
3163
+ "method": "POST",
3164
+ "path": "/v1/gateway-configs",
3165
+ "operationId": "pushGatewayConfig",
3166
+ "scopes": [
3167
+ "gateway-configs:write"
3168
+ ]
3169
+ },
3170
+ {
3171
+ "method": "GET",
3172
+ "path": "/v1/gateway-configs/:name",
3173
+ "operationId": "getGatewayConfig",
3174
+ "scopes": [
3175
+ "gateway-configs:read"
3176
+ ]
3177
+ },
3178
+ {
3179
+ "method": "GET",
3180
+ "path": "/v1/gateway-configs/:name/versions",
3181
+ "operationId": "listGatewayConfigVersions",
3182
+ "scopes": [
3183
+ "gateway-configs:read"
3184
+ ]
3185
+ },
3186
+ {
3187
+ "method": "GET",
3188
+ "path": "/v1/gateway-configs/:name/versions/:versionId",
3189
+ "operationId": "getGatewayConfigVersion",
3190
+ "scopes": [
3191
+ "gateway-configs:read"
3192
+ ]
3193
+ },
3194
+ {
3195
+ "method": "POST",
3196
+ "path": "/v1/gateway-logs/ingest/:provider/:environment",
3197
+ "operationId": "ingestGatewayLog",
3198
+ "scopes": [
3199
+ "gateway-logs:write"
3200
+ ]
3201
+ },
3202
+ {
3203
+ "method": "GET",
3204
+ "path": "/v1/gateway-logs",
3205
+ "operationId": "listGatewayLogs",
3206
+ "scopes": [
3207
+ "gateway-logs:read"
3208
+ ]
3209
+ },
3210
+ {
3211
+ "method": "GET",
3212
+ "path": "/v1/gateway-logs/stats",
3213
+ "operationId": "getGatewayLogStats",
3214
+ "scopes": [
3215
+ "gateway-logs:read"
3216
+ ]
3217
+ },
3218
+ {
3219
+ "method": "GET",
3220
+ "path": "/v1/gateway-logs/:id",
3221
+ "operationId": "getGatewayLog",
3222
+ "scopes": [
3223
+ "gateway-logs:read"
3224
+ ]
3225
+ }
3226
+ ];
3227
+
2948
3228
  // src/registry/server.ts
2949
3229
  function createApp(config, store) {
2950
3230
  const app = new Hono22();
@@ -2956,6 +3236,21 @@ function createApp(config, store) {
2956
3236
  c.set("config", config);
2957
3237
  await next();
2958
3238
  });
3239
+ const authRouteScopes = config.auth?.mode === "keycloak" ? routeScopes : [];
3240
+ app.use("*", createAuthMiddleware(config, authRouteScopes));
3241
+ app.onError((err, c) => {
3242
+ if (err instanceof AuthError) {
3243
+ return c.json(
3244
+ { error: err.code, message: err.message, statusCode: err.statusCode },
3245
+ err.statusCode
3246
+ );
3247
+ }
3248
+ console.error("Unhandled error:", err);
3249
+ return c.json(
3250
+ { error: "internal_error", message: "Internal server error", statusCode: 500 },
3251
+ 500
3252
+ );
3253
+ });
2959
3254
  app.route("/v1/specs", pushRoute);
2960
3255
  app.route("/v1/specs", validateRoute);
2961
3256
  app.route("/v1/specs", listRoute);
@@ -2983,7 +3278,8 @@ function createApp(config, store) {
2983
3278
  // src/registry/config.ts
2984
3279
  var defaultConfig = {
2985
3280
  port: 3750,
2986
- database: "sqlite"
3281
+ database: "sqlite",
3282
+ auth: { mode: "none" }
2987
3283
  };
2988
3284
 
2989
3285
  // src/registry/storage/sqlite.ts
@@ -3596,17 +3892,63 @@ var provisions2 = pgTable("provisions", {
3596
3892
  // src/registry/storage/postgresql.ts
3597
3893
  import { v4 as uuid6 } from "uuid";
3598
3894
  var MIGRATIONS_FOLDER2 = PG_MIGRATIONS_FOLDER;
3895
+ var DatabaseConnectionError = class extends Error {
3896
+ constructor(message, postgresUrl, options) {
3897
+ super(message, options);
3898
+ this.postgresUrl = postgresUrl;
3899
+ this.name = "DatabaseConnectionError";
3900
+ }
3901
+ postgresUrl;
3902
+ };
3903
+ function isConnectionError(err) {
3904
+ if (typeof err !== "object" || err === null) return false;
3905
+ if ("code" in err) {
3906
+ const code = err.code;
3907
+ if (code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "ENOTFOUND") {
3908
+ return true;
3909
+ }
3910
+ }
3911
+ if (err instanceof AggregateError) {
3912
+ if (err.errors.some(isConnectionError)) return true;
3913
+ }
3914
+ if ("cause" in err && err.cause) {
3915
+ return isConnectionError(err.cause);
3916
+ }
3917
+ return false;
3918
+ }
3919
+ function maskPostgresPassword(url) {
3920
+ try {
3921
+ const parsed = new URL(url);
3922
+ if (parsed.password) parsed.password = "***";
3923
+ return parsed.toString();
3924
+ } catch {
3925
+ return url;
3926
+ }
3927
+ }
3599
3928
  var PostgreSQLSpecStore = class {
3600
3929
  db;
3601
3930
  pool;
3931
+ postgresUrl;
3602
3932
  constructor(postgresUrl) {
3933
+ this.postgresUrl = postgresUrl;
3603
3934
  this.pool = new Pool({ connectionString: postgresUrl });
3604
3935
  this.db = drizzle2(this.pool);
3605
3936
  }
3606
3937
  async migrate() {
3607
- await migrate2(this.db, {
3608
- migrationsFolder: MIGRATIONS_FOLDER2
3609
- });
3938
+ try {
3939
+ await migrate2(this.db, {
3940
+ migrationsFolder: MIGRATIONS_FOLDER2
3941
+ });
3942
+ } catch (err) {
3943
+ if (isConnectionError(err)) {
3944
+ throw new DatabaseConnectionError(
3945
+ `PostgreSQL is not reachable at ${maskPostgresPassword(this.postgresUrl)}`,
3946
+ this.postgresUrl,
3947
+ { cause: err }
3948
+ );
3949
+ }
3950
+ throw err;
3951
+ }
3610
3952
  }
3611
3953
  async end() {
3612
3954
  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.1",
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
  }