@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.
Files changed (68) hide show
  1. package/README.md +5 -4
  2. package/dist/auth/discovery.js +11 -0
  3. package/dist/auth/verifier.js +44 -1
  4. package/dist/cli-options.js +1 -1
  5. package/dist/cli.js +12 -14
  6. package/dist/config.js +130 -6
  7. package/dist/data-dir-lock.js +58 -19
  8. package/dist/http/rest.js +1 -1
  9. package/dist/http/ui.js +118 -0
  10. package/dist/local-host.js +157 -0
  11. package/dist/mcp/server.js +5 -4
  12. package/dist/okf/lint.js +20 -0
  13. package/dist/server.js +138 -29
  14. package/dist/stdio-proxy.js +349 -0
  15. package/dist/store/fs-util.js +59 -4
  16. package/dist/store/git-backend.js +208 -59
  17. package/dist/store/git.js +92 -19
  18. package/dist/ui/assets/bash-BiGZg9Om.js +1 -0
  19. package/dist/ui/assets/css-BGIxQoYs.js +1 -0
  20. package/dist/ui/assets/css-DUjzNfgv.js +1 -0
  21. package/dist/ui/assets/diff-woXpYk--.js +1 -0
  22. package/dist/ui/assets/dockerfile-IyjqRm3v.js +1 -0
  23. package/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  24. package/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  25. package/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  26. package/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  27. package/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  28. package/dist/ui/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  29. package/dist/ui/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  30. package/dist/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  31. package/dist/ui/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  32. package/dist/ui/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  33. package/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  34. package/dist/ui/assets/github-dark-default-DXG-b-1a.js +1 -0
  35. package/dist/ui/assets/github-light-default-BXViO-2h.js +1 -0
  36. package/dist/ui/assets/go-rLFTqkRN.js +1 -0
  37. package/dist/ui/assets/highlight-CW19vWvw.js +153 -0
  38. package/dist/ui/assets/html-BMn1ogTm.js +1 -0
  39. package/dist/ui/assets/html-BlpXnbzK.js +1 -0
  40. package/dist/ui/assets/index-0b2NNRC5.js +127 -0
  41. package/dist/ui/assets/index-CFuTAZQM.css +1 -0
  42. package/dist/ui/assets/java-BtIQNnN0.js +1 -0
  43. package/dist/ui/assets/java-DxlWrXwe.js +1 -0
  44. package/dist/ui/assets/javascript-BzbQS41l.js +1 -0
  45. package/dist/ui/assets/javascript-Yelw-5ZN.js +1 -0
  46. package/dist/ui/assets/json-qhed-kSA.js +1 -0
  47. package/dist/ui/assets/jsx-D3KKoC5L.js +1 -0
  48. package/dist/ui/assets/jsx-hEbJ82HF.js +1 -0
  49. package/dist/ui/assets/markdown-BYOwaDjH.js +1 -0
  50. package/dist/ui/assets/python-gzcpVVnB.js +1 -0
  51. package/dist/ui/assets/ruby-Di5fYh6S.js +1 -0
  52. package/dist/ui/assets/rust-DpiIf5JO.js +1 -0
  53. package/dist/ui/assets/shellscript-CqHdAlCp.js +1 -0
  54. package/dist/ui/assets/sql-BOsskAmu.js +1 -0
  55. package/dist/ui/assets/sql-CNu3Or2W.js +1 -0
  56. package/dist/ui/assets/toml-CcmNWLt0.js +1 -0
  57. package/dist/ui/assets/tsx-5f3ulCja.js +1 -0
  58. package/dist/ui/assets/tsx-BOvsFOI6.js +1 -0
  59. package/dist/ui/assets/typescript-Utq2Cl8c.js +1 -0
  60. package/dist/ui/assets/typescript-j_1H8WHN.js +1 -0
  61. package/dist/ui/assets/yaml-CxbubiML.js +1 -0
  62. package/dist/ui/assets/yaml-DijMHRDA.js +1 -0
  63. package/dist/ui/favicon.svg +11 -0
  64. package/dist/ui/index.html +16 -0
  65. package/dist/ui/theme-init.js +10 -0
  66. package/dist/version.js +1 -1
  67. package/package.json +25 -4
  68. 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 several concurrent sessions: [Running locally](https://github.com/ferrule-io/ok-fine/wiki/Running-Locally).
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
 
@@ -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
@@ -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
- identity = val.trim();
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
  }
@@ -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 for one local client
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 { startServer, startStdioServer } from "./server.js";
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 server;
63
+ let proxy;
71
64
  try {
72
- server = await startStdioServer(config, { log, identity });
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(() => server.close(), (err) => log.error({ err }, "shutdown failed"));
79
- void server.done.then(shutdown);
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 && !httpUrl(data.OAUTH_JWKS_URI)) {
97
- ctx.addIssue({
98
- code: "custom",
99
- path: ["OAUTH_JWKS_URI"],
100
- message: "OAUTH_JWKS_URI must be a valid URL",
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
@@ -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
- function errorCode(err) {
5
- return err?.code;
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 errorCode(err) === "EPERM";
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
- * 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")`.
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 lockDataDir(dataDir) {
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
- return () => {
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 (errorCode(err) !== "ENOENT")
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 (errorCode(err) !== "EEXIST")
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 (errorCode(err) === "ENOENT")
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 && holderPid !== process.pid && isRunning(holderPid))
55
- break;
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
- 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}.`);
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(),
@@ -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