smoodly 0.0.25 → 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.
Files changed (59) hide show
  1. package/README.md +66 -2
  2. package/dist/admin/editor/EditorView.js +82 -18
  3. package/dist/admin/editor/autosave.d.ts +4 -1
  4. package/dist/admin/editor/autosave.js +24 -1
  5. package/dist/admin/editor/global-edit.d.ts +5 -1
  6. package/dist/admin/editor/global-edit.js +15 -5
  7. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  8. package/dist/admin/editor/useGlobalEdit.js +1 -1
  9. package/dist/admin/next/create-admin.d.ts +4 -0
  10. package/dist/admin/next/create-admin.js +3 -1
  11. package/dist/admin/next/index.d.ts +1 -0
  12. package/dist/admin/ops-impl.js +70 -24
  13. package/dist/admin/ops.d.ts +22 -12
  14. package/dist/admin/shell/AdminApp.d.ts +3 -1
  15. package/dist/admin/shell/AdminApp.js +36 -3
  16. package/dist/admin/shell/EntriesList.js +5 -1
  17. package/dist/admin/shell/EntryForm.js +44 -7
  18. package/dist/admin/shell/GlobalForm.js +46 -7
  19. package/dist/admin/shell/GlobalList.js +6 -2
  20. package/dist/admin/shell/PagesList.js +5 -1
  21. package/dist/admin/shell/presence-context.d.ts +9 -0
  22. package/dist/admin/shell/presence-context.js +35 -0
  23. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  24. package/dist/admin/shell/presence-realtime.js +32 -0
  25. package/dist/admin/shell/presence.d.ts +69 -0
  26. package/dist/admin/shell/presence.js +103 -0
  27. package/dist/admin/shell/stale.d.ts +28 -0
  28. package/dist/admin/shell/stale.js +17 -0
  29. package/dist/admin/ui/LockIcon.d.ts +4 -0
  30. package/dist/admin/ui/LockIcon.js +12 -0
  31. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  32. package/dist/admin/ui/PresenceLine.js +11 -0
  33. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  34. package/dist/admin/ui/StaleBanner.js +9 -0
  35. package/dist/admin/ui/primitives.d.ts +4 -1
  36. package/dist/admin/ui/primitives.js +5 -2
  37. package/dist/admin/ui/theme.js +4 -0
  38. package/dist/draft-token.d.ts +31 -0
  39. package/dist/draft-token.js +41 -0
  40. package/dist/entry-store.d.ts +11 -8
  41. package/dist/entry-store.js +24 -10
  42. package/dist/env.d.ts +9 -0
  43. package/dist/env.js +19 -0
  44. package/dist/index.d.ts +4 -2
  45. package/dist/index.js +2 -1
  46. package/dist/schema-version.d.ts +1 -1
  47. package/dist/schema-version.js +1 -1
  48. package/dist/sql-space.d.ts +6 -0
  49. package/dist/sql-space.js +41 -3
  50. package/dist/sql.js +16 -4
  51. package/dist/store.d.ts +11 -8
  52. package/dist/store.js +26 -8
  53. package/dist/supabase-entry-store.d.ts +15 -5
  54. package/dist/supabase-entry-store.js +87 -22
  55. package/dist/supabase-store.d.ts +15 -5
  56. package/dist/supabase-store.js +60 -20
  57. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  58. package/migrations/20260926140000_smoodly_v3.sql +38 -0
  59. package/package.json +1 -1
package/README.md CHANGED
@@ -468,6 +468,28 @@ Implemented:
468
468
  The media and refs walks descend into documents against the element
469
469
  schemas (`elements` beside `sections` in the store, resolve and site
470
470
  options).
471
+ - The token guard and presence (spec 2026-09-26 concurrency): every
472
+ draft write carries `{ versionId, savedAt }` from the locale row's
473
+ `draftSavedAt` and is refused as `conflict` + `stale` when the draft
474
+ moved (the write itself is conditional in Supabase); the admin freezes
475
+ under a notice naming who saved and when and reloads through "Load
476
+ their version"; one private Realtime channel per space shows who edits
477
+ what (a lock in the lists, a line under the top bar) and announces
478
+ saves so a clean view refreshes and a dirty one freezes early. Schema
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").
471
493
 
472
494
  Not yet: canvas drag-and-drop (list fields, the outline, manual
473
495
  collections and the pages tree drag since 2026-09-09 — spec
@@ -495,6 +517,48 @@ after both are on the registry. From the repo root:
495
517
  `prepublishOnly` rebuilds each package. The Claude Code skill
496
518
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
497
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
+
543
+ ## Follow-ups (logged 2026-09-26, concurrency spec)
544
+
545
+ - A hard lock with takeover, if a real team asks: presence has the facts.
546
+ - Presence on the members and media screens; today only pages, entries and
547
+ global items track.
548
+ - Postgres Changes for live list refresh; the lists still load on navigation.
549
+ - Field-level merging of two editors' changes; the guard refuses whole documents.
550
+ - Publish, discard and restore compare the token, then move the pointer under a
551
+ pointer filter: a save that rewrites the same draft row in place between the
552
+ two is published unseen (one round trip wide). A SQL function would close it.
553
+ - Publish leaves the token unchanged (the pointer and its saved time do not move),
554
+ so a publish with a token from before another person's publish of the same
555
+ draft succeeds — the content is the same. Recorded, not a bug.
556
+ - The read client over the read key (DESIGN.md OQ 31) can reuse `publicContent()`.
557
+ - A dropped socket clears presence on Realtime's timeout (tens of seconds); the
558
+ lock outlives a closed tab by that much.
559
+ - The live suites wipe `members` and the content tables; a pane walk after
560
+ them needs `npm run reseed` (or `scripts/demo-editor.ts`) first.
561
+
498
562
  ## Follow-ups (logged 2026-09-26, browser suite)
499
563
 
500
564
  - Drag and drop has no browser test (spec §4): the outline, the pages
@@ -1812,8 +1876,8 @@ the toolbar instead of pushing the content.
1812
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).
1813
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.
1814
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.
1815
- - 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).
1816
- - `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.
1817
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.
1818
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).
1819
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.
@@ -60,6 +60,12 @@ import { ResizeHandle } from "../ui/ResizeHandle.js";
60
60
  import { PublishControl } from "../ui/PublishControl.js";
61
61
  import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
62
62
  import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
63
+ import { draftToken } from "../../draft-token.js";
64
+ import { isStale, staleInfoOf } from "../shell/stale.js";
65
+ import { StaleBanner } from "../ui/StaleBanner.js";
66
+ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
67
+ import { editorsOf } from "../shell/presence.js";
68
+ import { PresenceLine } from "../ui/PresenceLine.js";
63
69
  import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
64
70
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
65
71
  /** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
@@ -67,6 +73,10 @@ export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ?
67
73
  export function EditorView({ ops, registry, id }) {
68
74
  const adminHref = useAdminHref();
69
75
  const [record, setRecord] = useState(null);
76
+ const recordRef = useRef(record);
77
+ recordRef.current = record;
78
+ /** The guard's refusal (spec 2026-09-26 concurrency §4): the view freezes under the banner until Load their version. */
79
+ const [stale, setStale] = useState(null);
70
80
  const [tree, setTree] = useState(null);
71
81
  const [loadError, setLoadError] = useState(null);
72
82
  const [ready, setReady] = useState(false);
@@ -77,6 +87,8 @@ export function EditorView({ ops, registry, id }) {
77
87
  // The locales the parent exists in — "Add locale" offers only these.
78
88
  const [parentLocales, setParentLocales] = useState([]);
79
89
  const [selectedId, setSelectedId] = useState(null);
90
+ /** A reload after someone else's save (spec 2026-09-26 concurrency §5) keeps what is selected; every other reload clears it. */
91
+ const keepSelectionRef = useRef(false);
80
92
  const [hoverId, setHoverId] = useState(null);
81
93
  const [hoverField, setHoverField] = useState(null);
82
94
  const [revealReq, setRevealReq] = useState(null);
@@ -112,21 +124,26 @@ export function EditorView({ ops, registry, id }) {
112
124
  // (Read once: localeFromUrl also remembers the locale.)
113
125
  const [openedLocale] = useState(() => localeFromUrl(registry));
114
126
  const treeLocaleRef = useRef(openedLocale);
127
+ /** The token of the row the tree belongs to — what every guarded op sends (spec 2026-09-26 concurrency §1). */
128
+ const token = () => { const row = recordRef.current?.locales[treeLocaleRef.current]; return row ? draftToken(row) : null; };
115
129
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
116
130
  // retry on failure, and the bridge refreshes the iframe on the save ack —
117
131
  // now, or once an inline session ends (spec 2026-09-10 §7).
118
- const { saveState, mark, flush } = useDraftAutosave({
132
+ const { saveState, mark, flush, reset } = useDraftAutosave({
119
133
  read: () => treeRef.current,
120
134
  persist: async (t) => {
121
135
  const target = treeLocaleRef.current;
122
- const r = await ops.pages.saveDraft(id, target, t);
136
+ const r = await ops.pages.saveDraft(id, target, t, token());
123
137
  if (r.ok) {
124
- // The badge and Publish follow each save without a re-get: saveDraft answers with the version id.
138
+ // The badge and Publish follow each save without a re-get: saveDraft answers with the version id and the new token.
125
139
  setRecord((rec) => rec?.locales[target]
126
- ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId } } }
140
+ ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } }
127
141
  : rec);
128
142
  setDirty(false);
143
+ presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
129
144
  }
145
+ else if (isStale(r))
146
+ setStale(staleInfoOf(r));
130
147
  return r;
131
148
  },
132
149
  onSaved: () => inlineRef.current?.onSaved(),
@@ -134,7 +151,7 @@ export function EditorView({ ops, registry, id }) {
134
151
  // Edits to a global block go to the global entry through their own
135
152
  // autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
136
153
  // through the same inline-session deferral as the page's.
137
- const globalEdit = useGlobalEdit({ ops, onSaved: () => { setGlobalDirty(false); inlineRef.current?.onSaved(); } });
154
+ const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
138
155
  const patchGlobal = (name, path, value) => {
139
156
  globalEdit.patch(name, path, value);
140
157
  setGlobalDirty(true);
@@ -148,6 +165,12 @@ export function EditorView({ ops, registry, id }) {
148
165
  clearBanner: () => setBanner(null),
149
166
  reload: () => setReloadKey((k) => k + 1),
150
167
  });
168
+ // Presence (spec 2026-09-26 concurrency §5): tracked once the record exists, the others on it, every ack announced.
169
+ const target = record ? { kind: "page", id, locale } : null;
170
+ useTrackPresence(target);
171
+ const { others } = usePresenceState();
172
+ const presence = usePresence();
173
+ const pageTarget = (forLocale) => ({ kind: "page", id, locale: forLocale });
151
174
  // The URL that serves this page, per locale, from the store's paths
152
175
  // index — the label is the CMS path, the canvas and "View live" take
153
176
  // the site URL (href applied server-side by pages.get).
@@ -177,11 +200,18 @@ export function EditorView({ ops, registry, id }) {
177
200
  setReady(false);
178
201
  setTree(null);
179
202
  treeRef.current = null;
180
- setSelectedId(null);
181
- setHoverId(null);
203
+ const keepSelection = keepSelectionRef.current;
204
+ keepSelectionRef.current = false;
205
+ if (!keepSelection) {
206
+ setSelectedId(null);
207
+ setHoverId(null);
208
+ }
182
209
  setFolder(false);
183
210
  globalEdit.reset(); // a new locale or a reload is a new canvas; the switch flushed first
211
+ if (keepSelection && selectedItemName)
212
+ void globalEdit.load(selectedItemName, locale); // the kept selection is a global item: load it again
184
213
  setDirty(false);
214
+ setStale(null);
185
215
  setTouched(false);
186
216
  setGlobalDirty(false);
187
217
  setGlobalTouched(false);
@@ -350,6 +380,24 @@ export function EditorView({ ops, registry, id }) {
350
380
  if (globalEdit.loadError)
351
381
  setBanner({ text: globalEdit.loadError });
352
382
  }, [globalEdit.loadError]);
383
+ // A global item's autosave refused by the guard freezes the whole editor too (§4).
384
+ useEffect(() => {
385
+ if (globalEdit.stale)
386
+ setStale(globalEdit.stale);
387
+ }, [globalEdit.stale]);
388
+ /** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
389
+ const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
390
+ // Someone else saved this page (§5): clean → their version silently; unsaved edits anywhere → the freeze at once.
391
+ useSavedElsewhere(target, (event) => {
392
+ if (stale)
393
+ return;
394
+ if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
395
+ setStale({ by: event.email, at: Date.now() });
396
+ else {
397
+ keepSelectionRef.current = true;
398
+ setReloadKey((k) => k + 1);
399
+ }
400
+ });
353
401
  // Selecting from the outline must reveal the block. The canvas column is
354
402
  // the scroller now (the iframe has no scrollbar of its own — it is sized to
355
403
  // the full document), so scroll it to the rect the bridge reported.
@@ -541,7 +589,7 @@ export function EditorView({ ops, registry, id }) {
541
589
  }
542
590
  let result;
543
591
  try {
544
- result = await ops.pages.publish(id, locale);
592
+ result = await ops.pages.publish(id, locale, token());
545
593
  }
546
594
  catch (e) {
547
595
  console.error(e);
@@ -552,9 +600,13 @@ export function EditorView({ ops, registry, id }) {
552
600
  setPageBusy(null);
553
601
  if (result.ok) {
554
602
  setRecord(result.data);
603
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
555
604
  setPublished(true);
556
605
  setTimeout(() => setPublished(false), 1600);
557
606
  }
607
+ else if (isStale(result)) {
608
+ setStale(staleInfoOf(result));
609
+ }
558
610
  else {
559
611
  setBanner({ text: result.message, fields: result.fields });
560
612
  }
@@ -573,17 +625,21 @@ export function EditorView({ ops, registry, id }) {
573
625
  }
574
626
  let result;
575
627
  try {
576
- result = await ops.pages.discardDraft(id, locale);
628
+ result = await ops.pages.discardDraft(id, locale, token());
577
629
  }
578
630
  catch {
579
631
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
580
632
  }
581
633
  setPageBusy(null);
582
634
  if (!result.ok) {
583
- setBanner({ text: result.message });
635
+ if (isStale(result))
636
+ setStale(staleInfoOf(result));
637
+ else
638
+ setBanner({ text: result.message });
584
639
  return;
585
640
  }
586
641
  setRecord(result.data);
642
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
587
643
  setReloadKey((k) => k + 1); // the tree and the canvas come back from the store
588
644
  };
589
645
  /** Unpublish (§5): the locale goes off the site; the draft stays for a later Publish. */
@@ -631,16 +687,20 @@ export function EditorView({ ops, registry, id }) {
631
687
  const doRestore = async (versionId) => {
632
688
  let result;
633
689
  try {
634
- result = await ops.pages.restore(id, locale, versionId);
690
+ result = await ops.pages.restore(id, locale, versionId, token());
635
691
  }
636
692
  catch {
637
693
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
638
694
  }
639
695
  if (!result.ok) {
640
- setBanner({ text: result.message });
696
+ if (isStale(result))
697
+ setStale(staleInfoOf(result));
698
+ else
699
+ setBanner({ text: result.message });
641
700
  return false;
642
701
  }
643
702
  setRecord(result.data);
703
+ presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
644
704
  setReloadKey((k) => k + 1);
645
705
  return true;
646
706
  };
@@ -650,8 +710,8 @@ export function EditorView({ ops, registry, id }) {
650
710
  setGlobalBusy("publish");
651
711
  const r = await globalEdit.setStatus(name, "published");
652
712
  setGlobalBusy(null);
653
- if (!r.ok)
654
- setBanner({ text: r.message, fields: r.fields });
713
+ if (!r.ok && !isStale(r))
714
+ setBanner({ text: r.message, fields: r.fields }); // a stale refusal arrives through globalEdit.stale
655
715
  };
656
716
  const doUnpublishGlobal = async (name) => {
657
717
  setBanner(null);
@@ -673,15 +733,19 @@ export function EditorView({ ops, registry, id }) {
673
733
  return;
674
734
  }
675
735
  let r;
736
+ const itemRow = globalEdit.items[name]?.record.locales[locale];
676
737
  try {
677
- r = await ops.globals.discardDraft(name, locale);
738
+ r = await ops.globals.discardDraft(name, locale, itemRow ? draftToken(itemRow) : null);
678
739
  }
679
740
  catch {
680
741
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
681
742
  }
682
743
  setGlobalBusy(null);
683
744
  if (!r.ok) {
684
- setBanner({ text: r.message });
745
+ if (isStale(r))
746
+ setStale(staleInfoOf(r));
747
+ else
748
+ setBanner({ text: r.message });
685
749
  return;
686
750
  }
687
751
  // The item reloads from the store; the canvas refreshes through the same path as a save ack.
@@ -765,7 +829,7 @@ export function EditorView({ ops, registry, id }) {
765
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); } })),
766
830
  },
767
831
  { label },
768
- ] }) }), 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(); } })] }) }), 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", 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)
769
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 && (() => {
770
834
  const { session } = inline.richtext;
771
835
  const item = globalItemOf(session.node);
@@ -781,7 +845,7 @@ export function EditorView({ ops, registry, id }) {
781
845
  else
782
846
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
783
847
  }, onEscape: inline.end }));
784
- })()] }) })] }), _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 &&
785
849
  !isFixedNode(record, registry) &&
786
850
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
787
851
  ? () => { void doRemoveLocale(); }
@@ -1,5 +1,5 @@
1
1
  import type { OpResult } from "../ops.ts";
2
- export type SaveState = "saved" | "saving" | "error";
2
+ export type SaveState = "saved" | "saving" | "error" | "stale";
3
3
  export type DraftAutosave = {
4
4
  /** Record a local change; a debounced save follows. */
5
5
  mark(): void;
@@ -11,6 +11,8 @@ export type DraftAutosave = {
11
11
  subscribe(listener: () => void): () => void;
12
12
  /** Cancel pending timers; call on unmount. */
13
13
  dispose(): void;
14
+ /** Leave `stale` after the record was reloaded: timers cleared, the reloaded value counts as saved. */
15
+ reset(): void;
14
16
  };
15
17
  export declare function createDraftAutosave<T>(opts: {
16
18
  read: () => T | null;
@@ -28,4 +30,5 @@ export declare function useDraftAutosave<T>(opts: DraftAutosaveOptions<T>): {
28
30
  saveState: SaveState;
29
31
  mark: () => void;
30
32
  flush: () => Promise<boolean>;
33
+ reset: () => void;
31
34
  };
@@ -46,6 +46,12 @@ export function createDraftAutosave(opts) {
46
46
  opts.onSaved?.();
47
47
  return true;
48
48
  }
49
+ else if (result.code === "conflict" && result.stale) {
50
+ // The guard refused (spec 2026-09-26 concurrency §4): terminal, never retried —
51
+ // the view freezes and only a reload (reset) brings the controller back.
52
+ setState("stale");
53
+ return false;
54
+ }
49
55
  else {
50
56
  // Keep local state; retry with a doubling, capped delay.
51
57
  setState("error");
@@ -62,12 +68,16 @@ export function createDraftAutosave(opts) {
62
68
  return () => { listeners.delete(listener); };
63
69
  },
64
70
  mark() {
71
+ if (state === "stale")
72
+ return;
65
73
  revision += 1;
66
74
  if (debounceTimer)
67
75
  clearTimeout(debounceTimer);
68
76
  debounceTimer = setTimeout(() => { debounceTimer = null; void save(); }, debounceMs);
69
77
  },
70
78
  flush() {
79
+ if (state === "stale")
80
+ return Promise.resolve(false);
71
81
  if (revision === savedRevision)
72
82
  return Promise.resolve(true);
73
83
  if (debounceTimer) {
@@ -86,6 +96,19 @@ export function createDraftAutosave(opts) {
86
96
  retryTimer = null;
87
97
  }
88
98
  },
99
+ reset() {
100
+ if (debounceTimer) {
101
+ clearTimeout(debounceTimer);
102
+ debounceTimer = null;
103
+ }
104
+ if (retryTimer) {
105
+ clearTimeout(retryTimer);
106
+ retryTimer = null;
107
+ }
108
+ savedRevision = revision;
109
+ retryDelay = retryBaseMs;
110
+ setState("saved");
111
+ },
89
112
  };
90
113
  }
91
114
  /** React glue: one controller per mount, always calling the latest
@@ -104,5 +127,5 @@ export function useDraftAutosave(opts) {
104
127
  }));
105
128
  useEffect(() => () => controller.dispose(), [controller]);
106
129
  const saveState = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
107
- return { saveState, mark: controller.mark, flush: controller.flush };
130
+ return { saveState, mark: controller.mark, flush: controller.flush, reset: controller.reset };
108
131
  }
@@ -1,6 +1,7 @@
1
1
  import type { EntryRecord, EntryUsage } from "../../entry-store.ts";
2
2
  import type { AdminOps, OpResult } from "../ops.ts";
3
3
  import { type SaveState } from "./autosave.ts";
4
+ import { type StaleInfo } from "../shell/stale.ts";
4
5
  export type GlobalEditItem = {
5
6
  name: string;
6
7
  locale: string;
@@ -15,6 +16,8 @@ export type GlobalEditState = {
15
16
  items: Record<string, GlobalEditItem>;
16
17
  saveState: SaveState;
17
18
  loadError: string | null;
19
+ /** The guard's refusal (spec 2026-09-26 concurrency §4): set once, cleared only by reset(). */
20
+ stale: StaleInfo | null;
18
21
  };
19
22
  export type GlobalEdit = {
20
23
  getState(): GlobalEditState;
@@ -32,7 +35,8 @@ export type GlobalEdit = {
32
35
  };
33
36
  export declare function createGlobalEdit(opts: {
34
37
  ops: Pick<AdminOps, "globals">;
35
- onSaved?: () => void;
38
+ /** Per saved item, with its name (presence announces each; spec 2026-09-26 concurrency §5). */
39
+ onSaved?: (name: string) => void;
36
40
  debounceMs?: number;
37
41
  retryBaseMs?: number;
38
42
  retryCapMs?: number;
@@ -1,8 +1,11 @@
1
1
  import { setAtPath } from "../field-paths.js";
2
2
  import { createDraftAutosave } from "./autosave.js";
3
+ import { draftToken } from "../../draft-token.js";
4
+ import { isStale, staleInfoOf } from "../shell/stale.js";
3
5
  export function createGlobalEdit(opts) {
4
6
  let items = {};
5
7
  let loadError = null;
8
+ let stale = null;
6
9
  let generation = 0;
7
10
  const loading = new Set();
8
11
  const rev = new Map();
@@ -20,7 +23,7 @@ export function createGlobalEdit(opts) {
20
23
  const at = rev.get(item.name) ?? 0;
21
24
  let r;
22
25
  try {
23
- r = await opts.ops.globals.save(item.name, item.locale, item.fields);
26
+ r = await opts.ops.globals.save(item.name, item.locale, item.fields, draftToken(item.record.locales[item.locale]));
24
27
  }
25
28
  catch {
26
29
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -30,8 +33,11 @@ export function createGlobalEdit(opts) {
30
33
  savedRev.set(item.name, at);
31
34
  if (current)
32
35
  items = { ...items, [item.name]: { ...current, record: r.data, errors: {} } };
36
+ opts.onSaved?.(item.name);
33
37
  }
34
38
  else {
39
+ if (isStale(r))
40
+ stale = staleInfoOf(r);
35
41
  failed ?? (failed = r);
36
42
  if (current) {
37
43
  items = { ...items, [item.name]: { ...current, errors: Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])) } };
@@ -41,13 +47,12 @@ export function createGlobalEdit(opts) {
41
47
  emit();
42
48
  return failed ?? { ok: true, data: null };
43
49
  },
44
- onSaved: opts.onSaved,
45
50
  debounceMs: opts.debounceMs,
46
51
  retryBaseMs: opts.retryBaseMs,
47
52
  retryCapMs: opts.retryCapMs,
48
53
  });
49
54
  autosave.subscribe(emit);
50
- const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError }));
55
+ const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError, stale }));
51
56
  return {
52
57
  getState,
53
58
  subscribe(listener) {
@@ -98,7 +103,7 @@ export function createGlobalEdit(opts) {
98
103
  }
99
104
  let r;
100
105
  try {
101
- r = await opts.ops.globals.setStatus(name, item.locale, status);
106
+ r = await opts.ops.globals.setStatus(name, item.locale, status, draftToken(item.record.locales[item.locale]));
102
107
  }
103
108
  catch {
104
109
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -107,6 +112,10 @@ export function createGlobalEdit(opts) {
107
112
  items = { ...items, [name]: { ...items[name], record: r.data } };
108
113
  emit();
109
114
  }
115
+ else if (isStale(r)) {
116
+ stale = staleInfoOf(r);
117
+ emit();
118
+ }
110
119
  return r;
111
120
  },
112
121
  reset() {
@@ -116,7 +125,8 @@ export function createGlobalEdit(opts) {
116
125
  savedRev.clear();
117
126
  loading.clear();
118
127
  loadError = null;
119
- autosave.dispose();
128
+ stale = null;
129
+ autosave.reset(); // timers cleared, and a stale controller comes back for the new canvas
120
130
  emit();
121
131
  },
122
132
  dispose: autosave.dispose,
@@ -2,5 +2,5 @@ import type { AdminOps } from "../ops.ts";
2
2
  import { type GlobalEdit, type GlobalEditState } from "./global-edit.ts";
3
3
  export declare function useGlobalEdit({ ops, onSaved }: {
4
4
  ops: Pick<AdminOps, "globals">;
5
- onSaved?: () => void;
5
+ onSaved?: (name: string) => void;
6
6
  }): GlobalEditState & Pick<GlobalEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
@@ -7,7 +7,7 @@ import { createGlobalEdit } from "./global-edit.js";
7
7
  export function useGlobalEdit({ ops, onSaved }) {
8
8
  const latest = useRef(onSaved);
9
9
  latest.current = onSaved;
10
- const [controller] = useState(() => createGlobalEdit({ ops, onSaved: () => latest.current?.() }));
10
+ const [controller] = useState(() => createGlobalEdit({ ops, onSaved: (name) => latest.current?.(name) }));
11
11
  useEffect(() => () => { void controller.flush(); controller.dispose(); }, [controller]);
12
12
  const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
13
13
  return {
@@ -2,10 +2,14 @@ import type { ReactNode } from "react";
2
2
  import type { ResolvedConfig } from "../../config.ts";
3
3
  import type { AdminOpAction } from "../ops.ts";
4
4
  import type { AuthConfig } from "../shell/session.ts";
5
+ import type { ContentConfig } from "../../env.ts";
6
+ /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
7
+ * concurrency §5); absent — an app wired before it — presence is off and everything else works. */
5
8
  export declare function createSmoodlyAdmin(input: {
6
9
  config: ResolvedConfig;
7
10
  op: AdminOpAction;
8
11
  auth: () => AuthConfig;
12
+ content?: () => ContentConfig;
9
13
  }): {
10
14
  AdminPage: () => null;
11
15
  AdminLayout: ({ children }: {
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { serializeAdminConfig } from "../serialize.js";
3
3
  import { AdminApp } from "../shell/AdminApp.js";
4
4
  import { AdminStyles } from "../ui/theme.js";
5
+ /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
6
+ * concurrency §5); absent — an app wired before it — presence is off and everything else works. */
5
7
  export function createSmoodlyAdmin(input) {
6
8
  const registry = serializeAdminConfig(input.config);
7
9
  /** The catch-all page only makes every /admin URL exist. The shell is
@@ -22,7 +24,7 @@ export function createSmoodlyAdmin(input) {
22
24
  * on this element before React hydrates, on purpose (no white flash
23
25
  * for a dark-mode editor), so the attribute differs from the server's. */
24
26
  function AdminLayout({ children }) {
25
- return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
27
+ return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), content: input.content?.() }), children] }));
26
28
  }
27
29
  return { AdminPage, AdminLayout };
28
30
  }
@@ -4,5 +4,6 @@ export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult
4
4
  export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
5
5
  export type { MemberRole } from "../../permissions.ts";
6
6
  export type { AuthConfig } from "../shell/session.ts";
7
+ export type { ContentConfig } from "../../env.ts";
7
8
  export type { AdminAuth } from "../auth.ts";
8
9
  export { supabaseAdminAuth } from "../supabase-auth.ts";