canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  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 +118 -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 +19 -1
  23. package/dist/api/github-sync.js +57 -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/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -5,7 +5,7 @@
5
5
  * AUTO-GENERATED by scripts/generate-client.ts; do not edit this file manually
6
6
  * (changes will be overwritten), edit the generator's templates instead.
7
7
  */
8
- import type { BranchDeleteResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
8
+ import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
9
9
  import type { BranchMergeResponse } from './branch-status.js';
10
10
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
11
11
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
@@ -25,6 +25,12 @@ export interface ApiClientOptions {
25
25
  baseUrl?: string;
26
26
  /** Custom fetch implementation, e.g. a mock in tests. */
27
27
  fetch?: typeof fetch;
28
+ /**
29
+ * End request paths with `/` (by `withTrailingSlash`'s rule), matching a Next host built with
30
+ * `trailingSlash: true`. Defaults to the value `withCanopy` inlines at build time (see
31
+ * `readTrailingSlashEnv`), else false.
32
+ */
33
+ trailingSlash?: boolean;
28
34
  /**
29
35
  * Called whenever a response comes back 401: the credential is no longer accepted. A
30
36
  * notification, not a retry; the editor's auth gate uses it to show sign-in.
@@ -44,12 +50,13 @@ export interface ApiClientOptions {
44
50
  export declare class CanopyApiClient {
45
51
  private baseUrl;
46
52
  private fetchFn;
53
+ private trailingSlash;
47
54
  private onUnauthorized;
48
55
  readonly branches: {
49
56
  /** GET /branches */
50
57
  list: () => Promise<BranchListResponse>;
51
58
  /** POST /branches */
52
- create: (body: CreateBranchBody) => Promise<BranchResponse>;
59
+ create: (body: CreateBranchBody) => Promise<BranchListItemResponse>;
53
60
  /** DELETE /:branch */
54
61
  delete: (params: Record<string, string>) => Promise<BranchDeleteResponse>;
55
62
  /** PATCH /:branch/access */
@@ -57,17 +64,17 @@ export declare class CanopyApiClient {
57
64
  };
58
65
  readonly workflow: {
59
66
  /** POST /:branch/withdraw */
60
- withdraw: (params: Record<string, string>) => Promise<BranchResponse>;
67
+ withdraw: (params: Record<string, string>) => Promise<BranchListItemResponse>;
61
68
  /** POST /:branch/request-changes */
62
- requestChanges: (params: Record<string, string>) => Promise<BranchResponse>;
69
+ requestChanges: (params: Record<string, string>) => Promise<BranchListItemResponse>;
63
70
  /** POST /:branch/approve */
64
- approve: (params: Record<string, string>) => Promise<BranchResponse>;
71
+ approve: (params: Record<string, string>) => Promise<BranchListItemResponse>;
65
72
  /** POST /:branch/mark-merged */
66
73
  markMerged: (params: Record<string, string>) => Promise<BranchMergeResponse>;
67
74
  /** GET /:branch/status */
68
75
  getStatus: (params: Record<string, string>) => Promise<BranchResponse>;
69
76
  /** POST /:branch/submit */
70
- submit: (params: Record<string, string>) => Promise<BranchResponse>;
77
+ submit: (params: Record<string, string>) => Promise<BranchListItemResponse>;
71
78
  };
72
79
  readonly comments: {
73
80
  /** GET /:branch/comments */
@@ -179,5 +186,10 @@ export declare class CanopyApiClient {
179
186
  private buildPath;
180
187
  private request;
181
188
  }
189
+ /**
190
+ * Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
191
+ * error page), so its status says nothing about the API itself.
192
+ */
193
+ export declare function isNonApiResponse(result: object): boolean;
182
194
  /** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
183
195
  export declare function createApiClient(options?: ApiClientOptions): CanopyApiClient;
@@ -6,6 +6,7 @@
6
6
  * (changes will be overwritten), edit the generator's templates instead.
7
7
  */
8
8
  import { computeContentSha256Hex } from './request-body-hash.js';
9
+ import { readTrailingSlashEnv, withTrailingSlash } from '../utils/url-prefix.js';
9
10
  /**
10
11
  * Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
11
12
  * returning that endpoint's ApiResponse.
@@ -260,6 +261,7 @@ export class CanopyApiClient {
260
261
  this.baseUrl = options.baseUrl ?? '/api/canopycms';
261
262
  // An unbound fetch throws "Illegal invocation" in browsers; Node has no window.
262
263
  this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
264
+ this.trailingSlash = options.trailingSlash ?? readTrailingSlashEnv();
263
265
  this.onUnauthorized = options.onUnauthorized;
264
266
  }
265
267
  buildPath(template, params) {
@@ -292,7 +294,7 @@ export class CanopyApiClient {
292
294
  return result;
293
295
  }
294
296
  async request(method, path, body, headers = {}) {
295
- const url = `${this.baseUrl}${path}`;
297
+ const url = `${this.baseUrl}${this.trailingSlash ? withTrailingSlash(path) : path}`;
296
298
  const requestHeaders = {
297
299
  ...headers,
298
300
  };
@@ -324,21 +326,86 @@ export class CanopyApiClient {
324
326
  if (requestBody !== undefined) {
325
327
  init.body = requestBody;
326
328
  }
327
- const response = await this.fetchFn(url, init);
329
+ let response = await this.fetchFn(url, init);
330
+ // A body from in front of the API (a proxy or CDN error page) may be empty, not JSON, or
331
+ // not an ApiResponse; every such body becomes an `ok: false` ApiResponse, never a throw.
332
+ let parsed = await response.json().catch(() => undefined);
333
+ // A 429 no handler wrote is a throttle in front of the API, such as Lambda's concurrency
334
+ // cap, which answers before the function runs. The request was never handled, so
335
+ // resending it is safe for writes too.
336
+ for (let attempt = 0; response.status === 429 && !isApiResponseBody(parsed); attempt++) {
337
+ const delay = throttleRetryDelayMs(response, attempt);
338
+ if (delay === undefined)
339
+ break;
340
+ await sleep(delay);
341
+ response = await this.fetchFn(url, init);
342
+ parsed = await response.json().catch(() => undefined);
343
+ }
328
344
  if (response.status === 401) {
329
345
  this.onUnauthorized?.();
330
- // A 401 is an auth answer whatever its body: one from in front of the API (a proxy) may
331
- // not be an ApiResponse, or not JSON. Always return it as one rather than throwing.
332
- const body = await response.json().catch(() => undefined);
333
- const error = typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
334
- ? body.error
335
- : 'Unauthorized';
336
- return { ok: false, status: 401, error };
346
+ return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
337
347
  }
338
- const payload = await response.json();
339
- // All responses use ApiResponse format: { ok, status, data?, error? }
340
- return payload;
348
+ if (isApiResponseBody(parsed))
349
+ return parsed;
350
+ const converted = {
351
+ ok: false,
352
+ status: response.status,
353
+ error: errorFromBody(parsed) ?? `Unexpected response from server (HTTP ${response.status})`,
354
+ };
355
+ convertedResponses.add(converted);
356
+ return converted;
357
+ }
358
+ }
359
+ /**
360
+ * Base wait before each resend of a throttled request. A throttle from a burst of cold starts
361
+ * clears as they finish, so the waits span a few seconds.
362
+ */
363
+ const THROTTLE_RETRY_DELAYS_MS = [250, 1000, 3000];
364
+ /** The longest `Retry-After` honoured; a throttle asking for longer is reported, not resent. */
365
+ const MAX_RETRY_AFTER_MS = 5000;
366
+ /**
367
+ * The wait before resend `attempt`, or undefined when there is to be no resend: the attempts
368
+ * are used up, or `Retry-After` asks for longer than {@link MAX_RETRY_AFTER_MS}. The base delay,
369
+ * or `Retry-After` when longer, plus up to half the base again, so requests throttled in one
370
+ * burst do not resend in lockstep.
371
+ */
372
+ function throttleRetryDelayMs(response, attempt) {
373
+ const base = THROTTLE_RETRY_DELAYS_MS[attempt];
374
+ if (base === undefined)
375
+ return undefined;
376
+ // Delta-seconds only; an HTTP-date or a blank value falls back to the base delay. A custom
377
+ // `fetch` option may return a Response-like without headers.
378
+ const header = response.headers?.get('retry-after')?.trim();
379
+ const retryAfter = header ? Number(header) : Number.NaN;
380
+ let wait = base;
381
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) {
382
+ const ms = retryAfter * 1000;
383
+ if (ms > MAX_RETRY_AFTER_MS)
384
+ return undefined;
385
+ wait = Math.max(ms, base);
341
386
  }
387
+ return wait + Math.random() * base * 0.5;
388
+ }
389
+ function sleep(ms) {
390
+ return new Promise((resolve) => setTimeout(resolve, ms));
391
+ }
392
+ const convertedResponses = new WeakSet();
393
+ /**
394
+ * Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
395
+ * error page), so its status says nothing about the API itself.
396
+ */
397
+ export function isNonApiResponse(result) {
398
+ return convertedResponses.has(result);
399
+ }
400
+ /** The `error` string of a JSON body, when it has one. */
401
+ function errorFromBody(body) {
402
+ return typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
403
+ ? body.error
404
+ : undefined;
405
+ }
406
+ /** Whether a parsed body has the `{ ok, status, ... }` shape every handler returns. */
407
+ function isApiResponseBody(body) {
408
+ return typeof body === 'object' && body !== null && 'ok' in body && typeof body.ok === 'boolean';
342
409
  }
343
410
  /** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
344
411
  export function createApiClient(options) {
@@ -40,11 +40,12 @@ export interface WriteContentBody {
40
40
  data?: Record<string, unknown>;
41
41
  body?: string;
42
42
  /**
43
- * OCC / create-intent token. Omit for a blind write (no opinion). A number
44
- * from a prior read/write response rejects the write with 409 if the file
45
- * has changed since. `null` means "this entry must not already exist" —
46
- * the create path uses this so a create against an existing slug is
47
- * rejected with 409 instead of silently overwriting it.
43
+ * OCC token. A number from a prior read/write response makes this an update,
44
+ * rejected with 409 if the file's mtime no longer matches (a file deleted
45
+ * since is written anew). `null` or omitted makes it
46
+ * a create, rejected with 409 if the entry already exists. There is no blind
47
+ * write: without a token, "no conflict detection" would be indistinguishable
48
+ * from "lost the token", so an update has to prove which version it read.
48
49
  */
49
50
  expectedVersion?: number | null;
50
51
  }
@@ -5,7 +5,7 @@ import { ReferenceValidator } from '../validation/reference-validator.js';
5
5
  import { findUnknownKeys, mergeBodyIntoData, normalizeReferenceValues, validateEntryData, } from '../validation/entry-validator.js';
6
6
  import { validateEntryLinks } from '../validation/entry-link-validator.js';
7
7
  import { branchNameSchema, logicalPathSchema, slugSchema } from './validators.js';
8
- import { parseSlug } from '../paths/index.js';
8
+ import { entryLogicalPath, parseSlug } from '../paths/index.js';
9
9
  import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
10
10
  import { isDataOnlyFormat } from '../utils/format.js';
11
11
  /**
@@ -52,7 +52,7 @@ const writeContentBodySchema = z.object({
52
52
  format: z.enum(['json', 'md', 'mdx', 'yaml']),
53
53
  data: boundedContentDataSchema.optional(),
54
54
  body: z.string().max(MAX_CONTENT_BODY_CHARS).optional(),
55
- // null = create-intent ("must not already exist"); see WriteContentBody.
55
+ // null or omitted = create ("must not already exist"); see WriteContentBody.
56
56
  expectedVersion: z.number().nullish(),
57
57
  });
58
58
  const validateReferencesParamsSchema = z.object({
@@ -80,24 +80,27 @@ const readContentHandler = async (gc, ctx, req, params) => {
80
80
  const logicalPathSegments = parseApiPath(params.path, contentRoot);
81
81
  let schemaItem;
82
82
  let slug;
83
- let relativePath;
84
83
  try {
85
84
  const resolved = store.resolvePath(logicalPathSegments);
86
85
  schemaItem = resolved.schemaItem;
87
86
  slug = resolved.slug;
88
- const pathResult = await store.resolveDocumentPath(schemaItem.logicalPath, slug);
89
- relativePath = pathResult.relativePath;
87
+ // Runs the store's slug and traversal checks before the permission check.
88
+ await store.resolveDocumentPath(schemaItem.logicalPath, slug);
90
89
  }
91
90
  catch (err) {
92
91
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
93
92
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
94
93
  }
95
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, relativePath, req.user, 'read');
96
- if (!access.allowed) {
94
+ // One checker for the entry and every reference target it resolves, so a reference cannot
95
+ // carry a target's data past the rules that would refuse a direct read of it.
96
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
97
+ if (!checkAccess(entryLogicalPath(schemaItem.logicalPath, slug), 'read').allowed) {
97
98
  return { ok: false, status: 403, error: 'Forbidden' };
98
99
  }
99
100
  try {
100
- const doc = await store.read(schemaItem.logicalPath, slug);
101
+ const doc = await store.read(schemaItem.logicalPath, slug, {
102
+ referenceAccess: (targetPath) => checkAccess(targetPath, 'read').allowed,
103
+ });
101
104
  return { ok: true, status: 200, data: doc };
102
105
  }
103
106
  catch (err) {
@@ -117,19 +120,18 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
117
120
  const logicalPathSegments = parseApiPath(params.path, contentRoot);
118
121
  let schemaItem;
119
122
  let slug;
120
- let relativePath;
121
123
  try {
122
124
  const resolved = store.resolvePath(logicalPathSegments);
123
125
  schemaItem = resolved.schemaItem;
124
126
  slug = resolved.slug;
125
- const pathResult = await store.resolveDocumentPath(schemaItem.logicalPath, slug);
126
- relativePath = pathResult.relativePath;
127
+ // Runs the store's slug and traversal checks before the permission check.
128
+ await store.resolveDocumentPath(schemaItem.logicalPath, slug);
127
129
  }
128
130
  catch (err) {
129
131
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
130
132
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
131
133
  }
132
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, relativePath, req.user, 'edit');
134
+ const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'edit');
133
135
  if (!access.allowed) {
134
136
  return { ok: false, status: 403, error: 'Forbidden' };
135
137
  }
@@ -198,20 +200,21 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
198
200
  }
199
201
  const data = body.data ?? {};
200
202
  const isDataOnly = isDataOnlyFormat(body.format);
203
+ // The store's own `undefined` (blind write) is never reachable from here: an omitted token is
204
+ // a create, so an update that lost its token 409s instead of overwriting unchecked.
205
+ const expectedVersion = body.expectedVersion ?? null;
206
+ const createConflictError = body.expectedVersion === null
207
+ ? `An entry with slug "${slug}" already exists`
208
+ : `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
201
209
  try {
202
210
  const exists = await store.documentExists(schemaItem.logicalPath, slug);
203
- // Create-intent guard: a create request (expectedVersion === null) against a slug that
204
- // already has content must never silently overwrite it — short-circuit with 409 before field
205
- // validation runs, so the error names the real problem instead of "field is required" or a
206
- // bare conflict. store.write() re-enforces this itself inside its per-entry lock against a
207
- // fresh stat (the race-safe authoritative check); this is just a cheaper fast path for the
208
- // common case.
209
- if (body.expectedVersion === null && exists) {
210
- return {
211
- ok: false,
212
- status: 409,
213
- error: `An entry with slug "${slug}" already exists`,
214
- };
211
+ // Create guard: a create against a slug that already has content must never silently
212
+ // overwrite it — short-circuit with 409 before field validation runs, so the error names the
213
+ // real problem instead of "field is required" or a bare conflict. store.write() re-enforces
214
+ // this itself inside its per-entry lock against a fresh stat (the race-safe authoritative
215
+ // check); this is just a cheaper fast path for the common case.
216
+ if (expectedVersion === null && exists) {
217
+ return { ok: false, status: 409, error: createConflictError };
215
218
  }
216
219
  // [SLUG] Create-only routability check: `writeContentParamsSchema.path`'s `parseLogicalPath`
217
220
  // has no charset rule, and `ContentStore.resolvePath` only lowercases the slug — so this is
@@ -360,13 +363,13 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
360
363
  ? {
361
364
  format: body.format,
362
365
  data: normalizedData ?? {},
363
- expectedVersion: body.expectedVersion,
366
+ expectedVersion,
364
367
  }
365
368
  : {
366
369
  format: body.format,
367
370
  data: normalizedData,
368
371
  body: body.body ?? '',
369
- expectedVersion: body.expectedVersion,
372
+ expectedVersion,
370
373
  };
371
374
  // Pass the resolved entryTypeName (not the raw, possibly-omitted
372
375
  // params.entryType) so the store's own format check agrees with the type
@@ -412,12 +415,8 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
412
415
  }
413
416
  // Race-safe fallback: the early `exists` check above catches the common case; this covers a
414
417
  // collision that lands between that check and store.write()'s in-lock stat.
415
- if (body.expectedVersion === null) {
416
- return {
417
- ok: false,
418
- status: 409,
419
- error: `An entry with slug "${slug}" already exists`,
420
- };
418
+ if (expectedVersion === null) {
419
+ return { ok: false, status: 409, error: createConflictError };
421
420
  }
422
421
  return {
423
422
  ok: false,
@@ -443,19 +442,19 @@ const validateReferencesHandler = async (gc, ctx, req, params, body) => {
443
442
  const contentRoot = ctx.services.config.contentRoot || 'content';
444
443
  const logicalPathSegments = parseApiPath(params.path, contentRoot);
445
444
  let schemaItem;
446
- let relativePath;
445
+ let slug;
447
446
  try {
448
447
  const resolved = store.resolvePath(logicalPathSegments);
449
448
  schemaItem = resolved.schemaItem;
450
- const slug = resolved.slug;
451
- const pathResult = await store.resolveDocumentPath(schemaItem.logicalPath, slug);
452
- relativePath = pathResult.relativePath;
449
+ slug = resolved.slug;
450
+ // Runs the store's slug and traversal checks before the permission check.
451
+ await store.resolveDocumentPath(schemaItem.logicalPath, slug);
453
452
  }
454
453
  catch (err) {
455
454
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
456
455
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
457
456
  }
458
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, relativePath, req.user, 'read');
457
+ const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
459
458
  if (!access.allowed) {
460
459
  return { ok: false, status: 403, error: 'Forbidden' };
461
460
  }
@@ -510,22 +509,26 @@ const renameEntryHandler = async (gc, ctx, req, params, body) => {
510
509
  const logicalPathSegments = parseApiPath(params.path, contentRoot);
511
510
  let schemaItem;
512
511
  let currentSlug;
513
- let relativePath;
514
512
  try {
515
513
  const resolved = store.resolvePath(logicalPathSegments);
516
514
  schemaItem = resolved.schemaItem;
517
515
  currentSlug = resolved.slug;
518
- const pathResult = await store.resolveDocumentPath(schemaItem.logicalPath, currentSlug);
519
- relativePath = pathResult.relativePath;
516
+ // Runs the store's slug and traversal checks before the permission check.
517
+ await store.resolveDocumentPath(schemaItem.logicalPath, currentSlug);
520
518
  }
521
519
  catch (err) {
522
520
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
523
521
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
524
522
  }
525
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, relativePath, req.user, 'edit');
523
+ const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, currentSlug), req.user, 'edit');
526
524
  if (!access.allowed) {
527
525
  return { ok: false, status: 403, error: 'Forbidden' };
528
526
  }
527
+ // A rename creates the entry at its new path, so that path needs edit access too.
528
+ const destinationAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, body.newSlug), req.user, 'edit');
529
+ if (!destinationAccess.allowed) {
530
+ return { ok: false, status: 403, error: 'Forbidden' };
531
+ }
529
532
  try {
530
533
  const result = await store.renameEntry(schemaItem.logicalPath, currentSlug, body.newSlug);
531
534
  return { ok: true, status: 200, data: { newPath: result.newPath } };
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { z } from 'zod';
3
3
  import { BranchSyncingError, ContentConflictError, ContentStore, ContentStoreError, } from '../content-store.js';
4
4
  import { defineEndpoint } from './route-builder.js';
5
- import { normalizeFilesystemPath, parseSlug, parseLogicalPath } from '../paths/index.js';
5
+ import { entryLogicalPath, normalizeFilesystemPath, parseSlug, parseLogicalPath } from '../paths/index.js';
6
6
  import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
7
7
  import { createDebugLogger } from '../utils/debug.js';
8
8
  import { resolveEntryTitle } from '../utils/title-field.js';
@@ -81,7 +81,7 @@ const listCollectionEntriesRecursive = async (root, targetPath, flatCollections)
81
81
  const filterWithAccessControl = (items, checkAccess, search) => {
82
82
  const results = [];
83
83
  for (const item of items) {
84
- const readAccess = checkAccess(item.physicalPath, 'read');
84
+ const readAccess = checkAccess(item.logicalPath, 'read');
85
85
  if (!readAccess.allowed)
86
86
  continue;
87
87
  if (search) {
@@ -90,7 +90,7 @@ const filterWithAccessControl = (items, checkAccess, search) => {
90
90
  continue;
91
91
  }
92
92
  // Compute edit access only for items that survive the read + search filters.
93
- const editAccess = checkAccess(item.physicalPath, 'edit');
93
+ const editAccess = checkAccess(item.logicalPath, 'edit');
94
94
  results.push({ ...item, canEdit: editAccess.allowed });
95
95
  }
96
96
  return results;
@@ -255,11 +255,9 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
255
255
  };
256
256
  }
257
257
  const entrySlug = slugResult.slug;
258
- // Resolve the real physical path before checking permissions
259
- let physicalPath;
258
+ // Resolution runs the store's slug and traversal checks before the permission check.
260
259
  try {
261
- const resolved = await contentStore.resolveDocumentPath(collectionLogicalPath, entrySlug);
262
- physicalPath = resolved.relativePath;
260
+ await contentStore.resolveDocumentPath(collectionLogicalPath, entrySlug);
263
261
  }
264
262
  catch (err) {
265
263
  if (isNotFoundError(err)) {
@@ -272,7 +270,7 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
272
270
  }
273
271
  throw err;
274
272
  }
275
- const editAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, physicalPath, req.user, 'edit');
273
+ const editAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(collection.logicalPath, entrySlug), req.user, 'edit');
276
274
  if (!editAccess.allowed) {
277
275
  return {
278
276
  ok: false,
@@ -301,13 +299,16 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
301
299
  // C6: order cleanup is best-effort hygiene after the entry is already deleted, so no
302
300
  // failure here may turn an otherwise-successful delete into an error (a retry would
303
301
  // just 404). Surface it as a warning instead (mirrors content.ts's `validationWarnings`).
304
- const reason = err instanceof SchemaStoreBusyError ? 'schema is busy' : getErrorMessage(err);
305
- log.warn('delete-entry', 'Skipped order cleanup', {
306
- collectionPath,
307
- contentId,
308
- error: getErrorMessage(err),
309
- });
310
- orderCleanupWarning = `Entry deleted, but the collection order list could not be updated (${sanitizeErrorMessage(reason)}); it will still list this entry until the next schema change.`;
302
+ // An `'unknown'` outcome means the update ran to completion: nothing to warn of.
303
+ if (!(err instanceof SchemaStoreBusyError && err.outcome === 'unknown')) {
304
+ const reason = err instanceof SchemaStoreBusyError ? 'schema is busy' : getErrorMessage(err);
305
+ log.warn('delete-entry', 'Skipped order cleanup', {
306
+ collectionPath,
307
+ contentId,
308
+ error: getErrorMessage(err),
309
+ });
310
+ orderCleanupWarning = `Entry deleted, but the collection order list could not be updated (${sanitizeErrorMessage(reason)}); it will still list this entry until the next schema change.`;
311
+ }
311
312
  }
312
313
  }
313
314
  }
@@ -1,5 +1,6 @@
1
1
  import type { ApiContext } from './types.js';
2
2
  import type { BranchContext, SyncStatus } from '../types.js';
3
+ import { type SubmissionEditor } from '../submission-attribution.js';
3
4
  /**
4
5
  * The caller uses this to update branch metadata.
5
6
  */
@@ -8,12 +9,29 @@ export interface GitHubSyncResult {
8
9
  prNumber?: number;
9
10
  syncStatus?: SyncStatus;
10
11
  }
12
+ /** What the PR body records about a submit. */
13
+ export interface SubmissionRecord {
14
+ submitter?: SubmissionEditor;
15
+ changedPaths: readonly string[];
16
+ }
11
17
  /**
12
18
  * Uses githubService directly if available, otherwise queues a task for the worker.
19
+ *
20
+ * The body is the canopycms PR section (submission-attribution.ts): an existing
21
+ * PR keeps whatever a human wrote outside it.
13
22
  */
14
- export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext): Promise<GitHubSyncResult>;
23
+ export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext, submission: SubmissionRecord): Promise<GitHubSyncResult>;
15
24
  /**
16
25
  * Used by withdraw and request-changes. Uses githubService directly if
17
26
  * available, otherwise queues a task.
18
27
  */
19
28
  export declare function syncConvertToDraft(ctx: ApiContext, context: BranchContext): Promise<void>;
29
+ /**
30
+ * Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
31
+ * put it there: a content branch reaches GitHub only through submit, which always opens a PR,
32
+ * so a recorded PR number is the proof. Without one, a same-named GitHub branch is someone
33
+ * else's (the create-time collision check is best-effort) and is never touched.
34
+ *
35
+ * Returns a client-facing warning when the delete could not be done or queued; never throws.
36
+ */
37
+ export declare function syncDeleteRemoteBranch(ctx: ApiContext, context: BranchContext): Promise<string | undefined>;
@@ -3,14 +3,23 @@ import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
3
3
  import { clientOperatingStrategy } from '../operating-mode/index.js';
4
4
  import { getErrorMessage } from '../utils/error.js';
5
5
  import { sanitizeBranchName } from '../paths/branch-name.js';
6
+ import { buildPrSection, mergePrSection } from '../submission-attribution.js';
7
+ import { isRefAlreadyGoneError } from '../github-service.js';
6
8
  /**
7
9
  * Uses githubService directly if available, otherwise queues a task for the worker.
10
+ *
11
+ * The body is the canopycms PR section (submission-attribution.ts): an existing
12
+ * PR keeps whatever a human wrote outside it.
8
13
  */
9
- export async function syncSubmitPr(ctx, context) {
14
+ export async function syncSubmitPr(ctx, context, submission) {
10
15
  const { githubService } = ctx.services;
11
16
  const mode = ctx.services.config.mode;
12
17
  const prTitle = context.branch.title || `Submit ${context.branch.name}`;
13
- const prBody = context.branch.description || '';
18
+ const prSection = buildPrSection({
19
+ description: context.branch.description,
20
+ submitter: submission.submitter,
21
+ changedPaths: submission.changedPaths,
22
+ });
14
23
  // Target the fork point recorded at branch creation when available.
15
24
  const baseBranch = context.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
16
25
  if (!clientOperatingStrategy(mode).supportsPullRequests()) {
@@ -29,9 +38,12 @@ export async function syncSubmitPr(ctx, context) {
29
38
  if (githubService) {
30
39
  try {
31
40
  if (context.branch.pullRequestNumber) {
41
+ // Read before writing: the update keeps the human text around the
42
+ // canopycms section, and the same read says whether the PR is a draft.
43
+ const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
32
44
  await githubService.updatePullRequest(context.branch.pullRequestNumber, {
33
45
  title: prTitle,
34
- body: prBody,
46
+ body: mergePrSection(pr.body, prSection),
35
47
  });
36
48
  // Best-effort draft->ready conversion. This branch updates a known
37
49
  // PR number directly (not through createOrUpdatePR), so it doesn't
@@ -42,7 +54,6 @@ export async function syncSubmitPr(ctx, context) {
42
54
  // consistent with createOrUpdatePullRequest's best-effort handling
43
55
  // in github-service.ts.
44
56
  try {
45
- const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
46
57
  if (pr.draft) {
47
58
  await githubService.convertToReady(context.branch.pullRequestNumber);
48
59
  }
@@ -73,8 +84,9 @@ export async function syncSubmitPr(ctx, context) {
73
84
  head: context.branch.name,
74
85
  base: baseBranch,
75
86
  title: prTitle,
76
- body: prBody,
87
+ body: prSection,
77
88
  markReadyIfDraft: true,
89
+ mergeSectionIntoBody: true,
78
90
  });
79
91
  return {
80
92
  prUrl: result.url,
@@ -105,13 +117,12 @@ export async function syncSubmitPr(ctx, context) {
105
117
  payload: {
106
118
  branch: context.branch.name,
107
119
  title: prTitle,
108
- body: prBody,
120
+ body: prSection,
121
+ mergeSectionIntoBody: true,
109
122
  baseBranch,
110
123
  pullRequestNumber: context.branch.pullRequestNumber,
111
- // Content submits are an explicit "ready for review" action — convert
112
- // a pre-existing draft PR to ready, unlike the settings-branch sync
113
- // path (services.ts commitToSettingsBranch), which enqueues the same
114
- // action without this flag.
124
+ // A content submit is an explicit "ready for review" action: convert a
125
+ // pre-existing draft PR to ready.
115
126
  markReadyIfDraft: true,
116
127
  },
117
128
  });
@@ -146,6 +157,42 @@ export async function syncConvertToDraft(ctx, context) {
146
157
  });
147
158
  }
148
159
  }
160
+ /**
161
+ * Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
162
+ * put it there: a content branch reaches GitHub only through submit, which always opens a PR,
163
+ * so a recorded PR number is the proof. Without one, a same-named GitHub branch is someone
164
+ * else's (the create-time collision check is best-effort) and is never touched.
165
+ *
166
+ * Returns a client-facing warning when the delete could not be done or queued; never throws.
167
+ */
168
+ export async function syncDeleteRemoteBranch(ctx, context) {
169
+ if (!context.branch.pullRequestNumber)
170
+ return undefined;
171
+ const { githubService } = ctx.services;
172
+ const branch = context.branch.name;
173
+ if (githubService) {
174
+ try {
175
+ await githubService.deleteBranch(branch);
176
+ }
177
+ catch (err) {
178
+ if (isRefAlreadyGoneError(err))
179
+ return undefined;
180
+ console.error(`CanopyCMS: Failed to delete GitHub branch for ${branch}:`, getErrorMessage(err));
181
+ return 'The branch could not be deleted on GitHub; delete it there by hand';
182
+ }
183
+ return undefined;
184
+ }
185
+ const mode = ctx.services.config.mode;
186
+ if (!clientOperatingStrategy(mode).supportsPullRequests())
187
+ return undefined;
188
+ const result = await enqueueGitHubTask(ctx, context, {
189
+ action: 'delete-remote-branch',
190
+ payload: { branch, pullRequestNumber: context.branch.pullRequestNumber },
191
+ });
192
+ return result.syncStatus === 'sync-failed'
193
+ ? 'Deleting the branch on GitHub could not be queued; delete it there by hand'
194
+ : undefined;
195
+ }
149
196
  async function enqueueGitHubTask(ctx, context, task) {
150
197
  const taskDir = getTaskQueueDir(ctx.services.config);
151
198
  try {
@@ -1,5 +1,5 @@
1
1
  export type { ApiResponse, ApiRequest, ApiContext } from './types.js';
2
- export type { BranchResponse, BranchListResponse, BranchDeleteResponse, CreateBranchBody, UpdateBranchAccessBody, } from './branch.js';
2
+ export type { BranchResponse, BranchListItemResponse, BranchListResponse, BranchDeleteResponse, CreateBranchBody, UpdateBranchAccessBody, } from './branch.js';
3
3
  export type { BranchMergeResponse } from './branch-merge.js';
4
4
  export type { CommentsResponse, AddCommentResponse, ResolveCommentResponse } from './comments.js';
5
5
  export type { EntriesResponse, ListEntriesResponse, CollectionItem, ListEntriesParams, DeleteEntryResponse, } from './entries.js';