@grapity/grapity 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +794 -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 +298 -3
- package/dist/registry/serve.d.ts +2 -1
- package/dist/registry/serve.js +349 -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,169 @@ 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, routeScopes2) {
|
|
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 routeScopes2) {
|
|
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
|
+
|
|
169
|
+
// src/cli/auth.ts
|
|
170
|
+
var cachedToken = null;
|
|
171
|
+
function resetTokenCache() {
|
|
172
|
+
cachedToken = null;
|
|
173
|
+
}
|
|
174
|
+
async function getAccessToken(config) {
|
|
175
|
+
const staticToken = process.env.GRAPITY_TOKEN;
|
|
176
|
+
if (staticToken) {
|
|
177
|
+
return staticToken;
|
|
178
|
+
}
|
|
179
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
180
|
+
if (cachedToken && cachedToken.expiresAt > now + 60) {
|
|
181
|
+
return cachedToken.accessToken;
|
|
182
|
+
}
|
|
183
|
+
const clientSecret = process.env.GRAPITY_CLIENT_SECRET;
|
|
184
|
+
if (!clientSecret) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
"Keycloak client secret not found. Set GRAPITY_CLIENT_SECRET or provide a static token via GRAPITY_TOKEN."
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const { tokenUrl } = buildKeycloakUrls({ serverUrl: config.serverUrl, realm: config.realm });
|
|
190
|
+
const params = new URLSearchParams();
|
|
191
|
+
params.set("grant_type", "client_credentials");
|
|
192
|
+
params.set("client_id", config.clientId);
|
|
193
|
+
params.set("client_secret", clientSecret);
|
|
194
|
+
if (config.audience) {
|
|
195
|
+
params.set("audience", config.audience);
|
|
196
|
+
}
|
|
197
|
+
const response = await fetch(tokenUrl, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
200
|
+
body: params.toString()
|
|
201
|
+
});
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
const text3 = await response.text().catch(() => "Unknown error");
|
|
204
|
+
throw new Error(`Keycloak token request failed: ${response.status} ${text3}`);
|
|
205
|
+
}
|
|
206
|
+
const data = await response.json();
|
|
207
|
+
cachedToken = {
|
|
208
|
+
accessToken: data.access_token,
|
|
209
|
+
expiresAt: now + data.expires_in
|
|
210
|
+
};
|
|
211
|
+
return data.access_token;
|
|
212
|
+
}
|
|
213
|
+
function decodeToken(token) {
|
|
214
|
+
try {
|
|
215
|
+
const payload = decodeJwt(token);
|
|
216
|
+
return {
|
|
217
|
+
sub: payload.sub,
|
|
218
|
+
exp: payload.exp,
|
|
219
|
+
iss: payload.iss,
|
|
220
|
+
scope: typeof payload.scope === "string" ? payload.scope : void 0,
|
|
221
|
+
roles: payload.realm_access?.roles
|
|
222
|
+
};
|
|
223
|
+
} catch {
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function formatTokenStatus(token) {
|
|
228
|
+
const decoded = decodeToken(token);
|
|
229
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
230
|
+
return {
|
|
231
|
+
valid: !!decoded.sub,
|
|
232
|
+
sub: decoded.sub,
|
|
233
|
+
exp: decoded.exp,
|
|
234
|
+
expired: decoded.exp ? decoded.exp < now : void 0
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
70
238
|
// src/cli/client.ts
|
|
71
239
|
var BreakingChangeError = class extends Error {
|
|
72
240
|
constructor(compatReport) {
|
|
@@ -76,13 +244,30 @@ var BreakingChangeError = class extends Error {
|
|
|
76
244
|
}
|
|
77
245
|
compatReport;
|
|
78
246
|
};
|
|
247
|
+
async function getAuthHeaders() {
|
|
248
|
+
const config = getConfig();
|
|
249
|
+
const authConfig = config.mode === "remote" ? config.remote?.auth : config.local?.auth;
|
|
250
|
+
const headers = {};
|
|
251
|
+
if (authConfig?.mode === "keycloak") {
|
|
252
|
+
if (!authConfig.clientId) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"Keycloak auth is configured but clientId is missing. Run grapity init with --keycloak-client-id."
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const token = await getAccessToken(authConfig);
|
|
258
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
259
|
+
}
|
|
260
|
+
return headers;
|
|
261
|
+
}
|
|
79
262
|
async function request(method, path12, body) {
|
|
80
263
|
const baseUrl = getRegistryUrl();
|
|
81
264
|
const url = `${baseUrl}${path12}`;
|
|
265
|
+
const authHeaders = await getAuthHeaders();
|
|
82
266
|
const response = await fetch(url, {
|
|
83
267
|
method,
|
|
84
268
|
headers: {
|
|
85
|
-
"Content-Type": "application/json"
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
...authHeaders
|
|
86
271
|
},
|
|
87
272
|
body: body ? JSON.stringify(body) : void 0
|
|
88
273
|
});
|
|
@@ -99,7 +284,11 @@ async function request(method, path12, body) {
|
|
|
99
284
|
async function requestText(method, path12) {
|
|
100
285
|
const baseUrl = getRegistryUrl();
|
|
101
286
|
const url = `${baseUrl}${path12}`;
|
|
102
|
-
const
|
|
287
|
+
const authHeaders = await getAuthHeaders();
|
|
288
|
+
const response = await fetch(url, {
|
|
289
|
+
method,
|
|
290
|
+
headers: authHeaders
|
|
291
|
+
});
|
|
103
292
|
if (!response.ok) {
|
|
104
293
|
const error = await response.json();
|
|
105
294
|
throw new Error(error.message ?? `Request failed: ${response.status}`);
|
|
@@ -534,12 +723,24 @@ function formatInitSuccess(params) {
|
|
|
534
723
|
if (params.database) lines.push(` ${c.label("Database")} ${c.primary(params.database)}`);
|
|
535
724
|
if (params.dbPath) lines.push(` ${c.label("Path")} ${c.dim(params.dbPath)}`);
|
|
536
725
|
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
726
|
} else {
|
|
540
727
|
if (params.url) lines.push(` ${c.label("URL")} ${c.cyan(params.url)}`);
|
|
728
|
+
}
|
|
729
|
+
if (params.authMode && params.authMode !== "none") {
|
|
730
|
+
lines.push("");
|
|
731
|
+
lines.push(` ${c.label("Auth")} ${c.primary(params.authMode)}`);
|
|
732
|
+
if (params.keycloakServer) lines.push(` ${c.label("Keycloak")} ${c.dim(params.keycloakServer)}`);
|
|
733
|
+
if (params.keycloakRealm) lines.push(` ${c.label("Realm")} ${c.dim(params.keycloakRealm)}`);
|
|
734
|
+
if (params.keycloakClientId) lines.push(` ${c.label("Client ID")} ${c.dim(params.keycloakClientId)}`);
|
|
735
|
+
lines.push("");
|
|
736
|
+
lines.push(` ${c.dim("\u203A")} Set the client secret in GRAPITY_CLIENT_SECRET before running commands.`);
|
|
737
|
+
} else {
|
|
541
738
|
lines.push("");
|
|
542
|
-
|
|
739
|
+
if (params.mode === "local") {
|
|
740
|
+
lines.push(` ${c.dim("\u203A")} Start the server with: ${c.primary("grapity serve")}`);
|
|
741
|
+
} else {
|
|
742
|
+
lines.push(` ${c.dim("\u203A")} Push a spec with: ${c.primary("grapity registry push ./openapi.yaml --name my-api")}`);
|
|
743
|
+
}
|
|
543
744
|
}
|
|
544
745
|
return lines.join("\n");
|
|
545
746
|
}
|
|
@@ -555,6 +756,16 @@ function formatServeConfig(params) {
|
|
|
555
756
|
if (params.database === "postgresql" && params.postgresUrl) {
|
|
556
757
|
lines.push(` ${c.label("PostgreSQL")} ${c.dim(maskPostgresUrl(params.postgresUrl))}`);
|
|
557
758
|
}
|
|
759
|
+
lines.push(` ${c.label("Auth")} ${c.primary(params.authMode ?? "none")}`);
|
|
760
|
+
if (params.keycloakServer) {
|
|
761
|
+
lines.push(` ${c.label("Keycloak")} ${c.dim(params.keycloakServer)}`);
|
|
762
|
+
}
|
|
763
|
+
if (params.keycloakRealm) {
|
|
764
|
+
lines.push(` ${c.label("Realm")} ${c.dim(params.keycloakRealm)}`);
|
|
765
|
+
}
|
|
766
|
+
if (params.keycloakAudience) {
|
|
767
|
+
lines.push(` ${c.label("Audience")} ${c.dim(params.keycloakAudience)}`);
|
|
768
|
+
}
|
|
558
769
|
return lines.join("\n");
|
|
559
770
|
}
|
|
560
771
|
function maskPostgresUrl(url) {
|
|
@@ -598,6 +809,39 @@ function formatEmptyState(message, hints) {
|
|
|
598
809
|
function formatReady(port) {
|
|
599
810
|
return ` ${c.success("\u25CF")} Server ready ${c.label("\xB7")} ${c.cyan(`http://localhost:${port}`)}`;
|
|
600
811
|
}
|
|
812
|
+
function formatAuthStatus(params) {
|
|
813
|
+
const lines = [];
|
|
814
|
+
if (params.cleared) {
|
|
815
|
+
lines.push(` ${c.success("\u2713")} Cached access token cleared`);
|
|
816
|
+
return lines.join("\n");
|
|
817
|
+
}
|
|
818
|
+
lines.push(` ${c.label("Mode")} ${c.primary(params.mode)}`);
|
|
819
|
+
if (!params.configured) {
|
|
820
|
+
lines.push(` ${c.dim("\xB7")} No remote authentication configured`);
|
|
821
|
+
return lines.join("\n");
|
|
822
|
+
}
|
|
823
|
+
if (params.serverUrl) lines.push(` ${c.label("Server")} ${c.dim(params.serverUrl)}`);
|
|
824
|
+
if (params.realm) lines.push(` ${c.label("Realm")} ${c.dim(params.realm)}`);
|
|
825
|
+
if (params.clientId) lines.push(` ${c.label("Client ID")} ${c.dim(params.clientId)}`);
|
|
826
|
+
if (params.audience) lines.push(` ${c.label("Audience")} ${c.dim(params.audience)}`);
|
|
827
|
+
lines.push("");
|
|
828
|
+
if (params.valid === false) {
|
|
829
|
+
lines.push(` ${c.error("\u2717")} Token is not a valid JWT`);
|
|
830
|
+
} else if (params.sub) {
|
|
831
|
+
lines.push(` ${c.success("\u2713")} Authenticated as ${c.primary(params.sub)}`);
|
|
832
|
+
if (params.exp) {
|
|
833
|
+
const date = new Date(params.exp * 1e3).toISOString();
|
|
834
|
+
if (params.expired) {
|
|
835
|
+
lines.push(` ${c.error("\u2717")} Token expired at ${c.dim(date)}`);
|
|
836
|
+
} else {
|
|
837
|
+
lines.push(` ${c.success("\u2713")} Token valid until ${c.dim(date)}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
lines.push(` ${c.dim("\xB7")} No token fetched yet`);
|
|
842
|
+
}
|
|
843
|
+
return lines.join("\n");
|
|
844
|
+
}
|
|
601
845
|
function formatHubReady(port) {
|
|
602
846
|
return ` ${c.success("\u25CF")} Hub ready ${c.label("\xB7")} ${c.cyan(`http://localhost:${port}`)}`;
|
|
603
847
|
}
|
|
@@ -1396,7 +1640,7 @@ import fs8 from "fs";
|
|
|
1396
1640
|
import os3 from "os";
|
|
1397
1641
|
import path8 from "path";
|
|
1398
1642
|
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) => {
|
|
1643
|
+
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
1644
|
const configDir = path8.join(os3.homedir(), ".grapity");
|
|
1401
1645
|
const configPath = path8.join(configDir, "config.yaml");
|
|
1402
1646
|
let mode;
|
|
@@ -1421,6 +1665,12 @@ var initCommand = new Command18("init").description("Configure grapity registry
|
|
|
1421
1665
|
);
|
|
1422
1666
|
process.exit(1);
|
|
1423
1667
|
}
|
|
1668
|
+
const authMode = options.auth ?? "none";
|
|
1669
|
+
if (authMode !== "none" && authMode !== "keycloak") {
|
|
1670
|
+
console.error(formatError("invalid auth mode", "--auth must be one of: none, keycloak"));
|
|
1671
|
+
process.exit(1);
|
|
1672
|
+
}
|
|
1673
|
+
const keycloakAuth = authMode === "keycloak" ? parseKeycloakOptions(options, mode) : void 0;
|
|
1424
1674
|
const config = { mode };
|
|
1425
1675
|
if (mode === "local") {
|
|
1426
1676
|
const dbValue = options.db ?? process.env.GRAPITY_DATABASE_URL;
|
|
@@ -1434,6 +1684,22 @@ var initCommand = new Command18("init").description("Configure grapity registry
|
|
|
1434
1684
|
} else {
|
|
1435
1685
|
config.local.sqlitePath = dbValue ?? path8.join(os3.homedir(), ".grapity", "registry.db");
|
|
1436
1686
|
}
|
|
1687
|
+
if (keycloakAuth) {
|
|
1688
|
+
if (!options.keycloakClientId) {
|
|
1689
|
+
console.error(
|
|
1690
|
+
formatError(
|
|
1691
|
+
"missing flag",
|
|
1692
|
+
"--keycloak-client-id is required when using Keycloak auth."
|
|
1693
|
+
)
|
|
1694
|
+
);
|
|
1695
|
+
process.exit(1);
|
|
1696
|
+
}
|
|
1697
|
+
config.local.auth = {
|
|
1698
|
+
mode: "keycloak",
|
|
1699
|
+
clientId: options.keycloakClientId,
|
|
1700
|
+
...keycloakAuth
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1437
1703
|
} else {
|
|
1438
1704
|
if (!options.url) {
|
|
1439
1705
|
console.error(
|
|
@@ -1448,6 +1714,16 @@ var initCommand = new Command18("init").description("Configure grapity registry
|
|
|
1448
1714
|
config.remote = {
|
|
1449
1715
|
url: options.url.replace(/\/$/, "")
|
|
1450
1716
|
};
|
|
1717
|
+
if (keycloakAuth) {
|
|
1718
|
+
config.remote.auth = {
|
|
1719
|
+
mode: "keycloak",
|
|
1720
|
+
serverUrl: keycloakAuth.serverUrl,
|
|
1721
|
+
realm: keycloakAuth.realm,
|
|
1722
|
+
clientId: keycloakAuth.clientId,
|
|
1723
|
+
audience: keycloakAuth.audience,
|
|
1724
|
+
roleSource: keycloakAuth.roleSource
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1451
1727
|
}
|
|
1452
1728
|
if (!fs8.existsSync(configDir)) {
|
|
1453
1729
|
fs8.mkdirSync(configDir, { recursive: true });
|
|
@@ -1462,16 +1738,107 @@ var initCommand = new Command18("init").description("Configure grapity registry
|
|
|
1462
1738
|
database: config.local?.database,
|
|
1463
1739
|
dbPath: config.local?.sqlitePath,
|
|
1464
1740
|
postgresUrl: config.local?.postgresUrl,
|
|
1465
|
-
url: config.remote?.url
|
|
1741
|
+
url: config.remote?.url,
|
|
1742
|
+
authMode,
|
|
1743
|
+
keycloakServer: keycloakAuth?.serverUrl,
|
|
1744
|
+
keycloakRealm: keycloakAuth?.realm,
|
|
1745
|
+
keycloakClientId: config.local?.auth?.clientId ?? config.remote?.auth?.clientId
|
|
1466
1746
|
})
|
|
1467
1747
|
);
|
|
1468
1748
|
});
|
|
1749
|
+
function parseKeycloakOptions(options, _mode) {
|
|
1750
|
+
if (!options.keycloakServer) {
|
|
1751
|
+
console.error(
|
|
1752
|
+
formatError(
|
|
1753
|
+
"missing flag",
|
|
1754
|
+
"--keycloak-server is required when auth mode is keycloak.",
|
|
1755
|
+
["Example: --keycloak-server https://keycloak.example.com"]
|
|
1756
|
+
)
|
|
1757
|
+
);
|
|
1758
|
+
process.exit(1);
|
|
1759
|
+
}
|
|
1760
|
+
if (!options.keycloakRealm) {
|
|
1761
|
+
console.error(
|
|
1762
|
+
formatError(
|
|
1763
|
+
"missing flag",
|
|
1764
|
+
"--keycloak-realm is required when auth mode is keycloak.",
|
|
1765
|
+
["Example: --keycloak-realm grapity"]
|
|
1766
|
+
)
|
|
1767
|
+
);
|
|
1768
|
+
process.exit(1);
|
|
1769
|
+
}
|
|
1770
|
+
const roleSource = options.keycloakRoleSource;
|
|
1771
|
+
if (roleSource && roleSource !== "scope" && roleSource !== "realm_access.roles") {
|
|
1772
|
+
console.error(formatError("invalid role source", "--keycloak-role-source must be one of: scope, realm_access.roles"));
|
|
1773
|
+
process.exit(1);
|
|
1774
|
+
}
|
|
1775
|
+
return {
|
|
1776
|
+
serverUrl: options.keycloakServer.replace(/\/$/, ""),
|
|
1777
|
+
realm: options.keycloakRealm,
|
|
1778
|
+
clientId: options.keycloakClientId,
|
|
1779
|
+
audience: options.keycloakAudience,
|
|
1780
|
+
roleSource
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1469
1783
|
|
|
1470
|
-
// src/cli/commands/
|
|
1784
|
+
// src/cli/commands/auth.ts
|
|
1471
1785
|
import { Command as Command19 } from "commander";
|
|
1786
|
+
var authCommand = new Command19("auth").description("Manage authentication for remote registry mode").addCommand(
|
|
1787
|
+
new Command19("status").description("Show current authentication status").action(async () => {
|
|
1788
|
+
const config = getConfig();
|
|
1789
|
+
const auth = config.mode === "remote" ? config.remote?.auth : config.local?.auth;
|
|
1790
|
+
if (!auth) {
|
|
1791
|
+
console.log(formatAuthStatus({ mode: "none", configured: false }));
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
if (auth.mode !== "keycloak") {
|
|
1795
|
+
console.log(formatAuthStatus({ mode: auth.mode, configured: true }));
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (!auth.clientId) {
|
|
1799
|
+
console.error(
|
|
1800
|
+
formatError(
|
|
1801
|
+
"auth misconfigured",
|
|
1802
|
+
"Keycloak auth is configured but clientId is missing. Run grapity init with --keycloak-client-id."
|
|
1803
|
+
)
|
|
1804
|
+
);
|
|
1805
|
+
process.exit(1);
|
|
1806
|
+
}
|
|
1807
|
+
try {
|
|
1808
|
+
const token = await getAccessToken(auth);
|
|
1809
|
+
const status = formatTokenStatus(token);
|
|
1810
|
+
console.log(
|
|
1811
|
+
formatAuthStatus({
|
|
1812
|
+
mode: auth.mode,
|
|
1813
|
+
configured: true,
|
|
1814
|
+
serverUrl: auth.serverUrl,
|
|
1815
|
+
realm: auth.realm,
|
|
1816
|
+
clientId: auth.clientId,
|
|
1817
|
+
audience: auth.audience,
|
|
1818
|
+
...status
|
|
1819
|
+
})
|
|
1820
|
+
);
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
console.error(
|
|
1823
|
+
formatError(
|
|
1824
|
+
"auth failed",
|
|
1825
|
+
err instanceof Error ? err.message : "Unable to fetch access token"
|
|
1826
|
+
)
|
|
1827
|
+
);
|
|
1828
|
+
process.exit(1);
|
|
1829
|
+
}
|
|
1830
|
+
})
|
|
1831
|
+
).addCommand(
|
|
1832
|
+
new Command19("clear").description("Clear the cached access token").action(() => {
|
|
1833
|
+
resetTokenCache();
|
|
1834
|
+
console.log(formatAuthStatus({ mode: "none", configured: false, cleared: true }));
|
|
1835
|
+
})
|
|
1836
|
+
);
|
|
1837
|
+
|
|
1838
|
+
// src/cli/commands/serve.ts
|
|
1839
|
+
import { Command as Command20 } from "commander";
|
|
1472
1840
|
import os4 from "os";
|
|
1473
1841
|
import path11 from "path";
|
|
1474
|
-
import { readFileSync } from "fs";
|
|
1475
1842
|
|
|
1476
1843
|
// src/registry/serve.ts
|
|
1477
1844
|
import path9 from "path";
|
|
@@ -3231,6 +3598,7 @@ var pushRoute = new Hono().post("/", async (c2) => {
|
|
|
3231
3598
|
}
|
|
3232
3599
|
const store = c2.get("store");
|
|
3233
3600
|
const service = new RegistryService(store);
|
|
3601
|
+
const actor = c2.get("actor") ?? body.pushedBy;
|
|
3234
3602
|
try {
|
|
3235
3603
|
const result = await service.pushSpec(body.content, body.name, {
|
|
3236
3604
|
type: body.type,
|
|
@@ -3239,7 +3607,7 @@ var pushRoute = new Hono().post("/", async (c2) => {
|
|
|
3239
3607
|
sourceRepo: body.sourceRepo,
|
|
3240
3608
|
tags: Array.isArray(body.tags) ? body.tags : void 0,
|
|
3241
3609
|
gitRef: body.gitRef,
|
|
3242
|
-
pushedBy:
|
|
3610
|
+
pushedBy: actor,
|
|
3243
3611
|
prerelease: body.prerelease,
|
|
3244
3612
|
force: body.force,
|
|
3245
3613
|
reason: body.reason
|
|
@@ -3404,7 +3772,8 @@ var deleteSpecRoute = new Hono5().delete(
|
|
|
3404
3772
|
const name = c2.req.param("name");
|
|
3405
3773
|
const store = c2.get("store");
|
|
3406
3774
|
const service = new RegistryService(store);
|
|
3407
|
-
const
|
|
3775
|
+
const actor = c2.get("actor");
|
|
3776
|
+
const deleted = await service.deleteSpec(name, actor);
|
|
3408
3777
|
if (!deleted) {
|
|
3409
3778
|
return c2.json({ error: "not_found", message: `Spec "${name}" not found`, statusCode: 404 }, 404);
|
|
3410
3779
|
}
|
|
@@ -4145,6 +4514,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c2) => {
|
|
|
4145
4514
|
}
|
|
4146
4515
|
const store = c2.get("store");
|
|
4147
4516
|
const service = new GatewayService(store, store);
|
|
4517
|
+
const actor = c2.get("actor") ?? body.pushedBy;
|
|
4148
4518
|
try {
|
|
4149
4519
|
const result = await service.pushGatewayConfig({
|
|
4150
4520
|
name: body.name,
|
|
@@ -4155,7 +4525,7 @@ var pushGatewayConfigRoute = new Hono13().post("/", async (c2) => {
|
|
|
4155
4525
|
environments: body.environments ?? {},
|
|
4156
4526
|
callerIdentification: body.callerIdentification,
|
|
4157
4527
|
content: body.content,
|
|
4158
|
-
pushedBy:
|
|
4528
|
+
pushedBy: actor
|
|
4159
4529
|
});
|
|
4160
4530
|
return c2.json({ data: result }, 201);
|
|
4161
4531
|
} catch (err) {
|
|
@@ -4420,6 +4790,192 @@ var gatewayLogStatsRoute = new Hono21().get("/stats", async (c2) => {
|
|
|
4420
4790
|
});
|
|
4421
4791
|
});
|
|
4422
4792
|
|
|
4793
|
+
// src/registry/generated/route-scopes.ts
|
|
4794
|
+
var routeScopes = [
|
|
4795
|
+
{
|
|
4796
|
+
"method": "GET",
|
|
4797
|
+
"path": "/v1/health",
|
|
4798
|
+
"operationId": "getHealth",
|
|
4799
|
+
"scopes": []
|
|
4800
|
+
},
|
|
4801
|
+
{
|
|
4802
|
+
"method": "GET",
|
|
4803
|
+
"path": "/v1/specs",
|
|
4804
|
+
"operationId": "listSpecs",
|
|
4805
|
+
"scopes": [
|
|
4806
|
+
"specs:read"
|
|
4807
|
+
]
|
|
4808
|
+
},
|
|
4809
|
+
{
|
|
4810
|
+
"method": "POST",
|
|
4811
|
+
"path": "/v1/specs",
|
|
4812
|
+
"operationId": "pushSpec",
|
|
4813
|
+
"scopes": [
|
|
4814
|
+
"specs:write"
|
|
4815
|
+
]
|
|
4816
|
+
},
|
|
4817
|
+
{
|
|
4818
|
+
"method": "GET",
|
|
4819
|
+
"path": "/v1/specs/:name",
|
|
4820
|
+
"operationId": "getSpec",
|
|
4821
|
+
"scopes": [
|
|
4822
|
+
"specs:read"
|
|
4823
|
+
]
|
|
4824
|
+
},
|
|
4825
|
+
{
|
|
4826
|
+
"method": "DELETE",
|
|
4827
|
+
"path": "/v1/specs/:name",
|
|
4828
|
+
"operationId": "deleteSpec",
|
|
4829
|
+
"scopes": [
|
|
4830
|
+
"specs:write"
|
|
4831
|
+
]
|
|
4832
|
+
},
|
|
4833
|
+
{
|
|
4834
|
+
"method": "POST",
|
|
4835
|
+
"path": "/v1/specs/:name/validate",
|
|
4836
|
+
"operationId": "validateSpec",
|
|
4837
|
+
"scopes": [
|
|
4838
|
+
"specs:write"
|
|
4839
|
+
]
|
|
4840
|
+
},
|
|
4841
|
+
{
|
|
4842
|
+
"method": "GET",
|
|
4843
|
+
"path": "/v1/specs/:name/versions",
|
|
4844
|
+
"operationId": "listVersions",
|
|
4845
|
+
"scopes": [
|
|
4846
|
+
"specs:read"
|
|
4847
|
+
]
|
|
4848
|
+
},
|
|
4849
|
+
{
|
|
4850
|
+
"method": "GET",
|
|
4851
|
+
"path": "/v1/specs/:name/versions/:semver",
|
|
4852
|
+
"operationId": "getVersion",
|
|
4853
|
+
"scopes": [
|
|
4854
|
+
"specs:read"
|
|
4855
|
+
]
|
|
4856
|
+
},
|
|
4857
|
+
{
|
|
4858
|
+
"method": "GET",
|
|
4859
|
+
"path": "/v1/specs/:name/spec.json",
|
|
4860
|
+
"operationId": "getSpecJson",
|
|
4861
|
+
"scopes": [
|
|
4862
|
+
"specs:read"
|
|
4863
|
+
]
|
|
4864
|
+
},
|
|
4865
|
+
{
|
|
4866
|
+
"method": "GET",
|
|
4867
|
+
"path": "/v1/specs/:name/spec.yaml",
|
|
4868
|
+
"operationId": "getSpecYaml",
|
|
4869
|
+
"scopes": [
|
|
4870
|
+
"specs:read"
|
|
4871
|
+
]
|
|
4872
|
+
},
|
|
4873
|
+
{
|
|
4874
|
+
"method": "GET",
|
|
4875
|
+
"path": "/v1/specs/:name/versions/:semver/spec.json",
|
|
4876
|
+
"operationId": "getVersionSpecJson",
|
|
4877
|
+
"scopes": [
|
|
4878
|
+
"specs:read"
|
|
4879
|
+
]
|
|
4880
|
+
},
|
|
4881
|
+
{
|
|
4882
|
+
"method": "GET",
|
|
4883
|
+
"path": "/v1/specs/:name/versions/:semver/spec.yaml",
|
|
4884
|
+
"operationId": "getVersionSpecYaml",
|
|
4885
|
+
"scopes": [
|
|
4886
|
+
"specs:read"
|
|
4887
|
+
]
|
|
4888
|
+
},
|
|
4889
|
+
{
|
|
4890
|
+
"method": "GET",
|
|
4891
|
+
"path": "/v1/specs/:name/compat/:semver",
|
|
4892
|
+
"operationId": "getCompatReport",
|
|
4893
|
+
"scopes": [
|
|
4894
|
+
"specs:read"
|
|
4895
|
+
]
|
|
4896
|
+
},
|
|
4897
|
+
{
|
|
4898
|
+
"method": "GET",
|
|
4899
|
+
"path": "/v1/specs/:name/compare",
|
|
4900
|
+
"operationId": "compareVersions",
|
|
4901
|
+
"scopes": [
|
|
4902
|
+
"specs:read"
|
|
4903
|
+
]
|
|
4904
|
+
},
|
|
4905
|
+
{
|
|
4906
|
+
"method": "GET",
|
|
4907
|
+
"path": "/v1/gateway-configs",
|
|
4908
|
+
"operationId": "listGatewayConfigs",
|
|
4909
|
+
"scopes": [
|
|
4910
|
+
"gateway-configs:read"
|
|
4911
|
+
]
|
|
4912
|
+
},
|
|
4913
|
+
{
|
|
4914
|
+
"method": "POST",
|
|
4915
|
+
"path": "/v1/gateway-configs",
|
|
4916
|
+
"operationId": "pushGatewayConfig",
|
|
4917
|
+
"scopes": [
|
|
4918
|
+
"gateway-configs:write"
|
|
4919
|
+
]
|
|
4920
|
+
},
|
|
4921
|
+
{
|
|
4922
|
+
"method": "GET",
|
|
4923
|
+
"path": "/v1/gateway-configs/:name",
|
|
4924
|
+
"operationId": "getGatewayConfig",
|
|
4925
|
+
"scopes": [
|
|
4926
|
+
"gateway-configs:read"
|
|
4927
|
+
]
|
|
4928
|
+
},
|
|
4929
|
+
{
|
|
4930
|
+
"method": "GET",
|
|
4931
|
+
"path": "/v1/gateway-configs/:name/versions",
|
|
4932
|
+
"operationId": "listGatewayConfigVersions",
|
|
4933
|
+
"scopes": [
|
|
4934
|
+
"gateway-configs:read"
|
|
4935
|
+
]
|
|
4936
|
+
},
|
|
4937
|
+
{
|
|
4938
|
+
"method": "GET",
|
|
4939
|
+
"path": "/v1/gateway-configs/:name/versions/:versionId",
|
|
4940
|
+
"operationId": "getGatewayConfigVersion",
|
|
4941
|
+
"scopes": [
|
|
4942
|
+
"gateway-configs:read"
|
|
4943
|
+
]
|
|
4944
|
+
},
|
|
4945
|
+
{
|
|
4946
|
+
"method": "POST",
|
|
4947
|
+
"path": "/v1/gateway-logs/ingest/:provider/:environment",
|
|
4948
|
+
"operationId": "ingestGatewayLog",
|
|
4949
|
+
"scopes": [
|
|
4950
|
+
"gateway-logs:write"
|
|
4951
|
+
]
|
|
4952
|
+
},
|
|
4953
|
+
{
|
|
4954
|
+
"method": "GET",
|
|
4955
|
+
"path": "/v1/gateway-logs",
|
|
4956
|
+
"operationId": "listGatewayLogs",
|
|
4957
|
+
"scopes": [
|
|
4958
|
+
"gateway-logs:read"
|
|
4959
|
+
]
|
|
4960
|
+
},
|
|
4961
|
+
{
|
|
4962
|
+
"method": "GET",
|
|
4963
|
+
"path": "/v1/gateway-logs/stats",
|
|
4964
|
+
"operationId": "getGatewayLogStats",
|
|
4965
|
+
"scopes": [
|
|
4966
|
+
"gateway-logs:read"
|
|
4967
|
+
]
|
|
4968
|
+
},
|
|
4969
|
+
{
|
|
4970
|
+
"method": "GET",
|
|
4971
|
+
"path": "/v1/gateway-logs/:id",
|
|
4972
|
+
"operationId": "getGatewayLog",
|
|
4973
|
+
"scopes": [
|
|
4974
|
+
"gateway-logs:read"
|
|
4975
|
+
]
|
|
4976
|
+
}
|
|
4977
|
+
];
|
|
4978
|
+
|
|
4423
4979
|
// src/registry/server.ts
|
|
4424
4980
|
function createApp(config, store) {
|
|
4425
4981
|
const app = new Hono22();
|
|
@@ -4431,6 +4987,21 @@ function createApp(config, store) {
|
|
|
4431
4987
|
c2.set("config", config);
|
|
4432
4988
|
await next();
|
|
4433
4989
|
});
|
|
4990
|
+
const authRouteScopes = config.auth?.mode === "keycloak" ? routeScopes : [];
|
|
4991
|
+
app.use("*", createAuthMiddleware(config, authRouteScopes));
|
|
4992
|
+
app.onError((err, c2) => {
|
|
4993
|
+
if (err instanceof AuthError) {
|
|
4994
|
+
return c2.json(
|
|
4995
|
+
{ error: err.code, message: err.message, statusCode: err.statusCode },
|
|
4996
|
+
err.statusCode
|
|
4997
|
+
);
|
|
4998
|
+
}
|
|
4999
|
+
console.error("Unhandled error:", err);
|
|
5000
|
+
return c2.json(
|
|
5001
|
+
{ error: "internal_error", message: "Internal server error", statusCode: 500 },
|
|
5002
|
+
500
|
|
5003
|
+
);
|
|
5004
|
+
});
|
|
4434
5005
|
app.route("/v1/specs", pushRoute);
|
|
4435
5006
|
app.route("/v1/specs", validateRoute);
|
|
4436
5007
|
app.route("/v1/specs", listRoute);
|
|
@@ -4458,7 +5029,8 @@ function createApp(config, store) {
|
|
|
4458
5029
|
// src/registry/config.ts
|
|
4459
5030
|
var defaultConfig = {
|
|
4460
5031
|
port: 3750,
|
|
4461
|
-
database: "sqlite"
|
|
5032
|
+
database: "sqlite",
|
|
5033
|
+
auth: { mode: "none" }
|
|
4462
5034
|
};
|
|
4463
5035
|
|
|
4464
5036
|
// src/registry/storage/sqlite.ts
|
|
@@ -5071,17 +5643,63 @@ var provisions2 = pgTable("provisions", {
|
|
|
5071
5643
|
// src/registry/storage/postgresql.ts
|
|
5072
5644
|
import { v4 as uuid6 } from "uuid";
|
|
5073
5645
|
var MIGRATIONS_FOLDER2 = PG_MIGRATIONS_FOLDER;
|
|
5646
|
+
var DatabaseConnectionError = class extends Error {
|
|
5647
|
+
constructor(message, postgresUrl, options) {
|
|
5648
|
+
super(message, options);
|
|
5649
|
+
this.postgresUrl = postgresUrl;
|
|
5650
|
+
this.name = "DatabaseConnectionError";
|
|
5651
|
+
}
|
|
5652
|
+
postgresUrl;
|
|
5653
|
+
};
|
|
5654
|
+
function isConnectionError(err) {
|
|
5655
|
+
if (typeof err !== "object" || err === null) return false;
|
|
5656
|
+
if ("code" in err) {
|
|
5657
|
+
const code = err.code;
|
|
5658
|
+
if (code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "ENOTFOUND") {
|
|
5659
|
+
return true;
|
|
5660
|
+
}
|
|
5661
|
+
}
|
|
5662
|
+
if (err instanceof AggregateError) {
|
|
5663
|
+
if (err.errors.some(isConnectionError)) return true;
|
|
5664
|
+
}
|
|
5665
|
+
if ("cause" in err && err.cause) {
|
|
5666
|
+
return isConnectionError(err.cause);
|
|
5667
|
+
}
|
|
5668
|
+
return false;
|
|
5669
|
+
}
|
|
5670
|
+
function maskPostgresPassword(url) {
|
|
5671
|
+
try {
|
|
5672
|
+
const parsed = new URL(url);
|
|
5673
|
+
if (parsed.password) parsed.password = "***";
|
|
5674
|
+
return parsed.toString();
|
|
5675
|
+
} catch {
|
|
5676
|
+
return url;
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5074
5679
|
var PostgreSQLSpecStore = class {
|
|
5075
5680
|
db;
|
|
5076
5681
|
pool;
|
|
5682
|
+
postgresUrl;
|
|
5077
5683
|
constructor(postgresUrl) {
|
|
5684
|
+
this.postgresUrl = postgresUrl;
|
|
5078
5685
|
this.pool = new Pool({ connectionString: postgresUrl });
|
|
5079
5686
|
this.db = drizzle2(this.pool);
|
|
5080
5687
|
}
|
|
5081
5688
|
async migrate() {
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5689
|
+
try {
|
|
5690
|
+
await migrate2(this.db, {
|
|
5691
|
+
migrationsFolder: MIGRATIONS_FOLDER2
|
|
5692
|
+
});
|
|
5693
|
+
} catch (err) {
|
|
5694
|
+
if (isConnectionError(err)) {
|
|
5695
|
+
throw new DatabaseConnectionError(
|
|
5696
|
+
`PostgreSQL is not reachable at ${maskPostgresPassword(this.postgresUrl)}`,
|
|
5697
|
+
this.postgresUrl,
|
|
5698
|
+
{ cause: err }
|
|
5699
|
+
);
|
|
5700
|
+
}
|
|
5701
|
+
throw err;
|
|
5702
|
+
}
|
|
5085
5703
|
}
|
|
5086
5704
|
async end() {
|
|
5087
5705
|
await this.pool.end();
|
|
@@ -5511,15 +6129,34 @@ var DEFAULT_REGISTRY_URL = "http://localhost:3750";
|
|
|
5511
6129
|
async function startHubServer(userConfig) {
|
|
5512
6130
|
const config = {
|
|
5513
6131
|
port: userConfig?.port ?? DEFAULT_PORT,
|
|
5514
|
-
registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL
|
|
6132
|
+
registryUrl: userConfig?.registryUrl ?? DEFAULT_REGISTRY_URL,
|
|
6133
|
+
auth: userConfig?.auth
|
|
5515
6134
|
};
|
|
5516
6135
|
const app = new Hono23();
|
|
6136
|
+
app.get("/config.js", (c2) => {
|
|
6137
|
+
const clientConfig = {
|
|
6138
|
+
registryUrl: config.registryUrl,
|
|
6139
|
+
auth: config.auth ? {
|
|
6140
|
+
mode: config.auth.mode,
|
|
6141
|
+
serverUrl: config.auth.serverUrl,
|
|
6142
|
+
realm: config.auth.realm,
|
|
6143
|
+
clientId: config.auth.clientId,
|
|
6144
|
+
audience: config.auth.audience
|
|
6145
|
+
} : void 0
|
|
6146
|
+
};
|
|
6147
|
+
c2.header("Content-Type", "application/javascript");
|
|
6148
|
+
return c2.body(
|
|
6149
|
+
`window.__GRAPITY_CONFIG__ = ${JSON.stringify(clientConfig)};`
|
|
6150
|
+
);
|
|
6151
|
+
});
|
|
5517
6152
|
app.use("/v1/*", async (c2) => {
|
|
5518
6153
|
const url = new URL(c2.req.url);
|
|
5519
6154
|
const targetUrl = config.registryUrl + url.pathname + url.search;
|
|
6155
|
+
const headers = new Headers(c2.req.raw.headers);
|
|
6156
|
+
headers.delete("host");
|
|
5520
6157
|
const response = await fetch(targetUrl, {
|
|
5521
6158
|
method: c2.req.method,
|
|
5522
|
-
headers
|
|
6159
|
+
headers,
|
|
5523
6160
|
body: c2.req.raw.body
|
|
5524
6161
|
});
|
|
5525
6162
|
return response;
|
|
@@ -5527,13 +6164,21 @@ async function startHubServer(userConfig) {
|
|
|
5527
6164
|
app.use("/*", serveStatic({ root: HUB_DIST_PATH }));
|
|
5528
6165
|
app.get("/*", async (c2) => {
|
|
5529
6166
|
const indexPath = path10.join(HUB_DIST_PATH, "index.html");
|
|
5530
|
-
if (fs10.existsSync(indexPath)) {
|
|
5531
|
-
return c2.
|
|
6167
|
+
if (!fs10.existsSync(indexPath)) {
|
|
6168
|
+
return c2.text(
|
|
6169
|
+
"index.html not found. Build the project with 'bun run build' first.",
|
|
6170
|
+
404
|
|
6171
|
+
);
|
|
5532
6172
|
}
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
6173
|
+
const html = fs10.readFileSync(indexPath, "utf-8");
|
|
6174
|
+
const configScript = `
|
|
6175
|
+
<script src="/config.js"></script>
|
|
6176
|
+
`;
|
|
6177
|
+
const injected = html.replace(
|
|
6178
|
+
"</head>",
|
|
6179
|
+
`${configScript}</head>`
|
|
5536
6180
|
);
|
|
6181
|
+
return c2.html(injected);
|
|
5537
6182
|
});
|
|
5538
6183
|
serve2({
|
|
5539
6184
|
fetch: app.fetch,
|
|
@@ -5545,76 +6190,148 @@ if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
|
5545
6190
|
}
|
|
5546
6191
|
|
|
5547
6192
|
// 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
6193
|
function resolveServerConfig(cliOptions, cliConfig) {
|
|
6194
|
+
if (cliConfig.mode === "remote") {
|
|
6195
|
+
throw new Error(
|
|
6196
|
+
`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.`
|
|
6197
|
+
);
|
|
6198
|
+
}
|
|
5558
6199
|
const envUrl = process.env.GRAPITY_DATABASE_URL;
|
|
5559
|
-
const
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
6200
|
+
const local = cliConfig.local;
|
|
6201
|
+
const dbValue = envUrl ?? local?.postgresUrl;
|
|
6202
|
+
const base = { port: cliOptions.port };
|
|
6203
|
+
if (dbValue && isPostgresqlUrl(dbValue)) {
|
|
6204
|
+
base.database = "postgresql";
|
|
6205
|
+
base.postgresUrl = dbValue;
|
|
6206
|
+
} else {
|
|
6207
|
+
base.database = "sqlite";
|
|
6208
|
+
base.sqlitePath = dbValue ?? local?.sqlitePath ?? path11.join(os4.homedir(), ".grapity", "registry.db");
|
|
5565
6209
|
}
|
|
5566
|
-
if (
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
6210
|
+
if (cliOptions.noAuth) {
|
|
6211
|
+
base.auth = { mode: "none" };
|
|
6212
|
+
} else {
|
|
6213
|
+
base.auth = resolveAuthConfig(local?.auth);
|
|
6214
|
+
}
|
|
6215
|
+
return base;
|
|
6216
|
+
}
|
|
6217
|
+
function resolveAuthConfig(authConfig) {
|
|
6218
|
+
if (!authConfig || authConfig.mode === "none") {
|
|
6219
|
+
throw new Error(
|
|
6220
|
+
"Authentication is required by default. Configure it with: grapity init --local --auth keycloak ...\nOr run with: grapity serve --no-auth"
|
|
6221
|
+
);
|
|
6222
|
+
}
|
|
6223
|
+
if (!authConfig.serverUrl) {
|
|
6224
|
+
throw new Error(
|
|
6225
|
+
"Keycloak auth is configured but serverUrl is missing. Run grapity init --local --auth keycloak --keycloak-server <url> ..."
|
|
6226
|
+
);
|
|
6227
|
+
}
|
|
6228
|
+
if (!authConfig.realm) {
|
|
6229
|
+
throw new Error(
|
|
6230
|
+
"Keycloak auth is configured but realm is missing. Run grapity init --local --auth keycloak --keycloak-realm <realm> ..."
|
|
6231
|
+
);
|
|
5581
6232
|
}
|
|
5582
6233
|
return {
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
6234
|
+
mode: "keycloak",
|
|
6235
|
+
serverUrl: authConfig.serverUrl.replace(/\/$/, ""),
|
|
6236
|
+
realm: authConfig.realm,
|
|
6237
|
+
audience: authConfig.audience,
|
|
6238
|
+
roleSource: authConfig.roleSource
|
|
5586
6239
|
};
|
|
5587
6240
|
}
|
|
5588
|
-
function
|
|
5589
|
-
|
|
6241
|
+
async function verifyKeycloakReachable(serverUrl, realm) {
|
|
6242
|
+
const url = `${serverUrl}/realms/${realm}/.well-known/openid-configuration`;
|
|
6243
|
+
try {
|
|
6244
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
6245
|
+
if (!res.ok) {
|
|
6246
|
+
throw new Error(`Keycloak returned ${res.status}`);
|
|
6247
|
+
}
|
|
6248
|
+
} catch (err) {
|
|
6249
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6250
|
+
throw new Error(
|
|
6251
|
+
`Keycloak is not reachable at ${url}: ${message}
|
|
6252
|
+
See https://grapity.dev/docs/cli-reference/init#local-mode-with-keycloak to set up a local Keycloak server.
|
|
6253
|
+
Or run with: grapity serve --no-auth`
|
|
6254
|
+
);
|
|
6255
|
+
}
|
|
6256
|
+
}
|
|
6257
|
+
function formatDatabaseConnectionError(err) {
|
|
6258
|
+
return formatError(
|
|
6259
|
+
"PostgreSQL is not reachable",
|
|
6260
|
+
err.message,
|
|
6261
|
+
[
|
|
6262
|
+
"Make sure PostgreSQL is running and accessible.",
|
|
6263
|
+
"Check the database URL in ~/.grapity/config.yaml or GRAPITY_DATABASE_URL."
|
|
6264
|
+
]
|
|
6265
|
+
);
|
|
6266
|
+
}
|
|
6267
|
+
function createServeCommand(version2) {
|
|
6268
|
+
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
6269
|
const port = parseInt(options.port, 10);
|
|
5591
6270
|
const hubPort = parseInt(options.hubPort, 10);
|
|
5592
6271
|
const startHub = options.hub !== false;
|
|
5593
|
-
const
|
|
6272
|
+
const noAuth = options.auth === false;
|
|
6273
|
+
if (!configExists()) {
|
|
6274
|
+
console.error(
|
|
6275
|
+
formatError(
|
|
6276
|
+
"not initialized",
|
|
6277
|
+
"Run grapity init first to configure the local registry.",
|
|
6278
|
+
["Example: grapity init --local"]
|
|
6279
|
+
)
|
|
6280
|
+
);
|
|
6281
|
+
process.exit(1);
|
|
6282
|
+
}
|
|
5594
6283
|
const cliConfig = getConfig();
|
|
5595
6284
|
let serverConfig;
|
|
5596
6285
|
try {
|
|
5597
|
-
serverConfig = resolveServerConfig({ port,
|
|
6286
|
+
serverConfig = resolveServerConfig({ port, noAuth }, cliConfig);
|
|
5598
6287
|
} catch (err) {
|
|
5599
6288
|
console.error(formatError("invalid config", err.message));
|
|
5600
6289
|
process.exit(1);
|
|
5601
6290
|
}
|
|
5602
|
-
|
|
6291
|
+
if (serverConfig.auth?.mode === "keycloak" && !noAuth) {
|
|
6292
|
+
try {
|
|
6293
|
+
await verifyKeycloakReachable(
|
|
6294
|
+
serverConfig.auth.serverUrl,
|
|
6295
|
+
serverConfig.auth.realm
|
|
6296
|
+
);
|
|
6297
|
+
} catch (err) {
|
|
6298
|
+
console.error(formatError("keycloak unreachable", err.message));
|
|
6299
|
+
process.exit(1);
|
|
6300
|
+
}
|
|
6301
|
+
}
|
|
6302
|
+
console.log(formatHeader("grapity", `v${version2}`));
|
|
6303
|
+
console.log("");
|
|
6304
|
+
console.log(formatHeader("grapity Registry"));
|
|
5603
6305
|
console.log("");
|
|
5604
6306
|
console.log(
|
|
5605
6307
|
formatServeConfig({
|
|
5606
6308
|
port,
|
|
5607
6309
|
database: serverConfig.database,
|
|
5608
6310
|
dbPath: serverConfig.sqlitePath,
|
|
5609
|
-
postgresUrl: serverConfig.postgresUrl
|
|
6311
|
+
postgresUrl: serverConfig.postgresUrl,
|
|
6312
|
+
authMode: serverConfig.auth?.mode,
|
|
6313
|
+
keycloakServer: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.serverUrl : void 0,
|
|
6314
|
+
keycloakRealm: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.realm : void 0,
|
|
6315
|
+
keycloakAudience: serverConfig.auth?.mode === "keycloak" ? serverConfig.auth.audience : void 0
|
|
5610
6316
|
})
|
|
5611
6317
|
);
|
|
5612
6318
|
console.log("");
|
|
5613
|
-
|
|
6319
|
+
let store;
|
|
6320
|
+
try {
|
|
6321
|
+
const started = await startServer(serverConfig);
|
|
6322
|
+
store = started.store;
|
|
6323
|
+
} catch (err) {
|
|
6324
|
+
if (err instanceof DatabaseConnectionError) {
|
|
6325
|
+
console.error(formatDatabaseConnectionError(err));
|
|
6326
|
+
} else {
|
|
6327
|
+
console.error(formatError("failed to start server", err.message));
|
|
6328
|
+
}
|
|
6329
|
+
process.exit(1);
|
|
6330
|
+
}
|
|
5614
6331
|
console.log(formatReady(port));
|
|
5615
6332
|
if (startHub) {
|
|
5616
6333
|
console.log("");
|
|
5617
|
-
console.log(formatHeader("grapity Hub"
|
|
6334
|
+
console.log(formatHeader("grapity Hub"));
|
|
5618
6335
|
console.log("");
|
|
5619
6336
|
console.log(
|
|
5620
6337
|
formatHubConfig({
|
|
@@ -5625,7 +6342,14 @@ function createServeCommand(_version) {
|
|
|
5625
6342
|
console.log("");
|
|
5626
6343
|
await startHubServer({
|
|
5627
6344
|
port: hubPort,
|
|
5628
|
-
registryUrl: `http://localhost:${port}
|
|
6345
|
+
registryUrl: `http://localhost:${port}`,
|
|
6346
|
+
auth: serverConfig.auth?.mode === "keycloak" ? {
|
|
6347
|
+
mode: "keycloak",
|
|
6348
|
+
serverUrl: serverConfig.auth.serverUrl,
|
|
6349
|
+
realm: serverConfig.auth.realm,
|
|
6350
|
+
clientId: "grapity-hub",
|
|
6351
|
+
audience: serverConfig.auth.audience
|
|
6352
|
+
} : void 0
|
|
5629
6353
|
});
|
|
5630
6354
|
console.log(formatHubReady(hubPort));
|
|
5631
6355
|
}
|
|
@@ -5644,7 +6368,7 @@ function createServeCommand(_version) {
|
|
|
5644
6368
|
import { createRequire } from "module";
|
|
5645
6369
|
var require2 = createRequire(import.meta.url);
|
|
5646
6370
|
var { version } = require2("../../package.json");
|
|
5647
|
-
var program = new
|
|
6371
|
+
var program = new Command21();
|
|
5648
6372
|
program.name("grapity").description("grapity - API spec registry and compatibility guardian").version(version).addHelpText(
|
|
5649
6373
|
"after",
|
|
5650
6374
|
"\nDocumentation: https://grapity.dev/docs/getting-started/quickstart"
|
|
@@ -5652,5 +6376,6 @@ program.name("grapity").description("grapity - API spec registry and compatibili
|
|
|
5652
6376
|
program.addCommand(registryCommand);
|
|
5653
6377
|
program.addCommand(gatewayCommand);
|
|
5654
6378
|
program.addCommand(initCommand);
|
|
6379
|
+
program.addCommand(authCommand);
|
|
5655
6380
|
program.addCommand(createServeCommand(version));
|
|
5656
6381
|
program.parse();
|