@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,244 @@
1
+ /**
2
+ * Content Type — 内容类型的 Schema 定义
3
+ *
4
+ * 类比 WordPress 的 post_type,但:
5
+ * - 字段是强类型的(string / number / boolean / relation / media / json)
6
+ * - 内置 semantic layer(schema.org 映射)
7
+ * - 支持字段验证规则
8
+ * - Agent 可遍历的元数据
9
+ *
10
+ * 使用示例:
11
+ * const Article = createContentType('article', {
12
+ * label: '文章',
13
+ * description: '博客文章内容类型',
14
+ * schemaOrg: 'Article',
15
+ * fields: {
16
+ * title: { type: 'string', required: true, maxLength: 200 },
17
+ * slug: { type: 'string', required: true, pattern: /^[a-z0-9-]+$/ },
18
+ * body: { type: 'json', required: true },
19
+ * excerpt: { type: 'string', maxLength: 500 },
20
+ * featuredImage: { type: 'media' },
21
+ * tags: { type: 'array', items: { type: 'string' } },
22
+ * category: { type: 'relation', target: 'category' },
23
+ * status: { type: 'enum', values: ['draft', 'published', 'archived'] },
24
+ * publishedAt: { type: 'datetime' }
25
+ * }
26
+ * });
27
+ */
28
+
29
+ import { ValidationError } from './errors.js';
30
+
31
+ const VALID_FIELD_TYPES = [
32
+ 'string', 'number', 'boolean',
33
+ 'json', 'array', 'enum',
34
+ 'datetime', 'media', 'relation'
35
+ ];
36
+
37
+ /**
38
+ * @param {string} name — 内容类型标识符,如 'article', 'category', 'page'
39
+ * @param {object} definition
40
+ * @param {string} definition.label — 人类可读名称
41
+ * @param {string} [definition.description]
42
+ * @param {string} [definition.schemaOrg] — schema.org 类型映射,如 'Article', 'Product'
43
+ * @param {Record<string, FieldDef>} definition.fields
44
+ * @returns {ContentType}
45
+ */
46
+ export function createContentType(name, definition) {
47
+ if (!name || typeof name !== 'string') {
48
+ throw new ValidationError('Content type name is required and must be a string');
49
+ }
50
+ if (!/^[a-z][a-z0-9_]*$/.test(name)) {
51
+ throw new ValidationError(`Invalid content type name: "${name}". Must be lowercase, start with a letter, and contain only letters, numbers, and underscores.`);
52
+ }
53
+ if (!definition || !definition.fields) {
54
+ throw new ValidationError(`Content type "${name}" must define at least one field`);
55
+ }
56
+
57
+ const fields = {};
58
+ const requiredFields = [];
59
+
60
+ for (const [fieldName, fieldDef] of Object.entries(definition.fields)) {
61
+ const def = normalizeFieldDef(fieldName, fieldDef);
62
+ fields[fieldName] = def;
63
+ if (def.required) requiredFields.push(fieldName);
64
+ }
65
+
66
+ const ct = {
67
+ name,
68
+ label: definition.label || name,
69
+ description: definition.description || '',
70
+ schemaOrg: definition.schemaOrg || null,
71
+ fields,
72
+ requiredFields,
73
+ features: definition.features || {},
74
+
75
+ /**
76
+ * Validate a document against this content type's schema.
77
+ * Returns { valid: true } or { valid: false, errors: [...] }
78
+ */
79
+ validate(doc) {
80
+ const errors = [];
81
+ if (!doc || typeof doc !== 'object') {
82
+ return { valid: false, errors: ['Document must be an object'] };
83
+ }
84
+
85
+ for (const fieldName of requiredFields) {
86
+ if (doc[fieldName] === undefined || doc[fieldName] === null || doc[fieldName] === '') {
87
+ errors.push(`Field "${fieldName}" is required`);
88
+ }
89
+ }
90
+
91
+ for (const [fieldName, value] of Object.entries(doc)) {
92
+ const fieldDef = fields[fieldName];
93
+ if (!fieldDef) {
94
+ // Unknown fields are silently accepted (extensibility)
95
+ continue;
96
+ }
97
+ const fieldErrors = validateField(fieldName, value, fieldDef);
98
+ errors.push(...fieldErrors);
99
+ }
100
+
101
+ return errors.length === 0
102
+ ? { valid: true }
103
+ : { valid: false, errors };
104
+ },
105
+
106
+ /**
107
+ * Return a JSON Schema representation of this content type.
108
+ * Useful for API documentation and OpenAPI generation.
109
+ */
110
+ toJSONSchema() {
111
+ const properties = {};
112
+ const required = [...requiredFields];
113
+
114
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
115
+ properties[fieldName] = fieldToJSONSchema(fieldDef);
116
+ }
117
+
118
+ return {
119
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
120
+ title: this.label,
121
+ description: this.description,
122
+ type: 'object',
123
+ properties,
124
+ required: required.length > 0 ? required : undefined
125
+ };
126
+ }
127
+ };
128
+
129
+ // Freeze to prevent runtime mutations
130
+ return Object.freeze(ct);
131
+ }
132
+
133
+ function normalizeFieldDef(name, raw) {
134
+ if (typeof raw === 'string') {
135
+ return { type: raw, required: false, label: name };
136
+ }
137
+
138
+ const type = raw.type || 'string';
139
+ if (!VALID_FIELD_TYPES.includes(type)) {
140
+ throw new ValidationError(
141
+ `Field "${name}": invalid type "${type}". Valid types: ${VALID_FIELD_TYPES.join(', ')}`
142
+ );
143
+ }
144
+
145
+ return {
146
+ type,
147
+ required: raw.required || false,
148
+ label: raw.label || name,
149
+ description: raw.description || '',
150
+ default: raw.default,
151
+ maxLength: raw.maxLength,
152
+ pattern: raw.pattern ? raw.pattern.source : undefined,
153
+ values: raw.values, // for enum
154
+ items: raw.items || null, // for array
155
+ target: raw.target || null, // for relation
156
+ indexed: raw.indexed !== false, // default indexed
157
+ semantic: raw.semantic || null // schema.org property mapping
158
+ };
159
+ }
160
+
161
+ function validateField(fieldName, value, fieldDef) {
162
+ const errors = [];
163
+
164
+ if (value === undefined || value === null) {
165
+ if (fieldDef.required) {
166
+ errors.push(`Field "${fieldName}" is required`);
167
+ }
168
+ return errors;
169
+ }
170
+
171
+ switch (fieldDef.type) {
172
+ case 'string':
173
+ if (typeof value !== 'string') errors.push(`Field "${fieldName}" must be a string`);
174
+ else if (fieldDef.maxLength && value.length > fieldDef.maxLength) {
175
+ errors.push(`Field "${fieldName}" exceeds max length of ${fieldDef.maxLength}`);
176
+ }
177
+ break;
178
+ case 'number':
179
+ if (typeof value !== 'number') errors.push(`Field "${fieldName}" must be a number`);
180
+ break;
181
+ case 'boolean':
182
+ if (typeof value !== 'boolean') errors.push(`Field "${fieldName}" must be a boolean`);
183
+ break;
184
+ case 'enum':
185
+ if (!fieldDef.values.includes(value)) {
186
+ errors.push(`Field "${fieldName}" must be one of: ${fieldDef.values.join(', ')}`);
187
+ }
188
+ break;
189
+ case 'array':
190
+ if (!Array.isArray(value)) errors.push(`Field "${fieldName}" must be an array`);
191
+ break;
192
+ case 'json':
193
+ // Accept any valid JSON-serializable value
194
+ break;
195
+ case 'datetime':
196
+ if (typeof value !== 'string' || isNaN(Date.parse(value))) {
197
+ errors.push(`Field "${fieldName}" must be a valid datetime string`);
198
+ }
199
+ break;
200
+ case 'date':
201
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
202
+ errors.push(`Field "${fieldName}" must be a valid date string (YYYY-MM-DD)`);
203
+ }
204
+ break;
205
+ case 'reference':
206
+ if (typeof value !== 'string' || !value) {
207
+ errors.push(`Field "${fieldName}" must be a valid reference ID`);
208
+ }
209
+ break;
210
+ case 'media':
211
+ // Media can be a string (id) or object (with url, alt, etc.)
212
+ if (typeof value !== 'string' && typeof value !== 'object') {
213
+ errors.push(`Field "${fieldName}" must be a media reference`);
214
+ }
215
+ break;
216
+ case 'relation':
217
+ // Relation can be a string (id) or array of strings
218
+ if (typeof value !== 'string' && !Array.isArray(value)) {
219
+ errors.push(`Field "${fieldName}" must be a relation reference`);
220
+ }
221
+ break;
222
+ }
223
+
224
+ return errors;
225
+ }
226
+
227
+ function fieldToJSONSchema(fieldDef) {
228
+ const base = { title: fieldDef.label, description: fieldDef.description };
229
+
230
+ switch (fieldDef.type) {
231
+ case 'string': return { ...base, type: 'string', maxLength: fieldDef.maxLength };
232
+ case 'number': return { ...base, type: 'number' };
233
+ case 'boolean': return { ...base, type: 'boolean' };
234
+ case 'enum': return { ...base, type: 'string', enum: fieldDef.values };
235
+ case 'date': return { ...base, type: 'string', format: 'date' };
236
+ case 'datetime': return { ...base, type: 'string', format: 'date-time' };
237
+ case 'reference': return { ...base, type: 'string', description: `Reference to ${fieldDef.refType || 'document'}` };
238
+ case 'array': return { ...base, type: 'array', items: fieldDef.items || { type: 'string' } };
239
+ case 'json': return { ...base }; // free-form
240
+ case 'media': return { ...base, oneOf: [{ type: 'string' }, { type: 'object' }] };
241
+ case 'relation': return { ...base, oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] };
242
+ default: return base;
243
+ }
244
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * @taichu/core — 核心模块测试
3
+ *
4
+ * 运行: node --test packages/core/src/core.test.js
5
+ */
6
+
7
+ import { describe, it } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+
10
+ import { createContentType } from './content-type.js';
11
+ import { createMemoryStore } from './store.js';
12
+ import { createHookSystem } from './hooks.js';
13
+ import { hashPassword, verifyPassword, signJWT, verifyJWT, generateAPIKey, verifyAPIKey } from './auth.js';
14
+ import { TaichuError, ValidationError, NotFoundError, UnauthorizedError, ForbiddenError, ConflictError } from './errors.js';
15
+ import { createHmac } from 'node:crypto';
16
+
17
+ // ════════════════════════════════════════════════════════════
18
+ // Content Type
19
+ // ════════════════════════════════════════════════════════════
20
+
21
+ describe('ContentType', () => {
22
+ it('should create a content type with fields', () => {
23
+ const Article = createContentType('article', {
24
+ label: '文章',
25
+ fields: {
26
+ title: { type: 'string', required: true, maxLength: 200 },
27
+ slug: { type: 'string', required: true },
28
+ tags: { type: 'array', items: { type: 'string' } }
29
+ }
30
+ });
31
+
32
+ assert.equal(Article.name, 'article');
33
+ assert.equal(Article.label, '文章');
34
+ assert.equal(Object.keys(Article.fields).length, 3);
35
+ });
36
+
37
+ it('should validate a valid document', () => {
38
+ const Article = createContentType('article', {
39
+ fields: {
40
+ title: { type: 'string', required: true },
41
+ body: { type: 'json' }
42
+ }
43
+ });
44
+
45
+ const result = Article.validate({ title: 'Hello', body: {} });
46
+ assert.equal(result.valid, true);
47
+ });
48
+
49
+ it('should reject missing required fields', () => {
50
+ const Article = createContentType('article', {
51
+ fields: { title: { type: 'string', required: true } }
52
+ });
53
+
54
+ const result = Article.validate({});
55
+ assert.equal(result.valid, false);
56
+ assert.equal(result.errors.length, 1);
57
+ });
58
+
59
+ it('should validate string maxLength', () => {
60
+ const Article = createContentType('article', {
61
+ fields: { title: { type: 'string', maxLength: 10 } }
62
+ });
63
+
64
+ const result = Article.validate({ title: 'this is way too long' });
65
+ assert.equal(result.valid, false);
66
+ });
67
+
68
+ it('should validate enum values', () => {
69
+ const Article = createContentType('article', {
70
+ fields: { status: { type: 'enum', values: ['draft', 'published'] } }
71
+ });
72
+
73
+ assert.equal(Article.validate({ status: 'draft' }).valid, true);
74
+ assert.equal(Article.validate({ status: 'deleted' }).valid, false);
75
+ });
76
+
77
+ it('should export JSON Schema', () => {
78
+ const Article = createContentType('article', {
79
+ label: '文章',
80
+ schemaOrg: 'Article',
81
+ fields: {
82
+ title: { type: 'string', required: true },
83
+ tags: { type: 'array', items: { type: 'string' } }
84
+ }
85
+ });
86
+
87
+ const schema = Article.toJSONSchema();
88
+ assert.equal(schema.title, '文章');
89
+ assert.equal(schema.type, 'object');
90
+ assert.ok(schema.required.includes('title'));
91
+ });
92
+ });
93
+
94
+ // ════════════════════════════════════════════════════════════
95
+ // Store (Memory)
96
+ // ════════════════════════════════════════════════════════════
97
+
98
+ describe('MemoryStore', () => {
99
+ it('should create and retrieve a document', async () => {
100
+ const store = createMemoryStore();
101
+ const doc = await store.create({ type: 'article', data: { title: 'Test' } });
102
+
103
+ assert.ok(doc.id);
104
+ assert.equal(doc.type, 'article');
105
+ assert.equal(doc.data.title, 'Test');
106
+ assert.equal(doc.status, 'draft');
107
+
108
+ const got = await store.get(doc.id);
109
+ assert.equal(got.data.title, 'Test');
110
+ });
111
+
112
+ it('should list documents by type', async () => {
113
+ const store = createMemoryStore();
114
+ await store.create({ type: 'article', data: { title: 'A' } });
115
+ await store.create({ type: 'article', data: { title: 'B' } });
116
+ await store.create({ type: 'page', data: { title: 'C' } });
117
+
118
+ const articles = await store.list({ type: 'article' });
119
+ assert.equal(articles.length, 2);
120
+
121
+ const pages = await store.list({ type: 'page' });
122
+ assert.equal(pages.length, 1);
123
+ });
124
+
125
+ it('should filter by status', async () => {
126
+ const store = createMemoryStore();
127
+ await store.create({ type: 'article', data: { title: 'A' }, status: 'draft' });
128
+ await store.create({ type: 'article', data: { title: 'B' }, status: 'published' });
129
+
130
+ const drafts = await store.list({ type: 'article', status: 'draft' });
131
+ assert.equal(drafts.length, 1);
132
+ });
133
+
134
+ it('should update a document', async () => {
135
+ const store = createMemoryStore();
136
+ const doc = await store.create({ type: 'article', data: { title: 'Old' } });
137
+
138
+ const updated = await store.update(doc.id, { data: { title: 'New' } });
139
+ assert.equal(updated.data.title, 'New');
140
+ });
141
+
142
+ it('should delete a document', async () => {
143
+ const store = createMemoryStore();
144
+ const doc = await store.create({ type: 'article', data: {} });
145
+
146
+ const deleted = await store.delete(doc.id);
147
+ assert.equal(deleted, true);
148
+
149
+ const got = await store.get(doc.id);
150
+ assert.equal(got, null);
151
+ });
152
+
153
+ it('should count documents', async () => {
154
+ const store = createMemoryStore();
155
+ await store.create({ type: 'article', data: {} });
156
+ await store.create({ type: 'article', data: {} });
157
+ await store.create({ type: 'page', data: {} });
158
+
159
+ const count = await store.count({ type: 'article' });
160
+ assert.equal(count, 2);
161
+ });
162
+ });
163
+
164
+ // ════════════════════════════════════════════════════════════
165
+ // Hook System
166
+ // ════════════════════════════════════════════════════════════
167
+
168
+ describe('HookSystem', () => {
169
+ it('should run registered hooks', async () => {
170
+ const hooks = createHookSystem();
171
+ const calls = [];
172
+
173
+ hooks.on('test', async (payload) => {
174
+ calls.push(payload);
175
+ });
176
+
177
+ await hooks.run('test', 'hello');
178
+ assert.deepEqual(calls, ['hello']);
179
+ });
180
+
181
+ it('should run hooks in priority order', async () => {
182
+ const hooks = createHookSystem();
183
+ const order = [];
184
+
185
+ hooks.on('test', () => { order.push('low'); }, 20);
186
+ hooks.on('test', () => { order.push('high'); }, 5);
187
+
188
+ await hooks.run('test', null);
189
+ assert.deepEqual(order, ['high', 'low']);
190
+ });
191
+
192
+ it('should pass payload through hooks', async () => {
193
+ const hooks = createHookSystem();
194
+
195
+ hooks.on('transform', async (payload) => (typeof payload === 'string' ? payload.toUpperCase() : null));
196
+
197
+ const result = await hooks.run('transform', 'hello');
198
+ assert.equal(result, 'HELLO');
199
+ });
200
+
201
+ it('should stop chain when handler returns null', async () => {
202
+ const hooks = createHookSystem();
203
+ const calls = [];
204
+
205
+ hooks.on('test', () => { calls.push(1); return null; });
206
+ hooks.on('test', () => { calls.push(2); });
207
+
208
+ await hooks.run('test', null);
209
+ assert.deepEqual(calls, [1]);
210
+ });
211
+
212
+ it('should deregister hooks', async () => {
213
+ const hooks = createHookSystem();
214
+ const calls = [];
215
+
216
+ const dereg = hooks.on('test', () => { calls.push(1); });
217
+ dereg();
218
+ await hooks.run('test', null);
219
+
220
+ assert.deepEqual(calls, []);
221
+ });
222
+ });
223
+
224
+ // ════════════════════════════════════════════════════════════
225
+ // Auth
226
+ // ════════════════════════════════════════════════════════════
227
+
228
+ describe('Password Hashing', () => {
229
+ it('should hash and verify a password', () => {
230
+ const hashed = hashPassword('taichu2026');
231
+ assert.ok(hashed.startsWith('pbkdf2_sha256$'));
232
+
233
+ assert.equal(verifyPassword('taichu2026', hashed), true);
234
+ });
235
+
236
+ it('should reject wrong password', () => {
237
+ const hashed = hashPassword('correct');
238
+ assert.equal(verifyPassword('wrong', hashed), false);
239
+ });
240
+
241
+ it('should produce different hashes for same password', () => {
242
+ const h1 = hashPassword('test');
243
+ const h2 = hashPassword('test');
244
+ assert.notEqual(h1, h2, 'Salt should produce different hashes');
245
+ assert.equal(verifyPassword('test', h1), true);
246
+ assert.equal(verifyPassword('test', h2), true);
247
+ });
248
+
249
+ it('should handle malformed hash gracefully', () => {
250
+ assert.equal(verifyPassword('test', 'bad_hash'), false);
251
+ });
252
+ });
253
+
254
+ describe('JWT', () => {
255
+ const secret = 'test-secret-key-32-chars-long!!';
256
+
257
+ it('should sign and verify a JWT', () => {
258
+ const token = signJWT({ sub: 'user-1', role: 'admin' }, secret, { expiresIn: '1h' });
259
+ assert.ok(typeof token === 'string');
260
+
261
+ const result = verifyJWT(token, secret);
262
+ assert.equal(result.valid, true);
263
+ assert.equal(result.payload.sub, 'user-1');
264
+ assert.equal(result.payload.role, 'admin');
265
+ });
266
+
267
+ it('should reject tokens with exp in the past', () => {
268
+ const expiredPayload = { sub: 'user-1', iat: Math.floor(Date.now() / 1000) - 7200, exp: Math.floor(Date.now() / 1000) - 3600 };
269
+ const header = JSON.stringify({ alg: 'HS256', typ: 'JWT' });
270
+ const headerB64 = Buffer.from(header).toString('base64url');
271
+ const payloadB64 = Buffer.from(JSON.stringify(expiredPayload)).toString('base64url');
272
+ const sig = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest('base64url');
273
+ const token = `${headerB64}.${payloadB64}.${sig}`;
274
+
275
+ const result = verifyJWT(token, secret);
276
+ assert.equal(result.valid, false);
277
+ assert.equal(result.error, 'Token expired');
278
+ });
279
+
280
+ it('should reject tokens with wrong secret', () => {
281
+ const token = signJWT({ sub: 'user-1' }, secret);
282
+ const result = verifyJWT(token, 'wrong-secret');
283
+ assert.equal(result.valid, false);
284
+ });
285
+ });
286
+
287
+ describe('API Key', () => {
288
+ it('should generate a valid API key', () => {
289
+ const key = generateAPIKey('Test Agent');
290
+ assert.ok(key.key.startsWith('taichu_'));
291
+ assert.ok(key.prefix.startsWith('taichu_'));
292
+ assert.equal(key.label, 'Test Agent');
293
+ assert.equal(key.key.length, 71); // taichu_ + 64 hex chars
294
+ });
295
+
296
+ it('should verify a valid API key', () => {
297
+ const key = generateAPIKey('Test');
298
+ assert.equal(verifyAPIKey(key.key, key.hash), true);
299
+ });
300
+
301
+ it('should reject invalid API key', () => {
302
+ const key = generateAPIKey('Test');
303
+ assert.equal(verifyAPIKey('taichu_fake', key.hash), false);
304
+ });
305
+
306
+ it('should produce different keys each time', () => {
307
+ const k1 = generateAPIKey();
308
+ const k2 = generateAPIKey();
309
+ assert.notEqual(k1.key, k2.key);
310
+ assert.notEqual(k1.hash, k2.hash);
311
+ });
312
+ });
313
+
314
+ // ════════════════════════════════════════════════════════════
315
+ // Errors
316
+ // ════════════════════════════════════════════════════════════
317
+
318
+ describe('TaichuError', () => {
319
+ it('should create a base error', () => {
320
+ const err = new TaichuError('test');
321
+ assert.equal(err.message, 'test');
322
+ assert.equal(err.status, 500);
323
+ assert.equal(err.code, 'TAICHU_ERROR');
324
+ });
325
+
326
+ it('should create typed errors with correct status codes', () => {
327
+ assert.equal(new ValidationError('bad').status, 400);
328
+ assert.equal(new NotFoundError('missing').status, 404);
329
+ assert.equal(new UnauthorizedError('nope').status, 401);
330
+ assert.equal(new ForbiddenError('no').status, 403);
331
+ assert.equal(new ConflictError('dup').status, 409);
332
+ });
333
+
334
+ it('should serialize to JSON', () => {
335
+ const err = new ValidationError('Invalid title');
336
+ const json = err.toJSON();
337
+ assert.equal(json.error, 'VALIDATION_ERROR');
338
+ assert.equal(json.message, 'Invalid title');
339
+ assert.equal(json.status, 400);
340
+ });
341
+
342
+ it('should report type conflicts', () => {
343
+ try {
344
+ new ConflictError('duplicate');
345
+ assert.ok(true);
346
+ } catch {
347
+ assert.fail('Should not throw');
348
+ }
349
+ });
350
+ });
351
+
352
+ // ════════════════════════════════════════════════════════════
353
+ // Tokenizer
354
+ // ════════════════════════════════════════════════════════════
355
+
356
+ describe('Tokenizer', () => {
357
+ it('should tokenize Chinese text (n-gram fallback)', async () => {
358
+ const { tokenize } = await import('./tokenizer.js');
359
+ const tokens = tokenize('人工智能正在改变内容管理');
360
+ assert.ok(tokens.length > 0);
361
+ // Should find 2-grams like 人工, 工智, 智能 etc.
362
+ assert.ok(tokens.includes('智能'));
363
+ });
364
+
365
+ it('should tokenize English text', async () => {
366
+ const { tokenize } = await import('./tokenizer.js');
367
+ const tokens = tokenize('AI is changing content management');
368
+ assert.ok(tokens.includes('changing'));
369
+ assert.ok(tokens.includes('content'));
370
+ });
371
+
372
+ it('should filter stopwords', async () => {
373
+ const { tokenize } = await import('./tokenizer.js');
374
+ const tokens = tokenize('这是一个测试内容');
375
+ assert.ok(!tokens.includes('了'));
376
+ assert.ok(!tokens.includes('的'));
377
+ });
378
+ });
379
+
380
+ // ════════════════════════════════════════════════════════════
381
+ // Vector Index
382
+ // ════════════════════════════════════════════════════════════
383
+
384
+ describe('VectorIndex', () => {
385
+ it('should index and search documents', async () => {
386
+ const { TFIDFIndex } = await import('./vector-index.js');
387
+ const idx = new TFIDFIndex();
388
+ idx.add('1', 'artificial intelligence content management');
389
+ idx.add('2', 'machine learning deep learning neural networks');
390
+ idx.add('3', 'content management system headless cms');
391
+
392
+ const results = idx.search('content management system');
393
+ assert.ok(results.length > 0);
394
+ // '3' has "content management system headless cms" — most relevant
395
+ assert.equal(results[0].docId, '3');
396
+ });
397
+
398
+ it('should return empty for no match', async () => {
399
+ const { TFIDFIndex } = await import('./vector-index.js');
400
+ const idx = new TFIDFIndex();
401
+ idx.add('1', 'hello world');
402
+
403
+ const results = idx.search('zzz');
404
+ assert.equal(results.length, 0);
405
+ });
406
+ });