canopycms 0.0.68-int.112 → 0.0.68-int.114

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 (180) hide show
  1. package/dist/ai/generate.d.ts +4 -4
  2. package/dist/ai/generate.js +75 -14
  3. package/dist/ai/types.d.ts +12 -0
  4. package/dist/api/branch.js +13 -13
  5. package/dist/auth/cache.d.ts +1 -1
  6. package/dist/auth/cache.js +1 -1
  7. package/dist/auth/file-based-auth-cache.d.ts +8 -0
  8. package/dist/auth/file-based-auth-cache.js +11 -0
  9. package/dist/authorization/branch.d.ts +1 -1
  10. package/dist/authorization/branch.js +2 -1
  11. package/dist/cli/cli.js +162 -26
  12. package/dist/cli/generate-ai-content.js +157 -23
  13. package/dist/content-reader.js +2 -1
  14. package/dist/context.js +2 -1
  15. package/dist/editor/BranchManager.js +13 -17
  16. package/dist/editor/CommentsPanel.js +7 -6
  17. package/dist/editor/Editor.js +152 -30
  18. package/dist/editor/EditorAuthGate.js +2 -2
  19. package/dist/editor/EditorPanes.d.ts +10 -0
  20. package/dist/editor/EditorPanes.js +32 -8
  21. package/dist/editor/FormRenderer.d.ts +10 -0
  22. package/dist/editor/FormRenderer.js +92 -73
  23. package/dist/editor/PreviewFrame.d.ts +3 -1
  24. package/dist/editor/PreviewFrame.js +72 -9
  25. package/dist/editor/admin/SystemHealthPanel.js +27 -24
  26. package/dist/editor/branch-status.d.ts +16 -0
  27. package/dist/editor/branch-status.js +28 -0
  28. package/dist/editor/comments/BranchComments.js +1 -1
  29. package/dist/editor/comments/EntryComments.d.ts +3 -0
  30. package/dist/editor/comments/EntryComments.js +3 -3
  31. package/dist/editor/comments/FieldWrapper.d.ts +3 -0
  32. package/dist/editor/comments/FieldWrapper.js +2 -2
  33. package/dist/editor/comments/InlineCommentThread.js +2 -1
  34. package/dist/editor/comments/ThreadCarousel.js +2 -2
  35. package/dist/editor/components/ConfirmDeleteModal.js +1 -1
  36. package/dist/editor/components/EditorCrashScreen.js +1 -1
  37. package/dist/editor/components/EditorHeader.d.ts +2 -0
  38. package/dist/editor/components/EditorHeader.js +15 -36
  39. package/dist/editor/components/EditorSidebar.d.ts +5 -6
  40. package/dist/editor/components/EditorSidebar.js +2 -2
  41. package/dist/editor/components/EntryCreateModal.js +1 -1
  42. package/dist/editor/components/NoEditPermissionNotice.d.ts +3 -2
  43. package/dist/editor/components/NoEditPermissionNotice.js +3 -2
  44. package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
  45. package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
  46. package/dist/editor/components/RenameEntryModal.js +1 -1
  47. package/dist/editor/components/StagedChangesDrawer.d.ts +37 -0
  48. package/dist/editor/components/StagedChangesDrawer.js +75 -0
  49. package/dist/editor/components/UndoToastMessage.d.ts +14 -0
  50. package/dist/editor/components/UndoToastMessage.js +9 -0
  51. package/dist/editor/components/UserBadge.js +9 -5
  52. package/dist/editor/copy.d.ts +14 -0
  53. package/dist/editor/copy.js +14 -0
  54. package/dist/editor/fields/BlockField.d.ts +5 -0
  55. package/dist/editor/fields/BlockField.js +38 -32
  56. package/dist/editor/fields/CodeField.d.ts +2 -0
  57. package/dist/editor/fields/CodeField.js +5 -2
  58. package/dist/editor/fields/DateTimeField.d.ts +2 -0
  59. package/dist/editor/fields/DateTimeField.js +5 -2
  60. package/dist/editor/fields/FieldCrashFallback.d.ts +3 -3
  61. package/dist/editor/fields/FieldCrashFallback.js +4 -4
  62. package/dist/editor/fields/FieldLabel.d.ts +24 -0
  63. package/dist/editor/fields/FieldLabel.js +9 -0
  64. package/dist/editor/fields/ImageField.d.ts +4 -0
  65. package/dist/editor/fields/ImageField.js +17 -8
  66. package/dist/editor/fields/InlineGroupField.js +4 -3
  67. package/dist/editor/fields/MarkdownField.d.ts +6 -0
  68. package/dist/editor/fields/MarkdownField.js +41 -11
  69. package/dist/editor/fields/MdxImageDialog.js +1 -1
  70. package/dist/editor/fields/NumberField.d.ts +2 -0
  71. package/dist/editor/fields/NumberField.js +4 -2
  72. package/dist/editor/fields/NumberListField.d.ts +2 -0
  73. package/dist/editor/fields/NumberListField.js +4 -2
  74. package/dist/editor/fields/ObjectField.d.ts +1 -0
  75. package/dist/editor/fields/ObjectField.js +5 -4
  76. package/dist/editor/fields/ReferenceField.d.ts +2 -0
  77. package/dist/editor/fields/ReferenceField.js +9 -3
  78. package/dist/editor/fields/SelectField.d.ts +2 -0
  79. package/dist/editor/fields/SelectField.js +8 -2
  80. package/dist/editor/fields/StringListField.d.ts +2 -0
  81. package/dist/editor/fields/StringListField.js +5 -2
  82. package/dist/editor/fields/TextField.d.ts +2 -0
  83. package/dist/editor/fields/TextField.js +5 -2
  84. package/dist/editor/fields/ToggleField.d.ts +2 -0
  85. package/dist/editor/fields/ToggleField.js +6 -3
  86. package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
  87. package/dist/editor/fields/list-item-keys.d.ts +8 -0
  88. package/dist/editor/fields/list-item-keys.js +50 -0
  89. package/dist/editor/group-manager/GroupCard.d.ts +2 -0
  90. package/dist/editor/group-manager/GroupCard.js +2 -2
  91. package/dist/editor/group-manager/GroupForm.js +1 -1
  92. package/dist/editor/group-manager/InternalGroupsTab.d.ts +1 -0
  93. package/dist/editor/group-manager/InternalGroupsTab.js +2 -2
  94. package/dist/editor/group-manager/MemberList.js +1 -1
  95. package/dist/editor/group-manager/hooks/useGroupState.d.ts +3 -0
  96. package/dist/editor/group-manager/hooks/useGroupState.js +19 -2
  97. package/dist/editor/group-manager/index.js +34 -5
  98. package/dist/editor/group-manager/types.d.ts +3 -1
  99. package/dist/editor/hooks/useBranchActions.js +2 -2
  100. package/dist/editor/hooks/useBranchManager.js +36 -22
  101. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  102. package/dist/editor/hooks/useDraftManager.js +27 -5
  103. package/dist/editor/hooks/useEditorLayout.d.ts +45 -3
  104. package/dist/editor/hooks/useEditorLayout.js +97 -7
  105. package/dist/editor/hooks/usePermissionManager.js +4 -3
  106. package/dist/editor/hooks/useUserMetadata.js +12 -1
  107. package/dist/editor/media/AssetCard.js +1 -1
  108. package/dist/editor/media/CropStep.js +6 -1
  109. package/dist/editor/media/MediaLibraryBody.js +3 -3
  110. package/dist/editor/permission-manager/PermissionEditor.js +3 -3
  111. package/dist/editor/permission-manager/index.js +9 -3
  112. package/dist/editor/permission-manager/types.d.ts +3 -1
  113. package/dist/editor/preview-bridge.js +2 -2
  114. package/dist/editor/schema-editor/CollectionEditor.js +1 -1
  115. package/dist/editor/schema-editor/EntryTypeEditor.js +1 -1
  116. package/dist/editor/theme.d.ts +1 -1
  117. package/dist/editor/theme.js +11 -11
  118. package/dist/editor/undo-removal.d.ts +44 -0
  119. package/dist/editor/undo-removal.js +129 -0
  120. package/dist/editor/user-display.d.ts +4 -0
  121. package/dist/editor/user-display.js +5 -0
  122. package/dist/editor/utils/confirm-modal.d.ts +8 -0
  123. package/dist/editor/utils/confirm-modal.js +12 -0
  124. package/dist/entry-link-resolver.d.ts +11 -1
  125. package/dist/entry-link-resolver.js +65 -5
  126. package/dist/git-manager.d.ts +6 -6
  127. package/dist/git-manager.js +18 -17
  128. package/dist/github-service.d.ts +25 -1
  129. package/dist/github-service.js +65 -6
  130. package/dist/http/handler.js +2 -1
  131. package/dist/static/seo.d.ts +5 -5
  132. package/dist/static/seo.js +5 -5
  133. package/dist/system-users.d.ts +12 -0
  134. package/dist/system-users.js +12 -0
  135. package/dist/types.d.ts +9 -1
  136. package/dist/utils/error.js +7 -0
  137. package/dist/utils/escape-text.d.ts +7 -0
  138. package/dist/utils/escape-text.js +27 -0
  139. package/dist/version.d.ts +1 -1
  140. package/dist/version.js +1 -1
  141. package/dist/worker/branch-policy.d.ts +124 -0
  142. package/dist/worker/branch-policy.js +243 -0
  143. package/dist/worker/bundle-header.d.ts +117 -0
  144. package/dist/worker/bundle-header.js +253 -0
  145. package/dist/worker/cms-worker.d.ts +79 -141
  146. package/dist/worker/cms-worker.js +144 -266
  147. package/dist/worker/gateway-egress.d.ts +43 -0
  148. package/dist/worker/gateway-egress.js +209 -0
  149. package/dist/worker/gateway-instance.d.ts +42 -0
  150. package/dist/worker/gateway-instance.js +129 -0
  151. package/dist/worker/git-process.d.ts +81 -0
  152. package/dist/worker/git-process.js +234 -0
  153. package/dist/worker/git-sync.d.ts +13 -10
  154. package/dist/worker/git-sync.js +205 -93
  155. package/dist/worker/github-auth.d.ts +7 -7
  156. package/dist/worker/github-auth.js +12 -14
  157. package/dist/worker/github-gateway-http.d.ts +62 -0
  158. package/dist/worker/github-gateway-http.js +977 -0
  159. package/dist/worker/github-gateway-wire.d.ts +589 -0
  160. package/dist/worker/github-gateway-wire.js +484 -0
  161. package/dist/worker/github-gateway.d.ts +238 -0
  162. package/dist/worker/github-gateway.js +726 -0
  163. package/dist/worker/github-mirror-upkeep.d.ts +75 -0
  164. package/dist/worker/github-mirror-upkeep.js +266 -0
  165. package/dist/worker/github-mirror.d.ts +314 -68
  166. package/dist/worker/github-mirror.js +872 -229
  167. package/dist/worker/github-transfer.d.ts +56 -0
  168. package/dist/worker/github-transfer.js +444 -0
  169. package/dist/worker/rebase.d.ts +1 -1
  170. package/dist/worker/rebase.js +1 -6
  171. package/dist/worker/self-checks.d.ts +107 -0
  172. package/dist/worker/self-checks.js +358 -0
  173. package/dist/worker/shared-repo-git.d.ts +22 -3
  174. package/dist/worker/shared-repo-git.js +53 -16
  175. package/dist/worker/stale-mirror.d.ts +82 -0
  176. package/dist/worker/stale-mirror.js +130 -0
  177. package/dist/worker/task-runner.d.ts +4 -2
  178. package/dist/worker/task-runner.js +109 -148
  179. package/dist/worker/worker-context.d.ts +18 -40
  180. package/package.json +1 -1
@@ -43,9 +43,9 @@ export interface GenerateResult {
43
43
  * and produces per-entry files, per-collection all.md files,
44
44
  * bundle files, and a manifest.
45
45
  *
46
- * A reference may only show a target that is itself exported, so every entry is read and
47
- * filtered first, which settles the exported set. Only then are references to anything outside
48
- * it masked, entry transforms run, and markdown rendered, in rounds that repeat while a render
49
- * fails (see the loop below).
46
+ * A reference or body `entry:` link may only show a target that is itself exported, so every
47
+ * entry is read and filtered first, which settles the exported set. Only then are body links
48
+ * resolved for output, references to anything outside the set masked, entry transforms run, and markdown
49
+ * rendered, in rounds that repeat while a render fails (see the loop below).
50
50
  */
51
51
  export declare function generateAIContent(options: GenerateOptions): Promise<GenerateResult>;
@@ -15,6 +15,7 @@ import { getErrorMessage, isNodeError } from '../utils/error.js';
15
15
  import { entryToMarkdown, frontmatterTitle } from './json-to-markdown.js';
16
16
  import { createReferenceRendering, createReferenceTargetResolver, maskUnexportedTargets, resolveReferenceFields, } from './references.js';
17
17
  import { resolveEntryLinksInText } from '../entry-link-resolver.js';
18
+ import { DEFAULT_SEO_FIELD_NAMES, isNoindexEntry } from '../static/seo.js';
18
19
  /**
19
20
  * Generate all AI content from the content store.
20
21
  *
@@ -22,13 +23,14 @@ import { resolveEntryLinksInText } from '../entry-link-resolver.js';
22
23
  * and produces per-entry files, per-collection all.md files,
23
24
  * bundle files, and a manifest.
24
25
  *
25
- * A reference may only show a target that is itself exported, so every entry is read and
26
- * filtered first, which settles the exported set. Only then are references to anything outside
27
- * it masked, entry transforms run, and markdown rendered, in rounds that repeat while a render
28
- * fails (see the loop below).
26
+ * A reference or body `entry:` link may only show a target that is itself exported, so every
27
+ * entry is read and filtered first, which settles the exported set. Only then are body links
28
+ * resolved for output, references to anything outside the set masked, entry transforms run, and markdown
29
+ * rendered, in rounds that repeat while a render fails (see the loop below).
29
30
  */
30
31
  export async function generateAIContent(options) {
31
32
  const { store, flatSchema, contentRoot, config, entryLinkUrl, generatedAt, buildId } = options;
33
+ assertValidNoindexConfig(config);
32
34
  const files = new Map();
33
35
  const idIndex = await store.idIndex();
34
36
  const read = {
@@ -69,13 +71,23 @@ export async function generateAIContent(options) {
69
71
  mountPath: config?.mountPath ?? '/ai',
70
72
  files: exported,
71
73
  });
72
- // An entry whose render throws is not in the export after all, so references to it are masked
73
- // like any other left-out target, and the entries that changed run their transforms and render
74
- // again. That can fail another entry, so this repeats until a round fails none; each round
75
- // removes at least one entry, so it ends.
74
+ // An entry whose render throws is not in the export after all, so references and body links to
75
+ // it are withheld like any other left-out target, and the entries that changed run their
76
+ // transforms and render again. That can fail another entry, so this repeats until a round fails
77
+ // none; each round removes at least one entry, so it ends.
76
78
  let round = allPending;
77
79
  for (;;) {
78
80
  for (const pending of round) {
81
+ if (pending.body !== undefined) {
82
+ const linked = new Set();
83
+ pending.entry.body = resolveEntryLinksInText(pending.body, idIndex, contentRoot, entryLinkUrl, {
84
+ isLinkable: (id) => {
85
+ linked.add(id);
86
+ return exported.has(id);
87
+ },
88
+ });
89
+ pending.linkedIds = [...linked];
90
+ }
79
91
  maskUnexportedTargets(pending.entry, exported);
80
92
  // Fold in adopter-supplied markdown (e.g. a colocated sibling artifact), on data as masked
81
93
  pending.entry.appendedSections = undefined;
@@ -84,11 +96,19 @@ export async function generateAIContent(options) {
84
96
  const failed = renderAll(round, config, references);
85
97
  if (failed.length === 0)
86
98
  break;
99
+ const failedIds = new Set();
87
100
  for (const pending of failed) {
88
- if (pending.contentId !== null)
89
- exported.delete(pending.contentId);
101
+ if (pending.contentId === null)
102
+ continue;
103
+ exported.delete(pending.contentId);
104
+ failedIds.add(pending.contentId);
90
105
  }
91
- round = allPending.filter((pending) => isRendered(pending) && maskUnexportedTargets(pending.entry, exported));
106
+ round = allPending.filter((pending) => {
107
+ if (!isRendered(pending))
108
+ return false;
109
+ const masked = maskUnexportedTargets(pending.entry, exported);
110
+ return masked || pending.linkedIds.some((id) => failedIds.has(id));
111
+ });
92
112
  }
93
113
  const manifestCollections = collectionNodes.map((node) => emitCollection(node, files));
94
114
  const manifestRootEntries = emitEntries(rootEntries, files);
@@ -166,6 +186,7 @@ async function collectCollection(read, collection, flatSchema) {
166
186
  /** Read, resolve and filter the entries directly in `collection` (not its subcollections). */
167
187
  async function collectEntries(read, collection, cleanPath) {
168
188
  const { store, contentRoot, config, idIndex, entryLinkUrl, resolveTarget } = read;
189
+ const skipNoindex = config?.exclude?.noindex !== false;
169
190
  const pending = [];
170
191
  const listed = await store.getCollectionEntryPaths(collection.logicalPath);
171
192
  // Filter to only entries in this exact collection (not subcollections)
@@ -183,15 +204,21 @@ async function collectEntries(read, collection, cleanPath) {
183
204
  const doc = await store.read(listEntry.collection, listEntry.slug, {
184
205
  resolveReferences: false,
185
206
  });
207
+ // AI content advertises an entry, so `noindex` leaves it out here as it does the sitemap.
208
+ if (skipNoindex && isNoindexEntry(doc.data, config?.seo))
209
+ continue;
186
210
  doc.data = await resolveReferenceFields(doc.data, entryTypeConfig.schema, resolveTarget);
187
211
  const aiEntry = docToAIEntry(doc, listEntry.slug, entryTypeName, entryTypeConfig, cleanPath);
188
- if (aiEntry.body) {
189
- aiEntry.body = resolveEntryLinksInText(aiEntry.body, idIndex, contentRoot, entryLinkUrl);
190
- }
212
+ const body = aiEntry.body;
213
+ // `where` sees every link resolved; the export's own rendering re-resolves from `body`
214
+ if (body)
215
+ aiEntry.body = resolveEntryLinksInText(body, idIndex, contentRoot, entryLinkUrl);
191
216
  if (config?.exclude?.where?.(aiEntry))
192
217
  continue;
193
218
  pending.push({
194
219
  entry: aiEntry,
220
+ body,
221
+ linkedIds: [],
195
222
  contentId: extractIdFromFilename(path.basename(listEntry.relativePath)),
196
223
  absolutePath: doc.absolutePath,
197
224
  collectionPath: collection.logicalPath,
@@ -236,6 +263,40 @@ function emitCollection(node, files) {
236
263
  subcollections: manifestSubcollections.length > 0 ? manifestSubcollections : undefined,
237
264
  };
238
265
  }
266
+ /**
267
+ * Refuse an `exclude.noindex` or `seo` the noindex gate would misread. A non-boolean
268
+ * `noindex: 'false'` would leave the gate on while the adopter believes it is off, and a
269
+ * misshapen `seo` reads the flag from a field no entry sets, so every noindex entry ships.
270
+ */
271
+ function assertValidNoindexConfig(config) {
272
+ const noindex = config?.exclude?.noindex;
273
+ if (noindex !== undefined && typeof noindex !== 'boolean') {
274
+ throw new Error(`AI content config: exclude.noindex must be a boolean, got ${typeof noindex}`);
275
+ }
276
+ const seo = config?.seo;
277
+ if (seo === undefined)
278
+ return;
279
+ if (typeof seo !== 'object' || seo === null || Array.isArray(seo)) {
280
+ throw new Error('AI content config: seo must be an object of { group?, fields? }');
281
+ }
282
+ const { group, fields } = seo;
283
+ if (group !== undefined && typeof group !== 'string') {
284
+ throw new Error('AI content config: seo.group must be a string');
285
+ }
286
+ if (fields === undefined)
287
+ return;
288
+ if (typeof fields !== 'object' ||
289
+ fields === null ||
290
+ Array.isArray(fields) ||
291
+ Object.values(fields).some((name) => typeof name !== 'string')) {
292
+ throw new Error('AI content config: seo.fields must map SEO fields to field-name strings');
293
+ }
294
+ // An unknown key (`noIndex`) would leave the gate reading the default field name
295
+ const unknown = Object.keys(fields).filter((key) => !(key in DEFAULT_SEO_FIELD_NAMES));
296
+ if (unknown.length > 0) {
297
+ throw new Error(`AI content config: unknown seo.fields key(s): ${unknown.join(', ')}`);
298
+ }
299
+ }
239
300
  function stripContentRoot(logicalPath, contentRoot) {
240
301
  if (logicalPath.startsWith(contentRoot + '/')) {
241
302
  return logicalPath.slice(contentRoot.length + 1);
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { FieldConfig } from '../config/index.js';
8
8
  import type { MissingReference, ResolvedReferenceMeta } from '../entry-schema.js';
9
+ import type { SeoFieldLocation } from '../static/seo.js';
9
10
  /** A reference whose target this export shows: the target's own data plus where it lives. */
10
11
  export type AIResolvedReference = Record<string, unknown> & ResolvedReferenceMeta & {
11
12
  unavailable?: undefined;
@@ -60,6 +61,12 @@ export interface ExcludeConfig {
60
61
  entryTypes?: string[];
61
62
  /** Custom predicate — return true to exclude the entry */
62
63
  where?: (entry: AIEntryMeta) => boolean;
64
+ /**
65
+ * Leave out entries marked `noindex`, read through `isNoindexEntry` at
66
+ * {@link AIContentConfig.seo}, the predicate the sitemap and robots metadata use. Default
67
+ * `true`; `false` exports them.
68
+ */
69
+ noindex?: boolean;
63
70
  }
64
71
  /**
65
72
  * Filter criteria for bundles. Filters are AND'd when combined.
@@ -211,6 +218,11 @@ export interface AIContentConfig {
211
218
  mountPath?: string;
212
219
  /** Opt-out exclusions — content to skip */
213
220
  exclude?: ExcludeConfig;
221
+ /**
222
+ * Where entries keep their SEO fields, when not the flat defaults. Drives the `noindex` read,
223
+ * so pass the same object as `createNextCanopyContext({ seo })`, or the two can disagree.
224
+ */
225
+ seo?: SeoFieldLocation;
214
226
  /** Custom bundles — filtered content subsets */
215
227
  bundles?: BundleConfig[];
216
228
  /** Per-entry-type, per-field markdown overrides */
@@ -202,7 +202,7 @@ export const createBranchHandler = async (ctx, req, body) => {
202
202
  // is BOTH this deployment's local git origin AND a mirror of GitHub's
203
203
  // view of the repo: worker/git-sync.ts's syncGit() fetches GitHub into it
204
204
  // (see GITHUB_TRACKING_REF_PREFIX's doc comment in git-manager.ts) and
205
- // then reconciles refs/heads/* non-destructively, so it carries both
205
+ // then reconciles refs/heads/* without touching editor work, so it carries both
206
206
  // this deployment's local heads and GitHub's view -- readable offline by
207
207
  // this same Lambda, since it resolves to the same EFS inode the worker
208
208
  // uses. Reading it here catches a sanitized-name collision with a branch
@@ -264,14 +264,14 @@ export const createBranchHandler = async (ctx, req, body) => {
264
264
  // deleteBranchHandler's cleanup (below) handles the common case, but
265
265
  // cannot cover every ordering: when the branch still existed on
266
266
  // GitHub at editor-delete time, the sync loop's reconcile re-creates
267
- // the local head from the tracking ref within a cycle, and once the
268
- // GitHub side is later deleted (pruning the tracking ref) that
269
- // re-created head is orphaned with no registry entry left for the
270
- // delete path to ever run against. Healing at REUSE time covers
271
- // every ordering, however the head got orphaned: the registry check
272
- // above already proved no live branch owns this name, and the
273
- // tracking check just proved GitHub doesn't either, so a remaining
274
- // local head is stale by definition. Best-effort (old-value-guarded
267
+ // the local head from GitHub's branch within a cycle, and once the
268
+ // GitHub side is later deleted that re-created head stays until the
269
+ // sync loop's next prune, with no registry entry left for the delete
270
+ // path to run against. Healing at REUSE time covers every ordering,
271
+ // however the head got orphaned: the registry check above already
272
+ // proved no live branch owns this name, and the tracking check just
273
+ // proved GitHub doesn't either, so a remaining local head is stale by
274
+ // definition. Best-effort (old-value-guarded
275
275
  // against a concurrent push): on failure, creation still proceeds --
276
276
  // that is today's status quo, and the publish-time 409 message names
277
277
  // this cause.
@@ -532,10 +532,10 @@ export const deleteBranchHandler = async (ctx, req, params) => {
532
532
  if (deletingPath)
533
533
  await removeLeftoverDir(deletingPath);
534
534
  // Also delete the branch's local head from the remote.git mirror (the
535
- // deployment's local origin). The sync loop deliberately never deletes a
536
- // local head (see reconcileTrackedBranches), so THIS is the one explicit
537
- // path that removes it -- without this, the head outlives the branch
538
- // forever, and reusing the name after the GitHub side is gone (e.g. a
535
+ // deployment's local origin). The sync loop's reconcileTrackedBranches
536
+ // removes a head only once GitHub no longer has its branch, so while GitHub
537
+ // does, THIS is the path that removes it -- without this, the head outlives
538
+ // the branch, and reusing the name after the GitHub side is gone (e.g. a
539
539
  // squash-merged PR with auto-delete) has the reused branch's first publish
540
540
  // rejected non-fast-forward against the stale old tip: a permanent,
541
541
  // misleading 409 -- and a retried submit would then ship the STALE head to
@@ -2,5 +2,5 @@
2
2
  * Server-only: these use node:fs/promises and must NOT reach a client bundle.
3
3
  * Import them as 'canopycms/auth/cache'.
4
4
  */
5
- export { FileBasedAuthCache, writeAuthCacheSnapshot } from './file-based-auth-cache.js';
5
+ export { FileBasedAuthCache, writeAuthCacheSnapshot, writeAuthSnapshot, type AuthCacheSnapshot, } from './file-based-auth-cache.js';
6
6
  export { CachingAuthPlugin } from './caching-auth-plugin.js';
@@ -2,5 +2,5 @@
2
2
  * Server-only: these use node:fs/promises and must NOT reach a client bundle.
3
3
  * Import them as 'canopycms/auth/cache'.
4
4
  */
5
- export { FileBasedAuthCache, writeAuthCacheSnapshot } from './file-based-auth-cache.js';
5
+ export { FileBasedAuthCache, writeAuthCacheSnapshot, writeAuthSnapshot, } from './file-based-auth-cache.js';
6
6
  export { CachingAuthPlugin } from './caching-auth-plugin.js';
@@ -32,3 +32,11 @@ export declare class FileBasedAuthCache implements AuthCacheProvider {
32
32
  * recent snapshot directories are kept.
33
33
  */
34
34
  export declare function writeAuthCacheSnapshot(cachePath: string, files: Record<string, unknown>): Promise<string>;
35
+ /** One auth provider's users, groups and memberships: what an auth-cache refresh writes. */
36
+ export interface AuthCacheSnapshot {
37
+ users: UserSearchResult[];
38
+ groups: GroupMetadata[];
39
+ memberships: Record<CanopyUserId, CanopyGroupId[]>;
40
+ }
41
+ /** Write `snapshot` as the three files FileBasedAuthCache reads (users.json, orgs.json, memberships.json). */
42
+ export declare function writeAuthSnapshot(cachePath: string, snapshot: AuthCacheSnapshot): Promise<string>;
@@ -182,6 +182,17 @@ export async function writeAuthCacheSnapshot(cachePath, files) {
182
182
  await cleanupOldSnapshots(cachePath, 2);
183
183
  return snapshotDir;
184
184
  }
185
+ /** Write `snapshot` as the three files FileBasedAuthCache reads (users.json, orgs.json, memberships.json). */
186
+ export async function writeAuthSnapshot(cachePath, snapshot) {
187
+ const users = { users: snapshot.users };
188
+ const groups = { groups: snapshot.groups };
189
+ const memberships = { memberships: snapshot.memberships };
190
+ return writeAuthCacheSnapshot(cachePath, {
191
+ 'users.json': users,
192
+ 'orgs.json': groups,
193
+ 'memberships.json': memberships,
194
+ });
195
+ }
185
196
  async function cleanupOldSnapshots(cachePath, keepCount) {
186
197
  let entries;
187
198
  try {
@@ -50,7 +50,7 @@ export declare function createCheckBranchAccess(defaultAccess?: DefaultBranchAcc
50
50
  export interface WorkflowActionOptions {
51
51
  /**
52
52
  * When true, disables the system-branch grant below. The protected base
53
- * branch is auto-provisioned with `createdBy: 'canopycms-system'`, which
53
+ * branch is auto-provisioned with `createdBy: SYSTEM_USER_ID`, which
54
54
  * would otherwise let anyone with general branch access submit/withdraw it
55
55
  * -- pass `getBranchProtection(...).isProtected` here so only
56
56
  * admins/reviewers/explicit-ACL users retain workflow rights on it.
@@ -1,5 +1,6 @@
1
1
  import { isAdmin, isReviewer } from './helpers.js';
2
2
  import { getBranchProtection } from './protected-branch.js';
3
+ import { SYSTEM_USER_ID } from '../system-users.js';
3
4
  /**
4
5
  * Check if user has access to a branch with explicit default behavior.
5
6
  *
@@ -80,7 +81,7 @@ export function canPerformWorkflowAction(context, user, defaultAccess = 'deny',
80
81
  return false;
81
82
  }
82
83
  const userIsCreator = context.branch.createdBy === user.userId;
83
- const isSystemBranch = !options?.isProtectedBranch && context.branch.createdBy === 'canopycms-system';
84
+ const isSystemBranch = !options?.isProtectedBranch && context.branch.createdBy === SYSTEM_USER_ID;
84
85
  return (userIsCreator ||
85
86
  accessResult.reason === 'privileged' ||
86
87
  accessResult.reason === 'allowed_by_acl' ||