@ferrule-io/ok-fine 0.0.0-stage → 0.3.6

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/dist/cli.js ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { pino } from "pino";
5
+ import { parseCli, USAGE } from "./cli-options.js";
6
+ import { loadConfig, loadStorageConfig } from "./config.js";
7
+ import { lockDataDir } from "./data-dir-lock.js";
8
+ import { startServer, startStdioServer } from "./server.js";
9
+ import { VERSION } from "./version.js";
10
+ const message = (err) => (err instanceof Error ? err.message : String(err));
11
+ /** `git config user.email` in the launch directory; null when unset or git is unavailable. Not `Git`: it forces ok-fine's own email. */
12
+ function localGitEmail(env) {
13
+ const { promise, resolve } = Promise.withResolvers();
14
+ execFile("git", ["config", "user.email"], { env }, (error, stdout) => {
15
+ const email = String(stdout).trim();
16
+ resolve(error || email.length === 0 ? null : email);
17
+ });
18
+ return promise;
19
+ }
20
+ /** Runs `close` once on the first trigger, then exits. */
21
+ function shutdownOnce(close, onError) {
22
+ let shuttingDown = false;
23
+ return () => {
24
+ if (shuttingDown)
25
+ return;
26
+ shuttingDown = true;
27
+ close().then(() => process.exit(0), (err) => {
28
+ onError(err);
29
+ process.exit(1);
30
+ });
31
+ };
32
+ }
33
+ let inv;
34
+ try {
35
+ inv = parseCli(process.argv.slice(2), process.env, homedir());
36
+ }
37
+ catch (err) {
38
+ console.error(`${message(err)}\nRun "ok-fine --help" for usage.`);
39
+ process.exit(2);
40
+ }
41
+ if (inv.command === "help") {
42
+ process.stdout.write(USAGE);
43
+ process.exit(0);
44
+ }
45
+ if (inv.command === "version") {
46
+ process.stdout.write(`${VERSION}\n`);
47
+ process.exit(0);
48
+ }
49
+ if (inv.command === "stdio") {
50
+ let config;
51
+ try {
52
+ config = loadStorageConfig(inv.env, { inheritGitEnv: true });
53
+ }
54
+ catch (err) {
55
+ console.error(message(err));
56
+ process.exit(1);
57
+ }
58
+ // stdout carries JSON-RPC only; every log line goes to stderr.
59
+ const log = pino({ level: config.logLevel }, pino.destination({ dest: 2, sync: true }));
60
+ try {
61
+ const release = await lockDataDir(config.dataDir);
62
+ process.once("exit", release);
63
+ }
64
+ catch (err) {
65
+ log.error(message(err));
66
+ process.exit(1);
67
+ }
68
+ const identity = await localGitEmail(inv.env);
69
+ log.info({ dataDir: config.dataDir, identity }, "serving MCP over stdio");
70
+ let server;
71
+ try {
72
+ server = await startStdioServer(config, { log, identity });
73
+ }
74
+ catch (err) {
75
+ log.error({ err }, "ok-fine failed to start");
76
+ process.exit(1);
77
+ }
78
+ const shutdown = shutdownOnce(() => server.close(), (err) => log.error({ err }, "shutdown failed"));
79
+ void server.done.then(shutdown);
80
+ process.once("SIGINT", shutdown);
81
+ process.once("SIGTERM", shutdown);
82
+ }
83
+ else {
84
+ let config;
85
+ try {
86
+ config = loadConfig(inv.env, { inheritGitEnv: true });
87
+ }
88
+ catch (err) {
89
+ console.error(message(err));
90
+ process.exit(1);
91
+ }
92
+ try {
93
+ const release = await lockDataDir(config.dataDir);
94
+ process.once("exit", release);
95
+ }
96
+ catch (err) {
97
+ console.error(message(err));
98
+ process.exit(1);
99
+ }
100
+ let server;
101
+ try {
102
+ server = await startServer(config);
103
+ }
104
+ catch (err) {
105
+ console.error("ok-fine failed to start:", err);
106
+ process.exit(1);
107
+ }
108
+ const shutdown = shutdownOnce(() => {
109
+ server.app.log.info("shutting down");
110
+ return server.close();
111
+ }, (err) => server.app.log.error({ err }, "shutdown failed"));
112
+ process.once("SIGINT", shutdown);
113
+ process.once("SIGTERM", shutdown);
114
+ }
115
+ //# sourceMappingURL=cli.js.map
package/dist/config.js ADDED
@@ -0,0 +1,196 @@
1
+ import { z } from "zod";
2
+ /** A whole-string decimal integer env var (rejects `8080junk`, `1x`, `1.5`, and blanks). */
3
+ function intEnv(name, fallback, min, max = Number.MAX_SAFE_INTEGER) {
4
+ return z
5
+ .string()
6
+ .default(fallback)
7
+ .refine((v) => /^\d+$/.test(v), { message: `${name} must be a whole decimal integer` })
8
+ .transform(Number)
9
+ .pipe(z.number().int().min(min, `${name} must be >= ${min}`).max(max, `${name} must be <= ${max}`));
10
+ }
11
+ /** An optional whole-string decimal integer env var; unset or empty string evaluates to undefined. */
12
+ function optionalIntEnv(name, min, max = Number.MAX_SAFE_INTEGER) {
13
+ return z
14
+ .string()
15
+ .optional()
16
+ .transform((v) => (v === undefined || v === "" ? undefined : v))
17
+ .pipe(z
18
+ .string()
19
+ .refine((v) => /^\d+$/.test(v), { message: `${name} must be a whole decimal integer` })
20
+ .transform(Number)
21
+ .pipe(z.number().int().min(min, `${name} must be >= ${min}`).max(max, `${name} must be <= ${max}`))
22
+ .optional());
23
+ }
24
+ function httpUrl(value) {
25
+ try {
26
+ const u = new URL(value);
27
+ return u.protocol === "http:" || u.protocol === "https:";
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ const storageEnvShape = {
34
+ LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
35
+ DATA_DIR: z
36
+ .string()
37
+ .default("/data")
38
+ .refine((v) => v.startsWith("/"), "DATA_DIR must be an absolute path"),
39
+ GIT_BRANCH: z.string().default("main"),
40
+ GIT_REMOTE_URL: z.string().optional(),
41
+ GIT_SYNC_INTERVAL_SECONDS: intEnv("GIT_SYNC_INTERVAL_SECONDS", "60", 0),
42
+ GIT_SSH_KEY_PATH: z.string().optional(),
43
+ GIT_SSH_KNOWN_HOSTS_PATH: z.string().optional(),
44
+ GIT_HTTP_USERNAME: z.string().optional(),
45
+ GIT_HTTP_PASSWORD: z.string().optional(),
46
+ MAX_FILE_BYTES: intEnv("MAX_FILE_BYTES", "1048576", 1),
47
+ MAX_ARCHIVE_BYTES: intEnv("MAX_ARCHIVE_BYTES", "52428800", 1),
48
+ DEFAULT_STALE_AFTER_DAYS: optionalIntEnv("DEFAULT_STALE_AFTER_DAYS", 1, 36500),
49
+ };
50
+ const httpEnvShape = {
51
+ AUTH_MODE: z.enum(["oidc", "none"], "AUTH_MODE must be oidc or none").default("oidc"),
52
+ PORT: intEnv("PORT", "8080", 0, 65535),
53
+ HOST: z.string().default("0.0.0.0"),
54
+ PUBLIC_BASE_URL: z
55
+ .string({ message: "PUBLIC_BASE_URL is required" })
56
+ .refine(httpUrl, "PUBLIC_BASE_URL must be a valid http or https URL"),
57
+ OAUTH_ISSUER: z.string().optional(),
58
+ OAUTH_AUDIENCE: z.string().optional(),
59
+ OAUTH_JWKS_URI: z.string().optional(),
60
+ OAUTH_SCOPE_READ: z.string().default("okf:read"),
61
+ OAUTH_SCOPE_WRITE: z.string().default("okf:write"),
62
+ OAUTH_SCOPE_ADMIN: z.string().default("okf:admin"),
63
+ OAUTH_IDENTITY_CLAIMS: z.string().default("email,preferred_username,sub"),
64
+ OAUTH_ALLOW_INSECURE_ISSUER: z.string().optional(),
65
+ };
66
+ function refineGitCredentials(data, ctx) {
67
+ if (!data || typeof data !== "object")
68
+ return;
69
+ if (Boolean(data.GIT_HTTP_USERNAME) !== Boolean(data.GIT_HTTP_PASSWORD)) {
70
+ ctx.addIssue({
71
+ code: "custom",
72
+ path: ["GIT_HTTP_USERNAME"],
73
+ message: "GIT_HTTP_USERNAME and GIT_HTTP_PASSWORD must be provided together or neither",
74
+ });
75
+ }
76
+ }
77
+ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefine((data, ctx) => {
78
+ if (!data || typeof data !== "object")
79
+ return;
80
+ refineGitCredentials(data, ctx);
81
+ if (data.AUTH_MODE === "oidc") {
82
+ if (data.OAUTH_ISSUER === undefined) {
83
+ ctx.addIssue({
84
+ code: "custom",
85
+ path: ["OAUTH_ISSUER"],
86
+ message: "OAUTH_ISSUER is required",
87
+ });
88
+ }
89
+ else if (!httpUrl(data.OAUTH_ISSUER)) {
90
+ ctx.addIssue({
91
+ code: "custom",
92
+ path: ["OAUTH_ISSUER"],
93
+ message: "OAUTH_ISSUER must be a valid URL",
94
+ });
95
+ }
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
+ });
102
+ }
103
+ if (data.OAUTH_ALLOW_INSECURE_ISSUER !== undefined &&
104
+ data.OAUTH_ALLOW_INSECURE_ISSUER !== "true" &&
105
+ data.OAUTH_ALLOW_INSECURE_ISSUER !== "false") {
106
+ ctx.addIssue({
107
+ code: "custom",
108
+ path: ["OAUTH_ALLOW_INSECURE_ISSUER"],
109
+ message: "OAUTH_ALLOW_INSECURE_ISSUER must be true or false",
110
+ });
111
+ }
112
+ }
113
+ }, { when: () => true });
114
+ const storageEnvSchema = z.object(storageEnvShape).superRefine(refineGitCredentials, { when: () => true });
115
+ function formatConfigError(error) {
116
+ const errorMessages = error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
117
+ return new Error(`Configuration errors:\n ${errorMessages.join("\n ")}`);
118
+ }
119
+ function storageFromRaw(raw, env, options) {
120
+ return {
121
+ logLevel: raw.LOG_LEVEL,
122
+ dataDir: raw.DATA_DIR,
123
+ gitBranch: raw.GIT_BRANCH,
124
+ gitRemoteUrl: raw.GIT_REMOTE_URL,
125
+ gitSyncIntervalSeconds: raw.GIT_SYNC_INTERVAL_SECONDS,
126
+ gitSshKeyPath: raw.GIT_SSH_KEY_PATH,
127
+ gitSshKnownHostsPath: raw.GIT_SSH_KNOWN_HOSTS_PATH,
128
+ gitHttpUsername: raw.GIT_HTTP_USERNAME,
129
+ gitHttpPassword: raw.GIT_HTTP_PASSWORD,
130
+ ...(options.inheritGitEnv
131
+ ? {
132
+ gitHostEnv: Object.fromEntries(Object.entries(env).filter((e) => e[1] !== undefined)),
133
+ }
134
+ : {}),
135
+ maxFileBytes: raw.MAX_FILE_BYTES,
136
+ maxArchiveBytes: raw.MAX_ARCHIVE_BYTES,
137
+ defaultStaleAfterDays: raw.DEFAULT_STALE_AFTER_DAYS,
138
+ };
139
+ }
140
+ /** Storage-only settings for the stdio transport: needs neither PUBLIC_BASE_URL nor OAUTH_*. */
141
+ export function loadStorageConfig(env, options = {}) {
142
+ const result = storageEnvSchema.safeParse(env);
143
+ if (!result.success)
144
+ throw formatConfigError(result.error);
145
+ return storageFromRaw(result.data, env, options);
146
+ }
147
+ export function loadConfig(env = process.env, options = {}) {
148
+ const result = rawEnvSchema.safeParse(env);
149
+ if (!result.success)
150
+ throw formatConfigError(result.error);
151
+ const raw = result.data;
152
+ const publicBaseUrl = raw.PUBLIC_BASE_URL.replace(/\/+$/, "");
153
+ let auth;
154
+ if (raw.AUTH_MODE === "none") {
155
+ auth = { mode: "none" };
156
+ }
157
+ else {
158
+ if (raw.OAUTH_ISSUER === undefined) {
159
+ throw new Error("OAUTH_ISSUER is required when AUTH_MODE is oidc");
160
+ }
161
+ let oauthAudiences;
162
+ if (raw.OAUTH_AUDIENCE && raw.OAUTH_AUDIENCE.trim().length > 0) {
163
+ oauthAudiences = raw.OAUTH_AUDIENCE.split(",")
164
+ .map((s) => s.trim())
165
+ .filter((s) => s.length > 0);
166
+ }
167
+ else {
168
+ oauthAudiences = [`${publicBaseUrl}/mcp`];
169
+ }
170
+ const identityClaims = raw.OAUTH_IDENTITY_CLAIMS.split(",")
171
+ .map((s) => s.trim())
172
+ .filter((s) => s.length > 0);
173
+ const allowInsecureIssuer = raw.OAUTH_ALLOW_INSECURE_ISSUER === "true";
174
+ auth = {
175
+ mode: "oidc",
176
+ issuer: raw.OAUTH_ISSUER,
177
+ audiences: oauthAudiences,
178
+ ...(raw.OAUTH_JWKS_URI === undefined ? {} : { jwksUri: raw.OAUTH_JWKS_URI }),
179
+ identityClaims,
180
+ allowInsecureIssuer,
181
+ };
182
+ }
183
+ return {
184
+ ...storageFromRaw(raw, env, options),
185
+ port: raw.PORT,
186
+ host: raw.HOST,
187
+ publicBaseUrl,
188
+ auth,
189
+ scopeNames: {
190
+ read: raw.OAUTH_SCOPE_READ,
191
+ write: raw.OAUTH_SCOPE_WRITE,
192
+ admin: raw.OAUTH_SCOPE_ADMIN,
193
+ },
194
+ };
195
+ }
196
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,60 @@
1
+ import { readFileSync, unlinkSync } from "node:fs";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ function errorCode(err) {
5
+ return err?.code;
6
+ }
7
+ function isRunning(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (err) {
13
+ return errorCode(err) === "EPERM";
14
+ }
15
+ }
16
+ /**
17
+ * Takes the exclusive per-data-dir lock for a local CLI process; a lock left by a dead process is taken over.
18
+ * Returns a synchronous release, safe to call from `process.on("exit")`.
19
+ */
20
+ export async function lockDataDir(dataDir) {
21
+ await mkdir(dataDir, { recursive: true });
22
+ const lockPath = join(dataDir, "ok-fine.lock");
23
+ const pid = String(process.pid);
24
+ let holder = "unknown";
25
+ for (let attempt = 0; attempt < 2; attempt++) {
26
+ try {
27
+ await writeFile(lockPath, `${pid}\n`, { flag: "wx" });
28
+ return () => {
29
+ try {
30
+ if (readFileSync(lockPath, "utf8").trim() === pid)
31
+ unlinkSync(lockPath);
32
+ }
33
+ catch (err) {
34
+ if (errorCode(err) !== "ENOENT")
35
+ throw err;
36
+ }
37
+ };
38
+ }
39
+ catch (err) {
40
+ if (errorCode(err) !== "EEXIST")
41
+ throw err;
42
+ }
43
+ let content;
44
+ try {
45
+ content = (await readFile(lockPath, "utf8")).trim();
46
+ }
47
+ catch (err) {
48
+ if (errorCode(err) === "ENOENT")
49
+ continue;
50
+ throw err;
51
+ }
52
+ holder = content || "unknown";
53
+ const holderPid = Number.parseInt(content, 10);
54
+ if (Number.isInteger(holderPid) && holderPid > 0 && holderPid !== process.pid && isRunning(holderPid))
55
+ break;
56
+ await rm(lockPath, { force: true });
57
+ }
58
+ throw new Error(`ok-fine is already running on ${dataDir} (pid ${holder}). One process serves a data directory: connect more clients to \`ok-fine serve\` over HTTP, or pass a different --data-dir. If no ok-fine process is running, delete ${lockPath}.`);
59
+ }
60
+ //# sourceMappingURL=data-dir-lock.js.map
package/dist/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ export class OkfError extends Error {
2
+ code;
3
+ status;
4
+ details;
5
+ constructor(code, status, message, details) {
6
+ super(message);
7
+ this.name = "OkfError";
8
+ this.code = code;
9
+ this.status = status;
10
+ this.details = details;
11
+ }
12
+ }
13
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,32 @@
1
+ const DROPPED_HEADERS = {
2
+ host: true,
3
+ "content-length": true,
4
+ "transfer-encoding": true,
5
+ connection: true,
6
+ };
7
+ export function registerMcpRoute(app, mcpFetch, publicBaseUrl) {
8
+ app.route({
9
+ method: ["GET", "POST", "DELETE"],
10
+ url: "/mcp",
11
+ config: { permission: "read" },
12
+ handler: async (req, reply) => {
13
+ const headers = new Headers();
14
+ for (const [name, value] of Object.entries(req.headers)) {
15
+ if (value === undefined || DROPPED_HEADERS[name])
16
+ continue;
17
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
18
+ }
19
+ const request = new Request(new URL(req.url, publicBaseUrl), {
20
+ method: req.method,
21
+ headers,
22
+ body: req.method === "POST" ? JSON.stringify(req.body) : undefined,
23
+ });
24
+ const response = await mcpFetch(request, {
25
+ authInfo: req.authInfo ?? undefined,
26
+ parsedBody: req.method === "POST" ? req.body : undefined,
27
+ });
28
+ return reply.send(response);
29
+ },
30
+ });
31
+ }
32
+ //# sourceMappingURL=mcp-route.js.map
@@ -0,0 +1,243 @@
1
+ import { z } from "zod";
2
+ import { OkfError } from "../errors.js";
3
+ import { parseFrontmatter, splitFrontmatter } from "../okf/frontmatter.js";
4
+ const projectParams = z.object({ project: z.string() });
5
+ const wildcardParams = z.object({ project: z.string(), "*": z.string() });
6
+ function principalOf(req) {
7
+ if (!req.principal)
8
+ throw new OkfError("internal", 500, "request is not authenticated");
9
+ return req.principal;
10
+ }
11
+ function actorOf(req) {
12
+ const actor = req.headers["x-okf-actor"];
13
+ if (typeof actor !== "string" || actor.trim() === "") {
14
+ throw new OkfError("invalid_actor", 400, "X-OKF-Actor header is required");
15
+ }
16
+ return actor.trim();
17
+ }
18
+ /** Maps If-Match / If-None-Match to the service's expectedRevision (undefined = unconditional). */
19
+ function expectedRevisionOf(req) {
20
+ const ifMatch = req.headers["if-match"];
21
+ const ifNoneMatch = req.headers["if-none-match"];
22
+ if (ifMatch !== undefined && ifNoneMatch !== undefined) {
23
+ throw new OkfError("bad_request", 400, "send either If-Match or If-None-Match, not both");
24
+ }
25
+ if (ifNoneMatch !== undefined) {
26
+ if (ifNoneMatch.trim() !== "*")
27
+ throw new OkfError("bad_request", 400, "If-None-Match supports only *");
28
+ return null;
29
+ }
30
+ if (ifMatch !== undefined) {
31
+ return ifMatch
32
+ .trim()
33
+ .replace(/^W\//, "")
34
+ .replace(/^"(.*)"$/, "$1");
35
+ }
36
+ return undefined;
37
+ }
38
+ function withRevision(reply, result) {
39
+ if (typeof result.revision === "string")
40
+ reply.header("etag", `"${result.revision}"`);
41
+ return result;
42
+ }
43
+ function decodeText(body) {
44
+ let text;
45
+ if (typeof body === "string") {
46
+ text = body;
47
+ }
48
+ else if (Buffer.isBuffer(body)) {
49
+ try {
50
+ text = new TextDecoder("utf-8", { fatal: true }).decode(body);
51
+ }
52
+ catch {
53
+ throw new OkfError("unsupported_media", 415, "file content must be UTF-8 text");
54
+ }
55
+ }
56
+ else {
57
+ throw new OkfError("unsupported_media", 415, "send text/* or application/octet-stream content");
58
+ }
59
+ if (text.includes("\0"))
60
+ throw new OkfError("unsupported_media", 415, "file content must not contain NUL bytes");
61
+ return text;
62
+ }
63
+ /** For routes whose target must already exist: If-None-Match: * cannot be honored, so it is rejected. */
64
+ function existingRevisionOf(req) {
65
+ const expected = expectedRevisionOf(req);
66
+ if (expected === null) {
67
+ throw new OkfError("bad_request", 400, "If-None-Match is not supported on this route; use If-Match");
68
+ }
69
+ return expected;
70
+ }
71
+ const conceptJsonBody = z.object({
72
+ frontmatter: z.record(z.string(), z.unknown()),
73
+ body: z.string(),
74
+ message: z.string().optional(),
75
+ });
76
+ function conceptInput(req) {
77
+ if (typeof req.body === "string") {
78
+ const split = splitFrontmatter(req.body);
79
+ if (!split)
80
+ throw new OkfError("invalid_frontmatter", 400, "markdown must start with a --- delimited YAML frontmatter");
81
+ const parsed = parseFrontmatter(split.yaml);
82
+ if ("error" in parsed)
83
+ throw new OkfError("invalid_frontmatter", 400, `${parsed.error}: ${parsed.message}`);
84
+ return { frontmatter: parsed.data, body: split.body };
85
+ }
86
+ return conceptJsonBody.parse(req.body);
87
+ }
88
+ const CONTENT_TYPE_BY_EXTENSION = {
89
+ ".md": "text/markdown; charset=utf-8",
90
+ ".json": "application/json",
91
+ };
92
+ const searchQuery = z.object({
93
+ q: z.string().optional(),
94
+ project: z.string().optional(),
95
+ type: z.string().optional(),
96
+ tag: z.union([z.string(), z.array(z.string())]).optional(),
97
+ status: z.enum(["draft", "stable", "deprecated"]).optional(),
98
+ trustTier: z.enum(["unverified", "machine-confirmed", "human-reviewed"]).optional(),
99
+ stale: z.enum(["true", "false"]).optional(),
100
+ limit: z.coerce.number().int().min(1).max(100).optional(),
101
+ });
102
+ export function registerRestRoutes(app, service) {
103
+ const read = { permission: "read" };
104
+ const write = { permission: "write" };
105
+ const admin = { permission: "admin" };
106
+ app.get("/api/v1/projects", { config: read }, async (req) => {
107
+ const { repository } = z.object({ repository: z.string().optional() }).parse(req.query);
108
+ return service.listProjects({ repository });
109
+ });
110
+ app.post("/api/v1/projects", { config: write }, async (req, reply) => {
111
+ const body = z
112
+ .object({ project: z.string(), title: z.string(), description: z.string().optional() })
113
+ .parse(req.body);
114
+ const result = await service.createProject(principalOf(req), { ...body, actor: actorOf(req) });
115
+ return reply.code(201).send(result);
116
+ });
117
+ app.get("/api/v1/projects/:project", { config: read }, async (req) => {
118
+ const { project } = projectParams.parse(req.params);
119
+ return service.getProject(project);
120
+ });
121
+ app.delete("/api/v1/projects/:project", { config: admin }, async (req) => {
122
+ const { project } = projectParams.parse(req.params);
123
+ return service.deleteProject(principalOf(req), { project, actor: actorOf(req) });
124
+ });
125
+ app.get("/api/v1/projects/:project/index", { config: read }, async (req) => {
126
+ const { project } = projectParams.parse(req.params);
127
+ const { path } = z.object({ path: z.string().optional() }).parse(req.query);
128
+ return service.getIndex(project, path ?? "");
129
+ });
130
+ app.get("/api/v1/projects/:project/concepts/*", { config: read }, async (req, reply) => {
131
+ const { project, "*": id } = wildcardParams.parse(req.params);
132
+ const concept = withRevision(reply, await service.readConcept(project, id));
133
+ if ((req.headers.accept ?? "").includes("text/markdown")) {
134
+ return reply.type("text/markdown; charset=utf-8").send(concept.markdown);
135
+ }
136
+ return concept;
137
+ });
138
+ app.put("/api/v1/projects/:project/concepts/*", { config: write }, async (req, reply) => {
139
+ const { project, "*": id } = wildcardParams.parse(req.params);
140
+ const input = conceptInput(req);
141
+ const expectedRevision = expectedRevisionOf(req);
142
+ const result = await service.writeConcept(principalOf(req), {
143
+ project,
144
+ id,
145
+ ...input,
146
+ actor: actorOf(req),
147
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
148
+ });
149
+ return reply.code(result.created ? 201 : 200).send(withRevision(reply, result));
150
+ });
151
+ app.delete("/api/v1/projects/:project/concepts/*", { config: write }, async (req) => {
152
+ const { project, "*": id } = wildcardParams.parse(req.params);
153
+ const expectedRevision = existingRevisionOf(req);
154
+ return service.deleteConcept(principalOf(req), {
155
+ project,
156
+ id,
157
+ actor: actorOf(req),
158
+ ...(typeof expectedRevision === "string" ? { expectedRevision } : {}),
159
+ });
160
+ });
161
+ app.post("/api/v1/projects/:project/verifications", { config: write }, async (req, reply) => {
162
+ const { project } = projectParams.parse(req.params);
163
+ const { id } = z.object({ id: z.string() }).parse(req.body);
164
+ const expectedRevision = existingRevisionOf(req);
165
+ const result = await service.verifyConcept(principalOf(req), {
166
+ project,
167
+ id,
168
+ actor: actorOf(req),
169
+ ...(typeof expectedRevision === "string" ? { expectedRevision } : {}),
170
+ });
171
+ return reply.code(201).send(withRevision(reply, result));
172
+ });
173
+ app.get("/api/v1/projects/:project/files/*", { config: read }, async (req, reply) => {
174
+ const { project, "*": path } = wildcardParams.parse(req.params);
175
+ const file = withRevision(reply, await service.readFile(project, path));
176
+ const extension = /\.[^./]+$/.exec(path)?.[0].toLowerCase() ?? "";
177
+ return reply.type(CONTENT_TYPE_BY_EXTENSION[extension] ?? "text/plain; charset=utf-8").send(file.content);
178
+ });
179
+ app.put("/api/v1/projects/:project/files/*", { config: write }, async (req, reply) => {
180
+ const { project, "*": path } = wildcardParams.parse(req.params);
181
+ const expectedRevision = expectedRevisionOf(req);
182
+ const result = await service.writeFile(principalOf(req), {
183
+ project,
184
+ path,
185
+ content: decodeText(req.body),
186
+ actor: actorOf(req),
187
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
188
+ });
189
+ return withRevision(reply, result);
190
+ });
191
+ app.delete("/api/v1/projects/:project/files/*", { config: write }, async (req) => {
192
+ const { project, "*": path } = wildcardParams.parse(req.params);
193
+ const expectedRevision = existingRevisionOf(req);
194
+ return service.deleteFile(principalOf(req), {
195
+ project,
196
+ path,
197
+ actor: actorOf(req),
198
+ ...(typeof expectedRevision === "string" ? { expectedRevision } : {}),
199
+ });
200
+ });
201
+ app.get("/api/v1/projects/:project/history", { config: read }, async (req) => {
202
+ const { project } = projectParams.parse(req.params);
203
+ const { id, limit } = z
204
+ .object({ id: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).optional() })
205
+ .parse(req.query);
206
+ return service.history(project, id, limit);
207
+ });
208
+ app.get("/api/v1/projects/:project/lint", { config: read }, async (req) => {
209
+ const { project } = projectParams.parse(req.params);
210
+ return service.lint(project);
211
+ });
212
+ app.get("/api/v1/projects/:project/archive", { config: read }, async (req, reply) => {
213
+ const { project } = projectParams.parse(req.params);
214
+ const stream = await service.exportArchive(project);
215
+ return reply
216
+ .type("application/gzip")
217
+ .header("content-disposition", `attachment; filename="${project}.tar.gz"`)
218
+ .send(stream);
219
+ });
220
+ app.put("/api/v1/projects/:project/archive", { config: admin }, async (req) => {
221
+ const { project } = projectParams.parse(req.params);
222
+ if (!Buffer.isBuffer(req.body)) {
223
+ throw new OkfError("unsupported_media", 415, "send the archive as application/gzip");
224
+ }
225
+ return service.importArchive(principalOf(req), { project, actor: actorOf(req), archive: req.body });
226
+ });
227
+ app.get("/api/v1/search", { config: read }, async (req) => {
228
+ const q = searchQuery.parse(req.query);
229
+ return service.search({
230
+ ...(q.q === undefined ? {} : { query: q.q }),
231
+ ...(q.project === undefined ? {} : { project: q.project }),
232
+ ...(q.type === undefined ? {} : { type: q.type }),
233
+ ...(q.tag === undefined ? {} : { tags: Array.isArray(q.tag) ? q.tag : [q.tag] }),
234
+ ...(q.status === undefined ? {} : { status: q.status }),
235
+ ...(q.trustTier === undefined ? {} : { trustTier: q.trustTier }),
236
+ ...(q.stale === undefined ? {} : { stale: q.stale === "true" }),
237
+ ...(q.limit === undefined ? {} : { limit: q.limit }),
238
+ });
239
+ });
240
+ app.get("/api/v1/sync", { config: read }, async () => service.syncStatus());
241
+ app.post("/api/v1/sync", { config: admin }, async () => service.syncNow());
242
+ }
243
+ //# sourceMappingURL=rest.js.map