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 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): one channel per signed-in session,
85
- // joined with the content project's public config; absent config = presence off.
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({ email, connect: () => connectRealtime(config, email) });
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
- return () => { p.dispose(); setPresence(null); };
98
- }, [signedIn, contentUrl, contentKey, contentSpace, email]);
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): one
3
- // supabase-js client on the CONTENT project with its public key (the anon key in
4
- // self-host, the read key in the cloud), a private channel named after the space,
5
- // presence keyed by the email. A key that is a JWT is also the realtime token.
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
- const client = createClient(config.url, config.key, { auth: { persistSession: false, autoRefreshToken: false } });
9
- if (config.key.split(".").length === 3)
10
- void client.realtime.setAuth(config.key);
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
- export declare function SaveIndicator({ state }: {
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
- export function SaveIndicator({ state }) {
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). */
@@ -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 = 2;
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;
@@ -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 = 2;
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$/;
@@ -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 §6): one
203
- // private Realtime topic per space, `space:<id>`. A caller receives on
204
- // and sends to its own space's topic only — the anon key resolves to the
205
- // default space, a cloud read key to its claim. Identical everywhere.
206
- const topic = `(select realtime.topic()) = 'space:' || (select public."smoodly_current_space"())::text`;
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 (${topic});`);
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 (${topic});`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {