@bygghammar/cms-platform 0.90.0-m7.5 → 0.90.0-m7.9

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 (51) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  5. package/package.json +9 -3
  6. package/scripts/create-cms-site.mjs +5 -4
  7. package/scripts/sync-migrations.mjs +27 -6
  8. package/src/backoffice/ContactDetailsView.tsx +19 -5
  9. package/src/backoffice/EditLock.tsx +513 -0
  10. package/src/backoffice/EmailSettingsView.tsx +99 -0
  11. package/src/backoffice/FormEditorView.tsx +531 -512
  12. package/src/backoffice/FormSettingsView.tsx +16 -4
  13. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  14. package/src/backoffice/NavigationView.tsx +35 -11
  15. package/src/backoffice/PageBuilderView.tsx +51 -16
  16. package/src/backoffice/RichTextEditor.tsx +50 -14
  17. package/src/backoffice/SectionEditorView.tsx +18 -6
  18. package/src/backoffice/SectionFormView.tsx +184 -31
  19. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  20. package/src/backoffice/inspectors/blocks.tsx +22 -1
  21. package/src/backoffice/inspectors/fields.tsx +21 -0
  22. package/src/backoffice/routes/host.ts +3 -0
  23. package/src/backoffice/routes/index.tsx +1 -0
  24. package/src/backoffice/routes/pages.tsx +10 -6
  25. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  26. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  27. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  28. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  29. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  30. package/src/blocks/components/RichTextBlock.tsx +26 -4
  31. package/src/blocks/index.ts +8 -0
  32. package/src/blocks/optional-content.ts +82 -0
  33. package/src/blocks/registry.tsx +1 -0
  34. package/src/blocks/types.ts +6 -0
  35. package/src/cms/edit-lock.ts +491 -0
  36. package/src/forms/FormRenderer.tsx +22 -0
  37. package/src/host/index.tsx +17 -0
  38. package/src/navigation/index.ts +29 -0
  39. package/src/popups/admin.tsx +4 -0
  40. package/src/popups/runtime.tsx +23 -6
  41. package/src/server/content-sections-core.ts +22 -5
  42. package/src/server/edit-lock-adapter.ts +91 -0
  43. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  44. package/src/server/edit-locks-core.ts +286 -0
  45. package/src/server/forms-core.ts +37 -2
  46. package/src/server/page-seo-save-core.ts +27 -0
  47. package/src/server/pages-core.ts +108 -11
  48. package/src/server/provider-secrets.ts +100 -4
  49. package/src/server/schemas.ts +74 -9
  50. package/src/site/SiteHeader.tsx +11 -5
  51. package/src/ui/Reveal.tsx +74 -9
package/CHANGELOG.md CHANGED
@@ -8,6 +8,107 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.90.0-m7.9
12
+
13
+ - The email settings screen says where provider credentials actually go. An
14
+ administrator told only that "passwords are stored as server secrets" does the
15
+ one thing the sentence was meant to prevent and pastes the password into the
16
+ CMS field in front of them, so the screen names the configuration file, the
17
+ environment-variable names and the secret-name convention for that
18
+ installation instead. It stays value-free: no secret is read, returned or
19
+ rendered, and hosts supply their own deployment root through
20
+ `secretsLocation` — including `/`, which stays absolute.
21
+ - Optional block content whose localized value is empty is no longer rendered.
22
+ A heading or paragraph written in one locale and left blank in another used to
23
+ leave its empty markup behind in the locale that had nothing to say.
24
+ - Nested navigation items are editable. Only top-level entries could be changed,
25
+ so fixing a typo in a child link meant deleting it and building it again.
26
+ - Rich text holds up at length, and a block chooses how it appears. Each block
27
+ carries its own reveal mode; `Reveal` still defaults to `fade`, so every
28
+ already-published page looks exactly as it did.
29
+ - Cooperative editing locks stop two administrators overwriting each other. A
30
+ lock is taken when an editor opens a resource, renewed while it stays open and
31
+ released when it closes, and a second editor is told who holds it instead of
32
+ silently losing the earlier write. Pages, global sections, shared content,
33
+ navigation, contact details, forms, form defaults, form history and page SEO
34
+ are covered, and the refusal is enforced in the server cores — not only by
35
+ disabling controls in the interface.
36
+ - The lock's session token is private and the session match is exact.
37
+ `session_id` is never returned by any read: authenticated callers hold column
38
+ grants that omit it, the JSON shape drops it, and the administrator listing
39
+ returns a redacted row. A write against a live lock succeeds only for the
40
+ exact, non-empty session that holds it. Empty or malformed lock responses fail
41
+ closed rather than reading as "free", a stale asynchronous reply cannot revive
42
+ a lock that was already released, reopening an editor can no longer let its
43
+ own release overtake its next acquire, and a duplicated browser tab is given
44
+ its own session identity — Web Locks where the browser provides them, a
45
+ best-effort BroadcastChannel fallback where it does not.
46
+ - Lock storage is a host port, not a provider. `editLockActionCore` and
47
+ `assertEditable` reach the database only through `EditLockAdapter`
48
+ (`@bygghammar/cms-platform/server/edit-lock-adapter`) — five methods covering
49
+ acquire, status, release, list and the staff check — so an application on
50
+ ordinary PostgreSQL implements it with the SQL it already writes, with no
51
+ PostgREST client and no `supabase` object to imitate. Hosts that already build
52
+ `{ supabase: { rpc }, userId, claims }` for their other cores keep passing
53
+ exactly that: `createSupabaseEditLockAdapter` is applied for them
54
+ automatically and nothing on an existing host changes.
55
+ - Locks require a migration and host wiring, and change nothing until both are
56
+ done. `migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql`
57
+ creates the table and its functions; `docs/EDIT-LOCKS.md` is the host
58
+ contract: one authenticated server endpoint through `editLockActionCore`, one
59
+ `EditLockProvider` built with `createEditLockApi`, the non-empty
60
+ `editSessionId` forwarded to every protected write, `pageKey` passed to
61
+ `PageSeoPanelView`, and the `edit_locked:` message prefix preserved across the
62
+ host's own transport. Until then every screen behaves exactly as it did before
63
+ locks existed.
64
+ - The lock is a cooperative server preflight, not a transaction-atomic write
65
+ fence. The check and the mutation are separate statements, so a host needing
66
+ strict exclusion must put both in one transaction or RPC. Full-document exits
67
+ deliberately let the lock lapse by TTL — 120 seconds by default — rather than
68
+ race a reload against its own release.
69
+ - Verified on PostgreSQL 16 and 17: the migration applies in a single
70
+ transaction against a throwaway container carrying a host's real role,
71
+ authentication and user objects; the grants, the RLS policy and the
72
+ `SECURITY DEFINER` `search_path` come out as written; twenty simultaneous
73
+ acquires of one resource leave exactly one holder and no errors; and a
74
+ shadowing `pg_temp` object cannot reach inside a definer body.
75
+ - Not verified in this release: the migration has not been applied to any real
76
+ staging or production database, and the two-user real-browser lock behaviour
77
+ has not been exercised against a live host. Both remain required before
78
+ production use.
79
+
80
+ ## 0.90.0-m7.8
81
+
82
+ - Requires `@bygghammar/lightweight-popup@>=0.2.0-m7.8`. `m7.6` and `m7.7` were
83
+ tagged and never published: two defects in the release workflow stopped them
84
+ after every gate had passed. The form-identity work they were meant to carry
85
+ is unchanged and ships here.
86
+ - The release workflow gets both corrections, proven in lightweight-popup:
87
+ the redundant post-checkout `git fetch` is gone (checkout already brings
88
+ every branch, and its credentials are removed on the way out), and
89
+ `npm pack`'s destination is created before it is packed into, with the same
90
+ directory carried to the tarball verification rather than spelled twice.
91
+ - `ops/verify-release-tag.mjs` checks `refs/remotes/origin/main` is present
92
+ before asking `merge-base` about it, so a checkout that fetched too little no
93
+ longer reports itself as a tag on an unmerged branch.
94
+
95
+ ## 0.90.0-m7.6
96
+
97
+ - A form submitted from inside a popup now says so. `FormRenderer` and
98
+ `ManagedForm` take an opaque `context` that is merged into the submission,
99
+ and the popup module fills it with `{ popupKey }` — the form system keeps
100
+ knowing nothing about the features that use it. Without this the request was
101
+ indistinguishable from the same form filled on an ordinary page, so the
102
+ server stored an ordinary entry and every delivery configured on the popup
103
+ silently did nothing.
104
+ - `HostSubmitFormInput` gains `popupKey?: string`, the only popup-shaped value
105
+ the browser sends. Destination, endpoint, signing secret and which fields may
106
+ leave are all read on the server from the popup's stored configuration.
107
+ - Drafts and the builder preview carry no context, so a part-filled form and an
108
+ editor checking a layout cannot fire a campaign's webhook.
109
+ - Requires `@bygghammar/lightweight-popup@>=0.2.0-m7.6`, which is where the
110
+ identity comes from.
111
+
11
112
  ## 0.90.0-m7.5
12
113
 
13
114
  - `BackofficeHost.pages.fns.listPages` is declared. It was what
package/README.md CHANGED
@@ -25,6 +25,14 @@ apply the canonical schema with `bunx cms-platform-migrations`, import
25
25
  `theme.css`, register host components / platform data / translations, wrap the
26
26
  server cores and mount the shared backoffice views.
27
27
 
28
+ ## Editing locks
29
+
30
+ `docs/EDIT-LOCKS.md` is what a host wires so two administrators cannot
31
+ overwrite each other: run the migration, expose one server endpoint through
32
+ `editLockActionCore`, mount `EditLockProvider` with `createEditLockApi`, and
33
+ forward `editSessionId` to the saves. Until that is done every screen behaves
34
+ exactly as it did before locks existed.
35
+
28
36
  ## Compatibility policy
29
37
 
30
38
  - `CHANGELOG.md` records every release; read it before upgrading.
@@ -119,8 +127,14 @@ bun run check # typecheck + tests + package verification
119
127
 
120
128
  The package ships TypeScript sources; there is no build output. Every change
121
129
  goes through a pull request, and every release is triggered by pushing a
122
- `vX.Y.Z` tag that matches `package.json`, which publishes to npm from GitHub
123
- Actions using the repository secret `NPM_TOKEN`. Never publish manually.
130
+ `vX.Y.Z` tag that matches `package.json`. That tag runs
131
+ `.github/workflows/publish.yml`, which publishes to npm using **Trusted
132
+ Publishing**: GitHub mints a short-lived OIDC token scoped to that single
133
+ workflow run, and npm exchanges it for publish rights through the package's
134
+ Trusted Publisher configuration. No long-lived npm credential takes part — no
135
+ `NPM_TOKEN`, no `NODE_AUTH_TOKEN`, no OTP. [RELEASING.md](./RELEASING.md) is
136
+ what the workflow checks and the npm settings it has to match. Never publish
137
+ manually.
124
138
 
125
139
  See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow and changelog
126
140
  conventions. Consumers install an explicit released version such as
@@ -0,0 +1,213 @@
1
+ # Editing locks: what a host has to wire
2
+
3
+ Two administrators opening the same page used to be a silent race — whoever
4
+ saved last won, and the other one's work disappeared without a message. The
5
+ lock makes that a refusal instead: the second person sees who has the page and
6
+ cannot type into it, and the server refuses the write even if they try.
7
+
8
+ The package supplies all of it except three connections only the host can make.
9
+ Until they are made every screen behaves exactly as it did before locks
10
+ existed, which is what lets an installation upgrade the package first and the
11
+ database afterwards.
12
+
13
+ ## 1. Run the migration
14
+
15
+ `migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql` creates
16
+ `public.cms_edit_locks` and five functions. It is safe to run more than once.
17
+ It is ordinary PostgreSQL — a table, an index, grants, one RLS policy and four
18
+ `SECURITY DEFINER` functions with a pinned `search_path`. A host that stores
19
+ locks some other way can skip it and supply its own adapter instead; see
20
+ [Storage](#storage).
21
+
22
+ Until it runs, `assertEditable` sees "no such function" and lets every write
23
+ through — an upgrade cannot break saving. After it runs, the server checks are
24
+ available; the provider and save-path forwarding below are still required for
25
+ per-tab UI locking.
26
+
27
+ ## 2. One server endpoint
28
+
29
+ Every lock call goes through `editLockActionCore`, so the host writes one
30
+ server function rather than five:
31
+
32
+ ```ts
33
+ import { editLockActionCore, isEditLockAction } from "@bygghammar/cms-platform/server/edit-locks-core";
34
+ import { editLockSchemas } from "@bygghammar/cms-platform/server/schemas";
35
+
36
+ export const editLock = serverFn(async ({ data }) => {
37
+ if (!isEditLockAction(data.action)) throw new Error("Unknown lock action");
38
+ const input = editLockSchemas.acquire.parse(data);
39
+ return editLockActionCore(await authContext(), data.action, input);
40
+ });
41
+ ```
42
+
43
+ `authContext()` returns the lock storage for the signed-in user — see
44
+ [Storage](#storage) for what that is. The lock functions run as that user, so
45
+ `is_staff` and RLS apply exactly as they do everywhere else.
46
+
47
+ ## 3. One provider in the browser
48
+
49
+ ```tsx
50
+ import { useMemo } from "react";
51
+ import { createEditLockApi, EditLockProvider } from "@bygghammar/cms-platform/backoffice/EditLock";
52
+
53
+ const lockApi = useMemo(
54
+ () => createEditLockApi(
55
+ (action, input) => editLock({ data: { action, ...input } }),
56
+ { canSteal: isSuperadmin },
57
+ ),
58
+ [editLock, isSuperadmin],
59
+ );
60
+
61
+ <EditLockProvider api={lockApi}>{backoffice}</EditLockProvider>
62
+ ```
63
+
64
+ Mount it once, around the backoffice. Every editing screen finds it by itself.
65
+ Keep the API object stable with `useMemo`: its identity controls the editors'
66
+ acquire/release effects, so rebuilding it during each render would repeatedly
67
+ release and reacquire their locks.
68
+
69
+ `canSteal` only decides whether the take-over button is *offered*; the database
70
+ function refuses anyone but a superadmin regardless, so offering it to an
71
+ ordinary editor produces a button that always fails.
72
+
73
+ Leave the api `null` — or mount no provider at all — and every screen reports
74
+ "not enforced" and behaves as before.
75
+
76
+ ## 4. Pass the session id on to the saves
77
+
78
+ The provider is what shows the banner. The session id is what makes the server
79
+ able to tell *this* editor's save from someone else's, and it travels with the
80
+ save, not with the lock.
81
+
82
+ Each screen already puts `lock.sessionId` into the deps it calls. The host's
83
+ job is to forward it to the matching core:
84
+
85
+ | Screen | Host dep | Core | Resource |
86
+ | --- | --- | --- | --- |
87
+ | `PageBuilderView` | `save`, `rename`, `schedule`, `cancelPublication`, `restoreVersion` | `savePageVersionCore`, `renamePageCore`, `schedulePageVersionCore`, `cancelPagePublicationCore`, `restorePageVersionCore` | `page` / page id |
88
+ | `GlobalSectionBuilderView` | `save`, `rename`, `setKind` | `saveGlobalSectionVersionCore`, `renameGlobalSectionCore`, `setGlobalSectionKindCore` | `global_section` / section id |
89
+ | `SectionEditorView`, `NavigationView`, `ContactDetailsView` | `save` | `saveContentSectionCore` | `content_section` / section key |
90
+ | `FormEditorView` | `saveForm`; pass `editSessionId` into its host `VersionHistory` too | `saveFormCore`, `restoreFormVersionCore` | `form` / form id |
91
+ | `FormSettingsView` | `saveDefaults`; forward the whole `data` object, or destructure its `editSessionId` into the core's third argument | `saveFormDefaultsCore` | `form_defaults` / `form_settings` |
92
+ | `PageSeoPanelView` | `onSave(form, editSessionId)`, `onReset(editSessionId)` | `savePageSeoCore`, `deletePageSeoOverrideCore` | `page_seo` / `pageKey` |
93
+
94
+ The cores and schemas accept `editSessionId` as an optional field so an
95
+ unwired/unmigrated host keeps its old behaviour while no lock exists. Once a
96
+ resource has a live lock, every protected write requires the matching session
97
+ id, including writes by another tab of the same user. Forward it on every path
98
+ in the table; omission is not a bypass.
99
+
100
+ `PageSeoPanelView` additionally needs `pageKey`. Without it the SEO panel has
101
+ no resource to lock and stays unlocked, even with everything else wired.
102
+
103
+ ## Storage
104
+
105
+ `editLockActionCore` and `assertEditable` never touch a database themselves.
106
+ Every read and write goes through `EditLockAdapter`
107
+ (`@bygghammar/cms-platform/server/edit-lock-adapter`), five methods over the
108
+ domain operations and nothing else:
109
+
110
+ ```ts
111
+ type EditLockAdapter = {
112
+ assertStaff(): Promise<void>;
113
+ acquire(input: EditLockRef & { sessionId: string; ttlSeconds: number; steal: boolean }): Promise<EditLockState>;
114
+ status(input: EditLockRef & { sessionId: string | null }): Promise<EditLockState>;
115
+ release(input: EditLockRef & { sessionId: string }): Promise<boolean>;
116
+ list(): Promise<EditLockRow[]>;
117
+ };
118
+ ```
119
+
120
+ The context a core takes is `{ editLocks: <adapter> }`. Nothing in the contract
121
+ names a driver, a client library or a wire format, so a self-hosted application
122
+ on ordinary PostgreSQL implements it with the SQL it already writes:
123
+
124
+ ```ts
125
+ import type { EditLockAdapter } from "@bygghammar/cms-platform/server/edit-lock-adapter";
126
+ import { EditLockNotInstalledError } from "@bygghammar/cms-platform/server/edit-lock-adapter";
127
+ import { asEditLockState } from "@bygghammar/cms-platform/cms/edit-lock";
128
+
129
+ const MISSING = new Set(["42883", "42P01"]); // no such function, no such table
130
+
131
+ export function editLockStorage(pool: Pool, userId: string): EditLockAdapter {
132
+ const query = async (text: string, values: unknown[]) => {
133
+ try {
134
+ return await pool.query(text, values);
135
+ } catch (error) {
136
+ if (MISSING.has(error.code)) throw new EditLockNotInstalledError();
137
+ throw error;
138
+ }
139
+ };
140
+ return {
141
+ async acquire(input) {
142
+ const { rows } = await query(
143
+ "SELECT public.cms_acquire_edit_lock($1, $2, $3, $4, $5) AS state",
144
+ [input.resourceType, input.resourceId, input.sessionId, input.ttlSeconds, input.steal],
145
+ );
146
+ return asEditLockState(rows[0]?.state);
147
+ },
148
+ // status, release, list and assertStaff in the same shape
149
+ };
150
+ }
151
+ ```
152
+
153
+ Two rules an adapter has to honour, whatever it is built on:
154
+
155
+ - **`acquire` must be one atomic statement.** Two editors opening the same page
156
+ at the same instant have to contend on a single unique index so exactly one
157
+ wins. Read-then-write leaves a window in which both do.
158
+ - **`sessionId` is a bearer token.** It goes in and is never returned. Knowing
159
+ another tab's session id is enough to save as its holder, so no read may
160
+ include it — not `status`, not `list`.
161
+
162
+ Whether the storage runs on a stored function, plain SQL in the adapter, or a
163
+ service of its own is the host's decision. A PostgREST-shaped host that already
164
+ builds `{ supabase: { rpc }, userId, claims }` for its other cores can keep
165
+ passing exactly that: `createSupabaseEditLockAdapter` is applied for it
166
+ automatically, and the migration's functions are the storage.
167
+
168
+ ## What the server does with it
169
+
170
+ `assertEditable` sits before each protected write. A write from another session
171
+ throws `EditLockedError`. On the server it has `error.code === "edit_locked"` and
172
+ `error.state`. TanStack Start serializes only an Error's message, so the message
173
+ also begins with `edit_locked:` and `isEditLockedError()` recognises either
174
+ form after transport. Do not replace that failure with a success response.
175
+
176
+ This is a cooperative preflight lock, not a transaction-wide database fence.
177
+ Expiry, a new acquisition, a superadmin steal, or an explicit release could
178
+ land between the status preflight and a later PostgREST mutation. Strict
179
+ transactional exclusion would require each mutation and its lock check in one
180
+ database RPC, or a fencing token checked by the write itself.
181
+
182
+ The per-tab session id is a bearer value and is never returned by a lock
183
+ status/acquire response. Authenticated staff have column-level read access to
184
+ the operational lock fields but not `session_id`; the administrative list RPC
185
+ omits that column. This prevents a blocked browser from reading the
186
+ holder's token and replaying it in a save.
187
+
188
+ Modern browsers also hold an exclusive Web Lock for that bearer value before
189
+ the first server acquire. This detects a duplicated tab even when the original
190
+ is frozen. Browsers without Web Locks fall back to a short BroadcastChannel
191
+ handshake. That fallback is best-effort because a frozen source tab can answer
192
+ too late; without either primitive there is no duplicate-tab signal at all.
193
+ Multi-user locking remains enforced in either case.
194
+
195
+ ## Choosing the lifetime
196
+
197
+ A lock lasts 120 seconds by default and is renewed every 40. A browser that
198
+ crashes therefore frees the page about two minutes later, with no cleanup job
199
+ anywhere. Pass `ttlSeconds` to `createEditLockApi` to change it; the database
200
+ clamps to 15–3600 whatever it is told.
201
+
202
+ Leaving an editor through client-side navigation releases immediately. Closing
203
+ or reloading the whole document relies on the TTL: firing a release from the
204
+ old document can arrive after the reload has already reacquired with the same
205
+ session and delete its new lock. A restored/backgrounded page stays fenced
206
+ until its catch-up renewal confirms it still holds the resource.
207
+
208
+ ## Recovering a stuck lock
209
+
210
+ `listEditLocksCore` returns every live lock, and `steal` takes one — superadmin
211
+ only, enforced in the database. Neither is needed for the feature to work; they
212
+ are for the afternoon somebody's laptop dies mid-edit and two minutes is too
213
+ long to wait.
@@ -0,0 +1,274 @@
1
+ -- Editing locks for CMS resources ---------------------------------------
2
+ --
3
+ -- One row per resource somebody currently has open in an editor. The primary
4
+ -- key is the resource itself, so two editors racing to open the same page
5
+ -- contend on a single unique index and exactly one of them wins — the reason
6
+ -- every mutation below happens inside one statement rather than a read
7
+ -- followed by a write.
8
+ --
9
+ -- Locks expire. A browser that crashes, a laptop that sleeps or a tab closed
10
+ -- without warning leaves a row behind, and `expires_at` makes that row
11
+ -- harmless a couple of minutes later without anyone having to intervene.
12
+
13
+ CREATE TABLE IF NOT EXISTS public.cms_edit_locks (
14
+ resource_type text NOT NULL,
15
+ resource_id text NOT NULL,
16
+ user_id uuid NOT NULL,
17
+ user_email text,
18
+ user_name text,
19
+ session_id text NOT NULL,
20
+ acquired_at timestamptz NOT NULL DEFAULT now(),
21
+ refreshed_at timestamptz NOT NULL DEFAULT now(),
22
+ expires_at timestamptz NOT NULL,
23
+ PRIMARY KEY (resource_type, resource_id)
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS cms_edit_locks_expires_at_idx
27
+ ON public.cms_edit_locks (expires_at);
28
+
29
+ -- `session_id` is a bearer token: knowing it would let another browser pose as
30
+ -- the holder on a save. Staff may inspect the operational fields, but only the
31
+ -- definer functions below may read that column on their behalf.
32
+ REVOKE ALL ON public.cms_edit_locks FROM PUBLIC, anon, authenticated;
33
+ GRANT SELECT (
34
+ resource_type, resource_id, user_id, user_email, user_name,
35
+ acquired_at, refreshed_at, expires_at
36
+ ) ON public.cms_edit_locks TO authenticated;
37
+ GRANT ALL ON public.cms_edit_locks TO service_role;
38
+ ALTER TABLE public.cms_edit_locks ENABLE ROW LEVEL SECURITY;
39
+
40
+ -- Staff may read who is editing what. Nobody writes this table directly:
41
+ -- every change goes through the SECURITY DEFINER functions below, which is
42
+ -- what makes "take the lock" atomic and keeps a client from inventing a lock
43
+ -- in someone else's name.
44
+ DROP POLICY IF EXISTS "Staff view edit locks" ON public.cms_edit_locks;
45
+ CREATE POLICY "Staff view edit locks" ON public.cms_edit_locks
46
+ FOR SELECT TO authenticated USING (public.is_staff(app.current_user_id()));
47
+
48
+ -- Shape one row for the client. An expired row reads as "not locked", so no
49
+ -- caller has to know the expiry rule. The session id is never returned: it is
50
+ -- the bearer value used to distinguish tabs, not display data.
51
+ CREATE OR REPLACE FUNCTION public.cms_edit_lock_json(
52
+ _row public.cms_edit_locks,
53
+ _viewer uuid,
54
+ _session_id text
55
+ )
56
+ RETURNS jsonb
57
+ LANGUAGE sql
58
+ STABLE
59
+ SET search_path = public, pg_temp
60
+ AS $$
61
+ SELECT CASE
62
+ WHEN _row.resource_id IS NULL OR _row.expires_at <= now()
63
+ THEN jsonb_build_object('locked', false, 'held', false, 'now', now())
64
+ ELSE jsonb_build_object(
65
+ 'locked', true,
66
+ 'held', (_row.user_id = _viewer
67
+ AND _session_id IS NOT NULL
68
+ AND _row.session_id = _session_id),
69
+ 'sameUser', (_row.user_id = _viewer),
70
+ 'resourceType', _row.resource_type,
71
+ 'resourceId', _row.resource_id,
72
+ 'userId', _row.user_id,
73
+ 'userEmail', _row.user_email,
74
+ 'userName', _row.user_name,
75
+ 'acquiredAt', _row.acquired_at,
76
+ 'refreshedAt', _row.refreshed_at,
77
+ 'expiresAt', _row.expires_at,
78
+ 'now', now()
79
+ )
80
+ END
81
+ $$;
82
+
83
+ -- Take a lock, or extend one we already hold — the same call, because that is
84
+ -- also what a heartbeat needs: after a laptop wakes up the lock may have
85
+ -- expired, and re-taking it is right precisely when nobody else claimed it.
86
+ CREATE OR REPLACE FUNCTION public.cms_acquire_edit_lock(
87
+ _resource_type text,
88
+ _resource_id text,
89
+ _session_id text,
90
+ _ttl_seconds integer DEFAULT 120,
91
+ _steal boolean DEFAULT false
92
+ )
93
+ RETURNS jsonb
94
+ LANGUAGE plpgsql
95
+ SECURITY DEFINER
96
+ SET search_path = public, pg_temp
97
+ AS $$
98
+ DECLARE
99
+ _uid uuid := app.current_user_id();
100
+ _email text;
101
+ _name text;
102
+ _type text := btrim(_resource_type);
103
+ _id text := btrim(_resource_id);
104
+ _sess text := btrim(_session_id);
105
+ _ttl interval;
106
+ _row public.cms_edit_locks;
107
+ BEGIN
108
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
109
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
110
+ END IF;
111
+ IF _type IS NULL OR _type = '' OR _id IS NULL OR _id = ''
112
+ OR _sess IS NULL OR _sess = '' THEN
113
+ RAISE EXCEPTION 'A resource type, resource id and session id are all required'
114
+ USING ERRCODE = '22023';
115
+ END IF;
116
+ IF _steal AND NOT public.has_role(_uid, 'superadmin'::public.app_role) THEN
117
+ RAISE EXCEPTION 'Forbidden — superadmin only' USING ERRCODE = '42501';
118
+ END IF;
119
+
120
+ _ttl := make_interval(secs => LEAST(3600, GREATEST(15, COALESCE(_ttl_seconds, 120))));
121
+
122
+ -- The name shown to whoever is blocked comes from the server, never from
123
+ -- the caller, so a lock cannot be taken under someone else's name.
124
+ SELECT au.email, au.display_name INTO _email, _name
125
+ FROM public.app_users au WHERE au.id = _uid;
126
+
127
+ INSERT INTO public.cms_edit_locks AS l
128
+ (resource_type, resource_id, user_id, user_email, user_name, session_id, expires_at)
129
+ VALUES (_type, _id, _uid, _email, _name, _sess, now() + _ttl)
130
+ ON CONFLICT (resource_type, resource_id) DO UPDATE
131
+ SET user_id = EXCLUDED.user_id,
132
+ user_email = EXCLUDED.user_email,
133
+ user_name = EXCLUDED.user_name,
134
+ session_id = EXCLUDED.session_id,
135
+ acquired_at = CASE
136
+ WHEN l.user_id = EXCLUDED.user_id
137
+ AND l.session_id = EXCLUDED.session_id
138
+ THEN l.acquired_at ELSE now()
139
+ END,
140
+ refreshed_at = now(),
141
+ expires_at = EXCLUDED.expires_at
142
+ WHERE l.expires_at <= now()
143
+ OR (l.user_id = EXCLUDED.user_id AND l.session_id = EXCLUDED.session_id)
144
+ OR _steal
145
+ RETURNING l.* INTO _row;
146
+
147
+ -- The conflict clause declined: somebody else holds a live lock. Report who.
148
+ IF _row.resource_id IS NULL THEN
149
+ SELECT * INTO _row FROM public.cms_edit_locks
150
+ WHERE resource_type = _type AND resource_id = _id;
151
+ END IF;
152
+
153
+ -- Opportunistic sweep, so the table never needs a scheduled job.
154
+ DELETE FROM public.cms_edit_locks WHERE expires_at <= now() - interval '1 day';
155
+
156
+ RETURN public.cms_edit_lock_json(_row, _uid, _sess);
157
+ END;
158
+ $$;
159
+
160
+ -- Who holds a resource right now. Read-only; an expired row reads as free.
161
+ CREATE OR REPLACE FUNCTION public.cms_edit_lock_status(
162
+ _resource_type text,
163
+ _resource_id text,
164
+ _session_id text DEFAULT NULL
165
+ )
166
+ RETURNS jsonb
167
+ LANGUAGE plpgsql
168
+ SECURITY DEFINER
169
+ STABLE
170
+ SET search_path = public, pg_temp
171
+ AS $$
172
+ DECLARE
173
+ _uid uuid := app.current_user_id();
174
+ _row public.cms_edit_locks;
175
+ BEGIN
176
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
177
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
178
+ END IF;
179
+ SELECT * INTO _row FROM public.cms_edit_locks
180
+ WHERE resource_type = btrim(_resource_type) AND resource_id = btrim(_resource_id);
181
+ RETURN public.cms_edit_lock_json(_row, _uid, NULLIF(btrim(_session_id), ''));
182
+ END;
183
+ $$;
184
+
185
+ -- Give a lock back. Only ever our own, and only the session that took it.
186
+ CREATE OR REPLACE FUNCTION public.cms_release_edit_lock(
187
+ _resource_type text,
188
+ _resource_id text,
189
+ _session_id text
190
+ )
191
+ RETURNS boolean
192
+ LANGUAGE plpgsql
193
+ SECURITY DEFINER
194
+ SET search_path = public, pg_temp
195
+ AS $$
196
+ DECLARE
197
+ _uid uuid := app.current_user_id();
198
+ _gone integer;
199
+ BEGIN
200
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
201
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
202
+ END IF;
203
+ DELETE FROM public.cms_edit_locks
204
+ WHERE resource_type = btrim(_resource_type)
205
+ AND resource_id = btrim(_resource_id)
206
+ AND user_id = _uid
207
+ AND session_id = btrim(_session_id);
208
+ GET DIAGNOSTICS _gone = ROW_COUNT;
209
+ RETURN _gone > 0;
210
+ END;
211
+ $$;
212
+
213
+ -- Every live lock, for an administrator looking at a stuck editor. Recreate
214
+ -- the function so an earlier local run of this unreleased migration also gets
215
+ -- the redacted return shape.
216
+ DROP FUNCTION IF EXISTS public.cms_list_edit_locks();
217
+ CREATE FUNCTION public.cms_list_edit_locks()
218
+ RETURNS TABLE (
219
+ resource_type text,
220
+ resource_id text,
221
+ user_id uuid,
222
+ user_email text,
223
+ user_name text,
224
+ acquired_at timestamptz,
225
+ refreshed_at timestamptz,
226
+ expires_at timestamptz
227
+ )
228
+ LANGUAGE plpgsql
229
+ SECURITY DEFINER
230
+ STABLE
231
+ SET search_path = public, pg_temp
232
+ AS $$
233
+ DECLARE
234
+ _uid uuid := app.current_user_id();
235
+ BEGIN
236
+ IF _uid IS NULL OR NOT public.is_staff(_uid) THEN
237
+ RAISE EXCEPTION 'Forbidden' USING ERRCODE = '42501';
238
+ END IF;
239
+ RETURN QUERY
240
+ SELECT
241
+ l.resource_type,
242
+ l.resource_id,
243
+ l.user_id,
244
+ l.user_email,
245
+ l.user_name,
246
+ l.acquired_at,
247
+ l.refreshed_at,
248
+ l.expires_at
249
+ FROM public.cms_edit_locks l
250
+ WHERE l.expires_at > now()
251
+ ORDER BY l.refreshed_at DESC;
252
+ END;
253
+ $$;
254
+
255
+ -- Nothing here is for the anonymous role, and a definer function that decides
256
+ -- who may edit is not something to leave on PUBLIC's default grant. The bodies
257
+ -- check `is_staff` regardless; this is the second lock on the same door.
258
+ REVOKE ALL ON FUNCTION public.cms_edit_lock_json(public.cms_edit_locks, uuid, text)
259
+ FROM PUBLIC, anon, authenticated, service_role;
260
+ REVOKE ALL ON FUNCTION public.cms_acquire_edit_lock(text, text, text, integer, boolean) FROM PUBLIC, anon;
261
+ REVOKE ALL ON FUNCTION public.cms_edit_lock_status(text, text, text) FROM PUBLIC, anon;
262
+ REVOKE ALL ON FUNCTION public.cms_release_edit_lock(text, text, text) FROM PUBLIC, anon;
263
+ REVOKE ALL ON FUNCTION public.cms_list_edit_locks() FROM PUBLIC, anon;
264
+
265
+ -- `cms_edit_lock_json` is an implementation helper called by the definer
266
+ -- functions; clients do not need an RPC surface for arbitrary composite rows.
267
+ GRANT EXECUTE ON FUNCTION public.cms_acquire_edit_lock(text, text, text, integer, boolean)
268
+ TO authenticated, service_role;
269
+ GRANT EXECUTE ON FUNCTION public.cms_edit_lock_status(text, text, text)
270
+ TO authenticated, service_role;
271
+ GRANT EXECUTE ON FUNCTION public.cms_release_edit_lock(text, text, text)
272
+ TO authenticated, service_role;
273
+ GRANT EXECUTE ON FUNCTION public.cms_list_edit_locks()
274
+ TO authenticated, service_role;