@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.
- package/CHANGELOG.md +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- 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(
|
|
29
|
-
|
|
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(
|
|
41
|
-
|
|
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
|
|
6
|
-
* server functions, its locale union and
|
|
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 {
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
):
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
*
|
|
95
|
+
* Whether `SITE_URL` — not the table — decides where the backoffice is.
|
|
97
96
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
134
|
-
* domain by clearing the flags on
|
|
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:
|
|
126
|
+
options: SaveLocaleDomainOptions = {},
|
|
140
127
|
): Promise<{ ok: true }> {
|
|
141
|
-
await
|
|
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
|
|
144
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
+
}
|