@actuate-media/cms-core 0.44.0 → 0.47.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 (78) hide show
  1. package/dist/__tests__/ai/brand-voice.test.d.ts +2 -0
  2. package/dist/__tests__/ai/brand-voice.test.d.ts.map +1 -0
  3. package/dist/__tests__/ai/brand-voice.test.js +82 -0
  4. package/dist/__tests__/ai/brand-voice.test.js.map +1 -0
  5. package/dist/__tests__/ai/runtime.test.d.ts +2 -0
  6. package/dist/__tests__/ai/runtime.test.d.ts.map +1 -0
  7. package/dist/__tests__/ai/runtime.test.js +292 -0
  8. package/dist/__tests__/ai/runtime.test.js.map +1 -0
  9. package/dist/__tests__/api/ai-quality.test.js +178 -0
  10. package/dist/__tests__/api/ai-quality.test.js.map +1 -1
  11. package/dist/__tests__/api/health.test.js +9 -5
  12. package/dist/__tests__/api/health.test.js.map +1 -1
  13. package/dist/__tests__/api/updates-routes.test.d.ts +2 -0
  14. package/dist/__tests__/api/updates-routes.test.d.ts.map +1 -0
  15. package/dist/__tests__/api/updates-routes.test.js +248 -0
  16. package/dist/__tests__/api/updates-routes.test.js.map +1 -0
  17. package/dist/__tests__/upgrade/version-check.test.d.ts +2 -0
  18. package/dist/__tests__/upgrade/version-check.test.d.ts.map +1 -0
  19. package/dist/__tests__/upgrade/version-check.test.js +98 -0
  20. package/dist/__tests__/upgrade/version-check.test.js.map +1 -0
  21. package/dist/ai/brand-voice.d.ts +37 -0
  22. package/dist/ai/brand-voice.d.ts.map +1 -0
  23. package/dist/ai/brand-voice.js +85 -0
  24. package/dist/ai/brand-voice.js.map +1 -0
  25. package/dist/ai/index.d.ts +6 -4
  26. package/dist/ai/index.d.ts.map +1 -1
  27. package/dist/ai/index.js +6 -4
  28. package/dist/ai/index.js.map +1 -1
  29. package/dist/ai/providers/anthropic.d.ts +2 -1
  30. package/dist/ai/providers/anthropic.d.ts.map +1 -1
  31. package/dist/ai/providers/anthropic.js +28 -1
  32. package/dist/ai/providers/anthropic.js.map +1 -1
  33. package/dist/ai/providers/custom.d.ts +2 -1
  34. package/dist/ai/providers/custom.d.ts.map +1 -1
  35. package/dist/ai/providers/custom.js +7 -1
  36. package/dist/ai/providers/custom.js.map +1 -1
  37. package/dist/ai/providers/http.d.ts +30 -0
  38. package/dist/ai/providers/http.d.ts.map +1 -1
  39. package/dist/ai/providers/http.js +51 -0
  40. package/dist/ai/providers/http.js.map +1 -1
  41. package/dist/ai/providers/index.d.ts +4 -2
  42. package/dist/ai/providers/index.d.ts.map +1 -1
  43. package/dist/ai/providers/index.js +3 -0
  44. package/dist/ai/providers/index.js.map +1 -1
  45. package/dist/ai/providers/openai.d.ts +2 -1
  46. package/dist/ai/providers/openai.d.ts.map +1 -1
  47. package/dist/ai/providers/openai.js +4 -1
  48. package/dist/ai/providers/openai.js.map +1 -1
  49. package/dist/ai/providers/xai.d.ts +2 -1
  50. package/dist/ai/providers/xai.d.ts.map +1 -1
  51. package/dist/ai/providers/xai.js +4 -1
  52. package/dist/ai/providers/xai.js.map +1 -1
  53. package/dist/ai/runtime.d.ts +86 -0
  54. package/dist/ai/runtime.d.ts.map +1 -0
  55. package/dist/ai/runtime.js +177 -0
  56. package/dist/ai/runtime.js.map +1 -0
  57. package/dist/ai/types.d.ts +24 -0
  58. package/dist/ai/types.d.ts.map +1 -1
  59. package/dist/api/handlers.d.ts.map +1 -1
  60. package/dist/api/handlers.js +333 -130
  61. package/dist/api/handlers.js.map +1 -1
  62. package/dist/index.d.ts +2 -2
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -1
  65. package/dist/index.js.map +1 -1
  66. package/dist/upgrade/changelog.d.ts +6 -1
  67. package/dist/upgrade/changelog.d.ts.map +1 -1
  68. package/dist/upgrade/changelog.js +10 -4
  69. package/dist/upgrade/changelog.js.map +1 -1
  70. package/dist/upgrade/index.d.ts +2 -2
  71. package/dist/upgrade/index.d.ts.map +1 -1
  72. package/dist/upgrade/index.js +1 -1
  73. package/dist/upgrade/index.js.map +1 -1
  74. package/dist/upgrade/version-check.d.ts +38 -6
  75. package/dist/upgrade/version-check.d.ts.map +1 -1
  76. package/dist/upgrade/version-check.js +68 -0
  77. package/dist/upgrade/version-check.js.map +1 -1
  78. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=brand-voice.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brand-voice.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/ai/brand-voice.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { applyBrandVoice, buildBrandVoiceSystemPrompt, getActiveBrandVoice, normalizeBrandVoice, BRAND_VOICE_LIMITS, } from '../../ai/brand-voice.js';
3
+ function profile(over = {}) {
4
+ return { enabled: true, name: 'Acme', tone: 'confident, warm', ...over };
5
+ }
6
+ function config(brandVoice) {
7
+ return {
8
+ providers: [],
9
+ settings: {
10
+ features: [],
11
+ budget: { alertAtPercentages: [], disableWhenExceeded: false },
12
+ logsRetentionDays: 30,
13
+ brandVoice,
14
+ },
15
+ };
16
+ }
17
+ describe('buildBrandVoiceSystemPrompt', () => {
18
+ it('returns null when disabled', () => {
19
+ expect(buildBrandVoiceSystemPrompt(profile({ enabled: false }))).toBeNull();
20
+ });
21
+ it('returns null when there is no tone or guidelines', () => {
22
+ expect(buildBrandVoiceSystemPrompt(profile({ tone: '', guidelines: '' }))).toBeNull();
23
+ });
24
+ it('includes brand, tone, audience, guidelines, and a fenced sample', () => {
25
+ const out = buildBrandVoiceSystemPrompt(profile({
26
+ audience: 'developers',
27
+ guidelines: 'No jargon. Use active voice.',
28
+ sampleText: 'Ship faster, sleep better.',
29
+ }));
30
+ expect(out).toContain('Brand: Acme');
31
+ expect(out).toContain('Tone: confident, warm');
32
+ expect(out).toContain('Audience: developers');
33
+ expect(out).toContain('No jargon');
34
+ expect(out).toContain('<<<BRAND_SAMPLE');
35
+ expect(out).toContain('Ship faster, sleep better.');
36
+ });
37
+ });
38
+ describe('applyBrandVoice', () => {
39
+ it('prepends the voice before the task system prompt', () => {
40
+ const out = applyBrandVoice('Return only JSON.', profile());
41
+ expect(out).toMatch(/^Write in the following brand voice:/);
42
+ expect(out).toContain('Return only JSON.');
43
+ // Voice comes first; task instruction last so it can override format.
44
+ expect(out.indexOf('brand voice')).toBeLessThan(out.indexOf('Return only JSON.'));
45
+ });
46
+ it('returns the original system prompt untouched when no voice applies', () => {
47
+ expect(applyBrandVoice('sys', profile({ enabled: false }))).toBe('sys');
48
+ expect(applyBrandVoice(undefined, undefined)).toBeUndefined();
49
+ });
50
+ });
51
+ describe('getActiveBrandVoice', () => {
52
+ it('returns null when disabled or empty, the profile when usable', () => {
53
+ expect(getActiveBrandVoice(config(undefined))).toBeNull();
54
+ expect(getActiveBrandVoice(config(profile({ enabled: false })))).toBeNull();
55
+ expect(getActiveBrandVoice(config(profile({ tone: '', guidelines: '' })))).toBeNull();
56
+ expect(getActiveBrandVoice(config(profile()))?.name).toBe('Acme');
57
+ });
58
+ });
59
+ describe('normalizeBrandVoice', () => {
60
+ it('clamps long fields and coerces enabled', () => {
61
+ const long = 'x'.repeat(5000);
62
+ const out = normalizeBrandVoice({
63
+ enabled: 1,
64
+ name: long,
65
+ tone: long,
66
+ guidelines: long,
67
+ sampleText: long,
68
+ });
69
+ expect(out.enabled).toBe(true);
70
+ expect(out.name.length).toBe(BRAND_VOICE_LIMITS.name);
71
+ expect(out.tone.length).toBe(BRAND_VOICE_LIMITS.tone);
72
+ expect(out.guidelines.length).toBe(BRAND_VOICE_LIMITS.guidelines);
73
+ expect(out.sampleText.length).toBe(BRAND_VOICE_LIMITS.sampleText);
74
+ });
75
+ it('drops empty optional fields to undefined', () => {
76
+ const out = normalizeBrandVoice({ enabled: false, name: 'A', tone: 'b', audience: ' ' });
77
+ expect(out.audience).toBeUndefined();
78
+ expect(out.guidelines).toBeUndefined();
79
+ expect(out.sampleText).toBeUndefined();
80
+ });
81
+ });
82
+ //# sourceMappingURL=brand-voice.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brand-voice.test.js","sourceRoot":"","sources":["../../../src/__tests__/ai/brand-voice.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=runtime.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/ai/runtime.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,292 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { generateTextForFeature, isAiFeatureEnabled, resolveFeatureRuntime, } from '../../ai/runtime.js';
3
+ const NO_CAPS = {
4
+ text: false,
5
+ vision: false,
6
+ imageGeneration: false,
7
+ embeddings: false,
8
+ toolCalling: false,
9
+ structuredOutput: false,
10
+ streaming: false,
11
+ reasoning: false,
12
+ longContext: false,
13
+ };
14
+ function model(id, caps, over = {}) {
15
+ const [provider = 'openai', providerModelId = id] = id.split(':');
16
+ return {
17
+ id,
18
+ provider: provider,
19
+ providerModelId,
20
+ displayName: id,
21
+ status: 'available',
22
+ capabilities: { ...NO_CAPS, ...caps },
23
+ inputModalities: ['text'],
24
+ outputModalities: ['text'],
25
+ lastSyncedAt: '2026-01-01T00:00:00.000Z',
26
+ source: 'provider',
27
+ metadata: {},
28
+ ...over,
29
+ };
30
+ }
31
+ function conn(over = {}) {
32
+ return {
33
+ id: 'p-openai',
34
+ provider: 'openai',
35
+ displayName: 'OpenAI',
36
+ enabled: true,
37
+ status: 'connected',
38
+ createdAt: '2026-01-01T00:00:00.000Z',
39
+ updatedAt: '2026-01-01T00:00:00.000Z',
40
+ updatedBy: 'u1',
41
+ ...over,
42
+ };
43
+ }
44
+ const CONTENT_FEATURE = {
45
+ key: 'ai.features.contentWriting',
46
+ label: 'Content writing',
47
+ description: '',
48
+ enabled: true,
49
+ category: 'content',
50
+ requiredCapabilities: ['text'],
51
+ approvalRequired: false,
52
+ allowBulkApply: false,
53
+ rolesAllowed: ['ADMIN'],
54
+ permissionsRequired: ['ai.use'],
55
+ };
56
+ function makeDb(opts) {
57
+ const created = [];
58
+ const data = {
59
+ providers: opts.providers ?? [],
60
+ settings: opts.settings ?? {},
61
+ catalog: opts.catalog,
62
+ };
63
+ const db = {
64
+ document: {
65
+ findFirst: vi.fn(async () => ({ data })),
66
+ },
67
+ auditLog: {
68
+ findMany: vi.fn(async () => opts.auditRows ?? []),
69
+ create: vi.fn(async (args) => {
70
+ created.push(args.data);
71
+ return args.data;
72
+ }),
73
+ },
74
+ };
75
+ return { db, created };
76
+ }
77
+ const KEY = CONTENT_FEATURE.key;
78
+ afterEach(() => {
79
+ delete process.env.OPENAI_API_KEY;
80
+ delete process.env.ANTHROPIC_API_KEY;
81
+ });
82
+ describe('isAiFeatureEnabled', () => {
83
+ it('reflects the stored toggle and defaults unknown keys to enabled', async () => {
84
+ const { db } = makeDb({
85
+ providers: [conn()],
86
+ settings: { features: [{ ...CONTENT_FEATURE, enabled: false }] },
87
+ });
88
+ expect(await isAiFeatureEnabled(db, KEY)).toBe(false);
89
+ expect(await isAiFeatureEnabled(db, 'ai.features.unknown')).toBe(true);
90
+ });
91
+ });
92
+ describe('resolveFeatureRuntime', () => {
93
+ it('blocks a disabled feature', async () => {
94
+ const { db } = makeDb({
95
+ providers: [conn()],
96
+ settings: { features: [{ ...CONTENT_FEATURE, enabled: false }] },
97
+ });
98
+ const r = await resolveFeatureRuntime(db, KEY);
99
+ expect(r.ok).toBe(false);
100
+ if (!r.ok)
101
+ expect(r.code).toBe('feature_disabled');
102
+ });
103
+ it('returns no_model when nothing is configured', async () => {
104
+ const { db } = makeDb({ providers: [], settings: { features: [CONTENT_FEATURE] } });
105
+ const r = await resolveFeatureRuntime(db, KEY);
106
+ expect(r.ok).toBe(false);
107
+ if (!r.ok)
108
+ expect(r.code).toBe('no_model');
109
+ });
110
+ it('returns no_provider when the only matching connection is disabled', async () => {
111
+ process.env.OPENAI_API_KEY = 'sk-env-test';
112
+ const { db } = makeDb({
113
+ providers: [conn({ enabled: false })],
114
+ settings: { defaultModelId: 'openai:gpt-x', features: [CONTENT_FEATURE] },
115
+ catalog: { 'p-openai': { models: [model('openai:gpt-x', { text: true })] } },
116
+ });
117
+ const r = await resolveFeatureRuntime(db, KEY);
118
+ expect(r.ok).toBe(false);
119
+ if (!r.ok)
120
+ expect(r.code).toBe('no_provider');
121
+ });
122
+ it('returns no_key when neither env nor a stored key exists', async () => {
123
+ const { db } = makeDb({
124
+ providers: [conn({ provider: 'anthropic', id: 'p-anthropic' })],
125
+ settings: { defaultModelId: 'anthropic:claude-x', features: [CONTENT_FEATURE] },
126
+ catalog: { 'p-anthropic': { models: [model('anthropic:claude-x', { text: true })] } },
127
+ });
128
+ const r = await resolveFeatureRuntime(db, KEY);
129
+ expect(r.ok).toBe(false);
130
+ if (!r.ok)
131
+ expect(r.code).toBe('no_key');
132
+ });
133
+ it('resolves a ready runtime, stripping the provider prefix from the model id', async () => {
134
+ process.env.OPENAI_API_KEY = 'sk-env-test';
135
+ const { db } = makeDb({
136
+ providers: [conn()],
137
+ settings: { defaultModelId: 'openai:gpt-x', features: [CONTENT_FEATURE] },
138
+ catalog: { 'p-openai': { models: [model('openai:gpt-x', { text: true })] } },
139
+ });
140
+ const r = await resolveFeatureRuntime(db, KEY);
141
+ expect(r.ok).toBe(true);
142
+ if (r.ok) {
143
+ expect(r.providerKey).toBe('openai');
144
+ expect(r.apiKey).toBe('sk-env-test');
145
+ expect(r.modelId).toBe('openai:gpt-x');
146
+ expect(r.providerModelId).toBe('gpt-x');
147
+ }
148
+ });
149
+ it('blocks when the monthly budget is exceeded and enforcement is on', async () => {
150
+ process.env.OPENAI_API_KEY = 'sk-env-test';
151
+ const { db } = makeDb({
152
+ providers: [conn()],
153
+ settings: {
154
+ defaultModelId: 'openai:gpt-x',
155
+ features: [CONTENT_FEATURE],
156
+ budget: { alertAtPercentages: [], disableWhenExceeded: true, monthlyTokenLimit: 100 },
157
+ },
158
+ catalog: { 'p-openai': { models: [model('openai:gpt-x', { text: true })] } },
159
+ auditRows: [{ details: { totalTokensUsed: 250 } }],
160
+ });
161
+ const r = await resolveFeatureRuntime(db, KEY);
162
+ expect(r.ok).toBe(false);
163
+ if (!r.ok)
164
+ expect(r.code).toBe('budget_exceeded');
165
+ });
166
+ });
167
+ describe('generateTextForFeature', () => {
168
+ function okFetch(body) {
169
+ return vi.fn(async () => new Response(JSON.stringify(body), {
170
+ status: 200,
171
+ headers: { 'content-type': 'application/json' },
172
+ }));
173
+ }
174
+ it('generates text and records real token usage', async () => {
175
+ process.env.OPENAI_API_KEY = 'sk-env-test';
176
+ const { db, created } = makeDb({
177
+ providers: [conn()],
178
+ settings: { defaultModelId: 'openai:gpt-x', features: [CONTENT_FEATURE] },
179
+ catalog: { 'p-openai': { models: [model('openai:gpt-x', { text: true })] } },
180
+ });
181
+ const fetchImpl = okFetch({
182
+ choices: [{ message: { content: 'rewritten copy' } }],
183
+ usage: { prompt_tokens: 12, completion_tokens: 34, total_tokens: 46 },
184
+ });
185
+ const r = await generateTextForFeature(db, KEY, {
186
+ prompt: 'hello',
187
+ system: 'be terse',
188
+ userId: 'u1',
189
+ fetchImpl,
190
+ });
191
+ expect(r.ok).toBe(true);
192
+ if (r.ok) {
193
+ expect(r.text).toBe('rewritten copy');
194
+ expect(r.usage.totalTokens).toBe(46);
195
+ expect(r.modelId).toBe('openai:gpt-x');
196
+ }
197
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
198
+ // A usage audit row is recorded with fields the usage meter reads.
199
+ expect(created).toHaveLength(1);
200
+ expect(created[0].event).toBe('ai_usage');
201
+ expect(created[0].details.totalTokensUsed).toBe(46);
202
+ expect(created[0].details.feature).toBe(KEY);
203
+ });
204
+ it('surfaces a provider HTTP failure as provider_error without recording usage', async () => {
205
+ process.env.OPENAI_API_KEY = 'sk-env-test';
206
+ const { db, created } = makeDb({
207
+ providers: [conn()],
208
+ settings: { defaultModelId: 'openai:gpt-x', features: [CONTENT_FEATURE] },
209
+ catalog: { 'p-openai': { models: [model('openai:gpt-x', { text: true })] } },
210
+ });
211
+ const fetchImpl = vi.fn(async () => new Response('nope', { status: 401 }));
212
+ const r = await generateTextForFeature(db, KEY, { prompt: 'hello', fetchImpl });
213
+ expect(r.ok).toBe(false);
214
+ if (!r.ok)
215
+ expect(r.code).toBe('provider_error');
216
+ expect(created).toHaveLength(0);
217
+ });
218
+ it('injects the active brand voice into the system prompt when opted in', async () => {
219
+ process.env.OPENAI_API_KEY = 'sk-env-test';
220
+ const { db } = makeDb({
221
+ providers: [conn()],
222
+ settings: {
223
+ defaultModelId: 'openai:gpt-x',
224
+ features: [CONTENT_FEATURE],
225
+ brandVoice: {
226
+ enabled: true,
227
+ name: 'Acme',
228
+ tone: 'confident, warm',
229
+ guidelines: 'No jargon.',
230
+ },
231
+ },
232
+ catalog: { 'p-openai': { models: [model('openai:gpt-x', { text: true })] } },
233
+ });
234
+ let sentBody;
235
+ const fetchImpl = vi.fn(async (_url, init) => {
236
+ sentBody = JSON.parse(init.body);
237
+ return new Response(JSON.stringify({
238
+ choices: [{ message: { content: 'ok' } }],
239
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
240
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
241
+ });
242
+ const r = await generateTextForFeature(db, KEY, {
243
+ prompt: 'write a headline',
244
+ system: 'Be concise.',
245
+ useBrandVoice: true,
246
+ fetchImpl,
247
+ });
248
+ expect(r.ok).toBe(true);
249
+ const systemMsg = sentBody?.messages.find((m) => m.role === 'system')?.content ?? '';
250
+ expect(systemMsg).toContain('brand voice');
251
+ expect(systemMsg).toContain('confident, warm');
252
+ // Task instruction still present, after the voice.
253
+ expect(systemMsg).toContain('Be concise.');
254
+ });
255
+ it('does NOT inject brand voice unless the caller opts in', async () => {
256
+ process.env.OPENAI_API_KEY = 'sk-env-test';
257
+ const { db } = makeDb({
258
+ providers: [conn()],
259
+ settings: {
260
+ defaultModelId: 'openai:gpt-x',
261
+ features: [CONTENT_FEATURE],
262
+ brandVoice: { enabled: true, name: 'Acme', tone: 'confident', guidelines: 'No jargon.' },
263
+ },
264
+ catalog: { 'p-openai': { models: [model('openai:gpt-x', { text: true })] } },
265
+ });
266
+ let sentBody;
267
+ const fetchImpl = vi.fn(async (_url, init) => {
268
+ sentBody = JSON.parse(init.body);
269
+ return new Response(JSON.stringify({
270
+ choices: [{ message: { content: 'ok' } }],
271
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
272
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
273
+ });
274
+ await generateTextForFeature(db, KEY, { prompt: 'x', system: 'Be concise.', fetchImpl });
275
+ const systemMsg = sentBody?.messages.find((m) => m.role === 'system')?.content ?? '';
276
+ expect(systemMsg).toBe('Be concise.');
277
+ expect(systemMsg).not.toContain('brand voice');
278
+ });
279
+ it('short-circuits a disabled feature before any network call', async () => {
280
+ const { db } = makeDb({
281
+ providers: [conn()],
282
+ settings: { features: [{ ...CONTENT_FEATURE, enabled: false }] },
283
+ });
284
+ const fetchImpl = vi.fn();
285
+ const r = await generateTextForFeature(db, KEY, { prompt: 'hi', fetchImpl });
286
+ expect(r.ok).toBe(false);
287
+ if (!r.ok)
288
+ expect(r.code).toBe('feature_disabled');
289
+ expect(fetchImpl).not.toHaveBeenCalled();
290
+ });
291
+ });
292
+ //# sourceMappingURL=runtime.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.test.js","sourceRoot":"","sources":["../../../src/__tests__/ai/runtime.test.ts"],"names":[],"mappings":"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"}
@@ -115,6 +115,54 @@ async function makeKey(scopes) {
115
115
  },
116
116
  };
117
117
  }
118
+ /**
119
+ * A stored `__ai_config__` document that wires a working OpenAI connection +
120
+ * default model, so the co-author's governed path resolves end-to-end.
121
+ */
122
+ function aiConfigDoc(opts) {
123
+ return {
124
+ id: 'ai-config-doc',
125
+ collection: '__ai_config__',
126
+ deletedAt: null,
127
+ data: {
128
+ providers: [
129
+ {
130
+ id: 'p-openai',
131
+ provider: 'openai',
132
+ displayName: 'OpenAI',
133
+ enabled: true,
134
+ status: 'connected',
135
+ createdAt: '2026-01-01T00:00:00.000Z',
136
+ updatedAt: '2026-01-01T00:00:00.000Z',
137
+ updatedBy: 'u1',
138
+ },
139
+ ],
140
+ settings: {
141
+ defaultModelId: 'openai:gpt-x',
142
+ features: [{ key: 'ai.features.contentWriting', enabled: opts.enabled }],
143
+ },
144
+ catalog: {
145
+ 'p-openai': {
146
+ models: [
147
+ {
148
+ id: 'openai:gpt-x',
149
+ provider: 'openai',
150
+ providerModelId: 'gpt-x',
151
+ displayName: 'GPT-X',
152
+ status: 'available',
153
+ capabilities: { text: true },
154
+ inputModalities: ['text'],
155
+ outputModalities: ['text'],
156
+ lastSyncedAt: '2026-01-01T00:00:00.000Z',
157
+ source: 'provider',
158
+ metadata: {},
159
+ },
160
+ ],
161
+ },
162
+ },
163
+ },
164
+ };
165
+ }
118
166
  const postsCollection = {
119
167
  slug: 'posts',
120
168
  labels: { singular: 'Post', plural: 'Posts' },
@@ -466,5 +514,135 @@ describe('POST /ai/coauthor', () => {
466
514
  }));
467
515
  expect(response.status).toBe(400);
468
516
  });
517
+ it('routes through the governed provider + model when configured', async () => {
518
+ process.env.OPENAI_API_KEY = 'sk-env-test';
519
+ const fetchSpy = vi.fn(async () => new Response(JSON.stringify({
520
+ choices: [{ message: { content: 'governed rewrite' } }],
521
+ usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 },
522
+ }), { status: 200, headers: { 'content-type': 'application/json' } }));
523
+ vi.stubGlobal('fetch', fetchSpy);
524
+ try {
525
+ const { key, record } = await makeKey({ collections: ['posts'], actions: ['read'] });
526
+ const db = createMockDB({ apiKey: record, documents: [aiConfigDoc({ enabled: true })] });
527
+ initDB(db);
528
+ const handler = handleActuateAPI({
529
+ prismaClient: db,
530
+ config: { collections: { posts: postsCollection } },
531
+ });
532
+ const response = await handler(new Request('https://example.com/api/cms/ai/coauthor', {
533
+ method: 'POST',
534
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
535
+ body: JSON.stringify({ action: 'rewrite', text: 'hello world' }),
536
+ }));
537
+ expect(response.status).toBe(200);
538
+ const body = await response.json();
539
+ expect(body.data.text).toBe('governed rewrite');
540
+ expect(fetchSpy).toHaveBeenCalled();
541
+ const [url, init] = fetchSpy.mock.calls[0];
542
+ expect(String(url)).toContain('/chat/completions');
543
+ expect(JSON.parse(init.body).model).toBe('gpt-x');
544
+ }
545
+ finally {
546
+ vi.unstubAllGlobals();
547
+ delete process.env.OPENAI_API_KEY;
548
+ }
549
+ });
550
+ it('returns 403 when content writing is disabled in Settings > AI', async () => {
551
+ const { key, record } = await makeKey({ collections: ['posts'], actions: ['read'] });
552
+ const db = createMockDB({ apiKey: record, documents: [aiConfigDoc({ enabled: false })] });
553
+ initDB(db);
554
+ const handler = handleActuateAPI({
555
+ prismaClient: db,
556
+ config: { collections: { posts: postsCollection } },
557
+ });
558
+ const response = await handler(new Request('https://example.com/api/cms/ai/coauthor', {
559
+ method: 'POST',
560
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
561
+ body: JSON.stringify({ action: 'rewrite', text: 'hello world' }),
562
+ }));
563
+ expect(response.status).toBe(403);
564
+ });
565
+ });
566
+ describe('POST /ai/seo/generate-field (governed)', () => {
567
+ beforeEach(() => {
568
+ delete globalThis.__actuateConfig;
569
+ });
570
+ function seoEntityDoc() {
571
+ return {
572
+ id: 'doc-seo-1',
573
+ collection: 'posts',
574
+ deletedAt: null,
575
+ title: 'Best Hiking Boots',
576
+ plainText: 'A detailed guide to choosing hiking boots for every terrain.',
577
+ data: { title: 'Best Hiking Boots', body: 'A detailed guide to choosing hiking boots.' },
578
+ status: 'PUBLISHED',
579
+ };
580
+ }
581
+ it('routes focus-keyword generation through the governed provider + model', async () => {
582
+ process.env.OPENAI_API_KEY = 'sk-env-test';
583
+ const fetchSpy = vi.fn(async () => new Response(JSON.stringify({
584
+ choices: [{ message: { content: 'hiking boots' } }],
585
+ usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
586
+ }), { status: 200, headers: { 'content-type': 'application/json' } }));
587
+ vi.stubGlobal('fetch', fetchSpy);
588
+ try {
589
+ const { key, record } = await makeKey({
590
+ collections: ['posts'],
591
+ actions: ['read'],
592
+ admin: true,
593
+ });
594
+ const db = createMockDB({
595
+ apiKey: record,
596
+ documents: [aiConfigDoc({ enabled: true }), seoEntityDoc()],
597
+ });
598
+ initDB(db);
599
+ const handler = handleActuateAPI({
600
+ prismaClient: db,
601
+ config: { collections: { posts: postsCollection } },
602
+ });
603
+ const response = await handler(new Request('https://example.com/api/cms/ai/seo/generate-field', {
604
+ method: 'POST',
605
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
606
+ body: JSON.stringify({
607
+ field: 'focusKeyword',
608
+ entityType: 'posts',
609
+ entityId: 'doc-seo-1',
610
+ }),
611
+ }));
612
+ expect(response.status).toBe(200);
613
+ const body = await response.json();
614
+ expect(body.data.text).toBe('hiking boots');
615
+ expect(fetchSpy).toHaveBeenCalled();
616
+ }
617
+ finally {
618
+ vi.unstubAllGlobals();
619
+ delete process.env.OPENAI_API_KEY;
620
+ }
621
+ });
622
+ it('returns 403 when SEO meta generation is disabled', async () => {
623
+ const doc = aiConfigDoc({ enabled: true });
624
+ doc.data.settings.features = [{ key: 'ai.features.seoMetaGeneration', enabled: false }];
625
+ const { key, record } = await makeKey({
626
+ collections: ['posts'],
627
+ actions: ['read'],
628
+ admin: true,
629
+ });
630
+ const db = createMockDB({ apiKey: record, documents: [doc, seoEntityDoc()] });
631
+ initDB(db);
632
+ const handler = handleActuateAPI({
633
+ prismaClient: db,
634
+ config: { collections: { posts: postsCollection } },
635
+ });
636
+ const response = await handler(new Request('https://example.com/api/cms/ai/seo/generate-field', {
637
+ method: 'POST',
638
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
639
+ body: JSON.stringify({
640
+ field: 'metaDescription',
641
+ entityType: 'posts',
642
+ entityId: 'doc-seo-1',
643
+ }),
644
+ }));
645
+ expect(response.status).toBe(403);
646
+ });
469
647
  });
470
648
  //# sourceMappingURL=ai-quality.test.js.map