smoodly 0.0.50 → 0.0.52

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 (285) hide show
  1. package/README.md +14 -13
  2. package/dist/admin/auth-config.d.ts +6 -5
  3. package/dist/admin/auth.d.ts +3 -11
  4. package/dist/admin/auth.js +1 -30
  5. package/dist/admin/browser/fetch-op.js +3 -2
  6. package/dist/admin/browser/main.js +6 -2
  7. package/dist/admin/client-ops.d.ts +5 -4
  8. package/dist/admin/client-ops.js +14 -11
  9. package/dist/admin/delivery-mode.d.ts +1 -1
  10. package/dist/admin/delivery-mode.js +2 -2
  11. package/dist/admin/drift.d.ts +0 -2
  12. package/dist/admin/drift.js +1 -1
  13. package/dist/admin/editor/EditorView.d.ts +0 -3
  14. package/dist/admin/editor/EditorView.js +11 -16
  15. package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
  16. package/dist/admin/editor/FloatingToolbar.js +1 -1
  17. package/dist/admin/editor/SeoSettings.d.ts +0 -2
  18. package/dist/admin/editor/SeoSettings.js +2 -2
  19. package/dist/admin/editor/canvas-address.d.ts +8 -0
  20. package/dist/admin/editor/canvas-address.js +18 -2
  21. package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
  22. package/dist/admin/editor/canvas-diagnosis.js +3 -0
  23. package/dist/admin/editor/inline-dom.d.ts +0 -4
  24. package/dist/admin/editor/inline-dom.js +1 -1
  25. package/dist/admin/editor/preview-grant.d.ts +6 -0
  26. package/dist/admin/editor/preview-grant.js +25 -0
  27. package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
  28. package/dist/admin/files.generated.js +1 -1
  29. package/dist/admin/fixed-nodes.d.ts +0 -6
  30. package/dist/admin/fixed-nodes.js +1 -81
  31. package/dist/admin/forms/CropEditor.d.ts +4 -2
  32. package/dist/admin/forms/CropEditor.js +58 -5
  33. package/dist/admin/forms/ImagePreview.d.ts +3 -1
  34. package/dist/admin/forms/ImagePreview.js +9 -3
  35. package/dist/admin/forms/MediaGrid.js +2 -1
  36. package/dist/admin/forms/MediaPicker.js +9 -6
  37. package/dist/admin/forms/MediaWidget.js +11 -9
  38. package/dist/admin/forms/assets-context.d.ts +7 -3
  39. package/dist/admin/forms/assets-context.js +5 -3
  40. package/dist/admin/forms/crop-editor.d.ts +24 -2
  41. package/dist/admin/forms/crop-editor.js +36 -2
  42. package/dist/admin/forms/date-picker.d.ts +0 -1
  43. package/dist/admin/forms/date-picker.js +1 -1
  44. package/dist/admin/forms/tabs.d.ts +0 -1
  45. package/dist/admin/forms/tabs.js +1 -1
  46. package/dist/admin/forms/upload.d.ts +18 -11
  47. package/dist/admin/forms/upload.js +62 -24
  48. package/dist/admin/global-items.d.ts +0 -3
  49. package/dist/admin/global-items.js +0 -54
  50. package/dist/admin/handler.d.ts +22 -28
  51. package/dist/admin/handler.js +146 -47
  52. package/dist/admin/health.d.ts +0 -4
  53. package/dist/admin/index.d.ts +5 -8
  54. package/dist/admin/index.js +3 -6
  55. package/dist/admin/ops.d.ts +36 -22
  56. package/dist/admin/page-data.d.ts +14 -6
  57. package/dist/admin/page-data.js +1 -1
  58. package/dist/admin/session-flow.d.ts +43 -0
  59. package/dist/admin/session-flow.js +94 -0
  60. package/dist/admin/shell/AdminApp.d.ts +8 -3
  61. package/dist/admin/shell/AdminApp.js +37 -58
  62. package/dist/admin/shell/ContentHealthPage.js +1 -3
  63. package/dist/admin/shell/GlobalFields.d.ts +0 -3
  64. package/dist/admin/shell/GlobalFields.js +1 -1
  65. package/dist/admin/shell/IconRail.js +5 -4
  66. package/dist/admin/shell/MediaScreen.js +9 -6
  67. package/dist/admin/shell/MembersPage.d.ts +1 -2
  68. package/dist/admin/shell/MembersPage.js +19 -90
  69. package/dist/admin/shell/SettingsView.d.ts +1 -2
  70. package/dist/admin/shell/SettingsView.js +2 -2
  71. package/dist/admin/shell/SignedOutView.d.ts +5 -0
  72. package/dist/admin/shell/SignedOutView.js +9 -0
  73. package/dist/admin/shell/console-links.d.ts +5 -0
  74. package/dist/admin/shell/console-links.js +11 -0
  75. package/dist/admin/shell/members-list.d.ts +1 -14
  76. package/dist/admin/shell/members-list.js +2 -23
  77. package/dist/admin/shell/pages-tree.d.ts +0 -2
  78. package/dist/admin/shell/pages-tree.js +1 -1
  79. package/dist/admin/shell/presence-socket.d.ts +10 -0
  80. package/dist/admin/shell/presence-socket.js +191 -0
  81. package/dist/admin/shell/presence.d.ts +6 -18
  82. package/dist/admin/shell/presence.js +4 -11
  83. package/dist/admin/shell/session.d.ts +20 -12
  84. package/dist/admin/shell/session.js +113 -47
  85. package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
  86. package/dist/admin/ui/LocaleSwitcher.js +2 -2
  87. package/dist/admin/ui/primitives.d.ts +4 -14
  88. package/dist/admin/ui/primitives.js +5 -8
  89. package/dist/admin/ui/theme.js +13 -2
  90. package/dist/admin/validate.js +10 -1
  91. package/dist/api-client.d.ts +26 -0
  92. package/dist/api-client.js +107 -0
  93. package/dist/assemble.d.ts +4 -41
  94. package/dist/assemble.js +4 -302
  95. package/dist/asset-store.d.ts +28 -30
  96. package/dist/asset-store.js +26 -121
  97. package/dist/astro/components/Head.astro +4 -1
  98. package/dist/astro/components/Image.astro +12 -17
  99. package/dist/astro/components/Video.astro +5 -2
  100. package/dist/astro/head.d.ts +3 -0
  101. package/dist/astro/head.js +24 -0
  102. package/dist/astro/index.js +54 -25
  103. package/dist/astro/locale-paths.d.ts +5 -2
  104. package/dist/astro/locale-paths.js +10 -2
  105. package/dist/astro/routes/admin.js +9 -7
  106. package/dist/astro/runtime.d.ts +2 -1
  107. package/dist/astro/runtime.js +4 -3
  108. package/dist/astro/server.d.ts +7 -11
  109. package/dist/astro/server.js +22 -28
  110. package/dist/astro/shell.d.ts +2 -2
  111. package/dist/astro/static-media-node.d.ts +4 -0
  112. package/dist/astro/static-media-node.js +25 -0
  113. package/dist/astro/static-media.d.ts +17 -0
  114. package/dist/astro/static-media.js +55 -0
  115. package/dist/astro/static.d.ts +5 -10
  116. package/dist/astro/static.js +13 -29
  117. package/dist/canvas-marker.d.ts +14 -0
  118. package/dist/canvas-marker.js +36 -0
  119. package/dist/cli/api.d.ts +14 -0
  120. package/dist/cli/api.js +48 -0
  121. package/dist/cli/app-env.d.ts +2 -0
  122. package/dist/cli/app-env.js +20 -0
  123. package/dist/cli/export.d.ts +16 -2
  124. package/dist/cli/export.js +52 -35
  125. package/dist/cli/import.d.ts +7 -11
  126. package/dist/cli/import.js +50 -54
  127. package/dist/cli/load-config.d.ts +0 -1
  128. package/dist/cli/load-config.js +1 -1
  129. package/dist/cli/main.d.ts +4 -12
  130. package/dist/cli/main.js +26 -72
  131. package/dist/cli/push.d.ts +6 -0
  132. package/dist/cli/push.js +31 -0
  133. package/dist/collections.d.ts +0 -1
  134. package/dist/collections.js +1 -1
  135. package/dist/config.d.ts +20 -5
  136. package/dist/config.js +18 -1
  137. package/dist/content/content-api.d.ts +22 -23
  138. package/dist/content/content-api.js +46 -56
  139. package/dist/content/mime.d.ts +1 -0
  140. package/dist/content/mime.js +6 -0
  141. package/dist/deploy-store.d.ts +19 -0
  142. package/dist/deploy-store.js +1 -0
  143. package/dist/entry-store.d.ts +3 -89
  144. package/dist/entry-store.js +2 -550
  145. package/dist/env.d.ts +27 -20
  146. package/dist/env.js +38 -50
  147. package/dist/fields.d.ts +11 -3
  148. package/dist/fields.js +33 -2
  149. package/dist/global-id.d.ts +2 -0
  150. package/dist/global-id.js +4 -2
  151. package/dist/image/SmoodlyImage.d.ts +9 -5
  152. package/dist/image/SmoodlyImage.js +29 -15
  153. package/dist/image/SmoodlyVideo.d.ts +1 -1
  154. package/dist/image/SmoodlyVideo.js +3 -2
  155. package/dist/image/index.d.ts +2 -3
  156. package/dist/image/index.js +0 -2
  157. package/dist/image/markup.d.ts +53 -0
  158. package/dist/image/markup.js +111 -0
  159. package/dist/index.d.ts +12 -22
  160. package/dist/index.js +7 -20
  161. package/dist/media-cut.d.ts +44 -0
  162. package/dist/media-cut.js +70 -0
  163. package/dist/media.d.ts +57 -3
  164. package/dist/media.js +125 -15
  165. package/dist/member-store.d.ts +3 -22
  166. package/dist/member-store.js +1 -53
  167. package/dist/next/admin-route.d.ts +1 -1
  168. package/dist/next/admin-route.js +5 -3
  169. package/dist/next/meta.js +2 -3
  170. package/dist/next/page-renderer.d.ts +2 -2
  171. package/dist/next/page-renderer.js +28 -15
  172. package/dist/next/queries.d.ts +2 -2
  173. package/dist/path-index.d.ts +0 -24
  174. package/dist/path-index.js +0 -98
  175. package/dist/push.d.ts +30 -0
  176. package/dist/push.js +52 -0
  177. package/dist/read-counter.d.ts +2 -0
  178. package/dist/read-counter.js +6 -0
  179. package/dist/reads.d.ts +9 -8
  180. package/dist/reads.js +7 -3
  181. package/dist/registry-json.d.ts +11 -0
  182. package/dist/registry-json.js +81 -0
  183. package/dist/render.d.ts +0 -1
  184. package/dist/render.js +1 -1
  185. package/dist/resolve.d.ts +12 -6
  186. package/dist/resolve.js +11 -8
  187. package/dist/revalidate.d.ts +8 -1
  188. package/dist/revalidate.js +8 -1
  189. package/dist/richtext/index.d.ts +1 -1
  190. package/dist/section-wrapper.d.ts +1 -1
  191. package/dist/section-wrapper.js +0 -9
  192. package/dist/signed-message.d.ts +26 -0
  193. package/dist/signed-message.js +103 -0
  194. package/dist/site.d.ts +59 -18
  195. package/dist/site.js +21 -252
  196. package/dist/space-transfer.d.ts +5 -2
  197. package/dist/space-transfer.js +17 -5
  198. package/dist/store.d.ts +2 -80
  199. package/dist/store.js +3 -489
  200. package/dist/toolset.d.ts +2 -1
  201. package/dist/toolset.js +1 -1
  202. package/dist/version.d.ts +1 -1
  203. package/dist/version.js +1 -1
  204. package/package.json +4 -15
  205. package/dist/admin/ops-impl.d.ts +0 -51
  206. package/dist/admin/ops-impl.js +0 -1400
  207. package/dist/admin/shell/InviteModal.d.ts +0 -6
  208. package/dist/admin/shell/InviteModal.js +0 -28
  209. package/dist/admin/shell/LoginView.d.ts +0 -5
  210. package/dist/admin/shell/LoginView.js +0 -80
  211. package/dist/admin/shell/SchemaNotice.d.ts +0 -4
  212. package/dist/admin/shell/SchemaNotice.js +0 -4
  213. package/dist/admin/shell/SetPasswordView.d.ts +0 -7
  214. package/dist/admin/shell/SetPasswordView.js +0 -41
  215. package/dist/admin/shell/presence-realtime.d.ts +0 -3
  216. package/dist/admin/shell/presence-realtime.js +0 -32
  217. package/dist/admin/supabase-auth.d.ts +0 -7
  218. package/dist/admin/supabase-auth.js +0 -30
  219. package/dist/astro/deploy-deps.d.ts +0 -10
  220. package/dist/astro/deploy-deps.js +0 -8
  221. package/dist/astro/image.d.ts +0 -27
  222. package/dist/astro/image.js +0 -29
  223. package/dist/bind.d.ts +0 -19
  224. package/dist/bind.js +0 -34
  225. package/dist/cli/add-admin.d.ts +0 -11
  226. package/dist/cli/add-admin.js +0 -25
  227. package/dist/cli/load-collections.d.ts +0 -2
  228. package/dist/cli/load-collections.js +0 -27
  229. package/dist/cli/migrate.d.ts +0 -43
  230. package/dist/cli/migrate.js +0 -138
  231. package/dist/cli/rebuild.d.ts +0 -23
  232. package/dist/cli/rebuild.js +0 -22
  233. package/dist/cli/space-io.d.ts +0 -41
  234. package/dist/cli/space-io.js +0 -84
  235. package/dist/client.d.ts +0 -11
  236. package/dist/client.js +0 -54
  237. package/dist/current-space.d.ts +0 -4
  238. package/dist/current-space.js +0 -9
  239. package/dist/document-stamp.d.ts +0 -7
  240. package/dist/document-stamp.js +0 -48
  241. package/dist/image/loaders.d.ts +0 -15
  242. package/dist/image/loaders.js +0 -5
  243. package/dist/image/next-config.d.ts +0 -18
  244. package/dist/image/next-config.js +0 -32
  245. package/dist/image/remote-pattern.d.ts +0 -1
  246. package/dist/image/remote-pattern.js +0 -5
  247. package/dist/migrations.d.ts +0 -37
  248. package/dist/migrations.js +0 -73
  249. package/dist/project.d.ts +0 -88
  250. package/dist/project.js +0 -231
  251. package/dist/published.d.ts +0 -142
  252. package/dist/published.js +0 -164
  253. package/dist/ref-index.d.ts +0 -19
  254. package/dist/ref-index.js +0 -0
  255. package/dist/schema-read.d.ts +0 -23
  256. package/dist/schema-read.js +0 -50
  257. package/dist/schema-version.d.ts +0 -19
  258. package/dist/schema-version.js +0 -34
  259. package/dist/sql-space.d.ts +0 -44
  260. package/dist/sql-space.js +0 -289
  261. package/dist/sql.d.ts +0 -15
  262. package/dist/sql.js +0 -556
  263. package/dist/supabase-asset-store.d.ts +0 -21
  264. package/dist/supabase-asset-store.js +0 -170
  265. package/dist/supabase-entry-store.d.ts +0 -92
  266. package/dist/supabase-entry-store.js +0 -824
  267. package/dist/supabase-member-store.d.ts +0 -21
  268. package/dist/supabase-member-store.js +0 -96
  269. package/dist/supabase-path-index.d.ts +0 -30
  270. package/dist/supabase-path-index.js +0 -138
  271. package/dist/supabase-published-store.d.ts +0 -56
  272. package/dist/supabase-published-store.js +0 -314
  273. package/dist/supabase-store.d.ts +0 -103
  274. package/dist/supabase-store.js +0 -678
  275. package/migrations/20260926120000_smoodly_v1.sql +0 -535
  276. package/migrations/20260926130000_smoodly_v2.sql +0 -26
  277. package/migrations/20260926140000_smoodly_v3.sql +0 -38
  278. package/migrations/20260926150000_smoodly_v4.sql +0 -41
  279. package/migrations/20260929120000_smoodly_v5.sql +0 -56
  280. package/migrations/20260929135523_smoodly_v6.sql +0 -200
  281. package/migrations/20260930040956_smoodly_v7.sql +0 -84
  282. package/migrations/20261007120000_smoodly_v8.sql +0 -154
  283. package/migrations/20261007200000_smoodly_v9.sql +0 -30
  284. package/migrations/20261008120000_smoodly_v10.sql +0 -47
  285. package/migrations/20261009120000_smoodly_v11.sql +0 -49
@@ -1,33 +1,71 @@
1
- import { MEDIA_CACHE_SECONDS } from "../../media.js";
1
+ // The browser half of an upload (spec 2026-10-18 media on R2 and Images §4): the browser measures the file, then
2
+ // sends it in one request to the service's admin media route, the bytes as the body and the rest as query
3
+ // parameters; the service streams it into the bucket and answers the ready asset. A video's poster frame is uploaded
4
+ // first as its own image asset and linked. The send and the measure are injected so the flow is testable without a
5
+ // DOM.
6
+ import { ASSET_REFUSED, assertMediaRules, isAssetRefused } from "../../asset-store.js";
2
7
  const stem = (name) => name.replace(/\.[^.]+$/, "");
3
- export async function uploadFile(ops, file, deps) {
4
- const begun = await ops.beginUpload({ name: file.name, mime: file.type, size: file.size });
5
- if (!begun.ok)
6
- return begun;
7
- try {
8
- await deps.put(begun.data.uploadUrl, file, file.type);
9
- }
10
- catch (e) {
11
- return { ok: false, code: "internal", message: `smoodly: upload failed — ${e instanceof Error ? e.message : String(e)}` };
12
- }
13
- const m = await deps.measure(file, file.type);
8
+ export async function uploadFile(send, file, measure, onProgress) {
9
+ const m = await measure(file, file.type);
14
10
  let posterId;
15
11
  if (m.poster) {
16
- const poster = await uploadFile(ops, new File([m.poster], `${stem(file.name)}-poster.jpg`, { type: "image/jpeg" }), deps);
12
+ const poster = await uploadFile(send, new File([m.poster], `${stem(file.name)}-poster.jpg`, { type: "image/jpeg" }), measure);
17
13
  if (poster.ok)
18
14
  posterId = poster.data.id;
19
15
  }
20
- return ops.finishUpload(begun.data.id, { width: m.width, height: m.height, duration: m.duration, posterId });
16
+ const params = { name: file.name, mime: file.type };
17
+ for (const [key, value] of Object.entries({ width: m.width, height: m.height, duration: m.duration, posterId })) {
18
+ if (value !== undefined)
19
+ params[key] = String(value);
20
+ }
21
+ return send(file, params, onProgress);
22
+ }
23
+ export const uploadQuery = (params) => new URLSearchParams(params).toString();
24
+ /** A send that first applies the space's media rules (its registry's `media`) with the store's own words, so a file
25
+ * the space refuses is never sent; the service checks again, and its word is final. */
26
+ export function checkedSend(send, rules) {
27
+ return async (file, params, onProgress) => {
28
+ try {
29
+ assertMediaRules(rules, { filename: params.name, mime: params.mime, size: file.size });
30
+ }
31
+ catch (e) {
32
+ if (isAssetRefused(e))
33
+ return { ok: false, code: "validation", message: e.message.slice(ASSET_REFUSED.length).trim() };
34
+ throw e;
35
+ }
36
+ return send(file, params, onProgress);
37
+ };
21
38
  }
22
- /** A raw PUT to the signed URL: the body is the file, the type its mime,
23
- * and the year-long cache-control Storage serves back (without the
24
- * header a signed upload lands as `no-cache`). */
25
- export async function putSigned(url, file, mime) {
26
- const res = await fetch(url, {
27
- method: "PUT",
28
- headers: { "content-type": mime, "x-upsert": "false", "cache-control": `max-age=${MEDIA_CACHE_SECONDS}` },
29
- body: file,
39
+ const failed = (message) => ({ ok: false, code: "internal", message });
40
+ /** The admin's send: an XMLHttpRequest PUT (fetch reports no upload progress) to the admin media route with the
41
+ * editor's pass. A pass refused as expired renews once and the file is sent again, as an operation is
42
+ * (client-ops.ts). */
43
+ export function xhrSend(url, token, onExpired) {
44
+ const once = (file, params, onProgress) => new Promise((resolve) => {
45
+ const xhr = new XMLHttpRequest();
46
+ xhr.open("PUT", `${url}?${uploadQuery(params)}`);
47
+ const t = token();
48
+ if (t)
49
+ xhr.setRequestHeader("authorization", `Bearer ${t}`);
50
+ xhr.setRequestHeader("content-type", params.mime || "application/octet-stream");
51
+ xhr.upload.onprogress = (e) => { if (e.lengthComputable && e.total > 0)
52
+ onProgress?.(e.loaded / e.total); };
53
+ xhr.onload = () => {
54
+ try {
55
+ const body = JSON.parse(xhr.responseText);
56
+ if (typeof body === "object" && body !== null && typeof body.ok === "boolean")
57
+ return resolve(body);
58
+ }
59
+ catch { /* answered below */ }
60
+ resolve(failed(`smoodly: the upload answered ${xhr.status} without a result — see the service's log.`));
61
+ };
62
+ xhr.onerror = () => resolve(failed("smoodly: the upload did not reach the service — check the connection and try again."));
63
+ xhr.send(file);
30
64
  });
31
- if (!res.ok)
32
- throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);
65
+ return async (file, params, onProgress) => {
66
+ const r = await once(file, params, onProgress);
67
+ if (r.ok || !r.expired)
68
+ return r;
69
+ return (await onExpired?.()) === true ? once(file, params, onProgress) : r;
70
+ };
33
71
  }
@@ -1,9 +1,6 @@
1
- import type { ResolvedConfig } from "../config.ts";
2
1
  import type { Descriptor } from "../fields.ts";
3
- import type { EntryRecord, EntryStore } from "../entry-store.ts";
4
2
  import { type GlobalSchema } from "../global.ts";
5
3
  /** The merged fields a fresh item starts from: sampled field values, and
6
4
  * for a visual item `$settings` = the section's settings defaults with the
7
5
  * sample's settings values on top. Unsampled fields stay absent. */
8
6
  export declare function initialGlobalFields(item: GlobalSchema, settingsDescriptors: Record<string, Descriptor>): Record<string, unknown>;
9
- export declare function ensureGlobalItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
@@ -1,5 +1,4 @@
1
1
  import { SETTINGS_KEY } from "../global.js";
2
- import { sectionSettings } from "./serialize.js";
3
2
  /** The merged fields a fresh item starts from: sampled field values, and
4
3
  * for a visual item `$settings` = the section's settings defaults with the
5
4
  * sample's settings values on top. Unsampled fields stay absent. */
@@ -21,56 +20,3 @@ export function initialGlobalFields(item, settingsDescriptors) {
21
20
  }
22
21
  return out;
23
22
  }
24
- export async function ensureGlobalItems(entries, config) {
25
- const { default: def, supported } = config.locales;
26
- const out = {};
27
- for (const item of config.registry.globals ?? []) {
28
- let [record] = await entries.list(item.name);
29
- if (!record) {
30
- try {
31
- const settings = item.section !== undefined ? sectionSettings(config, item.section) : {};
32
- record = await entries.create(item.name, initialGlobalFields(item, settings), { locale: def });
33
- }
34
- catch (e) {
35
- const message = e instanceof Error ? e.message : String(e);
36
- // Another admin load won the create: read theirs. Anything else is
37
- // worth saying out loud, and must not take the other items down.
38
- if (message.includes("it is a singleton"))
39
- [record] = await entries.list(item.name);
40
- else
41
- console.warn(`smoodly: could not materialize global item "${item.name}": ${message}`);
42
- if (!record)
43
- continue;
44
- }
45
- }
46
- for (const locale of supported) {
47
- if (record.locales[locale])
48
- continue;
49
- try {
50
- record = await entries.addLocale(item.name, record.id, locale, { from: def });
51
- }
52
- catch (e) {
53
- const message = e instanceof Error ? e.message : String(e);
54
- if (!message.includes("already exists in locale")) {
55
- console.warn(`smoodly: could not materialize global item "${item.name}" in "${locale}": ${message}`);
56
- }
57
- }
58
- }
59
- // Every global item is versioned (decided 2026-09-13). A row published before that has
60
- // no pointers, so its next save would go live: publish it once more here, which snapshots
61
- // what is live and points both pointers at it. Idempotent — pointed rows are skipped.
62
- for (const locale of supported) {
63
- const row = record.locales[locale];
64
- if (!row || row.status !== "published" || row.publishedVersionId !== null)
65
- continue;
66
- try {
67
- record = await entries.setStatus(item.name, record.id, locale, "published");
68
- }
69
- catch (e) {
70
- console.warn(`smoodly: could not version global item "${item.name}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
71
- }
72
- }
73
- out[item.name] = record;
74
- }
75
- return out;
76
- }
@@ -1,44 +1,38 @@
1
- import type { AssetStore } from "../asset-store.ts";
2
1
  import type { ResolvedConfig } from "../config.ts";
3
- import type { EntryStore } from "../entry-store.ts";
4
2
  import type { PresenceConfig } from "../env.ts";
5
- import type { MemberStore } from "../member-store.ts";
6
- import type { PageStore } from "../store.ts";
7
- import type { Projector } from "../project.ts";
8
- import { type AdminAuth } from "./auth.ts";
3
+ import { type KeySet } from "../signed-message.ts";
9
4
  import { type AdminFiles } from "./files.ts";
10
- import { type AdminEffects, type DeployDeps } from "./ops-impl.ts";
11
- import type { AuthConfig } from "./auth-config.ts";
5
+ import type { AdminEffects } from "./ops.ts";
6
+ import type { SignInConfig } from "./auth-config.ts";
12
7
  export type AdminHandlerDeps = {
13
8
  config: ResolvedConfig;
14
- pages: PageStore;
15
- entries: EntryStore;
16
- /** The server gate: verifies a session token and reads the member's role. */
17
- auth: AdminAuth;
18
- /** What the browser needs to sign in: public values only. */
19
- signIn: AuthConfig;
20
- href?: (path: string, locale: string) => string;
21
- /** The media library; without it the assets ops answer "no media store". */
22
- assets?: AssetStore;
23
- /** The admission table; without it members.list answers "no member store". */
24
- members?: MemberStore;
25
- /** The site's `site.projector`: a publish writes the delivery rows before it expires the cache. */
26
- projector: Projector;
27
- /** The content project's public config for editing presence; absent or failed, presence is off. */
9
+ /** Where the browser sends a person to sign in, and the dashboard it links to: public values only. Absent, the page
10
+ * asks Smoodly's API (`GET /v1/auth`, spec 2026-10-16 sign-in and the dashboard §4) once per process and API
11
+ * address. */
12
+ signIn?: SignInConfig;
13
+ /** Test seam for the sign-in fetch; default the global fetch. */
14
+ fetch?: typeof fetch;
15
+ /** Smoodly's API: the admin posts its operations to `${apiUrl}/v1/spaces/${spaceId}/admin/op`. */
16
+ apiUrl: string;
17
+ /** The site's space (SMOODLY_SPACE_ID): the service's admin routes live under it. */
18
+ spaceId: string;
19
+ /** The service's public signing keys; default the set published at `${apiUrl}/v1/signing-keys`, fetched once per
20
+ * process. The revalidate call and the preview grant are verified against it. */
21
+ keys?: KeySet;
22
+ /** Where the browser opens the editing-presence socket; absent or failed, presence is off. */
28
23
  presence?: () => PresenceConfig | Promise<PresenceConfig>;
29
24
  effects?: AdminEffects;
30
25
  /** Where drafts are served, when not at the page's own address (page-data.ts). */
31
26
  previewBase?: string;
32
- /** Static mode (spec 2026-10-09): the deploy operations' deps and the mode the page data names; absent = live. */
33
- deploy?: DeployDeps & {
34
- mode: "live" | "static";
35
- };
27
+ /** How the canvas marks its draft render (page-data.ts); the query by default, the path on Next. */
28
+ canvasMarks?: "path" | "query";
29
+ /** Static mode (spec 2026-10-09) shows the Deploy tile; absent = live. */
30
+ delivery?: "live" | "static";
36
31
  /** The prebuilt files; default: the ones the package's build wrote. Tests pass their own. */
37
32
  files?: AdminFiles;
38
33
  };
39
34
  /** The request came from a page of this site: its Origin names the host the browser asked for. Behind a
40
35
  * proxy that host is X-Forwarded-Host (the first, when a chain appended more), as in Next's own check for
41
- * server actions. No Origin, no operation: every browser sends one with a POST. */
36
+ * server actions. No Origin, no preview: every browser sends one with a POST. */
42
37
  export declare function sameOrigin(request: Request): boolean;
43
- /** `deps` is read on every request, so nothing is read at import and a build with no keys succeeds. */
44
38
  export declare function createAdminHandler(deps: () => AdminHandlerDeps): (request: Request) => Promise<Response>;
@@ -1,12 +1,13 @@
1
1
  // packages/smoodly/src/admin/handler.ts
2
- // The admin's whole server face (spec 2026-10-02 astro cloud fit §4), framework-free: a web Request in, a
2
+ // The admin's whole server face on the site (spec 2026-10-14 §1), framework-free: a web Request in, a
3
3
  // Response out. GET under the admin path is the HTML page, GET under the reserved segment is one of the
4
- // package's files, POST to `<admin path>/_smoodly/op` is an operation — gated by the origin check, then
5
- // by the session (auth.ts). Each framework's adapter supplies the effects (next/admin-route.ts).
4
+ // package's files, POST to `<admin path>/_smoodly/revalidate` is the service expiring tags after a publish
5
+ // (signed with the service's own key, spec 2026-10-16 the control plane §2), POST to `<admin path>/_smoodly/preview`
6
+ // is the browser posting a draft grant the service signed. Operations run in the service. Each framework's adapter supplies the
7
+ // effects (next/admin-route.ts).
6
8
  import { mountMismatch } from "../admin-path.js";
7
- import { gateAdminOp } from "./auth.js";
9
+ import { fetchedKeySet, verifyMessage } from "../signed-message.js";
8
10
  import { loadAdminFiles } from "./files.js";
9
- import { createAdminOps, runAdminOp } from "./ops-impl.js";
10
11
  import { ADMIN_INTERNAL } from "./page-data.js";
11
12
  import { adminErrorHtml, adminPageHtml } from "./page.js";
12
13
  import { serializeAdminConfig } from "./serialize.js";
@@ -19,7 +20,7 @@ const html = (status, body) => new Response(body, {
19
20
  const refused = (code, message) => ({ ok: false, code, message });
20
21
  /** The request came from a page of this site: its Origin names the host the browser asked for. Behind a
21
22
  * proxy that host is X-Forwarded-Host (the first, when a chain appended more), as in Next's own check for
22
- * server actions. No Origin, no operation: every browser sends one with a POST. */
23
+ * server actions. No Origin, no preview: every browser sends one with a POST. */
23
24
  export function sameOrigin(request) {
24
25
  return originOf(request).same;
25
26
  }
@@ -34,16 +35,76 @@ function originOf(request) {
34
35
  catch { /* not a URL: not the same */ }
35
36
  return { same, origin, host };
36
37
  }
37
- /** An operation's body: a whole page draft fits many times over; more than this is not an operation. */
38
- const MAX_BODY_BYTES = 4 * 1024 * 1024;
39
- /** The session token: `Authorization: Bearer <three dot-separated parts>`. Anything else is no token, so
40
- * junk never costs a call to the auth project. */
41
- function bearer(request) {
42
- const match = /^Bearer ([\w-]+\.[\w-]+\.[\w-]+)$/.exec(request.headers.get("authorization") ?? "");
43
- return match ? match[1] : null;
38
+ /** The two routes' bodies: a tag list or a grant. Both are read before anything proves who sent them (the
39
+ * signature covers the body), so the size is the first gate: a declared length above it is refused unread,
40
+ * and a stream without one is cut off when it passes it. */
41
+ const MAX_ROUTE_BODY_BYTES = 256 * 1024;
42
+ const tooLarge = () => json(413, refused("validation", "smoodly: the call is larger than 256 KB."));
43
+ async function cappedText(request) {
44
+ if (Number(request.headers.get("content-length") ?? 0) > MAX_ROUTE_BODY_BYTES)
45
+ return null;
46
+ if (!request.body)
47
+ return "";
48
+ const reader = request.body.getReader();
49
+ const chunks = [];
50
+ let size = 0;
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done)
54
+ break;
55
+ size += value.byteLength;
56
+ if (size > MAX_ROUTE_BODY_BYTES) {
57
+ await reader.cancel();
58
+ return null;
59
+ }
60
+ chunks.push(value);
61
+ }
62
+ const bytes = new Uint8Array(size);
63
+ let at = 0;
64
+ for (const chunk of chunks) {
65
+ bytes.set(chunk, at);
66
+ at += chunk.byteLength;
67
+ }
68
+ return new TextDecoder().decode(bytes);
44
69
  }
45
70
  const decode = (base64) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
71
+ /** Where editors sign in, from the service, per fetch function and API address: one answer kept for the process, and a
72
+ * failure forgotten so the next page asks again. */
73
+ const signIns = new WeakMap();
74
+ const isSignIn = (v) => typeof v === "object" && v !== null && typeof v.signInUrl === "string" && typeof v.consoleUrl === "string";
75
+ function signInOf(d) {
76
+ const doFetch = d.fetch ?? fetch;
77
+ let byApi = signIns.get(doFetch);
78
+ if (!byApi)
79
+ signIns.set(doFetch, (byApi = new Map()));
80
+ const apiUrl = d.apiUrl.replace(/\/+$/, "");
81
+ let asked = byApi.get(apiUrl);
82
+ if (!asked) {
83
+ asked = (async () => {
84
+ const res = await doFetch(`${apiUrl}/v1/auth`, { headers: { accept: "application/json" } });
85
+ const body = (await res.json().catch(() => null));
86
+ if (!res.ok || !body?.ok || !isSignIn(body.data))
87
+ throw new Error(`${apiUrl}/v1/auth answered ${res.status} with no sign-in address`);
88
+ return { signInUrl: body.data.signInUrl, consoleUrl: body.data.consoleUrl };
89
+ })();
90
+ byApi.set(apiUrl, asked);
91
+ asked.catch(() => byApi.delete(apiUrl));
92
+ }
93
+ return asked;
94
+ }
46
95
  /** `deps` is read on every request, so nothing is read at import and a build with no keys succeeds. */
96
+ /** The published key set per API address, so a process fetches it once whatever builds the deps per request. */
97
+ const keySets = new Map();
98
+ const keysOf = (d) => {
99
+ if (d.keys)
100
+ return d.keys;
101
+ let set = keySets.get(d.apiUrl);
102
+ if (!set) {
103
+ set = fetchedKeySet(d.apiUrl);
104
+ keySets.set(d.apiUrl, set);
105
+ }
106
+ return set;
107
+ };
47
108
  export function createAdminHandler(deps) {
48
109
  const registries = new WeakMap();
49
110
  const registryOf = (config) => {
@@ -63,8 +124,7 @@ export function createAdminHandler(deps) {
63
124
  const mismatch = mountMismatch(pathname, base);
64
125
  if (mismatch)
65
126
  return html(500, adminErrorHtml(mismatch));
66
- // A failed lookup (an unmigrated project, an unreachable database) is logged and turns presence off,
67
- // so the shell still reaches sign-in and the schema notice.
127
+ // A glue that throws (a missing env value) is logged and turns presence off, so the shell still reaches sign-in.
68
128
  let presence = null;
69
129
  try {
70
130
  presence = (await d.presence?.()) ?? null;
@@ -72,15 +132,28 @@ export function createAdminHandler(deps) {
72
132
  catch (e) {
73
133
  console.error("smoodly: editing presence is off —", e instanceof Error ? e.message : e);
74
134
  }
135
+ let signIn;
136
+ try {
137
+ signIn = d.signIn ?? (await signInOf(d));
138
+ }
139
+ catch (e) {
140
+ const message = `smoodly: the admin could not reach Smoodly's API for its sign-in values — ${e instanceof Error ? e.message : String(e)}`;
141
+ console.error(message);
142
+ return html(500, adminErrorHtml(message));
143
+ }
75
144
  const files = d.files ?? (await loadAdminFiles());
76
145
  const data = {
77
146
  registry: registryOf(d.config),
78
- auth: d.signIn,
147
+ signIn,
148
+ apiUrl: d.apiUrl.replace(/\/+$/, ""),
149
+ spaceId: d.spaceId,
79
150
  presence,
80
- opUrl: `${base}/${ADMIN_INTERNAL}/op`,
151
+ opUrl: `${d.apiUrl.replace(/\/+$/, "")}/v1/spaces/${encodeURIComponent(d.spaceId)}/admin/op`,
152
+ previewUrl: `${base}/${ADMIN_INTERNAL}/preview`,
81
153
  filesBase: `${base}/${ADMIN_INTERNAL}/${files.hash}`,
82
154
  previewBase: d.previewBase ?? "",
83
- delivery: d.deploy?.mode ?? "live",
155
+ canvasMarks: d.canvasMarks ?? "query",
156
+ delivery: d.delivery ?? "live",
84
157
  };
85
158
  return html(200, adminPageHtml(data));
86
159
  }
@@ -100,37 +173,59 @@ export function createAdminHandler(deps) {
100
173
  },
101
174
  });
102
175
  }
103
- /** The order is the defence: the origin, then the token, then the size, then the session — and only a
104
- * member's body is read and parsed. */
105
- async function op(request, d) {
176
+ /** The service, after a publish: which tags to expire. The signature is the only admission (no origin:
177
+ * it is server to server), and the window keeps a captured call from being replayed. */
178
+ async function revalidate(request, d) {
179
+ const body = await cappedText(request);
180
+ if (body === null)
181
+ return tooLarge();
182
+ const h = request.headers;
183
+ const signed = { ts: Number(h.get("x-smoodly-timestamp")), kid: h.get("x-smoodly-key-id") ?? "", sig: h.get("x-smoodly-signature") ?? "" };
184
+ // The message is verified for the site's own space: a call signed for another names another space and fails.
185
+ const space = h.get("x-smoodly-space");
186
+ if ((space !== null && space !== d.spaceId) || !(await verifyMessage(keysOf(d), "revalidate", d.spaceId, body, signed))) {
187
+ return json(401, refused("unauthorized", "smoodly: the revalidate call is not signed by Smoodly's current key, for another space, or older than a minute."));
188
+ }
189
+ let tags;
190
+ try {
191
+ tags = JSON.parse(body).tags;
192
+ }
193
+ catch {
194
+ return json(400, refused("validation", "smoodly: the revalidate call was not JSON."));
195
+ }
196
+ if (!Array.isArray(tags) || !tags.every((t) => typeof t === "string"))
197
+ return json(400, refused("validation", "smoodly: the revalidate call needs `tags`, a list of strings."));
198
+ for (const tag of tags)
199
+ await d.effects?.revalidate?.(tag);
200
+ return json(200, { ok: true, data: { expired: tags.length } });
201
+ }
202
+ /** The browser, holding a grant the service signed: the draft cookie is set on the site's own origin,
203
+ * so the origin check comes first and the grant second. */
204
+ async function preview(request, d) {
106
205
  const from = originOf(request);
107
- if (!from.same) {
108
- return json(403, refused("unauthorized", `smoodly: operations are accepted from the site's own address only — this request's Origin is ${String(from.origin).slice(0, 200)} and the server was asked at ${from.host}. Behind a proxy, pass the Host header through or set X-Forwarded-Host.`));
206
+ if (!from.same)
207
+ return json(403, refused("unauthorized", `smoodly: the preview route is called from the site's own address only — this request's Origin is ${String(from.origin).slice(0, 200)}.`));
208
+ const text = await cappedText(request);
209
+ if (text === null)
210
+ return tooLarge();
211
+ let sent;
212
+ try {
213
+ sent = JSON.parse(text);
109
214
  }
110
- const token = bearer(request);
111
- if (!token)
112
- return json(401, refused("unauthorized", "smoodly: sign in to use the admin."));
113
- if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) {
114
- return json(413, refused("validation", "smoodly: the operation is larger than 4 MB."));
215
+ catch {
216
+ return json(400, refused("validation", "smoodly: the preview call was not JSON."));
115
217
  }
116
- const base = { config: d.config, pages: d.pages, entries: d.entries, href: d.href, assets: d.assets, members: d.members, projector: d.projector, effects: d.effects, deploy: d.deploy };
117
- let status = 200;
118
- // The gate needs the token alone; the call is read once it has admitted the session.
119
- const result = await gateAdminOp(d.auth, { path: "", args: [], token }, async (_gate, session) => {
120
- let body;
121
- try {
122
- body = await request.json();
123
- }
124
- catch {
125
- status = 400;
126
- return refused("validation", "smoodly: the operation was not JSON.");
127
- }
128
- // The token is the header's alone: a `token` in the body is dropped here.
129
- const sent = typeof body === "object" && body !== null && !Array.isArray(body) ? body : {};
130
- // Ops are built per call so the verified session rides into every store write as `by` (spec 2026-09-13 history §2).
131
- return runAdminOp(createAdminOps({ ...base, actor: session }), { path: sent.path, args: sent.args });
132
- });
133
- return json(!result.ok && result.code === "unauthorized" ? 401 : status, result);
218
+ const action = sent.action === "enable" || sent.action === "disable" ? sent.action : null;
219
+ const grant = sent.grant;
220
+ const ok = action !== null && grant?.space === d.spaceId
221
+ && (await verifyMessage(keysOf(d), "preview", d.spaceId, action, { ts: Number(grant?.ts), kid: String(grant?.kid ?? ""), sig: String(grant?.sig ?? "") }));
222
+ if (!ok)
223
+ return json(401, refused("unauthorized", "smoodly: the preview grant is missing, for another action, or older than a minute — open the page again."));
224
+ if (action === "enable")
225
+ await d.effects?.draft?.enable();
226
+ else
227
+ await d.effects?.draft?.disable();
228
+ return json(200, { ok: true, data: null });
134
229
  }
135
230
  return async function handle(request) {
136
231
  try {
@@ -139,7 +234,11 @@ export function createAdminHandler(deps) {
139
234
  const internal = `${d.config.admin.path}/${ADMIN_INTERNAL}/`;
140
235
  const method = request.method.toUpperCase();
141
236
  if (method === "POST") {
142
- return pathname === `${internal}op` ? await op(request, d) : json(404, refused("not_found", "smoodly: operations are posted to the admin's endpoint."));
237
+ if (pathname === `${internal}revalidate`)
238
+ return await revalidate(request, d);
239
+ if (pathname === `${internal}preview`)
240
+ return await preview(request, d);
241
+ return json(404, refused("not_found", "smoodly: the admin mount serves the page, its files, the revalidate call and the preview grant; operations go to the service."));
143
242
  }
144
243
  if (method !== "GET" && method !== "HEAD")
145
244
  return new Response(null, { status: 405, headers: { allow: "GET, HEAD, POST" } });
@@ -13,15 +13,11 @@ export type HealthDocument = {
13
13
  collection: string | null;
14
14
  findings: HealthFinding[];
15
15
  };
16
- import type { DeliveryStatus } from "../project.ts";
17
- /** `delivery` (DESIGN §3, the document stamp): the delivery rows behind the current stamp, with the recorded one;
18
- * absent only in a report built without a projector (static tests). */
19
16
  export type HealthReport = {
20
17
  documents: HealthDocument[];
21
18
  collections: {
22
19
  name: string;
23
20
  count: number;
24
21
  }[];
25
- delivery?: DeliveryStatus;
26
22
  };
27
23
  export declare function buildHealthReport(registry: AdminRegistry, pages: PageScan[], entries: EntryScan[]): HealthReport;
@@ -1,15 +1,12 @@
1
- export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem, MembersListResult } from "./ops.ts";
1
+ export type { AdminEffects, AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem, MembersListResult } from "./ops.ts";
2
2
  export type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
3
3
  export type { MemberRole } from "../permissions.ts";
4
- export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
5
- export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
6
- export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
4
+ export { fixedNodeSpecs, fixedNodeOf, type FixedNode } from "./fixed-nodes.ts";
5
+ export { initialGlobalFields } from "./global-items.ts";
7
6
  export { makeClientOps } from "./client-ops.ts";
8
- export { gateAdminOp, type AdminAuth } from "./auth.ts";
9
- export { supabaseAdminAuth } from "./supabase-auth.ts";
10
- export type { AuthConfig } from "./auth-config.ts";
7
+ export type { SignInConfig } from "./auth-config.ts";
11
8
  export { serializeAdminConfig } from "./serialize.ts";
12
9
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
13
10
  export * from "./tree-ops.ts";
14
11
  export { validateFields, type FieldError } from "./validate.ts";
15
- export { createSmoodlyContent, SmoodlyContentError, type SmoodlyContent, type ContentDeps } from "../content/content-api.ts";
12
+ export { apiTransport, createSmoodlyContent, SmoodlyContentError, type SmoodlyContent, type ContentDeps, type ContentTransport } from "../content/content-api.ts";
@@ -1,10 +1,7 @@
1
- export { createAdminOps, runAdminOp } from "./ops-impl.js";
2
- export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
3
- export { ensureGlobalItems, initialGlobalFields } from "./global-items.js";
1
+ export { fixedNodeSpecs, fixedNodeOf } from "./fixed-nodes.js";
2
+ export { initialGlobalFields } from "./global-items.js";
4
3
  export { makeClientOps } from "./client-ops.js";
5
- export { gateAdminOp } from "./auth.js";
6
- export { supabaseAdminAuth } from "./supabase-auth.js";
7
4
  export { serializeAdminConfig } from "./serialize.js";
8
5
  export * from "./tree-ops.js";
9
6
  export { validateFields } from "./validate.js";
10
- export { createSmoodlyContent, SmoodlyContentError } from "../content/content-api.js";
7
+ export { apiTransport, createSmoodlyContent, SmoodlyContentError } from "../content/content-api.js";