canopycms 0.0.67 → 0.0.68-int.100

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 (268) hide show
  1. package/README.md +21 -14
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +111 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +10 -1
  23. package/dist/api/github-sync.js +20 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
package/dist/context.d.ts CHANGED
@@ -15,6 +15,10 @@
15
15
  * Only request-time reads on a server deployment resolve a branch workspace —
16
16
  * in dev, the clone under `.canopy-dev/content-branches/`.
17
17
  *
18
+ * A `branch` arrives from the request (a page passing `?branch=`), so only the
19
+ * active branch is ever provisioned here; any other name must already exist and
20
+ * pass the user's branch access before its files are read (`resolveBranch`).
21
+ *
18
22
  * The Next.js adapter wraps this in React `cache()` for per-request memoization
19
23
  * (`canopycms-next/src/context-wrapper.ts`).
20
24
  *
@@ -58,8 +62,12 @@ export interface CanopyBuildContext {
58
62
  * `meta.indexEntry` handed to `extract`, and a collection whose children are
59
63
  * all filtered out is pruned. On the build context and on static deployments
60
64
  * nothing is filtered — the synthetic admin sees everything.
65
+ *
66
+ * Branch: the `branch` option behaves as on `listEntries`.
61
67
  */
62
- buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes>) => Promise<ContentTreeNode<T>[]>;
68
+ buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes> & {
69
+ branch?: string;
70
+ }) => Promise<ContentTreeNode<T>[]>;
63
71
  /**
64
72
  * Every content entry as a flat array.
65
73
  *
@@ -67,13 +75,18 @@ export interface CanopyBuildContext {
67
75
  * cannot `read` are omitted before `extract` runs; on the build context and
68
76
  * static deployments nothing is filtered.
69
77
  *
70
- * Branch: unlike `read`/`readByUrlPath` this takes no `branch` option, always
71
- * listing `defaultActiveBranch ?? defaultBaseBranch ?? 'main'`. In `dev` that
72
- * tracks git HEAD via `refreshActiveBranch()`; in `prod` the refresh is a
73
- * no-op, so it always reads the base branch. See
74
- * `.claude/future-tasks/context-listing-branch-pinning.md`.
78
+ * Branch: with no `branch` it lists the active branch
79
+ * (`defaultActiveBranch ?? defaultBaseBranch`): git HEAD in `dev` when that is
80
+ * unset, otherwise usually the base branch in `prod`. Pass the preview
81
+ * iframe's `?branch=` as `branch`, as for `read`, so an index page previewed
82
+ * on a content branch lists that branch. Any other branch must already exist
83
+ * and, on the request-scoped context, be readable by the user; otherwise, or
84
+ * for an array, the result is empty. It selects nothing at build
85
+ * time or on static deployments.
75
86
  */
76
- listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T>) => Promise<ListEntriesItem<T>[]>;
87
+ listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T> & {
88
+ branch?: string;
89
+ }) => Promise<ListEntriesItem<T>[]>;
77
90
  /**
78
91
  * Content reader, with the auth context applied automatically (admin at build
79
92
  * time).
@@ -83,6 +96,11 @@ export interface CanopyBuildContext {
83
96
  * output, since it reveals the deployment's filesystem layout (home dir, EFS
84
97
  * mount, branch name). It is for build-time reads of colocated artifacts, e.g.
85
98
  * `fs.readFile(path.join(path.dirname(result.meta.physicalPath), 'profile.json'))`.
99
+ *
100
+ * Branch: with no `branch` it reads the active branch, provisioning its workspace if missing.
101
+ * Any other branch must already exist and, on the request-scoped context, be readable by the
102
+ * user; otherwise, or for an array, it throws NOT_FOUND, so a hidden branch reads as a missing
103
+ * one. It selects nothing at build time or on static deployments.
86
104
  */
87
105
  read: <T = unknown>(input: {
88
106
  entryPath: string;
@@ -109,8 +127,8 @@ export interface CanopyBuildContext {
109
127
  *
110
128
  * Null when nothing matches — a collection URL with no index entry (use
111
129
  * buildContentTree), a non-entry path like `/favicon.ico` the slug validator
112
- * rejects, or a path the user may not read, so a FORBIDDEN denial renders as
113
- * a 404 via `notFound()` rather than a 500. Strict `read()` still throws.
130
+ * rejects, a missing branch, or a path or branch the user may not read, so a
131
+ * denial renders as a 404 via `notFound()`, not a 500. Strict `read()` throws.
114
132
  *
115
133
  * `meta.physicalPath` is **server-only**, for the reason given on `read`.
116
134
  * `meta.entryType`/`entryId` come free with path resolution, so one catch-all
package/dist/context.js CHANGED
@@ -1,9 +1,9 @@
1
- import { isDeployedStatic, isBuildMode, STATIC_DEPLOY_USER } from './build-mode.js';
1
+ import { isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER } from './build-mode.js';
2
2
  import { createContentReader } from './content-reader.js';
3
3
  import { ContentStoreError } from './content-store.js';
4
- import { createLogicalPath, parseSlug, resolveBranchPaths } from './paths/index.js';
4
+ import { createLogicalPath, namesNoWorkspace, parseSlug, resolveBranchPaths, } from './paths/index.js';
5
5
  import { resolveUrlPathCandidates } from './url-path-resolver.js';
6
- import { loadOrCreateBranchContext } from './branch-workspace.js';
6
+ import { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace.js';
7
7
  import { buildContentTree as buildContentTreeImpl, } from './content-tree.js';
8
8
  import { listEntries as listEntriesImpl, } from './content-listing.js';
9
9
  import { createDebugLogger } from './utils/debug.js';
@@ -22,6 +22,30 @@ function isLookupFailure(err) {
22
22
  function isPageSwallowable(err) {
23
23
  return isLookupFailure(err) || err.code === 'FORBIDDEN';
24
24
  }
25
+ /**
26
+ * A `branch` option is typed `string` but usually handed over from untyped
27
+ * request input: a repeated `?branch=` arrives as an array, which names no
28
+ * branch, and `URLSearchParams.get` gives null for an absent one, which is no
29
+ * `branch` at all.
30
+ */
31
+ function isBranchOption(branch) {
32
+ return branch == null || typeof branch === 'string';
33
+ }
34
+ /**
35
+ * A branch that already has a workspace, or null. A name that cannot name one
36
+ * is null too (`namesNoWorkspace`): it arrives from the request, so it reads as
37
+ * not-found rather than failing the page.
38
+ */
39
+ async function loadExistingBranch(branchName, mode) {
40
+ try {
41
+ return await loadBranchContext({ branchName, mode });
42
+ }
43
+ catch (err) {
44
+ if (namesNoWorkspace(err))
45
+ return null;
46
+ throw err;
47
+ }
48
+ }
25
49
  /**
26
50
  * Create a Canopy context managing auth + content reading. Framework-agnostic:
27
51
  * the adapter supplies extractUser.
@@ -46,7 +70,66 @@ export function createCanopyContext(options) {
46
70
  // switching branches mid-session updates what getCanopy() serves.
47
71
  await services.refreshActiveBranch();
48
72
  const user = await getUser();
49
- const baseReader = createContentReader({ services });
73
+ // Read once, so every read and listing in this request agrees on it even if
74
+ // a concurrent request's refreshActiveBranch() replaces services.config.
75
+ const activeBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
76
+ // No ACL enforcement at build time, on static deployments, or for the
77
+ // synthetic admin. For the last this is load-bearing, not an optimization:
78
+ // that user has unconditional access anyway (path checks bypass entirely for
79
+ // an Admins-group user — authorization/path.ts), but BUILDING a path checker
80
+ // costs a getSettingsBranchRoot() call, which in modes with a separate
81
+ // settings branch provisions that branch's git workspace: an EFS round trip
82
+ // in prod. `createBuildCanopy` (build-canopy.ts) runs outside a request or
83
+ // Next.js build phase, so neither other guard fires for it, and without this
84
+ // one every such script pays for a settings-workspace clone it never needed —
85
+ // and hard-fails where that workspace cannot be provisioned. Compared by
86
+ // reference to the STATIC_DEPLOY_USER singleton, not by group membership, so
87
+ // a real authenticated admin at request time still goes through the real
88
+ // check.
89
+ const enforceAcls = !(isDeployedStatic(services.config) ||
90
+ isBuildMode() ||
91
+ user === STATIC_DEPLOY_USER);
92
+ /**
93
+ * `branch` when it names a branch other than the active one, else undefined:
94
+ * no `branch`, an empty one, the active one named explicitly, or any name
95
+ * when reading from the checkout, where it selects nothing.
96
+ */
97
+ const otherBranch = (branch) => branch && branch !== activeBranch && !readsFromCheckout(services.config) ? branch : undefined;
98
+ /**
99
+ * The workspace for the active branch (undefined) or another named one, or
100
+ * null when it yields nothing this user may see.
101
+ *
102
+ * The active branch is provisioned if missing. Any other name is load-only,
103
+ * never provisioned — a name with no workspace is null (`loadExistingBranch`) — and its branch
104
+ * access, held in its own metadata, is checked here, before its schema or
105
+ * content files are read, so a denied branch is indistinguishable from a
106
+ * missing one. The active branch's access is left to the caller: a listing
107
+ * of it lists nothing, and a strict `read` of it throws FORBIDDEN.
108
+ */
109
+ const resolveBranch = async (requested) => {
110
+ if (requested === undefined) {
111
+ return loadOrCreateBranchContext({
112
+ config: services.config,
113
+ branchName: activeBranch,
114
+ mode: services.config.mode,
115
+ createdBy: 'canopycms-context',
116
+ remoteUrl: services.config.defaultRemoteUrl,
117
+ });
118
+ }
119
+ const context = await loadExistingBranch(requested, services.config.mode);
120
+ if (!context)
121
+ return null;
122
+ if (enforceAcls && !services.checkBranchAccess(context, user).allowed)
123
+ return null;
124
+ return context;
125
+ };
126
+ // The reader resolves every branch through resolveBranch; a null reads as
127
+ // NOT_FOUND. Reads from the checkout bypass the resolver inside the reader.
128
+ const baseReader = createContentReader({
129
+ services,
130
+ defaultBranch: activeBranch,
131
+ getBranchContext: (branchName) => resolveBranch(otherBranch(branchName)),
132
+ });
50
133
  // Injects the user and validates strings → branded types at this boundary.
51
134
  // `extra` carries options that are NOT part of the public `read` surface —
52
135
  // today just readByUrlPath's URL-addressability gate. A separate inner
@@ -63,10 +146,13 @@ export function createCanopyContext(options) {
63
146
  }
64
147
  slug = slugResult.slug;
65
148
  }
149
+ if (!isBranchOption(input.branch)) {
150
+ throw new ContentStoreError(`Branch not found: ${String(input.branch)}`, 'NOT_FOUND');
151
+ }
66
152
  const readInput = {
67
153
  entryPath,
68
154
  slug,
69
- branch: input.branch,
155
+ branch: input.branch ?? undefined,
70
156
  user,
71
157
  resolveReferences: input.resolveReferences ?? true,
72
158
  ...extra,
@@ -119,80 +205,78 @@ export function createCanopyContext(options) {
119
205
  }
120
206
  return null;
121
207
  };
122
- /** Resolve branch workspace and schema — shared by buildContentTree and listEntries. Memoized per getContext call. */
123
- let schemaContextPromise = null;
124
- const resolveSchemaContextImpl = async () => {
125
- const operatingMode = services.config.mode;
126
- const defaultBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
127
- const branchContext = await loadOrCreateBranchContext({
128
- config: services.config,
129
- branchName: defaultBranch,
130
- mode: operatingMode,
131
- createdBy: 'canopycms-context',
132
- remoteUrl: services.config.defaultRemoteUrl,
133
- });
134
- const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
135
- const contentRootName = services.config.contentRoot || 'content';
136
- const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
137
- return { branchContext, branchRoot, flatSchema, contentRootName };
138
- };
139
- const resolveSchemaContext = () => {
140
- if (!schemaContextPromise) {
141
- schemaContextPromise = resolveSchemaContextImpl();
142
- }
143
- return schemaContextPromise;
144
- };
145
208
  /**
146
- * Path-ACL predicate for the batch reads (listEntries / buildContentTree),
147
- * memoized per getContext call like the schema context above. Without it an
148
- * unfiltered listing on this request-scoped, ACL-enforcing context would
149
- * disclose full entry `data` for paths the user cannot `read()` directly.
150
- *
151
- * `services.createContentAccessChecker` is the shared batch primitive
152
- * (api/entries.ts uses it too): it resolves the request-constant work —
153
- * branch access, the settings/permissions root, the rule set — once, and
154
- * returns a synchronous per-path check, so the per-entry cost is an admin
155
- * short-circuit or one minimatch per configured rule, with no extra I/O.
156
- *
157
- * The empty object (no predicate → unfiltered) at build time, on static
158
- * deployments, and for the synthetic admin is load-bearing, not an
159
- * optimization. That user has unconditional access anyway (path checks
160
- * bypass entirely for an Admins-group user — authorization/path.ts), but
161
- * BUILDING the checker costs a getSettingsBranchRoot() call, which in modes
162
- * with a separate settings branch provisions that branch's git workspace: an
163
- * EFS round trip in prod. `createBuildCanopy` (build-canopy.ts) runs outside
164
- * a request or Next.js build phase, so neither other guard fires for it, and
165
- * without this one every such script pays for a settings-workspace clone it
166
- * never needed — and hard-fails where that workspace cannot be provisioned.
209
+ * What a batch read (listEntries / buildContentTree) lists from: the branch's
210
+ * schema plus its path-ACL predicate, or null when the branch yields nothing
211
+ * this user may see. The branch resolves as for `read` (`resolveBranch`). At
212
+ * build time and on static deployments `branch` selects nothing, as for
213
+ * `read` (see the module doc).
167
214
  *
168
- * Compared by reference to the STATIC_DEPLOY_USER singleton, not by group
169
- * membership, so it stays scoped to the synthetic build identity: a real
170
- * authenticated admin at request time still goes through the real check.
171
- *
172
- * Deliberately NOT wrapped in try/catch — createContentAccessChecker is
173
- * fail-loud by contract, and swallowing would serve an unfiltered listing.
215
+ * Memoized per getContext call and per branch name, on the in-flight
216
+ * promise; a null result is memoized too, so one request answers a given
217
+ * name consistently. The memo is request-scoped, so it carries no
218
+ * generation protocol (docs/concurrency.md, "Adding a call-scoped memo").
174
219
  */
175
- let visibilityPromise = null;
176
- const resolveVisibilityImpl = async () => {
177
- if (isDeployedStatic(services.config) || isBuildMode() || user === STATIC_DEPLOY_USER)
178
- return {};
179
- const { branchContext, branchRoot } = await resolveSchemaContext();
180
- const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
181
- return { shouldInclude: (physicalPath) => checkAccess(physicalPath, 'read').allowed };
220
+ const listingSources = new Map();
221
+ const resolveListingSource = (branch) => {
222
+ if (!isBranchOption(branch))
223
+ return Promise.resolve(null);
224
+ const requested = otherBranch(branch);
225
+ const key = requested ?? activeBranch;
226
+ let source = listingSources.get(key);
227
+ if (!source) {
228
+ source = resolveListingSourceImpl(requested);
229
+ listingSources.set(key, source);
230
+ }
231
+ return source;
182
232
  };
183
- const resolveVisibility = () => {
184
- if (!visibilityPromise) {
185
- visibilityPromise = resolveVisibilityImpl();
233
+ const resolveListingSourceImpl = async (requested) => {
234
+ const operatingMode = services.config.mode;
235
+ const branchContext = await resolveBranch(requested);
236
+ if (!branchContext)
237
+ return null;
238
+ // resolveBranch has already checked any other branch.
239
+ if (requested === undefined &&
240
+ enforceAcls &&
241
+ !services.checkBranchAccess(branchContext, user).allowed) {
242
+ return null;
186
243
  }
187
- return visibilityPromise;
244
+ const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
245
+ const contentRootName = services.config.contentRoot || 'content';
246
+ const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
247
+ if (!enforceAcls)
248
+ return { branchRoot, flatSchema, contentRootName, visibility: {} };
249
+ // The path-ACL predicate keeps an unfiltered listing on this ACL-enforcing
250
+ // context from disclosing entry `data` for paths the user cannot `read()`.
251
+ // `services.createContentAccessChecker` is the shared batch primitive
252
+ // (api/entries.ts uses it too): it resolves the request-constant work —
253
+ // branch access, the settings/permissions root, the rule set — once, and
254
+ // returns a synchronous per-path check, so the per-entry cost is an admin
255
+ // short-circuit or one minimatch per configured rule, with no extra I/O.
256
+ //
257
+ // Deliberately NOT wrapped in try/catch — createContentAccessChecker is
258
+ // fail-loud by contract, and swallowing would serve an unfiltered listing.
259
+ const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
260
+ return {
261
+ branchRoot,
262
+ flatSchema,
263
+ contentRootName,
264
+ visibility: { shouldInclude: (logicalPath) => checkAccess(logicalPath, 'read').allowed },
265
+ };
188
266
  };
189
267
  const buildContentTree = async (options) => {
190
- const { branchRoot, flatSchema, contentRootName } = await resolveSchemaContext();
191
- return buildContentTreeImpl(branchRoot, flatSchema, contentRootName, options, await resolveVisibility());
268
+ const { branch, ...treeOptions } = options ?? {};
269
+ const source = await resolveListingSource(branch);
270
+ if (!source)
271
+ return [];
272
+ return buildContentTreeImpl(source.branchRoot, source.flatSchema, source.contentRootName, treeOptions, source.visibility);
192
273
  };
193
274
  const listEntries = async (options) => {
194
- const { branchRoot, flatSchema, contentRootName } = await resolveSchemaContext();
195
- return listEntriesImpl(branchRoot, flatSchema, contentRootName, options, await resolveVisibility());
275
+ const { branch, ...listOptions } = options ?? {};
276
+ const source = await resolveListingSource(branch);
277
+ if (!source)
278
+ return [];
279
+ return listEntriesImpl(source.branchRoot, source.flatSchema, source.contentRootName, listOptions, source.visibility);
196
280
  };
197
281
  return {
198
282
  read,
@@ -62,11 +62,17 @@ export interface BranchManagerProps {
62
62
  /** Current user context for permission checks */
63
63
  user?: UserContext;
64
64
  onSelect?: (name: string) => void;
65
+ /** Awaited before the spinner starts, so a confirmation it shows never reads as a hung create. `false` abandons the create. */
66
+ onBeforeCreate?: () => Promise<boolean>;
67
+ /**
68
+ * Resolving `false` means the branch was not created, so the form stays open
69
+ * with the user's values; any other return (including none) closes it.
70
+ */
65
71
  onCreate?: (branch: {
66
72
  name: string;
67
73
  title?: string;
68
74
  description?: string;
69
- }) => void;
75
+ }) => Promise<boolean> | void;
70
76
  onDelete?: (name: string) => void;
71
77
  onSubmit?: (name: string) => void;
72
78
  onWithdraw?: (name: string) => void;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useState } from 'react';
3
+ import { useRef, useState } from 'react';
4
4
  import { Badge, Button, Group, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
5
5
  import { BranchComments } from './comments/BranchComments.js';
6
6
  import { UserBadge } from './components/UserBadge.js';
@@ -76,26 +76,41 @@ const statusColorMap = {
76
76
  editing: { color: 'brand', variant: 'light' },
77
77
  submitted: { color: 'green', variant: 'light' },
78
78
  };
79
- export const BranchManager = ({ branches, mode, user, onSelect, onCreate, onDelete, onSubmit, onWithdraw, onRequestChanges, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
79
+ export const BranchManager = ({ branches, mode, user, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onRequestChanges, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
80
80
  const supportsBranching = clientOperatingStrategy(mode).supportsBranching();
81
81
  const [showCreateForm, setShowCreateForm] = useState(false);
82
82
  const [newBranchName, setNewBranchName] = useState('');
83
83
  const [newBranchTitle, setNewBranchTitle] = useState('');
84
84
  const [newBranchDescription, setNewBranchDescription] = useState('');
85
- const handleCreate = () => {
86
- if (!newBranchName.trim())
85
+ const [creating, setCreating] = useState(false);
86
+ // Read synchronously: state would not yet show a first click to a second one in the same tick.
87
+ const creatingRef = useRef(false);
88
+ const handleCreate = async () => {
89
+ if (creatingRef.current || !newBranchName.trim())
87
90
  return;
88
- onCreate?.({
89
- name: newBranchName.trim(),
90
- title: newBranchTitle.trim() || undefined,
91
- description: newBranchDescription.trim() || undefined,
92
- });
93
- setNewBranchName('');
94
- setNewBranchTitle('');
95
- setNewBranchDescription('');
96
- setShowCreateForm(false);
91
+ creatingRef.current = true;
92
+ try {
93
+ if (onBeforeCreate && !(await onBeforeCreate()))
94
+ return;
95
+ setCreating(true);
96
+ const created = await onCreate?.({
97
+ name: newBranchName.trim(),
98
+ title: newBranchTitle.trim() || undefined,
99
+ description: newBranchDescription.trim() || undefined,
100
+ });
101
+ if (created === false)
102
+ return;
103
+ setNewBranchName('');
104
+ setNewBranchTitle('');
105
+ setNewBranchDescription('');
106
+ setShowCreateForm(false);
107
+ }
108
+ finally {
109
+ creatingRef.current = false;
110
+ setCreating(false);
111
+ }
97
112
  };
98
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : 'Create New Branch' }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", radius: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch Name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title (optional)", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description (optional)", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, "data-testid": "branch-description-textarea" }), _jsx(Button, { onClick: handleCreate, disabled: !newBranchName.trim(), fullWidth: true, "data-testid": "create-branch-submit", children: "Create Branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: branches.map((b) => {
113
+ return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : 'Create New Branch' }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", radius: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch Name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title (optional)", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description (optional)", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create Branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: branches.map((b) => {
99
114
  const statusColor = statusColorMap[b.status] ?? {
100
115
  color: 'neutral',
101
116
  variant: 'light',
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { CanopyClientConfig } from '../config/index.js';
3
3
  import type { EditorProps } from './Editor.js';
4
- export interface CanopyEditorProps extends Omit<EditorProps, 'collections' | 'previewBaseByCollection' | 'title' | 'subtitle' | 'themeOptions' | 'entries' | 'contentRoot' | 'operatingMode'> {
4
+ export interface CanopyEditorProps extends Omit<EditorProps, 'collections' | 'previewBaseByCollection' | 'previewPrefix' | 'title' | 'subtitle' | 'themeOptions' | 'entries' | 'contentRoot' | 'operatingMode'> {
5
5
  config: CanopyClientConfig;
6
6
  entries?: EditorProps['entries'];
7
7
  }
@@ -4,13 +4,9 @@ import { useMemo } from 'react';
4
4
  import { Editor } from './Editor.js';
5
5
  import { ApiClientProvider } from './context/index.js';
6
6
  import { EditorAuthGate } from './EditorAuthGate.js';
7
- import { buildEditorCollections, buildPreviewBaseByCollection } from './editor-config.js';
7
+ import { buildEditorCollections } from './editor-config.js';
8
8
  export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialValues, renderPreview, onCreateEntry, branchName, customRenderers, }) => {
9
9
  const collections = useMemo(() => buildEditorCollections(config.flatSchema), [config.flatSchema]);
10
- const previewBase = useMemo(() => ({
11
- ...buildPreviewBaseByCollection(config, config.flatSchema),
12
- ...(config.editor?.previewBase ?? {}),
13
- }), [config]);
14
10
  // Empty string = branchless start; useBranchManager adopts the server's
15
11
  // detected default branch on the first branches.list() load.
16
12
  const resolvedBranchName = branchName ?? config.defaultActiveBranch ?? config.defaultBaseBranch ?? '';
@@ -19,6 +15,6 @@ export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialV
19
15
  const resolvedTheme = config.editor?.theme ?? undefined;
20
16
  // The gate owns the SWRProvider (keyed by user id), so it sits between the API client and the
21
17
  // editor: see EditorAuthGate for what a signed-out or lapsed session renders.
22
- return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: previewBase, assetBaseUrl: config.assetBaseUrl, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
18
+ return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: config.editor?.previewBase, previewPrefix: config.editor?.previewPrefix, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
23
19
  };
24
20
  export default CanopyEditor;
@@ -57,11 +57,12 @@ export interface EditorProps {
57
57
  renderPreview?: (entry: EditorEntry, value: FormValue | undefined) => React.ReactNode;
58
58
  onCreateEntry?: (collectionPath: LogicalPath) => Promise<void> | void;
59
59
  themeOptions?: CanopyThemeOptions;
60
- previewBaseByCollection?: Record<string, string>;
60
+ /** `editor.previewBase` from config (see `buildPreviewSrc`); `false` marks entries with no page. */
61
+ previewBaseByCollection?: Record<string, string | false>;
62
+ /** `editor.previewPrefix` from config: put in front of every preview iframe `src` (see `buildPreviewSrc`). */
63
+ previewPrefix?: string;
61
64
  currentUser?: string;
62
65
  canResolveComments?: boolean;
63
- /** `media.publicBaseUrl` from config - prefixed onto asset URLs the editor builds (MediaLibrary/ImageField/MDX image dialog). Undefined means root-relative (editor and site share an origin). */
64
- assetBaseUrl?: string;
65
66
  /**
66
67
  * `CanopyClientConfig.basePath` - the deployment prefix the host Next.js app is served under
67
68
  * (e.g. `/preview-123`). Prefixed onto every preview iframe `src` this component builds via