@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,135 @@
1
+ /**
2
+ * The editing-lock adapter for hosts that already talk PostgREST.
3
+ *
4
+ * Every Supabase- and PostgREST-shaped assumption in this feature lives in
5
+ * this one file: calling database functions by name, passing `_`-prefixed
6
+ * named arguments, reading `{ data, error }` instead of catching, and knowing
7
+ * that `PGRST202` means "no such function". `edit-locks-core` knows none of
8
+ * it, so a host on ordinary PostgreSQL simply never imports this module.
9
+ */
10
+ import { asEditLockState } from "../cms/edit-lock";
11
+ import {
12
+ EditLockNotInstalledError,
13
+ type EditLockAcquireInput,
14
+ type EditLockAdapter,
15
+ type EditLockReleaseInput,
16
+ type EditLockRow,
17
+ type EditLockStatusInput,
18
+ } from "./edit-lock-adapter";
19
+ import { assertStaff, type PlatformAuthContext } from "./guards";
20
+
21
+ /**
22
+ * Codes and wording that mean "this installation has not run the editing-lock
23
+ * migration yet". `PGRST202`/`PGRST203` come from PostgREST, `42883`/`42P01`
24
+ * from PostgreSQL itself.
25
+ */
26
+ const NOT_INSTALLED = new Set(["PGRST202", "PGRST203", "42883", "42P01"]);
27
+
28
+ type PostgrestError = { code?: string | null; message?: string | null } | null;
29
+
30
+ function notInstalled(error: PostgrestError): boolean {
31
+ if (!error) return false;
32
+ if (error.code && NOT_INSTALLED.has(error.code)) return true;
33
+ return /could not find the function|does not exist/i.test(
34
+ String(error.message ?? ""),
35
+ );
36
+ }
37
+
38
+ /**
39
+ * One database-function call, with the two failure modes separated: a missing
40
+ * function becomes `EditLockNotInstalledError`, anything else is a real error.
41
+ */
42
+ async function rpc(
43
+ context: PlatformAuthContext,
44
+ fn: string,
45
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
46
+ args: Record<string, any>,
47
+ whenFailed: string,
48
+ ): Promise<unknown> {
49
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
50
+ const { data, error } = (await context.supabase.rpc(
51
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
52
+ fn as any,
53
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
54
+ args as any,
55
+ )) as { data: unknown; error: PostgrestError };
56
+ if (error) {
57
+ if (notInstalled(error)) throw new EditLockNotInstalledError(whenFailed);
58
+ throw new Error(error.message ?? whenFailed);
59
+ }
60
+ return data;
61
+ }
62
+
63
+ /**
64
+ * Build the adapter from an authenticated PostgREST context.
65
+ *
66
+ * The calls run as the signed-in user's own client, so `is_staff` and RLS
67
+ * apply exactly as they do everywhere else — this never holds privileged
68
+ * credentials.
69
+ */
70
+ export function createSupabaseEditLockAdapter(
71
+ context: PlatformAuthContext,
72
+ ): EditLockAdapter {
73
+ return {
74
+ assertStaff: () => assertStaff(context),
75
+
76
+ async acquire(input: EditLockAcquireInput) {
77
+ return asEditLockState(
78
+ await rpc(
79
+ context,
80
+ "cms_acquire_edit_lock",
81
+ {
82
+ _resource_type: input.resourceType,
83
+ _resource_id: input.resourceId,
84
+ _session_id: input.sessionId,
85
+ _ttl_seconds: input.ttlSeconds,
86
+ _steal: input.steal,
87
+ },
88
+ input.steal
89
+ ? "Could not take over the editing lock"
90
+ : "Could not acquire the editing lock",
91
+ ),
92
+ );
93
+ },
94
+
95
+ async status(input: EditLockStatusInput) {
96
+ return asEditLockState(
97
+ await rpc(
98
+ context,
99
+ "cms_edit_lock_status",
100
+ {
101
+ _resource_type: input.resourceType,
102
+ _resource_id: input.resourceId,
103
+ _session_id: input.sessionId,
104
+ },
105
+ "Could not read the editing lock",
106
+ ),
107
+ );
108
+ },
109
+
110
+ async release(input: EditLockReleaseInput) {
111
+ return Boolean(
112
+ await rpc(
113
+ context,
114
+ "cms_release_edit_lock",
115
+ {
116
+ _resource_type: input.resourceType,
117
+ _resource_id: input.resourceId,
118
+ _session_id: input.sessionId,
119
+ },
120
+ "Could not release the editing lock",
121
+ ),
122
+ );
123
+ },
124
+
125
+ async list() {
126
+ const data = await rpc(
127
+ context,
128
+ "cms_list_edit_locks",
129
+ {},
130
+ "Could not list editing locks",
131
+ );
132
+ return Array.isArray(data) ? (data as EditLockRow[]) : [];
133
+ },
134
+ };
135
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Host-agnostic editing locks.
3
+ *
4
+ * This module owns the *domain*: what an editor is allowed to ask for, how
5
+ * long a lock may live, and — the point of the whole feature — that a write to
6
+ * a resource somebody else is holding must be refused by the server rather
7
+ * than by a disabled button.
8
+ *
9
+ * It owns none of the storage. Every database touch goes through
10
+ * `EditLockAdapter`, which a host implements with whatever it already uses:
11
+ * ordinary PostgreSQL through `pg`, a query builder, a stored function, or the
12
+ * prebuilt PostgREST adapter in `edit-lock-supabase-adapter`. Nothing here
13
+ * knows which.
14
+ *
15
+ * One rule an adapter must honour: taking a lock has to be a single atomic
16
+ * statement. Two editors opening the same page at the same instant contend on
17
+ * one unique index and exactly one wins. Doing that as "read, decide, write"
18
+ * would leave a window where both win.
19
+ */
20
+ import {
21
+ clampTtl,
22
+ FREE_EDIT_LOCK,
23
+ isBlocked,
24
+ lockedSaveMessage,
25
+ type EditLockRef,
26
+ type EditLockState,
27
+ } from "../cms/edit-lock";
28
+ import {
29
+ EditLockNotInstalledError,
30
+ hasEditLockAdapter,
31
+ type EditLockAdapter,
32
+ type EditLockHostContext,
33
+ type EditLockRow,
34
+ } from "./edit-lock-adapter";
35
+ import { createSupabaseEditLockAdapter } from "./edit-lock-supabase-adapter";
36
+ import { type PlatformAuthContext } from "./guards";
37
+
38
+ /**
39
+ * Either a host that carries its own lock storage, or — still supported — a
40
+ * PostgREST-shaped auth context, which is wrapped in the prebuilt adapter.
41
+ */
42
+ export type EditLockContext = EditLockHostContext | PlatformAuthContext;
43
+
44
+ export type EditLockRequest = EditLockRef & {
45
+ /** Identifies one tab across reloads. */
46
+ sessionId: string;
47
+ ttlSeconds?: number;
48
+ };
49
+
50
+ const EDIT_LOCKED_MESSAGE_PREFIX = "edit_locked: ";
51
+
52
+ /** Server refusal raised when somebody else is holding the resource. */
53
+ export class EditLockedError extends Error {
54
+ readonly code = "edit_locked" as const;
55
+ readonly state: EditLockState;
56
+ constructor(state: EditLockState) {
57
+ // TanStack Start deliberately serializes only Error.message. Keep the
58
+ // stable code in that field too, so a direct server-function response is
59
+ // recognisable after the custom `code` property crosses the wire.
60
+ super(`${EDIT_LOCKED_MESSAGE_PREFIX}${lockedSaveMessage(state)}`);
61
+ this.name = "EditLockedError";
62
+ this.state = state;
63
+ }
64
+ }
65
+
66
+ export type EditLockFailure = {
67
+ code?: "edit_locked";
68
+ message?: string;
69
+ state?: EditLockState;
70
+ };
71
+
72
+ /** True before or after an editing-lock failure crosses the server boundary. */
73
+ export function isEditLockedError(error: unknown): error is EditLockFailure {
74
+ if (error instanceof EditLockedError) return true;
75
+ if (!error || typeof error !== "object") return false;
76
+ const candidate = error as { code?: unknown; message?: unknown };
77
+ return (
78
+ candidate.code === "edit_locked" ||
79
+ (typeof candidate.message === "string" &&
80
+ candidate.message.startsWith(EDIT_LOCKED_MESSAGE_PREFIX))
81
+ );
82
+ }
83
+
84
+ const FREE = FREE_EDIT_LOCK;
85
+
86
+ /**
87
+ * An installation whose database has no lock storage keeps saving exactly as
88
+ * it did before locks existed, so every read path answers "nothing is locked"
89
+ * rather than failing the write. Any other error is a real error.
90
+ */
91
+ function whenMissing<T>(fallback: T) {
92
+ return (error: unknown): T => {
93
+ if (error instanceof EditLockNotInstalledError) return fallback;
94
+ throw error;
95
+ };
96
+ }
97
+
98
+ /** The host's storage, or the prebuilt PostgREST bridge for older hosts. */
99
+ function adapterFor(context: EditLockContext): EditLockAdapter {
100
+ if (hasEditLockAdapter(context)) return context.editLocks;
101
+ return createSupabaseEditLockAdapter(context);
102
+ }
103
+
104
+ async function acquire(
105
+ context: EditLockContext,
106
+ input: EditLockRequest,
107
+ steal: boolean,
108
+ ): Promise<EditLockState> {
109
+ const locks = adapterFor(context);
110
+ await locks.assertStaff();
111
+ return locks
112
+ .acquire({
113
+ resourceType: input.resourceType,
114
+ resourceId: input.resourceId,
115
+ sessionId: input.sessionId,
116
+ ttlSeconds: clampTtl(input.ttlSeconds),
117
+ steal,
118
+ })
119
+ .catch(whenMissing(FREE));
120
+ }
121
+
122
+ /**
123
+ * Take the lock, or extend one we already hold.
124
+ *
125
+ * The same call serves both because a heartbeat wants exactly this behaviour:
126
+ * if our lock lapsed while the laptop slept, re-taking it is right precisely
127
+ * when nobody else claimed it in the meantime — and the reply says plainly
128
+ * whether we ended up holding it.
129
+ */
130
+ export function acquireEditLockCore(
131
+ context: EditLockContext,
132
+ input: EditLockRequest,
133
+ ): Promise<EditLockState> {
134
+ return acquire(context, input, false);
135
+ }
136
+
137
+ /** Extend a lock we hold. Identical to acquiring it again; see above. */
138
+ export const renewEditLockCore = acquireEditLockCore;
139
+
140
+ /**
141
+ * Take a lock away from whoever holds it.
142
+ *
143
+ * The superadmin check belongs to the adapter's storage, not to this module,
144
+ * so no host can reach the same statement with a laxer rule of its own.
145
+ */
146
+ export function stealEditLockCore(
147
+ context: EditLockContext,
148
+ input: EditLockRequest,
149
+ ): Promise<EditLockState> {
150
+ return acquire(context, input, true);
151
+ }
152
+
153
+ /** Give a lock back. Only our own, and only from the session that took it. */
154
+ export async function releaseEditLockCore(
155
+ context: EditLockContext,
156
+ input: EditLockRef & { sessionId: string },
157
+ ): Promise<{ released: boolean }> {
158
+ const locks = adapterFor(context);
159
+ await locks.assertStaff();
160
+ const released = await locks
161
+ .release({
162
+ resourceType: input.resourceType,
163
+ resourceId: input.resourceId,
164
+ sessionId: input.sessionId,
165
+ })
166
+ .catch(whenMissing(false));
167
+ return { released };
168
+ }
169
+
170
+ /** Who holds a resource right now. An expired lock reads as free. */
171
+ export async function editLockStatusCore(
172
+ context: EditLockContext,
173
+ input: EditLockRef & { sessionId?: string },
174
+ ): Promise<EditLockState> {
175
+ const locks = adapterFor(context);
176
+ await locks.assertStaff();
177
+ return locks
178
+ .status({
179
+ resourceType: input.resourceType,
180
+ resourceId: input.resourceId,
181
+ sessionId: input.sessionId ?? null,
182
+ })
183
+ .catch(whenMissing(FREE));
184
+ }
185
+
186
+ /** Every live lock — what an administrator looks at when an editor is stuck. */
187
+ export async function listEditLocksCore(
188
+ context: EditLockContext,
189
+ ): Promise<EditLockRow[]> {
190
+ const locks = adapterFor(context);
191
+ await locks.assertStaff();
192
+ const rows = await locks.list().catch(whenMissing<EditLockRow[]>([]));
193
+ return Array.isArray(rows) ? rows : [];
194
+ }
195
+
196
+ /* ------------------------------------------------------- one host endpoint */
197
+
198
+ /** What a browser can ask of the lock service. */
199
+ export const EDIT_LOCK_ACTIONS = [
200
+ "acquire",
201
+ "renew",
202
+ "release",
203
+ "status",
204
+ "steal",
205
+ ] as const;
206
+
207
+ export type EditLockAction = (typeof EDIT_LOCK_ACTIONS)[number];
208
+
209
+ export function isEditLockAction(value: unknown): value is EditLockAction {
210
+ return (
211
+ typeof value === "string" &&
212
+ (EDIT_LOCK_ACTIONS as readonly string[]).includes(value)
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Every lock call behind one entry point.
218
+ *
219
+ * A host otherwise writes five near-identical server functions and wires five
220
+ * of them into the browser. This is the whole backend: validate with
221
+ * `editLockSchemas`, dispatch here, return what comes back.
222
+ *
223
+ * `steal` is deliberately not gated here — the adapter's storage refuses
224
+ * anyone but a superadmin — so a host that wants a narrower rule adds its own
225
+ * check before calling.
226
+ */
227
+ export async function editLockActionCore(
228
+ context: EditLockContext,
229
+ action: EditLockAction,
230
+ input: EditLockRef & { sessionId: string; ttlSeconds?: number },
231
+ ): Promise<EditLockState> {
232
+ switch (action) {
233
+ case "acquire":
234
+ return acquireEditLockCore(context, input);
235
+ case "renew":
236
+ return renewEditLockCore(context, input);
237
+ case "steal":
238
+ return stealEditLockCore(context, input);
239
+ case "status":
240
+ return editLockStatusCore(context, input);
241
+ case "release": {
242
+ await releaseEditLockCore(context, input);
243
+ // The screen only ever asks "may I edit"; after a release the answer is
244
+ // the same whether the row was ours or had already lapsed.
245
+ return FREE;
246
+ }
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Refuse a write to a resource somebody else is holding.
252
+ *
253
+ * Called by every save core, which is what makes the lock real: the editor's
254
+ * read-only banner is a courtesy, this is the rule. Two properties matter.
255
+ *
256
+ * It degrades only while there is no live lock. A caller that passes no
257
+ * `sessionId` keeps working on an unwired or unmigrated installation, but once
258
+ * any editor holds this resource the bearer id is required — including for
259
+ * the same user. Otherwise a stale tab could bypass the lock by omitting it.
260
+ *
261
+ * It is one read-only round trip, so it can sit at the top of every write path
262
+ * without changing the resource or extending its lock.
263
+ */
264
+ export async function assertEditable(
265
+ context: EditLockContext,
266
+ input: EditLockRef & { sessionId?: string | null },
267
+ ): Promise<EditLockState> {
268
+ const state = await adapterFor(context)
269
+ .status({
270
+ resourceType: input.resourceType,
271
+ resourceId: input.resourceId,
272
+ sessionId: input.sessionId ?? null,
273
+ })
274
+ .catch(whenMissing(FREE));
275
+ if (isBlocked(state)) throw new EditLockedError(state);
276
+ return state;
277
+ }
278
+
279
+ export type {
280
+ EditLockAdapter,
281
+ EditLockHostContext,
282
+ EditLockRow,
283
+ } from "./edit-lock-adapter";
284
+ export { EditLockNotInstalledError } from "./edit-lock-adapter";
285
+ export { createSupabaseEditLockAdapter } from "./edit-lock-supabase-adapter";
286
+ export type { EditLockRef, EditLockState } from "../cms/edit-lock";
@@ -21,6 +21,8 @@ import {
21
21
  } from "../forms/field-sets";
22
22
  import { EMAIL_RE, evaluateRule, normalizePolicyUrl } from "../forms/conditions";
23
23
  import { diffForms, summarizeChanges, type FormAuditChange } from "../forms/form-audit";
24
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
25
+ import { assertEditable } from "./edit-locks-core";
24
26
  import { actorEmail, assertStaff, type PlatformAuthContext } from "./guards";
25
27
 
26
28
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -357,6 +359,8 @@ export async function snapshotFormCore(
357
359
 
358
360
  export type SaveFormInput = {
359
361
  id?: string;
362
+ /** Editing session that holds the lock, when the host tracks one. */
363
+ editSessionId?: string;
360
364
  key: string;
361
365
  name: string;
362
366
  form_type: string;
@@ -371,6 +375,14 @@ export async function saveFormCore(
371
375
  data: SaveFormInput,
372
376
  ): Promise<{ id: string }> {
373
377
  await assertStaff(context);
378
+ // A form that does not exist yet cannot be held by anyone.
379
+ if (data.id) {
380
+ await assertEditable(context, {
381
+ resourceType: EDIT_LOCK_TYPES.form,
382
+ resourceId: data.id,
383
+ sessionId: data.editSessionId,
384
+ });
385
+ }
374
386
 
375
387
  // Never allow a field key that already has answers to disappear.
376
388
  let before: Record<string, unknown> | null = null;
@@ -485,8 +497,13 @@ export async function getFormUsageCore(
485
497
  }
486
498
 
487
499
  /** Soft delete: moves the form to the bin. Submissions are never touched. */
488
- export async function deleteFormCore(context: FormsContext, id: string) {
500
+ export async function deleteFormCore(context: FormsContext, id: string, editSessionId?: string) {
489
501
  await assertStaff(context);
502
+ await assertEditable(context, {
503
+ resourceType: EDIT_LOCK_TYPES.form,
504
+ resourceId: id,
505
+ sessionId: editSessionId,
506
+ });
490
507
  const email = actorEmail(context);
491
508
  const { data: form } = await context.supabase
492
509
  .from("forms")
@@ -662,9 +679,21 @@ export async function loadFormDefaultsCore(db: FormsDb): Promise<FormDefaults |
662
679
  export async function saveFormDefaultsCore(
663
680
  context: FormsContext,
664
681
  data: Record<string, unknown>,
682
+ editSessionId?: string,
665
683
  ) {
666
684
  await assertStaff(context);
667
- const patch = { ...data };
685
+ const { editSessionId: embeddedEditSessionId, ...settings } = data;
686
+ const lockSessionId =
687
+ editSessionId ??
688
+ (typeof embeddedEditSessionId === "string" ? embeddedEditSessionId : undefined);
689
+ await assertEditable(context, {
690
+ resourceType: EDIT_LOCK_TYPES.formDefaults,
691
+ resourceId: "form_settings",
692
+ sessionId: lockSessionId,
693
+ });
694
+ // The shared view puts the session beside the editable fields. Never pass
695
+ // that transport-only value through to the form_settings update.
696
+ const patch = { ...settings };
668
697
  if (typeof patch["privacy_policy_url"] === "string") {
669
698
  patch["privacy_policy_url"] = normalizePolicyUrl(patch["privacy_policy_url"] as string);
670
699
  }
@@ -759,6 +788,7 @@ export async function listFormVersionsCore(
759
788
  export async function restoreFormVersionCore(
760
789
  context: FormsContext,
761
790
  versionId: string,
791
+ editSessionId?: string,
762
792
  ): Promise<{ ok: true; carriedFields: string[] }> {
763
793
  await assertStaff(context);
764
794
  const { data: v, error } = await context.supabase
@@ -775,6 +805,11 @@ export async function restoreFormVersionCore(
775
805
  .maybeSingle();
776
806
  if (e2) throw new Error(e2.message);
777
807
  if (!form) throw new Error("This form no longer exists.");
808
+ await assertEditable(context, {
809
+ resourceType: EDIT_LOCK_TYPES.form,
810
+ resourceId: form.id as string,
811
+ sessionId: editSessionId,
812
+ });
778
813
 
779
814
  // Data safety: keep any field key that already has stored answers.
780
815
  const { data: subs } = await context.supabase