@actuate-media/cms-core 0.34.0 → 0.35.1

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=globals-routes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"globals-routes.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/globals-routes.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,100 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { handleActuateAPI } from '../../api/index.js';
3
+ import { initDB } from '../../db.js';
4
+ import { generateApiKey } from '../../security/api-key-enhanced.js';
5
+ /**
6
+ * Authenticated `GET /globals/:slug` behaviour.
7
+ *
8
+ * Globals are upsert-on-write (the first PUT creates the row). A slug that has
9
+ * no document yet is therefore a valid EMPTY state, not a 404 — the shipped
10
+ * admin Settings page reads `/globals/settings` and must load cleanly on a
11
+ * fresh install before anything has been saved. This regression test pins
12
+ * that contract so we don't reintroduce the "Global not found" banner.
13
+ */
14
+ const VALID_SECRET = 'a'.repeat(48);
15
+ function createMockDB(opts) {
16
+ const apiKey = opts.apiKey;
17
+ return {
18
+ apiKey: {
19
+ findMany: async () => (apiKey ? [apiKey] : []),
20
+ findUnique: async ({ where }) => {
21
+ if (!apiKey)
22
+ return null;
23
+ if (apiKey.keyHash !== where.keyHash)
24
+ return null;
25
+ return apiKey;
26
+ },
27
+ update: async () => apiKey,
28
+ },
29
+ document: {
30
+ findFirst: async () => opts.global ?? null,
31
+ findMany: async () => [],
32
+ count: async () => 0,
33
+ },
34
+ user: {},
35
+ session: {},
36
+ media: {},
37
+ };
38
+ }
39
+ async function makeKey(scopes) {
40
+ const { key, keyHash, keyPrefix } = await generateApiKey({ prefix: 'act_sk', scopes });
41
+ return {
42
+ key,
43
+ record: {
44
+ id: 'apikey-1',
45
+ keyHash,
46
+ keyPrefix,
47
+ userId: 'user-1',
48
+ scopes,
49
+ ipRestrictions: null,
50
+ expiresAt: null,
51
+ lastUsedAt: null,
52
+ revokedAt: null,
53
+ },
54
+ };
55
+ }
56
+ function makeHandler(db) {
57
+ return handleActuateAPI({ prismaClient: db, config: { collections: {} } });
58
+ }
59
+ describe('GET /globals/:slug (authenticated)', () => {
60
+ beforeEach(() => {
61
+ delete globalThis.__actuateConfig;
62
+ process.env.CMS_SECRET = VALID_SECRET;
63
+ });
64
+ it('returns 200 with empty data when the global has never been saved', async () => {
65
+ const { key, record } = await makeKey({ globals: ['settings'] });
66
+ const db = createMockDB({ apiKey: record, global: null });
67
+ initDB(db);
68
+ const handler = makeHandler(db);
69
+ const response = await handler(new Request('https://example.com/api/cms/globals/settings', {
70
+ headers: { Authorization: `Bearer ${key}` },
71
+ }));
72
+ expect(response.status).toBe(200);
73
+ await expect(response.json()).resolves.toEqual({ data: {} });
74
+ });
75
+ it('returns the stored data when the global document exists', async () => {
76
+ const { key, record } = await makeKey({ globals: ['settings'] });
77
+ const db = createMockDB({
78
+ apiKey: record,
79
+ global: { id: 'doc-1', collection: 'settings', data: { siteTitle: 'Actuate Media' } },
80
+ });
81
+ initDB(db);
82
+ const handler = makeHandler(db);
83
+ const response = await handler(new Request('https://example.com/api/cms/globals/settings', {
84
+ headers: { Authorization: `Bearer ${key}` },
85
+ }));
86
+ expect(response.status).toBe(200);
87
+ await expect(response.json()).resolves.toEqual({ data: { siteTitle: 'Actuate Media' } });
88
+ });
89
+ it('rejects with 403 when the API key is not scoped to the global', async () => {
90
+ const { key, record } = await makeKey({ collections: ['posts'], actions: ['read'] });
91
+ const db = createMockDB({ apiKey: record, global: null });
92
+ initDB(db);
93
+ const handler = makeHandler(db);
94
+ const response = await handler(new Request('https://example.com/api/cms/globals/settings', {
95
+ headers: { Authorization: `Bearer ${key}` },
96
+ }));
97
+ expect(response.status).toBe(403);
98
+ });
99
+ });
100
+ //# sourceMappingURL=globals-routes.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"globals-routes.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/globals-routes.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AAEzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACpC,OAAO,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAA;AAGnE;;;;;;;;GAQG;AACH,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAcnC,SAAS,YAAY,CAAC,IAAqD;IACzE,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAA;IAC1B,OAAO;QACL,MAAM,EAAE;YACN,QAAQ,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9C,UAAU,EAAE,KAAK,EAAE,EAAE,KAAK,EAAkC,EAAE,EAAE;gBAC9D,IAAI,CAAC,MAAM;oBAAE,OAAO,IAAI,CAAA;gBACxB,IAAI,MAAM,CAAC,OAAO,KAAK,KAAK,CAAC,OAAO;oBAAE,OAAO,IAAI,CAAA;gBACjD,OAAO,MAAM,CAAA;YACf,CAAC;YACD,MAAM,EAAE,KAAK,IAAI,EAAE,CAAC,MAAM;SAC3B;QACD,QAAQ,EAAE;YACR,SAAS,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI;YAC1C,QAAQ,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE;YACxB,KAAK,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC;SACrB;QACD,IAAI,EAAE,EAAE;QACR,OAAO,EAAE,EAAE;QACX,KAAK,EAAE,EAAE;KACH,CAAA;AACV,CAAC;AAED,KAAK,UAAU,OAAO,CAAC,MAAmB;IACxC,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,MAAM,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAA;IACtF,OAAO;QACL,GAAG;QACH,MAAM,EAAE;YACN,EAAE,EAAE,UAAU;YACd,OAAO;YACP,SAAS;YACT,MAAM,EAAE,QAAQ;YAChB,MAAM;YACN,cAAc,EAAE,IAAI;YACpB,SAAS,EAAE,IAAI;YACf,UAAU,EAAE,IAAI;YAChB,SAAS,EAAE,IAAI;SAChB;KACF,CAAA;AACH,CAAC;AAED,SAAS,WAAW,CAAC,EAAW;IAC9B,OAAO,gBAAgB,CAAC,EAAE,YAAY,EAAE,EAAW,EAAE,MAAM,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;AACrF,CAAC;AAED,QAAQ,CAAC,oCAAoC,EAAE,GAAG,EAAE;IAClD,UAAU,CAAC,GAAG,EAAE;QACd,OAAQ,UAAkB,CAAC,eAAe,CAAA;QAC1C,OAAO,CAAC,GAAG,CAAC,UAAU,GAAG,YAAY,CAAA;IACvC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kEAAkE,EAAE,KAAK,IAAI,EAAE;QAChF,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,CAAA;QAChE,MAAM,EAAE,GAAG,YAAY,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;QACzD,MAAM,CAAC,EAAE,CAAC,CAAA;QACV,MAAM,OAAO,GAAG,WAAW,CAAC,EAAE,CAAC,CAAA;QAE/B,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,IAAI,OAAO,CAAC,8CAA8C,EAAE;YAC1D,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,GAAG,EAAE,EAAE;SAC5C,CAAC,CACH,CAAA;QAED,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACjC,MAAM,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yDAAyD,EAAE,KAAK,IAAI,EAAE;QACvE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,CAAA;QAChE,MAAM,EAAE,GAAG,YAAY,CAAC;YACtB,MAAM,EAAE,MAAM;YACd,MAAM,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE;SACtF,CAAC,CAAA;QACF,MAAM,CAAC,EAAE,CAAC,CAAA;QACV,MAAM,OAAO,GAAG,WAAW,CAAC,EAAE,CAAC,CAAA;QAE/B,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,IAAI,OAAO,CAAC,8CAA8C,EAAE;YAC1D,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,GAAG,EAAE,EAAE;SAC5C,CAAC,CACH,CAAA;QAED,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACjC,MAAM,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,CAAC,CAAA;IAC1F,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,+DAA+D,EAAE,KAAK,IAAI,EAAE;QAC7E,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QACpF,MAAM,EAAE,GAAG,YAAY,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;QACzD,MAAM,CAAC,EAAE,CAAC,CAAA;QACV,MAAM,OAAO,GAAG,WAAW,CAAC,EAAE,CAAC,CAAA;QAE/B,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,IAAI,OAAO,CAAC,8CAA8C,EAAE;YAC1D,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,GAAG,EAAE,EAAE;SAC5C,CAAC,CACH,CAAA;QAED,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IACnC,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index-status.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index-status.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/seo/index-status.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,140 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { computeSiteIndexStatus, getSiteIndexStatus, robotsTxtBlocksEntireSite, } from '../../seo/index-status.js';
3
+ import { DEFAULT_SEO_MODULE_SETTINGS, SEO_CONFIG_COLLECTION } from '../../seo/config-store.js';
4
+ import { setActuateConfig } from '../../config/runtime.js';
5
+ /** Minimal config stub — index-status only reads `seo`. */
6
+ function stubConfig(seo) {
7
+ setActuateConfig({ collections: {}, ...(seo ? { seo } : {}) });
8
+ }
9
+ function inputs(overrides = {}) {
10
+ return {
11
+ environment: 'production',
12
+ seo: { siteUrl: 'https://example.com' },
13
+ module: { ...DEFAULT_SEO_MODULE_SETTINGS },
14
+ hasSeoConfig: true,
15
+ lastAuditAt: null,
16
+ ...overrides,
17
+ };
18
+ }
19
+ describe('robotsTxtBlocksEntireSite', () => {
20
+ it('detects a blanket Disallow: /', () => {
21
+ expect(robotsTxtBlocksEntireSite('User-agent: *\nDisallow: /')).toBe(true);
22
+ expect(robotsTxtBlocksEntireSite('User-agent: *\nDisallow: / # block all')).toBe(true);
23
+ });
24
+ it('does not flag scoped disallow rules', () => {
25
+ expect(robotsTxtBlocksEntireSite('User-agent: *\nDisallow: /admin\nDisallow: /api/')).toBe(false);
26
+ expect(robotsTxtBlocksEntireSite('')).toBe(false);
27
+ });
28
+ });
29
+ describe('computeSiteIndexStatus', () => {
30
+ it('reports indexable in production with a site URL and no blocks', () => {
31
+ const result = computeSiteIndexStatus(inputs());
32
+ expect(result.status).toBe('indexable');
33
+ expect(result.severity).toBe('success');
34
+ expect(result.label).toBe('Indexable');
35
+ });
36
+ it('reports blocked in production when global noindex is on', () => {
37
+ const result = computeSiteIndexStatus(inputs({ seo: { siteUrl: 'https://example.com', robots: { defaultNoIndex: true } } }));
38
+ expect(result.status).toBe('blocked');
39
+ expect(result.severity).toBe('danger');
40
+ expect(result.checks.find((c) => c.key === 'globalNoindex')?.status).toBe('fail');
41
+ });
42
+ it('reports blocked in production when robots.txt disallows everything', () => {
43
+ const result = computeSiteIndexStatus(inputs({
44
+ module: { ...DEFAULT_SEO_MODULE_SETTINGS, robotsTxt: 'User-agent: *\nDisallow: /' },
45
+ }));
46
+ expect(result.status).toBe('blocked');
47
+ expect(result.severity).toBe('danger');
48
+ });
49
+ it('reports blocked in production when the robots route is disabled', () => {
50
+ const result = computeSiteIndexStatus(inputs({ module: { ...DEFAULT_SEO_MODULE_SETTINGS, robotsEnabled: false } }));
51
+ expect(result.status).toBe('blocked');
52
+ });
53
+ it('reports protected when non-production noindex guards a preview env', () => {
54
+ const result = computeSiteIndexStatus(inputs({
55
+ environment: 'preview',
56
+ seo: { siteUrl: 'https://preview.example.com', robots: { noIndexNonProduction: true } },
57
+ }));
58
+ expect(result.status).toBe('protected');
59
+ expect(result.severity).toBe('warning');
60
+ expect(result.label).toBe('Protected');
61
+ });
62
+ it('warns (partially blocked) when a non-production env is NOT protected', () => {
63
+ const result = computeSiteIndexStatus(inputs({ environment: 'preview', seo: { siteUrl: 'https://preview.example.com' } }));
64
+ expect(result.status).toBe('partially_blocked');
65
+ expect(result.severity).toBe('warning');
66
+ expect(result.checks.find((c) => c.key === 'nonProdNoindex')?.status).toBe('warning');
67
+ });
68
+ it('flags a missing site URL as needs-attention', () => {
69
+ const result = computeSiteIndexStatus(inputs({ seo: { siteUrl: '' } }));
70
+ expect(result.status).toBe('unknown');
71
+ expect(result.severity).toBe('warning');
72
+ expect(result.checks.find((c) => c.key === 'siteUrl')?.status).toBe('warning');
73
+ });
74
+ it('reports partially blocked when the sitemap is disabled but the site is indexable', () => {
75
+ const result = computeSiteIndexStatus(inputs({ module: { ...DEFAULT_SEO_MODULE_SETTINGS, sitemapEnabled: false } }));
76
+ expect(result.status).toBe('partially_blocked');
77
+ expect(result.checks.find((c) => c.key === 'sitemap')?.status).toBe('warning');
78
+ });
79
+ it('reports unknown when no SEO config exists at all', () => {
80
+ const result = computeSiteIndexStatus(inputs({ hasSeoConfig: false, seo: null }));
81
+ expect(result.status).toBe('unknown');
82
+ expect(result.severity).toBe('neutral');
83
+ });
84
+ it('always exposes the three SEO actions and a full set of checks', () => {
85
+ const result = computeSiteIndexStatus(inputs());
86
+ expect(result.actions.map((a) => a.label)).toEqual([
87
+ 'Run SEO Audit',
88
+ 'View robots.txt',
89
+ 'View sitemap.xml',
90
+ ]);
91
+ expect(result.checks.map((c) => c.key)).toEqual([
92
+ 'environment',
93
+ 'siteUrl',
94
+ 'robotsBlockAll',
95
+ 'globalNoindex',
96
+ 'nonProdNoindex',
97
+ 'sitemap',
98
+ 'lastAudit',
99
+ ]);
100
+ });
101
+ it('surfaces the last audit date', () => {
102
+ const result = computeSiteIndexStatus(inputs({ lastAuditAt: '2026-01-02T03:04:05.000Z' }));
103
+ expect(result.checks.find((c) => c.key === 'lastAudit')?.value).toBe('2026-01-02');
104
+ expect(result.checks.find((c) => c.key === 'lastAudit')?.status).toBe('pass');
105
+ });
106
+ });
107
+ describe('getSiteIndexStatus', () => {
108
+ const origVercel = process.env.VERCEL_ENV;
109
+ const origNode = process.env.NODE_ENV;
110
+ afterEach(() => {
111
+ process.env.VERCEL_ENV = origVercel;
112
+ process.env.NODE_ENV = origNode;
113
+ stubConfig();
114
+ });
115
+ it('reads effective config + last audit from the DB', async () => {
116
+ process.env.VERCEL_ENV = 'production';
117
+ stubConfig({ siteUrl: 'https://example.com' });
118
+ const db = {
119
+ document: {
120
+ findFirst: async ({ where }) => where.collection === SEO_CONFIG_COLLECTION ? { data: { site: {}, module: {} } } : null,
121
+ },
122
+ seoAuditRun: {
123
+ findFirst: async () => ({ finishedAt: new Date('2026-02-03T00:00:00.000Z') }),
124
+ },
125
+ };
126
+ const result = await getSiteIndexStatus(db);
127
+ expect(result.status).toBe('indexable');
128
+ expect(result.environment).toBe('production');
129
+ expect(result.lastAuditAt).toBe('2026-02-03T00:00:00.000Z');
130
+ });
131
+ it('tolerates a missing audit table', async () => {
132
+ process.env.VERCEL_ENV = 'production';
133
+ stubConfig({ siteUrl: 'https://example.com' });
134
+ const db = { document: { findFirst: async () => null } };
135
+ const result = await getSiteIndexStatus(db);
136
+ expect(result.lastAuditAt).toBeNull();
137
+ expect(result.status).toBe('indexable');
138
+ });
139
+ });
140
+ //# sourceMappingURL=index-status.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index-status.test.js","sourceRoot":"","sources":["../../../src/__tests__/seo/index-status.test.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAkQD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAkrUzD"}
1
+ {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAyRD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA0tUzD"}
@@ -1102,6 +1102,27 @@ async function checkRateLimitAsync(limiter, key) {
1102
1102
  function clientIp(request) {
1103
1103
  return getClientIp(request);
1104
1104
  }
1105
+ /**
1106
+ * Compute a before/after diff of top-level scalar (string/number/boolean) keys
1107
+ * between a stored record and an incoming patch. Used to audit settings changes
1108
+ * without ever logging nested objects (which may hold secret blobs).
1109
+ */
1110
+ function diffScalarKeys(previous, next) {
1111
+ const isScalar = (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
1112
+ const changes = [];
1113
+ for (const key of Object.keys(next)) {
1114
+ const to = next[key];
1115
+ if (!isScalar(to))
1116
+ continue;
1117
+ const from = previous[key];
1118
+ if (isScalar(from) && from === to)
1119
+ continue;
1120
+ if (!isScalar(from) && from !== undefined)
1121
+ continue;
1122
+ changes.push({ key, from: isScalar(from) ? from : undefined, to });
1123
+ }
1124
+ return changes;
1125
+ }
1105
1126
  const MAX_CONCURRENT_SESSIONS = 5;
1106
1127
  /**
1107
1128
  * After a successful primary-credential check, prune the user's active sessions
@@ -6886,20 +6907,18 @@ export function registerCMSRoutes(router) {
6886
6907
  return accessErr;
6887
6908
  const { generateMetaTags } = await import('../seo/meta-tags.js');
6888
6909
  const { resolveRobotsDirectives } = await import('../seo/robots.js');
6910
+ const { getSeoOverrides, applySeoOverrides } = await import('../seo/config-store.js');
6889
6911
  const data = doc.data || {};
6890
- const settingsGlobal = await db().document.findFirst({
6891
- where: { collection: 'settings', deletedAt: null },
6892
- select: { data: true },
6893
- });
6894
- const settingsData = settingsGlobal?.data && typeof settingsGlobal.data === 'object'
6895
- ? settingsGlobal.data
6896
- : {};
6912
+ // Read robots defaults from the SAME effective SEO config the public
6913
+ // renderer (`composePageMeta`) and General Settings use — never the
6914
+ // legacy `settings` global — so this preview can't drift from reality.
6915
+ const effectiveSeo = applySeoOverrides(getActuateConfig(), await getSeoOverrides(db()))?.seo;
6897
6916
  const robots = resolveRobotsDirectives({
6898
6917
  pageData: data,
6899
6918
  settings: {
6900
- defaultNoIndex: settingsData.defaultNoIndex === true,
6901
- defaultNoFollow: settingsData.defaultNoFollow === true,
6902
- noIndexNonProduction: settingsData.noIndexNonProduction === true,
6919
+ defaultNoIndex: effectiveSeo?.robots?.defaultNoIndex,
6920
+ defaultNoFollow: effectiveSeo?.robots?.defaultNoFollow,
6921
+ noIndexNonProduction: effectiveSeo?.robots?.noIndexNonProduction,
6903
6922
  },
6904
6923
  environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
6905
6924
  });
@@ -7138,6 +7157,25 @@ export function registerCMSRoutes(router) {
7138
7157
  return internalError(err, 'seo/config PUT');
7139
7158
  }
7140
7159
  });
7160
+ // Effective, computed crawl/index status for the General Settings card.
7161
+ // Reads the SAME effective SEO config the public renderer uses, so the badge
7162
+ // shown to editors never drifts from what search engines actually see.
7163
+ router.get('/seo/index-status', async (request) => {
7164
+ try {
7165
+ const auth = await requireAuth(request);
7166
+ if (auth.error)
7167
+ return auth.error;
7168
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
7169
+ if (roleErr)
7170
+ return roleErr;
7171
+ const { getSiteIndexStatus } = await import('../seo/index-status.js');
7172
+ const status = await getSiteIndexStatus(db());
7173
+ return json({ data: status });
7174
+ }
7175
+ catch (err) {
7176
+ return internalError(err, 'seo/index-status GET');
7177
+ }
7178
+ });
7141
7179
  // ---------------------------------------------------------------------------
7142
7180
  // Technical SEO — sitemap status, crawl settings, robots.txt editor.
7143
7181
  // All backed by the `module` block of the SEO overrides document.
@@ -8874,10 +8912,13 @@ export function registerCMSRoutes(router) {
8874
8912
  build: async () => {
8875
8913
  const ctx = buildActionContext(auth.session, db());
8876
8914
  const global = await getGlobal(params.slug, ctx);
8877
- if (!global) {
8878
- return errorResponse('Global not found', 404);
8879
- }
8880
- return { data: global };
8915
+ // Globals are upsert-on-write: a slug with no row yet (fresh install,
8916
+ // never-saved settings) is a valid EMPTY state, not an error. Return
8917
+ // {} so the admin editor (e.g. the Settings page) loads cleanly before
8918
+ // the first save, and the next PUT creates the document. The public
8919
+ // `/public/globals/:slug` route keeps its config-gated 404 for
8920
+ // genuinely undeclared globals.
8921
+ return { data: global ?? {} };
8881
8922
  },
8882
8923
  });
8883
8924
  }
@@ -8900,7 +8941,30 @@ export function registerCMSRoutes(router) {
8900
8941
  }
8901
8942
  const body = (await request.json());
8902
8943
  const ctx = buildActionContext(auth.session, db());
8944
+ const previous = await getGlobal(params.slug, ctx).catch(() => null);
8903
8945
  const global = await updateGlobal(params.slug, body, ctx);
8946
+ // Audit settings changes (title/tagline/URL/locale/timezone/etc.) with a
8947
+ // per-key before/after diff so the audit trail records what changed and
8948
+ // who changed it. Scalar values only — we never log nested secret blobs.
8949
+ try {
8950
+ const changes = diffScalarKeys(previous ?? {}, body);
8951
+ if (changes.length > 0) {
8952
+ void logEvent({
8953
+ event: 'settings_changed',
8954
+ userId: auth.session.userId,
8955
+ ipAddress: getClientIp(request),
8956
+ details: {
8957
+ global: params.slug,
8958
+ source: 'database',
8959
+ environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
8960
+ changes,
8961
+ },
8962
+ });
8963
+ }
8964
+ }
8965
+ catch {
8966
+ // Audit failures must never block the write.
8967
+ }
8904
8968
  return json({ data: global });
8905
8969
  }
8906
8970
  catch (err) {