@ferrule-io/ok-fine 0.3.6 → 0.3.7
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 +4 -3
- package/dist/auth/discovery.js +11 -0
- package/dist/auth/verifier.js +44 -1
- package/dist/config.js +130 -6
- package/dist/http/rest.js +1 -1
- package/dist/http/ui.js +118 -0
- package/dist/mcp/server.js +3 -2
- package/dist/okf/lint.js +20 -0
- package/dist/server.js +113 -4
- package/dist/store/fs-util.js +59 -4
- package/dist/store/git-backend.js +208 -59
- package/dist/store/git.js +92 -19
- package/dist/ui/assets/bash-BiGZg9Om.js +1 -0
- package/dist/ui/assets/css-BGIxQoYs.js +1 -0
- package/dist/ui/assets/css-DUjzNfgv.js +1 -0
- package/dist/ui/assets/diff-woXpYk--.js +1 -0
- package/dist/ui/assets/dockerfile-IyjqRm3v.js +1 -0
- package/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/dist/ui/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/ui/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/dist/ui/assets/github-light-default-BXViO-2h.js +1 -0
- package/dist/ui/assets/go-rLFTqkRN.js +1 -0
- package/dist/ui/assets/highlight-CW19vWvw.js +153 -0
- package/dist/ui/assets/html-BMn1ogTm.js +1 -0
- package/dist/ui/assets/html-BlpXnbzK.js +1 -0
- package/dist/ui/assets/index-0b2NNRC5.js +127 -0
- package/dist/ui/assets/index-CFuTAZQM.css +1 -0
- package/dist/ui/assets/java-BtIQNnN0.js +1 -0
- package/dist/ui/assets/java-DxlWrXwe.js +1 -0
- package/dist/ui/assets/javascript-BzbQS41l.js +1 -0
- package/dist/ui/assets/javascript-Yelw-5ZN.js +1 -0
- package/dist/ui/assets/json-qhed-kSA.js +1 -0
- package/dist/ui/assets/jsx-D3KKoC5L.js +1 -0
- package/dist/ui/assets/jsx-hEbJ82HF.js +1 -0
- package/dist/ui/assets/markdown-BYOwaDjH.js +1 -0
- package/dist/ui/assets/python-gzcpVVnB.js +1 -0
- package/dist/ui/assets/ruby-Di5fYh6S.js +1 -0
- package/dist/ui/assets/rust-DpiIf5JO.js +1 -0
- package/dist/ui/assets/shellscript-CqHdAlCp.js +1 -0
- package/dist/ui/assets/sql-BOsskAmu.js +1 -0
- package/dist/ui/assets/sql-CNu3Or2W.js +1 -0
- package/dist/ui/assets/toml-CcmNWLt0.js +1 -0
- package/dist/ui/assets/tsx-5f3ulCja.js +1 -0
- package/dist/ui/assets/tsx-BOvsFOI6.js +1 -0
- package/dist/ui/assets/typescript-Utq2Cl8c.js +1 -0
- package/dist/ui/assets/typescript-j_1H8WHN.js +1 -0
- package/dist/ui/assets/yaml-CxbubiML.js +1 -0
- package/dist/ui/assets/yaml-DijMHRDA.js +1 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +16 -0
- package/dist/ui/theme-init.js +10 -0
- package/dist/version.js +1 -1
- package/package.json +25 -4
- package/dist/store/repo.js +0 -471
package/dist/server.js
CHANGED
|
@@ -9,28 +9,67 @@ import { JwtTokenVerifier, principalFromAuthInfo } from "./auth/verifier.js";
|
|
|
9
9
|
import { OkfError } from "./errors.js";
|
|
10
10
|
import { registerMcpRoute } from "./http/mcp-route.js";
|
|
11
11
|
import { registerRestRoutes } from "./http/rest.js";
|
|
12
|
+
import { DEFAULT_UI_DIR, endpointOrigins, registerUiRoutes } from "./http/ui.js";
|
|
12
13
|
import { createMcpServer } from "./mcp/server.js";
|
|
13
14
|
import { KnowledgeService } from "./service/knowledge-service.js";
|
|
14
15
|
import { Catalog } from "./store/catalog.js";
|
|
15
16
|
import { GitBackend } from "./store/git-backend.js";
|
|
17
|
+
import { VERSION } from "./version.js";
|
|
16
18
|
const DISCOVERY_ATTEMPTS = 5;
|
|
17
19
|
const DISCOVERY_RETRY_MS = 2000;
|
|
18
|
-
|
|
20
|
+
function parseHostHeader(hostHeader) {
|
|
21
|
+
if (!hostHeader || typeof hostHeader !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
const trimmed = hostHeader.trim();
|
|
24
|
+
if (trimmed.length === 0 || /[/\s\\@]/.test(trimmed))
|
|
25
|
+
return null;
|
|
26
|
+
if (trimmed.startsWith("[")) {
|
|
27
|
+
const closeBracket = trimmed.indexOf("]");
|
|
28
|
+
if (closeBracket === -1)
|
|
29
|
+
return null;
|
|
30
|
+
const rest = trimmed.slice(closeBracket + 1);
|
|
31
|
+
if (rest.length > 0 && !/^:\d+$/.test(rest))
|
|
32
|
+
return null;
|
|
33
|
+
return trimmed.slice(0, closeBracket + 1).toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
const colonIndex = trimmed.indexOf(":");
|
|
36
|
+
if (colonIndex !== -1) {
|
|
37
|
+
const port = trimmed.slice(colonIndex + 1);
|
|
38
|
+
if (!/^\d+$/.test(port))
|
|
39
|
+
return null;
|
|
40
|
+
return trimmed.slice(0, colonIndex).toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
return trimmed.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
export async function startServer(config, options = {}) {
|
|
45
|
+
const tp = config.trustProxy;
|
|
46
|
+
const trustProxy = typeof tp === "number"
|
|
47
|
+
? (_addr, hop) => hop < tp
|
|
48
|
+
: typeof tp === "string"
|
|
49
|
+
? tp
|
|
50
|
+
.split(",")
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length > 0)
|
|
53
|
+
: tp;
|
|
19
54
|
const app = Fastify({
|
|
20
55
|
logger: { level: config.logLevel, redact: ["req.headers.authorization"] },
|
|
21
|
-
trustProxy
|
|
56
|
+
trustProxy,
|
|
57
|
+
requestTimeout: 120_000,
|
|
58
|
+
connectionTimeout: 120_000,
|
|
22
59
|
bodyLimit: 4 * 1024 * 1024,
|
|
23
60
|
});
|
|
24
61
|
app.removeContentTypeParser("text/plain");
|
|
25
62
|
app.addContentTypeParser(/^text\//, { parseAs: "string", bodyLimit: config.maxFileBytes + 65536 }, (_req, body, done) => done(null, body));
|
|
26
63
|
app.addContentTypeParser(["application/gzip", "application/x-gzip", "application/octet-stream"], { parseAs: "buffer", bodyLimit: config.maxArchiveBytes }, (_req, body, done) => done(null, body));
|
|
27
64
|
let authHandler;
|
|
65
|
+
let uiConnectSrc = [];
|
|
28
66
|
if (config.auth.mode === "oidc") {
|
|
29
67
|
let discovered;
|
|
30
68
|
for (let attempt = 1; !discovered; attempt++) {
|
|
31
69
|
try {
|
|
32
70
|
discovered = await discoverAuthorizationServer(config.auth.issuer, {
|
|
33
71
|
timeoutMs: 5000,
|
|
72
|
+
allowInsecureIssuer: config.auth.allowInsecureIssuer,
|
|
34
73
|
...(config.auth.jwksUri === undefined ? {} : { jwksUri: config.auth.jwksUri }),
|
|
35
74
|
});
|
|
36
75
|
}
|
|
@@ -42,6 +81,8 @@ export async function startServer(config) {
|
|
|
42
81
|
}
|
|
43
82
|
}
|
|
44
83
|
const { metadata, jwksUri } = discovered;
|
|
84
|
+
// The web UI fetches these cross-origin; it reaches authorization_endpoint by navigation, which CSP does not cover.
|
|
85
|
+
uiConnectSrc = endpointOrigins(metadata.token_endpoint, metadata.registration_endpoint);
|
|
45
86
|
const resourceServerUrl = new URL(`${config.publicBaseUrl}/mcp`);
|
|
46
87
|
const prm = buildOAuthProtectedResourceMetadata({
|
|
47
88
|
// Discovery checked `issuer`; buildOAuthProtectedResourceMetadata validates the rest and throws on misconfiguration.
|
|
@@ -55,11 +96,19 @@ export async function startServer(config) {
|
|
|
55
96
|
app.get(path, async (_req, reply) => reply.header("access-control-allow-origin", "*").send(prm));
|
|
56
97
|
}
|
|
57
98
|
app.get("/.well-known/oauth-authorization-server", async (_req, reply) => reply.header("access-control-allow-origin", "*").send(metadata));
|
|
99
|
+
const { access } = config.auth;
|
|
100
|
+
if (access.allowedSubjects.length === 0 &&
|
|
101
|
+
access.allowedEmails.length === 0 &&
|
|
102
|
+
access.requiredGroups.length === 0 &&
|
|
103
|
+
access.allowedClientIds.length === 0) {
|
|
104
|
+
app.log.warn(`no OAUTH_ALLOWED_* / OAUTH_REQUIRED_GROUPS policy set: every token from ${config.auth.issuer} with an ok-fine scope can read all projects`);
|
|
105
|
+
}
|
|
58
106
|
const verifier = new JwtTokenVerifier({
|
|
59
107
|
issuer: config.auth.issuer,
|
|
60
108
|
audiences: config.auth.audiences,
|
|
61
109
|
jwksUri,
|
|
62
110
|
identityClaims: config.auth.identityClaims,
|
|
111
|
+
access: config.auth.access,
|
|
63
112
|
});
|
|
64
113
|
const authenticator = createAuthenticator({
|
|
65
114
|
verifier,
|
|
@@ -81,6 +130,55 @@ export async function startServer(config) {
|
|
|
81
130
|
}
|
|
82
131
|
else {
|
|
83
132
|
app.log.warn("authentication disabled, every request has admin access; never expose this beyond localhost");
|
|
133
|
+
const allowedHosts = {
|
|
134
|
+
localhost: true,
|
|
135
|
+
"127.0.0.1": true,
|
|
136
|
+
"[::1]": true,
|
|
137
|
+
"::1": true,
|
|
138
|
+
};
|
|
139
|
+
const publicHost = new URL(config.publicBaseUrl).hostname.toLowerCase();
|
|
140
|
+
allowedHosts[publicHost] = true;
|
|
141
|
+
if (publicHost.startsWith("[") && publicHost.endsWith("]")) {
|
|
142
|
+
allowedHosts[publicHost.slice(1, -1)] = true;
|
|
143
|
+
}
|
|
144
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
145
|
+
if (req.method === "GET" && (req.url === "/healthz" || req.url.startsWith("/healthz?"))) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const host = parseHostHeader(req.headers.host);
|
|
149
|
+
if (!host || !allowedHosts[host]) {
|
|
150
|
+
return reply.code(403).send({
|
|
151
|
+
error: {
|
|
152
|
+
code: "forbidden",
|
|
153
|
+
message: `Host header '${req.headers.host ?? ""}' is not allowed in AUTH_MODE=none`,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (req.headers.origin !== undefined) {
|
|
158
|
+
const rawOrigin = Array.isArray(req.headers.origin) ? req.headers.origin[0] : req.headers.origin;
|
|
159
|
+
let originAllowed = false;
|
|
160
|
+
if (typeof rawOrigin === "string") {
|
|
161
|
+
try {
|
|
162
|
+
const parsed = new URL(rawOrigin);
|
|
163
|
+
if ((parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
164
|
+
allowedHosts[parsed.hostname.toLowerCase()]) {
|
|
165
|
+
originAllowed = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// invalid origin URL
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!originAllowed) {
|
|
173
|
+
return reply.code(403).send({
|
|
174
|
+
error: {
|
|
175
|
+
code: "forbidden",
|
|
176
|
+
message: `Origin '${rawOrigin ?? ""}' is not allowed in AUTH_MODE=none`,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
84
182
|
const allScopes = [config.scopeNames.read, config.scopeNames.write, config.scopeNames.admin];
|
|
85
183
|
const anonymousAuthInfo = {
|
|
86
184
|
token: "",
|
|
@@ -135,15 +233,26 @@ export async function startServer(config) {
|
|
|
135
233
|
return reply.code(500).send({ error: { code: "internal", message: "internal error" } });
|
|
136
234
|
});
|
|
137
235
|
app.get("/healthz", async () => ({ status: "ok" }));
|
|
236
|
+
await registerUiRoutes(app, {
|
|
237
|
+
dir: options.uiDir ?? DEFAULT_UI_DIR,
|
|
238
|
+
publicBaseUrl: config.publicBaseUrl,
|
|
239
|
+
clientConfig: {
|
|
240
|
+
authMode: config.auth.mode,
|
|
241
|
+
oauthClientId: config.auth.mode === "oidc" ? (config.auth.uiClientId ?? null) : null,
|
|
242
|
+
scope: config.scopeNames.read,
|
|
243
|
+
version: VERSION,
|
|
244
|
+
},
|
|
245
|
+
connectSrc: uiConnectSrc,
|
|
246
|
+
});
|
|
138
247
|
app.decorateRequest("principal", null);
|
|
139
248
|
app.decorateRequest("authInfo", null);
|
|
140
249
|
await app.register(async (secured) => {
|
|
141
|
-
secured.addHook("
|
|
250
|
+
secured.addHook("onRequest", async (req, reply) => {
|
|
142
251
|
const challenge = await authHandler.authenticate(req);
|
|
143
252
|
if (challenge)
|
|
144
253
|
return reply.send(challenge);
|
|
145
254
|
if (authHandler.mode === "oidc") {
|
|
146
|
-
const need = req.routeOptions
|
|
255
|
+
const need = req.routeOptions?.config?.permission ?? "read";
|
|
147
256
|
const p = req.principal;
|
|
148
257
|
const permitted = p !== null && (need === "admin" ? p.canAdmin : need === "write" ? p.canWrite : p.canRead);
|
|
149
258
|
if (!permitted)
|
package/dist/store/fs-util.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { access, mkdir, readdir, readFile, rename,
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { access, lstat, mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
4
|
+
import { OkfError } from "../errors.js";
|
|
4
5
|
export async function atomicWrite(absPath, data) {
|
|
5
6
|
const dir = dirname(absPath);
|
|
6
7
|
await mkdir(dir, { recursive: true });
|
|
@@ -19,8 +20,8 @@ export async function pathExists(absPath) {
|
|
|
19
20
|
}
|
|
20
21
|
export async function isDirectory(absPath) {
|
|
21
22
|
try {
|
|
22
|
-
const s = await
|
|
23
|
-
return s.isDirectory();
|
|
23
|
+
const s = await lstat(absPath);
|
|
24
|
+
return s.isDirectory() && !s.isSymbolicLink();
|
|
24
25
|
}
|
|
25
26
|
catch {
|
|
26
27
|
return false;
|
|
@@ -31,6 +32,15 @@ export async function isDirectory(absPath) {
|
|
|
31
32
|
* non-regular entries are excluded and never followed. A missing root yields [].
|
|
32
33
|
*/
|
|
33
34
|
export async function listTreeFiles(rootDir) {
|
|
35
|
+
try {
|
|
36
|
+
const s = await lstat(rootDir);
|
|
37
|
+
if (s.isSymbolicLink() || !s.isDirectory()) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
34
44
|
const files = [];
|
|
35
45
|
async function walk(rel) {
|
|
36
46
|
let entries;
|
|
@@ -62,4 +72,49 @@ export async function fsBundleSource(rootDir) {
|
|
|
62
72
|
read: (path) => readFile(join(rootDir, path)).catch(() => null),
|
|
63
73
|
};
|
|
64
74
|
}
|
|
75
|
+
export async function assertContainedPath(repoDir, project, relPath) {
|
|
76
|
+
const projectDir = join(repoDir, project);
|
|
77
|
+
try {
|
|
78
|
+
const s = await lstat(projectDir);
|
|
79
|
+
if (s.isSymbolicLink()) {
|
|
80
|
+
throw new OkfError("invalid_path", 400, `project directory "${project}" is a symbolic link`);
|
|
81
|
+
}
|
|
82
|
+
if (!s.isDirectory()) {
|
|
83
|
+
throw new OkfError("invalid_path", 400, `project directory "${project}" is not a directory`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err.code === "ENOENT") {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
if (!relPath) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const parentDir = posix.dirname(relPath);
|
|
96
|
+
if (parentDir === "." || parentDir === "") {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const segments = parentDir.split("/").filter((s) => s.length > 0);
|
|
100
|
+
let current = projectDir;
|
|
101
|
+
for (const segment of segments) {
|
|
102
|
+
current = join(current, segment);
|
|
103
|
+
try {
|
|
104
|
+
const s = await lstat(current);
|
|
105
|
+
if (s.isSymbolicLink()) {
|
|
106
|
+
throw new OkfError("invalid_path", 400, `path component "${segment}" is a symbolic link`);
|
|
107
|
+
}
|
|
108
|
+
if (!s.isDirectory()) {
|
|
109
|
+
throw new OkfError("invalid_path", 400, `path component "${segment}" is not a directory`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err.code === "ENOENT") {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
65
120
|
//# sourceMappingURL=fs-util.js.map
|