@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 { createHash } from "node:crypto";
2
+ import { posix } from "node:path";
3
+ import { OkfError } from "../errors.js";
4
+ export const PROJECT_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
5
+ export const SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ export function normalizeConceptIdForWrite(raw) {
7
+ let s = raw;
8
+ if (s.startsWith("/")) {
9
+ s = s.slice(1);
10
+ }
11
+ if (s.endsWith(".md")) {
12
+ s = s.slice(0, -3);
13
+ }
14
+ if (s.length === 0) {
15
+ throw new OkfError("invalid_id", 400, "concept ID cannot be empty");
16
+ }
17
+ if (s.length > 512) {
18
+ throw new OkfError("invalid_id", 400, "concept ID cannot exceed 512 characters");
19
+ }
20
+ const segments = s.split("/");
21
+ if (segments.length > 12) {
22
+ throw new OkfError("invalid_id", 400, "concept ID cannot exceed 12 path segments");
23
+ }
24
+ for (const seg of segments) {
25
+ if (!SEGMENT_RE.test(seg)) {
26
+ throw new OkfError("invalid_id", 400, `invalid path segment "${seg}" in concept ID`);
27
+ }
28
+ }
29
+ const lastSeg = segments[segments.length - 1];
30
+ if (!lastSeg) {
31
+ throw new OkfError("invalid_id", 400, "concept ID cannot have empty trailing segment");
32
+ }
33
+ const lastLower = lastSeg.toLowerCase();
34
+ if (lastLower === "index" || lastLower === "log") {
35
+ throw new OkfError("invalid_id", 400, `last segment of concept ID cannot be "${lastLower}"`);
36
+ }
37
+ return segments.join("/");
38
+ }
39
+ export function normalizeFilePathForWrite(raw) {
40
+ if (raw.toLowerCase().endsWith(".md")) {
41
+ throw new OkfError("use_write_concept", 400, "markdown files are concepts; use write_concept");
42
+ }
43
+ let s = raw;
44
+ if (s.startsWith("/")) {
45
+ s = s.slice(1);
46
+ }
47
+ if (s.length === 0) {
48
+ throw new OkfError("invalid_path", 400, "file path cannot be empty");
49
+ }
50
+ if (s.length > 512) {
51
+ throw new OkfError("invalid_path", 400, "file path cannot exceed 512 characters");
52
+ }
53
+ const segments = s.split("/");
54
+ if (segments.length > 12) {
55
+ throw new OkfError("invalid_path", 400, "file path cannot exceed 12 path segments");
56
+ }
57
+ for (const seg of segments) {
58
+ if (!SEGMENT_RE.test(seg)) {
59
+ throw new OkfError("invalid_path", 400, `invalid path segment "${seg}" in file path`);
60
+ }
61
+ }
62
+ return segments.join("/");
63
+ }
64
+ export function resolveReadPath(raw) {
65
+ if (raw.includes("\\")) {
66
+ throw new OkfError("invalid_path", 400, "path cannot contain backslashes");
67
+ }
68
+ let s = raw;
69
+ if (s.startsWith("/")) {
70
+ s = s.slice(1);
71
+ }
72
+ const rawSegments = s.split("/");
73
+ for (const seg of rawSegments) {
74
+ if (seg === ".." || seg.startsWith(".")) {
75
+ throw new OkfError("invalid_path", 400, "path cannot traverse or contain dot segments");
76
+ }
77
+ }
78
+ const normalized = posix.normalize(s);
79
+ if (!normalized || normalized === "." || normalized === "") {
80
+ throw new OkfError("invalid_path", 400, "path cannot be empty");
81
+ }
82
+ const segments = normalized.split("/");
83
+ for (const seg of segments) {
84
+ if (seg === ".." || seg.startsWith(".")) {
85
+ throw new OkfError("invalid_path", 400, "path cannot traverse or contain dot segments");
86
+ }
87
+ }
88
+ return normalized;
89
+ }
90
+ export function isReservedName(basename) {
91
+ return basename === "index.md" || basename === "log.md";
92
+ }
93
+ export function blobRevision(buf) {
94
+ return createHash("sha1")
95
+ .update(Buffer.from(`blob ${buf.length}\0`))
96
+ .update(buf)
97
+ .digest("hex");
98
+ }
99
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Normalize a git remote URL to `host/path` (lowercase, no scheme, userinfo, port, or `.git`), so the same
3
+ * repository compares equal across `git@host:org/repo.git`, `https://host/org/repo`, and `ssh://git@host:22/org/repo`.
4
+ * Returns null for local paths and anything that is not a recognizable remote.
5
+ */
6
+ export function normalizeRepository(raw) {
7
+ const input = raw.trim();
8
+ if (input === "")
9
+ return null;
10
+ let host;
11
+ let path;
12
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
13
+ let url;
14
+ try {
15
+ url = new URL(input);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ if (url.protocol === "file:")
21
+ return null;
22
+ host = url.hostname;
23
+ path = url.pathname;
24
+ }
25
+ else {
26
+ const scp = /^[^\s/@]+@([^\s/:]+):(.+)$/.exec(input);
27
+ if (scp) {
28
+ host = scp[1] ?? "";
29
+ path = scp[2] ?? "";
30
+ }
31
+ else {
32
+ if (/^[/.~]/.test(input) || input.includes("\\"))
33
+ return null;
34
+ const slash = input.indexOf("/");
35
+ if (slash < 0)
36
+ return null;
37
+ host = input.slice(0, slash);
38
+ path = input.slice(slash + 1);
39
+ if (!host.includes(".") && host.toLowerCase() !== "localhost")
40
+ return null;
41
+ }
42
+ }
43
+ host = host.toLowerCase();
44
+ path = path
45
+ .toLowerCase()
46
+ .replace(/\/{2,}/g, "/")
47
+ .replace(/^\/+|\/+$/g, "")
48
+ .replace(/\.git$/, "")
49
+ .replace(/\/+$/, "");
50
+ const result = `${host}/${path}`;
51
+ return /^[a-z0-9][a-z0-9.-]*(\/[^/\s]+)+$/.test(result) ? result : null;
52
+ }
53
+ //# sourceMappingURL=repository.js.map
@@ -0,0 +1,121 @@
1
+ export const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
2
+ export function normalizeVerified(v) {
3
+ if (v == null) {
4
+ return [];
5
+ }
6
+ const rawList = Array.isArray(v) ? v : [v];
7
+ const result = [];
8
+ for (const item of rawList) {
9
+ if (item && typeof item === "object" && "by" in item && typeof item.by === "string" && item.by.length > 0) {
10
+ const at = "at" in item && typeof item.at === "string" ? item.at : undefined;
11
+ result.push({ by: item.by, at });
12
+ }
13
+ }
14
+ return result;
15
+ }
16
+ export function trustTier(fm) {
17
+ const verified = normalizeVerified(fm?.verified);
18
+ if (verified.length === 0) {
19
+ return "unverified";
20
+ }
21
+ for (const entry of verified) {
22
+ if (entry.by.startsWith("human:")) {
23
+ return "human-reviewed";
24
+ }
25
+ }
26
+ return "machine-confirmed";
27
+ }
28
+ export function effectiveStatus(fm) {
29
+ const s = fm?.status;
30
+ if (s === "draft" || s === "stable" || s === "deprecated") {
31
+ return s;
32
+ }
33
+ return "stable";
34
+ }
35
+ export function staleAfter(fm) {
36
+ const raw = fm?.stale_after;
37
+ if (typeof raw === "string") {
38
+ const parsed = Date.parse(raw);
39
+ if (!Number.isNaN(parsed)) {
40
+ return raw;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ export function isStale(fm, now) {
46
+ const sa = staleAfter(fm);
47
+ if (!sa) {
48
+ return false;
49
+ }
50
+ const time = Date.parse(sa);
51
+ if (Number.isNaN(time)) {
52
+ return false;
53
+ }
54
+ return now.getTime() >= time;
55
+ }
56
+ export function generatedAt(fm) {
57
+ if (fm?.generated &&
58
+ typeof fm.generated === "object" &&
59
+ "at" in fm.generated &&
60
+ typeof fm.generated.at === "string") {
61
+ return fm.generated.at;
62
+ }
63
+ const legacy = fm?.timestamp;
64
+ if (typeof legacy === "string") {
65
+ return legacy;
66
+ }
67
+ if (legacy instanceof Date) {
68
+ return legacy.toISOString().replace(/\.\d{3}Z$/, "Z");
69
+ }
70
+ return null;
71
+ }
72
+ export function generatedBy(fm) {
73
+ if (fm?.generated &&
74
+ typeof fm.generated === "object" &&
75
+ "by" in fm.generated &&
76
+ typeof fm.generated.by === "string") {
77
+ return fm.generated.by;
78
+ }
79
+ return null;
80
+ }
81
+ export function lastVerifiedAt(fm) {
82
+ const verified = normalizeVerified(fm?.verified);
83
+ let latest = null;
84
+ for (const entry of verified) {
85
+ if (entry.at) {
86
+ const parsed = Date.parse(entry.at);
87
+ if (!Number.isNaN(parsed)) {
88
+ if (!latest || parsed > latest.timestamp) {
89
+ latest = { atStr: entry.at, timestamp: parsed };
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return latest?.atStr ?? null;
95
+ }
96
+ const HUMAN_ACTOR_RE = /^human:([A-Za-z0-9._@+-]+)$/;
97
+ const PROCESS_ACTOR_RE = /^process:([A-Za-z0-9._@+-]+)$/;
98
+ const AGENT_ACTOR_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._:+-]+$/;
99
+ export function parseActor(s) {
100
+ const humanMatch = HUMAN_ACTOR_RE.exec(s);
101
+ if (humanMatch?.[1]) {
102
+ return { kind: "human", id: humanMatch[1] };
103
+ }
104
+ const processMatch = PROCESS_ACTOR_RE.exec(s);
105
+ if (processMatch?.[1]) {
106
+ return { kind: "process", id: processMatch[1] };
107
+ }
108
+ if (AGENT_ACTOR_RE.test(s)) {
109
+ return { kind: "agent", id: s };
110
+ }
111
+ return null;
112
+ }
113
+ export function displayTitle(fm, conceptId) {
114
+ if (typeof fm?.title === "string" && fm.title.length > 0) {
115
+ return fm.title;
116
+ }
117
+ const segments = conceptId.split("/");
118
+ const last = segments[segments.length - 1];
119
+ return last && last.length > 0 ? last : conceptId;
120
+ }
121
+ //# sourceMappingURL=semantics.js.map
package/dist/server.js ADDED
@@ -0,0 +1,343 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { buildOAuthProtectedResourceMetadata, createMcpHandler, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
3
+ import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio";
4
+ import Fastify, {} from "fastify";
5
+ import { ZodError } from "zod";
6
+ import { discoverAuthorizationServer } from "./auth/discovery.js";
7
+ import { createAuthenticator } from "./auth/http-auth.js";
8
+ import { JwtTokenVerifier, principalFromAuthInfo } from "./auth/verifier.js";
9
+ import { OkfError } from "./errors.js";
10
+ import { registerMcpRoute } from "./http/mcp-route.js";
11
+ import { registerRestRoutes } from "./http/rest.js";
12
+ import { DEFAULT_UI_DIR, endpointOrigins, registerUiRoutes } from "./http/ui.js";
13
+ import { createMcpServer } from "./mcp/server.js";
14
+ import { KnowledgeService } from "./service/knowledge-service.js";
15
+ import { Catalog } from "./store/catalog.js";
16
+ import { GitBackend } from "./store/git-backend.js";
17
+ import { VERSION } from "./version.js";
18
+ const DISCOVERY_ATTEMPTS = 5;
19
+ const DISCOVERY_RETRY_MS = 2000;
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;
54
+ const app = Fastify({
55
+ logger: { level: config.logLevel, redact: ["req.headers.authorization"] },
56
+ trustProxy,
57
+ requestTimeout: 120_000,
58
+ connectionTimeout: 120_000,
59
+ bodyLimit: 4 * 1024 * 1024,
60
+ });
61
+ app.removeContentTypeParser("text/plain");
62
+ app.addContentTypeParser(/^text\//, { parseAs: "string", bodyLimit: config.maxFileBytes + 65536 }, (_req, body, done) => done(null, body));
63
+ app.addContentTypeParser(["application/gzip", "application/x-gzip", "application/octet-stream"], { parseAs: "buffer", bodyLimit: config.maxArchiveBytes }, (_req, body, done) => done(null, body));
64
+ let authHandler;
65
+ let uiConnectSrc = [];
66
+ if (config.auth.mode === "oidc") {
67
+ let discovered;
68
+ for (let attempt = 1; !discovered; attempt++) {
69
+ try {
70
+ discovered = await discoverAuthorizationServer(config.auth.issuer, {
71
+ timeoutMs: 5000,
72
+ allowInsecureIssuer: config.auth.allowInsecureIssuer,
73
+ ...(config.auth.jwksUri === undefined ? {} : { jwksUri: config.auth.jwksUri }),
74
+ });
75
+ }
76
+ catch (err) {
77
+ if (attempt >= DISCOVERY_ATTEMPTS)
78
+ throw err;
79
+ app.log.warn({ err, attempt }, "authorization server discovery failed; retrying");
80
+ await sleep(DISCOVERY_RETRY_MS);
81
+ }
82
+ }
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);
86
+ const resourceServerUrl = new URL(`${config.publicBaseUrl}/mcp`);
87
+ const prm = buildOAuthProtectedResourceMetadata({
88
+ // Discovery checked `issuer`; buildOAuthProtectedResourceMetadata validates the rest and throws on misconfiguration.
89
+ oauthMetadata: metadata,
90
+ resourceServerUrl,
91
+ scopesSupported: [config.scopeNames.read, config.scopeNames.write, config.scopeNames.admin],
92
+ resourceName: "ok-fine",
93
+ dangerouslyAllowInsecureIssuerUrl: config.auth.allowInsecureIssuer,
94
+ });
95
+ for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) {
96
+ app.get(path, async (_req, reply) => reply.header("access-control-allow-origin", "*").send(prm));
97
+ }
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
+ }
106
+ const verifier = new JwtTokenVerifier({
107
+ issuer: config.auth.issuer,
108
+ audiences: config.auth.audiences,
109
+ jwksUri,
110
+ identityClaims: config.auth.identityClaims,
111
+ access: config.auth.access,
112
+ });
113
+ const authenticator = createAuthenticator({
114
+ verifier,
115
+ resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl),
116
+ scopeNames: config.scopeNames,
117
+ });
118
+ authHandler = {
119
+ mode: "oidc",
120
+ authenticate: async (req) => {
121
+ const result = await authenticator.authenticate(req.headers.authorization);
122
+ if (!result.ok)
123
+ return result.response;
124
+ req.principal = result.principal;
125
+ req.authInfo = result.authInfo;
126
+ return null;
127
+ },
128
+ insufficientScope: (scopeName) => authenticator.insufficientScope(scopeName),
129
+ };
130
+ }
131
+ else {
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
+ });
182
+ const allScopes = [config.scopeNames.read, config.scopeNames.write, config.scopeNames.admin];
183
+ const anonymousAuthInfo = {
184
+ token: "",
185
+ clientId: "anonymous",
186
+ scopes: allScopes,
187
+ extra: { sub: "anonymous", identity: null },
188
+ };
189
+ const anonymousPrincipal = principalFromAuthInfo(anonymousAuthInfo, config.scopeNames);
190
+ authHandler = {
191
+ mode: "none",
192
+ authenticate: async (req) => {
193
+ req.principal = anonymousPrincipal;
194
+ req.authInfo = anonymousAuthInfo;
195
+ return null;
196
+ },
197
+ };
198
+ }
199
+ const storage = await GitBackend.open(config, app.log);
200
+ const catalog = new Catalog();
201
+ const service = new KnowledgeService({ config, storage, catalog, log: app.log });
202
+ await service.initialize();
203
+ const mcpHandler = createMcpHandler(({ authInfo }) => {
204
+ if (!authInfo)
205
+ throw new Error("MCP request reached the handler without authInfo");
206
+ return createMcpServer(service, principalFromAuthInfo(authInfo, config.scopeNames), app.log);
207
+ }, { legacy: "stateless", responseMode: "json" });
208
+ app.setErrorHandler((error, req, reply) => {
209
+ if (error instanceof OkfError) {
210
+ return reply
211
+ .code(error.status)
212
+ .send({ error: { code: error.code, message: error.message, details: error.details } });
213
+ }
214
+ if (error instanceof ZodError) {
215
+ return reply
216
+ .code(400)
217
+ .send({ error: { code: "bad_request", message: "invalid request", details: error.issues } });
218
+ }
219
+ const fastifyError = error;
220
+ if (fastifyError.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
221
+ return reply.code(413).send({ error: { code: "payload_too_large", message: "request body too large" } });
222
+ }
223
+ if (fastifyError.code === "FST_ERR_CTP_INVALID_MEDIA_TYPE") {
224
+ return reply.code(415).send({ error: { code: "unsupported_media", message: "unsupported content type" } });
225
+ }
226
+ const status = fastifyError.statusCode;
227
+ if (typeof status === "number" && status >= 400 && status < 500) {
228
+ return reply
229
+ .code(status)
230
+ .send({ error: { code: "bad_request", message: fastifyError.message ?? "bad request" } });
231
+ }
232
+ req.log.error({ err: error }, "unhandled error");
233
+ return reply.code(500).send({ error: { code: "internal", message: "internal error" } });
234
+ });
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
+ });
247
+ app.decorateRequest("principal", null);
248
+ app.decorateRequest("authInfo", null);
249
+ await app.register(async (secured) => {
250
+ secured.addHook("onRequest", async (req, reply) => {
251
+ const challenge = await authHandler.authenticate(req);
252
+ if (challenge)
253
+ return reply.send(challenge);
254
+ if (authHandler.mode === "oidc") {
255
+ const need = req.routeOptions?.config?.permission ?? "read";
256
+ const p = req.principal;
257
+ const permitted = p !== null && (need === "admin" ? p.canAdmin : need === "write" ? p.canWrite : p.canRead);
258
+ if (!permitted)
259
+ return reply.send(authHandler.insufficientScope(config.scopeNames[need]));
260
+ }
261
+ });
262
+ registerMcpRoute(secured, (request, options) => mcpHandler.fetch(request, options), config.publicBaseUrl);
263
+ registerRestRoutes(secured, service);
264
+ });
265
+ const address = await app.listen({ port: config.port, host: config.host });
266
+ const stopSync = startPeriodicSync(config, service, app.log);
267
+ return {
268
+ app,
269
+ url: address,
270
+ close: async () => {
271
+ stopSync();
272
+ await app.close();
273
+ await storage.close();
274
+ await mcpHandler.close();
275
+ },
276
+ };
277
+ }
278
+ /** Runs `service.syncNow()` every GIT_SYNC_INTERVAL_SECONDS when a remote is configured; returns a stop function. */
279
+ function startPeriodicSync(config, service, log) {
280
+ if (!config.gitRemoteUrl || config.gitSyncIntervalSeconds <= 0)
281
+ return () => undefined;
282
+ let syncTimer;
283
+ let stopped = false;
284
+ const scheduleSync = () => {
285
+ syncTimer = setTimeout(async () => {
286
+ try {
287
+ await service.syncNow();
288
+ }
289
+ catch (err) {
290
+ log.error({ err }, "periodic sync failed");
291
+ }
292
+ if (!stopped)
293
+ scheduleSync();
294
+ }, config.gitSyncIntervalSeconds * 1000);
295
+ };
296
+ scheduleSync();
297
+ return () => {
298
+ stopped = true;
299
+ clearTimeout(syncTimer);
300
+ };
301
+ }
302
+ /** Serves MCP over stdio to one local client with full permissions. Only the transport writes to stdout. */
303
+ export async function startStdioServer(config, options) {
304
+ const { log } = options;
305
+ const stdin = options.stdin ?? process.stdin;
306
+ const stdout = options.stdout ?? process.stdout;
307
+ const storage = await GitBackend.open(config, log);
308
+ const catalog = new Catalog();
309
+ const service = new KnowledgeService({ config, storage, catalog, log });
310
+ await service.initialize();
311
+ const principal = {
312
+ subject: "local",
313
+ clientId: "stdio",
314
+ identity: options.identity,
315
+ scopes: [],
316
+ canRead: true,
317
+ canWrite: true,
318
+ canAdmin: true,
319
+ };
320
+ const stopSync = startPeriodicSync(config, service, log);
321
+ // serveStdio owns transport.onclose, so watch stdin directly.
322
+ const { promise: done, resolve } = Promise.withResolvers();
323
+ stdin.once("end", resolve);
324
+ stdin.once("close", resolve);
325
+ const handle = serveStdio(() => createMcpServer(service, principal, log), {
326
+ transport: new StdioServerTransport(stdin, stdout),
327
+ onerror: (err) => log.error({ err }, "stdio transport error"),
328
+ });
329
+ let closing;
330
+ return {
331
+ done,
332
+ close: () => {
333
+ closing ??= (async () => {
334
+ stopSync();
335
+ await handle.close();
336
+ // Waits for the storage mutex, so an in-flight write still commits.
337
+ await storage.close();
338
+ })();
339
+ return closing;
340
+ },
341
+ };
342
+ }
343
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,40 @@
1
+ import { OkfError } from "../errors.js";
2
+ import { VERSION } from "../version.js";
3
+ export const FEEDBACK_REPOSITORY = "https://github.com/ferrule-io/ok-fine";
4
+ // github.com answers 500 for logged-out users above ~6900 characters and 414 URI Too Long above ~8200.
5
+ export const MAX_FEEDBACK_URL_LENGTH = 6000;
6
+ export const FEEDBACK_TYPES = ["bug", "feature", "general"];
7
+ const MESSAGE = "Not submitted yet. Show this URL to the user: it opens a prefilled public GitHub issue on ferrule-io/ok-fine that they review, edit, and submit with their own GitHub account.";
8
+ function present(value) {
9
+ const trimmed = value?.trim();
10
+ return trimmed ? trimmed : undefined;
11
+ }
12
+ /**
13
+ * Build a prefilled GitHub new-issue URL. Only `title` and `body` are passed: GitHub 404s permissioned
14
+ * parameters such as `labels` for users without triage access.
15
+ */
16
+ export function feedbackLink(input) {
17
+ const title = `[${input.type}] ${input.title.trim()}`;
18
+ const lines = [input.body.trim()];
19
+ for (const [heading, value] of [
20
+ ["Expected", input.expected],
21
+ ["Actual", input.actual],
22
+ ["Reproduction", input.reproduction],
23
+ ]) {
24
+ const text = present(value);
25
+ if (text)
26
+ lines.push("", `### ${heading}`, "", text);
27
+ }
28
+ lines.push("", "---", "", `- Type: ${input.type}`, `- ok-fine version: ${VERSION}`);
29
+ const tool = present(input.tool);
30
+ if (tool)
31
+ lines.push(`- Tool: \`${tool}\``);
32
+ lines.push("", "_Drafted by an AI agent with the ok-fine `submit_feedback` MCP tool._");
33
+ const body = `${lines.join("\n")}\n`;
34
+ const url = `${FEEDBACK_REPOSITORY}/issues/new?${new URLSearchParams({ title, body })}`;
35
+ if (url.length > MAX_FEEDBACK_URL_LENGTH) {
36
+ throw new OkfError("bad_request", 400, `feedback is too long for a GitHub issue link (${url.length} > ${MAX_FEEDBACK_URL_LENGTH} characters); shorten body, expected, actual, or reproduction`, { length: url.length, max: MAX_FEEDBACK_URL_LENGTH });
37
+ }
38
+ return { url, title, submitted: false, message: MESSAGE };
39
+ }
40
+ //# sourceMappingURL=feedback.js.map