@sourcepress/server 0.1.0
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/.omc/state/agent-replay-31d84b63-606a-4368-b9e6-93fe4f5ae0f7.jsonl +2 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/.omc/state/mission-state.json +53 -0
- package/.omc/state/subagent-tracking.json +17 -0
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +26 -0
- package/dist/__tests__/app-integration.test.d.ts +2 -0
- package/dist/__tests__/app-integration.test.d.ts.map +1 -0
- package/dist/__tests__/app-integration.test.js +71 -0
- package/dist/__tests__/app-integration.test.js.map +1 -0
- package/dist/__tests__/approval.test.d.ts +2 -0
- package/dist/__tests__/approval.test.d.ts.map +1 -0
- package/dist/__tests__/approval.test.js +170 -0
- package/dist/__tests__/approval.test.js.map +1 -0
- package/dist/__tests__/content.test.d.ts +2 -0
- package/dist/__tests__/content.test.d.ts.map +1 -0
- package/dist/__tests__/content.test.js +187 -0
- package/dist/__tests__/content.test.js.map +1 -0
- package/dist/__tests__/engine.test.d.ts +2 -0
- package/dist/__tests__/engine.test.d.ts.map +1 -0
- package/dist/__tests__/engine.test.js +77 -0
- package/dist/__tests__/engine.test.js.map +1 -0
- package/dist/__tests__/eval.test.d.ts +2 -0
- package/dist/__tests__/eval.test.d.ts.map +1 -0
- package/dist/__tests__/eval.test.js +320 -0
- package/dist/__tests__/eval.test.js.map +1 -0
- package/dist/__tests__/graph.test.d.ts +2 -0
- package/dist/__tests__/graph.test.d.ts.map +1 -0
- package/dist/__tests__/graph.test.js +169 -0
- package/dist/__tests__/graph.test.js.map +1 -0
- package/dist/__tests__/health.test.d.ts +2 -0
- package/dist/__tests__/health.test.d.ts.map +1 -0
- package/dist/__tests__/health.test.js +56 -0
- package/dist/__tests__/health.test.js.map +1 -0
- package/dist/__tests__/import.test.d.ts +2 -0
- package/dist/__tests__/import.test.d.ts.map +1 -0
- package/dist/__tests__/import.test.js +138 -0
- package/dist/__tests__/import.test.js.map +1 -0
- package/dist/__tests__/intent.test.d.ts +2 -0
- package/dist/__tests__/intent.test.d.ts.map +1 -0
- package/dist/__tests__/intent.test.js +122 -0
- package/dist/__tests__/intent.test.js.map +1 -0
- package/dist/__tests__/jobs.test.d.ts +2 -0
- package/dist/__tests__/jobs.test.d.ts.map +1 -0
- package/dist/__tests__/jobs.test.js +96 -0
- package/dist/__tests__/jobs.test.js.map +1 -0
- package/dist/__tests__/knowledge.test.d.ts +2 -0
- package/dist/__tests__/knowledge.test.d.ts.map +1 -0
- package/dist/__tests__/knowledge.test.js +110 -0
- package/dist/__tests__/knowledge.test.js.map +1 -0
- package/dist/__tests__/media-routes.test.d.ts +2 -0
- package/dist/__tests__/media-routes.test.d.ts.map +1 -0
- package/dist/__tests__/media-routes.test.js +88 -0
- package/dist/__tests__/media-routes.test.js.map +1 -0
- package/dist/__tests__/schema.test.d.ts +2 -0
- package/dist/__tests__/schema.test.d.ts.map +1 -0
- package/dist/__tests__/schema.test.js +92 -0
- package/dist/__tests__/schema.test.js.map +1 -0
- package/dist/app.d.ts +7 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +85 -0
- package/dist/app.js.map +1 -0
- package/dist/engine.d.ts +38 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +106 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +17 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +54 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.d.ts.map +1 -0
- package/dist/middleware/cors.js +13 -0
- package/dist/middleware/cors.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +24 -0
- package/dist/middleware/error-handler.d.ts.map +1 -0
- package/dist/middleware/error-handler.js +30 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/index.d.ts +7 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +5 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/rate-limit.d.ts +11 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +26 -0
- package/dist/middleware/rate-limit.js.map +1 -0
- package/dist/middleware/route-error-handler.d.ts +12 -0
- package/dist/middleware/route-error-handler.d.ts.map +1 -0
- package/dist/middleware/route-error-handler.js +9 -0
- package/dist/middleware/route-error-handler.js.map +1 -0
- package/dist/routes/approval.d.ts +4 -0
- package/dist/routes/approval.d.ts.map +1 -0
- package/dist/routes/approval.js +70 -0
- package/dist/routes/approval.js.map +1 -0
- package/dist/routes/content.d.ts +4 -0
- package/dist/routes/content.d.ts.map +1 -0
- package/dist/routes/content.js +145 -0
- package/dist/routes/content.js.map +1 -0
- package/dist/routes/eval.d.ts +4 -0
- package/dist/routes/eval.d.ts.map +1 -0
- package/dist/routes/eval.js +178 -0
- package/dist/routes/eval.js.map +1 -0
- package/dist/routes/graph.d.ts +4 -0
- package/dist/routes/graph.d.ts.map +1 -0
- package/dist/routes/graph.js +90 -0
- package/dist/routes/graph.js.map +1 -0
- package/dist/routes/health.d.ts +13 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +19 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/import.d.ts +4 -0
- package/dist/routes/import.d.ts.map +1 -0
- package/dist/routes/import.js +85 -0
- package/dist/routes/import.js.map +1 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.d.ts.map +1 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/intent.d.ts +4 -0
- package/dist/routes/intent.d.ts.map +1 -0
- package/dist/routes/intent.js +80 -0
- package/dist/routes/intent.js.map +1 -0
- package/dist/routes/jobs.d.ts +4 -0
- package/dist/routes/jobs.d.ts.map +1 -0
- package/dist/routes/jobs.js +67 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/knowledge.d.ts +4 -0
- package/dist/routes/knowledge.d.ts.map +1 -0
- package/dist/routes/knowledge.js +48 -0
- package/dist/routes/knowledge.js.map +1 -0
- package/dist/routes/media.d.ts +4 -0
- package/dist/routes/media.d.ts.map +1 -0
- package/dist/routes/media.js +87 -0
- package/dist/routes/media.js.map +1 -0
- package/dist/routes/schema.d.ts +4 -0
- package/dist/routes/schema.d.ts.map +1 -0
- package/dist/routes/schema.js +54 -0
- package/dist/routes/schema.js.map +1 -0
- package/dist/standalone.d.ts +2 -0
- package/dist/standalone.d.ts.map +1 -0
- package/dist/standalone.js +115 -0
- package/dist/standalone.js.map +1 -0
- package/package.json +36 -0
- package/src/__tests__/app-integration.test.ts +80 -0
- package/src/__tests__/approval.test.ts +195 -0
- package/src/__tests__/content.test.ts +202 -0
- package/src/__tests__/engine.test.ts +86 -0
- package/src/__tests__/eval.test.ts +343 -0
- package/src/__tests__/graph.test.ts +182 -0
- package/src/__tests__/health.test.ts +68 -0
- package/src/__tests__/import.test.ts +148 -0
- package/src/__tests__/intent.test.ts +133 -0
- package/src/__tests__/jobs.test.ts +107 -0
- package/src/__tests__/knowledge.test.ts +121 -0
- package/src/__tests__/media-routes.test.ts +109 -0
- package/src/__tests__/schema.test.ts +100 -0
- package/src/app.ts +92 -0
- package/src/engine.ts +168 -0
- package/src/index.ts +31 -0
- package/src/middleware/auth.ts +66 -0
- package/src/middleware/cors.ts +15 -0
- package/src/middleware/error-handler.ts +42 -0
- package/src/middleware/index.ts +6 -0
- package/src/middleware/rate-limit.ts +27 -0
- package/src/middleware/route-error-handler.ts +13 -0
- package/src/routes/approval.ts +90 -0
- package/src/routes/content.ts +256 -0
- package/src/routes/eval.ts +262 -0
- package/src/routes/graph.ts +111 -0
- package/src/routes/health.ts +33 -0
- package/src/routes/import.ts +122 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/intent.ts +105 -0
- package/src/routes/jobs.ts +84 -0
- package/src/routes/knowledge.ts +73 -0
- package/src/routes/media.ts +117 -0
- package/src/routes/schema.ts +75 -0
- package/src/standalone.ts +130 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { KnowledgeFile } from "@sourcepress/core";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { EngineContext } from "../engine.js";
|
|
5
|
+
import { errorHandler } from "../middleware/error-handler.js";
|
|
6
|
+
import { knowledgeRoutes } from "../routes/knowledge.js";
|
|
7
|
+
|
|
8
|
+
const mockKnowledgeFiles: KnowledgeFile[] = [
|
|
9
|
+
{
|
|
10
|
+
path: "knowledge/clients/acme.md",
|
|
11
|
+
type: "project-notes",
|
|
12
|
+
quality: "structured",
|
|
13
|
+
quality_score: 8,
|
|
14
|
+
entities: [{ type: "client", name: "Acme Corp" }],
|
|
15
|
+
ingested_at: "2026-04-01T00:00:00Z",
|
|
16
|
+
source: "manual",
|
|
17
|
+
body: "Meeting notes with Acme Corp...",
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function createMockEngine(): EngineContext {
|
|
22
|
+
return {
|
|
23
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
24
|
+
config: {} as any,
|
|
25
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
26
|
+
github: {} as any,
|
|
27
|
+
knowledge: {
|
|
28
|
+
ingest: vi.fn().mockResolvedValue(mockKnowledgeFiles[0]),
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
30
|
+
} as any,
|
|
31
|
+
knowledgeStore: {
|
|
32
|
+
list: vi.fn().mockResolvedValue(mockKnowledgeFiles),
|
|
33
|
+
retrieve: vi.fn().mockImplementation(async (path: string) => {
|
|
34
|
+
return mockKnowledgeFiles.find((f) => f.path === path) ?? null;
|
|
35
|
+
}),
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
37
|
+
} as any,
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
39
|
+
cache: {} as any,
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
41
|
+
budget: {} as any,
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
43
|
+
provider: {} as any,
|
|
44
|
+
listContent: vi.fn().mockResolvedValue([]),
|
|
45
|
+
getContent: vi.fn().mockResolvedValue(null),
|
|
46
|
+
getCollectionDef: vi.fn().mockReturnValue(null),
|
|
47
|
+
listCollections: vi.fn().mockReturnValue([]),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createTestApp(engine: EngineContext) {
|
|
52
|
+
const app = new Hono().basePath("/api");
|
|
53
|
+
app.use("*", errorHandler());
|
|
54
|
+
app.route("/", knowledgeRoutes(engine));
|
|
55
|
+
return app;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("Knowledge routes", () => {
|
|
59
|
+
let engine: EngineContext;
|
|
60
|
+
let app: Hono;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
engine = createMockEngine();
|
|
64
|
+
app = createTestApp(engine);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("GET /api/knowledge", () => {
|
|
68
|
+
it("returns list of knowledge files", async () => {
|
|
69
|
+
const res = await app.request("/api/knowledge");
|
|
70
|
+
expect(res.status).toBe(200);
|
|
71
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
72
|
+
const body = (await res.json()) as any;
|
|
73
|
+
expect(body.total).toBe(1);
|
|
74
|
+
expect(body.items[0].path).toBe("knowledge/clients/acme.md");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("GET /api/knowledge/:path", () => {
|
|
79
|
+
it("returns a specific knowledge file", async () => {
|
|
80
|
+
const res = await app.request("/api/knowledge/knowledge/clients/acme.md");
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
83
|
+
const body = (await res.json()) as any;
|
|
84
|
+
expect(body.path).toBe("knowledge/clients/acme.md");
|
|
85
|
+
expect(body.body).toContain("Acme Corp");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns 404 for unknown path", async () => {
|
|
89
|
+
const res = await app.request("/api/knowledge/unknown.md");
|
|
90
|
+
expect(res.status).toBe(404);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("POST /api/knowledge", () => {
|
|
95
|
+
it("ingests knowledge and returns result", async () => {
|
|
96
|
+
const res = await app.request("/api/knowledge", {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
path: "knowledge/clients/acme.md",
|
|
101
|
+
body: "Meeting notes with Acme Corp...",
|
|
102
|
+
source: "manual",
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
expect(res.status).toBe(201);
|
|
106
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
107
|
+
const body = (await res.json()) as any;
|
|
108
|
+
expect(body.ingested).toBe(true);
|
|
109
|
+
expect(body.quality).toBe("structured");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns 400 for missing required fields", async () => {
|
|
113
|
+
const res = await app.request("/api/knowledge", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
body: JSON.stringify({ path: "test.md" }),
|
|
117
|
+
});
|
|
118
|
+
expect(res.status).toBe(400);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { MediaRef } from "@sourcepress/core";
|
|
2
|
+
import type { MediaStorage } from "@sourcepress/media";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { EngineContext } from "../engine.js";
|
|
6
|
+
import { mediaRoutes } from "../routes/media.js";
|
|
7
|
+
|
|
8
|
+
const sampleRef: MediaRef = {
|
|
9
|
+
path: "assets/hero.webp",
|
|
10
|
+
content_type: "image/webp",
|
|
11
|
+
size_bytes: 50000,
|
|
12
|
+
hash: "abc123",
|
|
13
|
+
source: "uploaded",
|
|
14
|
+
uploaded_at: "2026-04-04T10:00:00Z",
|
|
15
|
+
uploaded_by: "test-user",
|
|
16
|
+
alt: "Hero image",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function createMockMedia(): MediaStorage {
|
|
20
|
+
return {
|
|
21
|
+
upload: vi.fn().mockResolvedValue(sampleRef),
|
|
22
|
+
get: vi.fn().mockResolvedValue(Buffer.from("data")),
|
|
23
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
list: vi.fn().mockResolvedValue([sampleRef]),
|
|
25
|
+
getMeta: vi.fn().mockResolvedValue(sampleRef),
|
|
26
|
+
updateMeta: vi.fn().mockResolvedValue({ ...sampleRef, alt: "Updated" }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createApp(media?: MediaStorage) {
|
|
31
|
+
const engine = { media } as EngineContext;
|
|
32
|
+
const app = new Hono().basePath("/api");
|
|
33
|
+
app.route("/", mediaRoutes(engine));
|
|
34
|
+
return app;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("media routes", () => {
|
|
38
|
+
it("GET /api/media — lists media", async () => {
|
|
39
|
+
const media = createMockMedia();
|
|
40
|
+
const app = createApp(media);
|
|
41
|
+
|
|
42
|
+
const res = await app.request("/api/media");
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
46
|
+
const body = (await res.json()) as any;
|
|
47
|
+
expect(body.items).toHaveLength(1);
|
|
48
|
+
expect(body.items[0].path).toBe("assets/hero.webp");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("GET /api/media/:path — returns metadata", async () => {
|
|
52
|
+
const media = createMockMedia();
|
|
53
|
+
const app = createApp(media);
|
|
54
|
+
|
|
55
|
+
const res = await app.request("/api/media/assets/hero.webp");
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
|
|
58
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
59
|
+
const body = (await res.json()) as any;
|
|
60
|
+
expect(body.path).toBe("assets/hero.webp");
|
|
61
|
+
expect(body.alt).toBe("Hero image");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("GET /api/media/:path — 404 for missing file", async () => {
|
|
65
|
+
const media = createMockMedia();
|
|
66
|
+
(media.getMeta as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
|
67
|
+
const app = createApp(media);
|
|
68
|
+
|
|
69
|
+
const res = await app.request("/api/media/assets/missing.webp");
|
|
70
|
+
expect(res.status).toBe(404);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("PUT /api/media/:path — updates metadata", async () => {
|
|
74
|
+
const media = createMockMedia();
|
|
75
|
+
const app = createApp(media);
|
|
76
|
+
|
|
77
|
+
const res = await app.request("/api/media/assets/hero.webp", {
|
|
78
|
+
method: "PUT",
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify({ alt: "Updated" }),
|
|
81
|
+
});
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
|
|
84
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
85
|
+
const body = (await res.json()) as any;
|
|
86
|
+
expect(body.alt).toBe("Updated");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("DELETE /api/media/:path — deletes file", async () => {
|
|
90
|
+
const media = createMockMedia();
|
|
91
|
+
const app = createApp(media);
|
|
92
|
+
|
|
93
|
+
const res = await app.request("/api/media/assets/hero.webp", {
|
|
94
|
+
method: "DELETE",
|
|
95
|
+
});
|
|
96
|
+
expect(res.status).toBe(200);
|
|
97
|
+
|
|
98
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
99
|
+
const body = (await res.json()) as any;
|
|
100
|
+
expect(body.deleted).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns 501 when media is not configured", async () => {
|
|
104
|
+
const app = createApp(undefined);
|
|
105
|
+
|
|
106
|
+
const res = await app.request("/api/media");
|
|
107
|
+
expect(res.status).toBe(501);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { EngineContext } from "../engine.js";
|
|
4
|
+
import { errorHandler } from "../middleware/error-handler.js";
|
|
5
|
+
import { schemaRoutes } from "../routes/schema.js";
|
|
6
|
+
|
|
7
|
+
const mockCollections = {
|
|
8
|
+
posts: {
|
|
9
|
+
name: "Blog Posts",
|
|
10
|
+
path: "content/posts",
|
|
11
|
+
format: "mdx" as const,
|
|
12
|
+
fields: {
|
|
13
|
+
title: { type: "string" as const, required: true },
|
|
14
|
+
draft: { type: "boolean" as const, default: true },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
team: {
|
|
18
|
+
name: "Team",
|
|
19
|
+
path: "content/team",
|
|
20
|
+
format: "yaml" as const,
|
|
21
|
+
fields: {
|
|
22
|
+
name: { type: "string" as const, required: true },
|
|
23
|
+
role: { type: "string" as const },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function createMockEngine(): EngineContext {
|
|
29
|
+
return {
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
31
|
+
config: {} as any,
|
|
32
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
33
|
+
github: {} as any,
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
35
|
+
knowledge: {} as any,
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
37
|
+
knowledgeStore: {} as any,
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
39
|
+
cache: {} as any,
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
41
|
+
budget: {} as any,
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
|
|
43
|
+
provider: {} as any,
|
|
44
|
+
listContent: vi.fn().mockResolvedValue([]),
|
|
45
|
+
getContent: vi.fn().mockResolvedValue(null),
|
|
46
|
+
getCollectionDef: vi.fn().mockImplementation((name: string) => {
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: index access on typed const object
|
|
48
|
+
return (mockCollections as any)[name] ?? null;
|
|
49
|
+
}),
|
|
50
|
+
listCollections: vi.fn().mockReturnValue(["posts", "team"]),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createTestApp(engine: EngineContext) {
|
|
55
|
+
const app = new Hono().basePath("/api");
|
|
56
|
+
app.use("*", errorHandler());
|
|
57
|
+
app.route("/", schemaRoutes(engine));
|
|
58
|
+
return app;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("Schema routes", () => {
|
|
62
|
+
let engine: EngineContext;
|
|
63
|
+
let app: Hono;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
engine = createMockEngine();
|
|
67
|
+
app = createTestApp(engine);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("GET /api/schema", () => {
|
|
71
|
+
it("returns all collection schemas", async () => {
|
|
72
|
+
const res = await app.request("/api/schema");
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
75
|
+
const body = (await res.json()) as any;
|
|
76
|
+
expect(body.total).toBe(2);
|
|
77
|
+
expect(body.collections.posts.name).toBe("Blog Posts");
|
|
78
|
+
expect(body.collections.team.name).toBe("Team");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("GET /api/schema/:collection", () => {
|
|
83
|
+
it("returns schema for a valid collection", async () => {
|
|
84
|
+
const res = await app.request("/api/schema/posts");
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
87
|
+
const body = (await res.json()) as any;
|
|
88
|
+
expect(body.name).toBe("Blog Posts");
|
|
89
|
+
expect(body.fields.title.type).toBe("string");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns 404 for unknown collection", async () => {
|
|
93
|
+
const res = await app.request("/api/schema/unknown");
|
|
94
|
+
expect(res.status).toBe(404);
|
|
95
|
+
// biome-ignore lint/suspicious/noExplicitAny: typed JSON response parsing in tests
|
|
96
|
+
const body = (await res.json()) as any;
|
|
97
|
+
expect(body.code).toBe("COLLECTION_NOT_FOUND");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
3
|
+
import type { EngineContext } from "./engine.js";
|
|
4
|
+
import { authMiddleware, corsMiddleware, errorHandler, requireAuth } from "./middleware/index.js";
|
|
5
|
+
import { aiRateLimit, generalRateLimit } from "./middleware/rate-limit.js";
|
|
6
|
+
import { approvalRoutes } from "./routes/approval.js";
|
|
7
|
+
import { contentRoutes } from "./routes/content.js";
|
|
8
|
+
import { evalRoutes } from "./routes/eval.js";
|
|
9
|
+
import { graphRoutes } from "./routes/graph.js";
|
|
10
|
+
import { healthRoutes } from "./routes/health.js";
|
|
11
|
+
import { importRoutes } from "./routes/import.js";
|
|
12
|
+
import { intentRoutes } from "./routes/intent.js";
|
|
13
|
+
import { jobRoutes } from "./routes/jobs.js";
|
|
14
|
+
import { knowledgeRoutes } from "./routes/knowledge.js";
|
|
15
|
+
import { mediaRoutes } from "./routes/media.js";
|
|
16
|
+
import { schemaRoutes } from "./routes/schema.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a fully wired SourcePress API app.
|
|
20
|
+
* If no engine is provided, only health endpoint is available.
|
|
21
|
+
*/
|
|
22
|
+
export function createApp(engine?: EngineContext, apiKeys?: string[]) {
|
|
23
|
+
const app = new Hono().basePath("/api");
|
|
24
|
+
|
|
25
|
+
// Global middleware
|
|
26
|
+
app.use("*", secureHeaders());
|
|
27
|
+
app.use("*", corsMiddleware());
|
|
28
|
+
app.use("*", errorHandler());
|
|
29
|
+
app.use("*", authMiddleware(apiKeys));
|
|
30
|
+
app.use("*", generalRateLimit());
|
|
31
|
+
|
|
32
|
+
// Tighter rate limits for AI-consuming endpoints
|
|
33
|
+
app.use("/eval/*", aiRateLimit());
|
|
34
|
+
app.use("/knowledge", aiRateLimit());
|
|
35
|
+
app.use("/knowledge/import/*", aiRateLimit());
|
|
36
|
+
app.use("/knowledge/import", aiRateLimit());
|
|
37
|
+
app.use("/graph/rebuild", aiRateLimit());
|
|
38
|
+
|
|
39
|
+
// Protect all write endpoints
|
|
40
|
+
app.use("/content/*", async (c, next) => {
|
|
41
|
+
if (c.req.method === "POST" || c.req.method === "PUT" || c.req.method === "DELETE") {
|
|
42
|
+
return requireAuth()(c, next);
|
|
43
|
+
}
|
|
44
|
+
await next();
|
|
45
|
+
});
|
|
46
|
+
app.use("/knowledge", async (c, next) => {
|
|
47
|
+
if (c.req.method === "POST") {
|
|
48
|
+
return requireAuth()(c, next);
|
|
49
|
+
}
|
|
50
|
+
await next();
|
|
51
|
+
});
|
|
52
|
+
app.use("/knowledge/import/*", requireAuth());
|
|
53
|
+
app.use("/knowledge/import", requireAuth());
|
|
54
|
+
app.use("/graph/rebuild", requireAuth());
|
|
55
|
+
app.use("/eval/*", async (c, next) => {
|
|
56
|
+
if (c.req.method === "POST" || c.req.method === "PUT") {
|
|
57
|
+
return requireAuth()(c, next);
|
|
58
|
+
}
|
|
59
|
+
await next();
|
|
60
|
+
});
|
|
61
|
+
app.use("/approval/*", async (c, next) => {
|
|
62
|
+
if (c.req.method === "POST" || c.req.method === "PUT" || c.req.method === "DELETE") {
|
|
63
|
+
return requireAuth()(c, next);
|
|
64
|
+
}
|
|
65
|
+
await next();
|
|
66
|
+
});
|
|
67
|
+
app.use("/media/*", async (c, next) => {
|
|
68
|
+
if (c.req.method === "POST" || c.req.method === "PUT" || c.req.method === "DELETE") {
|
|
69
|
+
return requireAuth()(c, next);
|
|
70
|
+
}
|
|
71
|
+
await next();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Health is always available
|
|
75
|
+
app.route("/", healthRoutes());
|
|
76
|
+
|
|
77
|
+
// Engine-dependent routes
|
|
78
|
+
if (engine) {
|
|
79
|
+
app.route("/", contentRoutes(engine));
|
|
80
|
+
app.route("/", knowledgeRoutes(engine));
|
|
81
|
+
app.route("/", graphRoutes(engine));
|
|
82
|
+
app.route("/", schemaRoutes(engine));
|
|
83
|
+
app.route("/", intentRoutes(engine));
|
|
84
|
+
app.route("/", evalRoutes(engine));
|
|
85
|
+
app.route("/", mediaRoutes(engine));
|
|
86
|
+
app.route("/", jobRoutes(engine));
|
|
87
|
+
app.route("/", importRoutes(engine));
|
|
88
|
+
app.route("/", approvalRoutes(engine));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return app;
|
|
92
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { BudgetTracker, resolveProvider } from "@sourcepress/ai";
|
|
3
|
+
import type { ResolvedProvider } from "@sourcepress/ai";
|
|
4
|
+
import { MemoryCache } from "@sourcepress/cache";
|
|
5
|
+
import type { CacheProvider } from "@sourcepress/cache";
|
|
6
|
+
import type {
|
|
7
|
+
ApprovalProvider,
|
|
8
|
+
CollectionDefinition,
|
|
9
|
+
ContentFile,
|
|
10
|
+
SourcePressConfig,
|
|
11
|
+
} from "@sourcepress/core";
|
|
12
|
+
import {
|
|
13
|
+
GitHubClient,
|
|
14
|
+
GitHubPRApprovalProvider,
|
|
15
|
+
getContentFile,
|
|
16
|
+
listContentFiles,
|
|
17
|
+
listKnowledgeFiles,
|
|
18
|
+
} from "@sourcepress/github";
|
|
19
|
+
import { InProcessJobProvider } from "@sourcepress/jobs";
|
|
20
|
+
import type { JobProvider } from "@sourcepress/jobs";
|
|
21
|
+
import { InMemoryKnowledgeStore, JsonFileStore, KnowledgeEngine } from "@sourcepress/knowledge";
|
|
22
|
+
import type { KnowledgeGraph, KnowledgeStoreBackend } from "@sourcepress/knowledge";
|
|
23
|
+
import { GitMediaStorage } from "@sourcepress/media";
|
|
24
|
+
import type { MediaStorage } from "@sourcepress/media";
|
|
25
|
+
|
|
26
|
+
export interface EngineContext {
|
|
27
|
+
config: SourcePressConfig;
|
|
28
|
+
github: GitHubClient;
|
|
29
|
+
knowledge: KnowledgeEngine;
|
|
30
|
+
knowledgeStore: KnowledgeStoreBackend;
|
|
31
|
+
cache: CacheProvider;
|
|
32
|
+
budget: BudgetTracker;
|
|
33
|
+
provider: ResolvedProvider;
|
|
34
|
+
media?: MediaStorage;
|
|
35
|
+
jobs?: JobProvider;
|
|
36
|
+
approval?: ApprovalProvider;
|
|
37
|
+
|
|
38
|
+
// Convenience methods that combine packages
|
|
39
|
+
listContent(collection: string): Promise<ContentFile[]>;
|
|
40
|
+
getContent(collection: string, slug: string): Promise<ContentFile | null>;
|
|
41
|
+
getCollectionDef(collection: string): CollectionDefinition | null;
|
|
42
|
+
listCollections(): string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveApprovalRule(
|
|
46
|
+
config: SourcePressConfig,
|
|
47
|
+
collection: string,
|
|
48
|
+
path: string,
|
|
49
|
+
): "pr" | "direct" {
|
|
50
|
+
if (!config.approval?.rules) return "direct";
|
|
51
|
+
for (const [pattern, rule] of Object.entries(config.approval.rules)) {
|
|
52
|
+
if (path.startsWith(pattern.replace("/*", "/")) || path === pattern) {
|
|
53
|
+
return rule;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return "direct";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CreateEngineOptions {
|
|
60
|
+
config: SourcePressConfig;
|
|
61
|
+
githubToken: string;
|
|
62
|
+
aiApiKey?: string;
|
|
63
|
+
apiKeys?: string[];
|
|
64
|
+
knowledgeStore?: KnowledgeStoreBackend;
|
|
65
|
+
cache?: CacheProvider;
|
|
66
|
+
media?: MediaStorage;
|
|
67
|
+
jobs?: JobProvider;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function createEngine(options: CreateEngineOptions): Promise<EngineContext> {
|
|
71
|
+
const { config, githubToken, aiApiKey } = options;
|
|
72
|
+
|
|
73
|
+
// GitHub client
|
|
74
|
+
const github = new GitHubClient({
|
|
75
|
+
owner: config.repository.owner,
|
|
76
|
+
repo: config.repository.repo,
|
|
77
|
+
branch: config.repository.branch,
|
|
78
|
+
token: githubToken,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// AI provider + budget
|
|
82
|
+
const provider = resolveProvider({
|
|
83
|
+
provider: config.ai.provider,
|
|
84
|
+
model: config.ai.model,
|
|
85
|
+
apiKey: aiApiKey,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const budget = new BudgetTracker({
|
|
89
|
+
daily_limit_usd: config.ai.daily_limit_usd ?? 5.0,
|
|
90
|
+
warn_at_usd: config.ai.warn_at_usd ?? 3.0,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Knowledge store + engine
|
|
94
|
+
const knowledgeStore =
|
|
95
|
+
options.knowledgeStore ??
|
|
96
|
+
(config.knowledge.graph.backend === "local"
|
|
97
|
+
? new JsonFileStore(join(process.cwd(), ".sourcepress"))
|
|
98
|
+
: new InMemoryKnowledgeStore());
|
|
99
|
+
const knowledge = new KnowledgeEngine(knowledgeStore, provider, budget);
|
|
100
|
+
await knowledge.initialize();
|
|
101
|
+
|
|
102
|
+
// Cache
|
|
103
|
+
const cache = options.cache ?? new MemoryCache();
|
|
104
|
+
|
|
105
|
+
// Media storage (optional — only if configured)
|
|
106
|
+
let media: MediaStorage | undefined;
|
|
107
|
+
if (config.media) {
|
|
108
|
+
media = options.media ?? new GitMediaStorage(github, config.media);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Job provider (always available, default is in-process)
|
|
112
|
+
const jobs: JobProvider = options.jobs ?? new InProcessJobProvider();
|
|
113
|
+
|
|
114
|
+
// Approval provider (optional — only if configured)
|
|
115
|
+
let approval: ApprovalProvider | undefined;
|
|
116
|
+
if (config.approval?.provider === "github-pr") {
|
|
117
|
+
approval = new GitHubPRApprovalProvider(github);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Convenience methods
|
|
121
|
+
async function listContent(collection: string): Promise<ContentFile[]> {
|
|
122
|
+
const collDef = config.collections[collection];
|
|
123
|
+
if (!collDef) return [];
|
|
124
|
+
const paths = await listContentFiles(github, collDef.path);
|
|
125
|
+
const files: ContentFile[] = [];
|
|
126
|
+
for (const path of paths) {
|
|
127
|
+
const file = await getContentFile(github, path, collection);
|
|
128
|
+
if (file) files.push(file);
|
|
129
|
+
}
|
|
130
|
+
return files;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function getContent(collection: string, slug: string): Promise<ContentFile | null> {
|
|
134
|
+
const collDef = config.collections[collection];
|
|
135
|
+
if (!collDef) return null;
|
|
136
|
+
if (!/^[a-z0-9-]+$/.test(slug)) return null;
|
|
137
|
+
const ext =
|
|
138
|
+
collDef.format === "yaml" ? "yaml" : collDef.format === "json" ? "json" : collDef.format;
|
|
139
|
+
const basePath = collDef.path.replace(/\/+$/, "");
|
|
140
|
+
const path = `${basePath}/${slug}.${ext}`;
|
|
141
|
+
return getContentFile(github, path, collection);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getCollectionDef(collection: string): CollectionDefinition | null {
|
|
145
|
+
return config.collections[collection] ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function listCollections(): string[] {
|
|
149
|
+
return Object.keys(config.collections);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
config,
|
|
154
|
+
github,
|
|
155
|
+
knowledge,
|
|
156
|
+
knowledgeStore,
|
|
157
|
+
cache,
|
|
158
|
+
budget,
|
|
159
|
+
provider,
|
|
160
|
+
media,
|
|
161
|
+
jobs,
|
|
162
|
+
approval,
|
|
163
|
+
listContent,
|
|
164
|
+
getContent,
|
|
165
|
+
getCollectionDef,
|
|
166
|
+
listCollections,
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// App factory
|
|
2
|
+
export { createApp } from "./app.js";
|
|
3
|
+
|
|
4
|
+
// Engine
|
|
5
|
+
export { createEngine, resolveApprovalRule } from "./engine.js";
|
|
6
|
+
export type { EngineContext, CreateEngineOptions } from "./engine.js";
|
|
7
|
+
|
|
8
|
+
// Routes
|
|
9
|
+
export {
|
|
10
|
+
approvalRoutes,
|
|
11
|
+
healthRoutes,
|
|
12
|
+
contentRoutes,
|
|
13
|
+
knowledgeRoutes,
|
|
14
|
+
graphRoutes,
|
|
15
|
+
schemaRoutes,
|
|
16
|
+
intentRoutes,
|
|
17
|
+
evalRoutes,
|
|
18
|
+
mediaRoutes,
|
|
19
|
+
jobRoutes,
|
|
20
|
+
importRoutes,
|
|
21
|
+
} from "./routes/index.js";
|
|
22
|
+
export type { HealthStatus } from "./routes/health.js";
|
|
23
|
+
|
|
24
|
+
// Middleware
|
|
25
|
+
export {
|
|
26
|
+
corsMiddleware,
|
|
27
|
+
errorHandler,
|
|
28
|
+
authMiddleware,
|
|
29
|
+
SourcePressError,
|
|
30
|
+
} from "./middleware/index.js";
|
|
31
|
+
export type { ApiError, AuthContext } from "./middleware/index.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { Context, Next } from "hono";
|
|
3
|
+
|
|
4
|
+
export interface AuthContext {
|
|
5
|
+
authenticated: boolean;
|
|
6
|
+
user?: string;
|
|
7
|
+
method?: "api-key" | "bearer" | "none";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function safeCompare(a: string, b: string): boolean {
|
|
11
|
+
const aBuf = Buffer.from(a, "utf-8");
|
|
12
|
+
const bBuf = Buffer.from(b, "utf-8");
|
|
13
|
+
if (aBuf.length !== bBuf.length) {
|
|
14
|
+
// Still run comparison on equal-length buffers to avoid timing leak on length
|
|
15
|
+
timingSafeEqual(aBuf, aBuf);
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return timingSafeEqual(aBuf, bBuf);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function authMiddleware(apiKeys?: string[]) {
|
|
22
|
+
return async (c: Context, next: Next) => {
|
|
23
|
+
const authHeader = c.req.header("Authorization");
|
|
24
|
+
|
|
25
|
+
const auth: AuthContext = {
|
|
26
|
+
authenticated: false,
|
|
27
|
+
method: "none",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (authHeader?.startsWith("Bearer ") || authHeader?.startsWith("ApiKey ")) {
|
|
31
|
+
const scheme = authHeader.startsWith("Bearer ") ? "bearer" : "api-key";
|
|
32
|
+
const token = authHeader.slice(scheme === "bearer" ? 7 : 7);
|
|
33
|
+
|
|
34
|
+
if (!apiKeys || apiKeys.length === 0) {
|
|
35
|
+
// No keys configured — accept any token (warn once is gone; engine should configure keys)
|
|
36
|
+
auth.authenticated = true;
|
|
37
|
+
auth.method = scheme;
|
|
38
|
+
auth.user = "anonymous";
|
|
39
|
+
} else {
|
|
40
|
+
const matched = apiKeys.some((key) => safeCompare(token, key));
|
|
41
|
+
if (matched) {
|
|
42
|
+
auth.authenticated = true;
|
|
43
|
+
auth.method = scheme;
|
|
44
|
+
auth.user = "api-client";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
c.set("auth", auth);
|
|
50
|
+
await next();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Middleware that rejects unauthenticated requests with 401.
|
|
56
|
+
* Apply to write endpoints (POST, PUT, DELETE).
|
|
57
|
+
*/
|
|
58
|
+
export function requireAuth() {
|
|
59
|
+
return async (c: Context, next: Next) => {
|
|
60
|
+
const auth = c.get("auth") as AuthContext | undefined;
|
|
61
|
+
if (!auth?.authenticated) {
|
|
62
|
+
return c.json({ status: 401, message: "Unauthorized", code: "UNAUTHORIZED" }, 401);
|
|
63
|
+
}
|
|
64
|
+
await next();
|
|
65
|
+
};
|
|
66
|
+
}
|