@ferrule-io/ok-fine 0.0.0-stage → 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 (87) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +93 -2
  3. package/dist/auth/discovery.js +67 -0
  4. package/dist/auth/http-auth.js +50 -0
  5. package/dist/auth/verifier.js +176 -0
  6. package/dist/cli-options.js +99 -0
  7. package/dist/cli.js +115 -0
  8. package/dist/config.js +320 -0
  9. package/dist/data-dir-lock.js +60 -0
  10. package/dist/errors.js +13 -0
  11. package/dist/http/mcp-route.js +32 -0
  12. package/dist/http/rest.js +243 -0
  13. package/dist/http/ui.js +118 -0
  14. package/dist/main.js +32 -0
  15. package/dist/mcp/server.js +203 -0
  16. package/dist/okf/concept.js +99 -0
  17. package/dist/okf/frontmatter.js +180 -0
  18. package/dist/okf/index-file.js +74 -0
  19. package/dist/okf/lint.js +480 -0
  20. package/dist/okf/log-file.js +74 -0
  21. package/dist/okf/markdown.js +106 -0
  22. package/dist/okf/paths.js +99 -0
  23. package/dist/okf/repository.js +53 -0
  24. package/dist/okf/semantics.js +121 -0
  25. package/dist/server.js +343 -0
  26. package/dist/service/feedback.js +40 -0
  27. package/dist/service/knowledge-service.js +819 -0
  28. package/dist/service/principal.js +22 -0
  29. package/dist/store/archive.js +108 -0
  30. package/dist/store/backend.js +4 -0
  31. package/dist/store/bundle.js +250 -0
  32. package/dist/store/catalog.js +295 -0
  33. package/dist/store/fs-util.js +120 -0
  34. package/dist/store/git-backend.js +713 -0
  35. package/dist/store/git.js +274 -0
  36. package/dist/store/mutex.js +19 -0
  37. package/dist/store/path-index.js +54 -0
  38. package/dist/ui/assets/bash-BiGZg9Om.js +1 -0
  39. package/dist/ui/assets/css-BGIxQoYs.js +1 -0
  40. package/dist/ui/assets/css-DUjzNfgv.js +1 -0
  41. package/dist/ui/assets/diff-woXpYk--.js +1 -0
  42. package/dist/ui/assets/dockerfile-IyjqRm3v.js +1 -0
  43. package/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  44. package/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  45. package/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  46. package/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  47. package/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  48. package/dist/ui/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  49. package/dist/ui/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  50. package/dist/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  51. package/dist/ui/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  52. package/dist/ui/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  53. package/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  54. package/dist/ui/assets/github-dark-default-DXG-b-1a.js +1 -0
  55. package/dist/ui/assets/github-light-default-BXViO-2h.js +1 -0
  56. package/dist/ui/assets/go-rLFTqkRN.js +1 -0
  57. package/dist/ui/assets/highlight-CW19vWvw.js +153 -0
  58. package/dist/ui/assets/html-BMn1ogTm.js +1 -0
  59. package/dist/ui/assets/html-BlpXnbzK.js +1 -0
  60. package/dist/ui/assets/index-0b2NNRC5.js +127 -0
  61. package/dist/ui/assets/index-CFuTAZQM.css +1 -0
  62. package/dist/ui/assets/java-BtIQNnN0.js +1 -0
  63. package/dist/ui/assets/java-DxlWrXwe.js +1 -0
  64. package/dist/ui/assets/javascript-BzbQS41l.js +1 -0
  65. package/dist/ui/assets/javascript-Yelw-5ZN.js +1 -0
  66. package/dist/ui/assets/json-qhed-kSA.js +1 -0
  67. package/dist/ui/assets/jsx-D3KKoC5L.js +1 -0
  68. package/dist/ui/assets/jsx-hEbJ82HF.js +1 -0
  69. package/dist/ui/assets/markdown-BYOwaDjH.js +1 -0
  70. package/dist/ui/assets/python-gzcpVVnB.js +1 -0
  71. package/dist/ui/assets/ruby-Di5fYh6S.js +1 -0
  72. package/dist/ui/assets/rust-DpiIf5JO.js +1 -0
  73. package/dist/ui/assets/shellscript-CqHdAlCp.js +1 -0
  74. package/dist/ui/assets/sql-BOsskAmu.js +1 -0
  75. package/dist/ui/assets/sql-CNu3Or2W.js +1 -0
  76. package/dist/ui/assets/toml-CcmNWLt0.js +1 -0
  77. package/dist/ui/assets/tsx-5f3ulCja.js +1 -0
  78. package/dist/ui/assets/tsx-BOvsFOI6.js +1 -0
  79. package/dist/ui/assets/typescript-Utq2Cl8c.js +1 -0
  80. package/dist/ui/assets/typescript-j_1H8WHN.js +1 -0
  81. package/dist/ui/assets/yaml-CxbubiML.js +1 -0
  82. package/dist/ui/assets/yaml-DijMHRDA.js +1 -0
  83. package/dist/ui/favicon.svg +11 -0
  84. package/dist/ui/index.html +16 -0
  85. package/dist/ui/theme-init.js +10 -0
  86. package/dist/version.js +2 -0
  87. package/package.json +77 -4
@@ -0,0 +1,99 @@
1
+ import { join, resolve } from "node:path";
2
+ import { parseArgs } from "node:util";
3
+ export const USAGE = `Usage: ok-fine [stdio|serve] [options]
4
+
5
+ Commands:
6
+ stdio (default) Serve MCP over stdin/stdout for one local client
7
+ serve Serve MCP and the REST API over HTTP
8
+
9
+ Options:
10
+ --data-dir <path> Knowledge repository location (DATA_DIR, default ~/.ok-fine)
11
+ --remote <url> Git remote to sync with, using your git credentials (GIT_REMOTE_URL)
12
+ --branch <name> Git branch (GIT_BRANCH, default main)
13
+ --sync-interval <sec> Periodic sync interval, 0 disables (GIT_SYNC_INTERVAL_SECONDS, default 60)
14
+ --log-level <level> fatal|error|warn|info|debug|trace|silent (LOG_LEVEL, default info)
15
+ -h, --help Show this help
16
+ -v, --version Print the version
17
+
18
+ serve options:
19
+ --port <port> PORT, default 8080
20
+ --host <addr> HOST, default 127.0.0.1
21
+ --public-base-url <url> PUBLIC_BASE_URL, default http://localhost:<port>
22
+ --no-auth AUTH_MODE=none: no authentication, every caller is admin; localhost only
23
+
24
+ Every variable in https://github.com/ferrule-io/ok-fine/wiki/Configuration also works; flags win.
25
+ `;
26
+ const FLAG_ENV = {
27
+ "data-dir": "DATA_DIR",
28
+ remote: "GIT_REMOTE_URL",
29
+ branch: "GIT_BRANCH",
30
+ "sync-interval": "GIT_SYNC_INTERVAL_SECONDS",
31
+ "log-level": "LOG_LEVEL",
32
+ port: "PORT",
33
+ host: "HOST",
34
+ "public-base-url": "PUBLIC_BASE_URL",
35
+ };
36
+ const SERVE_ONLY_FLAGS = ["port", "host", "public-base-url", "no-auth"];
37
+ /** Pure: maps argv and the process env to a command and the env its config loader reads. Throws on bad usage. */
38
+ export function parseCli(argv, env, homeDir) {
39
+ // --help/--version win over everything else, including unknown flags and bad commands.
40
+ const flags = argv.includes("--") ? argv.slice(0, argv.indexOf("--")) : argv;
41
+ if (flags.includes("--help") || flags.includes("-h"))
42
+ return { command: "help", env };
43
+ if (flags.includes("--version") || flags.includes("-v"))
44
+ return { command: "version", env };
45
+ const { values, positionals } = parseArgs({
46
+ args: argv,
47
+ allowPositionals: true,
48
+ strict: true,
49
+ options: {
50
+ "data-dir": { type: "string" },
51
+ remote: { type: "string" },
52
+ branch: { type: "string" },
53
+ "sync-interval": { type: "string" },
54
+ "log-level": { type: "string" },
55
+ port: { type: "string" },
56
+ host: { type: "string" },
57
+ "public-base-url": { type: "string" },
58
+ "no-auth": { type: "boolean" },
59
+ },
60
+ });
61
+ let command;
62
+ if (positionals.length === 0 || (positionals.length === 1 && positionals[0] === "stdio")) {
63
+ command = "stdio";
64
+ }
65
+ else if (positionals.length === 1 && positionals[0] === "serve") {
66
+ command = "serve";
67
+ }
68
+ else {
69
+ throw new Error(`unknown command "${positionals.join(" ")}"`);
70
+ }
71
+ if (command === "stdio") {
72
+ for (const name of SERVE_ONLY_FLAGS) {
73
+ if (values[name] !== undefined)
74
+ throw new Error(`--${name} only applies to "ok-fine serve"`);
75
+ }
76
+ }
77
+ const flagEnv = {};
78
+ for (const [flag, envName] of Object.entries(FLAG_ENV)) {
79
+ const value = values[flag];
80
+ if (typeof value === "string")
81
+ flagEnv[envName] = value;
82
+ }
83
+ const dataDirFlag = values["data-dir"];
84
+ if (dataDirFlag !== undefined) {
85
+ const expanded = dataDirFlag === "~" ? homeDir : dataDirFlag.startsWith("~/") ? join(homeDir, dataDirFlag.slice(2)) : dataDirFlag;
86
+ flagEnv.DATA_DIR = resolve(expanded);
87
+ }
88
+ if (values["no-auth"])
89
+ flagEnv.AUTH_MODE = "none";
90
+ const defaults = { DATA_DIR: join(homeDir, ".ok-fine") };
91
+ if (command === "serve")
92
+ defaults.HOST = "127.0.0.1";
93
+ const merged = { ...defaults, ...env, ...flagEnv };
94
+ if (command === "serve" && merged.PUBLIC_BASE_URL === undefined) {
95
+ merged.PUBLIC_BASE_URL = `http://localhost:${merged.PORT ?? "8080"}`;
96
+ }
97
+ return { command, env: merged };
98
+ }
99
+ //# sourceMappingURL=cli-options.js.map
package/dist/cli.js ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { pino } from "pino";
5
+ import { parseCli, USAGE } from "./cli-options.js";
6
+ import { loadConfig, loadStorageConfig } from "./config.js";
7
+ import { lockDataDir } from "./data-dir-lock.js";
8
+ import { startServer, startStdioServer } from "./server.js";
9
+ import { VERSION } from "./version.js";
10
+ const message = (err) => (err instanceof Error ? err.message : String(err));
11
+ /** `git config user.email` in the launch directory; null when unset or git is unavailable. Not `Git`: it forces ok-fine's own email. */
12
+ function localGitEmail(env) {
13
+ const { promise, resolve } = Promise.withResolvers();
14
+ execFile("git", ["config", "user.email"], { env }, (error, stdout) => {
15
+ const email = String(stdout).trim();
16
+ resolve(error || email.length === 0 ? null : email);
17
+ });
18
+ return promise;
19
+ }
20
+ /** Runs `close` once on the first trigger, then exits. */
21
+ function shutdownOnce(close, onError) {
22
+ let shuttingDown = false;
23
+ return () => {
24
+ if (shuttingDown)
25
+ return;
26
+ shuttingDown = true;
27
+ close().then(() => process.exit(0), (err) => {
28
+ onError(err);
29
+ process.exit(1);
30
+ });
31
+ };
32
+ }
33
+ let inv;
34
+ try {
35
+ inv = parseCli(process.argv.slice(2), process.env, homedir());
36
+ }
37
+ catch (err) {
38
+ console.error(`${message(err)}\nRun "ok-fine --help" for usage.`);
39
+ process.exit(2);
40
+ }
41
+ if (inv.command === "help") {
42
+ process.stdout.write(USAGE);
43
+ process.exit(0);
44
+ }
45
+ if (inv.command === "version") {
46
+ process.stdout.write(`${VERSION}\n`);
47
+ process.exit(0);
48
+ }
49
+ if (inv.command === "stdio") {
50
+ let config;
51
+ try {
52
+ config = loadStorageConfig(inv.env, { inheritGitEnv: true });
53
+ }
54
+ catch (err) {
55
+ console.error(message(err));
56
+ process.exit(1);
57
+ }
58
+ // stdout carries JSON-RPC only; every log line goes to stderr.
59
+ const log = pino({ level: config.logLevel }, pino.destination({ dest: 2, sync: true }));
60
+ try {
61
+ const release = await lockDataDir(config.dataDir);
62
+ process.once("exit", release);
63
+ }
64
+ catch (err) {
65
+ log.error(message(err));
66
+ process.exit(1);
67
+ }
68
+ const identity = await localGitEmail(inv.env);
69
+ log.info({ dataDir: config.dataDir, identity }, "serving MCP over stdio");
70
+ let server;
71
+ try {
72
+ server = await startStdioServer(config, { log, identity });
73
+ }
74
+ catch (err) {
75
+ log.error({ err }, "ok-fine failed to start");
76
+ process.exit(1);
77
+ }
78
+ const shutdown = shutdownOnce(() => server.close(), (err) => log.error({ err }, "shutdown failed"));
79
+ void server.done.then(shutdown);
80
+ process.once("SIGINT", shutdown);
81
+ process.once("SIGTERM", shutdown);
82
+ }
83
+ else {
84
+ let config;
85
+ try {
86
+ config = loadConfig(inv.env, { inheritGitEnv: true });
87
+ }
88
+ catch (err) {
89
+ console.error(message(err));
90
+ process.exit(1);
91
+ }
92
+ try {
93
+ const release = await lockDataDir(config.dataDir);
94
+ process.once("exit", release);
95
+ }
96
+ catch (err) {
97
+ console.error(message(err));
98
+ process.exit(1);
99
+ }
100
+ let server;
101
+ try {
102
+ server = await startServer(config);
103
+ }
104
+ catch (err) {
105
+ console.error("ok-fine failed to start:", err);
106
+ process.exit(1);
107
+ }
108
+ const shutdown = shutdownOnce(() => {
109
+ server.app.log.info("shutting down");
110
+ return server.close();
111
+ }, (err) => server.app.log.error({ err }, "shutdown failed"));
112
+ process.once("SIGINT", shutdown);
113
+ process.once("SIGTERM", shutdown);
114
+ }
115
+ //# sourceMappingURL=cli.js.map
package/dist/config.js ADDED
@@ -0,0 +1,320 @@
1
+ import { isIPv4 } from "node:net";
2
+ import { z } from "zod";
3
+ /** A whole-string decimal integer env var (rejects `8080junk`, `1x`, `1.5`, and blanks). */
4
+ function intEnv(name, fallback, min, max = Number.MAX_SAFE_INTEGER) {
5
+ return z
6
+ .string()
7
+ .default(fallback)
8
+ .refine((v) => /^\d+$/.test(v), { message: `${name} must be a whole decimal integer` })
9
+ .transform(Number)
10
+ .pipe(z.number().int().min(min, `${name} must be >= ${min}`).max(max, `${name} must be <= ${max}`));
11
+ }
12
+ /** An optional whole-string decimal integer env var; unset or empty string evaluates to undefined. */
13
+ function optionalIntEnv(name, min, max = Number.MAX_SAFE_INTEGER) {
14
+ return z
15
+ .string()
16
+ .optional()
17
+ .transform((v) => (v === undefined || v === "" ? undefined : v))
18
+ .pipe(z
19
+ .string()
20
+ .refine((v) => /^\d+$/.test(v), { message: `${name} must be a whole decimal integer` })
21
+ .transform(Number)
22
+ .pipe(z.number().int().min(min, `${name} must be >= ${min}`).max(max, `${name} must be <= ${max}`))
23
+ .optional());
24
+ }
25
+ function httpUrl(value) {
26
+ try {
27
+ const u = new URL(value);
28
+ return u.protocol === "http:" || u.protocol === "https:";
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function isLoopbackHost(host) {
35
+ const normalized = host.trim().toLowerCase();
36
+ if (normalized === "localhost" || normalized === "::1" || normalized === "[::1]") {
37
+ return true;
38
+ }
39
+ if (isIPv4(normalized)) {
40
+ const octets = normalized.split(".");
41
+ if (octets[0] === "127") {
42
+ return true;
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+ function parseTrustProxy(val) {
48
+ if (val === undefined || val === "") {
49
+ return "loopback,linklocal,uniquelocal";
50
+ }
51
+ const trimmed = val.trim();
52
+ if (trimmed === "true")
53
+ return true;
54
+ if (trimmed === "false")
55
+ return false;
56
+ if (/^\d+$/.test(trimmed)) {
57
+ return Number.parseInt(trimmed, 10);
58
+ }
59
+ return trimmed;
60
+ }
61
+ function parseList(val) {
62
+ if (!val)
63
+ return [];
64
+ return val
65
+ .split(",")
66
+ .map((s) => s.trim())
67
+ .filter((s) => s.length > 0);
68
+ }
69
+ const storageEnvShape = {
70
+ LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
71
+ DATA_DIR: z
72
+ .string()
73
+ .default("/data")
74
+ .refine((v) => v.startsWith("/"), "DATA_DIR must be an absolute path"),
75
+ GIT_BRANCH: z.string().default("main"),
76
+ GIT_REMOTE_URL: z.string().optional(),
77
+ GIT_SYNC_INTERVAL_SECONDS: intEnv("GIT_SYNC_INTERVAL_SECONDS", "60", 0),
78
+ GIT_SSH_KEY_PATH: z.string().optional(),
79
+ GIT_SSH_KNOWN_HOSTS_PATH: z.string().optional(),
80
+ GIT_HTTP_USERNAME: z.string().optional(),
81
+ GIT_HTTP_PASSWORD: z.string().optional(),
82
+ MAX_FILE_BYTES: intEnv("MAX_FILE_BYTES", "1048576", 1),
83
+ MAX_ARCHIVE_BYTES: intEnv("MAX_ARCHIVE_BYTES", "52428800", 1),
84
+ DEFAULT_STALE_AFTER_DAYS: optionalIntEnv("DEFAULT_STALE_AFTER_DAYS", 1, 36500),
85
+ };
86
+ const httpEnvShape = {
87
+ AUTH_MODE: z.enum(["oidc", "none"], "AUTH_MODE must be oidc or none").default("oidc"),
88
+ PORT: intEnv("PORT", "8080", 0, 65535),
89
+ HOST: z.string().default("0.0.0.0"),
90
+ PUBLIC_BASE_URL: z
91
+ .string({ message: "PUBLIC_BASE_URL is required" })
92
+ .refine(httpUrl, "PUBLIC_BASE_URL must be a valid http or https URL"),
93
+ OAUTH_ISSUER: z.string().optional(),
94
+ OAUTH_AUDIENCE: z.string().optional(),
95
+ OAUTH_JWKS_URI: z.string().optional(),
96
+ OAUTH_SCOPE_READ: z.string().default("okf:read"),
97
+ OAUTH_SCOPE_WRITE: z.string().default("okf:write"),
98
+ OAUTH_SCOPE_ADMIN: z.string().default("okf:admin"),
99
+ OAUTH_IDENTITY_CLAIMS: z.string().default("email,preferred_username,sub"),
100
+ OAUTH_ALLOW_INSECURE_ISSUER: z.string().optional(),
101
+ OAUTH_ALLOWED_SUBJECTS: z.string().optional(),
102
+ OAUTH_ALLOWED_EMAILS: z.string().optional(),
103
+ OAUTH_REQUIRED_GROUPS: z.string().optional(),
104
+ OAUTH_GROUPS_CLAIM: z.string().default("groups"),
105
+ OAUTH_ALLOWED_CLIENT_IDS: z.string().optional(),
106
+ ALLOW_UNAUTHENTICATED_NETWORK: z.string().optional(),
107
+ TRUST_PROXY: z.string().optional(),
108
+ OAUTH_UI_CLIENT_ID: z.string().optional(),
109
+ };
110
+ function isValidGitRemoteUrl(url) {
111
+ if (url.startsWith("/") || url.startsWith("https://") || url.startsWith("ssh://") || url.startsWith("file://")) {
112
+ return true;
113
+ }
114
+ if (!url.includes("://")) {
115
+ const at = url.indexOf("@");
116
+ const colon = url.indexOf(":");
117
+ if (at !== -1 && colon !== -1 && at < colon) {
118
+ const slash = url.indexOf("/");
119
+ return slash === -1 || slash > colon;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+ function refineGitCredentials(data, ctx) {
125
+ if (!data || typeof data !== "object")
126
+ return;
127
+ if (Boolean(data.GIT_HTTP_USERNAME) !== Boolean(data.GIT_HTTP_PASSWORD)) {
128
+ ctx.addIssue({
129
+ code: "custom",
130
+ path: ["GIT_HTTP_USERNAME"],
131
+ message: "GIT_HTTP_USERNAME and GIT_HTTP_PASSWORD must be provided together or neither",
132
+ });
133
+ }
134
+ if (data.GIT_REMOTE_URL !== undefined && !isValidGitRemoteUrl(data.GIT_REMOTE_URL)) {
135
+ ctx.addIssue({
136
+ code: "custom",
137
+ path: ["GIT_REMOTE_URL"],
138
+ message: "GIT_REMOTE_URL must be https://, ssh://, user@host:path, file:// or an absolute path",
139
+ });
140
+ }
141
+ if (data.GIT_SSH_KEY_PATH?.trim() && !data.GIT_SSH_KNOWN_HOSTS_PATH?.trim()) {
142
+ ctx.addIssue({
143
+ code: "custom",
144
+ path: ["GIT_SSH_KNOWN_HOSTS_PATH"],
145
+ message: "GIT_SSH_KNOWN_HOSTS_PATH is required when GIT_SSH_KEY_PATH is set",
146
+ });
147
+ }
148
+ }
149
+ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefine((data, ctx) => {
150
+ if (!data || typeof data !== "object")
151
+ return;
152
+ refineGitCredentials(data, ctx);
153
+ if (data.AUTH_MODE === "none") {
154
+ const isLoopback = isLoopbackHost(data.HOST ?? "0.0.0.0");
155
+ const allowUnauthenticated = data.ALLOW_UNAUTHENTICATED_NETWORK === "true";
156
+ if (!isLoopback && !allowUnauthenticated) {
157
+ ctx.addIssue({
158
+ code: "custom",
159
+ path: ["HOST"],
160
+ message: "AUTH_MODE=none requires a loopback HOST (127.0.0.0/8, ::1, localhost); set ALLOW_UNAUTHENTICATED_NETWORK=true only behind a loopback-only port mapping",
161
+ });
162
+ }
163
+ }
164
+ if (data.ALLOW_UNAUTHENTICATED_NETWORK !== undefined &&
165
+ data.ALLOW_UNAUTHENTICATED_NETWORK !== "true" &&
166
+ data.ALLOW_UNAUTHENTICATED_NETWORK !== "false") {
167
+ ctx.addIssue({
168
+ code: "custom",
169
+ path: ["ALLOW_UNAUTHENTICATED_NETWORK"],
170
+ message: "ALLOW_UNAUTHENTICATED_NETWORK must be true or false",
171
+ });
172
+ }
173
+ if (data.AUTH_MODE === "oidc") {
174
+ if (data.OAUTH_ISSUER === undefined) {
175
+ ctx.addIssue({
176
+ code: "custom",
177
+ path: ["OAUTH_ISSUER"],
178
+ message: "OAUTH_ISSUER is required",
179
+ });
180
+ }
181
+ else if (!httpUrl(data.OAUTH_ISSUER)) {
182
+ ctx.addIssue({
183
+ code: "custom",
184
+ path: ["OAUTH_ISSUER"],
185
+ message: "OAUTH_ISSUER must be a valid URL",
186
+ });
187
+ }
188
+ if (data.OAUTH_JWKS_URI !== undefined) {
189
+ if (!httpUrl(data.OAUTH_JWKS_URI)) {
190
+ ctx.addIssue({
191
+ code: "custom",
192
+ path: ["OAUTH_JWKS_URI"],
193
+ message: "OAUTH_JWKS_URI must be a valid URL",
194
+ });
195
+ }
196
+ else {
197
+ try {
198
+ const parsed = new URL(data.OAUTH_JWKS_URI);
199
+ if (parsed.protocol !== "https:" && data.OAUTH_ALLOW_INSECURE_ISSUER !== "true") {
200
+ ctx.addIssue({
201
+ code: "custom",
202
+ path: ["OAUTH_JWKS_URI"],
203
+ message: "OAUTH_JWKS_URI must be https unless OAUTH_ALLOW_INSECURE_ISSUER=true",
204
+ });
205
+ }
206
+ }
207
+ catch {
208
+ // Handled by httpUrl
209
+ }
210
+ }
211
+ }
212
+ if (data.OAUTH_ALLOW_INSECURE_ISSUER !== undefined &&
213
+ data.OAUTH_ALLOW_INSECURE_ISSUER !== "true" &&
214
+ data.OAUTH_ALLOW_INSECURE_ISSUER !== "false") {
215
+ ctx.addIssue({
216
+ code: "custom",
217
+ path: ["OAUTH_ALLOW_INSECURE_ISSUER"],
218
+ message: "OAUTH_ALLOW_INSECURE_ISSUER must be true or false",
219
+ });
220
+ }
221
+ }
222
+ }, { when: () => true });
223
+ const storageEnvSchema = z.object(storageEnvShape).superRefine(refineGitCredentials, { when: () => true });
224
+ function formatConfigError(error) {
225
+ const errorMessages = error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
226
+ return new Error(`Configuration errors:\n ${errorMessages.join("\n ")}`);
227
+ }
228
+ function storageFromRaw(raw, env, options) {
229
+ return {
230
+ logLevel: raw.LOG_LEVEL,
231
+ dataDir: raw.DATA_DIR,
232
+ gitBranch: raw.GIT_BRANCH,
233
+ gitRemoteUrl: raw.GIT_REMOTE_URL,
234
+ gitSyncIntervalSeconds: raw.GIT_SYNC_INTERVAL_SECONDS,
235
+ gitSshKeyPath: raw.GIT_SSH_KEY_PATH,
236
+ gitSshKnownHostsPath: raw.GIT_SSH_KNOWN_HOSTS_PATH,
237
+ gitHttpUsername: raw.GIT_HTTP_USERNAME,
238
+ gitHttpPassword: raw.GIT_HTTP_PASSWORD,
239
+ ...(options.inheritGitEnv
240
+ ? {
241
+ gitHostEnv: Object.fromEntries(Object.entries(env).filter((e) => e[1] !== undefined)),
242
+ }
243
+ : {}),
244
+ maxFileBytes: raw.MAX_FILE_BYTES,
245
+ maxArchiveBytes: raw.MAX_ARCHIVE_BYTES,
246
+ defaultStaleAfterDays: raw.DEFAULT_STALE_AFTER_DAYS,
247
+ };
248
+ }
249
+ /** Storage-only settings for the stdio transport: needs neither PUBLIC_BASE_URL nor OAUTH_*. */
250
+ export function loadStorageConfig(env, options = {}) {
251
+ const result = storageEnvSchema.safeParse(env);
252
+ if (!result.success)
253
+ throw formatConfigError(result.error);
254
+ return storageFromRaw(result.data, env, options);
255
+ }
256
+ export function loadConfig(env = process.env, options = {}) {
257
+ const result = rawEnvSchema.safeParse(env);
258
+ if (!result.success)
259
+ throw formatConfigError(result.error);
260
+ const raw = result.data;
261
+ const publicBaseUrl = raw.PUBLIC_BASE_URL.replace(/\/+$/, "");
262
+ const trustProxy = parseTrustProxy(raw.TRUST_PROXY);
263
+ const allowUnauthenticatedNetwork = raw.ALLOW_UNAUTHENTICATED_NETWORK === "true";
264
+ let auth;
265
+ if (raw.AUTH_MODE === "none") {
266
+ auth = { mode: "none" };
267
+ }
268
+ else {
269
+ if (raw.OAUTH_ISSUER === undefined) {
270
+ throw new Error("OAUTH_ISSUER is required when AUTH_MODE is oidc");
271
+ }
272
+ let oauthAudiences;
273
+ if (raw.OAUTH_AUDIENCE && raw.OAUTH_AUDIENCE.trim().length > 0) {
274
+ oauthAudiences = raw.OAUTH_AUDIENCE.split(",")
275
+ .map((s) => s.trim())
276
+ .filter((s) => s.length > 0);
277
+ }
278
+ else {
279
+ oauthAudiences = [`${publicBaseUrl}/mcp`];
280
+ }
281
+ const identityClaims = raw.OAUTH_IDENTITY_CLAIMS.split(",")
282
+ .map((s) => s.trim())
283
+ .filter((s) => s.length > 0);
284
+ const allowInsecureIssuer = raw.OAUTH_ALLOW_INSECURE_ISSUER === "true";
285
+ const uiClientId = raw.OAUTH_UI_CLIENT_ID?.trim();
286
+ const groupsClaim = raw.OAUTH_GROUPS_CLAIM && raw.OAUTH_GROUPS_CLAIM.trim().length > 0 ? raw.OAUTH_GROUPS_CLAIM.trim() : "groups";
287
+ const access = {
288
+ allowedSubjects: parseList(raw.OAUTH_ALLOWED_SUBJECTS),
289
+ allowedEmails: parseList(raw.OAUTH_ALLOWED_EMAILS).map((s) => s.toLowerCase()),
290
+ requiredGroups: parseList(raw.OAUTH_REQUIRED_GROUPS),
291
+ groupsClaim,
292
+ allowedClientIds: parseList(raw.OAUTH_ALLOWED_CLIENT_IDS),
293
+ };
294
+ auth = {
295
+ mode: "oidc",
296
+ issuer: raw.OAUTH_ISSUER,
297
+ audiences: oauthAudiences,
298
+ ...(raw.OAUTH_JWKS_URI === undefined ? {} : { jwksUri: raw.OAUTH_JWKS_URI }),
299
+ identityClaims,
300
+ allowInsecureIssuer,
301
+ access,
302
+ ...(uiClientId ? { uiClientId } : {}),
303
+ };
304
+ }
305
+ return {
306
+ ...storageFromRaw(raw, env, options),
307
+ port: raw.PORT,
308
+ host: raw.HOST,
309
+ publicBaseUrl,
310
+ auth,
311
+ scopeNames: {
312
+ read: raw.OAUTH_SCOPE_READ,
313
+ write: raw.OAUTH_SCOPE_WRITE,
314
+ admin: raw.OAUTH_SCOPE_ADMIN,
315
+ },
316
+ allowUnauthenticatedNetwork,
317
+ trustProxy,
318
+ };
319
+ }
320
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,60 @@
1
+ import { readFileSync, unlinkSync } from "node:fs";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ function errorCode(err) {
5
+ return err?.code;
6
+ }
7
+ function isRunning(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (err) {
13
+ return errorCode(err) === "EPERM";
14
+ }
15
+ }
16
+ /**
17
+ * Takes the exclusive per-data-dir lock for a local CLI process; a lock left by a dead process is taken over.
18
+ * Returns a synchronous release, safe to call from `process.on("exit")`.
19
+ */
20
+ export async function lockDataDir(dataDir) {
21
+ await mkdir(dataDir, { recursive: true });
22
+ const lockPath = join(dataDir, "ok-fine.lock");
23
+ const pid = String(process.pid);
24
+ let holder = "unknown";
25
+ for (let attempt = 0; attempt < 2; attempt++) {
26
+ try {
27
+ await writeFile(lockPath, `${pid}\n`, { flag: "wx" });
28
+ return () => {
29
+ try {
30
+ if (readFileSync(lockPath, "utf8").trim() === pid)
31
+ unlinkSync(lockPath);
32
+ }
33
+ catch (err) {
34
+ if (errorCode(err) !== "ENOENT")
35
+ throw err;
36
+ }
37
+ };
38
+ }
39
+ catch (err) {
40
+ if (errorCode(err) !== "EEXIST")
41
+ throw err;
42
+ }
43
+ let content;
44
+ try {
45
+ content = (await readFile(lockPath, "utf8")).trim();
46
+ }
47
+ catch (err) {
48
+ if (errorCode(err) === "ENOENT")
49
+ continue;
50
+ throw err;
51
+ }
52
+ holder = content || "unknown";
53
+ const holderPid = Number.parseInt(content, 10);
54
+ if (Number.isInteger(holderPid) && holderPid > 0 && holderPid !== process.pid && isRunning(holderPid))
55
+ break;
56
+ await rm(lockPath, { force: true });
57
+ }
58
+ throw new Error(`ok-fine is already running on ${dataDir} (pid ${holder}). One process serves a data directory: connect more clients to \`ok-fine serve\` over HTTP, or pass a different --data-dir. If no ok-fine process is running, delete ${lockPath}.`);
59
+ }
60
+ //# sourceMappingURL=data-dir-lock.js.map
package/dist/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ export class OkfError extends Error {
2
+ code;
3
+ status;
4
+ details;
5
+ constructor(code, status, message, details) {
6
+ super(message);
7
+ this.name = "OkfError";
8
+ this.code = code;
9
+ this.status = status;
10
+ this.details = details;
11
+ }
12
+ }
13
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,32 @@
1
+ const DROPPED_HEADERS = {
2
+ host: true,
3
+ "content-length": true,
4
+ "transfer-encoding": true,
5
+ connection: true,
6
+ };
7
+ export function registerMcpRoute(app, mcpFetch, publicBaseUrl) {
8
+ app.route({
9
+ method: ["GET", "POST", "DELETE"],
10
+ url: "/mcp",
11
+ config: { permission: "read" },
12
+ handler: async (req, reply) => {
13
+ const headers = new Headers();
14
+ for (const [name, value] of Object.entries(req.headers)) {
15
+ if (value === undefined || DROPPED_HEADERS[name])
16
+ continue;
17
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
18
+ }
19
+ const request = new Request(new URL(req.url, publicBaseUrl), {
20
+ method: req.method,
21
+ headers,
22
+ body: req.method === "POST" ? JSON.stringify(req.body) : undefined,
23
+ });
24
+ const response = await mcpFetch(request, {
25
+ authInfo: req.authInfo ?? undefined,
26
+ parsedBody: req.method === "POST" ? req.body : undefined,
27
+ });
28
+ return reply.send(response);
29
+ },
30
+ });
31
+ }
32
+ //# sourceMappingURL=mcp-route.js.map