@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.
package/dist/hub/index.js CHANGED
@@ -17,15 +17,34 @@ var DEFAULT_REGISTRY_URL = "http://localhost:3750";
17
17
  async function startHubServer(userConfig) {
18
18
  const config = {
19
19
  port: userConfig?.port ?? DEFAULT_PORT,
20
- registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL
20
+ registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL,
21
+ auth: userConfig?.auth
21
22
  };
22
23
  const app = new Hono();
24
+ app.get("/config.js", (c) => {
25
+ const clientConfig = {
26
+ registryUrl: config.registryUrl,
27
+ auth: config.auth ? {
28
+ mode: config.auth.mode,
29
+ serverUrl: config.auth.serverUrl,
30
+ realm: config.auth.realm,
31
+ clientId: config.auth.clientId,
32
+ audience: config.auth.audience
33
+ } : void 0
34
+ };
35
+ c.header("Content-Type", "application/javascript");
36
+ return c.body(
37
+ `window.__GRAPITY_CONFIG__ = ${JSON.stringify(clientConfig)};`
38
+ );
39
+ });
23
40
  app.use("/v1/*", async (c) => {
24
41
  const url = new URL(c.req.url);
25
42
  const targetUrl = config.registryUrl + url.pathname + url.search;
43
+ const headers = new Headers(c.req.raw.headers);
44
+ headers.delete("host");
26
45
  const response = await fetch(targetUrl, {
27
46
  method: c.req.method,
28
- headers: c.req.raw.headers,
47
+ headers,
29
48
  body: c.req.raw.body
30
49
  });
31
50
  return response;
@@ -33,13 +52,21 @@ async function startHubServer(userConfig) {
33
52
  app.use("/*", serveStatic({ root: HUB_DIST_PATH }));
34
53
  app.get("/*", async (c) => {
35
54
  const indexPath = path.join(HUB_DIST_PATH, "index.html");
36
- if (fs.existsSync(indexPath)) {
37
- return c.html(fs.readFileSync(indexPath, "utf-8"));
55
+ if (!fs.existsSync(indexPath)) {
56
+ return c.text(
57
+ "index.html not found. Build the project with 'bun run build' first.",
58
+ 404
59
+ );
38
60
  }
39
- return c.text(
40
- "index.html not found. Build the project with 'bun run build' first.",
41
- 404
61
+ const html = fs.readFileSync(indexPath, "utf-8");
62
+ const configScript = `
63
+ <script src="/config.js"></script>
64
+ `;
65
+ const injected = html.replace(
66
+ "</head>",
67
+ `${configScript}</head>`
42
68
  );
69
+ return c.html(injected);
43
70
  });
44
71
  serve({
45
72
  fetch: app.fetch,
@@ -1,7 +1,15 @@
1
+ interface HubAuthConfig {
2
+ mode: "keycloak";
3
+ serverUrl: string;
4
+ realm: string;
5
+ clientId: string;
6
+ audience?: string;
7
+ }
1
8
  interface HubConfig {
2
9
  port?: number;
3
10
  registryUrl?: string;
11
+ auth?: HubAuthConfig;
4
12
  }
5
13
  declare function startHubServer(userConfig?: Partial<HubConfig>): Promise<void>;
6
14
 
7
- export { type HubConfig, startHubServer };
15
+ export { type HubAuthConfig, type HubConfig, startHubServer };
package/dist/hub/serve.js CHANGED
@@ -17,15 +17,34 @@ var DEFAULT_REGISTRY_URL = "http://localhost:3750";
17
17
  async function startHubServer(userConfig) {
18
18
  const config = {
19
19
  port: userConfig?.port ?? DEFAULT_PORT,
20
- registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL
20
+ registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL,
21
+ auth: userConfig?.auth
21
22
  };
22
23
  const app = new Hono();
24
+ app.get("/config.js", (c) => {
25
+ const clientConfig = {
26
+ registryUrl: config.registryUrl,
27
+ auth: config.auth ? {
28
+ mode: config.auth.mode,
29
+ serverUrl: config.auth.serverUrl,
30
+ realm: config.auth.realm,
31
+ clientId: config.auth.clientId,
32
+ audience: config.auth.audience
33
+ } : void 0
34
+ };
35
+ c.header("Content-Type", "application/javascript");
36
+ return c.body(
37
+ `window.__GRAPITY_CONFIG__ = ${JSON.stringify(clientConfig)};`
38
+ );
39
+ });
23
40
  app.use("/v1/*", async (c) => {
24
41
  const url = new URL(c.req.url);
25
42
  const targetUrl = config.registryUrl + url.pathname + url.search;
43
+ const headers = new Headers(c.req.raw.headers);
44
+ headers.delete("host");
26
45
  const response = await fetch(targetUrl, {
27
46
  method: c.req.method,
28
- headers: c.req.raw.headers,
47
+ headers,
29
48
  body: c.req.raw.body
30
49
  });
31
50
  return response;
@@ -33,13 +52,21 @@ async function startHubServer(userConfig) {
33
52
  app.use("/*", serveStatic({ root: HUB_DIST_PATH }));
34
53
  app.get("/*", async (c) => {
35
54
  const indexPath = path.join(HUB_DIST_PATH, "index.html");
36
- if (fs.existsSync(indexPath)) {
37
- return c.html(fs.readFileSync(indexPath, "utf-8"));
55
+ if (!fs.existsSync(indexPath)) {
56
+ return c.text(
57
+ "index.html not found. Build the project with 'bun run build' first.",
58
+ 404
59
+ );
38
60
  }
39
- return c.text(
40
- "index.html not found. Build the project with 'bun run build' first.",
41
- 404
61
+ const html = fs.readFileSync(indexPath, "utf-8");
62
+ const configScript = `
63
+ <script src="/config.js"></script>
64
+ `;
65
+ const injected = html.replace(
66
+ "</head>",
67
+ `${configScript}</head>`
42
68
  );
69
+ return c.html(injected);
43
70
  });
44
71
  serve({
45
72
  fetch: app.fetch,
package/dist/index.html CHANGED
@@ -9,8 +9,9 @@
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com" />
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
11
  <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
12
- <script type="module" crossorigin src="/assets/index-Dq5tdnlb.js"></script>
13
- <link rel="stylesheet" crossorigin href="/assets/index-NJpHAonA.css">
12
+ <script src="/config.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-JAhtTTW2.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-LDlidn22.css">
14
15
  </head>
15
16
  <body>
16
17
  <div id="root"></div>
@@ -191,18 +191,33 @@ interface GatewayConfigStore {
191
191
  deleteGatewayLogsOlderThan(days: number): Promise<void>;
192
192
  }
193
193
 
194
+ type RoleSource = "scope" | "realm_access.roles";
195
+ interface KeycloakAuthConfig {
196
+ mode: "keycloak";
197
+ serverUrl: string;
198
+ realm: string;
199
+ audience?: string;
200
+ roleSource?: RoleSource;
201
+ }
202
+ type AuthConfig = {
203
+ mode: "none";
204
+ } | KeycloakAuthConfig;
205
+
194
206
  type DatabaseBackend = "sqlite" | "postgresql";
195
207
  interface ServerConfig {
196
208
  port: number;
197
209
  database: DatabaseBackend;
198
210
  sqlitePath?: string;
199
211
  postgresUrl?: string;
212
+ auth: AuthConfig;
200
213
  }
201
214
 
202
215
  type AppEnv = {
203
216
  Variables: {
204
217
  store: SpecStore & GatewayConfigStore;
205
218
  config: ServerConfig;
219
+ actor?: string;
220
+ claims?: Record<string, unknown>;
206
221
  };
207
222
  };
208
223
  declare function createApp(config: ServerConfig, store: SpecStore & GatewayConfigStore): Hono<AppEnv, hono_types.BlankSchema, "/">;
@@ -1,3 +1,3 @@
1
- export { j as ServerConfig, i as createApp } from './index-DUPbMrAe.js';
1
+ export { j as ServerConfig, i as createApp } from './index-Bx-7YlUF.js';
2
2
  import 'hono/types';
3
3
  import 'hono';
@@ -1751,6 +1751,7 @@ var pushRoute = new Hono().post("/", async (c) => {
1751
1751
  }
1752
1752
  const store = c.get("store");
1753
1753
  const service = new RegistryService(store);
1754
+ const actor = c.get("actor") ?? body.pushedBy;
1754
1755
  try {
1755
1756
  const result = await service.pushSpec(body.content, body.name, {
1756
1757
  type: body.type,
@@ -1759,7 +1760,7 @@ var pushRoute = new Hono().post("/", async (c) => {
1759
1760
  sourceRepo: body.sourceRepo,
1760
1761
  tags: Array.isArray(body.tags) ? body.tags : void 0,
1761
1762
  gitRef: body.gitRef,
1762
- pushedBy: body.pushedBy,
1763
+ pushedBy: actor,
1763
1764
  prerelease: body.prerelease,
1764
1765
  force: body.force,
1765
1766
  reason: body.reason
@@ -1924,7 +1925,8 @@ var deleteSpecRoute = new Hono5().delete(
1924
1925
  const name = c.req.param("name");
1925
1926
  const store = c.get("store");
1926
1927
  const service = new RegistryService(store);
1927
- const deleted = await service.deleteSpec(name);
1928
+ const actor = c.get("actor");
1929
+ const deleted = await service.deleteSpec(name, actor);
1928
1930
  if (!deleted) {
1929
1931
  return c.json({ error: "not_found", message: `Spec "${name}" not found`, statusCode: 404 }, 404);
1930
1932
  }
@@ -2665,6 +2667,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c) => {
2665
2667
  }
2666
2668
  const store = c.get("store");
2667
2669
  const service = new GatewayService(store, store);
2670
+ const actor = c.get("actor") ?? body.pushedBy;
2668
2671
  try {
2669
2672
  const result = await service.pushGatewayConfig({
2670
2673
  name: body.name,
@@ -2675,7 +2678,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c) => {
2675
2678
  environments: body.environments ?? {},
2676
2679
  callerIdentification: body.callerIdentification,
2677
2680
  content: body.content,
2678
- pushedBy: body.pushedBy
2681
+ pushedBy: actor
2679
2682
  });
2680
2683
  return c.json({ data: result }, 201);
2681
2684
  } catch (err) {
@@ -2940,7 +2943,137 @@ var gatewayLogStatsRoute = new Hono21().get("/stats", async (c) => {
2940
2943
  });
2941
2944
  });
2942
2945
 
2946
+ // src/registry/auth/middleware.ts
2947
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2948
+ var AuthError = class extends Error {
2949
+ constructor(statusCode, code, message) {
2950
+ super(message);
2951
+ this.statusCode = statusCode;
2952
+ this.code = code;
2953
+ this.name = "AuthError";
2954
+ }
2955
+ statusCode;
2956
+ code;
2957
+ };
2958
+ function buildKeycloakUrls(config) {
2959
+ const base = `${config.serverUrl}/realms/${config.realm}`;
2960
+ return {
2961
+ issuer: base,
2962
+ jwksUri: `${base}/protocol/openid-connect/certs`,
2963
+ tokenUrl: `${base}/protocol/openid-connect/token`
2964
+ };
2965
+ }
2966
+ function createAuthMiddleware(config, routeScopes) {
2967
+ if (config.auth?.mode !== "keycloak") {
2968
+ return async (_c, next) => await next();
2969
+ }
2970
+ const authConfig = config.auth;
2971
+ const { issuer, jwksUri } = buildKeycloakUrls(authConfig);
2972
+ const jwks = createRemoteJWKSet(new URL(jwksUri));
2973
+ const scopeByRoute = /* @__PURE__ */ new Map();
2974
+ for (const route of routeScopes) {
2975
+ const key = `${route.method.toUpperCase()}:${route.path}`;
2976
+ scopeByRoute.set(key, {
2977
+ operationId: route.operationId,
2978
+ scopes: route.scopes
2979
+ });
2980
+ }
2981
+ return async (c, next) => {
2982
+ const matchedPath = c.req.matchedRoutes.map((r) => r.path).filter((p) => p !== "/*").pop();
2983
+ const routeKey = matchedPath ? `${c.req.method}:${matchedPath}` : `${c.req.method}:${c.req.routePath}`;
2984
+ const required = scopeByRoute.get(routeKey);
2985
+ if (!required || required.scopes.length === 0) {
2986
+ return await next();
2987
+ }
2988
+ const authHeader = c.req.header("Authorization");
2989
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
2990
+ throw new AuthError(401, "unauthorized", "Bearer token required");
2991
+ }
2992
+ const token = authHeader.slice("Bearer ".length).trim();
2993
+ if (!token) {
2994
+ throw new AuthError(401, "unauthorized", "Bearer token required");
2995
+ }
2996
+ let payload;
2997
+ try {
2998
+ const result = await jwtVerify(token, jwks, {
2999
+ issuer,
3000
+ audience: authConfig.audience
3001
+ });
3002
+ payload = result.payload;
3003
+ } catch (err) {
3004
+ const message = err instanceof Error ? err.message : "Invalid token";
3005
+ throw new AuthError(401, "unauthorized", `Invalid or expired token: ${message}`);
3006
+ }
3007
+ const subject = payload.sub;
3008
+ if (!subject) {
3009
+ throw new AuthError(401, "unauthorized", "Token missing subject claim");
3010
+ }
3011
+ const grantedScopes = extractScopes(payload, authConfig.roleSource ?? "scope");
3012
+ const missing = required.scopes.filter((s) => !grantedScopes.has(s));
3013
+ if (missing.length > 0) {
3014
+ throw new AuthError(
3015
+ 403,
3016
+ "forbidden",
3017
+ `Missing required scope${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`
3018
+ );
3019
+ }
3020
+ c.set("actor", subject);
3021
+ c.set("claims", payload);
3022
+ await next();
3023
+ };
3024
+ }
3025
+ function extractScopes(payload, source) {
3026
+ if (source === "realm_access.roles") {
3027
+ const roles = payload.realm_access?.roles;
3028
+ return new Set(roles ?? []);
3029
+ }
3030
+ const scopeValue = payload.scope;
3031
+ if (typeof scopeValue === "string") {
3032
+ return new Set(scopeValue.split(/\s+/).filter(Boolean));
3033
+ }
3034
+ return /* @__PURE__ */ new Set();
3035
+ }
3036
+ function parseRouteScopes(spec) {
3037
+ const routes = [];
3038
+ const paths = spec.paths;
3039
+ if (!paths) return routes;
3040
+ for (const [path, operations] of Object.entries(paths)) {
3041
+ for (const [method, operation] of Object.entries(operations)) {
3042
+ if (typeof operation !== "object" || operation === null) continue;
3043
+ const op = operation;
3044
+ const operationId = op.operationId;
3045
+ if (!operationId) continue;
3046
+ const security = op.security;
3047
+ const scopes = [];
3048
+ if (security) {
3049
+ for (const sec of security) {
3050
+ for (const [name, required] of Object.entries(sec)) {
3051
+ if (name === "keycloak" && Array.isArray(required)) {
3052
+ scopes.push(...required);
3053
+ }
3054
+ }
3055
+ }
3056
+ }
3057
+ routes.push({
3058
+ method: method.toUpperCase(),
3059
+ path: path.replace(/\{([^}]+)\}/g, ":$1"),
3060
+ operationId,
3061
+ scopes
3062
+ });
3063
+ }
3064
+ }
3065
+ return routes;
3066
+ }
3067
+
2943
3068
  // src/registry/server.ts
3069
+ import { readFileSync } from "fs";
3070
+ import { fileURLToPath } from "url";
3071
+ import yaml6 from "js-yaml";
3072
+ function loadOpenApiSpec() {
3073
+ const path = fileURLToPath(new URL("../../openapi.yaml", import.meta.url));
3074
+ const content = readFileSync(path, "utf-8");
3075
+ return yaml6.load(content);
3076
+ }
2944
3077
  function createApp(config, store) {
2945
3078
  const app = new Hono22();
2946
3079
  app.use("*", logger());
@@ -2951,6 +3084,21 @@ function createApp(config, store) {
2951
3084
  c.set("config", config);
2952
3085
  await next();
2953
3086
  });
3087
+ const routeScopes = parseRouteScopes(loadOpenApiSpec());
3088
+ app.use("*", createAuthMiddleware(config, routeScopes));
3089
+ app.onError((err, c) => {
3090
+ if (err instanceof AuthError) {
3091
+ return c.json(
3092
+ { error: err.code, message: err.message, statusCode: err.statusCode },
3093
+ err.statusCode
3094
+ );
3095
+ }
3096
+ console.error("Unhandled error:", err);
3097
+ return c.json(
3098
+ { error: "internal_error", message: "Internal server error", statusCode: 500 },
3099
+ 500
3100
+ );
3101
+ });
2954
3102
  app.route("/v1/specs", pushRoute);
2955
3103
  app.route("/v1/specs", validateRoute);
2956
3104
  app.route("/v1/specs", listRoute);
@@ -1,5 +1,5 @@
1
1
  import { ServerType } from '@hono/node-server';
2
- import { S as SpecStore, G as GatewayConfigStore, a as Spec, b as SpecVersion, c as SpecFilters, C as CompatReport, A as AuditAction, d as GatewayConfig, e as GatewayConfigVersion, P as Provision, f as GatewayLog, g as GatewayLogFilters, h as GatewayLogStats, i as createApp, j as ServerConfig } from './index-DUPbMrAe.js';
2
+ import { S as SpecStore, G as GatewayConfigStore, a as Spec, b as SpecVersion, c as SpecFilters, C as CompatReport, A as AuditAction, d as GatewayConfig, e as GatewayConfigVersion, P as Provision, f as GatewayLog, g as GatewayLogFilters, h as GatewayLogStats, i as createApp, j as ServerConfig } from './index-Bx-7YlUF.js';
3
3
  import 'hono/types';
4
4
  import 'hono';
5
5
 
@@ -47,6 +47,7 @@ declare class SQLiteSpecStore implements SpecStore, GatewayConfigStore {
47
47
  declare class PostgreSQLSpecStore implements SpecStore, GatewayConfigStore {
48
48
  private db;
49
49
  private pool;
50
+ private postgresUrl;
50
51
  constructor(postgresUrl: string);
51
52
  migrate(): Promise<void>;
52
53
  end(): Promise<void>;