@claudelaw/taichu 0.6.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.
Files changed (93) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/docker-compose.yml +42 -0
  6. package/docs/ROADMAP.md +101 -0
  7. package/docs/api/README.md +102 -0
  8. package/docs/architecture/001-zero-dependency-core.md +61 -0
  9. package/docs/architecture/002-structured-content-model.md +70 -0
  10. package/docs/architecture/003-hook-based-extension.md +82 -0
  11. package/docs/architecture/004-api-first-architecture.md +122 -0
  12. package/docs/architecture/README.md +24 -0
  13. package/docs/logo.svg +40 -0
  14. package/docs/research/ai-era-cms-user-research.md +247 -0
  15. package/docs/zh/README.md +81 -0
  16. package/docs/zh/guides/deploy.md +75 -0
  17. package/docs/zh/guides/mcp.md +84 -0
  18. package/docs/zh/guides/promotion.md +51 -0
  19. package/marketplace.json +78 -0
  20. package/package.json +60 -0
  21. package/packages/core/src/auth.js +158 -0
  22. package/packages/core/src/content-type.js +244 -0
  23. package/packages/core/src/core.test.js +406 -0
  24. package/packages/core/src/errors.js +60 -0
  25. package/packages/core/src/hooks.js +104 -0
  26. package/packages/core/src/index.js +16 -0
  27. package/packages/core/src/server.test.js +149 -0
  28. package/packages/core/src/sm-crypto.js +31 -0
  29. package/packages/core/src/sqlite-store.js +354 -0
  30. package/packages/core/src/store.js +174 -0
  31. package/packages/core/src/tokenizer.js +89 -0
  32. package/packages/core/src/vector-index.js +131 -0
  33. package/packages/llm-providers/src/index.js +181 -0
  34. package/packages/mcp/src/index.js +355 -0
  35. package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
  36. package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
  37. package/packages/server/public/admin/index.html +28 -0
  38. package/packages/server/public/aurora/style.css +1173 -0
  39. package/packages/server/public/favicon.svg +46 -0
  40. package/packages/server/public/theme/index.html +288 -0
  41. package/packages/server/public/theme/style.css +133 -0
  42. package/packages/server/public/theme-minimal/index.html +223 -0
  43. package/packages/server/public/theme-minimal/style.css +109 -0
  44. package/packages/server/public/ws-test.html +106 -0
  45. package/packages/server/src/activitypub.js +228 -0
  46. package/packages/server/src/audit.js +104 -0
  47. package/packages/server/src/auth-provider.js +76 -0
  48. package/packages/server/src/body-parser.js +52 -0
  49. package/packages/server/src/bootstrap.js +272 -0
  50. package/packages/server/src/collab.js +154 -0
  51. package/packages/server/src/config.js +136 -0
  52. package/packages/server/src/context.js +86 -0
  53. package/packages/server/src/email.js +317 -0
  54. package/packages/server/src/index.js +195 -0
  55. package/packages/server/src/logger.js +78 -0
  56. package/packages/server/src/media-store.js +213 -0
  57. package/packages/server/src/middleware/auth.js +203 -0
  58. package/packages/server/src/middleware/cors.js +15 -0
  59. package/packages/server/src/middleware/error-handler.js +49 -0
  60. package/packages/server/src/middleware/rate-limit.js +118 -0
  61. package/packages/server/src/multipart.js +150 -0
  62. package/packages/server/src/notify.js +126 -0
  63. package/packages/server/src/pipeline.js +206 -0
  64. package/packages/server/src/plugin-installer.js +139 -0
  65. package/packages/server/src/plugin-manager.js +165 -0
  66. package/packages/server/src/relationships.js +217 -0
  67. package/packages/server/src/revisions.js +114 -0
  68. package/packages/server/src/router.js +194 -0
  69. package/packages/server/src/routes/activitypub.js +140 -0
  70. package/packages/server/src/routes/api.js +363 -0
  71. package/packages/server/src/routes/audit.js +222 -0
  72. package/packages/server/src/routes/auth.js +205 -0
  73. package/packages/server/src/routes/collab.js +90 -0
  74. package/packages/server/src/routes/export.js +77 -0
  75. package/packages/server/src/routes/graphql.js +344 -0
  76. package/packages/server/src/routes/media.js +169 -0
  77. package/packages/server/src/routes/plugin-marketplace.js +171 -0
  78. package/packages/server/src/routes/relationships.js +133 -0
  79. package/packages/server/src/routes/rss.js +92 -0
  80. package/packages/server/src/routes/sso.js +211 -0
  81. package/packages/server/src/routes/theme.js +119 -0
  82. package/packages/server/src/routes/webhook.js +94 -0
  83. package/packages/server/src/routes/wechat.js +115 -0
  84. package/packages/server/src/routes/workflow.js +157 -0
  85. package/packages/server/src/scheduler.js +96 -0
  86. package/packages/server/src/search.js +100 -0
  87. package/packages/server/src/server.test.js +295 -0
  88. package/packages/server/src/sso-analytics.js +78 -0
  89. package/packages/server/src/static.js +70 -0
  90. package/packages/server/src/theme-engine.js +119 -0
  91. package/packages/server/src/webhook.js +192 -0
  92. package/packages/server/src/websocket.js +308 -0
  93. package/scripts/cli.js +90 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * GraphQL API Route
3
+ *
4
+ * REST 之外的第二个 API 通道。
5
+ * 提供灵活的客户端自定义查询——前端想要什么字段就取什么字段。
6
+ *
7
+ * POST /api/graphql — GraphQL 端点
8
+ * GET /api/graphql — GraphiQL 交互式 IDE(开发环境)
9
+ *
10
+ * 支持 Bearer JWT 或 X-Taichu-Agent-Key 认证。
11
+ */
12
+
13
+ import { buildSchema, graphql } from 'graphql';
14
+ import { getStore, getHooks } from '../context.js';
15
+ import { search as vectorSearch } from '../search.js';
16
+ import { requireAuth } from '../middleware/auth.js';
17
+
18
+ // ─── Schema Definition ────────────────────────────────────
19
+
20
+ const schemaSDL = `
21
+ type Document {
22
+ id: ID!
23
+ type: String!
24
+ data: JSON
25
+ status: String
26
+ createdBy: String
27
+ createdAt: String
28
+ updatedAt: String
29
+ }
30
+
31
+ type ContentType {
32
+ name: String!
33
+ label: String!
34
+ description: String
35
+ schemaOrg: String
36
+ fieldCount: Int
37
+ }
38
+
39
+ type SearchResult {
40
+ id: ID!
41
+ type: String!
42
+ title: String
43
+ status: String
44
+ score: Float
45
+ updatedAt: String
46
+ }
47
+
48
+ scalar JSON
49
+
50
+ type Query {
51
+ """获取单个文档"""
52
+ content(type: String!, id: ID!): Document
53
+
54
+ """列出某类型的文档"""
55
+ contentList(
56
+ type: String!
57
+ status: String
58
+ search: String
59
+ limit: Int
60
+ offset: Int
61
+ ): [Document!]!
62
+
63
+ """语义搜索"""
64
+ search(query: String!, type: String, limit: Int): [SearchResult!]!
65
+
66
+ """列出所有内容类型"""
67
+ contentTypes: [ContentType!]!
68
+
69
+ """健康检查"""
70
+ health: String!
71
+ }
72
+
73
+ type Mutation {
74
+ """创建文档"""
75
+ createContent(type: String!, data: JSON!, status: String): Document
76
+
77
+ """更新文档"""
78
+ updateContent(type: String!, id: ID!, data: JSON!): Document
79
+
80
+ """删除文档"""
81
+ deleteContent(type: String!, id: ID!): Boolean!
82
+ }
83
+ `;
84
+
85
+ // ─── Resolvers ────────────────────────────────────────────
86
+
87
+ const resolvers = {
88
+ JSON: {
89
+ serialize: (v) => v,
90
+ parseValue: (v) => v
91
+ },
92
+
93
+ Query: {
94
+ async content(_, { type, id }) {
95
+ const store = getStore();
96
+ return store.get(id);
97
+ },
98
+
99
+ async contentList(_, args) {
100
+ const store = getStore();
101
+ const docs = await store.list({
102
+ type: args.type,
103
+ status: args.status || undefined,
104
+ search: args.search || undefined,
105
+ limit: args.limit || 20,
106
+ offset: args.offset || 0
107
+ });
108
+ return docs;
109
+ },
110
+
111
+ async search(_, { query, type, limit = 10 }) {
112
+ const results = vectorSearch(query, limit);
113
+ const store = getStore();
114
+ const docs = [];
115
+
116
+ for (const { docId, score } of results) {
117
+ try {
118
+ const doc = await store.get(docId);
119
+ if (doc && (!type || doc.type === type)) {
120
+ docs.push({
121
+ id: doc.id,
122
+ type: doc.type,
123
+ title: doc.data?.title || doc.data?.name || '(untitled)',
124
+ status: doc.status,
125
+ score: Math.round(score * 100) / 100,
126
+ updatedAt: doc.updatedAt
127
+ });
128
+ }
129
+ } catch { /* skip */ }
130
+ }
131
+
132
+ return docs;
133
+ },
134
+
135
+ contentTypes() {
136
+ // Import dynamically to avoid circular deps
137
+ return import('./api.js').then(m => {
138
+ const types = m.getContentTypes ? m.getContentTypes() : [];
139
+ return types;
140
+ }).catch(() => []);
141
+ },
142
+
143
+ health() {
144
+ return `Taichu CMS v0.2.0 — uptime: ${Math.floor(process.uptime())}s`;
145
+ }
146
+ },
147
+
148
+ Mutation: {
149
+ async createContent(_, { type, data, status }) {
150
+ const store = getStore();
151
+ const hooks = getHooks();
152
+
153
+ let payload = { type, data, status: status || 'draft' };
154
+ payload = await hooks.run('beforeCreate', payload, { store });
155
+ const doc = await store.create(payload);
156
+ await hooks.run('afterCreate', doc, { store });
157
+ return doc;
158
+ },
159
+
160
+ async updateContent(_, { type, id, data }) {
161
+ const store = getStore();
162
+ const hooks = getHooks();
163
+
164
+ let payload = { id, type, data };
165
+ payload = await hooks.run('beforeUpdate', payload, { store });
166
+ const doc = await store.update(id, payload);
167
+ if (doc) await hooks.run('afterUpdate', doc, { store });
168
+ return doc;
169
+ },
170
+
171
+ async deleteContent(_, { type, id }) {
172
+ const store = getStore();
173
+ const hooks = getHooks();
174
+
175
+ const basePayload = { id, type };
176
+ const payload = await hooks.run('beforeDelete', basePayload, { store });
177
+ const deleted = await store.delete(id);
178
+ if (deleted) await hooks.run('afterDelete', { id, type }, { store });
179
+ return deleted;
180
+ }
181
+ }
182
+ };
183
+
184
+ // ─── Build Schema ─────────────────────────────────────────
185
+
186
+ let schema = null;
187
+
188
+ function getSchema() {
189
+ if (!schema) {
190
+ schema = buildSchema(schemaSDL);
191
+ }
192
+ return schema;
193
+ }
194
+
195
+ // ─── Route Handler ────────────────────────────────────────
196
+
197
+ /**
198
+ * @param {import('../context.js').Context} ctx
199
+ */
200
+ export async function graphqlRoutes(ctx) {
201
+ const s = getSchema();
202
+
203
+ // GET: GraphiQL playground (simple HTML)
204
+ if (ctx.req.method === 'GET') {
205
+ ctx.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
206
+ ctx.res.end(GRAPHIQL_HTML);
207
+ return;
208
+ }
209
+
210
+ // POST: Execute GraphQL query (mutations require auth)
211
+ if (ctx.req.method === 'POST') {
212
+ const { query, variables, operationName } = ctx.body || {};
213
+
214
+ if (!query) {
215
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
216
+ ctx.res.end(JSON.stringify({ errors: [{ message: 'Query is required' }] }));
217
+ return;
218
+ }
219
+
220
+ // Require auth for mutations
221
+ const isMutation = query.trim().toLowerCase().startsWith('mutation');
222
+ if (isMutation) {
223
+ const authResult = await requireAuth(ctx);
224
+ if (!authResult.authenticated) {
225
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
226
+ ctx.res.end(JSON.stringify({ errors: [{ message: authResult.message }] }));
227
+ return;
228
+ }
229
+ ctx.actor = authResult.actor;
230
+ }
231
+
232
+ // Validate query depth (prevent DoS via deeply nested queries)
233
+ const maxDepth = parseInt(process.env.TAICHU_GRAPHQL_MAX_DEPTH) || 5;
234
+ if (estimateQueryDepth(query) > maxDepth) {
235
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
236
+ ctx.res.end(JSON.stringify({ errors: [{ message: `Query depth exceeds maximum of ${maxDepth}` }] }));
237
+ return;
238
+ }
239
+
240
+ try {
241
+ const result = await graphql({
242
+ schema: s,
243
+ source: query,
244
+ variableValues: variables,
245
+ operationName,
246
+ contextValue: {}
247
+ });
248
+
249
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
250
+ ctx.res.end(JSON.stringify(result));
251
+ } catch (err) {
252
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
253
+ ctx.res.end(JSON.stringify({ errors: [{ message: err.message }] }));
254
+ }
255
+ return;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Estimate query depth to prevent deeply nested DoS attacks.
261
+ * Simple bracket-based heuristic.
262
+ */
263
+ function estimateQueryDepth(query) {
264
+ let depth = 0, maxDepth = 0;
265
+ for (const ch of query) {
266
+ if (ch === '{') { depth++; maxDepth = Math.max(maxDepth, depth); }
267
+ if (ch === '}') depth--;
268
+ }
269
+ return maxDepth;
270
+ }
271
+
272
+ // ─── GraphiQL HTML ────────────────────────────────────────
273
+
274
+ const GRAPHIQL_HTML = `<!DOCTYPE html>
275
+ <html>
276
+ <head>
277
+ <title>Taichu GraphiQL</title>
278
+ <style>
279
+ * { margin:0; padding:0; box-sizing:border-box; }
280
+ body { font-family: -apple-system, sans-serif; background: #0F172A; color: #E2E8F0; height:100vh; display:flex; flex-direction:column; }
281
+ header { padding: 12px 24px; background: #1E293B; border-bottom: 1px solid #334155; display:flex; align-items:center; gap:12px; }
282
+ header h1 { font-size: 16px; color: #10B981; }
283
+ header span { font-size: 12px; color: #64748B; }
284
+ main { flex:1; display:flex; flex-direction:column; padding: 16px; gap:12px; }
285
+ #editor { flex:1; background:#1E293B; border:1px solid #334155; border-radius:8px; padding:16px; color:#E2E8F0; font-family:'Cascadia Code',monospace; font-size:13px; resize:none; outline:none; }
286
+ #editor:focus { border-color:#10B981; }
287
+ .bar { display:flex; gap:8px; align-items:center; }
288
+ button { padding: 8px 20px; background:#10B981; color:white; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; }
289
+ button:hover { background:#059669; }
290
+ #result { flex:1; background:#1E293B; border:1px solid #334155; border-radius:8px; padding:16px; overflow:auto; font-family:'Cascadia Code',monospace; font-size:13px; white-space:pre-wrap; }
291
+ .error { color:#EF4444; }
292
+ .split { display:flex; flex:1; gap:12px; }
293
+ .split > * { flex:1; }
294
+ </style>
295
+ </head>
296
+ <body>
297
+ <header><h1>⚡ Taichu GraphiQL</h1><span>GraphQL Explorer</span></header>
298
+ <main>
299
+ <div class="bar">
300
+ <button onclick="run()">▶ 执行</button>
301
+ <span style="font-size:12px;color:#64748B">Ctrl+Enter</span>
302
+ </div>
303
+ <div class="split">
304
+ <textarea id="editor" placeholder="# GraphQL Query
305
+ {
306
+ health
307
+ contentTypes { name label }
308
+ contentList(type:"article", limit:5) { id data status }
309
+ }">{
310
+ health
311
+ contentTypes {
312
+ name
313
+ label
314
+ fieldCount
315
+ }
316
+ }</textarea>
317
+ <div id="result">点击执行查看结果</div>
318
+ </div>
319
+ </main>
320
+ <script>
321
+ const editor=document.getElementById('editor');
322
+ const result=document.getElementById('result');
323
+
324
+ async function run() {
325
+ try {
326
+ const q = editor.value;
327
+ const res = await fetch('/api/graphql', {
328
+ method: 'POST',
329
+ headers: {'Content-Type':'application/json'},
330
+ body: JSON.stringify({query:q})
331
+ });
332
+ const data = await res.json();
333
+ result.innerHTML = JSON.stringify(data, null, 2);
334
+ } catch(e) {
335
+ result.innerHTML = '<span class="error">'+e.message+'</span>';
336
+ }
337
+ }
338
+
339
+ editor.addEventListener('keydown', e => {
340
+ if (e.ctrlKey && e.key === 'Enter') run();
341
+ });
342
+ </script>
343
+ </body>
344
+ </html>`;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Media Routes — 文件上传和管理 API
3
+ *
4
+ * POST /api/media/upload — 上传文件(multipart)
5
+ * GET /api/media — 列出媒体
6
+ * GET /api/media/:id — 获取媒体元数据
7
+ * DELETE /api/media/:id — 删除媒体
8
+ * GET /uploads/* — 静态文件服务(由 router 转发到 static.js)
9
+ */
10
+
11
+ import { parseMultipart } from '../multipart.js';
12
+ import { createMediaStore } from '../media-store.js';
13
+ import { requireAuth } from '../middleware/auth.js';
14
+ import { getStore } from '../context.js';
15
+
16
+ // Lazy-init media store singleton
17
+ let _mediaStore = null;
18
+ function getMediaStore() {
19
+ if (!_mediaStore) _mediaStore = createMediaStore();
20
+ return _mediaStore;
21
+ }
22
+
23
+ /**
24
+ * @param {import('../context.js').Context} ctx
25
+ */
26
+ export async function mediaRoutes(ctx) {
27
+ const { pathname } = ctx.url;
28
+ const method = ctx.req.method;
29
+
30
+ // POST /api/media/upload
31
+ if (pathname === '/api/media/upload' && method === 'POST') {
32
+ const authResult = await requireAuth(ctx);
33
+ if (!authResult.authenticated) {
34
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
35
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
36
+ return;
37
+ }
38
+ ctx.actor = authResult.actor;
39
+
40
+ try {
41
+ const { files, fields } = await parseMultipart(ctx.req);
42
+ if (!files || files.length === 0) {
43
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
44
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'No file uploaded' }));
45
+ return;
46
+ }
47
+
48
+ const mediaStore = getMediaStore();
49
+ const store = getStore();
50
+ const results = [];
51
+
52
+ for (const file of files) {
53
+ const saved = await mediaStore.save(file.buffer, file.filename, file.mimetype);
54
+
55
+ // Store metadata in Taichu's content system
56
+ const doc = await store.create({
57
+ type: 'media',
58
+ data: {
59
+ filename: saved.filename,
60
+ originalName: saved.originalName,
61
+ mimeType: saved.mimetype,
62
+ size: saved.size,
63
+ url: saved.url,
64
+ width: saved.width || null,
65
+ height: saved.height || null,
66
+ compressed: saved.compressed || false,
67
+ webp: saved.webp || null,
68
+ thumbnails: saved.thumbnails || {},
69
+ altText: fields.alt || '',
70
+ caption: fields.caption || '',
71
+ uploadedBy: ctx.actor.id
72
+ },
73
+ status: 'active'
74
+ });
75
+
76
+ results.push({
77
+ id: doc.id,
78
+ ...saved,
79
+ altText: fields.alt || '',
80
+ caption: fields.caption || ''
81
+ });
82
+ }
83
+
84
+ ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
85
+ ctx.res.end(JSON.stringify(results.length === 1 ? results[0] : { files: results }));
86
+ } catch (err) {
87
+ if (err.code === 'FILE_TOO_LARGE' || err.code === 'PAYLOAD_TOO_LARGE') {
88
+ ctx.res.writeHead(413, { 'Content-Type': 'application/json' });
89
+ ctx.res.end(JSON.stringify({ error: err.code, message: err.message }));
90
+ return;
91
+ }
92
+ throw err;
93
+ }
94
+ return;
95
+ }
96
+
97
+ // GET /api/media — list media
98
+ if (pathname === '/api/media' && method === 'GET') {
99
+ const authResult = await requireAuth(ctx);
100
+ if (!authResult.authenticated) {
101
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
102
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
103
+ return;
104
+ }
105
+
106
+ const store = getStore();
107
+ const docs = await store.list({ type: 'media', limit: 50, ...Object.fromEntries(ctx.url.searchParams) });
108
+
109
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
110
+ ctx.res.end(JSON.stringify({ docs, total: docs.length }));
111
+ return;
112
+ }
113
+
114
+ // GET /api/media/:id
115
+ const mediaMatch = pathname.match(/^\/api\/media\/([\w-]+)$/);
116
+ if (mediaMatch && method === 'GET') {
117
+ const authResult = await requireAuth(ctx);
118
+ if (!authResult.authenticated) {
119
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
120
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
121
+ return;
122
+ }
123
+
124
+ const store = getStore();
125
+ const doc = await store.get(mediaMatch[1]);
126
+ if (!doc || doc.type !== 'media') {
127
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
128
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Media not found' }));
129
+ return;
130
+ }
131
+
132
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
133
+ ctx.res.end(JSON.stringify(doc));
134
+ return;
135
+ }
136
+
137
+ // DELETE /api/media/:id
138
+ if (mediaMatch && method === 'DELETE') {
139
+ const authResult = await requireAuth(ctx);
140
+ if (!authResult.authenticated) {
141
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
142
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
143
+ return;
144
+ }
145
+
146
+ const store = getStore();
147
+ const mediaStore = getMediaStore();
148
+ const doc = await store.get(mediaMatch[1]);
149
+
150
+ if (!doc || doc.type !== 'media') {
151
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
152
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: 'Media not found' }));
153
+ return;
154
+ }
155
+
156
+ // Delete file from disk
157
+ await mediaStore.remove(doc.data.filename);
158
+ // Delete metadata
159
+ await store.delete(mediaMatch[1]);
160
+
161
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
162
+ ctx.res.end(JSON.stringify({ success: true }));
163
+ return;
164
+ }
165
+
166
+ // 404
167
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
168
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Media route not found: ${method} ${pathname}` }));
169
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Plugin Marketplace Routes
3
+ *
4
+ * GET /api/plugins — list installed plugins
5
+ * GET /api/plugins/marketplace — search/browse marketplace
6
+ * POST /api/plugins/install — install a plugin from marketplace
7
+ * POST /api/plugins/uninstall/:name — uninstall a plugin
8
+ * POST /api/plugins/refresh — refresh marketplace index
9
+ */
10
+
11
+ import { requireAuth } from '../middleware/auth.js';
12
+ import { installPlugin, uninstallPlugin, listInstalled, isInstalled } from '../plugin-installer.js';
13
+ import { getPluginManager } from '../plugin-manager.js';
14
+
15
+ /** Default marketplace index URL */
16
+ const DEFAULT_MARKETPLACE_URL = 'https://raw.githubusercontent.com/Caludelaw/Taichu/main/marketplace.json';
17
+ const MARKETPLACE_URL = process.env.TAICHU_MARKETPLACE_URL || DEFAULT_MARKETPLACE_URL;
18
+
19
+ let _marketplaceCache = null;
20
+ let _marketplaceCacheTime = 0;
21
+ const CACHE_TTL = 300000; // 5 min
22
+
23
+ /**
24
+ * Fetch marketplace index (with cache).
25
+ */
26
+ async function fetchMarketplace(opts = {}) {
27
+ const force = opts.force || false;
28
+ if (!force && _marketplaceCache && (Date.now() - _marketplaceCacheTime) < CACHE_TTL) {
29
+ return _marketplaceCache;
30
+ }
31
+
32
+ try {
33
+ const res = await fetch(MARKETPLACE_URL);
34
+ if (!res.ok) throw new Error(`Marketplace fetch failed: ${res.status}`);
35
+ _marketplaceCache = await res.json();
36
+ _marketplaceCacheTime = Date.now();
37
+ return _marketplaceCache;
38
+ } catch (err) {
39
+ // Return cached if available, otherwise empty
40
+ if (_marketplaceCache) return _marketplaceCache;
41
+ return { version: 1, plugins: [], error: err.message };
42
+ }
43
+ }
44
+
45
+ /** @param {import('../context.js').Context} ctx */
46
+ export async function pluginMarketplaceRoutes(ctx) {
47
+ const { pathname } = ctx.url;
48
+ const method = ctx.req.method;
49
+
50
+ // Auth required for all plugin management
51
+ const authResult = await requireAuth(ctx);
52
+ if (!authResult.authenticated) {
53
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
54
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
55
+ return;
56
+ }
57
+
58
+ // GET /api/plugins — list installed
59
+ if (pathname === '/api/plugins' && method === 'GET') {
60
+ const pm = getPluginManager();
61
+ const installed = listInstalled();
62
+ const loaded = pm.list();
63
+ const result = installed.map(p => ({
64
+ ...p,
65
+ loaded: loaded.some(l => l.name === p.name)
66
+ }));
67
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
68
+ ctx.res.end(JSON.stringify({ plugins: result, total: result.length }));
69
+ return;
70
+ }
71
+
72
+ // GET /api/plugins/marketplace — browse marketplace
73
+ if (pathname === '/api/plugins/marketplace' && method === 'GET') {
74
+ const search = ctx.url.searchParams.get('search')?.toLowerCase();
75
+ const category = ctx.url.searchParams.get('category');
76
+
77
+ const marketplace = await fetchMarketplace();
78
+ let results = marketplace.plugins || [];
79
+
80
+ if (search) {
81
+ results = results.filter(p =>
82
+ p.name.toLowerCase().includes(search) ||
83
+ p.description.toLowerCase().includes(search) ||
84
+ (p.keywords || []).some(k => k.toLowerCase().includes(search))
85
+ );
86
+ }
87
+ if (category) {
88
+ results = results.filter(p => p.category === category);
89
+ }
90
+
91
+ // Add install status
92
+ const enriched = results.map(p => ({
93
+ ...p,
94
+ installed: isInstalled(p.name)
95
+ }));
96
+
97
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
98
+ ctx.res.end(JSON.stringify({
99
+ plugins: enriched,
100
+ total: enriched.length,
101
+ lastUpdated: marketplace.lastUpdated || null,
102
+ source: MARKETPLACE_URL
103
+ }));
104
+ return;
105
+ }
106
+
107
+ // POST /api/plugins/install — install from marketplace
108
+ if (pathname === '/api/plugins/install' && method === 'POST') {
109
+ const { repo, name } = ctx.body || {};
110
+
111
+ if (!repo && !name) {
112
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
113
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Either "repo" (GitHub repo) or "name" (marketplace plugin name) is required' }));
114
+ return;
115
+ }
116
+
117
+ let installRepo = repo;
118
+ if (!installRepo) {
119
+ // Look up in marketplace
120
+ const marketplace = await fetchMarketplace();
121
+ const plugin = (marketplace.plugins || []).find(p => p.name === name);
122
+ if (!plugin) {
123
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
124
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Plugin "${name}" not found in marketplace` }));
125
+ return;
126
+ }
127
+ installRepo = plugin.repository?.replace('https://github.com/', '') || plugin.repo;
128
+ if (!installRepo) {
129
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
130
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: `Plugin "${name}" has no repository URL` }));
131
+ return;
132
+ }
133
+ }
134
+
135
+ const result = await installPlugin(installRepo, { version: ctx.body.version });
136
+ if (result.success) {
137
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
138
+ ctx.res.end(JSON.stringify(result));
139
+ } else {
140
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
141
+ ctx.res.end(JSON.stringify(result));
142
+ }
143
+ return;
144
+ }
145
+
146
+ // POST /api/plugins/uninstall/:name
147
+ const uninstallMatch = pathname.match(/^\/api\/plugins\/uninstall\/(.+)$/);
148
+ if (uninstallMatch && method === 'POST') {
149
+ const name = uninstallMatch[1];
150
+ const result = await uninstallPlugin(name);
151
+ if (result.success) {
152
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
153
+ ctx.res.end(JSON.stringify(result));
154
+ } else {
155
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
156
+ ctx.res.end(JSON.stringify(result));
157
+ }
158
+ return;
159
+ }
160
+
161
+ // POST /api/plugins/refresh — refresh marketplace cache
162
+ if (pathname === '/api/plugins/refresh' && method === 'POST') {
163
+ const marketplace = await fetchMarketplace({ force: true });
164
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
165
+ ctx.res.end(JSON.stringify({ refreshed: true, pluginCount: (marketplace.plugins || []).length }));
166
+ return;
167
+ }
168
+
169
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
170
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
171
+ }