@bygghammar/cms-platform 0.51.0 → 0.53.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.53.0
4
+
5
+ - `admin/describe-log`: one place that turns action codes and JSON meta into
6
+ ordinary English sentences for the Audit, Activity and Email log views.
7
+ - `server/audit-core`: `auditAs` lets any staff-guarded core log through the
8
+ caller's own client, and every entry now stores its sentence in
9
+ `meta.summary` at write time.
10
+ - `server/email-log-core`: `logEmail` records every send attempt — form
11
+ notifications, auto-replies, provider tests and account emails — with a
12
+ purpose and a sentence. Form delivery and the backoffice test send both go
13
+ through it.
14
+ - Content records, language domains, redirects, site settings, image focus
15
+ points, image text and submission/delivery actions are now audited.
16
+ - `AuditView`/`EmailLogsView`: rows lead with the sentence; codes stay in the
17
+ expanded detail only.
18
+
19
+ ## 0.52.0
20
+
21
+ - `IndexingStatusCard`: read-only search-engine indexing status (from
22
+ `site/indexing`), shown on the SEO list and SEO detail views via a new
23
+ optional `indexing` dep — the same box as Admin → Site settings.
24
+
3
25
  ## 0.51.0
4
26
 
5
27
  - `site/indexing`: environment-derived search-engine policy (`SITE_INDEXING`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -141,7 +141,9 @@
141
141
  "./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx",
142
142
  "./backoffice/SharedContentListView": "./src/backoffice/SharedContentListView.tsx",
143
143
  "./backoffice/seo/SeoListView": "./src/backoffice/seo/SeoListView.tsx",
144
+ "./admin/describe-log": "./src/admin/describe-log.ts",
144
145
  "./server/audit-core": "./src/server/audit-core.ts",
146
+ "./server/email-log-core": "./src/server/email-log-core.ts",
145
147
  "./server/review-image-validate": "./src/server/review-image-validate.ts",
146
148
  "./server/media-library-core": "./src/server/media-library-core.ts",
147
149
  "./server/admin-core": "./src/server/admin-core.ts",
@@ -197,7 +199,8 @@
197
199
  "./runtime/error-reporting": "./src/runtime/error-reporting.ts",
198
200
  "./server/media-usage-host": "./src/server/media-usage-host.ts",
199
201
  "./runtime/server-entry": "./src/runtime/server-entry.ts",
200
- "./site/indexing": "./src/site/indexing.ts"
202
+ "./site/indexing": "./src/site/indexing.ts",
203
+ "./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx"
201
204
  },
202
205
  "dependencies": {
203
206
  "fflate": "^0.8.3"
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Plain-English descriptions for audit and email log rows.
3
+ *
4
+ * Everything an editor sees in the Activity, Audit and Email log views is
5
+ * written here in ordinary sentences — never raw action codes, table names or
6
+ * JSON. The same module runs on the server, where each log row gets its sentence
7
+ * stored in `meta.summary` at write time, so the wording survives even if the
8
+ * label tables later change.
9
+ */
10
+
11
+ export const ACTION_LABELS: Record<string, string> = {
12
+ "user.create": "Created user",
13
+ "user.delete": "Deleted user",
14
+ "user.password_reset": "Set a new password",
15
+ "user.send_reset": "Sent a password reset email",
16
+ "user.set_roles": "Changed user roles",
17
+ "user.invite": "Invited user",
18
+ "role.grant": "Granted role",
19
+ "role.revoke": "Revoked role",
20
+ "role.update": "Updated role",
21
+ "email.settings.update": "Updated email settings",
22
+ "email_settings.update": "Updated email settings",
23
+ "email.settings.test": "Sent test email",
24
+ "email_provider.create": "Added email provider",
25
+ "email_provider.update": "Updated email provider",
26
+ "email_provider.delete": "Deleted email provider",
27
+ "email_provider.test_send": "Sent a test email",
28
+ "content.section.save": "Saved content",
29
+ "content.section.delete": "Deleted content",
30
+ "site_settings.update": "Updated site settings",
31
+ "site_settings.clear": "Reset a site setting to the code default",
32
+ "page.create": "Created page",
33
+ "page.update": "Updated page",
34
+ "page.delete": "Deleted page",
35
+ "page.publish": "Published page",
36
+ "page.unpublish": "Unpublished page",
37
+ "page.version.create": "Saved page version",
38
+ "page.version.restore": "Restored page version",
39
+ "template.create": "Created block template",
40
+ "template.update": "Updated block template",
41
+ "template.delete": "Deleted block template",
42
+ "global.section.save": "Saved global block",
43
+ "global.section.delete": "Deleted global block",
44
+ "domain.create": "Added language domain",
45
+ "domain.update": "Updated language domain",
46
+ "domain.save": "Saved language domain",
47
+ "domain.delete": "Removed language domain",
48
+ "seo.update": "Updated SEO settings",
49
+ "seo.delete": "Removed SEO settings",
50
+ "redirect.save": "Saved redirect",
51
+ "redirect.delete": "Deleted redirect",
52
+ "media.upload": "Uploaded media",
53
+ "media.delete": "Deleted media",
54
+ "media.import": "Imported external media",
55
+ "media.replace": "Replaced an image",
56
+ "media.focus.set": "Set an image focus point",
57
+ "media.focus.clear": "Cleared an image focus point",
58
+ "media.meta.set": "Updated image text",
59
+ "media.meta.clear": "Cleared image text",
60
+ "submission.partial.delete": "Deleted saved partial submissions",
61
+ "delivery.resolve": "Marked delivery attempts as resolved",
62
+ "translate.run": "Ran auto-translation",
63
+ "form.create": "Created form",
64
+ "form.update": "Updated form",
65
+ "form.duplicate": "Duplicated form",
66
+ "form.soft_delete": "Moved form to bin",
67
+ "form.restore": "Restored form",
68
+ "form.purge": "Permanently deleted form",
69
+ "form.defaults.update": "Updated shared form defaults",
70
+ "submission.status": "Changed a submission status",
71
+ "submission.purge": "Removed old submissions",
72
+ "submission.sheet_push": "Sent submissions to the spreadsheet",
73
+ "submission.delivery_retry": "Retried a failed delivery",
74
+ "submission.delivery_resolve": "Marked a delivery problem as resolved",
75
+ "submission.partials_delete": "Deleted saved drafts",
76
+ "backup.export": "Exported a backup",
77
+ "backup.import": "Restored from a backup",
78
+ "auth.login": "Signed in",
79
+ "auth.logout": "Signed out",
80
+ };
81
+
82
+ export const ENTITY_LABELS: Record<string, string> = {
83
+ user: "user",
84
+ "auth.users": "user",
85
+ user_role: "role",
86
+ user_roles: "roles",
87
+ content_section: "content record",
88
+ content_sections: "content record",
89
+ site_settings: "site settings",
90
+ page: "page",
91
+ page_version: "page version",
92
+ page_publication: "publication",
93
+ block_template: "template",
94
+ global_section: "global block",
95
+ locale_domain: "language domain",
96
+ locale_domains: "language domain",
97
+ page_seo: "SEO settings",
98
+ email_settings: "email settings",
99
+ email_providers: "email provider",
100
+ media: "media file",
101
+ media_focus: "image focus point",
102
+ media_meta: "image text",
103
+ redirect: "redirect",
104
+ redirects: "redirect",
105
+ forms: "form",
106
+ form_submissions: "submission",
107
+ form_partials: "saved draft",
108
+ form_delivery_attempts: "delivery attempt",
109
+ translations: "translations",
110
+ backup: "backup",
111
+ };
112
+
113
+ export function humanizeAction(action: string | null | undefined): string {
114
+ if (!action) return "—";
115
+ const label = ACTION_LABELS[action];
116
+ if (label) return label;
117
+ return action
118
+ .replace(/[._-]+/g, " ")
119
+ .replace(/\s+/g, " ")
120
+ .trim()
121
+ .replace(/^./, (c) => c.toUpperCase());
122
+ }
123
+
124
+ export function humanizeEntity(entity: string | null | undefined): string {
125
+ if (!entity) return "—";
126
+ const label = ENTITY_LABELS[entity];
127
+ if (label) return label.replace(/^./, (c) => c.toUpperCase());
128
+ return entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase());
129
+ }
130
+
131
+ /** Lower-case noun for use inside a sentence, e.g. "the page". */
132
+ export function entityNoun(entity: string | null | undefined): string {
133
+ if (!entity) return "record";
134
+ return ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").toLowerCase();
135
+ }
136
+
137
+ const isUuid = (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(v);
138
+
139
+ function metaString(meta: Record<string, unknown>, keys: string[]): string | null {
140
+ for (const key of keys) {
141
+ const value = meta[key];
142
+ if (typeof value === "string" && value.trim()) return value.trim();
143
+ if (typeof value === "number") return String(value);
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * One sentence describing what happened, in ordinary English.
150
+ *
151
+ * Uses whatever human-friendly handle the meta payload carries (name, key,
152
+ * email, path) and only falls back to an identifier when nothing better exists.
153
+ */
154
+ export function describeAudit(input: {
155
+ action: string;
156
+ entity?: string | null;
157
+ entityId?: string | null;
158
+ meta?: Record<string, unknown> | null;
159
+ }): string {
160
+ const meta = (input.meta ?? {}) as Record<string, unknown>;
161
+ const existing = metaString(meta, ["summary"]);
162
+ if (existing) return existing;
163
+
164
+ const verb = humanizeAction(input.action);
165
+ const noun = entityNoun(input.entity);
166
+ const handle =
167
+ metaString(meta, ["name", "title", "email", "key", "path", "from_path", "domain", "locale"]) ??
168
+ (input.entityId && !isUuid(input.entityId) ? input.entityId : null);
169
+
170
+ const firstWord = noun.split(" ")[0] ?? "";
171
+ const alreadyNamesEntity = firstWord ? verb.toLowerCase().includes(firstWord) : false;
172
+ const subject = alreadyNamesEntity ? "" : ` the ${noun}`;
173
+ const named = handle ? ` “${handle}”` : "";
174
+
175
+ let sentence = `${verb}${subject}${named}`.replace(/\s+/g, " ").trim();
176
+
177
+ const extras: string[] = [];
178
+ const roles = meta["roles"];
179
+ if (Array.isArray(roles) && roles.length) extras.push(`roles: ${roles.join(", ")}`);
180
+ const to = metaString(meta, ["to_path"]);
181
+ if (to && input.action.startsWith("redirect")) extras.push(`now points to ${to}`);
182
+ const count = meta["count"] ?? meta["deleted"] ?? meta["updated"];
183
+ if (typeof count === "number") extras.push(`${count} item${count === 1 ? "" : "s"}`);
184
+ const status = metaString(meta, ["status"]);
185
+ if (status && input.action.startsWith("submission.status")) extras.push(`set to ${status}`);
186
+ const reason = metaString(meta, ["reason", "note"]);
187
+ if (reason) extras.push(reason);
188
+
189
+ if (extras.length) sentence += ` — ${extras.join("; ")}`;
190
+ if (!/[.!?]$/.test(sentence)) sentence += ".";
191
+ return sentence;
192
+ }
193
+
194
+ /** One sentence describing an email attempt. */
195
+ export function describeEmail(input: {
196
+ status: string;
197
+ to: string;
198
+ subject?: string | null;
199
+ provider?: string | null;
200
+ purpose?: string | null;
201
+ error?: string | null;
202
+ }): string {
203
+ const purposeLabel = humanizeEmailPurpose(input.purpose);
204
+ const lead = purposeLabel ? `${purposeLabel} email` : "Email";
205
+ const subject = input.subject ? ` “${input.subject}”` : "";
206
+ const via = input.provider ? ` via ${input.provider}` : "";
207
+ const ok = input.status === "sent" || input.status === "delivered";
208
+ const verb = ok ? "sent to" : "could not be sent to";
209
+ const tail = !ok && input.error ? ` — ${input.error}` : "";
210
+ return `${lead}${subject} ${verb} ${input.to}${via}${tail}.`;
211
+ }
212
+
213
+ /** Friendly names for the purposes app emails are sent for. */
214
+ export const EMAIL_PURPOSE_LABELS: Record<string, string> = {
215
+ form_notification: "Form notification",
216
+ form_autoreply: "Form auto-reply",
217
+ form_test: "Form test",
218
+ provider_test: "Provider test",
219
+ password_reset: "Password reset",
220
+ user_invite: "New account",
221
+ };
222
+
223
+ export function humanizeEmailPurpose(purpose: string | null | undefined): string | null {
224
+ if (!purpose) return null;
225
+ return (
226
+ EMAIL_PURPOSE_LABELS[purpose] ??
227
+ purpose.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
228
+ );
229
+ }
@@ -1,83 +1,18 @@
1
1
  // Turn machine action codes and JSON meta into human-readable strings for the
2
- // backoffice Audit and Email log views.
2
+ // backoffice Audit and Email log views. All wording lives in `describe-log`.
3
3
 
4
- const ACTION_LABELS: Record<string, string> = {
5
- "user.create": "Created user",
6
- "user.delete": "Deleted user",
7
- "user.password_reset": "Reset password",
8
- "user.invite": "Invited user",
9
- "role.grant": "Granted role",
10
- "role.revoke": "Revoked role",
11
- "role.update": "Updated role",
12
- "email.settings.update": "Updated email settings",
13
- "email.settings.test": "Sent test email",
14
- "content.section.save": "Saved section",
15
- "content.section.delete": "Deleted section",
16
- "page.create": "Created page",
17
- "page.update": "Updated page",
18
- "page.delete": "Deleted page",
19
- "page.publish": "Published page",
20
- "page.unpublish": "Unpublished page",
21
- "page.version.create": "Saved page version",
22
- "page.version.restore": "Restored page version",
23
- "template.create": "Created block template",
24
- "template.update": "Updated block template",
25
- "template.delete": "Deleted block template",
26
- "global.section.save": "Saved global block",
27
- "global.section.delete": "Deleted global block",
28
- "domain.create": "Added locale domain",
29
- "domain.update": "Updated locale domain",
30
- "domain.delete": "Removed locale domain",
31
- "seo.update": "Updated SEO",
32
- "media.upload": "Uploaded media",
33
- "media.delete": "Deleted media",
34
- "media.import": "Imported external media",
35
- "translate.run": "Ran auto-translation",
36
- "form.create": "Created form",
37
- "form.update": "Updated form",
38
- "form.duplicate": "Duplicated form",
39
- "form.soft_delete": "Moved form to bin",
40
- "form.restore": "Restored form",
41
- "form.purge": "Permanently deleted form",
42
- "form.defaults.update": "Updated shared form defaults",
43
- "auth.login": "Signed in",
44
- "auth.logout": "Signed out",
45
- };
4
+ export {
5
+ ACTION_LABELS,
6
+ ENTITY_LABELS,
7
+ describeAudit,
8
+ describeEmail,
9
+ entityNoun,
10
+ humanizeAction,
11
+ humanizeEmailPurpose,
12
+ humanizeEntity,
13
+ } from "./describe-log";
46
14
 
47
- const ENTITY_LABELS: Record<string, string> = {
48
- user: "User",
49
- user_role: "Role",
50
- content_section: "Section",
51
- page: "Page",
52
- page_version: "Page version",
53
- page_publication: "Publication",
54
- block_template: "Template",
55
- global_section: "Global block",
56
- locale_domain: "Domain",
57
- page_seo: "SEO",
58
- email_settings: "Email settings",
59
- media: "Media",
60
- redirect: "Redirect",
61
- forms: "Form",
62
- };
63
-
64
- export function humanizeAction(action: string | null | undefined): string {
65
- if (!action) return "—";
66
- if (ACTION_LABELS[action]) return ACTION_LABELS[action];
67
- // Fallback: turn "some.snake_case.action" into "Some snake case action".
68
- return action
69
- .replace(/[._-]+/g, " ")
70
- .replace(/\s+/g, " ")
71
- .trim()
72
- .replace(/^./, (c) => c.toUpperCase());
73
- }
74
-
75
- export function humanizeEntity(entity: string | null | undefined): string {
76
- if (!entity) return "—";
77
- return (
78
- ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
79
- );
80
- }
15
+ import { describeAudit, humanizeEntity } from "./describe-log";
81
16
 
82
17
  function shortId(id: string): string {
83
18
  if (id.length <= 12) return id;
@@ -105,6 +40,7 @@ export function summarizeMeta(meta: unknown): string {
105
40
  if (!Object.keys(m).length) return "";
106
41
 
107
42
  const parts: string[] = [];
43
+ // `summary` is rendered on its own as the row's sentence — never repeat it here.
108
44
  const push = (label: string, value: unknown) => {
109
45
  if (value == null || value === "") return;
110
46
  if (typeof value === "object") {
@@ -120,7 +56,6 @@ export function summarizeMeta(meta: unknown): string {
120
56
 
121
57
  // Common well-known fields, in a nice order
122
58
  const known = [
123
- ["summary", "Summary"],
124
59
  ["email", "Email"],
125
60
  ["role", "Role"],
126
61
  ["name", "Name"],
@@ -146,7 +81,7 @@ export function summarizeMeta(meta: unknown): string {
146
81
  }
147
82
  // Any remaining keys, up to 4 total extra fields
148
83
  const extras = Object.keys(m)
149
- .filter((k) => !used.has(k))
84
+ .filter((k) => !used.has(k) && k !== "summary")
150
85
  .slice(0, 4);
151
86
  for (const k of extras) {
152
87
  push(
@@ -215,3 +150,28 @@ export function emailStatusTone(status: string | null | undefined): {
215
150
  }
216
151
  );
217
152
  }
153
+
154
+
155
+ /**
156
+ * The plain-English sentence for one audit row: the stored `meta.summary`
157
+ * written at the time of the action, or a sentence derived from the row.
158
+ */
159
+ export function auditSentence(row: {
160
+ action: string;
161
+ entity?: string | null;
162
+ entity_id?: string | null;
163
+ meta?: Record<string, unknown> | null;
164
+ }): string {
165
+ return describeAudit({
166
+ action: row.action,
167
+ entity: row.entity ?? null,
168
+ entityId: row.entity_id ?? null,
169
+ meta: row.meta ?? null,
170
+ });
171
+ }
172
+
173
+ /** The plain-English sentence stored with an email log row, when present. */
174
+ export function emailSentence(row: { meta?: Record<string, unknown> | null }): string {
175
+ const summary = row.meta?.["summary"];
176
+ return typeof summary === "string" ? summary : "";
177
+ }
@@ -1,6 +1,7 @@
1
1
  import { Fragment, useMemo, useState } from "react";
2
2
 
3
3
  import {
4
+ auditSentence,
4
5
  formatAbsoluteTime,
5
6
  formatEntityRef,
6
7
  formatRelativeTime,
@@ -35,6 +36,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
35
36
  r.actor_email,
36
37
  r.action,
37
38
  humanizeAction(r.action),
39
+ auditSentence(r),
38
40
  r.entity,
39
41
  r.entity_id,
40
42
  JSON.stringify(r.meta ?? {}),
@@ -77,6 +79,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
77
79
  </thead>
78
80
  <tbody>
79
81
  {filtered.map((r) => {
82
+ const sentence = auditSentence(r);
80
83
  const summary = summarizeMeta(r.meta as never);
81
84
  const isOpen = expanded === r.id;
82
85
  return (
@@ -100,7 +103,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
100
103
  )}
101
104
  </td>
102
105
  <td className="p-3">
103
- <div className="text-ink">{humanizeAction(r.action)}</div>
106
+ <div className="text-ink">{sentence}</div>
104
107
  {summary && (
105
108
  <div className="text-[0.7rem] text-ink/60 mt-1 line-clamp-2">{summary}</div>
106
109
  )}
@@ -1,6 +1,12 @@
1
1
  import { Fragment, useMemo, useState } from "react";
2
2
 
3
- import { emailStatusTone, formatAbsoluteTime, formatRelativeTime } from "../admin/format-log";
3
+ import {
4
+ emailSentence,
5
+ emailStatusTone,
6
+ formatAbsoluteTime,
7
+ formatRelativeTime,
8
+ humanizeEmailPurpose,
9
+ } from "../admin/format-log";
4
10
 
5
11
  /** One outgoing email attempt. */
6
12
  export type EmailLogRow = {
@@ -33,7 +39,17 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
33
39
  return rows.filter((r) => {
34
40
  if (status !== "all" && r.status !== status) return false;
35
41
  if (!s) return true;
36
- const hay = [r.to_address, r.from_address, r.subject, r.provider, r.error]
42
+ const hay = [
43
+ r.to_address,
44
+ r.from_address,
45
+ r.subject,
46
+ r.provider,
47
+ r.error,
48
+ emailSentence(r),
49
+ humanizeEmailPurpose(
50
+ typeof r.meta?.["purpose"] === "string" ? (r.meta["purpose"] as string) : null,
51
+ ),
52
+ ]
37
53
  .filter(Boolean)
38
54
  .join(" ")
39
55
  .toLowerCase();
@@ -88,6 +104,10 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
88
104
  <tbody>
89
105
  {filtered.map((r) => {
90
106
  const tone = emailStatusTone(r.status);
107
+ const purpose = humanizeEmailPurpose(
108
+ typeof r.meta?.["purpose"] === "string" ? (r.meta["purpose"] as string) : null,
109
+ );
110
+ const sentence = emailSentence(r);
91
111
  const isOpen = expanded === r.id;
92
112
  return (
93
113
  <Fragment key={r.id}>
@@ -109,6 +129,11 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
109
129
  </td>
110
130
  <td className="p-3">
111
131
  <div className="text-ink">{r.subject ?? "(no subject)"}</div>
132
+ {purpose && (
133
+ <div className="text-[0.65rem] uppercase tracking-[0.15em] text-ink/50 mt-1">
134
+ {purpose}
135
+ </div>
136
+ )}
112
137
  {r.body_preview && (
113
138
  <div className="text-[0.7rem] text-ink/50 mt-1 line-clamp-1">
114
139
  {r.body_preview}
@@ -133,6 +158,7 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
133
158
  {isOpen && (
134
159
  <tr className="bg-ink/[0.02] border-t border-ink/10">
135
160
  <td colSpan={6} className="p-4 space-y-3 text-xs">
161
+ {sentence && <p className="text-ink/80">{sentence}</p>}
136
162
  <dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
137
163
  <DetailRow label="To" value={r.to_address} />
138
164
  <DetailRow label="From" value={r.from_address ?? "—"} />
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { IndexingPolicy } from "../site/indexing";
4
+
5
+ /**
6
+ * Read-only search-engine indexing status, resolved server-side from the
7
+ * environment (`SITE_INDEXING` / `SITE_ENV` — see `site/indexing`). Shown on
8
+ * Site settings and the SEO screens so a blocked staging deployment is
9
+ * visible wherever metadata is edited. Never a database toggle: indexing is
10
+ * an environment fact so a content restore cannot flip it.
11
+ */
12
+ export function IndexingStatusCard({
13
+ indexing,
14
+ }: {
15
+ indexing?: IndexingPolicy;
16
+ }): ReactNode {
17
+ const status = indexing
18
+ ? indexing.mode === "block"
19
+ ? "Blocked — not indexed"
20
+ : "Indexed"
21
+ : "Unknown";
22
+ const source = indexing
23
+ ? indexing.source === "SITE_INDEXING"
24
+ ? `From SITE_INDEXING=${indexing.value}`
25
+ : indexing.source === "SITE_ENV"
26
+ ? `From SITE_ENV=${indexing.value}`
27
+ : "Safe default — SITE_INDEXING unset"
28
+ : "Set SITE_INDEXING per environment";
29
+
30
+ return (
31
+ <div className="max-w-sm border border-ink/10 bg-white p-4">
32
+ <div className="text-xs uppercase tracking-wider text-ink/50">Search engines</div>
33
+ <div className="mt-1 text-sm text-ink">{status}</div>
34
+ <div className="mt-2 text-[11px] uppercase tracking-wider text-ink/40">{source}</div>
35
+ <p className="mt-3 text-xs text-ink/50">
36
+ Indexing is an environment fact, not a setting here: set <code>SITE_INDEXING=off</code> on
37
+ staging and <code>SITE_INDEXING=on</code> in production. A blocked deployment serves{" "}
38
+ <code>Disallow: /</code> in robots.txt and a <code>noindex, nofollow</code> header on every
39
+ response.
40
+ </p>
41
+ </div>
42
+ );
43
+ }
@@ -7,6 +7,9 @@
7
7
  import type { ReactNode } from "react";
8
8
  import { Check, ChevronLeft, ChevronRight } from "lucide-react";
9
9
 
10
+ import type { IndexingPolicy } from "../../site/indexing";
11
+ import { IndexingStatusCard } from "../IndexingStatusCard";
12
+
10
13
  export type SeoListRow = {
11
14
  /** Page key, e.g. `index` or a CMS-only page key. */
12
15
  key: string;
@@ -21,10 +24,15 @@ export type SeoListDeps = {
21
24
  rows: readonly SeoListRow[];
22
25
  /** Navigate to the SEO editor for one page key. */
23
26
  openEditor: (key: string) => void;
27
+ /**
28
+ * Indexing policy for the running deployment, resolved from the environment
29
+ * server-side (see `site/indexing`). Shown read-only, same as Site settings.
30
+ */
31
+ indexing?: IndexingPolicy;
24
32
  };
25
33
 
26
34
  export function SeoListView({ deps }: { deps: SeoListDeps }): ReactNode {
27
- const { rows, openEditor } = deps;
35
+ const { rows, openEditor, indexing } = deps;
28
36
 
29
37
  return (
30
38
  <div className="p-8 md:p-12 w-full">
@@ -36,6 +44,10 @@ export function SeoListView({ deps }: { deps: SeoListDeps }): ReactNode {
36
44
  defaults from the codebase and are used directly by canonical, hreflang and sitemap.
37
45
  </p>
38
46
 
47
+ <div className="mt-6">
48
+ <IndexingStatusCard indexing={indexing} />
49
+ </div>
50
+
39
51
  <ul className="mt-10 divide-y divide-ink/10 border-y border-ink/10">
40
52
  {rows.map((row) => (
41
53
  <li key={row.key}>
@@ -73,10 +85,15 @@ export type SeoDetailDeps = {
73
85
  onBack: () => void;
74
86
  /** The host's page SEO panel for this page key. */
75
87
  children: ReactNode;
88
+ /**
89
+ * Indexing policy for the running deployment, resolved from the environment
90
+ * server-side (see `site/indexing`). Shown read-only, same as Site settings.
91
+ */
92
+ indexing?: IndexingPolicy;
76
93
  };
77
94
 
78
95
  export function SeoDetailView({ deps }: { deps: SeoDetailDeps }): ReactNode {
79
- const { pageKey, onBack, children } = deps;
96
+ const { pageKey, onBack, children, indexing } = deps;
80
97
 
81
98
  return (
82
99
  <div className="p-8 md:p-12 w-full">
@@ -98,6 +115,10 @@ export function SeoDetailView({ deps }: { deps: SeoDetailDeps }): ReactNode {
98
115
  hreflang, Open Graph and Twitter cards.
99
116
  </p>
100
117
 
118
+ <div className="mt-6">
119
+ <IndexingStatusCard indexing={indexing} />
120
+ </div>
121
+
101
122
  <div className="mt-8">{children}</div>
102
123
  </div>
103
124
  );
@@ -15,6 +15,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
15
15
 
16
16
  import { writeAudit } from "./audit-core";
17
17
  import { actorEmail, assertRole, assertStaff, type PlatformAuthContext } from "./guards";
18
+ import { logEmail } from "./email-log-core";
18
19
 
19
20
  /* eslint-disable @typescript-eslint/no-explicit-any */
20
21
 
@@ -111,6 +112,15 @@ export async function createUserCore(
111
112
  .from("user_roles")
112
113
  .insert(roles.map((role) => ({ user_id: uid, role })));
113
114
  }
115
+ if (!input.password) {
116
+ await logEmail(anyClient(admin), {
117
+ to: input.email,
118
+ subject: "Your new account",
119
+ provider: "Authentication service",
120
+ status: "sent",
121
+ purpose: "user_invite",
122
+ });
123
+ }
114
124
  await audit(deps, "user.create", "auth.users", uid, { email: input.email, roles });
115
125
  return { id: uid };
116
126
  }
@@ -153,6 +163,13 @@ export async function sendPasswordResetEmailCore(
153
163
  email: input.email,
154
164
  });
155
165
  if (error) throw new Error(error.message);
166
+ await logEmail(anyClient(admin), {
167
+ to: input.email,
168
+ subject: "Password reset",
169
+ provider: "Authentication service",
170
+ status: "sent",
171
+ purpose: "password_reset",
172
+ });
156
173
  await audit(deps, "user.send_reset", "auth.users", undefined, { email: input.email });
157
174
  return { ok: true };
158
175
  }
@@ -359,19 +376,18 @@ export async function sendTestEmailCore(
359
376
  )
360
377
  : ({ ok: false, error: "No from address configured for this provider or in the email settings." } as const);
361
378
 
362
- await anyClient(admin)
363
- .from("email_logs")
364
- .insert({
365
- to_address: input.to,
366
- from_address: fromEmail,
367
- reply_to: replyTo,
368
- subject,
369
- body_preview: text.slice(0, 200),
370
- provider: provider.name,
371
- status: result.ok ? "sent" : "failed",
372
- error: result.ok ? null : result.error,
373
- meta: { kind: provider.kind, provider_id: provider.id, test: true },
374
- });
379
+ await logEmail(anyClient(admin), {
380
+ to: input.to,
381
+ from: fromEmail,
382
+ replyTo: replyTo,
383
+ subject,
384
+ bodyPreview: text,
385
+ provider: provider.name,
386
+ status: result.ok ? "sent" : "failed",
387
+ error: result.ok ? null : result.error,
388
+ purpose: "provider_test",
389
+ meta: { kind: provider.kind, provider_id: provider.id, test: true },
390
+ });
375
391
 
376
392
  await audit(deps, "email_provider.test_send", "email_providers", provider.id, {
377
393
  to: input.to,
@@ -2,19 +2,20 @@
2
2
  * Host-agnostic audit trail writer.
3
3
  *
4
4
  * Every platform core records what an editor did in the append-only
5
- * `audit_logs` table. The host passes a privileged client because the trail must
6
- * be written even when the acting user cannot read it back, and the entry always
7
- * carries the acting user's id and email so the log stays attributable.
5
+ * `audit_logs` table. Entries always carry the acting user's id and email, and
6
+ * always carry a plain-English sentence in `meta.summary` describing what
7
+ * happened, so the Audit view never has to show raw codes.
8
8
  *
9
9
  * Auditing is deliberately best-effort: a failed log entry never fails the
10
10
  * operation the editor asked for.
11
11
  */
12
+ import { describeAudit } from "../admin/describe-log";
12
13
  import type { PlatformAuthContext } from "./guards";
13
14
  import { actorEmail } from "./guards";
14
15
 
15
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
16
17
 
17
- /** Minimal privileged-client shape the audit writer needs. */
18
+ /** Minimal client shape the audit writer needs. */
18
19
  export type AuditClient = {
19
20
  from: (table: string) => { insert: (row: any) => PromiseLike<{ error: any }> };
20
21
  };
@@ -26,6 +27,9 @@ export type AuditEntry = {
26
27
  meta?: Record<string, unknown>;
27
28
  };
28
29
 
30
+ /** Writer shape hosts can pass into cores that need auditing. */
31
+ export type AuditWriter = (entry: AuditEntry) => Promise<void>;
32
+
29
33
  /** Write one audit entry. Never throws — logging must not break the action. */
30
34
  export async function writeAudit(
31
35
  admin: AuditClient,
@@ -33,13 +37,20 @@ export async function writeAudit(
33
37
  entry: AuditEntry,
34
38
  ): Promise<void> {
35
39
  try {
40
+ const meta = { ...(entry.meta ?? {}) } as Record<string, unknown>;
41
+ meta["summary"] = describeAudit({
42
+ action: entry.action,
43
+ entity: entry.entity ?? null,
44
+ entityId: entry.entityId ?? null,
45
+ meta,
46
+ });
36
47
  const { error } = await admin.from("audit_logs").insert({
37
48
  actor_id: context.userId,
38
49
  actor_email: actorEmail(context),
39
50
  action: entry.action,
40
51
  entity: entry.entity ?? null,
41
52
  entity_id: entry.entityId ?? null,
42
- meta: (entry.meta ?? {}) as unknown,
53
+ meta: meta as unknown,
43
54
  });
44
55
  if (error) console.warn("audit failed", error.message);
45
56
  } catch (err) {
@@ -52,3 +63,17 @@ export function auditWriter(admin: AuditClient, context: PlatformAuthContext, en
52
63
  return (action: string, entityId: string, meta: Record<string, unknown> = {}) =>
53
64
  writeAudit(admin, context, { action, entity, entityId, meta });
54
65
  }
66
+
67
+ /**
68
+ * Write one audit entry as the signed-in staff user.
69
+ *
70
+ * `audit_logs` accepts inserts from staff under row-level security, so cores
71
+ * that already ran a staff check can log through the ordinary request client
72
+ * without any privileged credential.
73
+ */
74
+ export async function auditAs(
75
+ context: PlatformAuthContext & { supabase: unknown },
76
+ entry: AuditEntry,
77
+ ): Promise<void> {
78
+ await writeAudit(context.supabase as unknown as AuditClient, context, entry);
79
+ }
@@ -10,6 +10,7 @@
10
10
  import type { Json } from "../i18n/json";
11
11
  import { stampManualEdits } from "../i18n/translation-meta";
12
12
  import { assertStaff, type PlatformAuthContext } from "./guards";
13
+ import { auditAs } from "./audit-core";
13
14
 
14
15
  /** Structural subset of a Supabase-shaped client used by this core. */
15
16
  export type ContentClient = {
@@ -64,5 +65,12 @@ export async function saveContentSectionCore(
64
65
  .from("content_sections")
65
66
  .upsert({ key: input.key, data: stamped as Json }, { onConflict: "key" });
66
67
  if (error) throw new Error(error.message);
68
+
69
+ await auditAs(context as never, {
70
+ action: "content.section.save",
71
+ entity: "content_sections",
72
+ entityId: input.key,
73
+ meta: { key: input.key },
74
+ });
67
75
  return { ok: true, key: input.key };
68
76
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { assertStaff, type PlatformAuthContext } from "./guards";
10
+ import { auditAs } from "./audit-core";
10
11
 
11
12
  /* eslint-disable @typescript-eslint/no-explicit-any */
12
13
  /** Structural subset of a Supabase-shaped client used by this core. */
@@ -165,6 +166,18 @@ export async function saveLocaleDomainCore(
165
166
  { onConflict: "locale" },
166
167
  );
167
168
  if (error) throw new Error(error.message);
169
+
170
+ await auditAs(context as never, {
171
+ action: "domain.save",
172
+ entity: "locale_domains",
173
+ entityId: input.locale,
174
+ meta: {
175
+ locale: input.locale,
176
+ domain: normalized,
177
+ is_default: input.isDefault,
178
+ is_admin_domain: input.isAdminDomain,
179
+ },
180
+ });
168
181
  return { ok: true };
169
182
  }
170
183
 
@@ -176,5 +189,11 @@ export async function deleteLocaleDomainCore(
176
189
  await assertStaff(context);
177
190
  const { error } = await context.supabase.from("locale_domains").delete().eq("locale", locale);
178
191
  if (error) throw new Error(error.message);
192
+ await auditAs(context as never, {
193
+ action: "domain.delete",
194
+ entity: "locale_domains",
195
+ entityId: locale,
196
+ meta: { locale },
197
+ });
179
198
  return { ok: true };
180
199
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Central email log writer.
3
+ *
4
+ * Every message the platform attempts to send — form notifications, auto-replies,
5
+ * provider tests and account emails sent by the authentication service — is
6
+ * recorded in `email_logs`, successful or not, with a plain-English sentence in
7
+ * `meta.summary`. Logging is best-effort and never fails a send.
8
+ */
9
+ import { describeEmail } from "../admin/describe-log";
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+
13
+ export type EmailLogClient = {
14
+ from: (table: string) => { insert: (row: any) => PromiseLike<{ error: any }> };
15
+ };
16
+
17
+ /** Why the message was sent, e.g. `form_notification` or `password_reset`. */
18
+ export type EmailPurpose =
19
+ | "form_notification"
20
+ | "form_autoreply"
21
+ | "form_test"
22
+ | "provider_test"
23
+ | "password_reset"
24
+ | "user_invite"
25
+ | (string & {});
26
+
27
+ export type EmailLogEntry = {
28
+ to: string;
29
+ from?: string | null;
30
+ replyTo?: string | null;
31
+ subject?: string | null;
32
+ bodyPreview?: string | null;
33
+ /** Provider name as shown in the backoffice, or the transport used. */
34
+ provider?: string | null;
35
+ status: "sent" | "failed" | "skipped" | string;
36
+ error?: string | null;
37
+ purpose?: EmailPurpose | null;
38
+ meta?: Record<string, unknown>;
39
+ };
40
+
41
+ /** Record one send attempt. Never throws. */
42
+ export async function logEmail(db: EmailLogClient, entry: EmailLogEntry): Promise<void> {
43
+ try {
44
+ const meta = { ...(entry.meta ?? {}) } as Record<string, unknown>;
45
+ if (entry.purpose) meta["purpose"] = entry.purpose;
46
+ meta["summary"] = describeEmail({
47
+ status: entry.status,
48
+ to: entry.to,
49
+ subject: entry.subject ?? null,
50
+ provider: entry.provider ?? null,
51
+ purpose: entry.purpose ?? null,
52
+ error: entry.error ?? null,
53
+ });
54
+ const { error } = await db.from("email_logs").insert({
55
+ to_address: entry.to,
56
+ from_address: entry.from ?? null,
57
+ reply_to: entry.replyTo ?? null,
58
+ subject: entry.subject ?? null,
59
+ body_preview: (entry.bodyPreview ?? "").slice(0, 200) || null,
60
+ provider: entry.provider ?? null,
61
+ status: entry.status,
62
+ error: entry.error ?? null,
63
+ meta: meta as unknown,
64
+ });
65
+ if (error) console.warn("email log failed", error.message);
66
+ } catch (err) {
67
+ console.warn("email log failed", err);
68
+ }
69
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { FormFieldDef } from "../forms/field-sets";
16
16
  import { sendViaProvider } from "./email-send-core";
17
+ import { logEmail } from "./email-log-core";
17
18
 
18
19
  /* eslint-disable @typescript-eslint/no-explicit-any */
19
20
  /** Structural subset of a Supabase-shaped privileged client. */
@@ -132,15 +133,16 @@ async function dispatch(
132
133
  },
133
134
  );
134
135
 
135
- await db.from("email_logs").insert({
136
- to_address: msg.to,
137
- from_address: fromEmail,
138
- reply_to: msg.replyTo,
136
+ await logEmail(db as never, {
137
+ to: msg.to,
138
+ from: fromEmail,
139
+ replyTo: msg.replyTo,
139
140
  subject: msg.subject,
140
- body_preview: msg.text.slice(0, 200),
141
+ bodyPreview: msg.text,
141
142
  provider: provider.name,
142
143
  status: result.ok ? "sent" : "failed",
143
144
  error: result.ok ? null : result.error,
145
+ purpose: typeof meta["purpose"] === "string" ? (meta["purpose"] as string) : null,
144
146
  meta: { ...meta, kind: provider.kind },
145
147
  });
146
148
 
@@ -33,6 +33,7 @@ import {
33
33
  type FormsContext,
34
34
  type FormsDb,
35
35
  } from "./forms-core";
36
+ import { auditAs } from "./audit-core";
36
37
  import {
37
38
  evaluateRule,
38
39
  isFieldVisible,
@@ -461,6 +462,12 @@ export async function setSubmissionStatusCore(
461
462
  .update({ status: data.status })
462
463
  .in("id", data.ids);
463
464
  if (error) throw new Error(error.message);
465
+ await auditAs(context as never, {
466
+ action: "submission.status",
467
+ entity: "form_submissions",
468
+ entityId: data.ids.join(", "),
469
+ meta: { status: data.status, count: data.ids.length },
470
+ });
464
471
  return { ok: true as const, updated: data.ids.length };
465
472
  }
466
473
 
@@ -800,6 +807,12 @@ export async function deletePartialsCore(context: FormsContext, ids: string[]) {
800
807
  await assertStaff(context);
801
808
  const { error } = await context.supabase.from("form_partials").delete().in("id", ids);
802
809
  if (error) throw new Error(error.message);
810
+ await auditAs(context as never, {
811
+ action: "submission.partial.delete",
812
+ entity: "form_partials",
813
+ entityId: ids.join(", "),
814
+ meta: { count: ids.length },
815
+ });
803
816
  return { ok: true as const, deleted: ids.length };
804
817
  }
805
818
 
@@ -1326,6 +1339,12 @@ export async function resolveDeliveryAttemptsCore(context: FormsContext, ids: st
1326
1339
  .update({ resolved_at: new Date().toISOString() })
1327
1340
  .in("id", ids);
1328
1341
  if (error) throw new Error(error.message);
1342
+ await auditAs(context as never, {
1343
+ action: "delivery.resolve",
1344
+ entity: "form_delivery_attempts",
1345
+ entityId: ids.join(", "),
1346
+ meta: { count: ids.length },
1347
+ });
1329
1348
  return { ok: true as const, resolved: ids.length };
1330
1349
  }
1331
1350
 
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { assertStaff, type PlatformAuthContext } from "./guards";
12
12
  import { clampFocal, focusKey, type FocusDevice, type FocusMap } from "../media/focus";
13
+ import { auditAs } from "./audit-core";
13
14
 
14
15
  /* eslint-disable @typescript-eslint/no-explicit-any */
15
16
 
@@ -95,6 +96,12 @@ export async function setMediaFocusCore(
95
96
  { onConflict: "path" },
96
97
  );
97
98
  if (error) throw new Error(error.message);
99
+ await auditAs(context as never, {
100
+ action: "media.focus.set",
101
+ entity: "media_focus",
102
+ entityId: path,
103
+ meta: { path, device, x: point.x, y: point.y },
104
+ });
98
105
  return { ok: true, path, device, ...point };
99
106
  }
100
107
 
@@ -105,6 +112,12 @@ export async function setMediaFocusCore(
105
112
  { onConflict: "path" },
106
113
  );
107
114
  if (error) throw new Error(error.message);
115
+ await auditAs(context as never, {
116
+ action: "media.focus.set",
117
+ entity: "media_focus",
118
+ entityId: path,
119
+ meta: { path, device, x: point.x, y: point.y },
120
+ });
108
121
  return { ok: true, path, device, ...point };
109
122
  }
110
123
 
@@ -127,10 +140,22 @@ export async function clearMediaFocusCore(
127
140
  .update({ mobile_x: null, mobile_y: null, updated_by: context.userId })
128
141
  .eq("path", path);
129
142
  if (error) throw new Error(error.message);
143
+ await auditAs(context as never, {
144
+ action: "media.focus.clear",
145
+ entity: "media_focus",
146
+ entityId: path,
147
+ meta: { path, device: "mobile" },
148
+ });
130
149
  return { ok: true };
131
150
  }
132
151
 
133
152
  const { error } = await context.supabase.from("media_focus").delete().eq("path", path);
134
153
  if (error) throw new Error(error.message);
154
+ await auditAs(context as never, {
155
+ action: "media.focus.clear",
156
+ entity: "media_focus",
157
+ entityId: path,
158
+ meta: { path, device: "desktop" },
159
+ });
135
160
  return { ok: true };
136
161
  }
@@ -9,6 +9,7 @@
9
9
  * public site; writes run under the host's authenticated context.
10
10
  */
11
11
  import { assertStaff, type PlatformAuthContext } from "./guards";
12
+ import { auditAs } from "./audit-core";
12
13
  import {
13
14
  cleanLocalized,
14
15
  metaKey,
@@ -77,6 +78,12 @@ export async function setMediaMetaCore(
77
78
  { onConflict: "path" },
78
79
  );
79
80
  if (error) throw new Error(error.message);
81
+ await auditAs(context as never, {
82
+ action: "media.meta.set",
83
+ entity: "media_meta",
84
+ entityId: path,
85
+ meta: { path },
86
+ });
80
87
  return { ok: true, path };
81
88
  }
82
89
 
@@ -91,5 +98,11 @@ export async function clearMediaMetaCore(
91
98
  if (!path) return { ok: true };
92
99
  const { error } = await context.supabase.from("media_meta").delete().eq("path", path);
93
100
  if (error) throw new Error(error.message);
101
+ await auditAs(context as never, {
102
+ action: "media.meta.clear",
103
+ entity: "media_meta",
104
+ entityId: path,
105
+ meta: { path },
106
+ });
94
107
  return { ok: true };
95
108
  }
@@ -6,6 +6,8 @@
6
6
  * so every site behaves identically.
7
7
  */
8
8
 
9
+ import { auditAs } from "./audit-core";
10
+
9
11
  /** Structural subset of a Supabase-shaped client used by these cores. */
10
12
  export type RedirectClient = {
11
13
  from: (table: string) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -50,6 +52,7 @@ export async function saveRedirectCore(
50
52
  client: RedirectClient,
51
53
  input: { id?: string; from_path: string; to_path: string; status: number },
52
54
  userId: string,
55
+ context?: Parameters<typeof auditAs>[0],
53
56
  ): Promise<{ ok: true }> {
54
57
  const row = {
55
58
  from_path: normalizePath(input.from_path),
@@ -61,15 +64,40 @@ export async function saveRedirectCore(
61
64
  ? await client.from("redirects").update(row).eq("id", input.id)
62
65
  : await client.from("redirects").insert({ ...row, created_by: userId });
63
66
  if (error) throw new Error(error.message);
67
+ if (context) {
68
+ await auditAs(context, {
69
+ action: "redirect.save",
70
+ entity: "redirects",
71
+ entityId: input.id ?? row.from_path,
72
+ meta: { from_path: row.from_path, to_path: row.to_path, status: row.status },
73
+ });
74
+ }
64
75
  return { ok: true };
65
76
  }
66
77
 
67
78
  export async function deleteRedirectCore(
68
79
  client: RedirectClient,
69
80
  id: string,
81
+ context?: Parameters<typeof auditAs>[0],
70
82
  ): Promise<{ ok: true }> {
83
+ const { data: existing } = await client
84
+ .from("redirects")
85
+ .select("from_path, to_path")
86
+ .eq("id", id)
87
+ .maybeSingle();
71
88
  const { error } = await client.from("redirects").delete().eq("id", id);
72
89
  if (error) throw new Error(error.message);
90
+ if (context) {
91
+ await auditAs(context, {
92
+ action: "redirect.delete",
93
+ entity: "redirects",
94
+ entityId: id,
95
+ meta: {
96
+ from_path: existing?.from_path ?? null,
97
+ to_path: existing?.to_path ?? null,
98
+ },
99
+ });
100
+ }
73
101
  return { ok: true };
74
102
  }
75
103
 
@@ -1,4 +1,5 @@
1
1
  /**
2
+ import { auditAs } from "./audit-core";
2
3
  * Host-agnostic core for the `site_settings` key/value store.
3
4
  *
4
5
  * Rows are `{ key, value jsonb }` where `key` is one of
@@ -13,6 +14,7 @@ import {
13
14
  type OverridableSettingKey,
14
15
  type SiteSettingOverrides,
15
16
  } from "../config/site-config";
17
+ import { auditAs } from "./audit-core";
16
18
 
17
19
  /* eslint-disable @typescript-eslint/no-explicit-any */
18
20
  type Client = {
@@ -20,6 +22,9 @@ type Client = {
20
22
  };
21
23
  /* eslint-enable @typescript-eslint/no-explicit-any */
22
24
 
25
+ /** Optional auth context, supplied when the caller wants the change audited. */
26
+ type AuditContext = Parameters<typeof auditAs>[0];
27
+
23
28
  export const SITE_SETTINGS_TABLE = "site_settings";
24
29
 
25
30
  /** Load every stored override, narrowed to the overridable subset. */
@@ -41,6 +46,7 @@ export async function saveSiteSettingsCore(
41
46
  client: Client,
42
47
  patch: SiteSettingOverrides,
43
48
  actorId?: string | null,
49
+ context?: AuditContext,
44
50
  ): Promise<SiteSettingOverrides> {
45
51
  const clean = parseSiteSettingOverrides(patch);
46
52
  const rows = (Object.keys(clean) as OverridableSettingKey[]).map((key) => ({
@@ -52,6 +58,14 @@ export async function saveSiteSettingsCore(
52
58
  if (rows.length) {
53
59
  const { error } = await client.from(SITE_SETTINGS_TABLE).upsert(rows, { onConflict: "key" });
54
60
  if (error) throw new Error(error.message);
61
+ if (context) {
62
+ await auditAs(context, {
63
+ action: "site_settings.update",
64
+ entity: "site_settings",
65
+ entityId: rows.map((r) => r.key).join(", "),
66
+ meta: { keys: rows.map((r) => r.key) },
67
+ });
68
+ }
55
69
  }
56
70
  return loadSiteSettingsCore(client);
57
71
  }
@@ -60,7 +74,16 @@ export async function saveSiteSettingsCore(
60
74
  export async function clearSiteSettingCore(
61
75
  client: Client,
62
76
  key: OverridableSettingKey,
77
+ context?: AuditContext,
63
78
  ): Promise<void> {
64
79
  const { error } = await client.from(SITE_SETTINGS_TABLE).delete().eq("key", key);
65
80
  if (error) throw new Error(error.message);
81
+ if (context) {
82
+ await auditAs(context, {
83
+ action: "site_settings.clear",
84
+ entity: "site_settings",
85
+ entityId: key,
86
+ meta: { key },
87
+ });
88
+ }
66
89
  }