@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,194 @@
1
+ /**
2
+ * Router — Taichu 路由分发
3
+ *
4
+ * 基于 URL 模式匹配的轻量路由。
5
+ * 支持:
6
+ * - 静态路由:/api/health
7
+ * - 参数路由:/api/content/:type/:id
8
+ * - HTTP 方法区分
9
+ */
10
+
11
+ import { apiRoutes } from './routes/api.js';
12
+ import { authRoutes } from './routes/auth.js';
13
+ import { graphqlRoutes } from './routes/graphql.js';
14
+ import { mediaRoutes } from './routes/media.js';
15
+ import { collabRoutes } from './routes/collab.js';
16
+ import { webhookRoutes } from './routes/webhook.js';
17
+ import { auditRoutes, revisionRoutes } from './routes/audit.js';
18
+ import { relationshipRoutes } from './routes/relationships.js';
19
+ import { pluginMarketplaceRoutes } from './routes/plugin-marketplace.js';
20
+ import { activityPubRoutes } from './routes/activitypub.js';
21
+ import { workflowRoutes } from './routes/workflow.js';
22
+ import { wechatRoutes } from './routes/wechat.js';
23
+ import { ssoRoutes } from './routes/sso.js';
24
+ import { themeRoutes } from './routes/theme.js';
25
+ import { rssSitemapRoutes } from './routes/rss.js';
26
+ import { exportRoutes } from './routes/export.js';
27
+ import { serveStatic } from './static.js';
28
+ import { createMediaStore } from './media-store.js';
29
+ import { renderTheme, serveThemeAsset } from './theme-engine.js';
30
+ import { join, dirname } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+ const PUBLIC_DIR = join(__dirname, '..', 'public');
35
+
36
+ /**
37
+ * @param {import('./context.js').Context} ctx
38
+ */
39
+ export async function router(ctx) {
40
+ const { pathname } = ctx.url;
41
+ const method = ctx.req.method;
42
+
43
+ // Auth routes
44
+ if (pathname.startsWith('/api/auth')) {
45
+ return authRoutes(ctx);
46
+ }
47
+
48
+ // ActivityPub & WebFinger (no auth required for federation)
49
+ if (pathname.startsWith('/api/activitypub') || pathname.startsWith('/.well-known/')) {
50
+ return activityPubRoutes(ctx);
51
+ }
52
+
53
+ // GraphQL API
54
+ if (pathname === '/api/graphql') {
55
+ return graphqlRoutes(ctx);
56
+ }
57
+
58
+ // Collaboration & WebSocket
59
+ if (pathname.startsWith('/api/collab') || pathname === '/api/ws') {
60
+ return collabRoutes(ctx);
61
+ }
62
+
63
+ // Webhooks
64
+ if (pathname.startsWith('/api/webhooks')) {
65
+ return webhookRoutes(ctx);
66
+ }
67
+
68
+ // Audit, pipelines, site settings
69
+ if (pathname.startsWith('/api/audit') || pathname.startsWith('/api/pipelines') || pathname === '/api/site-settings') {
70
+ return auditRoutes(ctx);
71
+ }
72
+
73
+ // Workflow routes (review/approve/reject)
74
+ if (pathname.startsWith('/api/workflow')) {
75
+ return workflowRoutes(ctx);
76
+ }
77
+
78
+ // SSO routes
79
+ if (pathname.startsWith('/api/sso')) {
80
+ return ssoRoutes(ctx);
81
+ }
82
+
83
+ // Theme management routes
84
+ if (pathname.startsWith('/api/theme')) {
85
+ return themeRoutes(ctx);
86
+ }
87
+
88
+ // WeChat integration routes
89
+ if (pathname.startsWith('/api/wechat')) {
90
+ return wechatRoutes(ctx);
91
+ }
92
+
93
+ // Media routes (upload/list/delete)
94
+ if (pathname.startsWith('/api/media')) {
95
+ return mediaRoutes(ctx);
96
+ }
97
+
98
+ // Revision routes (must precede content routes)
99
+ const revMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/([\w-]+)\/(revisions.*)$/);
100
+ if (revMatch) {
101
+ return revisionRoutes(ctx, revMatch[1], revMatch[2]);
102
+ }
103
+
104
+ // Relationship routes (must precede content routes)
105
+ const relMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/([\w-]+)\/(relationships|graph)/);
106
+ if (relMatch) {
107
+ return relationshipRoutes(ctx);
108
+ }
109
+
110
+ // Plugin marketplace routes
111
+ if (pathname.startsWith('/api/plugins')) {
112
+ return pluginMarketplaceRoutes(ctx);
113
+ }
114
+
115
+ // Content API routes
116
+ if (pathname.startsWith('/api')) {
117
+ return apiRoutes(ctx);
118
+ }
119
+
120
+ // Admin SPA static files
121
+ if (pathname.startsWith('/admin')) {
122
+ const served = await serveStatic(ctx, PUBLIC_DIR, pathname);
123
+ if (served) return;
124
+ }
125
+
126
+ // Uploaded media files
127
+ if (pathname.startsWith('/uploads/')) {
128
+ const mediaStore = createMediaStore();
129
+ const relativePath = pathname.slice('/uploads/'.length);
130
+ const served = await serveStatic(ctx, mediaStore.uploadDir, relativePath);
131
+ if (served) return;
132
+ }
133
+
134
+ // Theme static assets
135
+ if (pathname.startsWith('/theme/')) {
136
+ const assetPath = pathname.replace('/theme/', '');
137
+ const served = await serveThemeAsset(ctx, assetPath);
138
+ if (served) return;
139
+ }
140
+
141
+ // Public static files (ws-test.html, etc.)
142
+ {
143
+ const served = await serveStatic(ctx, PUBLIC_DIR, pathname);
144
+ if (served) return;
145
+ }
146
+
147
+ // Health check
148
+ if (pathname === '/health') {
149
+ const mem = process.memoryUsage();
150
+ const { getWSS } = await import('./websocket.js');
151
+ const { getConfig } = await import('./config.js');
152
+ const cfg = getConfig();
153
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
154
+ ctx.res.end(JSON.stringify({
155
+ status: 'ok',
156
+ name: 'taichu',
157
+ version: cfg.version,
158
+ uptime: Math.floor(process.uptime()),
159
+ node: process.version,
160
+ env: cfg.nodeEnv,
161
+ store: cfg.storage,
162
+ memory: {
163
+ rss: Math.round(mem.rss / 1024 / 1024) + 'MB',
164
+ heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + 'MB'
165
+ },
166
+ ws: getWSS().getStats(),
167
+ timestamp: new Date().toISOString()
168
+ }));
169
+ return;
170
+ }
171
+
172
+ // Content Export
173
+ if (pathname.startsWith('/api/export')) {
174
+ const handled = await exportRoutes(ctx);
175
+ if (handled) return;
176
+ }
177
+
178
+ // RSS & Sitemap
179
+ if (pathname === '/rss.xml' || pathname === '/sitemap.xml') {
180
+ return rssSitemapRoutes(ctx);
181
+ }
182
+
183
+ // Frontend Theme — catch-all for non-API, non-admin paths
184
+ if (!pathname.startsWith('/api') && !pathname.startsWith('/admin') && !pathname.startsWith('/uploads')) {
185
+ return renderTheme(ctx);
186
+ }
187
+
188
+ // 404
189
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
190
+ ctx.res.end(JSON.stringify({
191
+ error: 'NOT_FOUND',
192
+ message: `Route not found: ${method} ${pathname}`
193
+ }));
194
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * ActivityPub Routes
3
+ *
4
+ * GET /.well-known/webfinger?resource=acct:taichu@host — WebFinger discovery
5
+ * GET /api/activitypub/actor — Actor JSON-LD
6
+ * GET /api/activitypub/outbox — Activity outbox
7
+ * POST /api/activitypub/inbox — Receive activities
8
+ * GET /api/activitypub/followers — Followers collection
9
+ */
10
+
11
+ import {
12
+ actorObject,
13
+ webfingerResponse,
14
+ createContentActivity,
15
+ processInboxActivity
16
+ } from '../activitypub.js';
17
+ import { getStore } from '../context.js';
18
+ import { createLogger } from '../logger.js';
19
+
20
+ const log = createLogger('ap-routes');
21
+
22
+ const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
23
+
24
+ /** @param {import('../context.js').Context} ctx */
25
+ export async function activityPubRoutes(ctx) {
26
+ const { pathname } = ctx.url;
27
+ const method = ctx.req.method;
28
+
29
+ // WebFinger
30
+ if (pathname === '/.well-known/webfinger' && method === 'GET') {
31
+ const resource = ctx.url.searchParams.get('resource');
32
+ if (!resource) {
33
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
34
+ ctx.res.end(JSON.stringify({ error: 'Missing "resource" parameter' }));
35
+ return;
36
+ }
37
+ const result = webfingerResponse(resource);
38
+ ctx.res.writeHead(200, { 'Content-Type': 'application/jrd+json' });
39
+ ctx.res.end(JSON.stringify(result));
40
+ return;
41
+ }
42
+
43
+ // NodeInfo (for fediverse discovery)
44
+ if (pathname === '/.well-known/nodeinfo' && method === 'GET') {
45
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
46
+ ctx.res.end(JSON.stringify({
47
+ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', href: `${getBaseUrl(ctx)}/api/activitypub/nodeinfo` }]
48
+ }));
49
+ return;
50
+ }
51
+
52
+ // Actor
53
+ if (pathname === '/api/activitypub/actor' && method === 'GET') {
54
+ const actor = actorObject();
55
+ ctx.res.writeHead(200, { 'Content-Type': AP_CONTENT_TYPE });
56
+ ctx.res.end(JSON.stringify(actor));
57
+ return;
58
+ }
59
+
60
+ // Outbox
61
+ if (pathname === '/api/activitypub/outbox' && method === 'GET') {
62
+ const store = getStore();
63
+ const activities = [];
64
+ try {
65
+ // Get recently published content as activities
66
+ const docs = await store.list({ type: 'article', status: 'published', limit: 20, orderBy: 'updated_at', order: 'desc' });
67
+ for (const doc of docs) {
68
+ activities.push(createContentActivity(doc));
69
+ }
70
+ } catch (_) {}
71
+
72
+ const outbox = {
73
+ '@context': 'https://www.w3.org/ns/activitystreams',
74
+ id: `${getBaseUrl(ctx)}/api/activitypub/outbox`,
75
+ type: 'OrderedCollection',
76
+ totalItems: activities.length,
77
+ orderedItems: activities
78
+ };
79
+
80
+ ctx.res.writeHead(200, { 'Content-Type': AP_CONTENT_TYPE });
81
+ ctx.res.end(JSON.stringify(outbox));
82
+ return;
83
+ }
84
+
85
+ // Followers
86
+ if (pathname === '/api/activitypub/followers' && method === 'GET') {
87
+ ctx.res.writeHead(200, { 'Content-Type': AP_CONTENT_TYPE });
88
+ ctx.res.end(JSON.stringify({
89
+ '@context': 'https://www.w3.org/ns/activitystreams',
90
+ id: `${getBaseUrl(ctx)}/api/activitypub/followers`,
91
+ type: 'OrderedCollection',
92
+ totalItems: 0,
93
+ orderedItems: []
94
+ }));
95
+ return;
96
+ }
97
+
98
+ // Inbox (POST only)
99
+ if (pathname === '/api/activitypub/inbox' && method === 'POST') {
100
+ try {
101
+ const result = await processInboxActivity(ctx.body, ctx.req.headers);
102
+ if (result.accepted) {
103
+ ctx.res.writeHead(result.response ? 200 : 202, { 'Content-Type': AP_CONTENT_TYPE });
104
+ ctx.res.end(JSON.stringify(result.response || { accepted: true }));
105
+ } else {
106
+ ctx.res.writeHead(401, { 'Content-Type': 'application/json' });
107
+ ctx.res.end(JSON.stringify({ error: result.reason || 'Rejected' }));
108
+ }
109
+ } catch (err) {
110
+ log.error(`Inbox error: ${err.message}`);
111
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
112
+ ctx.res.end(JSON.stringify({ error: 'Internal error' }));
113
+ }
114
+ return;
115
+ }
116
+
117
+ // NodeInfo endpoint
118
+ if (pathname === '/api/activitypub/nodeinfo' && method === 'GET') {
119
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
120
+ ctx.res.end(JSON.stringify({
121
+ version: '2.0',
122
+ software: { name: 'taichu', version: '0.5.0' },
123
+ protocols: ['activitypub'],
124
+ services: { inbound: [], outbound: [] },
125
+ openRegistrations: false,
126
+ usage: { users: { total: 1 } },
127
+ metadata: { nodeName: 'Taichu CMS' }
128
+ }));
129
+ return;
130
+ }
131
+
132
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
133
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
134
+ }
135
+
136
+ function getBaseUrl(ctx) {
137
+ const proto = ctx.req.headers['x-forwarded-proto'] || 'http';
138
+ const host = ctx.req.headers['x-forwarded-host'] || ctx.req.headers.host || 'localhost';
139
+ return `${proto}://${host}`;
140
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * API Routes
3
+ *
4
+ * 所有 /api/* 的路由处理。
5
+ *
6
+ * 端点设计:
7
+ * GET /api/content/:type — 列出某类型的所有文档
8
+ * GET /api/content/:type/:id — 获取单个文档
9
+ * POST /api/content/:type — 创建文档
10
+ * PUT /api/content/:type/:id — 更新文档
11
+ * DELETE /api/content/:type/:id — 删除文档
12
+ * GET /api/content-types — 列出所有已注册的内容类型
13
+ * GET /api/content-types/:name — 获取内容类型 Schema
14
+ * GET /api/health — 健康检查
15
+ */
16
+
17
+ import { NotFoundError, ValidationError } from '../../../core/src/errors.js';
18
+ import { search as vectorSearch } from '../search.js';
19
+ import { requireAuth, requireScopedAuth, optionalAuth } from '../middleware/auth.js';
20
+
21
+ // Built-in content type registry
22
+ // Plugins/extensions can register additional types via hooks
23
+ const _contentTypes = new Map();
24
+
25
+ /**
26
+ * Register a content type for API exposure.
27
+ */
28
+ export function registerContentType(ct) {
29
+ _contentTypes.set(ct.name, ct);
30
+ }
31
+
32
+ /**
33
+ * Get all registered content types (for GraphQL resolver).
34
+ */
35
+ export function getContentTypes() {
36
+ return Array.from(_contentTypes.values()).map(ct => ({
37
+ name: ct.name,
38
+ label: ct.label,
39
+ description: ct.description,
40
+ schemaOrg: ct.schemaOrg || null,
41
+ fieldCount: Object.keys(ct.fields).length
42
+ }));
43
+ }
44
+
45
+ /**
46
+ * @param {import('../context.js').Context} ctx
47
+ */
48
+ export async function apiRoutes(ctx) {
49
+ const { pathname } = ctx.url;
50
+ const method = ctx.req.method;
51
+
52
+ // /api/health
53
+ if (pathname === '/api/health') {
54
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
55
+ ctx.res.end(JSON.stringify({
56
+ status: 'ok',
57
+ name: 'taichu',
58
+ version: '0.1.0',
59
+ uptime: process.uptime()
60
+ }));
61
+ return;
62
+ }
63
+
64
+ // /api/search?q=xxx&type=article
65
+ if (pathname === '/api/search' && method === 'GET') {
66
+ const q = ctx.url.searchParams.get('q') || '';
67
+ const type = ctx.url.searchParams.get('type') || null;
68
+
69
+ if (!q || q.length < 2) {
70
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
71
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'Query must be at least 2 characters' }));
72
+ return;
73
+ }
74
+
75
+ const results = vectorSearch(q, 20);
76
+ const docs = [];
77
+ for (const { docId, score } of results) {
78
+ try {
79
+ const doc = await ctx.store.get(docId);
80
+ if (doc && (!type || doc.type === type)) {
81
+ docs.push({ ...doc, _score: Math.round(score * 100) / 100 });
82
+ }
83
+ } catch (e) { /* skip */ }
84
+ }
85
+
86
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
87
+ ctx.res.end(JSON.stringify({ query: q, docs, total: docs.length }));
88
+ return;
89
+ }
90
+
91
+ // /api/content-types
92
+ if (pathname === '/api/content-types' && method === 'GET') {
93
+ const types = Array.from(_contentTypes.values()).map(ct => ({
94
+ name: ct.name,
95
+ label: ct.label,
96
+ description: ct.description,
97
+ schemaOrg: ct.schemaOrg,
98
+ fields: ct.fields,
99
+ fieldCount: Object.keys(ct.fields).length
100
+ }));
101
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
102
+ ctx.res.end(JSON.stringify({ types }));
103
+ return;
104
+ }
105
+
106
+ // /api/content-types/:name
107
+ const ctMatch = pathname.match(/^\/api\/content-types\/([a-z][a-z0-9_]*)$/);
108
+ if (ctMatch && method === 'GET') {
109
+ const ct = _contentTypes.get(ctMatch[1]);
110
+ if (!ct) {
111
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
112
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Content type "${ctMatch[1]}" not found` }));
113
+ return;
114
+ }
115
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
116
+ ctx.res.end(JSON.stringify(ct.toJSONSchema()));
117
+ return;
118
+ }
119
+
120
+ // /api/content/:type
121
+ const listMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)$/);
122
+ if (listMatch && method === 'GET') {
123
+ // Auth required by default; set TAICHU_PUBLIC_READ=1 to allow anonymous GET
124
+ if (!process.env.TAICHU_PUBLIC_READ) {
125
+ const authResult = await requireAuth(ctx);
126
+ if (!authResult.authenticated) {
127
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
128
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
129
+ return;
130
+ }
131
+ ctx.actor = authResult.actor;
132
+ } else {
133
+ await optionalAuth(ctx);
134
+ }
135
+
136
+ const type = listMatch[1];
137
+ const queryOpts = Object.fromEntries(ctx.url.searchParams);
138
+ // Multi-tenant: enforce tenant filter unless admin explicitly overrides
139
+ if (ctx.multiTenant && !queryOpts.tenantId) {
140
+ queryOpts.tenantId = ctx.tenantId;
141
+ }
142
+ const docs = await ctx.store.list({ type, ...queryOpts });
143
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
144
+ ctx.res.end(JSON.stringify({ docs, total: docs.length }));
145
+ return;
146
+ }
147
+
148
+ if (listMatch && method === 'POST') {
149
+ // Require scoped auth for content creation
150
+ // Exception: comments can be submitted publicly
151
+ const type = listMatch[1];
152
+ if (type !== 'comment') {
153
+ const authResult = await requireScopedAuth(ctx, `${type}:write`);
154
+ if (!authResult.authenticated) {
155
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
156
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
157
+ return;
158
+ }
159
+ ctx.actor = authResult.actor;
160
+ } else {
161
+ await optionalAuth(ctx);
162
+ }
163
+ const ct = _contentTypes.get(type);
164
+ if (!ct) {
165
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
166
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: `Unknown content type: "${type}"` }));
167
+ return;
168
+ }
169
+
170
+ // Validate
171
+ const validation = ct.validate(ctx.body?.data || {});
172
+ if (!validation.valid) {
173
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
174
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', errors: validation.errors }));
175
+ return;
176
+ }
177
+
178
+ // Scheduled publishing: validate publishedAt when status='scheduled'
179
+ const requestedStatus = ctx.body.status;
180
+ if (requestedStatus === 'scheduled') {
181
+ const pubAt = ctx.body.publishedAt || ctx.body.data?.publishedAt;
182
+ if (!pubAt) {
183
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
184
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt is required when status is "scheduled"' }));
185
+ return;
186
+ }
187
+ if (new Date(pubAt) <= new Date()) {
188
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
189
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt must be in the future for scheduled content' }));
190
+ return;
191
+ }
192
+ }
193
+
194
+ // Run beforeCreate hooks
195
+ let payload = { type, data: ctx.body.data, status: ctx.body.status, publishedAt: ctx.body.publishedAt || null, tenantId: ctx.tenantId };
196
+ payload = await ctx.hooks.run('beforeCreate', payload, ctx);
197
+
198
+ const doc = await ctx.store.create(payload);
199
+
200
+ // Run afterCreate hooks
201
+ await ctx.hooks.run('afterCreate', doc, ctx);
202
+
203
+ ctx.res.writeHead(201, { 'Content-Type': 'application/json' });
204
+ ctx.res.end(JSON.stringify(doc));
205
+ return;
206
+ }
207
+
208
+ // /api/content/:type/batch — bulk operations
209
+ const batchMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/batch$/);
210
+ if (batchMatch && method === 'POST') {
211
+ const authResult = await requireScopedAuth(ctx, `${batchMatch[1]}:write`);
212
+ if (!authResult.authenticated) {
213
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
214
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
215
+ return;
216
+ }
217
+ ctx.actor = authResult.actor;
218
+
219
+ const { action, ids } = ctx.body || {};
220
+ if (!action || !Array.isArray(ids) || !ids.length) {
221
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
222
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'action and ids[] are required' }));
223
+ return;
224
+ }
225
+
226
+ const validActions = ['delete', 'publish', 'archive'];
227
+ if (!validActions.includes(action)) {
228
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
229
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: `action must be one of: ${validActions.join(', ')}` }));
230
+ return;
231
+ }
232
+
233
+ const results = { success: 0, failed: 0, errors: [] };
234
+ for (const id of ids) {
235
+ try {
236
+ const doc = await ctx.store.get(id);
237
+ if (!doc || doc.type !== batchMatch[1]) {
238
+ results.failed++;
239
+ results.errors.push({ id, error: 'Not found or wrong type' });
240
+ continue;
241
+ }
242
+
243
+ if (action === 'delete') {
244
+ await ctx.store.delete(id);
245
+ await ctx.hooks.run('afterDelete', { id, type: batchMatch[1] }, ctx);
246
+ } else {
247
+ await ctx.store.update(id, { status: action === 'publish' ? 'published' : 'archived' });
248
+ }
249
+ results.success++;
250
+ } catch (e) {
251
+ results.failed++;
252
+ results.errors.push({ id, error: e.message });
253
+ }
254
+ }
255
+
256
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
257
+ ctx.res.end(JSON.stringify(results));
258
+ return;
259
+ }
260
+
261
+ // /api/content/:type/:id
262
+ const itemMatch = pathname.match(/^\/api\/content\/([a-z][a-z0-9_]*)\/([\w-]+)$/);
263
+ if (itemMatch) {
264
+ const [, type, id] = itemMatch;
265
+
266
+ if (method === 'GET') {
267
+ if (!process.env.TAICHU_PUBLIC_READ) {
268
+ const authResult = await requireAuth(ctx);
269
+ if (!authResult.authenticated) {
270
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
271
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
272
+ return;
273
+ }
274
+ ctx.actor = authResult.actor;
275
+ } else {
276
+ await optionalAuth(ctx);
277
+ }
278
+
279
+ const doc = await ctx.store.get(id);
280
+ if (!doc) {
281
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
282
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Document "${id}" not found` }));
283
+ return;
284
+ }
285
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
286
+ ctx.res.end(JSON.stringify(doc));
287
+ return;
288
+ }
289
+
290
+ if (method === 'PUT') {
291
+ const authResult = await requireScopedAuth(ctx, `${type}:write`);
292
+ if (!authResult.authenticated) {
293
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
294
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
295
+ return;
296
+ }
297
+ ctx.actor = authResult.actor;
298
+
299
+ // Scheduled publishing: validate publishedAt when status='scheduled'
300
+ const requestedStatus = ctx.body.status;
301
+ if (requestedStatus === 'scheduled') {
302
+ const pubAt = ctx.body.publishedAt || ctx.body.data?.publishedAt;
303
+ if (!pubAt) {
304
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
305
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt is required when status is "scheduled"' }));
306
+ return;
307
+ }
308
+ if (new Date(pubAt) <= new Date()) {
309
+ ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
310
+ ctx.res.end(JSON.stringify({ error: 'VALIDATION_ERROR', message: 'publishedAt must be in the future for scheduled content' }));
311
+ return;
312
+ }
313
+ }
314
+
315
+ let payload = { id, type, data: ctx.body.data, status: ctx.body.status, publishedAt: ctx.body.publishedAt || undefined };
316
+ payload = await ctx.hooks.run('beforeUpdate', payload, ctx);
317
+
318
+ const doc = await ctx.store.update(id, payload);
319
+ if (!doc) {
320
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
321
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Document "${id}" not found` }));
322
+ return;
323
+ }
324
+
325
+ await ctx.hooks.run('afterUpdate', doc, ctx);
326
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
327
+ ctx.res.end(JSON.stringify(doc));
328
+ return;
329
+ }
330
+
331
+ if (method === 'DELETE') {
332
+ const authResult = await requireScopedAuth(ctx, `${type}:delete`);
333
+ if (!authResult.authenticated) {
334
+ ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
335
+ ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
336
+ return;
337
+ }
338
+ ctx.actor = authResult.actor;
339
+
340
+ const basePayload = { id, type };
341
+ const payload = await ctx.hooks.run('beforeDelete', basePayload, ctx);
342
+
343
+ const deleted = await ctx.store.delete(id);
344
+ if (!deleted) {
345
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
346
+ ctx.res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Document "${id}" not found` }));
347
+ return;
348
+ }
349
+
350
+ await ctx.hooks.run('afterDelete', { id, type }, ctx);
351
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
352
+ ctx.res.end(JSON.stringify({ success: true }));
353
+ return;
354
+ }
355
+ }
356
+
357
+ // 404 for API
358
+ ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
359
+ ctx.res.end(JSON.stringify({
360
+ error: 'NOT_FOUND',
361
+ message: `API route not found: ${method} ${pathname}`
362
+ }));
363
+ }