@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/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import { Command as Command20 } from "commander";
4
+ import { Command as Command21 } from "commander";
5
5
 
6
6
  // src/cli/commands/registry/index.ts
7
7
  import { Command as Command8 } from "commander";
@@ -18,6 +18,7 @@ import fs from "fs";
18
18
  import os from "os";
19
19
  import path from "path";
20
20
  import yaml from "js-yaml";
21
+ var CONFIG_PATH = () => path.join(os.homedir(), ".grapity", "config.yaml");
21
22
  var DEFAULT_CONFIG = {
22
23
  mode: "local",
23
24
  local: {
@@ -25,8 +26,11 @@ var DEFAULT_CONFIG = {
25
26
  database: "sqlite"
26
27
  }
27
28
  };
29
+ function configExists() {
30
+ return fs.existsSync(CONFIG_PATH());
31
+ }
28
32
  function getConfig() {
29
- const configPath = path.join(os.homedir(), ".grapity", "config.yaml");
33
+ const configPath = CONFIG_PATH();
30
34
  if (!fs.existsSync(configPath)) {
31
35
  return DEFAULT_CONFIG;
32
36
  }
@@ -52,7 +56,8 @@ function getConfig() {
52
56
  port: config.local?.port ?? DEFAULT_CONFIG.local.port,
53
57
  database,
54
58
  sqlitePath: config.local?.sqlitePath,
55
- postgresUrl: config.local?.postgresUrl
59
+ postgresUrl: config.local?.postgresUrl,
60
+ auth: config.local?.auth
56
61
  }
57
62
  };
58
63
  }
@@ -67,6 +72,200 @@ function isPostgresqlUrl(value) {
67
72
  return value.startsWith("postgresql://") || value.startsWith("postgres://");
68
73
  }
69
74
 
75
+ // src/cli/auth.ts
76
+ import { decodeJwt } from "jose";
77
+
78
+ // src/registry/auth/middleware.ts
79
+ import { createRemoteJWKSet, jwtVerify } from "jose";
80
+ var AuthError = class extends Error {
81
+ constructor(statusCode, code, message) {
82
+ super(message);
83
+ this.statusCode = statusCode;
84
+ this.code = code;
85
+ this.name = "AuthError";
86
+ }
87
+ statusCode;
88
+ code;
89
+ };
90
+ function buildKeycloakUrls(config) {
91
+ const base = `${config.serverUrl}/realms/${config.realm}`;
92
+ return {
93
+ issuer: base,
94
+ jwksUri: `${base}/protocol/openid-connect/certs`,
95
+ tokenUrl: `${base}/protocol/openid-connect/token`
96
+ };
97
+ }
98
+ function createAuthMiddleware(config, routeScopes) {
99
+ if (config.auth?.mode !== "keycloak") {
100
+ return async (_c, next) => await next();
101
+ }
102
+ const authConfig = config.auth;
103
+ const { issuer, jwksUri } = buildKeycloakUrls(authConfig);
104
+ const jwks = createRemoteJWKSet(new URL(jwksUri));
105
+ const scopeByRoute = /* @__PURE__ */ new Map();
106
+ for (const route of routeScopes) {
107
+ const key = `${route.method.toUpperCase()}:${route.path}`;
108
+ scopeByRoute.set(key, {
109
+ operationId: route.operationId,
110
+ scopes: route.scopes
111
+ });
112
+ }
113
+ return async (c2, next) => {
114
+ const matchedPath = c2.req.matchedRoutes.map((r) => r.path).filter((p) => p !== "/*").pop();
115
+ const routeKey = matchedPath ? `${c2.req.method}:${matchedPath}` : `${c2.req.method}:${c2.req.routePath}`;
116
+ const required = scopeByRoute.get(routeKey);
117
+ if (!required || required.scopes.length === 0) {
118
+ return await next();
119
+ }
120
+ const authHeader = c2.req.header("Authorization");
121
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
122
+ throw new AuthError(401, "unauthorized", "Bearer token required");
123
+ }
124
+ const token = authHeader.slice("Bearer ".length).trim();
125
+ if (!token) {
126
+ throw new AuthError(401, "unauthorized", "Bearer token required");
127
+ }
128
+ let payload;
129
+ try {
130
+ const result = await jwtVerify(token, jwks, {
131
+ issuer,
132
+ audience: authConfig.audience
133
+ });
134
+ payload = result.payload;
135
+ } catch (err) {
136
+ const message = err instanceof Error ? err.message : "Invalid token";
137
+ throw new AuthError(401, "unauthorized", `Invalid or expired token: ${message}`);
138
+ }
139
+ const subject = payload.sub;
140
+ if (!subject) {
141
+ throw new AuthError(401, "unauthorized", "Token missing subject claim");
142
+ }
143
+ const grantedScopes = extractScopes(payload, authConfig.roleSource ?? "scope");
144
+ const missing = required.scopes.filter((s) => !grantedScopes.has(s));
145
+ if (missing.length > 0) {
146
+ throw new AuthError(
147
+ 403,
148
+ "forbidden",
149
+ `Missing required scope${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`
150
+ );
151
+ }
152
+ c2.set("actor", subject);
153
+ c2.set("claims", payload);
154
+ await next();
155
+ };
156
+ }
157
+ function extractScopes(payload, source) {
158
+ if (source === "realm_access.roles") {
159
+ const roles = payload.realm_access?.roles;
160
+ return new Set(roles ?? []);
161
+ }
162
+ const scopeValue = payload.scope;
163
+ if (typeof scopeValue === "string") {
164
+ return new Set(scopeValue.split(/\s+/).filter(Boolean));
165
+ }
166
+ return /* @__PURE__ */ new Set();
167
+ }
168
+ function parseRouteScopes(spec) {
169
+ const routes = [];
170
+ const paths = spec.paths;
171
+ if (!paths) return routes;
172
+ for (const [path12, operations] of Object.entries(paths)) {
173
+ for (const [method, operation] of Object.entries(operations)) {
174
+ if (typeof operation !== "object" || operation === null) continue;
175
+ const op = operation;
176
+ const operationId = op.operationId;
177
+ if (!operationId) continue;
178
+ const security = op.security;
179
+ const scopes = [];
180
+ if (security) {
181
+ for (const sec of security) {
182
+ for (const [name, required] of Object.entries(sec)) {
183
+ if (name === "keycloak" && Array.isArray(required)) {
184
+ scopes.push(...required);
185
+ }
186
+ }
187
+ }
188
+ }
189
+ routes.push({
190
+ method: method.toUpperCase(),
191
+ path: path12.replace(/\{([^}]+)\}/g, ":$1"),
192
+ operationId,
193
+ scopes
194
+ });
195
+ }
196
+ }
197
+ return routes;
198
+ }
199
+
200
+ // src/cli/auth.ts
201
+ var cachedToken = null;
202
+ function resetTokenCache() {
203
+ cachedToken = null;
204
+ }
205
+ async function getAccessToken(config) {
206
+ const staticToken = process.env.GRAPITY_TOKEN;
207
+ if (staticToken) {
208
+ return staticToken;
209
+ }
210
+ const now = Math.floor(Date.now() / 1e3);
211
+ if (cachedToken && cachedToken.expiresAt > now + 60) {
212
+ return cachedToken.accessToken;
213
+ }
214
+ const clientSecret = process.env.GRAPITY_CLIENT_SECRET;
215
+ if (!clientSecret) {
216
+ throw new Error(
217
+ "Keycloak client secret not found. Set GRAPITY_CLIENT_SECRET or provide a static token via GRAPITY_TOKEN."
218
+ );
219
+ }
220
+ const { tokenUrl } = buildKeycloakUrls({ serverUrl: config.serverUrl, realm: config.realm });
221
+ const params = new URLSearchParams();
222
+ params.set("grant_type", "client_credentials");
223
+ params.set("client_id", config.clientId);
224
+ params.set("client_secret", clientSecret);
225
+ if (config.audience) {
226
+ params.set("audience", config.audience);
227
+ }
228
+ const response = await fetch(tokenUrl, {
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
231
+ body: params.toString()
232
+ });
233
+ if (!response.ok) {
234
+ const text3 = await response.text().catch(() => "Unknown error");
235
+ throw new Error(`Keycloak token request failed: ${response.status} ${text3}`);
236
+ }
237
+ const data = await response.json();
238
+ cachedToken = {
239
+ accessToken: data.access_token,
240
+ expiresAt: now + data.expires_in
241
+ };
242
+ return data.access_token;
243
+ }
244
+ function decodeToken(token) {
245
+ try {
246
+ const payload = decodeJwt(token);
247
+ return {
248
+ sub: payload.sub,
249
+ exp: payload.exp,
250
+ iss: payload.iss,
251
+ scope: typeof payload.scope === "string" ? payload.scope : void 0,
252
+ roles: payload.realm_access?.roles
253
+ };
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
258
+ function formatTokenStatus(token) {
259
+ const decoded = decodeToken(token);
260
+ const now = Math.floor(Date.now() / 1e3);
261
+ return {
262
+ valid: !!decoded.sub,
263
+ sub: decoded.sub,
264
+ exp: decoded.exp,
265
+ expired: decoded.exp ? decoded.exp < now : void 0
266
+ };
267
+ }
268
+
70
269
  // src/cli/client.ts
71
270
  var BreakingChangeError = class extends Error {
72
271
  constructor(compatReport) {
@@ -76,13 +275,30 @@ var BreakingChangeError = class extends Error {
76
275
  }
77
276
  compatReport;
78
277
  };
278
+ async function getAuthHeaders() {
279
+ const config = getConfig();
280
+ const authConfig = config.mode === "remote" ? config.remote?.auth : config.local?.auth;
281
+ const headers = {};
282
+ if (authConfig?.mode === "keycloak") {
283
+ if (!authConfig.clientId) {
284
+ throw new Error(
285
+ "Keycloak auth is configured but clientId is missing. Run grapity init with --keycloak-client-id."
286
+ );
287
+ }
288
+ const token = await getAccessToken(authConfig);
289
+ headers["Authorization"] = `Bearer ${token}`;
290
+ }
291
+ return headers;
292
+ }
79
293
  async function request(method, path12, body) {
80
294
  const baseUrl = getRegistryUrl();
81
295
  const url = `${baseUrl}${path12}`;
296
+ const authHeaders = await getAuthHeaders();
82
297
  const response = await fetch(url, {
83
298
  method,
84
299
  headers: {
85
- "Content-Type": "application/json"
300
+ "Content-Type": "application/json",
301
+ ...authHeaders
86
302
  },
87
303
  body: body ? JSON.stringify(body) : void 0
88
304
  });
@@ -99,7 +315,11 @@ async function request(method, path12, body) {
99
315
  async function requestText(method, path12) {
100
316
  const baseUrl = getRegistryUrl();
101
317
  const url = `${baseUrl}${path12}`;
102
- const response = await fetch(url, { method });
318
+ const authHeaders = await getAuthHeaders();
319
+ const response = await fetch(url, {
320
+ method,
321
+ headers: authHeaders
322
+ });
103
323
  if (!response.ok) {
104
324
  const error = await response.json();
105
325
  throw new Error(error.message ?? `Request failed: ${response.status}`);
@@ -534,12 +754,24 @@ function formatInitSuccess(params) {
534
754
  if (params.database) lines.push(` ${c.label("Database")} ${c.primary(params.database)}`);
535
755
  if (params.dbPath) lines.push(` ${c.label("Path")} ${c.dim(params.dbPath)}`);
536
756
  if (params.postgresUrl) lines.push(` ${c.label("PostgreSQL")} ${c.dim(params.postgresUrl)}`);
537
- lines.push("");
538
- lines.push(` ${c.dim("\u203A")} Start the server with: ${c.primary("grapity serve")}`);
539
757
  } else {
540
758
  if (params.url) lines.push(` ${c.label("URL")} ${c.cyan(params.url)}`);
759
+ }
760
+ if (params.authMode && params.authMode !== "none") {
761
+ lines.push("");
762
+ lines.push(` ${c.label("Auth")} ${c.primary(params.authMode)}`);
763
+ if (params.keycloakServer) lines.push(` ${c.label("Keycloak")} ${c.dim(params.keycloakServer)}`);
764
+ if (params.keycloakRealm) lines.push(` ${c.label("Realm")} ${c.dim(params.keycloakRealm)}`);
765
+ if (params.keycloakClientId) lines.push(` ${c.label("Client ID")} ${c.dim(params.keycloakClientId)}`);
766
+ lines.push("");
767
+ lines.push(` ${c.dim("\u203A")} Set the client secret in GRAPITY_CLIENT_SECRET before running commands.`);
768
+ } else {
541
769
  lines.push("");
542
- lines.push(` ${c.dim("\u203A")} Push a spec with: ${c.primary("grapity registry push ./openapi.yaml --name my-api")}`);
770
+ if (params.mode === "local") {
771
+ lines.push(` ${c.dim("\u203A")} Start the server with: ${c.primary("grapity serve")}`);
772
+ } else {
773
+ lines.push(` ${c.dim("\u203A")} Push a spec with: ${c.primary("grapity registry push ./openapi.yaml --name my-api")}`);
774
+ }
543
775
  }
544
776
  return lines.join("\n");
545
777
  }
@@ -555,6 +787,16 @@ function formatServeConfig(params) {
555
787
  if (params.database === "postgresql" && params.postgresUrl) {
556
788
  lines.push(` ${c.label("PostgreSQL")} ${c.dim(maskPostgresUrl(params.postgresUrl))}`);
557
789
  }
790
+ lines.push(` ${c.label("Auth")} ${c.primary(params.authMode ?? "none")}`);
791
+ if (params.keycloakServer) {
792
+ lines.push(` ${c.label("Keycloak")} ${c.dim(params.keycloakServer)}`);
793
+ }
794
+ if (params.keycloakRealm) {
795
+ lines.push(` ${c.label("Realm")} ${c.dim(params.keycloakRealm)}`);
796
+ }
797
+ if (params.keycloakAudience) {
798
+ lines.push(` ${c.label("Audience")} ${c.dim(params.keycloakAudience)}`);
799
+ }
558
800
  return lines.join("\n");
559
801
  }
560
802
  function maskPostgresUrl(url) {
@@ -598,6 +840,39 @@ function formatEmptyState(message, hints) {
598
840
  function formatReady(port) {
599
841
  return ` ${c.success("\u25CF")} Server ready ${c.label("\xB7")} ${c.cyan(`http://localhost:${port}`)}`;
600
842
  }
843
+ function formatAuthStatus(params) {
844
+ const lines = [];
845
+ if (params.cleared) {
846
+ lines.push(` ${c.success("\u2713")} Cached access token cleared`);
847
+ return lines.join("\n");
848
+ }
849
+ lines.push(` ${c.label("Mode")} ${c.primary(params.mode)}`);
850
+ if (!params.configured) {
851
+ lines.push(` ${c.dim("\xB7")} No remote authentication configured`);
852
+ return lines.join("\n");
853
+ }
854
+ if (params.serverUrl) lines.push(` ${c.label("Server")} ${c.dim(params.serverUrl)}`);
855
+ if (params.realm) lines.push(` ${c.label("Realm")} ${c.dim(params.realm)}`);
856
+ if (params.clientId) lines.push(` ${c.label("Client ID")} ${c.dim(params.clientId)}`);
857
+ if (params.audience) lines.push(` ${c.label("Audience")} ${c.dim(params.audience)}`);
858
+ lines.push("");
859
+ if (params.valid === false) {
860
+ lines.push(` ${c.error("\u2717")} Token is not a valid JWT`);
861
+ } else if (params.sub) {
862
+ lines.push(` ${c.success("\u2713")} Authenticated as ${c.primary(params.sub)}`);
863
+ if (params.exp) {
864
+ const date = new Date(params.exp * 1e3).toISOString();
865
+ if (params.expired) {
866
+ lines.push(` ${c.error("\u2717")} Token expired at ${c.dim(date)}`);
867
+ } else {
868
+ lines.push(` ${c.success("\u2713")} Token valid until ${c.dim(date)}`);
869
+ }
870
+ }
871
+ } else {
872
+ lines.push(` ${c.dim("\xB7")} No token fetched yet`);
873
+ }
874
+ return lines.join("\n");
875
+ }
601
876
  function formatHubReady(port) {
602
877
  return ` ${c.success("\u25CF")} Hub ready ${c.label("\xB7")} ${c.cyan(`http://localhost:${port}`)}`;
603
878
  }
@@ -1396,7 +1671,7 @@ import fs8 from "fs";
1396
1671
  import os3 from "os";
1397
1672
  import path8 from "path";
1398
1673
  import yaml4 from "js-yaml";
1399
- var initCommand = new Command18("init").description("Configure grapity registry (local or remote mode)").option("--local", "Use local mode (SQLite or PostgreSQL)").option("--remote", "Use remote mode (connect to a grapity server)").option("--url <url>", "Registry URL (for remote mode)").option("--port <port>", "Port for local server (default: 3750)").option("--db <path-or-url>", "SQLite path or postgresql:// URL (for local mode)").action(async (options) => {
1674
+ var initCommand = new Command18("init").description("Configure grapity registry (local or remote mode)").option("--local", "Use local mode (SQLite or PostgreSQL)").option("--remote", "Use remote mode (connect to a grapity server)").option("--url <url>", "Registry URL (for remote mode)").option("--port <port>", "Port for local server (default: 3750)").option("--db <path-or-url>", "SQLite path or postgresql:// URL (for local mode)").option("--auth <mode>", "Auth mode: none | keycloak (default: none)").option("--keycloak-server <url>", "Keycloak server URL").option("--keycloak-realm <realm>", "Keycloak realm").option("--keycloak-client-id <id>", "Keycloak client ID (for CLI client credentials)").option("--keycloak-audience <audience>", "Keycloak token audience to validate").option("--keycloak-role-source <source>", "Where to read roles from: scope | realm_access.roles (default: scope)").action(async (options) => {
1400
1675
  const configDir = path8.join(os3.homedir(), ".grapity");
1401
1676
  const configPath = path8.join(configDir, "config.yaml");
1402
1677
  let mode;
@@ -1421,6 +1696,12 @@ var initCommand = new Command18("init").description("Configure grapity registry
1421
1696
  );
1422
1697
  process.exit(1);
1423
1698
  }
1699
+ const authMode = options.auth ?? "none";
1700
+ if (authMode !== "none" && authMode !== "keycloak") {
1701
+ console.error(formatError("invalid auth mode", "--auth must be one of: none, keycloak"));
1702
+ process.exit(1);
1703
+ }
1704
+ const keycloakAuth = authMode === "keycloak" ? parseKeycloakOptions(options, mode) : void 0;
1424
1705
  const config = { mode };
1425
1706
  if (mode === "local") {
1426
1707
  const dbValue = options.db ?? process.env.GRAPITY_DATABASE_URL;
@@ -1434,6 +1715,22 @@ var initCommand = new Command18("init").description("Configure grapity registry
1434
1715
  } else {
1435
1716
  config.local.sqlitePath = dbValue ?? path8.join(os3.homedir(), ".grapity", "registry.db");
1436
1717
  }
1718
+ if (keycloakAuth) {
1719
+ if (!options.keycloakClientId) {
1720
+ console.error(
1721
+ formatError(
1722
+ "missing flag",
1723
+ "--keycloak-client-id is required when using Keycloak auth."
1724
+ )
1725
+ );
1726
+ process.exit(1);
1727
+ }
1728
+ config.local.auth = {
1729
+ mode: "keycloak",
1730
+ clientId: options.keycloakClientId,
1731
+ ...keycloakAuth
1732
+ };
1733
+ }
1437
1734
  } else {
1438
1735
  if (!options.url) {
1439
1736
  console.error(
@@ -1448,6 +1745,16 @@ var initCommand = new Command18("init").description("Configure grapity registry
1448
1745
  config.remote = {
1449
1746
  url: options.url.replace(/\/$/, "")
1450
1747
  };
1748
+ if (keycloakAuth) {
1749
+ config.remote.auth = {
1750
+ mode: "keycloak",
1751
+ serverUrl: keycloakAuth.serverUrl,
1752
+ realm: keycloakAuth.realm,
1753
+ clientId: keycloakAuth.clientId,
1754
+ audience: keycloakAuth.audience,
1755
+ roleSource: keycloakAuth.roleSource
1756
+ };
1757
+ }
1451
1758
  }
1452
1759
  if (!fs8.existsSync(configDir)) {
1453
1760
  fs8.mkdirSync(configDir, { recursive: true });
@@ -1462,16 +1769,107 @@ var initCommand = new Command18("init").description("Configure grapity registry
1462
1769
  database: config.local?.database,
1463
1770
  dbPath: config.local?.sqlitePath,
1464
1771
  postgresUrl: config.local?.postgresUrl,
1465
- url: config.remote?.url
1772
+ url: config.remote?.url,
1773
+ authMode,
1774
+ keycloakServer: keycloakAuth?.serverUrl,
1775
+ keycloakRealm: keycloakAuth?.realm,
1776
+ keycloakClientId: config.local?.auth?.clientId ?? config.remote?.auth?.clientId
1466
1777
  })
1467
1778
  );
1468
1779
  });
1780
+ function parseKeycloakOptions(options, _mode) {
1781
+ if (!options.keycloakServer) {
1782
+ console.error(
1783
+ formatError(
1784
+ "missing flag",
1785
+ "--keycloak-server is required when auth mode is keycloak.",
1786
+ ["Example: --keycloak-server https://keycloak.example.com"]
1787
+ )
1788
+ );
1789
+ process.exit(1);
1790
+ }
1791
+ if (!options.keycloakRealm) {
1792
+ console.error(
1793
+ formatError(
1794
+ "missing flag",
1795
+ "--keycloak-realm is required when auth mode is keycloak.",
1796
+ ["Example: --keycloak-realm grapity"]
1797
+ )
1798
+ );
1799
+ process.exit(1);
1800
+ }
1801
+ const roleSource = options.keycloakRoleSource;
1802
+ if (roleSource && roleSource !== "scope" && roleSource !== "realm_access.roles") {
1803
+ console.error(formatError("invalid role source", "--keycloak-role-source must be one of: scope, realm_access.roles"));
1804
+ process.exit(1);
1805
+ }
1806
+ return {
1807
+ serverUrl: options.keycloakServer.replace(/\/$/, ""),
1808
+ realm: options.keycloakRealm,
1809
+ clientId: options.keycloakClientId,
1810
+ audience: options.keycloakAudience,
1811
+ roleSource
1812
+ };
1813
+ }
1469
1814
 
1470
- // src/cli/commands/serve.ts
1815
+ // src/cli/commands/auth.ts
1471
1816
  import { Command as Command19 } from "commander";
1817
+ var authCommand = new Command19("auth").description("Manage authentication for remote registry mode").addCommand(
1818
+ new Command19("status").description("Show current authentication status").action(async () => {
1819
+ const config = getConfig();
1820
+ const auth = config.mode === "remote" ? config.remote?.auth : config.local?.auth;
1821
+ if (!auth) {
1822
+ console.log(formatAuthStatus({ mode: "none", configured: false }));
1823
+ return;
1824
+ }
1825
+ if (auth.mode !== "keycloak") {
1826
+ console.log(formatAuthStatus({ mode: auth.mode, configured: true }));
1827
+ return;
1828
+ }
1829
+ if (!auth.clientId) {
1830
+ console.error(
1831
+ formatError(
1832
+ "auth misconfigured",
1833
+ "Keycloak auth is configured but clientId is missing. Run grapity init with --keycloak-client-id."
1834
+ )
1835
+ );
1836
+ process.exit(1);
1837
+ }
1838
+ try {
1839
+ const token = await getAccessToken(auth);
1840
+ const status = formatTokenStatus(token);
1841
+ console.log(
1842
+ formatAuthStatus({
1843
+ mode: auth.mode,
1844
+ configured: true,
1845
+ serverUrl: auth.serverUrl,
1846
+ realm: auth.realm,
1847
+ clientId: auth.clientId,
1848
+ audience: auth.audience,
1849
+ ...status
1850
+ })
1851
+ );
1852
+ } catch (err) {
1853
+ console.error(
1854
+ formatError(
1855
+ "auth failed",
1856
+ err instanceof Error ? err.message : "Unable to fetch access token"
1857
+ )
1858
+ );
1859
+ process.exit(1);
1860
+ }
1861
+ })
1862
+ ).addCommand(
1863
+ new Command19("clear").description("Clear the cached access token").action(() => {
1864
+ resetTokenCache();
1865
+ console.log(formatAuthStatus({ mode: "none", configured: false, cleared: true }));
1866
+ })
1867
+ );
1868
+
1869
+ // src/cli/commands/serve.ts
1870
+ import { Command as Command20 } from "commander";
1472
1871
  import os4 from "os";
1473
1872
  import path11 from "path";
1474
- import { readFileSync } from "fs";
1475
1873
 
1476
1874
  // src/registry/serve.ts
1477
1875
  import path9 from "path";
@@ -3231,6 +3629,7 @@ var pushRoute = new Hono().post("/", async (c2) => {
3231
3629
  }
3232
3630
  const store = c2.get("store");
3233
3631
  const service = new RegistryService(store);
3632
+ const actor = c2.get("actor") ?? body.pushedBy;
3234
3633
  try {
3235
3634
  const result = await service.pushSpec(body.content, body.name, {
3236
3635
  type: body.type,
@@ -3239,7 +3638,7 @@ var pushRoute = new Hono().post("/", async (c2) => {
3239
3638
  sourceRepo: body.sourceRepo,
3240
3639
  tags: Array.isArray(body.tags) ? body.tags : void 0,
3241
3640
  gitRef: body.gitRef,
3242
- pushedBy: body.pushedBy,
3641
+ pushedBy: actor,
3243
3642
  prerelease: body.prerelease,
3244
3643
  force: body.force,
3245
3644
  reason: body.reason
@@ -3404,7 +3803,8 @@ var deleteSpecRoute = new Hono5().delete(
3404
3803
  const name = c2.req.param("name");
3405
3804
  const store = c2.get("store");
3406
3805
  const service = new RegistryService(store);
3407
- const deleted = await service.deleteSpec(name);
3806
+ const actor = c2.get("actor");
3807
+ const deleted = await service.deleteSpec(name, actor);
3408
3808
  if (!deleted) {
3409
3809
  return c2.json({ error: "not_found", message: `Spec "${name}" not found`, statusCode: 404 }, 404);
3410
3810
  }
@@ -4145,6 +4545,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c2) => {
4145
4545
  }
4146
4546
  const store = c2.get("store");
4147
4547
  const service = new GatewayService(store, store);
4548
+ const actor = c2.get("actor") ?? body.pushedBy;
4148
4549
  try {
4149
4550
  const result = await service.pushGatewayConfig({
4150
4551
  name: body.name,
@@ -4155,7 +4556,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c2) => {
4155
4556
  environments: body.environments ?? {},
4156
4557
  callerIdentification: body.callerIdentification,
4157
4558
  content: body.content,
4158
- pushedBy: body.pushedBy
4559
+ pushedBy: actor
4159
4560
  });
4160
4561
  return c2.json({ data: result }, 201);
4161
4562
  } catch (err) {
@@ -4421,6 +4822,14 @@ var gatewayLogStatsRoute = new Hono21().get("/stats", async (c2) => {
4421
4822
  });
4422
4823
 
4423
4824
  // src/registry/server.ts
4825
+ import { readFileSync } from "fs";
4826
+ import { fileURLToPath } from "url";
4827
+ import yaml10 from "js-yaml";
4828
+ function loadOpenApiSpec() {
4829
+ const path12 = fileURLToPath(new URL("../../openapi.yaml", import.meta.url));
4830
+ const content = readFileSync(path12, "utf-8");
4831
+ return yaml10.load(content);
4832
+ }
4424
4833
  function createApp(config, store) {
4425
4834
  const app = new Hono22();
4426
4835
  app.use("*", logger());
@@ -4431,6 +4840,21 @@ function createApp(config, store) {
4431
4840
  c2.set("config", config);
4432
4841
  await next();
4433
4842
  });
4843
+ const routeScopes = parseRouteScopes(loadOpenApiSpec());
4844
+ app.use("*", createAuthMiddleware(config, routeScopes));
4845
+ app.onError((err, c2) => {
4846
+ if (err instanceof AuthError) {
4847
+ return c2.json(
4848
+ { error: err.code, message: err.message, statusCode: err.statusCode },
4849
+ err.statusCode
4850
+ );
4851
+ }
4852
+ console.error("Unhandled error:", err);
4853
+ return c2.json(
4854
+ { error: "internal_error", message: "Internal server error", statusCode: 500 },
4855
+ 500
4856
+ );
4857
+ });
4434
4858
  app.route("/v1/specs", pushRoute);
4435
4859
  app.route("/v1/specs", validateRoute);
4436
4860
  app.route("/v1/specs", listRoute);
@@ -4458,7 +4882,8 @@ function createApp(config, store) {
4458
4882
  // src/registry/config.ts
4459
4883
  var defaultConfig = {
4460
4884
  port: 3750,
4461
- database: "sqlite"
4885
+ database: "sqlite",
4886
+ auth: { mode: "none" }
4462
4887
  };
4463
4888
 
4464
4889
  // src/registry/storage/sqlite.ts
@@ -5071,17 +5496,63 @@ var provisions2 = pgTable("provisions", {
5071
5496
  // src/registry/storage/postgresql.ts
5072
5497
  import { v4 as uuid6 } from "uuid";
5073
5498
  var MIGRATIONS_FOLDER2 = PG_MIGRATIONS_FOLDER;
5499
+ var DatabaseConnectionError = class extends Error {
5500
+ constructor(message, postgresUrl, options) {
5501
+ super(message, options);
5502
+ this.postgresUrl = postgresUrl;
5503
+ this.name = "DatabaseConnectionError";
5504
+ }
5505
+ postgresUrl;
5506
+ };
5507
+ function isConnectionError(err) {
5508
+ if (typeof err !== "object" || err === null) return false;
5509
+ if ("code" in err) {
5510
+ const code = err.code;
5511
+ if (code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "ENOTFOUND") {
5512
+ return true;
5513
+ }
5514
+ }
5515
+ if (err instanceof AggregateError) {
5516
+ if (err.errors.some(isConnectionError)) return true;
5517
+ }
5518
+ if ("cause" in err && err.cause) {
5519
+ return isConnectionError(err.cause);
5520
+ }
5521
+ return false;
5522
+ }
5523
+ function maskPostgresPassword(url) {
5524
+ try {
5525
+ const parsed = new URL(url);
5526
+ if (parsed.password) parsed.password = "***";
5527
+ return parsed.toString();
5528
+ } catch {
5529
+ return url;
5530
+ }
5531
+ }
5074
5532
  var PostgreSQLSpecStore = class {
5075
5533
  db;
5076
5534
  pool;
5535
+ postgresUrl;
5077
5536
  constructor(postgresUrl) {
5537
+ this.postgresUrl = postgresUrl;
5078
5538
  this.pool = new Pool({ connectionString: postgresUrl });
5079
5539
  this.db = drizzle2(this.pool);
5080
5540
  }
5081
5541
  async migrate() {
5082
- await migrate2(this.db, {
5083
- migrationsFolder: MIGRATIONS_FOLDER2
5084
- });
5542
+ try {
5543
+ await migrate2(this.db, {
5544
+ migrationsFolder: MIGRATIONS_FOLDER2
5545
+ });
5546
+ } catch (err) {
5547
+ if (isConnectionError(err)) {
5548
+ throw new DatabaseConnectionError(
5549
+ `PostgreSQL is not reachable at ${maskPostgresPassword(this.postgresUrl)}`,
5550
+ this.postgresUrl,
5551
+ { cause: err }
5552
+ );
5553
+ }
5554
+ throw err;
5555
+ }
5085
5556
  }
5086
5557
  async end() {
5087
5558
  await this.pool.end();
@@ -5511,15 +5982,34 @@ var DEFAULT_REGISTRY_URL = "http://localhost:3750";
5511
5982
  async function startHubServer(userConfig) {
5512
5983
  const config = {
5513
5984
  port: userConfig?.port ?? DEFAULT_PORT,
5514
- registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL
5985
+ registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL,
5986
+ auth: userConfig?.auth
5515
5987
  };
5516
5988
  const app = new Hono23();
5989
+ app.get("/config.js", (c2) => {
5990
+ const clientConfig = {
5991
+ registryUrl: config.registryUrl,
5992
+ auth: config.auth ? {
5993
+ mode: config.auth.mode,
5994
+ serverUrl: config.auth.serverUrl,
5995
+ realm: config.auth.realm,
5996
+ clientId: config.auth.clientId,
5997
+ audience: config.auth.audience
5998
+ } : void 0
5999
+ };
6000
+ c2.header("Content-Type", "application/javascript");
6001
+ return c2.body(
6002
+ `window.__GRAPITY_CONFIG__ = ${JSON.stringify(clientConfig)};`
6003
+ );
6004
+ });
5517
6005
  app.use("/v1/*", async (c2) => {
5518
6006
  const url = new URL(c2.req.url);
5519
6007
  const targetUrl = config.registryUrl + url.pathname + url.search;
6008
+ const headers = new Headers(c2.req.raw.headers);
6009
+ headers.delete("host");
5520
6010
  const response = await fetch(targetUrl, {
5521
6011
  method: c2.req.method,
5522
- headers: c2.req.raw.headers,
6012
+ headers,
5523
6013
  body: c2.req.raw.body
5524
6014
  });
5525
6015
  return response;
@@ -5527,13 +6017,21 @@ async function startHubServer(userConfig) {
5527
6017
  app.use("/*", serveStatic({ root: HUB_DIST_PATH }));
5528
6018
  app.get("/*", async (c2) => {
5529
6019
  const indexPath = path10.join(HUB_DIST_PATH, "index.html");
5530
- if (fs10.existsSync(indexPath)) {
5531
- return c2.html(fs10.readFileSync(indexPath, "utf-8"));
6020
+ if (!fs10.existsSync(indexPath)) {
6021
+ return c2.text(
6022
+ "index.html not found. Build the project with 'bun run build' first.",
6023
+ 404
6024
+ );
5532
6025
  }
5533
- return c2.text(
5534
- "index.html not found. Build the project with 'bun run build' first.",
5535
- 404
6026
+ const html = fs10.readFileSync(indexPath, "utf-8");
6027
+ const configScript = `
6028
+ <script src="/config.js"></script>
6029
+ `;
6030
+ const injected = html.replace(
6031
+ "</head>",
6032
+ `${configScript}</head>`
5536
6033
  );
6034
+ return c2.html(injected);
5537
6035
  });
5538
6036
  serve2({
5539
6037
  fetch: app.fetch,
@@ -5545,76 +6043,148 @@ if (process.argv[1] === new URL(import.meta.url).pathname) {
5545
6043
  }
5546
6044
 
5547
6045
  // src/cli/commands/serve.ts
5548
- function getPackageVersion() {
5549
- try {
5550
- const pkgPath = new URL("../../../../package.json", import.meta.url);
5551
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
5552
- return pkg.version;
5553
- } catch {
5554
- return "unknown";
5555
- }
5556
- }
5557
6046
  function resolveServerConfig(cliOptions, cliConfig) {
6047
+ if (cliConfig.mode === "remote") {
6048
+ throw new Error(
6049
+ `grapity serve is for local mode only. Your config is set to remote (${cliConfig.remote?.url ?? "no URL"}). Use grapity serve on the machine running the local registry.`
6050
+ );
6051
+ }
5558
6052
  const envUrl = process.env.GRAPITY_DATABASE_URL;
5559
- const dbValue = envUrl ?? cliOptions.db;
5560
- if (dbValue) {
5561
- if (isPostgresqlUrl(dbValue)) {
5562
- return { port: cliOptions.port, database: "postgresql", postgresUrl: dbValue };
5563
- }
5564
- return { port: cliOptions.port, database: "sqlite", sqlitePath: dbValue };
6053
+ const local = cliConfig.local;
6054
+ const dbValue = envUrl ?? local?.postgresUrl;
6055
+ const base = { port: cliOptions.port };
6056
+ if (dbValue && isPostgresqlUrl(dbValue)) {
6057
+ base.database = "postgresql";
6058
+ base.postgresUrl = dbValue;
6059
+ } else {
6060
+ base.database = "sqlite";
6061
+ base.sqlitePath = dbValue ?? local?.sqlitePath ?? path11.join(os4.homedir(), ".grapity", "registry.db");
5565
6062
  }
5566
- if (cliConfig.mode === "local") {
5567
- const local = cliConfig.local;
5568
- if (local?.database === "postgresql") {
5569
- if (!local.postgresUrl) {
5570
- throw new Error(
5571
- "PostgreSQL is configured but no postgresUrl is set. Run grapity init --local --db postgresql://... or set GRAPITY_DATABASE_URL."
5572
- );
5573
- }
5574
- return { port: cliOptions.port, database: "postgresql", postgresUrl: local.postgresUrl };
5575
- }
5576
- return {
5577
- port: cliOptions.port,
5578
- database: "sqlite",
5579
- sqlitePath: local?.sqlitePath ?? path11.join(os4.homedir(), ".grapity", "registry.db")
5580
- };
6063
+ if (cliOptions.noAuth) {
6064
+ base.auth = { mode: "none" };
6065
+ } else {
6066
+ base.auth = resolveAuthConfig(local?.auth);
6067
+ }
6068
+ return base;
6069
+ }
6070
+ function resolveAuthConfig(authConfig) {
6071
+ if (!authConfig || authConfig.mode === "none") {
6072
+ throw new Error(
6073
+ "Authentication is required by default. Configure it with: grapity init --local --auth keycloak ...\nOr run with: grapity serve --no-auth"
6074
+ );
6075
+ }
6076
+ if (!authConfig.serverUrl) {
6077
+ throw new Error(
6078
+ "Keycloak auth is configured but serverUrl is missing. Run grapity init --local --auth keycloak --keycloak-server <url> ..."
6079
+ );
6080
+ }
6081
+ if (!authConfig.realm) {
6082
+ throw new Error(
6083
+ "Keycloak auth is configured but realm is missing. Run grapity init --local --auth keycloak --keycloak-realm <realm> ..."
6084
+ );
5581
6085
  }
5582
6086
  return {
5583
- port: cliOptions.port,
5584
- database: "sqlite",
5585
- sqlitePath: path11.join(os4.homedir(), ".grapity", "registry.db")
6087
+ mode: "keycloak",
6088
+ serverUrl: authConfig.serverUrl.replace(/\/$/, ""),
6089
+ realm: authConfig.realm,
6090
+ audience: authConfig.audience,
6091
+ roleSource: authConfig.roleSource
5586
6092
  };
5587
6093
  }
5588
- function createServeCommand(_version) {
5589
- return new Command19("serve").description("Start the local grapity registry server").option("-p, --port <port>", "Port to listen on", "3750").option("--hub-port <port>", "Port for the developer portal (Hub)", "3000").option("--no-hub", "Skip starting the developer portal").option("--db <path-or-url>", "SQLite path or postgresql:// URL").action(async (options) => {
6094
+ async function verifyKeycloakReachable(serverUrl, realm) {
6095
+ const url = `${serverUrl}/realms/${realm}/.well-known/openid-configuration`;
6096
+ try {
6097
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
6098
+ if (!res.ok) {
6099
+ throw new Error(`Keycloak returned ${res.status}`);
6100
+ }
6101
+ } catch (err) {
6102
+ const message = err instanceof Error ? err.message : String(err);
6103
+ throw new Error(
6104
+ `Keycloak is not reachable at ${url}: ${message}
6105
+ See https://grapity.dev/docs/cli-reference/init#local-mode-with-keycloak to set up a local Keycloak server.
6106
+ Or run with: grapity serve --no-auth`
6107
+ );
6108
+ }
6109
+ }
6110
+ function formatDatabaseConnectionError(err) {
6111
+ return formatError(
6112
+ "PostgreSQL is not reachable",
6113
+ err.message,
6114
+ [
6115
+ "Make sure PostgreSQL is running and accessible.",
6116
+ "Check the database URL in ~/.grapity/config.yaml or GRAPITY_DATABASE_URL."
6117
+ ]
6118
+ );
6119
+ }
6120
+ function createServeCommand(version2) {
6121
+ return new Command20("serve").description("Start the local grapity registry server").option("-p, --port <port>", "Port to listen on", "3750").option("--hub-port <port>", "Port for the developer portal (Hub)", "3000").option("--no-hub", "Skip starting the developer portal").option("--no-auth", "Start without authentication").action(async (options) => {
5590
6122
  const port = parseInt(options.port, 10);
5591
6123
  const hubPort = parseInt(options.hubPort, 10);
5592
6124
  const startHub = options.hub !== false;
5593
- const version2 = getPackageVersion();
6125
+ const noAuth = options.auth === false;
6126
+ if (!configExists()) {
6127
+ console.error(
6128
+ formatError(
6129
+ "not initialized",
6130
+ "Run grapity init first to configure the local registry.",
6131
+ ["Example: grapity init --local"]
6132
+ )
6133
+ );
6134
+ process.exit(1);
6135
+ }
5594
6136
  const cliConfig = getConfig();
5595
6137
  let serverConfig;
5596
6138
  try {
5597
- serverConfig = resolveServerConfig({ port, db: options.db }, cliConfig);
6139
+ serverConfig = resolveServerConfig({ port, noAuth }, cliConfig);
5598
6140
  } catch (err) {
5599
6141
  console.error(formatError("invalid config", err.message));
5600
6142
  process.exit(1);
5601
6143
  }
5602
- console.log(formatHeader("grapity Registry", `v${version2}`));
6144
+ if (serverConfig.auth?.mode === "keycloak" && !noAuth) {
6145
+ try {
6146
+ await verifyKeycloakReachable(
6147
+ serverConfig.auth.serverUrl,
6148
+ serverConfig.auth.realm
6149
+ );
6150
+ } catch (err) {
6151
+ console.error(formatError("keycloak unreachable", err.message));
6152
+ process.exit(1);
6153
+ }
6154
+ }
6155
+ console.log(formatHeader("grapity", `v${version2}`));
6156
+ console.log("");
6157
+ console.log(formatHeader("grapity Registry"));
5603
6158
  console.log("");
5604
6159
  console.log(
5605
6160
  formatServeConfig({
5606
6161
  port,
5607
6162
  database: serverConfig.database,
5608
6163
  dbPath: serverConfig.sqlitePath,
5609
- postgresUrl: serverConfig.postgresUrl
6164
+ postgresUrl: serverConfig.postgresUrl,
6165
+ authMode: serverConfig.auth?.mode,
6166
+ keycloakServer: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.serverUrl : void 0,
6167
+ keycloakRealm: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.realm : void 0,
6168
+ keycloakAudience: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.audience : void 0
5610
6169
  })
5611
6170
  );
5612
6171
  console.log("");
5613
- const { store } = await startServer(serverConfig);
6172
+ let store;
6173
+ try {
6174
+ const started = await startServer(serverConfig);
6175
+ store = started.store;
6176
+ } catch (err) {
6177
+ if (err instanceof DatabaseConnectionError) {
6178
+ console.error(formatDatabaseConnectionError(err));
6179
+ } else {
6180
+ console.error(formatError("failed to start server", err.message));
6181
+ }
6182
+ process.exit(1);
6183
+ }
5614
6184
  console.log(formatReady(port));
5615
6185
  if (startHub) {
5616
6186
  console.log("");
5617
- console.log(formatHeader("grapity Hub", `v${version2}`));
6187
+ console.log(formatHeader("grapity Hub"));
5618
6188
  console.log("");
5619
6189
  console.log(
5620
6190
  formatHubConfig({
@@ -5625,7 +6195,14 @@ function createServeCommand(_version) {
5625
6195
  console.log("");
5626
6196
  await startHubServer({
5627
6197
  port: hubPort,
5628
- registryUrl: `http://localhost:${port}`
6198
+ registryUrl: `http://localhost:${port}`,
6199
+ auth: serverConfig.auth?.mode === "keycloak" ? {
6200
+ mode: "keycloak",
6201
+ serverUrl: serverConfig.auth.serverUrl,
6202
+ realm: serverConfig.auth.realm,
6203
+ clientId: "grapity-hub",
6204
+ audience: serverConfig.auth.audience
6205
+ } : void 0
5629
6206
  });
5630
6207
  console.log(formatHubReady(hubPort));
5631
6208
  }
@@ -5644,7 +6221,7 @@ function createServeCommand(_version) {
5644
6221
  import { createRequire } from "module";
5645
6222
  var require2 = createRequire(import.meta.url);
5646
6223
  var { version } = require2("../../package.json");
5647
- var program = new Command20();
6224
+ var program = new Command21();
5648
6225
  program.name("grapity").description("grapity - API spec registry and compatibility guardian").version(version).addHelpText(
5649
6226
  "after",
5650
6227
  "\nDocumentation: https://grapity.dev/docs/getting-started/quickstart"
@@ -5652,5 +6229,6 @@ program.name("grapity").description("grapity - API spec registry and compatibili
5652
6229
  program.addCommand(registryCommand);
5653
6230
  program.addCommand(gatewayCommand);
5654
6231
  program.addCommand(initCommand);
6232
+ program.addCommand(authCommand);
5655
6233
  program.addCommand(createServeCommand(version));
5656
6234
  program.parse();