smoodly 0.0.26 → 0.0.28

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 (66) hide show
  1. package/README.md +70 -6
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +5 -2
  4. package/dist/admin/editor/PageInspector.d.ts +4 -1
  5. package/dist/admin/editor/PageInspector.js +2 -2
  6. package/dist/admin/editor/PageSettings.d.ts +4 -1
  7. package/dist/admin/editor/PageSettings.js +16 -8
  8. package/dist/admin/ops-impl.js +86 -7
  9. package/dist/admin/ops.d.ts +40 -1
  10. package/dist/admin/redirect-path.d.ts +6 -0
  11. package/dist/admin/redirect-path.js +33 -0
  12. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  13. package/dist/admin/shell/AddRedirectModal.js +29 -0
  14. package/dist/admin/shell/AdminApp.js +24 -10
  15. package/dist/admin/shell/EntryForm.js +14 -6
  16. package/dist/admin/shell/GlobalForm.js +1 -1
  17. package/dist/admin/shell/IconRail.d.ts +1 -1
  18. package/dist/admin/shell/IconRail.js +3 -2
  19. package/dist/admin/shell/PagesList.js +6 -4
  20. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  21. package/dist/admin/shell/RedirectsPage.js +75 -0
  22. package/dist/admin/shell/SettingsView.d.ts +4 -2
  23. package/dist/admin/shell/SettingsView.js +18 -14
  24. package/dist/admin/shell/live-url.d.ts +10 -2
  25. package/dist/admin/shell/live-url.js +9 -4
  26. package/dist/admin/shell/members-list.js +1 -1
  27. package/dist/admin/shell/presence-realtime.d.ts +1 -1
  28. package/dist/admin/shell/presence-realtime.js +19 -9
  29. package/dist/admin/shell/presence.d.ts +18 -0
  30. package/dist/admin/shell/presence.js +13 -0
  31. package/dist/admin/shell/redirects-list.d.ts +12 -0
  32. package/dist/admin/shell/redirects-list.js +15 -0
  33. package/dist/admin/shell/settings.d.ts +12 -3
  34. package/dist/admin/shell/settings.js +13 -9
  35. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  36. package/dist/admin/ui/ConfirmDialog.js +4 -3
  37. package/dist/admin/ui/confirm.d.ts +13 -2
  38. package/dist/admin/ui/confirm.js +9 -5
  39. package/dist/admin/ui/primitives.d.ts +4 -1
  40. package/dist/admin/ui/primitives.js +4 -2
  41. package/dist/admin/ui/theme.js +3 -0
  42. package/dist/entry-store.d.ts +18 -6
  43. package/dist/entry-store.js +13 -8
  44. package/dist/next/page-renderer.js +28 -9
  45. package/dist/path-index.d.ts +28 -4
  46. package/dist/path-index.js +37 -4
  47. package/dist/paths.d.ts +16 -0
  48. package/dist/paths.js +9 -0
  49. package/dist/schema-version.d.ts +1 -1
  50. package/dist/schema-version.js +1 -1
  51. package/dist/site.d.ts +4 -1
  52. package/dist/site.js +3 -1
  53. package/dist/sql-space.d.ts +6 -0
  54. package/dist/sql-space.js +37 -10
  55. package/dist/sql.js +24 -5
  56. package/dist/store.d.ts +30 -5
  57. package/dist/store.js +24 -8
  58. package/dist/supabase-entry-store.d.ts +7 -2
  59. package/dist/supabase-entry-store.js +12 -8
  60. package/dist/supabase-path-index.d.ts +10 -3
  61. package/dist/supabase-path-index.js +50 -17
  62. package/dist/supabase-store.d.ts +13 -2
  63. package/dist/supabase-store.js +15 -8
  64. package/migrations/20260926140000_smoodly_v3.sql +38 -0
  65. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  66. package/package.json +1 -1
package/README.md CHANGED
@@ -477,6 +477,30 @@ 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").
493
+ - Redirects (spec 2026-09-26 redirects): a `paths` row with `redirect`
494
+ set is an old address of its target (schema version 4). A published
495
+ rename or move keeps the old addresses as redirects unless the editor
496
+ unticks "Redirect the old address" on the live-URL confirm; the live
497
+ URL wins an address; nothing chains; `renderSmoodlyPath` answers a
498
+ redirect row with `permanentRedirect` through `href`. Settings ›
499
+ Redirects (every member) lists, adds by hand (validated old address,
500
+ target type-ahead) and removes. Tests: `test/path-index-contract.ts`
501
+ (the rule table, both adapters), the store contracts, `admin-ops`
502
+ ("redirects on rename and move", "redirects ops"),
503
+ `next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
480
504
 
481
505
  Not yet: canvas drag-and-drop (list fields, the outline, manual
482
506
  collections and the pages tree drag since 2026-09-09 — spec
@@ -504,6 +528,50 @@ after both are on the registry. From the repo root:
504
528
  `prepublishOnly` rebuilds each package. The Claude Code skill
505
529
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
506
530
 
531
+ ## Follow-ups (logged 2026-09-26, redirects spec)
532
+
533
+ - Destinations outside Smoodly, extension and query-string addresses,
534
+ code-owned renames (a collection `path`, a fixed slug): the guide's
535
+ `next.config` recipe; a `redirects` table is OQ 34.
536
+ - The content API's `getPage("/old")` returns null for a redirect row
537
+ (spec §7); a `follow` option is a one-line addition if a site asks.
538
+ - A redirect row has no audit columns (who, when); add them with a
539
+ need. The Redirects page is not paged.
540
+ - The browser suite covers rename, the unticked box, no chains, the
541
+ Settings tab's list and the manual add/remove; a parent's MOVE
542
+ redirecting a child's URL is proven at the store contract and ops
543
+ level only (no tree drag in the suite yet).
544
+ - `remove` on the Supabase index deletes by target, one statement per
545
+ target (the RPC now spares redirect rows); fine at subtree sizes.
546
+ - Pressing Enter in the page Settings tab's slug input commits once,
547
+ then the confirm's autofocus blurs the input and `commit` runs again,
548
+ so a second identical question queues behind the first and its Cancel
549
+ restores the input from a stale row closure (pre-existing, seen on the
550
+ redirects walk). A re-entrancy guard on `commit` fixes it.
551
+
552
+ ## Follow-ups (logged 2026-09-26, security pass)
553
+
554
+ - The bridge posts with `"*"` on both sides. Safe today: the admin
555
+ accepts a message only when `e.source` is its own iframe's window, and
556
+ the canvas acts on two kinds, refresh and scroll. The target-origin
557
+ pass belongs to the content-surface sweep (spec §Out of scope).
558
+ - The invite's `redirectTo` is a client-supplied string bounded by the
559
+ auth project's redirect allow-list; a same-origin check in the op is
560
+ one line when wanted.
561
+ - Every op call with a junk token costs one `auth.getUser` against the
562
+ auth project; rate limiting is the session-model spec's (OQ 20).
563
+ - The content-surface sweep: `f.link` values reach section components
564
+ unfiltered, uploads allow SVG and PDF on a public bucket (SVG served
565
+ with `?download=`), the custom CSS refusal list, richtext hrefs.
566
+ - A refused private-channel join surfaces as `CHANNEL_ERROR` (about 80
567
+ ms on the local stack); the suite asserts "not SUBSCRIBED".
568
+ - The isolation suite's `join` helper still uses a bare
569
+ `realtime.setAuth(key)`: its positive cases resolve on the first
570
+ SUBSCRIBED, before realtime-js re-asks the default `accessToken`
571
+ callback and the server closes the channel. Fine for proving the
572
+ policy; a client that must stay joined passes `accessToken` (what
573
+ `connectRealtime` does since 3ed8931).
574
+
507
575
  ## Follow-ups (logged 2026-09-26, concurrency spec)
508
576
 
509
577
  - A hard lock with takeover, if a real team asks: presence has the facts.
@@ -522,10 +590,6 @@ after both are on the registry. From the repo root:
522
590
  lock outlives a closed tab by that much.
523
591
  - The live suites wipe `members` and the content tables; a pane walk after
524
592
  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
593
 
530
594
  ## Follow-ups (logged 2026-09-26, browser suite)
531
595
 
@@ -1844,8 +1908,8 @@ the toolbar instead of pushing the content.
1844
1908
  - 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
1909
  - 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
1910
  - 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.
1911
+ - **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).
1912
+ - **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
1913
  - 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
1914
  - 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
1915
  - `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.
@@ -4,6 +4,7 @@ const GROUPS = {
4
4
  globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
6
  members: ["list", "me", "invite", "setRole", "remove"],
7
+ redirects: ["list", "targets", "add", "remove"],
7
8
  schema: ["status"],
8
9
  preview: ["enable", "disable"],
9
10
  };
@@ -83,6 +83,7 @@ export function EditorView({ ops, registry, id }) {
83
83
  const [folder, setFolder] = useState(false);
84
84
  const [reloadKey, setReloadKey] = useState(0);
85
85
  const [paths, setPaths] = useState({});
86
+ const [redirects, setRedirects] = useState({});
86
87
  const [urls, setUrls] = useState({});
87
88
  // The locales the parent exists in — "Add locale" offers only these.
88
89
  const [parentLocales, setParentLocales] = useState([]);
@@ -249,6 +250,7 @@ export function EditorView({ ops, registry, id }) {
249
250
  }
250
251
  setRecord(result.data.record);
251
252
  setPaths(result.data.paths);
253
+ setRedirects(result.data.redirects);
252
254
  setUrls(result.data.urls);
253
255
  setParentLocales(result.data.parentLocales);
254
256
  // Absent in this locale: the "Add <locale>" pane, derived from the record.
@@ -287,6 +289,7 @@ export function EditorView({ ops, registry, id }) {
287
289
  return;
288
290
  setRecord(result.data.record);
289
291
  setPaths(result.data.paths);
292
+ setRedirects(result.data.redirects);
290
293
  setUrls(result.data.urls);
291
294
  setParentLocales(result.data.parentLocales);
292
295
  }, [ops, id, locale]);
@@ -829,7 +832,7 @@ export function EditorView({ ops, registry, id }) {
829
832
  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
833
  },
831
834
  { 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)
835
+ ] }) }), 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
836
  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
837
  const { session } = inline.richtext;
835
838
  const item = globalItemOf(session.node);
@@ -845,7 +848,7 @@ export function EditorView({ ops, registry, id }) {
845
848
  else
846
849
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
847
850
  }, 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 &&
851
+ })()] }) })] }), _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], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
849
852
  !isFixedNode(record, registry) &&
850
853
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
851
854
  ? () => { void doRemoveLocale(); }
@@ -3,7 +3,7 @@ import type { PageRecord } from "../../store.ts";
3
3
  import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
4
4
  export type PageTab = "SEO & Social" | "Settings";
5
5
  export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
6
- export declare function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }: {
6
+ export declare function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }: {
7
7
  record: PageRecord;
8
8
  locale: string;
9
9
  registry: AdminRegistry;
@@ -11,9 +11,12 @@ export declare function PageInspector({ record, locale, registry, tree, path, fi
11
11
  /** The page's path in this locale (the `paths` index). */
12
12
  path: string | undefined;
13
13
  fixed: boolean;
14
+ /** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
15
+ redirects?: string[];
14
16
  onRename(patch: {
15
17
  title?: string;
16
18
  slug?: string;
19
+ redirect?: boolean;
17
20
  }): Promise<string | null>;
18
21
  /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
19
22
  onRemoveLocale?: () => void;
@@ -13,12 +13,12 @@ import { SeoSettings } from "./SeoSettings.js";
13
13
  export function pageTabsFor(template) {
14
14
  return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
15
15
  }
16
- export function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }) {
16
+ export function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }) {
17
17
  const template = registry.pages.find((p) => p.name === record.template);
18
18
  const tabs = pageTabsFor(template);
19
19
  const [tab, setTab] = useState(tabs[0]);
20
20
  useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
21
21
  const active = tabs.includes(tab) ? tab : tabs[0];
22
22
  const title = record.locales[locale]?.title ?? "";
23
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
23
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
24
24
  }
@@ -1,6 +1,6 @@
1
1
  import type { PageRecord } from "../../store.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
3
+ export declare function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }: {
4
4
  record: PageRecord;
5
5
  locale: string;
6
6
  registry: AdminRegistry;
@@ -8,8 +8,11 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
8
8
  path: string | undefined;
9
9
  fixed: boolean;
10
10
  /** Resolves to an error message, or null when the rename landed. */
11
+ /** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
12
+ redirects?: string[];
11
13
  onRename(patch: {
12
14
  title?: string;
13
15
  slug?: string;
16
+ redirect?: boolean;
14
17
  }): Promise<string | null>;
15
18
  }): import("react").JSX.Element | null;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
4
4
  // §6): the ACTIVE locale's title and slug, saved through pages.rename,
5
5
  // and "Remove from this locale". The switcher in the canvas meta row
@@ -11,10 +11,13 @@ import { useEffect, useState } from "react";
11
11
  import { updatedLine } from "../ui/format.js";
12
12
  import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
13
13
  import { confirmLiveUrl } from "../shell/live-url.js";
14
- export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
14
+ import { RouteLink } from "../shell/RouteLink.js";
15
+ import { useAdminHref } from "../shell/base.js";
16
+ export function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }) {
15
17
  const row = record.locales[locale];
16
18
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
17
19
  const [error, setError] = useState(null);
20
+ const adminHref = useAdminHref();
18
21
  const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
19
22
  // The record changes identity on every rename; re-seed the inputs from it.
20
23
  useEffect(() => {
@@ -31,12 +34,17 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
31
34
  setError(`A ${key} is required — nothing falls back to another locale.`);
32
35
  return;
33
36
  }
34
- // A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
35
- if (key === "slug" && !(await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }))) {
36
- setDraft((d) => ({ ...d, slug: row.slug }));
37
- return;
37
+ // A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
38
+ let redirect = true;
39
+ if (key === "slug") {
40
+ const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
41
+ if (!live.ok) {
42
+ setDraft((d) => ({ ...d, slug: row.slug }));
43
+ return;
44
+ }
45
+ redirect = live.redirect;
38
46
  }
39
- setError(await onRename({ [key]: next }));
47
+ setError(await onRename(key === "slug" ? { slug: next, redirect } : { title: next }));
40
48
  };
41
49
  const onKey = (key) => (e) => {
42
50
  if (e.key === "Enter") {
@@ -44,5 +52,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
44
52
  void commit(key);
45
53
  }
46
54
  };
47
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
55
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), redirects && redirects.length > 0 && (_jsx(Field, { label: "Redirects from", asDiv: true, hint: _jsxs(_Fragment, { children: ["Old addresses that send visitors here. Manage them in ", _jsx(RouteLink, { href: adminHref("/settings/redirects"), children: "Settings \u203A Redirects" }), "."] }), children: _jsx("div", { className: "sm-redirects-from", children: redirects.map((p) => _jsx("code", { children: p }, p)) }) })), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
48
56
  }
@@ -11,6 +11,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
11
11
  import { SETTINGS_KEY, CSS_KEY } from "../global.js";
12
12
  import { withDefaults } from "../field-defaults.js";
13
13
  import { elementSchemas } from "../richtext-walk.js";
14
+ import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "./redirect-path.js";
14
15
  import { SCHEMA_VERSION } from "../schema-version.js";
15
16
  import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
16
17
  const ok = (data) => ({ ok: true, data });
@@ -65,6 +66,8 @@ const classify = (e) => {
65
66
  return err("not_found", message);
66
67
  if (message.includes("cannot take a slug"))
67
68
  return err("validation", message);
69
+ if (message.includes("is a live address"))
70
+ return err("validation", message);
68
71
  // Ahead of the generic "already exists" check below: the singleton
69
72
  // refusal's message ("...already exists — it is a singleton.") contains
70
73
  // "already exists" as a substring, so it must classify here first or it
@@ -208,6 +211,22 @@ export function createAdminOps(deps) {
208
211
  const def = config.locales.default;
209
212
  /** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
210
213
  const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
214
+ /** "kind:id" → title in the locale (+ collection for an entry): every page with a template, every entry of a routed collection. */
215
+ const targetTitles = async (locale) => {
216
+ const out = new Map();
217
+ for (const p of await pages.listPages()) {
218
+ if (p.template === null)
219
+ continue;
220
+ out.set(`page:${p.id}`, { title: p.locales[locale]?.title ?? nameOf(p), collection: null });
221
+ }
222
+ for (const c of config.registry.collections ?? []) {
223
+ if (!c.path)
224
+ continue;
225
+ for (const e of await entries.list(c.name))
226
+ out.set(`entry:${e.id}`, { title: entryTitleIn(e, locale, c), collection: c.name });
227
+ }
228
+ return out;
229
+ };
211
230
  const assets = deps.assets;
212
231
  const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
213
232
  const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to createAdminOpHandler.");
@@ -292,12 +311,13 @@ export function createAdminOps(deps) {
292
311
  const draft = record.template === null || !record.locales[locale] ? null : await pages.getDraftTree(id, locale);
293
312
  const paths = await pages.pathsOf(id);
294
313
  const urls = Object.fromEntries(Object.entries(paths).map(([l, path]) => [l, href(path, l)]));
314
+ const redirects = await pages.redirectsOf(id);
295
315
  // A locale can only be added where the parent already has it; a
296
316
  // root has no parent, so every supported locale is open.
297
317
  const parentLocales = record.parentId === null
298
318
  ? config.locales.supported
299
319
  : Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
300
- return ok({ record, locale, draft, paths, urls, parentLocales });
320
+ return ok({ record, locale, draft, paths, urls, redirects, parentLocales });
301
321
  }),
302
322
  create: (input) => guard(async () => {
303
323
  const unsupported = supportedLocale(input.locale);
@@ -494,7 +514,8 @@ export function createAdminOps(deps) {
494
514
  return unsupported;
495
515
  if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
496
516
  return slugTaken();
497
- const renamed = await pages.rename(id, patch, by);
517
+ const { redirect, ...rest } = patch;
518
+ const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
498
519
  // Every descendant's path changed with this segment; the parent
499
520
  // lists this node's title and path.
500
521
  expirePages([...(await subtreeIds(id)), record.parentId]);
@@ -521,7 +542,8 @@ export function createAdminOps(deps) {
521
542
  if (blocked)
522
543
  return blocked;
523
544
  }
524
- const moved = await pages.move(id, to);
545
+ const { redirect, ...dest } = to;
546
+ const moved = await pages.move(id, dest, { redirect: redirect ?? true });
525
547
  expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
526
548
  expireTree();
527
549
  return ok(moved);
@@ -607,7 +629,8 @@ export function createAdminOps(deps) {
607
629
  : Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
608
630
  const paths = await entries.pathsOf(collection, id);
609
631
  const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
610
- return ok({ record, locale, fields, parentLocales, urls });
632
+ const redirects = await entries.redirectsOf(collection, id);
633
+ return ok({ record, locale, fields, parentLocales, urls, redirects });
611
634
  }),
612
635
  create: (collection, input) => guard(async () => {
613
636
  const unsupported = supportedLocale(input.locale);
@@ -620,7 +643,7 @@ export function createAdminOps(deps) {
620
643
  expireCollection(collection);
621
644
  return ok(created);
622
645
  }),
623
- save: (collection, id, locale, fields, expected) => guard(async () => {
646
+ save: (collection, id, locale, fields, expected, options) => guard(async () => {
624
647
  const unsupported = supportedLocale(locale);
625
648
  if (unsupported)
626
649
  return unsupported;
@@ -630,7 +653,7 @@ export function createAdminOps(deps) {
630
653
  const descriptors = collectionFields(collection);
631
654
  if (!descriptors)
632
655
  return err("not_found", `smoodly: no collection named "${collection}".`);
633
- const entry = await entries.update(collection, id, locale, fields, by, token);
656
+ const entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
634
657
  await fanOut(id);
635
658
  expireCollection(collection);
636
659
  return ok(entry);
@@ -707,7 +730,8 @@ export function createAdminOps(deps) {
707
730
  // The tag expires only after the delete succeeded; a blocked delete expires nothing.
708
731
  delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
709
732
  move: (collection, id, to) => guard(async () => {
710
- const moved = await entries.move(collection, id, to);
733
+ const { redirect, ...dest } = to;
734
+ const moved = await entries.move(collection, id, dest, { redirect: redirect ?? true });
711
735
  if (effects?.revalidate) {
712
736
  for (const entryId of await entrySubtreeIds(collection, id))
713
737
  effects.revalidate(entryTag(entryId));
@@ -973,6 +997,61 @@ export function createAdminOps(deps) {
973
997
  return ok(null);
974
998
  }),
975
999
  },
1000
+ redirects: {
1001
+ list: (locale) => guard(async () => {
1002
+ const unsupported = supportedLocale(locale);
1003
+ if (unsupported)
1004
+ return unsupported;
1005
+ const rows = await pages.listRedirects(locale);
1006
+ const titles = await targetTitles(locale);
1007
+ const items = [];
1008
+ for (const r of rows) {
1009
+ const t = titles.get(`${r.kind}:${r.id}`);
1010
+ const currentPath = (await pages.targetPaths({ kind: r.kind, id: r.id }))[locale] ?? null;
1011
+ items.push({ locale, path: r.path, target: { kind: r.kind, id: r.id }, collection: t?.collection ?? null, title: t?.title ?? r.id, currentPath, url: currentPath ? href(currentPath, locale) : null });
1012
+ }
1013
+ return ok(items.sort((a, b) => a.path.localeCompare(b.path)));
1014
+ }),
1015
+ targets: (locale) => guard(async () => {
1016
+ const unsupported = supportedLocale(locale);
1017
+ if (unsupported)
1018
+ return unsupported;
1019
+ const titles = await targetTitles(locale);
1020
+ const out = [];
1021
+ for (const r of await pages.listLive(locale)) {
1022
+ const t = titles.get(`${r.kind}:${r.id}`);
1023
+ if (!t)
1024
+ continue; // a folder, or an entry outside a routed collection
1025
+ out.push({ target: { kind: r.kind, id: r.id }, collection: t.collection, title: t.title, path: r.path });
1026
+ }
1027
+ return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
1028
+ }),
1029
+ add: (locale, path, target) => guard(async () => {
1030
+ const unsupported = supportedLocale(locale);
1031
+ if (unsupported)
1032
+ return unsupported;
1033
+ const problem = redirectPathProblem(path, { adminPath: config.admin.path, multilingual: config.locales.supported.length > 1 });
1034
+ if (problem)
1035
+ return err("validation", REDIRECT_PATH_MESSAGE[problem]);
1036
+ const titles = await targetTitles(locale);
1037
+ if (!titles.has(`${target.kind}:${target.id}`))
1038
+ return err("not_found", `smoodly: no ${target.kind} "${target.id}" to redirect to.`);
1039
+ const holder = await pages.resolvePath(locale, path);
1040
+ if (holder && !holder.redirect) {
1041
+ const owner = titles.get(`${holder.kind}:${holder.id}`)?.title ?? holder.id;
1042
+ return err("validation", `smoodly: "${path}" is the address of "${owner}".`);
1043
+ }
1044
+ await pages.addRedirect({ kind: target.kind, id: target.id, locale, path });
1045
+ return ok(null);
1046
+ }),
1047
+ remove: (locale, path) => guard(async () => {
1048
+ const unsupported = supportedLocale(locale);
1049
+ if (unsupported)
1050
+ return unsupported;
1051
+ await pages.removeRedirect(locale, path);
1052
+ return ok(null);
1053
+ }),
1054
+ },
976
1055
  schema: {
977
1056
  status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
978
1057
  },
@@ -7,6 +7,7 @@ import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts
7
7
  import type { MemberRole } from "../permissions.ts";
8
8
  import type { FieldError } from "./validate.ts";
9
9
  import type { DraftToken } from "../draft-token.ts";
10
+ import type { PathTarget } from "../paths.ts";
10
11
  export type OpError = {
11
12
  ok: false;
12
13
  code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
@@ -44,6 +45,8 @@ export type PageGetResult = {
44
45
  paths: Record<string, string>;
45
46
  /** locale → site URL, `href` applied server-side (the canvas and "View live" use these). */
46
47
  urls: Record<string, string>;
48
+ /** locale → the page's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
49
+ redirects: Record<string, string[]>;
47
50
  /** The locales the PARENT exists in — a locale can only be added here
48
51
  * once the parent has it (DESIGN.md, Localization), so the editor
49
52
  * disables the rest with a reason. Every supported locale for a root
@@ -65,6 +68,8 @@ export type EntryGetResult = {
65
68
  fields: Record<string, unknown> | null;
66
69
  /** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
67
70
  urls: Record<string, string>;
71
+ /** locale → the entry's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
72
+ redirects: Record<string, string[]>;
68
73
  /** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
69
74
  parentLocales: string[];
70
75
  };
@@ -109,6 +114,23 @@ export type SchemaStatus = {
109
114
  database: number | null;
110
115
  required: number;
111
116
  };
117
+ /** A redirect row with its target joined (spec 2026-09-26 redirects §6): `currentPath`/`url` null when the target has no live path in the locale. */
118
+ export type RedirectItem = {
119
+ locale: string;
120
+ path: string;
121
+ target: PathTarget;
122
+ collection: string | null;
123
+ title: string;
124
+ currentPath: string | null;
125
+ url: string | null;
126
+ };
127
+ /** A page or entry live in the locale, for the Add dialog's type-ahead. */
128
+ export type RedirectTargetItem = {
129
+ target: PathTarget;
130
+ collection: string | null;
131
+ title: string;
132
+ path: string;
133
+ };
112
134
  export type AdminOps = {
113
135
  pages: {
114
136
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -127,14 +149,17 @@ export type AdminOps = {
127
149
  /** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
128
150
  discardDraft(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
129
151
  delete(id: string): Promise<OpResult<null>>;
152
+ /** `redirect` (default true) keeps a published row's old address as a redirect (spec 2026-09-26 redirects §1). */
130
153
  rename(id: string, patch: {
131
154
  locale: string;
132
155
  slug?: string;
133
156
  title?: string;
157
+ redirect?: boolean;
134
158
  }): Promise<OpResult<PageRecord>>;
135
159
  move(id: string, to: {
136
160
  parentId: string | null;
137
161
  index?: number;
162
+ redirect?: boolean;
138
163
  }): Promise<OpResult<PageRecord>>;
139
164
  /** "Add page here": a folder becomes a page. */
140
165
  setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
@@ -159,7 +184,9 @@ export type AdminOps = {
159
184
  get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
160
185
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
161
186
  /** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
162
- save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
187
+ save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null, options?: {
188
+ redirect?: boolean;
189
+ }): Promise<OpResult<EntryRecord>>;
163
190
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
164
191
  /** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
165
192
  discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
@@ -167,6 +194,7 @@ export type AdminOps = {
167
194
  move(collection: string, id: string, to: {
168
195
  parentId: string | null;
169
196
  index?: number;
197
+ redirect?: boolean;
170
198
  }): Promise<OpResult<EntryRecord>>;
171
199
  /** Copies `from`'s localized fields and slug as the new locale's draft (spec 2026-09-06 §7). Expires nothing. */
172
200
  addLocale(collection: string, id: string, locale: string, options: {
@@ -225,6 +253,17 @@ export type AdminOps = {
225
253
  /** Admins only, self-host only; the same two guards. The Auth account stays. */
226
254
  remove(userId: string): Promise<OpResult<null>>;
227
255
  };
256
+ /** Redirects by hand (spec 2026-09-26 redirects §5): open to every member, as renaming is; nothing here expires a cache. */
257
+ redirects: {
258
+ /** The locale's redirect rows with their targets' titles and current URLs, sorted by old address. */
259
+ list(locale: string): Promise<OpResult<RedirectItem[]>>;
260
+ /** Every page with a template and every routed entry live in the locale, for the Add dialog's type-ahead. */
261
+ targets(locale: string): Promise<OpResult<RedirectTargetItem[]>>;
262
+ /** Validates the old address (redirect-path.ts), refuses a live address naming its owner, `not_found` for an unknown target. */
263
+ add(locale: string, path: string, target: PathTarget): Promise<OpResult<null>>;
264
+ /** Deletes a redirect row; a live row is never touched. */
265
+ remove(locale: string, path: string): Promise<OpResult<null>>;
266
+ };
228
267
  schema: {
229
268
  /** Every member. The shell calls it once on mount and blocks when behind. */
230
269
  status(): Promise<OpResult<SchemaStatus>>;
@@ -0,0 +1,6 @@
1
+ export type RedirectPathProblem = "empty" | "leading-slash" | "root" | "trailing-slash" | "double-slash" | "whitespace" | "query-or-hash" | "admin" | "extension";
2
+ export declare const REDIRECT_PATH_MESSAGE: Record<RedirectPathProblem, string>;
3
+ export declare function redirectPathProblem(path: string, opts: {
4
+ adminPath: string;
5
+ multilingual: boolean;
6
+ }): RedirectPathProblem | null;
@@ -0,0 +1,33 @@
1
+ export const REDIRECT_PATH_MESSAGE = {
2
+ empty: "Enter the old address.",
3
+ "leading-slash": "Start with a slash: /old-address.",
4
+ root: "The site root cannot redirect.",
5
+ "trailing-slash": "Leave out the trailing slash.",
6
+ "double-slash": "An address cannot have an empty part.",
7
+ whitespace: "An address cannot contain spaces.",
8
+ "query-or-hash": "Leave out the query string and the hash: they are not part of the address.",
9
+ admin: "That is the admin's address.",
10
+ extension: "An address with a file extension never reaches Smoodly on a multilingual site — redirect it in next.config instead.",
11
+ };
12
+ export function redirectPathProblem(path, opts) {
13
+ if (path === "")
14
+ return "empty";
15
+ if (/\s/.test(path))
16
+ return "whitespace";
17
+ if (/[?#]/.test(path))
18
+ return "query-or-hash";
19
+ if (!path.startsWith("/"))
20
+ return "leading-slash";
21
+ if (path === "/")
22
+ return "root";
23
+ if (path.endsWith("/"))
24
+ return "trailing-slash";
25
+ if (path.includes("//"))
26
+ return "double-slash";
27
+ if (path === opts.adminPath || path.startsWith(`${opts.adminPath}/`))
28
+ return "admin";
29
+ const last = path.slice(path.lastIndexOf("/") + 1);
30
+ if (opts.multilingual && /\.[A-Za-z0-9]+$/.test(last))
31
+ return "extension";
32
+ return null;
33
+ }
@@ -0,0 +1,11 @@
1
+ import type { PathTarget } from "../../paths.ts";
2
+ import type { RedirectTargetItem } from "../ops.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ export declare function AddRedirectModal({ locale, registry, targets, onClose, onAdd }: {
5
+ locale: string;
6
+ registry: AdminRegistry;
7
+ targets: RedirectTargetItem[];
8
+ onClose(): void;
9
+ /** Resolves to an error message, or null when the redirect landed (the caller closes). */
10
+ onAdd(path: string, target: PathTarget): Promise<string | null>;
11
+ }): import("react").JSX.Element;