smoodly 0.0.26 → 0.0.27
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/README.md +38 -6
- package/dist/admin/editor/EditorView.js +2 -2
- package/dist/admin/shell/AdminApp.js +22 -7
- package/dist/admin/shell/EntryForm.js +1 -1
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/presence-realtime.d.ts +1 -1
- package/dist/admin/shell/presence-realtime.js +19 -9
- package/dist/admin/shell/presence.d.ts +18 -0
- package/dist/admin/shell/presence.js +13 -0
- package/dist/admin/ui/primitives.d.ts +4 -1
- package/dist/admin/ui/primitives.js +4 -2
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +37 -10
- package/dist/sql.js +5 -4
- package/migrations/20260926140000_smoodly_v3.sql +38 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -477,6 +477,19 @@ Implemented:
|
|
|
477
477
|
what (a lock in the lists, a line under the top bar) and announces
|
|
478
478
|
saves so a clean view refreshes and a dirty one freezes early. Schema
|
|
479
479
|
version 2 adds the draft-pointer foreign keys and the channel policies.
|
|
480
|
+
- The security pass over the database boundary (spec 2026-09-26 security
|
|
481
|
+
pass): the presence channel admits a signed-in member of the topic's
|
|
482
|
+
space (`smoodly_presence_member()`, the socket carries the session
|
|
483
|
+
token through the client's `accessToken` callback, re-sent on refresh);
|
|
484
|
+
every function pins `search_path`; the three mutation RPCs are revoked
|
|
485
|
+
from `anon`/`public`. Schema version 3. The threat table, one test per
|
|
486
|
+
cell: content, `refs`/`members`, `spaces`/`smoodly_meta` and storage
|
|
487
|
+
columns — `test/isolation.supabase.test.ts` ("space isolation", "read
|
|
488
|
+
keys", "cloud state rows") and `test/rls.supabase.test.ts`; presence
|
|
489
|
+
and RPC columns — `isolation.supabase.test.ts` ("signed-in users and
|
|
490
|
+
the public keys"); admin ops — `test/admin-auth.test.ts`,
|
|
491
|
+
`test/admin-auth.supabase.test.ts`, `test/admin-ops.test.ts` ("members
|
|
492
|
+
ops").
|
|
480
493
|
|
|
481
494
|
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
482
495
|
collections and the pages tree drag since 2026-09-09 — spec
|
|
@@ -504,6 +517,29 @@ after both are on the registry. From the repo root:
|
|
|
504
517
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
505
518
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
506
519
|
|
|
520
|
+
## Follow-ups (logged 2026-09-26, security pass)
|
|
521
|
+
|
|
522
|
+
- The bridge posts with `"*"` on both sides. Safe today: the admin
|
|
523
|
+
accepts a message only when `e.source` is its own iframe's window, and
|
|
524
|
+
the canvas acts on two kinds, refresh and scroll. The target-origin
|
|
525
|
+
pass belongs to the content-surface sweep (spec §Out of scope).
|
|
526
|
+
- The invite's `redirectTo` is a client-supplied string bounded by the
|
|
527
|
+
auth project's redirect allow-list; a same-origin check in the op is
|
|
528
|
+
one line when wanted.
|
|
529
|
+
- Every op call with a junk token costs one `auth.getUser` against the
|
|
530
|
+
auth project; rate limiting is the session-model spec's (OQ 20).
|
|
531
|
+
- The content-surface sweep: `f.link` values reach section components
|
|
532
|
+
unfiltered, uploads allow SVG and PDF on a public bucket (SVG served
|
|
533
|
+
with `?download=`), the custom CSS refusal list, richtext hrefs.
|
|
534
|
+
- A refused private-channel join surfaces as `CHANNEL_ERROR` (about 80
|
|
535
|
+
ms on the local stack); the suite asserts "not SUBSCRIBED".
|
|
536
|
+
- The isolation suite's `join` helper still uses a bare
|
|
537
|
+
`realtime.setAuth(key)`: its positive cases resolve on the first
|
|
538
|
+
SUBSCRIBED, before realtime-js re-asks the default `accessToken`
|
|
539
|
+
callback and the server closes the channel. Fine for proving the
|
|
540
|
+
policy; a client that must stay joined passes `accessToken` (what
|
|
541
|
+
`connectRealtime` does since 3ed8931).
|
|
542
|
+
|
|
507
543
|
## Follow-ups (logged 2026-09-26, concurrency spec)
|
|
508
544
|
|
|
509
545
|
- A hard lock with takeover, if a real team asks: presence has the facts.
|
|
@@ -522,10 +558,6 @@ after both are on the registry. From the repo root:
|
|
|
522
558
|
lock outlives a closed tab by that much.
|
|
523
559
|
- The live suites wipe `members` and the content tables; a pane walk after
|
|
524
560
|
them needs `npm run reseed` (or `scripts/demo-editor.ts`) first.
|
|
525
|
-
- `e2e/helpers.ts`'s `expectSaved` passes at once when the indicator still
|
|
526
|
-
reads "Saved" from before the edit (the debounce is 800 ms), so a step that
|
|
527
|
-
needs the save landed can race it; `concurrency.spec.ts` waits for the
|
|
528
|
-
save's server response instead. Fold that into the helper for every spec.
|
|
529
561
|
|
|
530
562
|
## Follow-ups (logged 2026-09-26, browser suite)
|
|
531
563
|
|
|
@@ -1844,8 +1876,8 @@ the toolbar instead of pushing the content.
|
|
|
1844
1876
|
- Once the policy-based published read exists, decide whether it becomes the canonical headless read path and the generated per-collection views turn optional (cloud has no views).
|
|
1845
1877
|
- Add an index on `pages(published_version_id)`: the `page_versions` read policy's `exists` probe runs on every anon/read-key read and has no supporting index.
|
|
1846
1878
|
- A malformed `space_id`/`key_id` claim raises `22P02` inside the policy: fails closed but surfaces as a 500. Only reachable from a validly-signed key, so it is a cloud key-minting guard, not a request-path one.
|
|
1847
|
-
- Authorization trusts the JWT claims alone; `smoodly_space_key_revoked()` checks only `revoked_at`. When cloud key-minting exists, make it a full key check (the claims must match the `space_keys` row).
|
|
1848
|
-
- `memberOf` and `SupabaseMemberStore` read `members` (and `spaces`) with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
|
|
1879
|
+
- **Verified and kept 2026-09-26 (security pass §3)** — Authorization trusts the JWT claims alone; `smoodly_space_key_revoked()` checks only `revoked_at`. When cloud key-minting exists, make it a full key check (the claims must match the `space_keys` row).
|
|
1880
|
+
- **Verified and kept 2026-09-26 (security pass §3)** — `memberOf` and `SupabaseMemberStore` read `members` (and `spaces`) with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
|
|
1849
1881
|
- The `sb_secret_` branch of `clientOptions` (secret key as the sole `apikey`, no bearer) was exercised live on 2026-09-05 against a hosted project: PostgREST reads with the service-role template and the GoTrue admin API (`listUsers`, `createUser`) both accept it; a password sign-in with the matching `sb_publishable_` key returns a session. Unit tests still cover the shape only; the local stack stays on legacy JWT keys.
|
|
1850
1882
|
- The migration is not re-appliable to a pre-rename (`project_id`) database: `create table if not exists` leaves it untouched and the `space_id` policies then fail. No installs predate the rename; fold into DESIGN.md OQ #2 (schema upgrades).
|
|
1851
1883
|
- `examples/site/smoodly/server.ts` should carry `import "server-only"` now that it also exports `publicAuth` — a client-side import would fail loudly at build instead of at runtime.
|
|
@@ -829,7 +829,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
829
829
|
trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
|
|
830
830
|
},
|
|
831
831
|
{ label },
|
|
832
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
832
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
833
833
|
setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
|
|
834
834
|
const { session } = inline.richtext;
|
|
835
835
|
const item = globalItemOf(session.node);
|
|
@@ -845,7 +845,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
845
845
|
else
|
|
846
846
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
847
847
|
}, onEscape: inline.end }));
|
|
848
|
-
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
848
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
849
849
|
!isFixedNode(record, registry) &&
|
|
850
850
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
851
851
|
? () => { void doRemoveLocale(); }
|
|
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
12
|
// (shell/IconRail.tsx), then the main column whose views own their own
|
|
13
13
|
// 48px top bar (the editor adds the third, its inspector). The shell is
|
|
14
14
|
// viewport-height; every column scrolls inside itself.
|
|
15
|
-
import { useEffect, useState } from "react";
|
|
15
|
+
import { useEffect, useRef, useState } from "react";
|
|
16
16
|
import { makeClientOps } from "../client-ops.js";
|
|
17
17
|
import { PagesList } from "./PagesList.js";
|
|
18
18
|
import { EntriesList } from "./EntriesList.js";
|
|
@@ -37,7 +37,7 @@ import { ElementsProvider } from "../forms/elements-context.js";
|
|
|
37
37
|
import { TimeZoneProvider } from "../forms/time-zone-context.js";
|
|
38
38
|
import { SchemaNotice } from "./SchemaNotice.js";
|
|
39
39
|
import { schemaBehind } from "../../schema-version.js";
|
|
40
|
-
import { createPresence } from "./presence.js";
|
|
40
|
+
import { createPresence, followTokenRefresh } from "./presence.js";
|
|
41
41
|
import { connectRealtime } from "./presence-realtime.js";
|
|
42
42
|
import { PresenceProvider } from "./presence-context.js";
|
|
43
43
|
export function AdminApp({ registry, op, auth, content }) {
|
|
@@ -81,9 +81,15 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
81
81
|
return () => { live = false; };
|
|
82
82
|
}, [signedIn, ops]);
|
|
83
83
|
const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
|
|
84
|
-
// Presence (spec 2026-09-26 concurrency §5
|
|
85
|
-
//
|
|
84
|
+
// Presence (spec 2026-09-26 concurrency §5; gated 2026-09-26 security pass §1):
|
|
85
|
+
// one channel per signed-in session, the content project's public config as the
|
|
86
|
+
// gateway and the SESSION token on the socket; absent config = presence off.
|
|
87
|
+
// The token getter is a new closure every render — read it through a ref so
|
|
88
|
+
// the effect keys on the session, not on the render.
|
|
86
89
|
const [presence, setPresence] = useState(null);
|
|
90
|
+
const tokenRef = useRef(session.token);
|
|
91
|
+
tokenRef.current = session.token;
|
|
92
|
+
const authClient = session.client;
|
|
87
93
|
const contentUrl = content?.url, contentKey = content?.key, contentSpace = content?.spaceId;
|
|
88
94
|
useEffect(() => {
|
|
89
95
|
if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
|
|
@@ -91,11 +97,20 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
93
99
|
const config = { url: contentUrl, key: contentKey, spaceId: contentSpace };
|
|
94
|
-
const p = createPresence({
|
|
100
|
+
const p = createPresence({
|
|
101
|
+
email,
|
|
102
|
+
connect: () => {
|
|
103
|
+
const token = tokenRef.current();
|
|
104
|
+
if (!token)
|
|
105
|
+
throw new Error("smoodly: no session token for presence");
|
|
106
|
+
return connectRealtime(config, email, token);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
95
109
|
p.join();
|
|
96
110
|
setPresence(p);
|
|
97
|
-
|
|
98
|
-
|
|
111
|
+
const stop = followTokenRefresh(authClient.auth, p);
|
|
112
|
+
return () => { stop(); p.dispose(); setPresence(null); };
|
|
113
|
+
}, [signedIn, contentUrl, contentKey, contentSpace, email, authClient]);
|
|
99
114
|
if (session.state.status === "loading") {
|
|
100
115
|
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
101
116
|
}
|
|
@@ -411,6 +411,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
411
411
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
412
412
|
},
|
|
413
413
|
{ label: heading },
|
|
414
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
414
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
415
415
|
revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
416
416
|
}
|
|
@@ -298,6 +298,6 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
298
298
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
299
299
|
},
|
|
300
300
|
{ label: item.title },
|
|
301
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
301
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
302
302
|
revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
|
|
303
303
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { ContentConfig } from "../../env.ts";
|
|
2
2
|
import type { PresenceChannel } from "./presence.ts";
|
|
3
|
-
export declare function connectRealtime(config: ContentConfig, email: string): PresenceChannel;
|
|
3
|
+
export declare function connectRealtime(config: ContentConfig, email: string, token: string): PresenceChannel;
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
// The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6
|
|
3
|
-
// supabase-js client on the CONTENT
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// The real channel behind `createPresence` (spec 2026-09-26 concurrency §5–§6;
|
|
3
|
+
// gated 2026-09-26 security pass §1): one supabase-js client on the CONTENT
|
|
4
|
+
// project whose public key (the anon key in self-host, the read key in the
|
|
5
|
+
// cloud) is only the gateway apikey — the socket authenticates with the
|
|
6
|
+
// editor's SESSION token, which the policies read auth.uid() from. A
|
|
7
|
+
// private channel named after the space, presence keyed by the email.
|
|
8
|
+
//
|
|
9
|
+
// The token rides the client's `accessToken` callback, not a bare
|
|
10
|
+
// `realtime.setAuth(token)`: realtime-js treats the callback as the source
|
|
11
|
+
// of truth and re-asks it after every join and reconnect — with the
|
|
12
|
+
// default callback that answer is the public key, which the server then
|
|
13
|
+
// refuses ("no permissions to read"). The join itself waits for the first
|
|
14
|
+
// setAuth, so it never goes out with the apikey alone.
|
|
6
15
|
import { createClient } from "@supabase/supabase-js";
|
|
7
|
-
export function connectRealtime(config, email) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
export function connectRealtime(config, email, token) {
|
|
17
|
+
let current = token;
|
|
18
|
+
const client = createClient(config.url, config.key, { accessToken: async () => current });
|
|
19
|
+
const ready = client.realtime.setAuth(token).catch(() => { });
|
|
11
20
|
const channel = client.channel(`space:${config.spaceId}`, { config: { private: true, presence: { key: email } } });
|
|
12
21
|
return {
|
|
13
|
-
subscribe: (onStatus) => { channel.subscribe((status) => onStatus(status)); },
|
|
22
|
+
subscribe: (onStatus) => { void ready.then(() => { channel.subscribe((status) => onStatus(status)); }); },
|
|
23
|
+
setAuth: (fresh) => { current = fresh; return client.realtime.setAuth(fresh); },
|
|
14
24
|
track: (state) => channel.track(state),
|
|
15
25
|
untrack: () => channel.untrack(),
|
|
16
26
|
send: (message) => channel.send(message),
|
|
@@ -18,6 +18,8 @@ export type PresenceState = {
|
|
|
18
18
|
/** The slice of a Realtime channel presence uses. */
|
|
19
19
|
export type PresenceChannel = {
|
|
20
20
|
subscribe(onStatus: (status: string) => void): void;
|
|
21
|
+
/** A session token for the socket (spec 2026-09-26 security pass §1): the channel policies admit by auth.uid(). */
|
|
22
|
+
setAuth(token: string): Promise<unknown>;
|
|
21
23
|
track(state: Record<string, unknown>): Promise<unknown>;
|
|
22
24
|
untrack(): Promise<unknown>;
|
|
23
25
|
send(message: {
|
|
@@ -37,6 +39,8 @@ export type Presence = {
|
|
|
37
39
|
track(target: PresenceTarget | null): void;
|
|
38
40
|
/** After a guarded op acked: tell the others. */
|
|
39
41
|
announce(target: PresenceTarget, savedAt: string | null): void;
|
|
42
|
+
/** The session token was refreshed: re-authenticate the socket so the channel outlives the old token. No-op unless joined. */
|
|
43
|
+
setAuth(token: string): void;
|
|
40
44
|
onSaved(listener: (event: SavedEvent) => void): () => void;
|
|
41
45
|
getState(): PresenceState;
|
|
42
46
|
subscribe(listener: () => void): () => void;
|
|
@@ -49,3 +53,17 @@ export declare function createPresence(opts: {
|
|
|
49
53
|
email: string;
|
|
50
54
|
connect: () => PresenceChannel;
|
|
51
55
|
}): Presence;
|
|
56
|
+
/** What the shell needs from the auth client to follow refreshes — supabase-js's `auth` satisfies it. */
|
|
57
|
+
export type RefreshSource = {
|
|
58
|
+
onAuthStateChange(cb: (event: string, session: {
|
|
59
|
+
access_token: string;
|
|
60
|
+
} | null) => void): {
|
|
61
|
+
data: {
|
|
62
|
+
subscription: {
|
|
63
|
+
unsubscribe(): void;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
69
|
+
export declare function followTokenRefresh(auth: RefreshSource, presence: Pick<Presence, "setAuth">): () => void;
|
|
@@ -76,6 +76,11 @@ export function createPresence(opts) {
|
|
|
76
76
|
return;
|
|
77
77
|
void channel.send({ type: "broadcast", event: "saved", payload: { ...target, email: opts.email, savedAt } }).catch(() => { });
|
|
78
78
|
},
|
|
79
|
+
setAuth(token) {
|
|
80
|
+
if (!channel)
|
|
81
|
+
return;
|
|
82
|
+
void channel.setAuth(token).catch(() => { });
|
|
83
|
+
},
|
|
79
84
|
onSaved(listener) { savedListeners.add(listener); return () => { savedListeners.delete(listener); }; },
|
|
80
85
|
getState: () => state,
|
|
81
86
|
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
@@ -88,3 +93,11 @@ export function createPresence(opts) {
|
|
|
88
93
|
},
|
|
89
94
|
};
|
|
90
95
|
}
|
|
96
|
+
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
97
|
+
export function followTokenRefresh(auth, presence) {
|
|
98
|
+
const { data } = auth.onAuthStateChange((event, session) => {
|
|
99
|
+
if (event === "TOKEN_REFRESHED" && session)
|
|
100
|
+
presence.setAuth(session.access_token);
|
|
101
|
+
});
|
|
102
|
+
return () => data.subscription.unsubscribe();
|
|
103
|
+
}
|
|
@@ -120,8 +120,11 @@ export declare function StatusBadge({ status }: {
|
|
|
120
120
|
status: string;
|
|
121
121
|
}): import("react").JSX.Element;
|
|
122
122
|
/** The autosave indicator: a dot and one uppercase word. */
|
|
123
|
-
|
|
123
|
+
/** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
|
|
124
|
+
* store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
|
|
125
|
+
export declare function SaveIndicator({ state, pending }: {
|
|
124
126
|
state: SaveState;
|
|
127
|
+
pending?: boolean;
|
|
125
128
|
}): import("react").JSX.Element;
|
|
126
129
|
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
|
127
130
|
export declare function SectionLabel({ children, action, caps, style }: {
|
|
@@ -124,14 +124,16 @@ export function StatusBadge({ status }) {
|
|
|
124
124
|
}, children: status }));
|
|
125
125
|
}
|
|
126
126
|
/** The autosave indicator: a dot and one uppercase word. */
|
|
127
|
-
|
|
127
|
+
/** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
|
|
128
|
+
* store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
|
|
129
|
+
export function SaveIndicator({ state, pending = false }) {
|
|
128
130
|
const map = {
|
|
129
131
|
saved: { label: "Saved", dot: "var(--status-saved-dot)", color: "var(--text-muted)" },
|
|
130
132
|
saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
|
|
131
133
|
error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
|
|
132
134
|
stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
|
|
133
135
|
};
|
|
134
|
-
const { label, dot, color } = map[state];
|
|
136
|
+
const { label, dot, color } = map[pending && state === "saved" ? "saving" : state];
|
|
135
137
|
return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
|
136
138
|
}
|
|
137
139
|
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
2
2
|
* that adds a table, column, function or policy the package reads; a
|
|
3
3
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
4
|
-
export declare const SCHEMA_VERSION =
|
|
4
|
+
export declare const SCHEMA_VERSION = 3;
|
|
5
5
|
export declare const SCHEMA_VERSION_KEY = "schema_version";
|
|
6
6
|
export declare const CORE_FILE_RE: RegExp;
|
|
7
7
|
export declare const VIEWS_FILE_RE: RegExp;
|
package/dist/schema-version.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
8
8
|
* that adds a table, column, function or policy the package reads; a
|
|
9
9
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 3;
|
|
11
11
|
export const SCHEMA_VERSION_KEY = "schema_version";
|
|
12
12
|
export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
|
|
13
13
|
export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -15,3 +15,9 @@ export declare function spaceLayerSQL(): string;
|
|
|
15
15
|
* Schema-qualified calls: storage policies do not run with public on
|
|
16
16
|
* the search path. Last in the migration, after the functions exist. */
|
|
17
17
|
export declare function storageSQL(): string;
|
|
18
|
+
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
19
|
+
* 2026-09-26 security pass §2): executable by the service role and by
|
|
20
|
+
* `authenticated` (the role a cloud write key carries), not by anon or
|
|
21
|
+
* public. `smoodly_current_space` is NOT here — the stores read it through
|
|
22
|
+
* rpc(). Last in the schema, after every definition exists. */
|
|
23
|
+
export declare function grantsSQL(): string;
|
package/dist/sql-space.js
CHANGED
|
@@ -25,7 +25,7 @@ export function currentSpaceSQL() {
|
|
|
25
25
|
-- default space in self-host (service role, anon key). Policies, the
|
|
26
26
|
-- insert trigger and the reorder RPC all read this ONE function.
|
|
27
27
|
create or replace function "smoodly_current_space"() returns uuid
|
|
28
|
-
language sql stable as $$
|
|
28
|
+
language sql stable set search_path = '' as $$
|
|
29
29
|
select coalesce((auth.jwt() ->> 'space_id')::uuid, ${DEFAULT_SPACE});
|
|
30
30
|
$$;`.trim();
|
|
31
31
|
}
|
|
@@ -90,7 +90,7 @@ alter table "spaces" enable row level security;
|
|
|
90
90
|
-- ── Claim readers. The two lookups run as definer so they can read the
|
|
91
91
|
-- service-role-only tables from inside a policy; search_path is pinned.
|
|
92
92
|
create or replace function "smoodly_key_kind"() returns text
|
|
93
|
-
language sql stable as $$
|
|
93
|
+
language sql stable set search_path = '' as $$
|
|
94
94
|
select auth.jwt() ->> 'kind';
|
|
95
95
|
$$;
|
|
96
96
|
|
|
@@ -107,12 +107,25 @@ language sql stable security definer set search_path = public as $$
|
|
|
107
107
|
select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
|
|
108
108
|
$$;
|
|
109
109
|
|
|
110
|
+
-- The presence gate (spec 2026-09-26 security pass §1): the caller is a
|
|
111
|
+
-- member of the space the Realtime topic names. auth.uid() is the session
|
|
112
|
+
-- token's subject — the anon key and a read key carry none, so a visitor
|
|
113
|
+
-- is refused at join. Definer, because members has no read policy for
|
|
114
|
+
-- anyone but a write key. No space claim is read: the same rule serves
|
|
115
|
+
-- the cloud once the content project accepts smoodly.io's tokens (OQ 32).
|
|
116
|
+
create or replace function "smoodly_presence_member"() returns boolean
|
|
117
|
+
language sql stable security definer set search_path = public as $$
|
|
118
|
+
select exists (select 1 from "members" m
|
|
119
|
+
where m."user_id" = auth.uid()
|
|
120
|
+
and realtime.topic() = 'space:' || m."space_id"::text);
|
|
121
|
+
$$;
|
|
122
|
+
|
|
110
123
|
-- ── Triggers. A key's claim wins over whatever the insert said, so an
|
|
111
124
|
-- adapter never has to know its space and cannot write into another.
|
|
112
125
|
-- No claim (service role, anon) leaves the given or default value —
|
|
113
126
|
-- that is how smoodly.io writes cloud-state rows for a chosen space.
|
|
114
127
|
create or replace function "smoodly_set_space"() returns trigger
|
|
115
|
-
language plpgsql as $$
|
|
128
|
+
language plpgsql set search_path = '' as $$
|
|
116
129
|
begin
|
|
117
130
|
if auth.jwt() ->> 'space_id' is not null then
|
|
118
131
|
new."space_id" := (auth.jwt() ->> 'space_id')::uuid;
|
|
@@ -199,17 +212,19 @@ $$;`.trim());
|
|
|
199
212
|
statements.push(`drop policy if exists "smoodly_read" on "smoodly_meta";\n` +
|
|
200
213
|
`create policy "smoodly_read" on "smoodly_meta" for select\n` +
|
|
201
214
|
` using ((select "smoodly_key_kind"()) = 'write' and not (select "smoodly_space_key_revoked"()));`);
|
|
202
|
-
// The editing-presence channel (spec 2026-09-26 concurrency §
|
|
203
|
-
// private Realtime topic per space,
|
|
204
|
-
// and sends to
|
|
205
|
-
//
|
|
206
|
-
|
|
215
|
+
// The editing-presence channel (spec 2026-09-26 concurrency §5; gated
|
|
216
|
+
// 2026-09-26 security pass §1): one private Realtime topic per space,
|
|
217
|
+
// `space:<id>`. A caller receives on and sends to a topic only when a
|
|
218
|
+
// members row of that space names their session's user — the browser
|
|
219
|
+
// authenticates the socket with the editor's session token, never with
|
|
220
|
+
// the public key alone. Identical in self-host and the cloud.
|
|
221
|
+
const member = `(select public."smoodly_presence_member"())`;
|
|
207
222
|
statements.push(`drop policy if exists "smoodly_presence_read" on "realtime"."messages";\n` +
|
|
208
223
|
`create policy "smoodly_presence_read" on "realtime"."messages" for select\n` +
|
|
209
|
-
` using (${
|
|
224
|
+
` using (${member});`);
|
|
210
225
|
statements.push(`drop policy if exists "smoodly_presence_write" on "realtime"."messages";\n` +
|
|
211
226
|
`create policy "smoodly_presence_write" on "realtime"."messages" for insert\n` +
|
|
212
|
-
` with check (${
|
|
227
|
+
` with check (${member});`);
|
|
213
228
|
statements.push(storageSQL());
|
|
214
229
|
return statements.join("\n\n");
|
|
215
230
|
}
|
|
@@ -238,3 +253,15 @@ create policy "smoodly_media_write" on storage.objects for all
|
|
|
238
253
|
using (${write})
|
|
239
254
|
with check (${write});`.trim();
|
|
240
255
|
}
|
|
256
|
+
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
257
|
+
* 2026-09-26 security pass §2): executable by the service role and by
|
|
258
|
+
* `authenticated` (the role a cloud write key carries), not by anon or
|
|
259
|
+
* public. `smoodly_current_space` is NOT here — the stores read it through
|
|
260
|
+
* rpc(). Last in the schema, after every definition exists. */
|
|
261
|
+
export function grantsSQL() {
|
|
262
|
+
return [
|
|
263
|
+
`revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
|
|
264
|
+
`revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
|
|
265
|
+
`revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;`,
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
package/dist/sql.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// locale (spec 2026-09-06 §7); a versioned collection reads its
|
|
12
12
|
// fields from the published snapshot.
|
|
13
13
|
import { BUILT_IN_ORDER_FIELDS } from "./collections.js";
|
|
14
|
-
import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
14
|
+
import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
15
15
|
const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
16
16
|
const q = (s) => `"${s}"`;
|
|
17
17
|
// Casts are fine in views (unlike index expressions, which must be
|
|
@@ -111,7 +111,7 @@ create table if not exists "page_locales" (
|
|
|
111
111
|
);
|
|
112
112
|
|
|
113
113
|
-- Sibling reorder for pages: same shape as smoodly_reorder for entries.
|
|
114
|
-
create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
|
|
114
|
+
create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql set search_path = public as $$
|
|
115
115
|
update "pages" pg set "sort" = u.ord
|
|
116
116
|
from unnest(p_ids) with ordinality as u(id, ord)
|
|
117
117
|
where pg."id" = u.id
|
|
@@ -211,7 +211,7 @@ alter table "entry_locales" add constraint "entry_locales_draft_version_fkey"
|
|
|
211
211
|
create or replace function "smoodly_reorder"(
|
|
212
212
|
p_collection text,
|
|
213
213
|
p_ids uuid[]
|
|
214
|
-
) returns void language sql as $$
|
|
214
|
+
) returns void language sql set search_path = public as $$
|
|
215
215
|
update "entries" e set "sort" = u.ord
|
|
216
216
|
from unnest(p_ids) with ordinality as u(id, ord)
|
|
217
217
|
where e."id" = u.id
|
|
@@ -280,7 +280,7 @@ create index if not exists "assets_space_created_idx"
|
|
|
280
280
|
-- function runs in the caller's transaction, so a primary-key collision
|
|
281
281
|
-- rolls the whole rewrite back. Inserted rows get their space from the
|
|
282
282
|
-- a_set_space trigger (the claim, or the default space).
|
|
283
|
-
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
|
|
283
|
+
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
|
|
284
284
|
delete from "paths" p
|
|
285
285
|
using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
|
|
286
286
|
where p."kind" = t.kind and p."target_id" = t.id
|
|
@@ -318,5 +318,6 @@ alter table "assets" enable row level security;
|
|
|
318
318
|
alter table "smoodly_meta" enable row level security;
|
|
319
319
|
`.trim(),
|
|
320
320
|
spaceLayerSQL(),
|
|
321
|
+
grantsSQL(),
|
|
321
322
|
].join("\n\n");
|
|
322
323
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Smoodly core schema, version 3 (docs/superpowers/specs/2026-09-26-security-pass-design.md).
|
|
2
|
+
-- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
|
|
3
|
+
-- Never edit a published file: the next change is the next file.
|
|
4
|
+
-- The delta over version 2: the presence channel admits a signed-in
|
|
5
|
+
-- member of the topic's space (a definer lookup in members by the session
|
|
6
|
+
-- token's user), the five unpinned functions get a search_path, and the
|
|
7
|
+
-- three mutation RPCs are no longer executable by anon or public.
|
|
8
|
+
|
|
9
|
+
set client_min_messages = warning;
|
|
10
|
+
|
|
11
|
+
create or replace function "smoodly_presence_member"() returns boolean
|
|
12
|
+
language sql stable security definer set search_path = public as $$
|
|
13
|
+
select exists (select 1 from "members" m
|
|
14
|
+
where m."user_id" = auth.uid()
|
|
15
|
+
and realtime.topic() = 'space:' || m."space_id"::text);
|
|
16
|
+
$$;
|
|
17
|
+
|
|
18
|
+
drop policy if exists "smoodly_presence_read" on "realtime"."messages";
|
|
19
|
+
create policy "smoodly_presence_read" on "realtime"."messages" for select
|
|
20
|
+
using ((select public."smoodly_presence_member"()));
|
|
21
|
+
|
|
22
|
+
drop policy if exists "smoodly_presence_write" on "realtime"."messages";
|
|
23
|
+
create policy "smoodly_presence_write" on "realtime"."messages" for insert
|
|
24
|
+
with check ((select public."smoodly_presence_member"()));
|
|
25
|
+
|
|
26
|
+
alter function "smoodly_current_space"() set search_path = '';
|
|
27
|
+
alter function "smoodly_key_kind"() set search_path = '';
|
|
28
|
+
alter function "smoodly_set_space"() set search_path = '';
|
|
29
|
+
alter function "smoodly_reorder"(text, uuid[]) set search_path = public;
|
|
30
|
+
alter function "smoodly_reorder_pages"(uuid[]) set search_path = public;
|
|
31
|
+
alter function "smoodly_rewrite_paths"(jsonb, jsonb) set search_path = public;
|
|
32
|
+
|
|
33
|
+
revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;
|
|
34
|
+
revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;
|
|
35
|
+
revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;
|
|
36
|
+
|
|
37
|
+
insert into "smoodly_meta" ("key", "value") values ('schema_version', '3')
|
|
38
|
+
on conflict ("key") do update set "value" = excluded."value";
|
package/package.json
CHANGED