@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/README.md +47 -14
- package/dist/assets/index-JAhtTTW2.js +336 -0
- package/dist/assets/index-JAhtTTW2.js.map +1 -0
- package/dist/assets/index-LDlidn22.css +1 -0
- package/dist/cli/index.js +647 -69
- package/dist/core/index.d.ts +391 -23
- package/dist/hub/index.js +34 -7
- package/dist/hub/serve.d.ts +9 -1
- package/dist/hub/serve.js +34 -7
- package/dist/index.html +3 -2
- package/dist/registry/{index-DUPbMrAe.d.ts → index-Bx-7YlUF.d.ts} +15 -0
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.js +151 -3
- package/dist/registry/serve.d.ts +2 -1
- package/dist/registry/serve.js +202 -7
- package/package.json +13 -11
- package/dist/assets/index-Dq5tdnlb.js +0 -326
- package/dist/assets/index-Dq5tdnlb.js.map +0 -1
- package/dist/assets/index-NJpHAonA.css +0 -1
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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/
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
5083
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
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
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
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 (
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
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
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
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
|
|
5589
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
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();
|