Nyk commited on
Commit
5a5899d
·
1 Parent(s): e5e47c1

feat(docs): add docs knowledge APIs for issue 189

Browse files
playwright.config.ts CHANGED
@@ -34,6 +34,7 @@ export default defineConfig({
34
  API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
35
  AUTH_USER: process.env.AUTH_USER || 'testadmin',
36
  AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!',
 
37
  },
38
  }
39
  })
 
34
  API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
35
  AUTH_USER: process.env.AUTH_USER || 'testadmin',
36
  AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!',
37
+ OPENCLAW_MEMORY_DIR: process.env.OPENCLAW_MEMORY_DIR || '.data/e2e-memory',
38
  },
39
  }
40
  })
src/app/api/docs/content/route.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+ import { readLimiter } from '@/lib/rate-limit'
4
+ import { logger } from '@/lib/logger'
5
+ import { readDocsContent } from '@/lib/docs-knowledge'
6
+
7
+ export async function GET(request: NextRequest) {
8
+ const auth = requireRole(request, 'viewer')
9
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
10
+
11
+ const rateCheck = readLimiter(request)
12
+ if (rateCheck) return rateCheck
13
+
14
+ try {
15
+ const { searchParams } = new URL(request.url)
16
+ const path = (searchParams.get('path') || '').trim()
17
+
18
+ if (!path) {
19
+ return NextResponse.json({ error: 'Path required' }, { status: 400 })
20
+ }
21
+
22
+ try {
23
+ const doc = await readDocsContent(path)
24
+ return NextResponse.json(doc)
25
+ } catch (error) {
26
+ const message = (error as Error).message || ''
27
+ if (message.includes('Path not allowed')) {
28
+ return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
29
+ }
30
+ if (message.includes('not configured')) {
31
+ return NextResponse.json({ error: 'Docs directory not configured' }, { status: 500 })
32
+ }
33
+ return NextResponse.json({ error: 'File not found' }, { status: 404 })
34
+ }
35
+ } catch (error) {
36
+ logger.error({ err: error }, 'GET /api/docs/content error')
37
+ return NextResponse.json({ error: 'Failed to load doc content' }, { status: 500 })
38
+ }
39
+ }
src/app/api/docs/search/route.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+ import { readLimiter } from '@/lib/rate-limit'
4
+ import { logger } from '@/lib/logger'
5
+ import { searchDocs } from '@/lib/docs-knowledge'
6
+
7
+ export async function GET(request: NextRequest) {
8
+ const auth = requireRole(request, 'viewer')
9
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
10
+
11
+ const rateCheck = readLimiter(request)
12
+ if (rateCheck) return rateCheck
13
+
14
+ try {
15
+ const { searchParams } = new URL(request.url)
16
+ const query = (searchParams.get('q') || searchParams.get('query') || '').trim()
17
+ const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 200)
18
+
19
+ if (!query) {
20
+ return NextResponse.json({ error: 'Query required' }, { status: 400 })
21
+ }
22
+
23
+ const results = await searchDocs(query, limit)
24
+ return NextResponse.json({ query, results, count: results.length })
25
+ } catch (error) {
26
+ logger.error({ err: error }, 'GET /api/docs/search error')
27
+ return NextResponse.json({ error: 'Failed to search docs' }, { status: 500 })
28
+ }
29
+ }
src/app/api/docs/tree/route.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+ import { readLimiter } from '@/lib/rate-limit'
4
+ import { logger } from '@/lib/logger'
5
+ import { getDocsTree, listDocsRoots } from '@/lib/docs-knowledge'
6
+
7
+ export async function GET(request: NextRequest) {
8
+ const auth = requireRole(request, 'viewer')
9
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
10
+
11
+ const rateCheck = readLimiter(request)
12
+ if (rateCheck) return rateCheck
13
+
14
+ try {
15
+ const tree = await getDocsTree()
16
+ return NextResponse.json({ roots: listDocsRoots(), tree })
17
+ } catch (error) {
18
+ logger.error({ err: error }, 'GET /api/docs/tree error')
19
+ return NextResponse.json({ error: 'Failed to load docs tree' }, { status: 500 })
20
+ }
21
+ }
src/app/api/memory/route.ts CHANGED
@@ -44,17 +44,22 @@ async function resolveSafeMemoryPath(baseDir: string, relativePath: string): Pro
44
  const baseReal = await realpath(baseDir)
45
  const fullPath = resolveWithin(baseDir, relativePath)
46
 
47
- // For non-existent paths, validate containment using the parent directory realpath.
48
- // This also blocks symlinked parent segments that escape the base.
49
- let parentReal: string
50
- try {
51
- parentReal = await realpath(dirname(fullPath))
52
- } catch (err) {
53
- const code = (err as NodeJS.ErrnoException).code
54
- if (code === 'ENOENT') {
55
- throw new Error('Parent directory not found')
 
 
 
 
 
 
56
  }
57
- throw err
58
  }
59
  if (!isWithinBase(baseReal, parentReal)) {
60
  throw new Error('Path escapes base directory (symlink)')
 
44
  const baseReal = await realpath(baseDir)
45
  const fullPath = resolveWithin(baseDir, relativePath)
46
 
47
+ // For non-existent targets, validate containment using the nearest existing ancestor.
48
+ // This allows nested creates (mkdir -p) while still blocking symlink escapes.
49
+ let current = dirname(fullPath)
50
+ let parentReal = ''
51
+ while (!parentReal) {
52
+ try {
53
+ parentReal = await realpath(current)
54
+ } catch (err) {
55
+ const code = (err as NodeJS.ErrnoException).code
56
+ if (code !== 'ENOENT') throw err
57
+ const next = dirname(current)
58
+ if (next === current) {
59
+ throw new Error('Parent directory not found')
60
+ }
61
+ current = next
62
  }
 
63
  }
64
  if (!isWithinBase(baseReal, parentReal)) {
65
  throw new Error('Path escapes base directory (symlink)')
src/lib/docs-knowledge.ts ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readdir, readFile, stat, lstat, realpath } from 'fs/promises'
2
+ import { existsSync } from 'fs'
3
+ import { dirname, join, sep } from 'path'
4
+ import { resolveWithin } from '@/lib/paths'
5
+ import { config } from '@/lib/config'
6
+
7
+ const DOC_ROOT_CANDIDATES = ['docs', 'knowledge-base', 'knowledge', 'memory']
8
+
9
+ export interface DocsTreeNode {
10
+ path: string
11
+ name: string
12
+ type: 'file' | 'directory'
13
+ size?: number
14
+ modified?: number
15
+ children?: DocsTreeNode[]
16
+ }
17
+
18
+ function normalizeRelativePath(value: string): string {
19
+ return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
20
+ }
21
+
22
+ function isWithinBase(base: string, candidate: string): boolean {
23
+ if (candidate === base) return true
24
+ return candidate.startsWith(base + sep)
25
+ }
26
+
27
+ async function resolveSafePath(baseDir: string, relativePath: string): Promise<string> {
28
+ const baseReal = await realpath(baseDir)
29
+ const fullPath = resolveWithin(baseDir, relativePath)
30
+
31
+ let parentReal: string
32
+ try {
33
+ parentReal = await realpath(dirname(fullPath))
34
+ } catch (err) {
35
+ const code = (err as NodeJS.ErrnoException).code
36
+ if (code === 'ENOENT') throw new Error('Parent directory not found')
37
+ throw err
38
+ }
39
+
40
+ if (!isWithinBase(baseReal, parentReal)) {
41
+ throw new Error('Path escapes base directory (symlink)')
42
+ }
43
+
44
+ try {
45
+ const st = await lstat(fullPath)
46
+ if (st.isSymbolicLink()) throw new Error('Symbolic links are not allowed')
47
+ const fileReal = await realpath(fullPath)
48
+ if (!isWithinBase(baseReal, fileReal)) {
49
+ throw new Error('Path escapes base directory (symlink)')
50
+ }
51
+ } catch (err) {
52
+ const code = (err as NodeJS.ErrnoException).code
53
+ if (code !== 'ENOENT') throw err
54
+ }
55
+
56
+ return fullPath
57
+ }
58
+
59
+ function allowedRoots(baseDir: string): string[] {
60
+ const candidateRoots = DOC_ROOT_CANDIDATES.filter((root) => existsSync(join(baseDir, root)))
61
+ if (candidateRoots.length > 0) return candidateRoots
62
+
63
+ const fromConfig = (config.memoryAllowedPrefixes || [])
64
+ .map((prefix) => normalizeRelativePath(prefix).replace(/\/$/, ''))
65
+ .filter((prefix) => prefix.length > 0)
66
+ .filter((prefix) => existsSync(join(baseDir, prefix)))
67
+
68
+ return fromConfig
69
+ }
70
+
71
+ export function listDocsRoots(): string[] {
72
+ const baseDir = config.memoryDir
73
+ if (!baseDir || !existsSync(baseDir)) return []
74
+ return allowedRoots(baseDir)
75
+ }
76
+
77
+ export function isDocsPathAllowed(relativePath: string): boolean {
78
+ const normalized = normalizeRelativePath(relativePath)
79
+ if (!normalized) return false
80
+
81
+ const baseDir = config.memoryDir
82
+ if (!baseDir || !existsSync(baseDir)) return false
83
+
84
+ const roots = allowedRoots(baseDir)
85
+ if (roots.length === 0) return false
86
+
87
+ return roots.some((root) => normalized === root || normalized.startsWith(`${root}/`))
88
+ }
89
+
90
+ async function buildTreeFrom(dirPath: string, relativeBase: string): Promise<DocsTreeNode[]> {
91
+ const items = await readdir(dirPath, { withFileTypes: true })
92
+ const nodes: DocsTreeNode[] = []
93
+
94
+ for (const item of items) {
95
+ if (item.isSymbolicLink()) continue
96
+ const fullPath = join(dirPath, item.name)
97
+ const relativePath = normalizeRelativePath(join(relativeBase, item.name))
98
+
99
+ try {
100
+ const info = await stat(fullPath)
101
+ if (item.isDirectory()) {
102
+ const children = await buildTreeFrom(fullPath, relativePath)
103
+ nodes.push({
104
+ path: relativePath,
105
+ name: item.name,
106
+ type: 'directory',
107
+ modified: info.mtime.getTime(),
108
+ children,
109
+ })
110
+ } else if (item.isFile()) {
111
+ nodes.push({
112
+ path: relativePath,
113
+ name: item.name,
114
+ type: 'file',
115
+ size: info.size,
116
+ modified: info.mtime.getTime(),
117
+ })
118
+ }
119
+ } catch {
120
+ // Ignore unreadable files
121
+ }
122
+ }
123
+
124
+ return nodes.sort((a, b) => {
125
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
126
+ return a.name.localeCompare(b.name)
127
+ })
128
+ }
129
+
130
+ export async function getDocsTree(): Promise<DocsTreeNode[]> {
131
+ const baseDir = config.memoryDir
132
+ if (!baseDir || !existsSync(baseDir)) return []
133
+
134
+ const roots = allowedRoots(baseDir)
135
+ const tree: DocsTreeNode[] = []
136
+
137
+ for (const root of roots) {
138
+ const rootPath = join(baseDir, root)
139
+ try {
140
+ const info = await stat(rootPath)
141
+ if (!info.isDirectory()) continue
142
+ tree.push({
143
+ path: root,
144
+ name: root,
145
+ type: 'directory',
146
+ modified: info.mtime.getTime(),
147
+ children: await buildTreeFrom(rootPath, root),
148
+ })
149
+ } catch {
150
+ // Ignore unreadable roots
151
+ }
152
+ }
153
+
154
+ return tree
155
+ }
156
+
157
+ export async function readDocsContent(relativePath: string): Promise<{ content: string; size: number; modified: number; path: string }> {
158
+ if (!isDocsPathAllowed(relativePath)) {
159
+ throw new Error('Path not allowed')
160
+ }
161
+
162
+ const baseDir = config.memoryDir
163
+ if (!baseDir || !existsSync(baseDir)) {
164
+ throw new Error('Docs directory not configured')
165
+ }
166
+
167
+ const safePath = await resolveSafePath(baseDir, relativePath)
168
+ const content = await readFile(safePath, 'utf-8')
169
+ const info = await stat(safePath)
170
+
171
+ return {
172
+ content,
173
+ size: info.size,
174
+ modified: info.mtime.getTime(),
175
+ path: normalizeRelativePath(relativePath),
176
+ }
177
+ }
178
+
179
+ function isSearchable(name: string): boolean {
180
+ return name.endsWith('.md') || name.endsWith('.txt')
181
+ }
182
+
183
+ export async function searchDocs(query: string, limit = 100): Promise<Array<{ path: string; name: string; matches: number }>> {
184
+ const baseDir = config.memoryDir
185
+ if (!baseDir || !existsSync(baseDir)) return []
186
+
187
+ const roots = allowedRoots(baseDir)
188
+ if (roots.length === 0) return []
189
+
190
+ const q = query.trim().toLowerCase()
191
+ if (!q) return []
192
+
193
+ const results: Array<{ path: string; name: string; matches: number }> = []
194
+
195
+ const searchFile = async (fullPath: string, relativePath: string) => {
196
+ try {
197
+ const info = await stat(fullPath)
198
+ if (info.size > 1_000_000) return
199
+ const content = (await readFile(fullPath, 'utf-8')).toLowerCase()
200
+ let count = 0
201
+ let idx = content.indexOf(q)
202
+ while (idx !== -1) {
203
+ count += 1
204
+ idx = content.indexOf(q, idx + q.length)
205
+ }
206
+ if (count > 0) {
207
+ results.push({
208
+ path: normalizeRelativePath(relativePath),
209
+ name: relativePath.split('/').pop() || relativePath,
210
+ matches: count,
211
+ })
212
+ }
213
+ } catch {
214
+ // Ignore unreadable files
215
+ }
216
+ }
217
+
218
+ const searchDir = async (fullDir: string, relativeDir: string) => {
219
+ const items = await readdir(fullDir, { withFileTypes: true })
220
+ for (const item of items) {
221
+ if (item.isSymbolicLink()) continue
222
+ const itemFull = join(fullDir, item.name)
223
+ const itemRel = normalizeRelativePath(join(relativeDir, item.name))
224
+ if (item.isDirectory()) {
225
+ await searchDir(itemFull, itemRel)
226
+ } else if (item.isFile() && isSearchable(item.name.toLowerCase())) {
227
+ await searchFile(itemFull, itemRel)
228
+ }
229
+ }
230
+ }
231
+
232
+ for (const root of roots) {
233
+ const rootPath = join(baseDir, root)
234
+ try {
235
+ await searchDir(rootPath, root)
236
+ } catch {
237
+ // Ignore unreadable roots
238
+ }
239
+ }
240
+
241
+ return results.sort((a, b) => b.matches - a.matches).slice(0, Math.max(1, Math.min(limit, 200)))
242
+ }
tests/docs-knowledge.spec.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+ import { API_KEY_HEADER } from './helpers'
3
+
4
+ test.describe('Docs Knowledge API', () => {
5
+ test('tree/search/content flows for markdown knowledge docs', async ({ request }) => {
6
+ const stamp = Date.now()
7
+ const path = `knowledge-base/e2e-kb-${stamp}.md`
8
+ const content = `# E2E Knowledge ${stamp}\n\nDeployment runbook token: kb-search-${stamp}`
9
+
10
+ const create = await request.post('/api/memory', {
11
+ headers: API_KEY_HEADER,
12
+ data: {
13
+ action: 'create',
14
+ path,
15
+ content,
16
+ },
17
+ })
18
+ expect(create.status()).toBe(200)
19
+
20
+ const tree = await request.get('/api/docs/tree', { headers: API_KEY_HEADER })
21
+ expect(tree.status()).toBe(200)
22
+ const treeBody = await tree.json()
23
+ expect(Array.isArray(treeBody.tree)).toBe(true)
24
+
25
+ const search = await request.get(`/api/docs/search?q=${encodeURIComponent(`kb-search-${stamp}`)}`, {
26
+ headers: API_KEY_HEADER,
27
+ })
28
+ expect(search.status()).toBe(200)
29
+ const searchBody = await search.json()
30
+ const found = searchBody.results.find((r: any) => r.path === path)
31
+ expect(found).toBeTruthy()
32
+
33
+ const doc = await request.get(`/api/docs/content?path=${encodeURIComponent(path)}`, {
34
+ headers: API_KEY_HEADER,
35
+ })
36
+ expect(doc.status()).toBe(200)
37
+ const docBody = await doc.json()
38
+ expect(docBody.path).toBe(path)
39
+ expect(docBody.content).toContain(`kb-search-${stamp}`)
40
+
41
+ const cleanup = await request.delete('/api/memory', {
42
+ headers: API_KEY_HEADER,
43
+ data: {
44
+ action: 'delete',
45
+ path,
46
+ },
47
+ })
48
+ expect(cleanup.status()).toBe(200)
49
+ })
50
+
51
+ test('docs APIs require auth', async ({ request }) => {
52
+ const tree = await request.get('/api/docs/tree')
53
+ expect(tree.status()).toBe(401)
54
+
55
+ const search = await request.get('/api/docs/search?q=deployment')
56
+ expect(search.status()).toBe(401)
57
+
58
+ const content = await request.get('/api/docs/content?path=knowledge-base/example.md')
59
+ expect(content.status()).toBe(401)
60
+ })
61
+ })