@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10

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 (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. package/src/ui/Reveal.tsx +74 -9
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The Popups module's public-site integration.
3
+ *
4
+ * Importing this file registers the popup runtime with the module host, so a
5
+ * site enables popups by importing it once and switching the module on. It is
6
+ * the only popup entry a public bundle may reach: the builder, the statistics
7
+ * screen and the validator all live behind `./popups/admin`.
8
+ *
9
+ * Everything CMS-shaped is bridged here — the site's forms, its routing, its
10
+ * design tokens — so neither the popup package nor the site has to know about
11
+ * the other.
12
+ */
13
+ import { useCallback, useEffect, useMemo, useRef } from "react";
14
+ import type { ReactNode } from "react";
15
+ import { useRouterState } from "@tanstack/react-router";
16
+ import {
17
+ PopupRuntime,
18
+ withExcludedBase,
19
+ } from "@bygghammar/lightweight-popup/runtime";
20
+ import type {
21
+ FormRenderPort,
22
+ RuntimeAdapters,
23
+ } from "@bygghammar/lightweight-popup/adapters";
24
+
25
+ import { ManagedForm } from "../forms/FormRenderer";
26
+ import { onFormSubmissionSuccess } from "../forms/success-listeners";
27
+ import { siteConfig } from "../config/site-config";
28
+ import type { PopupPublicHost } from "./host";
29
+ import { popupPublicHost } from "./host";
30
+
31
+ /**
32
+ * Renders one of the site's own forms inside a popup.
33
+ *
34
+ * `popupKey` goes into the submission itself, and that is the whole point of
35
+ * this component existing: without it the request is indistinguishable from
36
+ * the same form filled on an ordinary page, so the server stores an ordinary
37
+ * entry and every delivery configured on the popup quietly does nothing.
38
+ *
39
+ * Success comes from the CMS form seam rather than from this component, which
40
+ * is what makes a honeypot rejection impossible to miscount: the form runtime
41
+ * only announces submissions the host actually stored. That listener drives
42
+ * the popup's own flow and analytics — it is deliberately not a second way to
43
+ * deliver, because it would be delivering on the browser's word.
44
+ */
45
+ function PopupForm({
46
+ popupKey,
47
+ formKey,
48
+ onSuccess,
49
+ }: {
50
+ popupKey: string;
51
+ formKey: string;
52
+ onSuccess: () => void;
53
+ }): ReactNode {
54
+ const latest = useRef(onSuccess);
55
+ latest.current = onSuccess;
56
+
57
+ useEffect(
58
+ () =>
59
+ onFormSubmissionSuccess((event) => {
60
+ if (event.formKey === formKey) latest.current();
61
+ }),
62
+ [formKey],
63
+ );
64
+
65
+ // The popup names itself here, in the popup module. The form renderer takes
66
+ // an opaque context and stays independent of the features that use it.
67
+ return <ManagedForm context={{ popupKey }} formKey={formKey} />;
68
+ }
69
+
70
+ /**
71
+ * Paths the runtime never displays on.
72
+ *
73
+ * Three sources, in one list. The site's own exclusions come first and are
74
+ * kept whole — this only ever adds to them. `/preview/**` is the platform's:
75
+ * a popup over the editor's live preview ends up in screenshots. The
76
+ * backoffice comes from the site's configured `backofficePath`, the same
77
+ * value `backofficePaths()` builds every backoffice address from, so a site
78
+ * that mounts its office at `/admin` or `/cms/backoffice` is covered without
79
+ * telling anyone twice.
80
+ *
81
+ * The popup package deliberately excludes nothing by itself. It cannot know
82
+ * which of a site's paths are private, and when it used to guess, every site
83
+ * whose office was named differently got popups over its own admin screens.
84
+ */
85
+ export function popupExcludedPaths(
86
+ host: Pick<PopupPublicHost, "excludedPaths">,
87
+ backofficePath: string,
88
+ ): readonly string[] {
89
+ return withExcludedBase(
90
+ [...(host.excludedPaths ?? []), "/preview/**"],
91
+ backofficePath,
92
+ );
93
+ }
94
+
95
+ export function PopupModuleRuntime(): ReactNode {
96
+ const path = useRouterState({ select: (state) => state.location.pathname });
97
+ const host = popupPublicHost();
98
+
99
+ // The argument type comes from the port, not a copy of it: a package that
100
+ // stopped supplying `popupKey` would fail to compile here rather than
101
+ // quietly go back to submitting forms with no identity.
102
+ const renderForm = useCallback<FormRenderPort["renderForm"]>(
103
+ ({ popupKey, formKey, onSuccess }) => (
104
+ <PopupForm popupKey={popupKey} formKey={formKey} onSuccess={onSuccess} />
105
+ ),
106
+ [],
107
+ );
108
+
109
+ const adapters = useMemo<RuntimeAdapters>(
110
+ () => ({
111
+ configs: host.configs,
112
+ analytics: host.analytics,
113
+ forms: { renderForm },
114
+ }),
115
+ [host, renderForm],
116
+ );
117
+
118
+ return (
119
+ <PopupRuntime
120
+ path={path}
121
+ adapters={adapters}
122
+ excludedPaths={popupExcludedPaths(host, siteConfig().backofficePath)}
123
+ />
124
+ );
125
+ }
126
+
127
+ // Default export so the module host can load this file lazily.
128
+ export default PopupModuleRuntime;
@@ -31,6 +31,11 @@ export type ServerEntryOptions = {
31
31
  * enters the SSR module graph for sites that do not use it.
32
32
  */
33
33
  injections?: () => Promise<SiteCodeInjections>;
34
+ /**
35
+ * The site's backoffice path, so a site that moved it off the default still
36
+ * keeps tracking code out of its admin pages.
37
+ */
38
+ backofficePath?: string;
34
39
  };
35
40
 
36
41
  type ServerEntry = {
@@ -86,6 +91,7 @@ async function injectSiteCode(
86
91
  request: Request,
87
92
  response: Response,
88
93
  load: () => Promise<SiteCodeInjections>,
94
+ backofficePath: string | undefined,
89
95
  ): Promise<Response> {
90
96
  if (response.status >= 400) return response;
91
97
  const contentType = response.headers.get("content-type") ?? "";
@@ -94,7 +100,7 @@ async function injectSiteCode(
94
100
  try {
95
101
  const injections = await load();
96
102
  const url = new URL(request.url);
97
- if (!injections.isInjectablePath(url.pathname)) return response;
103
+ if (!injections.isInjectablePath(url.pathname, backofficePath)) return response;
98
104
 
99
105
  const bundle = await injections.resolve(url.host);
100
106
  if (!bundle.head && !bundle.bodyStart && !bundle.bodyEnd) return response;
@@ -118,7 +124,7 @@ export function createServerEntry(options: ServerEntryOptions = {}) {
118
124
  const response = await handler.fetch(request, env, ctx);
119
125
  const normalized = await normalizeCatastrophicSsrResponse(response);
120
126
  const injected = options.injections
121
- ? await injectSiteCode(request, normalized, options.injections)
127
+ ? await injectSiteCode(request, normalized, options.injections, options.backofficePath)
122
128
  : normalized;
123
129
  return await applyEdgeHeaders(request, injected, { compress: compressionEnabled() });
124
130
  } catch (error) {
@@ -9,6 +9,8 @@
9
9
 
10
10
  import type { Json } from "../i18n/json";
11
11
  import { stampManualEdits } from "../i18n/translation-meta";
12
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
13
+ import { assertEditable } from "./edit-locks-core";
12
14
  import { assertStaff, type PlatformAuthContext } from "./guards";
13
15
  import { auditAs } from "./audit-core";
14
16
 
@@ -25,8 +27,12 @@ export type ContentOverrides = Record<string, Json>;
25
27
  * Never throws: a backend hiccup degrades to "no overrides" so the site still
26
28
  * renders its in-code defaults.
27
29
  */
28
- export async function getContentOverridesCore(client: ContentClient): Promise<ContentOverrides> {
29
- const { data, error } = await client.from("content_sections").select("key, data");
30
+ export async function getContentOverridesCore(
31
+ client: ContentClient,
32
+ ): Promise<ContentOverrides> {
33
+ const { data, error } = await client
34
+ .from("content_sections")
35
+ .select("key, data");
30
36
  if (error) {
31
37
  console.error("[cms] getContentOverrides", error);
32
38
  return {};
@@ -37,8 +43,12 @@ export async function getContentOverridesCore(client: ContentClient): Promise<Co
37
43
  }
38
44
 
39
45
  /** True when the caller passes the host's `is_staff` check. */
40
- export async function isStaffCore(context: PlatformAuthContext): Promise<boolean> {
41
- const { data, error } = await context.supabase.rpc("is_staff", { _user_id: context.userId });
46
+ export async function isStaffCore(
47
+ context: PlatformAuthContext,
48
+ ): Promise<boolean> {
49
+ const { data, error } = await context.supabase.rpc("is_staff", {
50
+ _user_id: context.userId,
51
+ });
42
52
  if (error) throw new Error(error.message);
43
53
  return Boolean(data);
44
54
  }
@@ -50,9 +60,16 @@ export async function isStaffCore(context: PlatformAuthContext): Promise<boolean
50
60
  */
51
61
  export async function saveContentSectionCore(
52
62
  context: PlatformAuthContext & { supabase: ContentClient },
53
- input: { key: string; data?: unknown },
63
+ input: { key: string; data?: unknown; editSessionId?: string },
54
64
  ): Promise<{ ok: true; key: string }> {
55
65
  await assertStaff(context);
66
+ // Navigation, the footer, shared content and contact details are all rows in
67
+ // this table, so one check covers every editor built on top of it.
68
+ await assertEditable(context, {
69
+ resourceType: EDIT_LOCK_TYPES.contentSection,
70
+ resourceId: input.key,
71
+ sessionId: input.editSessionId,
72
+ });
56
73
 
57
74
  const { data: prevRow } = await context.supabase
58
75
  .from("content_sections")
@@ -1,39 +1,36 @@
1
1
  /**
2
- * Host-agnostic logic for locale → domain mapping (`locale_domains`).
2
+ * Host-agnostic database logic for locale → domain mapping (`locale_domains`).
3
3
  *
4
4
  * Reads use a privileged client because the table reveals which hostname serves
5
- * the backoffice; writes run as the signed-in staff user. The host owns the
6
- * server functions, its locale union and the request headers.
5
+ * the backoffice; writes run as the signed-in admin, matching the table's own
6
+ * row-level policies. The host owns the server functions, its locale union and
7
+ * the request headers.
8
+ *
9
+ * The routing itself — matching, the public projection, and which hostname
10
+ * serves the backoffice — is pure and lives in `src/site/locale-domains.ts`,
11
+ * re-exported here so server callers keep one import.
7
12
  */
8
13
 
9
- import { assertStaff, type PlatformAuthContext } from "./guards";
14
+ import { assertRole, type PlatformAuthContext } from "./guards";
10
15
  import { auditAs } from "./audit-core";
16
+ import {
17
+ canonicalDomainProblemMessage,
18
+ parseCanonicalDomain,
19
+ } from "../site/host";
20
+ import {
21
+ domainChangeProblemMessage,
22
+ validateDomainChange,
23
+ type AdminDomainChange,
24
+ type LocaleDomainRecord,
25
+ } from "../site/locale-domains";
26
+
27
+ export * from "../site/locale-domains";
11
28
 
12
29
  /* eslint-disable @typescript-eslint/no-explicit-any */
13
30
  /** Structural subset of a Supabase-shaped client used by this core. */
14
31
  export type DomainsClient = { from: (table: string) => any };
15
32
  /* eslint-enable @typescript-eslint/no-explicit-any */
16
33
 
17
- export type LocaleDomainRecord = {
18
- locale: string;
19
- domain: string;
20
- isDefault: boolean;
21
- isAdminDomain: boolean;
22
- };
23
-
24
- /** Strip protocol, trailing slashes and casing from a stored domain. */
25
- export function normalizeDomain(domain: string): string {
26
- return domain
27
- .replace(/^https?:\/\//, "")
28
- .replace(/\/+$/, "")
29
- .toLowerCase();
30
- }
31
-
32
- /** Strip a leading `www.` and casing from a request host. */
33
- export function normalizeHost(host: string): string {
34
- return host.replace(/^www\./, "").toLowerCase();
35
- }
36
-
37
34
  function rowToDomain(row: {
38
35
  locale: string;
39
36
  domain: string;
@@ -58,115 +55,127 @@ export async function listLocaleDomainsCore(client: DomainsClient): Promise<Loca
58
55
  return (data ?? []).map(rowToDomain);
59
56
  }
60
57
 
61
- /** The mapping whose domain matches `host`, ignoring protocol and `www.`. */
62
- export function matchDomain(
63
- domains: LocaleDomainRecord[],
64
- host: string,
65
- ): LocaleDomainRecord | undefined {
66
- const normalizedHost = normalizeHost(host);
67
- return domains.find((d) => {
68
- const normalized = normalizeDomain(d.domain);
69
- return normalizedHost === normalized || normalizedHost === `www.${normalized}`;
70
- });
71
- }
72
-
73
58
  /**
74
- * Resolve the active locale for a request host, falling back to the default
75
- * mapping, then to the first mapping, then to a synthetic host-based record.
59
+ * Refuse a change the table must not be left in after.
60
+ *
61
+ * The current rows are read here rather than accepted from the caller. A host
62
+ * that forgot to pass them would otherwise skip every check silently, and the
63
+ * checks exist for cases — two languages on aliases of one hostname, a table
64
+ * left with no default — that a screen can prevent but only the database can
65
+ * actually know about at the moment of writing.
76
66
  */
77
- export function resolveRequestLocale(
78
- domains: LocaleDomainRecord[],
79
- host: string,
80
- defaultLocale: string,
81
- ): { locale: string; domain: LocaleDomainRecord } {
82
- const match = matchDomain(domains, host);
83
- if (match) return { locale: match.locale, domain: match };
84
-
85
- const fallback = domains.find((d) => d.isDefault) ??
86
- domains[0] ?? {
87
- locale: defaultLocale,
88
- domain: host || "localhost",
89
- isDefault: true,
90
- isAdminDomain: true,
91
- };
92
- return { locale: fallback.locale, domain: fallback };
67
+ async function assertChangeIsAllowed(
68
+ client: DomainsClient,
69
+ change: AdminDomainChange,
70
+ options: { siteUrlAuthoritative: boolean; invalidDomainMessage?: string },
71
+ ): Promise<void> {
72
+ const problem = validateDomainChange(await listLocaleDomainsCore(client), change, {
73
+ siteUrlAuthoritative: options.siteUrlAuthoritative,
74
+ });
75
+ if (!problem) return;
76
+ if (problem.reason === "invalid-domain" && options.invalidDomainMessage) {
77
+ throw new Error(options.invalidDomainMessage);
78
+ }
79
+ throw new Error(domainChangeProblemMessage(problem));
93
80
  }
94
81
 
82
+ export type SaveLocaleDomainOptions = {
83
+ /** Host-supplied message replacing the generic rejection text. */
84
+ invalidDomainMessage?: string;
85
+ /** `SITE_URL`'s hostname, when this install has one. */
86
+ forcedHost?: string | null;
87
+ };
88
+
89
+ export type DeleteLocaleDomainOptions = {
90
+ /** `SITE_URL`'s hostname, when this install has one. */
91
+ forcedHost?: string | null;
92
+ };
93
+
95
94
  /**
96
- * The domain that serves the backoffice.
95
+ * Whether `SITE_URL` — not the table — decides where the backoffice is.
97
96
  *
98
- * `forcedHost` (a self-host `SITE_URL`) is authoritative: an imported row from
99
- * another deployment must never send the backoffice to that other hostname.
100
- * Otherwise the flagged database row wins, and the request host is last resort.
97
+ * The value is checked exactly as it was configured, before any normalizing.
98
+ * Normalizing first strips the scheme, so `ftp://example.com` becomes the
99
+ * perfectly valid `example.com` and a `SITE_URL` nobody can serve is treated
100
+ * as authoritative. That is not a cosmetic mistake: authority here switches
101
+ * off the lockout check below, so a malformed `SITE_URL` would let an admin
102
+ * domain of `localhost` be saved on an install where the flag is still what
103
+ * routes the backoffice — and the next administrator to sign out could not
104
+ * get back in.
101
105
  */
102
- export function resolveAdminDomain(
103
- domains: LocaleDomainRecord[],
104
- options: { forcedHost?: string | null; requestHost?: string | null; defaultLocale: string },
105
- ): LocaleDomainRecord {
106
- const dbAdmin = domains.find((d) => d.isAdminDomain) ?? domains[0];
107
-
108
- if (options.forcedHost) {
109
- return {
110
- locale: dbAdmin?.locale ?? options.defaultLocale,
111
- domain: options.forcedHost,
112
- isDefault: dbAdmin?.isDefault ?? true,
113
- isAdminDomain: true,
114
- };
115
- }
116
- if (dbAdmin) return dbAdmin;
117
- return {
118
- locale: options.defaultLocale,
119
- domain: options.requestHost || "localhost",
120
- isDefault: true,
121
- isAdminDomain: true,
122
- };
123
- }
124
-
125
- /** Absolute origin for a locale, or "" when the locale has no mapping. */
126
- export function resolveLocaleOrigin(locale: string, domains: LocaleDomainRecord[]): string {
127
- const match = domains.find((d) => d.locale === locale);
128
- if (!match) return "";
129
- return `https://${normalizeDomain(match.domain)}`;
106
+ function siteUrlDecides(forcedHost: string | null | undefined): boolean {
107
+ return parseCanonicalDomain(forcedHost ?? "").ok;
130
108
  }
131
109
 
132
110
  /**
133
- * Staff-only upsert of one mapping. Keeps at most one default and one admin
134
- * domain by clearing the flags on every other locale first.
111
+ * Admin-only upsert of one mapping, matching the table's row-level policies.
112
+ * Keeps at most one default and one admin domain by clearing the flags on
113
+ * every other locale afterwards.
114
+ *
115
+ * The order is deliberate. There is no transaction here, so the row being
116
+ * saved is written first and the flags elsewhere are cleared only once it
117
+ * exists: an upsert that fails then changes nothing, instead of stripping the
118
+ * default or the admin domain off the rows that still hold it. Every statement
119
+ * is checked, and the audit entry is written only after all of them succeeded,
120
+ * so the log never claims a change that half happened. A later migration can
121
+ * make this atomic; until then this is the order that fails safely.
135
122
  */
136
123
  export async function saveLocaleDomainCore(
137
124
  context: PlatformAuthContext & { supabase: DomainsClient },
138
125
  input: { locale: string; domain: string; isDefault: boolean; isAdminDomain: boolean },
139
- options: { invalidDomainMessage?: string } = {},
126
+ options: SaveLocaleDomainOptions = {},
140
127
  ): Promise<{ ok: true }> {
141
- await assertStaff(context);
128
+ await assertRole(context, "admin");
129
+
130
+ const parsed = parseCanonicalDomain(input.domain);
131
+ if (!parsed.ok) {
132
+ throw new Error(
133
+ options.invalidDomainMessage ?? canonicalDomainProblemMessage(parsed.reason),
134
+ );
135
+ }
136
+ const normalized = parsed.domain;
137
+
138
+ await assertChangeIsAllowed(
139
+ context.supabase,
140
+ {
141
+ kind: "save",
142
+ locale: input.locale,
143
+ domain: normalized,
144
+ isDefault: input.isDefault,
145
+ isAdminDomain: input.isAdminDomain,
146
+ },
147
+ {
148
+ siteUrlAuthoritative: siteUrlDecides(options.forcedHost),
149
+ invalidDomainMessage: options.invalidDomainMessage,
150
+ },
151
+ );
142
152
 
143
- const normalized = normalizeDomain(input.domain);
144
- if (!normalized) throw new Error(options.invalidDomainMessage ?? "Invalid domain");
153
+ const upserted = await context.supabase.from("locale_domains").upsert(
154
+ {
155
+ locale: input.locale,
156
+ domain: normalized,
157
+ is_default: input.isDefault,
158
+ is_admin_domain: input.isAdminDomain,
159
+ },
160
+ { onConflict: "locale" },
161
+ );
162
+ if (upserted.error) throw new Error(upserted.error.message);
145
163
 
146
164
  if (input.isDefault) {
147
- await context.supabase
165
+ const cleared = await context.supabase
148
166
  .from("locale_domains")
149
167
  .update({ is_default: false })
150
168
  .neq("locale", input.locale);
169
+ if (cleared.error) throw new Error(cleared.error.message);
151
170
  }
152
171
  if (input.isAdminDomain) {
153
- await context.supabase
172
+ const cleared = await context.supabase
154
173
  .from("locale_domains")
155
174
  .update({ is_admin_domain: false })
156
175
  .neq("locale", input.locale);
176
+ if (cleared.error) throw new Error(cleared.error.message);
157
177
  }
158
178
 
159
- const { error } = await context.supabase.from("locale_domains").upsert(
160
- {
161
- locale: input.locale,
162
- domain: normalized,
163
- is_default: input.isDefault,
164
- is_admin_domain: input.isAdminDomain,
165
- },
166
- { onConflict: "locale" },
167
- );
168
- if (error) throw new Error(error.message);
169
-
170
179
  await auditAs(context as never, {
171
180
  action: "domain.save",
172
181
  entity: "locale_domains",
@@ -181,12 +190,20 @@ export async function saveLocaleDomainCore(
181
190
  return { ok: true };
182
191
  }
183
192
 
184
- /** Staff-only delete of one mapping. */
193
+ /** Admin-only delete of one mapping. */
185
194
  export async function deleteLocaleDomainCore(
186
195
  context: PlatformAuthContext & { supabase: DomainsClient },
187
196
  locale: string,
197
+ options: DeleteLocaleDomainOptions = {},
188
198
  ): Promise<{ ok: true }> {
189
- await assertStaff(context);
199
+ await assertRole(context, "admin");
200
+
201
+ await assertChangeIsAllowed(
202
+ context.supabase,
203
+ { kind: "delete", locale },
204
+ { siteUrlAuthoritative: siteUrlDecides(options.forcedHost) },
205
+ );
206
+
190
207
  const { error } = await context.supabase.from("locale_domains").delete().eq("locale", locale);
191
208
  if (error) throw new Error(error.message);
192
209
  await auditAs(context as never, {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The host-facing contract for editing locks.
3
+ *
4
+ * `edit-locks-core` expresses *what* it needs — take this lock, tell me who
5
+ * holds it, give it back — and nothing about how a host stores it. An
6
+ * application on ordinary PostgreSQL implements these five methods with
7
+ * whatever it already uses (`pg`, Kysely, Drizzle, a stored function, an HTTP
8
+ * service); an application on Supabase gets the same thing prebuilt from
9
+ * `createSupabaseEditLockAdapter`. Neither has to know the other exists.
10
+ *
11
+ * Two rules keep an adapter honest:
12
+ *
13
+ * 1. `acquire` must be **one atomic statement**. Two editors opening the same
14
+ * resource in the same instant have to contend on a unique index, so
15
+ * exactly one wins. A read-then-write leaves a window where both do — which
16
+ * is the whole failure this feature exists to prevent.
17
+ * 2. `sessionId` is a bearer token. Never return it to a caller, and never
18
+ * expose the column that stores it to ordinary clients.
19
+ */
20
+ import type { EditLockRef, EditLockState } from "../cms/edit-lock";
21
+
22
+ /** One live lock as an administrator sees it. Note the absent `session_id`. */
23
+ export type EditLockRow = {
24
+ resource_type: string;
25
+ resource_id: string;
26
+ user_id: string;
27
+ user_email: string | null;
28
+ user_name: string | null;
29
+ acquired_at: string;
30
+ refreshed_at: string;
31
+ expires_at: string;
32
+ };
33
+
34
+ /** Take a lock, extend one, or take it over. `ttlSeconds` arrives clamped. */
35
+ export type EditLockAcquireInput = EditLockRef & {
36
+ sessionId: string;
37
+ ttlSeconds: number;
38
+ /** Take it from whoever holds it. An adapter must refuse a non-superadmin. */
39
+ steal: boolean;
40
+ };
41
+
42
+ /** Read a lock. A null `sessionId` asks "is anyone holding this at all". */
43
+ export type EditLockStatusInput = EditLockRef & { sessionId: string | null };
44
+
45
+ /** Give a lock back. Only the holder's own session may succeed. */
46
+ export type EditLockReleaseInput = EditLockRef & { sessionId: string };
47
+
48
+ /**
49
+ * Raised by an adapter when this installation has no lock storage yet.
50
+ *
51
+ * A CMS that upgrades the package before it upgrades its database must keep
52
+ * saving exactly as it did, so the core treats this one error as "no locks
53
+ * configured" and lets the write through. Every other failure surfaces.
54
+ */
55
+ export class EditLockNotInstalledError extends Error {
56
+ readonly code = "edit_lock_not_installed" as const;
57
+ constructor(message = "Editing locks are not installed on this database") {
58
+ super(message);
59
+ this.name = "EditLockNotInstalledError";
60
+ }
61
+ }
62
+
63
+ /** What a host application supplies to make editing locks work. */
64
+ export type EditLockAdapter = {
65
+ /**
66
+ * Throw unless the acting user may use the backoffice at all.
67
+ *
68
+ * Defence in depth: storage is expected to enforce this too, and an adapter
69
+ * whose storage already does may implement this as a no-op deliberately.
70
+ */
71
+ assertStaff(): Promise<void>;
72
+ acquire(input: EditLockAcquireInput): Promise<EditLockState>;
73
+ status(input: EditLockStatusInput): Promise<EditLockState>;
74
+ /** True when a row was actually removed. */
75
+ release(input: EditLockReleaseInput): Promise<boolean>;
76
+ list(): Promise<EditLockRow[]>;
77
+ };
78
+
79
+ /** A request context that carries its own lock storage. */
80
+ export type EditLockHostContext = { editLocks: EditLockAdapter };
81
+
82
+ export function hasEditLockAdapter(
83
+ context: unknown,
84
+ ): context is EditLockHostContext {
85
+ return (
86
+ !!context &&
87
+ typeof context === "object" &&
88
+ typeof (context as EditLockHostContext).editLocks === "object" &&
89
+ (context as EditLockHostContext).editLocks !== null
90
+ );
91
+ }