@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,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.
|
package/docs/POPUPS.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Popups: what this package supplies
|
|
2
|
+
|
|
3
|
+
`@bygghammar/lightweight-popup` is deliberately host-agnostic. It knows nothing
|
|
4
|
+
about Supabase, about this CMS, or about any particular site. Everything it
|
|
5
|
+
needs from the outside arrives through ports.
|
|
6
|
+
|
|
7
|
+
This package is the adapter that fills those ports with the CMS's own systems,
|
|
8
|
+
so a site gets a working popup builder by registering one module instead of
|
|
9
|
+
reimplementing a media library, a form renderer and a page picker.
|
|
10
|
+
|
|
11
|
+
## The bridges
|
|
12
|
+
|
|
13
|
+
| Port | Filled with | File |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| `MediaPickerPort` | The CMS media library dialog | `src/popups/media-bridge.tsx` |
|
|
16
|
+
| `FormRenderPort` | The CMS form renderer | `src/popups/cms-bridges.ts` |
|
|
17
|
+
| `PagePickerPort` | The CMS page list | `src/popups/cms-bridges.ts` |
|
|
18
|
+
| `OptInDestinationAdminPort` | The host's webhook server functions | `src/popups/destinations.tsx` |
|
|
19
|
+
|
|
20
|
+
All of them are assembled in `src/popups/admin.tsx`. **An unfilled port is not
|
|
21
|
+
an integration.** `cmsMediaPicker` existed for weeks and was never placed on the
|
|
22
|
+
adapters object, so `adapters.media` was permanently `undefined`, the builder
|
|
23
|
+
took its no-picker fallback branch, and every administrator pasted storage URLs
|
|
24
|
+
by hand. The failure was invisible because the fallback is a legitimate state
|
|
25
|
+
for a host without a media library.
|
|
26
|
+
|
|
27
|
+
`tests/popup-media-bridge.test.ts` now asserts against the source of
|
|
28
|
+
`admin.tsx` that the key is present. Deleting the line fails the suite.
|
|
29
|
+
|
|
30
|
+
## The media bridge
|
|
31
|
+
|
|
32
|
+
`MediaPickerPort` is a render seam:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
renderPicker(props: { onPick(item): void; onClose(): void }): ReactNode
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The bridge returns the CMS's own `MediaPickerDialog` with `accept="image"`. Not
|
|
39
|
+
a wrapper around it and not a reimplementation — the same component every other
|
|
40
|
+
inspector uses, so browsing, searching, folders and **upload** all come along
|
|
41
|
+
for free. The popup package never uploads anything and never learns which
|
|
42
|
+
storage provider the site uses.
|
|
43
|
+
|
|
44
|
+
What crosses the boundary is `{ src, name?, alt? }`: the stable public URL the
|
|
45
|
+
library already serves to the site, the filename to show, and the library's own
|
|
46
|
+
description when it has one. `toHostMediaItem` returns `null` for a row with no
|
|
47
|
+
address rather than passing an empty string on.
|
|
48
|
+
|
|
49
|
+
The URL must be the **public** one. A signed or expiring link renders in the
|
|
50
|
+
builder and 404s a month later on the live site, which is why the popup package
|
|
51
|
+
refuses those shapes itself in `checkMediaReference`.
|
|
52
|
+
|
|
53
|
+
## The webhook layers
|
|
54
|
+
|
|
55
|
+
Opt-in delivery is split across all three layers, and the split is the security
|
|
56
|
+
model rather than a tidiness preference.
|
|
57
|
+
|
|
58
|
+
| Layer | Has |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| lightweight-popup | The destinations screen and the delivery log. No fetch, no crypto, no database |
|
|
61
|
+
| **this package** | `webhook-url-guard.ts`, `popup-optin-webhook-core.ts`, `popup-optin-trigger.ts` — validation, signing, the retry schedule, the genuine-opt-in predicate, field selection |
|
|
62
|
+
| The site | Tables, server functions, `process.env` secret resolution, routing |
|
|
63
|
+
|
|
64
|
+
Everything here is pure and host-agnostic: it is handed a row and a secret and
|
|
65
|
+
returns an outcome. It never reads an environment variable and never opens a
|
|
66
|
+
database connection, so the same code is testable without either.
|
|
67
|
+
|
|
68
|
+
`src/server/*` is server-only by convention **and** by reachability: nothing in
|
|
69
|
+
`src/backoffice` or `src/popups` imports it, which is what keeps delivery code
|
|
70
|
+
out of every client bundle. The host's own chunk-boundary test proves that on
|
|
71
|
+
the built assets.
|
|
72
|
+
|
|
73
|
+
### The one rule that matters
|
|
74
|
+
|
|
75
|
+
**Delivery is triggered from the stored submission, on the server.** The
|
|
76
|
+
browser contributes a popup key and nothing else. There is no parameter that
|
|
77
|
+
carries a payload, an endpoint or a recipient, so a crafted request cannot make
|
|
78
|
+
the server send anything anywhere.
|
|
79
|
+
|
|
80
|
+
`shouldDeliverOptIn` is the only predicate. A honeypot's
|
|
81
|
+
`{ ok: true, skipped: true }` — the deliberate lie the form system tells a bot —
|
|
82
|
+
is **not** an opt-in. Neither is a "yes" click, a preview, or a test-mode popup.
|
|
83
|
+
|
|
84
|
+
### Secrets
|
|
85
|
+
|
|
86
|
+
A destination stores the **name** of an environment variable, never a value.
|
|
87
|
+
The host resolves it at delivery time. The admin port's `DestinationRecord`
|
|
88
|
+
carries `secretConfigured: boolean` and an `endpointDisplay` with the query
|
|
89
|
+
string removed; there is no field on it that could hold a credential, and the
|
|
90
|
+
form that creates one has nowhere to type a secret.
|
|
91
|
+
|
|
92
|
+
## Registering it on a site
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { registerPopupModule } from "@bygghammar/cms-platform/popups";
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The host supplies its `fns` object. The webhook slots on it —
|
|
99
|
+
`listWebhookDestinations`, `saveWebhookDestination`, `deleteWebhookDestination`,
|
|
100
|
+
`sendTestWebhook`, `listWebhookDeliveries` — are **optional**. A site with no
|
|
101
|
+
webhook tables simply omits them and the destinations screen is not offered;
|
|
102
|
+
nothing breaks and no dead menu item appears.
|
|
@@ -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;
|