@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.
Files changed (63) hide show
  1. package/README.md +4 -3
  2. package/dist/auth/discovery.js +11 -0
  3. package/dist/auth/verifier.js +44 -1
  4. package/dist/config.js +130 -6
  5. package/dist/http/rest.js +1 -1
  6. package/dist/http/ui.js +118 -0
  7. package/dist/mcp/server.js +3 -2
  8. package/dist/okf/lint.js +20 -0
  9. package/dist/server.js +113 -4
  10. package/dist/store/fs-util.js +59 -4
  11. package/dist/store/git-backend.js +208 -59
  12. package/dist/store/git.js +92 -19
  13. package/dist/ui/assets/bash-BiGZg9Om.js +1 -0
  14. package/dist/ui/assets/css-BGIxQoYs.js +1 -0
  15. package/dist/ui/assets/css-DUjzNfgv.js +1 -0
  16. package/dist/ui/assets/diff-woXpYk--.js +1 -0
  17. package/dist/ui/assets/dockerfile-IyjqRm3v.js +1 -0
  18. package/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  19. package/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  20. package/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  21. package/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  22. package/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  23. package/dist/ui/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  24. package/dist/ui/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  25. package/dist/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  26. package/dist/ui/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  27. package/dist/ui/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  28. package/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  29. package/dist/ui/assets/github-dark-default-DXG-b-1a.js +1 -0
  30. package/dist/ui/assets/github-light-default-BXViO-2h.js +1 -0
  31. package/dist/ui/assets/go-rLFTqkRN.js +1 -0
  32. package/dist/ui/assets/highlight-CW19vWvw.js +153 -0
  33. package/dist/ui/assets/html-BMn1ogTm.js +1 -0
  34. package/dist/ui/assets/html-BlpXnbzK.js +1 -0
  35. package/dist/ui/assets/index-0b2NNRC5.js +127 -0
  36. package/dist/ui/assets/index-CFuTAZQM.css +1 -0
  37. package/dist/ui/assets/java-BtIQNnN0.js +1 -0
  38. package/dist/ui/assets/java-DxlWrXwe.js +1 -0
  39. package/dist/ui/assets/javascript-BzbQS41l.js +1 -0
  40. package/dist/ui/assets/javascript-Yelw-5ZN.js +1 -0
  41. package/dist/ui/assets/json-qhed-kSA.js +1 -0
  42. package/dist/ui/assets/jsx-D3KKoC5L.js +1 -0
  43. package/dist/ui/assets/jsx-hEbJ82HF.js +1 -0
  44. package/dist/ui/assets/markdown-BYOwaDjH.js +1 -0
  45. package/dist/ui/assets/python-gzcpVVnB.js +1 -0
  46. package/dist/ui/assets/ruby-Di5fYh6S.js +1 -0
  47. package/dist/ui/assets/rust-DpiIf5JO.js +1 -0
  48. package/dist/ui/assets/shellscript-CqHdAlCp.js +1 -0
  49. package/dist/ui/assets/sql-BOsskAmu.js +1 -0
  50. package/dist/ui/assets/sql-CNu3Or2W.js +1 -0
  51. package/dist/ui/assets/toml-CcmNWLt0.js +1 -0
  52. package/dist/ui/assets/tsx-5f3ulCja.js +1 -0
  53. package/dist/ui/assets/tsx-BOvsFOI6.js +1 -0
  54. package/dist/ui/assets/typescript-Utq2Cl8c.js +1 -0
  55. package/dist/ui/assets/typescript-j_1H8WHN.js +1 -0
  56. package/dist/ui/assets/yaml-CxbubiML.js +1 -0
  57. package/dist/ui/assets/yaml-DijMHRDA.js +1 -0
  58. package/dist/ui/favicon.svg +11 -0
  59. package/dist/ui/index.html +16 -0
  60. package/dist/ui/theme-init.js +10 -0
  61. package/dist/version.js +1 -1
  62. package/package.json +25 -4
  63. 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
- export async function startServer(config) {
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: true,
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("preHandler", async (req, reply) => {
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.config.permission ?? "read";
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)
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { access, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
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 stat(absPath);
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