@ferrule-io/ok-fine 0.3.6 → 0.3.8
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 +5 -4
- package/dist/auth/discovery.js +11 -0
- package/dist/auth/verifier.js +44 -1
- package/dist/cli-options.js +1 -1
- package/dist/cli.js +12 -14
- package/dist/config.js +130 -6
- package/dist/data-dir-lock.js +58 -19
- package/dist/http/rest.js +1 -1
- package/dist/http/ui.js +118 -0
- package/dist/local-host.js +157 -0
- package/dist/mcp/server.js +5 -4
- package/dist/okf/lint.js +20 -0
- package/dist/server.js +138 -29
- package/dist/stdio-proxy.js +349 -0
- 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/README.md
CHANGED
|
@@ -9,6 +9,7 @@ from your own identity provider.
|
|
|
9
9
|
- Every change is a git commit, attributed to the writing agent or human, with optional two-way sync to a remote.
|
|
10
10
|
- Full-text keyword search (BM25), trust tiers (`unverified` → `machine-confirmed` → `human-reviewed`), staleness,
|
|
11
11
|
link graph, and OKF conformance linting.
|
|
12
|
+
- A read-only web UI at `/ui/` for browsing projects, concepts, links, history, and lint results.
|
|
12
13
|
- Ships as an npm package (`npx @ferrule-io/ok-fine`), a container image, and a Helm chart.
|
|
13
14
|
|
|
14
15
|
## Quick start (local)
|
|
@@ -20,11 +21,11 @@ claude mcp add --scope user ok-fine -- npx -y @ferrule-io/ok-fine
|
|
|
20
21
|
```
|
|
21
22
|
|
|
22
23
|
Other harnesses: [Coding agents](https://github.com/ferrule-io/ok-fine/wiki/Coding-Agents#local-setup-stdio). Git
|
|
23
|
-
remotes, flags, and
|
|
24
|
+
remotes, flags, and concurrent sessions: [Running locally](https://github.com/ferrule-io/ok-fine/wiki/Running-Locally).
|
|
24
25
|
|
|
25
26
|
### HTTP server (REST + MCP)
|
|
26
27
|
|
|
27
|
-
> **Warning:** `--no-auth` / `AUTH_MODE=none` disables authentication completely and gives every caller full admin access. Never expose it beyond localhost.
|
|
28
|
+
> **Warning:** `--no-auth` / `AUTH_MODE=none` disables authentication completely and gives every caller full admin access. Never expose it beyond localhost. `--no-auth` refuses to start on a non-loopback `--host`/`HOST` (127.0.0.0/8, ::1, localhost) unless `ALLOW_UNAUTHENTICATED_NETWORK=true`.
|
|
28
29
|
|
|
29
30
|
```sh
|
|
30
31
|
npx -y @ferrule-io/ok-fine serve --no-auth
|
|
@@ -33,7 +34,7 @@ npx -y @ferrule-io/ok-fine serve --no-auth
|
|
|
33
34
|
Or with Docker (requires Docker):
|
|
34
35
|
|
|
35
36
|
```sh
|
|
36
|
-
docker run --rm -p 127.0.0.1:8080:8080 -e AUTH_MODE=none -e PUBLIC_BASE_URL=http://localhost:8080 -v okf-data:/data ghcr.io/ferrule-io/ok-fine:latest
|
|
37
|
+
docker run --rm -p 127.0.0.1:8080:8080 -e AUTH_MODE=none -e ALLOW_UNAUTHENTICATED_NETWORK=true -e PUBLIC_BASE_URL=http://localhost:8080 -v okf-data:/data ghcr.io/ferrule-io/ok-fine:latest
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
Or, from a checkout, with Docker Compose (pulls the published image, or builds it from source when the pull fails):
|
|
@@ -70,7 +71,7 @@ curl -s -X PUT localhost:8080/api/v1/projects/demo/concepts/tables/orders \
|
|
|
70
71
|
curl -s "localhost:8080/api/v1/search?q=orders"
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
Next, [connect an MCP client](https://github.com/ferrule-io/ok-fine/wiki/Connecting-an-MCP-Client) to `http://localhost:8080/mcp`.
|
|
74
|
+
Browse it at http://localhost:8080/ui/. Next, [connect an MCP client](https://github.com/ferrule-io/ok-fine/wiki/Connecting-an-MCP-Client) to `http://localhost:8080/mcp`.
|
|
74
75
|
|
|
75
76
|
## Documentation
|
|
76
77
|
|
package/dist/auth/discovery.js
CHANGED
|
@@ -51,6 +51,17 @@ export async function discoverAuthorizationServer(issuer, options) {
|
|
|
51
51
|
if (!jwksUri) {
|
|
52
52
|
throw new Error(`authorization server discovery failed for ${issuer}: missing jwks_uri`);
|
|
53
53
|
}
|
|
54
|
+
const allowInsecureIssuer = options?.allowInsecureIssuer === true;
|
|
55
|
+
let parsedJwks;
|
|
56
|
+
try {
|
|
57
|
+
parsedJwks = new URL(jwksUri);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error(`authorization server discovery failed for ${issuer}: invalid jwks_uri`);
|
|
61
|
+
}
|
|
62
|
+
if (parsedJwks.protocol !== "https:" && !allowInsecureIssuer) {
|
|
63
|
+
throw new Error(`authorization server discovery failed for ${issuer}: jwks_uri must be https`);
|
|
64
|
+
}
|
|
54
65
|
return { metadata, jwksUri };
|
|
55
66
|
}
|
|
56
67
|
//# sourceMappingURL=discovery.js.map
|
package/dist/auth/verifier.js
CHANGED
|
@@ -8,10 +8,12 @@ export class JwtTokenVerifier {
|
|
|
8
8
|
audiences;
|
|
9
9
|
identityClaims;
|
|
10
10
|
jwks;
|
|
11
|
+
access;
|
|
11
12
|
constructor(options) {
|
|
12
13
|
this.issuer = options.issuer;
|
|
13
14
|
this.audiences = options.audiences;
|
|
14
15
|
this.identityClaims = options.identityClaims;
|
|
16
|
+
this.access = options.access;
|
|
15
17
|
this.jwks = createRemoteJWKSet(new URL(options.jwksUri), {
|
|
16
18
|
cooldownDuration: 30000,
|
|
17
19
|
cacheMaxAge: 600000,
|
|
@@ -82,11 +84,52 @@ export class JwtTokenVerifier {
|
|
|
82
84
|
break;
|
|
83
85
|
}
|
|
84
86
|
}
|
|
87
|
+
if (this.access) {
|
|
88
|
+
const { allowedSubjects, allowedEmails, requiredGroups, groupsClaim, allowedClientIds } = this.access;
|
|
89
|
+
if (allowedSubjects.length > 0 || allowedEmails.length > 0) {
|
|
90
|
+
const sub = typeof payload.sub === "string" ? payload.sub : undefined;
|
|
91
|
+
const subMatch = sub !== undefined && allowedSubjects.includes(sub);
|
|
92
|
+
const email = typeof payload.email === "string" ? payload.email.toLowerCase() : undefined;
|
|
93
|
+
const emailVerified = payload.email_verified === true;
|
|
94
|
+
const emailMatch = emailVerified && email !== undefined && allowedEmails.includes(email);
|
|
95
|
+
if (!subMatch && !emailMatch) {
|
|
96
|
+
throw new OAuthError(OAuthErrorCode.InsufficientScope, "token not permitted by this server's access policy");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (requiredGroups.length > 0) {
|
|
100
|
+
const groupsVal = payload[groupsClaim];
|
|
101
|
+
let tokenGroups = [];
|
|
102
|
+
if (typeof groupsVal === "string") {
|
|
103
|
+
tokenGroups = [groupsVal];
|
|
104
|
+
}
|
|
105
|
+
else if (Array.isArray(groupsVal)) {
|
|
106
|
+
tokenGroups = groupsVal.filter((g) => typeof g === "string");
|
|
107
|
+
}
|
|
108
|
+
const hasRequiredGroup = tokenGroups.some((g) => requiredGroups.includes(g));
|
|
109
|
+
if (!hasRequiredGroup) {
|
|
110
|
+
throw new OAuthError(OAuthErrorCode.InsufficientScope, "token not permitted by this server's access policy");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (allowedClientIds.length > 0) {
|
|
114
|
+
const clientIds = [payload.azp, payload.client_id, payload.cid].filter((v) => typeof v === "string" && v.length > 0);
|
|
115
|
+
const clientAllowed = clientIds.some((id) => allowedClientIds.includes(id));
|
|
116
|
+
if (!clientAllowed) {
|
|
117
|
+
throw new OAuthError(OAuthErrorCode.InsufficientScope, "token not permitted by this server's access policy");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
85
121
|
let identity = null;
|
|
86
122
|
for (const claim of this.identityClaims) {
|
|
123
|
+
if (claim === "email" && payload.email_verified !== true) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
87
126
|
const val = payload[claim];
|
|
88
127
|
if (typeof val === "string" && val.trim().length > 0) {
|
|
89
|
-
|
|
128
|
+
const trimmed = val.trim();
|
|
129
|
+
if (claim !== "email" && claim !== "sub" && trimmed.includes("@")) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
identity = trimmed;
|
|
90
133
|
break;
|
|
91
134
|
}
|
|
92
135
|
}
|
package/dist/cli-options.js
CHANGED
|
@@ -3,7 +3,7 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
export const USAGE = `Usage: ok-fine [stdio|serve] [options]
|
|
4
4
|
|
|
5
5
|
Commands:
|
|
6
|
-
stdio (default) Serve MCP over stdin/stdout
|
|
6
|
+
stdio (default) Serve MCP over stdin/stdout
|
|
7
7
|
serve Serve MCP and the REST API over HTTP
|
|
8
8
|
|
|
9
9
|
Options:
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,8 @@ import { pino } from "pino";
|
|
|
5
5
|
import { parseCli, USAGE } from "./cli-options.js";
|
|
6
6
|
import { loadConfig, loadStorageConfig } from "./config.js";
|
|
7
7
|
import { lockDataDir } from "./data-dir-lock.js";
|
|
8
|
-
import {
|
|
8
|
+
import { startLocalHost, startServer } from "./server.js";
|
|
9
|
+
import { startStdioProxy } from "./stdio-proxy.js";
|
|
9
10
|
import { VERSION } from "./version.js";
|
|
10
11
|
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
11
12
|
/** `git config user.email` in the launch directory; null when unset or git is unavailable. Not `Git`: it forces ok-fine's own email. */
|
|
@@ -57,26 +58,23 @@ if (inv.command === "stdio") {
|
|
|
57
58
|
}
|
|
58
59
|
// stdout carries JSON-RPC only; every log line goes to stderr.
|
|
59
60
|
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
61
|
const identity = await localGitEmail(inv.env);
|
|
69
62
|
log.info({ dataDir: config.dataDir, identity }, "serving MCP over stdio");
|
|
70
|
-
let
|
|
63
|
+
let proxy;
|
|
71
64
|
try {
|
|
72
|
-
|
|
65
|
+
proxy = await startStdioProxy({
|
|
66
|
+
config,
|
|
67
|
+
identity,
|
|
68
|
+
log,
|
|
69
|
+
startHost: () => startLocalHost(config, { log }),
|
|
70
|
+
});
|
|
73
71
|
}
|
|
74
72
|
catch (err) {
|
|
75
73
|
log.error({ err }, "ok-fine failed to start");
|
|
76
74
|
process.exit(1);
|
|
77
75
|
}
|
|
78
|
-
const shutdown = shutdownOnce(() =>
|
|
79
|
-
void
|
|
76
|
+
const shutdown = shutdownOnce(() => proxy.close(), (err) => log.error({ err }, "shutdown failed"));
|
|
77
|
+
void proxy.done.then(shutdown);
|
|
80
78
|
process.once("SIGINT", shutdown);
|
|
81
79
|
process.once("SIGTERM", shutdown);
|
|
82
80
|
}
|
|
@@ -99,7 +97,7 @@ else {
|
|
|
99
97
|
}
|
|
100
98
|
let server;
|
|
101
99
|
try {
|
|
102
|
-
server = await startServer(config);
|
|
100
|
+
server = await startServer(config, { hostEndpoint: config.auth.mode === "none" });
|
|
103
101
|
}
|
|
104
102
|
catch (err) {
|
|
105
103
|
console.error("ok-fine failed to start:", err);
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isIPv4 } from "node:net";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
/** A whole-string decimal integer env var (rejects `8080junk`, `1x`, `1.5`, and blanks). */
|
|
3
4
|
function intEnv(name, fallback, min, max = Number.MAX_SAFE_INTEGER) {
|
|
@@ -30,6 +31,41 @@ function httpUrl(value) {
|
|
|
30
31
|
return false;
|
|
31
32
|
}
|
|
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
|
+
}
|
|
33
69
|
const storageEnvShape = {
|
|
34
70
|
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
|
|
35
71
|
DATA_DIR: z
|
|
@@ -62,7 +98,29 @@ const httpEnvShape = {
|
|
|
62
98
|
OAUTH_SCOPE_ADMIN: z.string().default("okf:admin"),
|
|
63
99
|
OAUTH_IDENTITY_CLAIMS: z.string().default("email,preferred_username,sub"),
|
|
64
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(),
|
|
65
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
|
+
}
|
|
66
124
|
function refineGitCredentials(data, ctx) {
|
|
67
125
|
if (!data || typeof data !== "object")
|
|
68
126
|
return;
|
|
@@ -73,11 +131,45 @@ function refineGitCredentials(data, ctx) {
|
|
|
73
131
|
message: "GIT_HTTP_USERNAME and GIT_HTTP_PASSWORD must be provided together or neither",
|
|
74
132
|
});
|
|
75
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
|
+
}
|
|
76
148
|
}
|
|
77
149
|
const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefine((data, ctx) => {
|
|
78
150
|
if (!data || typeof data !== "object")
|
|
79
151
|
return;
|
|
80
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
|
+
}
|
|
81
173
|
if (data.AUTH_MODE === "oidc") {
|
|
82
174
|
if (data.OAUTH_ISSUER === undefined) {
|
|
83
175
|
ctx.addIssue({
|
|
@@ -93,12 +185,29 @@ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefi
|
|
|
93
185
|
message: "OAUTH_ISSUER must be a valid URL",
|
|
94
186
|
});
|
|
95
187
|
}
|
|
96
|
-
if (data.OAUTH_JWKS_URI !== undefined
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
102
211
|
}
|
|
103
212
|
if (data.OAUTH_ALLOW_INSECURE_ISSUER !== undefined &&
|
|
104
213
|
data.OAUTH_ALLOW_INSECURE_ISSUER !== "true" &&
|
|
@@ -150,6 +259,8 @@ export function loadConfig(env = process.env, options = {}) {
|
|
|
150
259
|
throw formatConfigError(result.error);
|
|
151
260
|
const raw = result.data;
|
|
152
261
|
const publicBaseUrl = raw.PUBLIC_BASE_URL.replace(/\/+$/, "");
|
|
262
|
+
const trustProxy = parseTrustProxy(raw.TRUST_PROXY);
|
|
263
|
+
const allowUnauthenticatedNetwork = raw.ALLOW_UNAUTHENTICATED_NETWORK === "true";
|
|
153
264
|
let auth;
|
|
154
265
|
if (raw.AUTH_MODE === "none") {
|
|
155
266
|
auth = { mode: "none" };
|
|
@@ -171,6 +282,15 @@ export function loadConfig(env = process.env, options = {}) {
|
|
|
171
282
|
.map((s) => s.trim())
|
|
172
283
|
.filter((s) => s.length > 0);
|
|
173
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
|
+
};
|
|
174
294
|
auth = {
|
|
175
295
|
mode: "oidc",
|
|
176
296
|
issuer: raw.OAUTH_ISSUER,
|
|
@@ -178,6 +298,8 @@ export function loadConfig(env = process.env, options = {}) {
|
|
|
178
298
|
...(raw.OAUTH_JWKS_URI === undefined ? {} : { jwksUri: raw.OAUTH_JWKS_URI }),
|
|
179
299
|
identityClaims,
|
|
180
300
|
allowInsecureIssuer,
|
|
301
|
+
access,
|
|
302
|
+
...(uiClientId ? { uiClientId } : {}),
|
|
181
303
|
};
|
|
182
304
|
}
|
|
183
305
|
return {
|
|
@@ -191,6 +313,8 @@ export function loadConfig(env = process.env, options = {}) {
|
|
|
191
313
|
write: raw.OAUTH_SCOPE_WRITE,
|
|
192
314
|
admin: raw.OAUTH_SCOPE_ADMIN,
|
|
193
315
|
},
|
|
316
|
+
allowUnauthenticatedNetwork,
|
|
317
|
+
trustProxy,
|
|
194
318
|
};
|
|
195
319
|
}
|
|
196
320
|
//# sourceMappingURL=config.js.map
|
package/dist/data-dir-lock.js
CHANGED
|
@@ -1,43 +1,52 @@
|
|
|
1
1
|
import { readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
function isRunning(pid) {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
/** Checks if a process with the given PID is alive. */
|
|
5
|
+
export function isProcessAlive(pid) {
|
|
8
6
|
try {
|
|
9
7
|
process.kill(pid, 0);
|
|
10
8
|
return true;
|
|
11
9
|
}
|
|
12
10
|
catch (err) {
|
|
13
|
-
return
|
|
11
|
+
return err?.code === "EPERM";
|
|
14
12
|
}
|
|
15
13
|
}
|
|
14
|
+
/** In-process tracking so concurrent sessions in the same process do not clobber each other's locks. */
|
|
15
|
+
const inProcessLocks = new Set();
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
* Returns a
|
|
17
|
+
* Attempts to acquire the exclusive per-data-dir lock without throwing.
|
|
18
|
+
* Returns a release function (callable directly or via .release()) on success,
|
|
19
|
+
* or { acquired: false, holderPid } when held by another process or concurrent session.
|
|
19
20
|
*/
|
|
20
|
-
export async function
|
|
21
|
+
export async function tryLockDataDir(dataDir) {
|
|
22
|
+
const canonicalDir = resolve(dataDir);
|
|
23
|
+
if (inProcessLocks.has(canonicalDir)) {
|
|
24
|
+
return { acquired: false, holderPid: process.pid };
|
|
25
|
+
}
|
|
21
26
|
await mkdir(dataDir, { recursive: true });
|
|
22
27
|
const lockPath = join(dataDir, "ok-fine.lock");
|
|
23
28
|
const pid = String(process.pid);
|
|
24
|
-
let holder = "unknown";
|
|
25
29
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
26
30
|
try {
|
|
27
31
|
await writeFile(lockPath, `${pid}\n`, { flag: "wx" });
|
|
28
|
-
|
|
32
|
+
inProcessLocks.add(canonicalDir);
|
|
33
|
+
const releaseFn = (() => {
|
|
34
|
+
inProcessLocks.delete(canonicalDir);
|
|
29
35
|
try {
|
|
30
36
|
if (readFileSync(lockPath, "utf8").trim() === pid)
|
|
31
37
|
unlinkSync(lockPath);
|
|
32
38
|
}
|
|
33
39
|
catch (err) {
|
|
34
|
-
if (
|
|
40
|
+
if (err?.code !== "ENOENT")
|
|
35
41
|
throw err;
|
|
36
42
|
}
|
|
37
|
-
};
|
|
43
|
+
});
|
|
44
|
+
releaseFn.acquired = true;
|
|
45
|
+
releaseFn.release = releaseFn;
|
|
46
|
+
return releaseFn;
|
|
38
47
|
}
|
|
39
48
|
catch (err) {
|
|
40
|
-
if (
|
|
49
|
+
if (err?.code !== "EEXIST")
|
|
41
50
|
throw err;
|
|
42
51
|
}
|
|
43
52
|
let content;
|
|
@@ -45,16 +54,46 @@ export async function lockDataDir(dataDir) {
|
|
|
45
54
|
content = (await readFile(lockPath, "utf8")).trim();
|
|
46
55
|
}
|
|
47
56
|
catch (err) {
|
|
48
|
-
if (
|
|
57
|
+
if (err?.code === "ENOENT")
|
|
49
58
|
continue;
|
|
50
59
|
throw err;
|
|
51
60
|
}
|
|
52
|
-
holder = content || "unknown";
|
|
53
61
|
const holderPid = Number.parseInt(content, 10);
|
|
54
|
-
if (Number.isInteger(holderPid) && holderPid > 0
|
|
55
|
-
|
|
62
|
+
if (Number.isInteger(holderPid) && holderPid > 0) {
|
|
63
|
+
if (holderPid !== process.pid && isProcessAlive(holderPid)) {
|
|
64
|
+
return { acquired: false, holderPid };
|
|
65
|
+
}
|
|
66
|
+
if (holderPid === process.pid && inProcessLocks.has(canonicalDir)) {
|
|
67
|
+
return { acquired: false, holderPid: process.pid };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
56
70
|
await rm(lockPath, { force: true });
|
|
57
71
|
}
|
|
58
|
-
|
|
72
|
+
let content = null;
|
|
73
|
+
try {
|
|
74
|
+
content = (await readFile(lockPath, "utf8")).trim();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// ignore
|
|
78
|
+
}
|
|
79
|
+
const finalHolder = content ? Number.parseInt(content, 10) : null;
|
|
80
|
+
return {
|
|
81
|
+
acquired: false,
|
|
82
|
+
holderPid: finalHolder !== null && Number.isInteger(finalHolder) && finalHolder > 0 ? finalHolder : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Takes the exclusive per-data-dir lock for a local CLI process; a lock left by a dead process is taken over.
|
|
87
|
+
* Returns a synchronous release, safe to call from `process.on("exit")`.
|
|
88
|
+
* Throws if already held by a live process or concurrent session.
|
|
89
|
+
*/
|
|
90
|
+
export async function lockDataDir(dataDir) {
|
|
91
|
+
const result = await tryLockDataDir(dataDir);
|
|
92
|
+
if (result.acquired) {
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
const lockPath = join(dataDir, "ok-fine.lock");
|
|
96
|
+
const holder = result.holderPid ?? "unknown";
|
|
97
|
+
throw new Error(`ok-fine is already running on ${dataDir} (pid ${holder}). Close existing ok-fine sessions or pass a different --data-dir. If no ok-fine process is running, delete ${lockPath}.`);
|
|
59
98
|
}
|
|
60
99
|
//# sourceMappingURL=data-dir-lock.js.map
|
package/dist/http/rest.js
CHANGED
|
@@ -90,7 +90,7 @@ const CONTENT_TYPE_BY_EXTENSION = {
|
|
|
90
90
|
".json": "application/json",
|
|
91
91
|
};
|
|
92
92
|
const searchQuery = z.object({
|
|
93
|
-
q: z.string().optional(),
|
|
93
|
+
q: z.string().max(512).optional(),
|
|
94
94
|
project: z.string().optional(),
|
|
95
95
|
type: z.string().optional(),
|
|
96
96
|
tag: z.union([z.string(), z.array(z.string())]).optional(),
|
package/dist/http/ui.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** <package>/dist/ui from both src/http (vitest) and dist/http (build). */
|
|
5
|
+
export const DEFAULT_UI_DIR = fileURLToPath(new URL("../../dist/ui/", import.meta.url));
|
|
6
|
+
const CONTENT_TYPES = {
|
|
7
|
+
".html": "text/html; charset=utf-8",
|
|
8
|
+
".js": "text/javascript; charset=utf-8",
|
|
9
|
+
".css": "text/css; charset=utf-8",
|
|
10
|
+
".svg": "image/svg+xml",
|
|
11
|
+
".png": "image/png",
|
|
12
|
+
".ico": "image/x-icon",
|
|
13
|
+
".woff2": "font/woff2",
|
|
14
|
+
".woff": "font/woff",
|
|
15
|
+
".json": "application/json",
|
|
16
|
+
".webmanifest": "application/manifest+json",
|
|
17
|
+
".txt": "text/plain; charset=utf-8",
|
|
18
|
+
};
|
|
19
|
+
const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
20
|
+
/** Unique origins of the string http(s) URLs among `endpoints`; ignores anything else. */
|
|
21
|
+
export function endpointOrigins(...endpoints) {
|
|
22
|
+
const origins = new Set();
|
|
23
|
+
for (const endpoint of endpoints) {
|
|
24
|
+
if (typeof endpoint !== "string")
|
|
25
|
+
continue;
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(endpoint);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol === "http:" || url.protocol === "https:")
|
|
34
|
+
origins.add(url.origin);
|
|
35
|
+
}
|
|
36
|
+
return [...origins];
|
|
37
|
+
}
|
|
38
|
+
async function loadAssets(dir) {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err.code === "ENOENT")
|
|
45
|
+
return null;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
const assets = new Map();
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.isFile())
|
|
51
|
+
continue;
|
|
52
|
+
const abs = path.join(entry.parentPath, entry.name);
|
|
53
|
+
const rel = path.relative(dir, abs).split(path.sep).join("/");
|
|
54
|
+
assets.set(rel, {
|
|
55
|
+
body: await readFile(abs),
|
|
56
|
+
type: CONTENT_TYPES[path.extname(entry.name).toLowerCase()] ?? "application/octet-stream",
|
|
57
|
+
cache: rel.startsWith("assets/") ? IMMUTABLE : "no-cache",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return assets.has("index.html") ? assets : null;
|
|
61
|
+
}
|
|
62
|
+
/** Serves the built web UI from memory under /ui/. Returns false (and registers nothing) when `<dir>/index.html` is missing. */
|
|
63
|
+
export async function registerUiRoutes(app, options) {
|
|
64
|
+
const assets = await loadAssets(options.dir);
|
|
65
|
+
const index = assets?.get("index.html");
|
|
66
|
+
if (!assets || !index) {
|
|
67
|
+
app.log.warn({ dir: options.dir }, "web UI assets not found; /ui disabled");
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const csp = [
|
|
71
|
+
"default-src 'self'",
|
|
72
|
+
"script-src 'self'",
|
|
73
|
+
// Shiki emits inline style attributes.
|
|
74
|
+
"style-src 'self' 'unsafe-inline'",
|
|
75
|
+
"img-src 'self' data: https:",
|
|
76
|
+
"font-src 'self' data:",
|
|
77
|
+
`connect-src 'self'${options.connectSrc.map((origin) => ` ${origin}`).join("")}`,
|
|
78
|
+
"object-src 'none'",
|
|
79
|
+
"base-uri 'none'",
|
|
80
|
+
"form-action 'none'",
|
|
81
|
+
"frame-ancestors 'none'",
|
|
82
|
+
].join("; ");
|
|
83
|
+
const sendHtml = (reply) => reply
|
|
84
|
+
.header("content-type", "text/html; charset=utf-8")
|
|
85
|
+
.header("cache-control", "no-cache")
|
|
86
|
+
.header("referrer-policy", "no-referrer")
|
|
87
|
+
.header("x-frame-options", "DENY")
|
|
88
|
+
.header("x-content-type-options", "nosniff")
|
|
89
|
+
.header("content-security-policy", csp)
|
|
90
|
+
.send(index.body);
|
|
91
|
+
app.get("/", async (_req, reply) => reply.redirect("/ui/", 302));
|
|
92
|
+
app.get("/ui", async (_req, reply) => reply.redirect("/ui/", 301));
|
|
93
|
+
app.get("/ui/config.json", async (_req, reply) => reply.header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(options.clientConfig));
|
|
94
|
+
app.get("/ui/*", async (req, reply) => {
|
|
95
|
+
const rel = req.params["*"];
|
|
96
|
+
if (rel === "" || rel === "index.html")
|
|
97
|
+
return sendHtml(reply);
|
|
98
|
+
const asset = assets.get(rel);
|
|
99
|
+
if (asset) {
|
|
100
|
+
return reply
|
|
101
|
+
.header("content-type", asset.type)
|
|
102
|
+
.header("cache-control", asset.cache)
|
|
103
|
+
.header("x-content-type-options", "nosniff")
|
|
104
|
+
.send(asset.body);
|
|
105
|
+
}
|
|
106
|
+
const last = rel.slice(rel.lastIndexOf("/") + 1);
|
|
107
|
+
if (/\.[A-Za-z0-9]+$/.test(last)) {
|
|
108
|
+
return reply
|
|
109
|
+
.code(404)
|
|
110
|
+
.header("x-content-type-options", "nosniff")
|
|
111
|
+
.send({ error: { code: "not_found", message: "not found" } });
|
|
112
|
+
}
|
|
113
|
+
return sendHtml(reply);
|
|
114
|
+
});
|
|
115
|
+
app.log.info({ url: `${options.publicBaseUrl}/ui/` }, "web UI enabled");
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=ui.js.map
|