nca-ai-cms-astro-plugin 1.0.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 (73) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/README.md +87 -0
  3. package/package.json +53 -0
  4. package/src/api/_utils.ts +20 -0
  5. package/src/api/articles/[id]/apply.ts +89 -0
  6. package/src/api/articles/[id]/regenerate-image.ts +49 -0
  7. package/src/api/articles/[id]/regenerate-text.ts +57 -0
  8. package/src/api/articles/[id].ts +53 -0
  9. package/src/api/auth/check.ts +6 -0
  10. package/src/api/auth/login.ts +43 -0
  11. package/src/api/auth/logout.ts +6 -0
  12. package/src/api/generate-content.ts +43 -0
  13. package/src/api/generate-image.ts +33 -0
  14. package/src/api/prompts.ts +45 -0
  15. package/src/api/save-image.ts +38 -0
  16. package/src/api/save.ts +49 -0
  17. package/src/api/scheduler/[id].ts +31 -0
  18. package/src/api/scheduler/generate.ts +94 -0
  19. package/src/api/scheduler/publish.ts +96 -0
  20. package/src/api/scheduler.ts +51 -0
  21. package/src/components/Editor.tsx +115 -0
  22. package/src/components/editor/GenerateTab.tsx +384 -0
  23. package/src/components/editor/PlannerTab.tsx +345 -0
  24. package/src/components/editor/SettingsTab.tsx +185 -0
  25. package/src/components/editor/styles.ts +597 -0
  26. package/src/components/editor/types.ts +49 -0
  27. package/src/components/editor/useTabNavigation.ts +69 -0
  28. package/src/config.d.ts +4 -0
  29. package/src/db/tables.ts +39 -0
  30. package/src/domain/entities/Article.test.ts +138 -0
  31. package/src/domain/entities/Article.ts +90 -0
  32. package/src/domain/entities/ScheduledPost.test.ts +228 -0
  33. package/src/domain/entities/ScheduledPost.ts +152 -0
  34. package/src/domain/entities/Source.test.ts +57 -0
  35. package/src/domain/entities/Source.ts +43 -0
  36. package/src/domain/entities/index.ts +9 -0
  37. package/src/domain/index.ts +16 -0
  38. package/src/domain/value-objects/ArticleFinder.test.ts +104 -0
  39. package/src/domain/value-objects/ArticleFinder.ts +61 -0
  40. package/src/domain/value-objects/SEOMetadata.test.ts +48 -0
  41. package/src/domain/value-objects/SEOMetadata.ts +19 -0
  42. package/src/domain/value-objects/Slug.test.ts +51 -0
  43. package/src/domain/value-objects/Slug.ts +33 -0
  44. package/src/domain/value-objects/index.ts +4 -0
  45. package/src/index.ts +146 -0
  46. package/src/middleware.ts +30 -0
  47. package/src/pages/editor.astro +22 -0
  48. package/src/pages/login.astro +117 -0
  49. package/src/services/ArticleService.test.ts +148 -0
  50. package/src/services/ArticleService.ts +150 -0
  51. package/src/services/AutoPublisher.ts +122 -0
  52. package/src/services/ContentFetcher.ts +89 -0
  53. package/src/services/ContentGenerator.ts +320 -0
  54. package/src/services/FileWriter.test.ts +80 -0
  55. package/src/services/FileWriter.ts +59 -0
  56. package/src/services/ImageConverter.ts +15 -0
  57. package/src/services/ImageGenerator.ts +108 -0
  58. package/src/services/PromptService.ts +84 -0
  59. package/src/services/SchedulerDBAdapter.ts +75 -0
  60. package/src/services/SchedulerService.test.ts +286 -0
  61. package/src/services/SchedulerService.ts +149 -0
  62. package/src/services/index.ts +27 -0
  63. package/src/utils/authUtils.test.ts +60 -0
  64. package/src/utils/authUtils.ts +25 -0
  65. package/src/utils/envUtils.test.ts +40 -0
  66. package/src/utils/envUtils.ts +26 -0
  67. package/src/utils/index.ts +7 -0
  68. package/src/utils/markdown.test.ts +65 -0
  69. package/src/utils/markdown.ts +13 -0
  70. package/src/utils/sanitize.test.ts +180 -0
  71. package/src/utils/sanitize.ts +98 -0
  72. package/tsconfig.json +22 -0
  73. package/vitest.config.ts +14 -0
@@ -0,0 +1,75 @@
1
+ import { db, ScheduledPosts, eq } from 'astro:db';
2
+ import type { ScheduledPostDBRow } from '../domain/entities/ScheduledPost';
3
+ import type { SchedulerDBAdapter } from './SchedulerService';
4
+
5
+ export class AstroSchedulerDBAdapter implements SchedulerDBAdapter {
6
+ async listAll(): Promise<ScheduledPostDBRow[]> {
7
+ return await db.select().from(ScheduledPosts);
8
+ }
9
+
10
+ async getById(id: string): Promise<ScheduledPostDBRow | null> {
11
+ const row = await db
12
+ .select()
13
+ .from(ScheduledPosts)
14
+ .where(eq(ScheduledPosts.id, id))
15
+ .get();
16
+ return row ?? null;
17
+ }
18
+
19
+ async insert(row: ScheduledPostDBRow): Promise<void> {
20
+ await db.insert(ScheduledPosts).values({
21
+ id: row.id,
22
+ input: row.input,
23
+ inputType: row.inputType,
24
+ scheduledDate: row.scheduledDate,
25
+ status: row.status,
26
+ generatedTitle: row.generatedTitle ?? undefined,
27
+ generatedDescription: row.generatedDescription ?? undefined,
28
+ generatedContent: row.generatedContent ?? undefined,
29
+ generatedTags: row.generatedTags ?? undefined,
30
+ generatedImageData: row.generatedImageData ?? undefined,
31
+ generatedImageAlt: row.generatedImageAlt ?? undefined,
32
+ publishedPath: row.publishedPath ?? undefined,
33
+ createdAt: row.createdAt,
34
+ });
35
+ }
36
+
37
+ async update(id: string, data: Partial<ScheduledPostDBRow>): Promise<void> {
38
+ const updateData: Record<string, unknown> = {};
39
+ if (data.status !== undefined) updateData.status = data.status;
40
+ if (data.generatedTitle !== undefined)
41
+ updateData.generatedTitle = data.generatedTitle;
42
+ if (data.generatedDescription !== undefined)
43
+ updateData.generatedDescription = data.generatedDescription;
44
+ if (data.generatedContent !== undefined)
45
+ updateData.generatedContent = data.generatedContent;
46
+ if (data.generatedTags !== undefined)
47
+ updateData.generatedTags = data.generatedTags;
48
+ if (data.generatedImageData !== undefined)
49
+ updateData.generatedImageData = data.generatedImageData;
50
+ if (data.generatedImageAlt !== undefined)
51
+ updateData.generatedImageAlt = data.generatedImageAlt;
52
+ if (data.publishedPath !== undefined)
53
+ updateData.publishedPath = data.publishedPath;
54
+
55
+ await db
56
+ .update(ScheduledPosts)
57
+ .set(updateData)
58
+ .where(eq(ScheduledPosts.id, id));
59
+ }
60
+
61
+ async deleteById(id: string): Promise<void> {
62
+ await db.delete(ScheduledPosts).where(eq(ScheduledPosts.id, id));
63
+ }
64
+
65
+ async getByDate(date: Date): Promise<ScheduledPostDBRow | null> {
66
+ // Get all rows and filter by date (astro:db doesn't have date comparison operators)
67
+ const all = await db.select().from(ScheduledPosts);
68
+ const dateStr = date.toISOString().split('T')[0];
69
+ const match = all.find((row: ScheduledPostDBRow) => {
70
+ const rowDate = new Date(row.scheduledDate).toISOString().split('T')[0];
71
+ return rowDate === dateStr && row.status !== 'published';
72
+ });
73
+ return match ?? null;
74
+ }
75
+ }
@@ -0,0 +1,286 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { SchedulerService } from './SchedulerService';
3
+ import type { ScheduledPostDBRow } from '../domain/entities/ScheduledPost';
4
+
5
+ // Mock DB adapter interface
6
+ interface MockDB {
7
+ listAll(): Promise<ScheduledPostDBRow[]>;
8
+ getById(id: string): Promise<ScheduledPostDBRow | null>;
9
+ insert(row: ScheduledPostDBRow): Promise<void>;
10
+ update(id: string, data: Partial<ScheduledPostDBRow>): Promise<void>;
11
+ deleteById(id: string): Promise<void>;
12
+ getByDate(date: Date): Promise<ScheduledPostDBRow | null>;
13
+ }
14
+
15
+ function createMockDB(rows: ScheduledPostDBRow[] = []): MockDB {
16
+ const store = new Map<string, ScheduledPostDBRow>();
17
+ rows.forEach((r) => store.set(r.id, r));
18
+
19
+ return {
20
+ listAll: vi.fn(async () => [...store.values()]),
21
+ getById: vi.fn(async (id: string) => store.get(id) || null),
22
+ insert: vi.fn(async (row: ScheduledPostDBRow) => {
23
+ store.set(row.id, row);
24
+ }),
25
+ update: vi.fn(async (id: string, data: Partial<ScheduledPostDBRow>) => {
26
+ const existing = store.get(id);
27
+ if (existing) store.set(id, { ...existing, ...data });
28
+ }),
29
+ deleteById: vi.fn(async (id: string) => {
30
+ store.delete(id);
31
+ }),
32
+ getByDate: vi.fn(async (date: Date) => {
33
+ const dateStr = date.toISOString().split('T')[0];
34
+ for (const row of store.values()) {
35
+ const rowDate = new Date(row.scheduledDate).toISOString().split('T')[0];
36
+ if (rowDate === dateStr && row.status !== 'published') return row;
37
+ }
38
+ return null;
39
+ }),
40
+ };
41
+ }
42
+
43
+ describe('SchedulerService', () => {
44
+ let db: MockDB;
45
+ let service: SchedulerService;
46
+
47
+ beforeEach(() => {
48
+ db = createMockDB();
49
+ service = new SchedulerService(db);
50
+ });
51
+
52
+ describe('create', () => {
53
+ it('creates a new scheduled post', async () => {
54
+ const result = await service.create({
55
+ input: 'ARIA Landmarks',
56
+ scheduledDate: new Date('2026-04-01'),
57
+ });
58
+
59
+ expect(result.id).toMatch(/^sp_/);
60
+ expect(result.status).toBe('pending');
61
+ expect(result.inputType).toBe('keywords');
62
+ expect(db.insert).toHaveBeenCalled();
63
+ });
64
+
65
+ it('detects URL input type', async () => {
66
+ const result = await service.create({
67
+ input: 'https://example.com/article',
68
+ scheduledDate: new Date('2026-04-01'),
69
+ });
70
+
71
+ expect(result.inputType).toBe('url');
72
+ });
73
+
74
+ it('rejects duplicate dates', async () => {
75
+ const date = new Date('2026-04-01');
76
+ db = createMockDB([
77
+ {
78
+ id: 'sp_existing',
79
+ input: 'existing',
80
+ inputType: 'keywords',
81
+ scheduledDate: date,
82
+ status: 'pending',
83
+ createdAt: new Date(),
84
+ },
85
+ ]);
86
+ service = new SchedulerService(db);
87
+
88
+ await expect(
89
+ service.create({ input: 'new entry', scheduledDate: date })
90
+ ).rejects.toThrow('already scheduled');
91
+ });
92
+ });
93
+
94
+ describe('list', () => {
95
+ it('returns all posts sorted by date', async () => {
96
+ const rows: ScheduledPostDBRow[] = [
97
+ {
98
+ id: 'sp_2',
99
+ input: 'b',
100
+ inputType: 'keywords',
101
+ scheduledDate: new Date('2026-04-02'),
102
+ status: 'pending',
103
+ createdAt: new Date(),
104
+ },
105
+ {
106
+ id: 'sp_1',
107
+ input: 'a',
108
+ inputType: 'keywords',
109
+ scheduledDate: new Date('2026-04-01'),
110
+ status: 'pending',
111
+ createdAt: new Date(),
112
+ },
113
+ ];
114
+ db = createMockDB(rows);
115
+ service = new SchedulerService(db);
116
+
117
+ const result = await service.list();
118
+ expect(result).toHaveLength(2);
119
+ });
120
+ });
121
+
122
+ describe('delete', () => {
123
+ it('deletes a non-published post', async () => {
124
+ db = createMockDB([
125
+ {
126
+ id: 'sp_1',
127
+ input: 'test',
128
+ inputType: 'keywords',
129
+ scheduledDate: new Date('2026-04-01'),
130
+ status: 'pending',
131
+ createdAt: new Date(),
132
+ },
133
+ ]);
134
+ service = new SchedulerService(db);
135
+
136
+ await service.delete('sp_1');
137
+ expect(db.deleteById).toHaveBeenCalledWith('sp_1');
138
+ });
139
+
140
+ it('refuses to delete a published post', async () => {
141
+ db = createMockDB([
142
+ {
143
+ id: 'sp_1',
144
+ input: 'test',
145
+ inputType: 'keywords',
146
+ scheduledDate: new Date('2026-04-01'),
147
+ status: 'published',
148
+ createdAt: new Date(),
149
+ },
150
+ ]);
151
+ service = new SchedulerService(db);
152
+
153
+ await expect(service.delete('sp_1')).rejects.toThrow('Cannot delete');
154
+ });
155
+
156
+ it('throws if post not found', async () => {
157
+ await expect(service.delete('sp_nonexistent')).rejects.toThrow(
158
+ 'not found'
159
+ );
160
+ });
161
+ });
162
+
163
+ describe('getDuePosts', () => {
164
+ it('returns generated posts whose date has passed', async () => {
165
+ const yesterday = new Date();
166
+ yesterday.setDate(yesterday.getDate() - 1);
167
+
168
+ const tomorrow = new Date();
169
+ tomorrow.setDate(tomorrow.getDate() + 1);
170
+
171
+ db = createMockDB([
172
+ {
173
+ id: 'sp_due',
174
+ input: 'due',
175
+ inputType: 'keywords',
176
+ scheduledDate: yesterday,
177
+ status: 'generated',
178
+ generatedTitle: 'Title',
179
+ generatedContent: '# Content',
180
+ createdAt: new Date(),
181
+ },
182
+ {
183
+ id: 'sp_future',
184
+ input: 'future',
185
+ inputType: 'keywords',
186
+ scheduledDate: tomorrow,
187
+ status: 'generated',
188
+ createdAt: new Date(),
189
+ },
190
+ {
191
+ id: 'sp_pending',
192
+ input: 'pending',
193
+ inputType: 'keywords',
194
+ scheduledDate: yesterday,
195
+ status: 'pending',
196
+ createdAt: new Date(),
197
+ },
198
+ ]);
199
+ service = new SchedulerService(db);
200
+
201
+ const due = await service.getDuePosts();
202
+ expect(due).toHaveLength(1);
203
+ expect(due[0]!.id).toBe('sp_due');
204
+ });
205
+ });
206
+
207
+ describe('markGenerated', () => {
208
+ it('updates post with generated content', async () => {
209
+ db = createMockDB([
210
+ {
211
+ id: 'sp_1',
212
+ input: 'test',
213
+ inputType: 'keywords',
214
+ scheduledDate: new Date('2026-04-01'),
215
+ status: 'pending',
216
+ createdAt: new Date(),
217
+ },
218
+ ]);
219
+ service = new SchedulerService(db);
220
+
221
+ await service.markGenerated('sp_1', {
222
+ title: 'Generated Title',
223
+ description: 'Generated description',
224
+ content: '# Content',
225
+ tags: ['tag1', 'tag2'],
226
+ });
227
+
228
+ expect(db.update).toHaveBeenCalledWith(
229
+ 'sp_1',
230
+ expect.objectContaining({
231
+ status: 'generated',
232
+ generatedTitle: 'Generated Title',
233
+ })
234
+ );
235
+ });
236
+
237
+ it('rejects generation for published posts', async () => {
238
+ db = createMockDB([
239
+ {
240
+ id: 'sp_1',
241
+ input: 'test',
242
+ inputType: 'keywords',
243
+ scheduledDate: new Date('2026-04-01'),
244
+ status: 'published',
245
+ createdAt: new Date(),
246
+ },
247
+ ]);
248
+ service = new SchedulerService(db);
249
+
250
+ await expect(
251
+ service.markGenerated('sp_1', {
252
+ title: 'Title',
253
+ description: 'Desc',
254
+ content: '# C',
255
+ tags: [],
256
+ })
257
+ ).rejects.toThrow('Cannot generate');
258
+ });
259
+ });
260
+
261
+ describe('markPublished', () => {
262
+ it('updates post status to published with path', async () => {
263
+ db = createMockDB([
264
+ {
265
+ id: 'sp_1',
266
+ input: 'test',
267
+ inputType: 'keywords',
268
+ scheduledDate: new Date('2026-04-01'),
269
+ status: 'generated',
270
+ createdAt: new Date(),
271
+ },
272
+ ]);
273
+ service = new SchedulerService(db);
274
+
275
+ await service.markPublished('sp_1', 'nca-ai-cms-content/2026/04/test');
276
+
277
+ expect(db.update).toHaveBeenCalledWith(
278
+ 'sp_1',
279
+ expect.objectContaining({
280
+ status: 'published',
281
+ publishedPath: 'nca-ai-cms-content/2026/04/test',
282
+ })
283
+ );
284
+ });
285
+ });
286
+ });
@@ -0,0 +1,149 @@
1
+ import {
2
+ ScheduledPost,
3
+ type ScheduledPostDBRow,
4
+ type ScheduledPostStatus,
5
+ } from '../domain/entities/ScheduledPost';
6
+
7
+ export interface SchedulerDBAdapter {
8
+ listAll(): Promise<ScheduledPostDBRow[]>;
9
+ getById(id: string): Promise<ScheduledPostDBRow | null>;
10
+ insert(row: ScheduledPostDBRow): Promise<void>;
11
+ update(id: string, data: Partial<ScheduledPostDBRow>): Promise<void>;
12
+ deleteById(id: string): Promise<void>;
13
+ getByDate(date: Date): Promise<ScheduledPostDBRow | null>;
14
+ }
15
+
16
+ export interface CreateScheduledPostInput {
17
+ input: string;
18
+ scheduledDate: Date;
19
+ }
20
+
21
+ export class SchedulerService {
22
+ constructor(private readonly db: SchedulerDBAdapter) {}
23
+
24
+ private async requireGeneratable(id: string, action: string): Promise<void> {
25
+ const post = await this.getById(id);
26
+ if (!post.canGenerate()) {
27
+ throw new Error(`Cannot ${action}: post is in status "${post.status}"`);
28
+ }
29
+ }
30
+
31
+ async create(data: CreateScheduledPostInput): Promise<ScheduledPost> {
32
+ // Check for duplicate date
33
+ const existing = await this.db.getByDate(data.scheduledDate);
34
+ if (existing) {
35
+ const dateStr = data.scheduledDate.toISOString().split('T')[0];
36
+ throw new Error(`Date ${dateStr} is already scheduled for another post`);
37
+ }
38
+
39
+ const inputType = ScheduledPost.detectInputType(data.input);
40
+ const post = ScheduledPost.create({
41
+ input: data.input,
42
+ inputType,
43
+ scheduledDate: data.scheduledDate,
44
+ });
45
+
46
+ await this.db.insert({
47
+ id: post.id,
48
+ input: post.input,
49
+ inputType: post.inputType,
50
+ scheduledDate: post.scheduledDate,
51
+ status: post.status,
52
+ createdAt: post.createdAt,
53
+ });
54
+
55
+ return post;
56
+ }
57
+
58
+ async list(): Promise<ScheduledPost[]> {
59
+ const rows = await this.db.listAll();
60
+ return rows
61
+ .map((row) => ScheduledPost.fromDB(row))
62
+ .sort((a, b) => a.scheduledDate.getTime() - b.scheduledDate.getTime());
63
+ }
64
+
65
+ async getById(id: string): Promise<ScheduledPost> {
66
+ const row = await this.db.getById(id);
67
+ if (!row) {
68
+ throw new Error(`Scheduled post not found: ${id}`);
69
+ }
70
+ return ScheduledPost.fromDB(row);
71
+ }
72
+
73
+ async delete(id: string): Promise<void> {
74
+ const post = await this.getById(id);
75
+ if (!post.canDelete()) {
76
+ throw new Error('Cannot delete a published post');
77
+ }
78
+ await this.db.deleteById(id);
79
+ }
80
+
81
+ async markGenerated(
82
+ id: string,
83
+ data: {
84
+ title: string;
85
+ description: string;
86
+ content: string;
87
+ tags: string[];
88
+ imageData?: string | undefined;
89
+ imageAlt?: string | undefined;
90
+ }
91
+ ): Promise<void> {
92
+ await this.requireGeneratable(id, 'generate');
93
+
94
+ await this.db.update(id, {
95
+ status: 'generated' as ScheduledPostStatus,
96
+ generatedTitle: data.title,
97
+ generatedDescription: data.description,
98
+ generatedContent: data.content,
99
+ generatedTags: JSON.stringify(data.tags),
100
+ generatedImageData: data.imageData || null,
101
+ generatedImageAlt: data.imageAlt || null,
102
+ });
103
+ }
104
+
105
+ async updateText(
106
+ id: string,
107
+ data: {
108
+ title: string;
109
+ description: string;
110
+ content: string;
111
+ tags: string[];
112
+ }
113
+ ): Promise<void> {
114
+ await this.requireGeneratable(id, 'regenerate');
115
+
116
+ await this.db.update(id, {
117
+ status: 'generated' as ScheduledPostStatus,
118
+ generatedTitle: data.title,
119
+ generatedDescription: data.description,
120
+ generatedContent: data.content,
121
+ generatedTags: JSON.stringify(data.tags),
122
+ });
123
+ }
124
+
125
+ async updateImage(
126
+ id: string,
127
+ data: { imageData: string; imageAlt: string }
128
+ ): Promise<void> {
129
+ await this.requireGeneratable(id, 'regenerate');
130
+
131
+ await this.db.update(id, {
132
+ status: 'generated' as ScheduledPostStatus,
133
+ generatedImageData: data.imageData,
134
+ generatedImageAlt: data.imageAlt,
135
+ });
136
+ }
137
+
138
+ async markPublished(id: string, publishedPath: string): Promise<void> {
139
+ await this.db.update(id, {
140
+ status: 'published' as ScheduledPostStatus,
141
+ publishedPath,
142
+ });
143
+ }
144
+
145
+ async getDuePosts(): Promise<ScheduledPost[]> {
146
+ const all = await this.list();
147
+ return all.filter((post) => post.isDue());
148
+ }
149
+ }
@@ -0,0 +1,27 @@
1
+ export { ContentFetcher, type FetchedContent } from './ContentFetcher';
2
+ export {
3
+ ContentGenerator,
4
+ type GeneratedContent,
5
+ type ContentGeneratorConfig,
6
+ type IPromptService,
7
+ } from './ContentGenerator';
8
+ export { FileWriter, type WriteResult } from './FileWriter';
9
+ export {
10
+ ArticleService,
11
+ type ArticleData,
12
+ ArticleNotFoundError,
13
+ type UpdateContentOptions,
14
+ } from './ArticleService';
15
+ export {
16
+ ImageGenerator,
17
+ type GeneratedImage,
18
+ type ImageGeneratorConfig,
19
+ } from './ImageGenerator';
20
+ export { convertToWebP } from './ImageConverter';
21
+ export {
22
+ SchedulerService,
23
+ type SchedulerDBAdapter,
24
+ type CreateScheduledPostInput,
25
+ } from './SchedulerService';
26
+ export { PromptService, type CTAConfig } from './PromptService';
27
+ export { startAutoPublisher, stopAutoPublisher } from './AutoPublisher';
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { isPublicPath, isProtectedPath, isAuthenticated } from './authUtils.js';
3
+
4
+ describe('isPublicPath', () => {
5
+ it('allows auth endpoints and login page', () => {
6
+ expect(isPublicPath('/api/auth/login')).toBe(true);
7
+ expect(isPublicPath('/api/auth/logout')).toBe(true);
8
+ expect(isPublicPath('/login')).toBe(true);
9
+ });
10
+
11
+ it('rejects other paths', () => {
12
+ expect(isPublicPath('/api/generate-content')).toBe(false);
13
+ expect(isPublicPath('/editor')).toBe(false);
14
+ expect(isPublicPath('/')).toBe(false);
15
+ });
16
+ });
17
+
18
+ describe('isProtectedPath', () => {
19
+ it('protects API routes and editor', () => {
20
+ expect(isProtectedPath('/api/generate-content')).toBe(true);
21
+ expect(isProtectedPath('/api/prompts')).toBe(true);
22
+ expect(isProtectedPath('/editor')).toBe(true);
23
+ });
24
+
25
+ it('does not protect other paths', () => {
26
+ expect(isProtectedPath('/')).toBe(false);
27
+ expect(isProtectedPath('/about')).toBe(false);
28
+ expect(isProtectedPath('/login')).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('isAuthenticated', () => {
33
+ beforeEach(() => {
34
+ process.env.EDITOR_ADMIN = 'admin';
35
+ process.env.EDITOR_PASSWORD = 'secret';
36
+ });
37
+
38
+ afterEach(() => {
39
+ delete process.env.EDITOR_ADMIN;
40
+ delete process.env.EDITOR_PASSWORD;
41
+ });
42
+
43
+ it('returns true for valid base64 credentials', () => {
44
+ const token = btoa('admin:secret');
45
+ expect(isAuthenticated(token)).toBe(true);
46
+ });
47
+
48
+ it('returns false for wrong credentials', () => {
49
+ const token = btoa('admin:wrong');
50
+ expect(isAuthenticated(token)).toBe(false);
51
+ });
52
+
53
+ it('returns false for undefined', () => {
54
+ expect(isAuthenticated(undefined)).toBe(false);
55
+ });
56
+
57
+ it('returns false for invalid base64', () => {
58
+ expect(isAuthenticated('%%%not-base64')).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,25 @@
1
+ import { getEnvVariable } from './envUtils.js';
2
+
3
+ const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/logout', '/login'];
4
+
5
+ export function isPublicPath(pathname: string): boolean {
6
+ return PUBLIC_PATHS.includes(pathname);
7
+ }
8
+
9
+ export function isProtectedPath(pathname: string): boolean {
10
+ return pathname.startsWith('/api/') || pathname === '/editor';
11
+ }
12
+
13
+ export function isAuthenticated(cookieValue: string | undefined): boolean {
14
+ if (!cookieValue) return false;
15
+
16
+ try {
17
+ const decoded = atob(cookieValue);
18
+ const [username, password] = decoded.split(':');
19
+ const expectedUsername = getEnvVariable('EDITOR_ADMIN');
20
+ const expectedPassword = getEnvVariable('EDITOR_PASSWORD');
21
+ return username === expectedUsername && password === expectedPassword;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { getEnvVariable } from "./envUtils.js";
3
+
4
+ describe("getEnvVariable", () => {
5
+ const TEST_VAR = "TEST_ENV_VARIABLE_FOR_UTILS";
6
+
7
+ afterEach(() => {
8
+ delete process.env[TEST_VAR];
9
+ });
10
+
11
+ it("returns the value when the environment variable is set", () => {
12
+ process.env[TEST_VAR] = "hello";
13
+ expect(getEnvVariable(TEST_VAR)).toBe("hello");
14
+ });
15
+
16
+ it("returns the default value when the variable is not set", () => {
17
+ expect(getEnvVariable(TEST_VAR, "fallback")).toBe("fallback");
18
+ });
19
+
20
+ it("returns the default value when the variable is an empty string", () => {
21
+ process.env[TEST_VAR] = "";
22
+ expect(getEnvVariable(TEST_VAR, "fallback")).toBe("fallback");
23
+ });
24
+
25
+ it("throws when a required variable is missing and no default is provided", () => {
26
+ expect(() => getEnvVariable(TEST_VAR)).toThrow(
27
+ `Required environment variable "${TEST_VAR}" is not set.`,
28
+ );
29
+ });
30
+
31
+ it("prefers the actual env value over the default", () => {
32
+ process.env[TEST_VAR] = "actual";
33
+ expect(getEnvVariable(TEST_VAR, "default")).toBe("actual");
34
+ });
35
+
36
+ it("returns the default when the variable is undefined in process.env", () => {
37
+ delete process.env[TEST_VAR];
38
+ expect(getEnvVariable(TEST_VAR, "")).toBe("");
39
+ });
40
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Reads an environment variable from `process.env`.
3
+ *
4
+ * @param variable - The name of the environment variable.
5
+ * @param defaultValue - Optional fallback when the variable is not set.
6
+ * @returns The value of the environment variable, or the default.
7
+ * @throws {Error} When the variable is not set and no default is provided.
8
+ */
9
+ export function getEnvVariable(
10
+ variable: string,
11
+ defaultValue?: string,
12
+ ): string {
13
+ const value = process.env[variable];
14
+
15
+ if (value !== undefined && value !== "") {
16
+ return value;
17
+ }
18
+
19
+ if (defaultValue !== undefined) {
20
+ return defaultValue;
21
+ }
22
+
23
+ throw new Error(
24
+ `Required environment variable "${variable}" is not set.`,
25
+ );
26
+ }
@@ -0,0 +1,7 @@
1
+ export { renderMarkdown } from "./markdown.js";
2
+ export {
3
+ sanitizeMarkdownHtml,
4
+ escapeJsonLd,
5
+ escapeHtml,
6
+ } from "./sanitize.js";
7
+ export { getEnvVariable } from "./envUtils.js";