canopycms 0.0.67 → 0.0.68-int.94

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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
@@ -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,10 +9,18 @@ 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.
@@ -3,14 +3,22 @@ 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';
6
7
  /**
7
8
  * Uses githubService directly if available, otherwise queues a task for the worker.
9
+ *
10
+ * The body is the canopycms PR section (submission-attribution.ts): an existing
11
+ * PR keeps whatever a human wrote outside it.
8
12
  */
9
- export async function syncSubmitPr(ctx, context) {
13
+ export async function syncSubmitPr(ctx, context, submission) {
10
14
  const { githubService } = ctx.services;
11
15
  const mode = ctx.services.config.mode;
12
16
  const prTitle = context.branch.title || `Submit ${context.branch.name}`;
13
- const prBody = context.branch.description || '';
17
+ const prSection = buildPrSection({
18
+ description: context.branch.description,
19
+ submitter: submission.submitter,
20
+ changedPaths: submission.changedPaths,
21
+ });
14
22
  // Target the fork point recorded at branch creation when available.
15
23
  const baseBranch = context.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
16
24
  if (!clientOperatingStrategy(mode).supportsPullRequests()) {
@@ -29,9 +37,12 @@ export async function syncSubmitPr(ctx, context) {
29
37
  if (githubService) {
30
38
  try {
31
39
  if (context.branch.pullRequestNumber) {
40
+ // Read before writing: the update keeps the human text around the
41
+ // canopycms section, and the same read says whether the PR is a draft.
42
+ const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
32
43
  await githubService.updatePullRequest(context.branch.pullRequestNumber, {
33
44
  title: prTitle,
34
- body: prBody,
45
+ body: mergePrSection(pr.body, prSection),
35
46
  });
36
47
  // Best-effort draft->ready conversion. This branch updates a known
37
48
  // PR number directly (not through createOrUpdatePR), so it doesn't
@@ -42,7 +53,6 @@ export async function syncSubmitPr(ctx, context) {
42
53
  // consistent with createOrUpdatePullRequest's best-effort handling
43
54
  // in github-service.ts.
44
55
  try {
45
- const pr = await githubService.getPullRequest(context.branch.pullRequestNumber);
46
56
  if (pr.draft) {
47
57
  await githubService.convertToReady(context.branch.pullRequestNumber);
48
58
  }
@@ -73,8 +83,9 @@ export async function syncSubmitPr(ctx, context) {
73
83
  head: context.branch.name,
74
84
  base: baseBranch,
75
85
  title: prTitle,
76
- body: prBody,
86
+ body: prSection,
77
87
  markReadyIfDraft: true,
88
+ mergeSectionIntoBody: true,
78
89
  });
79
90
  return {
80
91
  prUrl: result.url,
@@ -105,13 +116,12 @@ export async function syncSubmitPr(ctx, context) {
105
116
  payload: {
106
117
  branch: context.branch.name,
107
118
  title: prTitle,
108
- body: prBody,
119
+ body: prSection,
120
+ mergeSectionIntoBody: true,
109
121
  baseBranch,
110
122
  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.
123
+ // A content submit is an explicit "ready for review" action: convert a
124
+ // pre-existing draft PR to ready.
115
125
  markReadyIfDraft: true,
116
126
  },
117
127
  });
@@ -70,7 +70,7 @@ const getReferenceOptionsHandler = async (gc, ctx, req, _params) => {
70
70
  // for an option the caller isn't allowed to see.
71
71
  const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
72
72
  const resolver = new ReferenceResolver(store, idIndex);
73
- const options = await resolver.loadReferenceOptions(collections, displayField, search, entryTypes, (relativePath) => checkAccess(relativePath, 'read').allowed);
73
+ const options = await resolver.loadReferenceOptions(collections, displayField, search, entryTypes, (logicalPath) => checkAccess(logicalPath, 'read').allowed);
74
74
  return {
75
75
  ok: true,
76
76
  status: 200,
@@ -1,9 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ContentStore } from '../content-store.js';
3
3
  import { defineEndpoint } from './route-builder.js';
4
- import { ReferenceResolver } from '../reference-resolver.js';
5
- import { buildResolvedReference } from '../entry-schema.js';
6
- import { computeEntryUrl } from '../utils/entry-url.js';
7
4
  import { branchNameSchema, contentIdSchema } from './validators.js';
8
5
  /**
9
6
  * Resolution does sequential per-ID file I/O, so the request body caps how much filesystem work
@@ -20,49 +17,25 @@ const resolveReferencesBodySchema = z.object({
20
17
  const resolveReferencesHandler = async (gc, ctx, req, params, body) => {
21
18
  const { branchContext } = gc;
22
19
  const { ids } = body;
23
- const flatSchema = branchContext.flatSchema;
24
- const contentRootName = ctx.services.config.contentRoot || 'content';
25
- const store = new ContentStore(branchContext.branchRoot, flatSchema, {
26
- contentRootName,
20
+ const store = new ContentStore(branchContext.branchRoot, branchContext.flatSchema, {
21
+ contentRootName: ctx.services.config.contentRoot || 'content',
27
22
  });
28
- // Get ID index (automatically loads if needed)
29
- const idIndex = await store.idIndex();
30
- // Resolve each ID to full document
31
- const resolver = new ReferenceResolver(store, idIndex);
32
- // Build the access checker once, reused for every id instead of re-loading per id in the loop.
33
- // A failure here (e.g. settings workspace unavailable) surfaces as a handler error, not
34
- // swallowed silently per id.
23
+ // Built once for every id. A failure here (e.g. settings workspace unavailable) surfaces as a
24
+ // handler error rather than being swallowed per id.
35
25
  const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
26
+ const access = (logicalPath) => checkAccess(logicalPath, 'read').allowed;
27
+ // The resolution `read()` applies to a reference field, so live preview shows this user what
28
+ // `read()` would: the target's data, or a `RestrictedReference` for a target they may not
29
+ // read. The target's own references stay ids, as in `read()`.
36
30
  const resolved = {};
37
31
  for (const id of ids) {
38
32
  try {
39
- const result = await resolver.resolve(id);
40
- if (result && result.exists && result.collection && result.slug) {
41
- // Check path-level read permission before returning content
42
- const resolvedPath = await store.resolveDocumentPath(result.collection, result.slug);
43
- const access = checkAccess(resolvedPath.relativePath, 'read');
44
- if (!access.allowed)
45
- continue;
46
- // `resolveReferences: false` matches the server-side resolver (content-store.ts's
47
- // resolveSingleReferenceOnce), so a nested reference inside a target renders the same way
48
- // in live preview as on the published site, instead of resolving one level deeper.
49
- const doc = await store.read(result.collection, result.slug, { resolveReferences: false });
50
- if (doc && doc.data) {
51
- // Same shape as the server-side resolver: target data first, then the reserved keys.
52
- // Order is the corruption guard (a target modelling `id` as content must not shadow the
53
- // real content ID); the extra keys (incl. `urlPath`) keep live preview and production
54
- // in sync for consumers like client-reference-resolver.ts.
55
- resolved[id] = buildResolvedReference(doc.data, {
56
- id,
57
- slug: result.slug,
58
- collection: result.collection,
59
- urlPath: computeEntryUrl(result.collection, result.slug, contentRootName),
60
- });
61
- }
62
- }
33
+ const value = await store.resolveReferenceTarget(id, access);
34
+ if (value)
35
+ resolved[id] = value;
63
36
  }
64
37
  catch (error) {
65
- // Skip failed resolutions, don't block entire request
38
+ // An id that fails to resolve is omitted rather than failing the whole request.
66
39
  console.error(`Failed to resolve reference ID ${id}:`, error);
67
40
  }
68
41
  }
@@ -73,7 +46,8 @@ const resolveReferencesHandler = async (gc, ctx, req, params, body) => {
73
46
  };
74
47
  };
75
48
  /**
76
- * Resolve reference IDs to full document objects
49
+ * Resolve reference IDs as a reference field would: full target data, or title + URL tagged
50
+ * `unavailable` for a target the user may not read. An id naming no entry is omitted.
77
51
  * POST /:branch/resolve-references
78
52
  * Body: { ids: string[] }
79
53
  */
@@ -30,7 +30,7 @@ export interface CommitSettingsResult {
30
30
  /**
31
31
  * Commit and push settings changes based on the mode.
32
32
  * Both prod and dev use commitToSettingsBranch.
33
- * In dev mode, commits to the settings branch but does not create a PR.
33
+ * Settings changes are never reviewed through a PR (see commitToSettingsBranch).
34
34
  */
35
35
  export declare function commitSettings(ctx: ApiContext, options: {
36
36
  context: {
@@ -23,7 +23,7 @@ export async function getSettingsBranchContext(ctx) {
23
23
  /**
24
24
  * Commit and push settings changes based on the mode.
25
25
  * Both prod and dev use commitToSettingsBranch.
26
- * In dev mode, commits to the settings branch but does not create a PR.
26
+ * Settings changes are never reviewed through a PR (see commitToSettingsBranch).
27
27
  */
28
28
  export async function commitSettings(ctx, options) {
29
29
  const strategy = operatingStrategy(options.mode);
@@ -38,9 +38,6 @@ export async function commitSettings(ctx, options) {
38
38
  branchRoot: options.branchRoot,
39
39
  files: options.fileName,
40
40
  message: options.message,
41
- createPR: strategy.shouldCreateSettingsPR({
42
- autoCreateSettingsPR: ctx.services.config.autoCreateSettingsPR,
43
- }),
44
41
  });
45
42
  if (!result.pushed) {
46
43
  console.warn(`${options.message} committed but not pushed:`, result.error);
@@ -1,7 +1,10 @@
1
1
  import type { ApiResponse } from './types.js';
2
+ import type { BuildIdentity } from '../types.js';
2
3
  export type UserInfoResponse = ApiResponse<{
3
4
  userId: string;
4
5
  groups: string[];
6
+ /** Admins only: the deployed version is reconnaissance for anyone else. */
7
+ build?: BuildIdentity;
5
8
  }>;
6
9
  export declare const USER_ROUTES: {
7
10
  readonly whoami: import("./route-builder.js").RouteDefinition<undefined, undefined, UserInfoResponse>;
package/dist/api/user.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { defineEndpoint } from './route-builder.js';
2
+ import { isAdmin } from '../authorization/helpers.js';
3
+ import { getBuildIdentity } from '../build-identity.js';
2
4
  /**
3
5
  * This is a PUBLIC endpoint - no special permissions required
4
6
  */
@@ -10,6 +12,7 @@ const getUserInfoHandler = async (ctx, req) => {
10
12
  data: {
11
13
  userId: req.user.userId,
12
14
  groups: req.user.groups,
15
+ ...(isAdmin(req.user.groups) ? { build: getBuildIdentity() } : {}),
13
16
  },
14
17
  };
15
18
  };
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * This is the one place that turns the (currently-unconsumed) `media` config
5
5
  * into a real store — see .claude/future-tasks/resolved/assets-media-system.md.
6
+ *
7
+ * The store is branch-agnostic: a local one is rooted at the dev workspace's
8
+ * `assets/` or at `media.directory` resolved against the process's working
9
+ * directory, never inside a branch clone. So asset writes take no [SYNC-C1]
10
+ * content-write lock — the worker's rebase never touches them.
6
11
  */
7
12
  import type { MediaConfig } from '../config/types.js';
8
13
  import type { AssetStore } from './types.js';
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * This is the one place that turns the (currently-unconsumed) `media` config
5
5
  * into a real store — see .claude/future-tasks/resolved/assets-media-system.md.
6
+ *
7
+ * The store is branch-agnostic: a local one is rooted at the dev workspace's
8
+ * `assets/` or at `media.directory` resolved against the process's working
9
+ * directory, never inside a branch clone. So asset writes take no [SYNC-C1]
10
+ * content-write lock — the worker's rebase never touches them.
6
11
  */
7
12
  import { LocalAssetStore } from './store-local.js';
8
13
  import { S3AssetStore } from './store-s3.js';
@@ -6,12 +6,13 @@ import type { BranchContext } from '../types.js';
6
6
  import type { PermissionLevel } from '../config/index.js';
7
7
  import type { CanopyUser } from '../user.js';
8
8
  import type { ContentAccessResult, ContentAccessDeps } from './types.js';
9
- import type { PhysicalPath } from '../paths/types.js';
9
+ import type { LogicalPath } from '../paths/types.js';
10
10
  /**
11
11
  * A bound content-access checker: evaluates a single path/level synchronously
12
12
  * against permissions that were already loaded when the checker was created.
13
+ * The path is the entry's logical path (see `checkPathAccess`).
13
14
  */
14
- export type ContentAccessChecker = (relativePath: PhysicalPath, level: PermissionLevel) => ContentAccessResult;
15
+ export type ContentAccessChecker = (logicalPath: LogicalPath, level: PermissionLevel) => ContentAccessResult;
15
16
  /**
16
17
  * Resolve the request-constant work once -- branch access, the
17
18
  * settings/permissions root and the path-permission rules, all identical for a
@@ -26,5 +27,5 @@ export declare function createContentAccessChecker(deps: ContentAccessDeps, cont
26
27
  * Single-path check; loads the path permissions from the branch root per call.
27
28
  * @internal Exported for tests.
28
29
  */
29
- export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
30
- export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
30
+ export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
31
+ export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
@@ -28,9 +28,9 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
28
28
  }
29
29
  const rules = await deps.loadPathPermissions(permissionsRoot, deps.mode);
30
30
  const pathChecker = createCheckPathAccess(rules, deps.defaultPathAccess);
31
- return (relativePath, level) => {
31
+ return (logicalPath, level) => {
32
32
  const path = pathChecker({
33
- relativePath,
33
+ logicalPath,
34
34
  user,
35
35
  level,
36
36
  });
@@ -45,10 +45,10 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
45
45
  * Single-path check; loads the path permissions from the branch root per call.
46
46
  * @internal Exported for tests.
47
47
  */
48
- export async function checkContentAccess(deps, context, branchRoot, relativePath, user, level) {
48
+ export async function checkContentAccess(deps, context, branchRoot, logicalPath, user, level) {
49
49
  const check = await createContentAccessChecker(deps, context, branchRoot, user);
50
- return check(relativePath, level);
50
+ return check(logicalPath, level);
51
51
  }
52
52
  export function createCheckContentAccess(deps) {
53
- return (context, branchRoot, relativePath, user, level) => checkContentAccess(deps, context, branchRoot, relativePath, user, level);
53
+ return (context, branchRoot, logicalPath, user, level) => checkContentAccess(deps, context, branchRoot, logicalPath, user, level);
54
54
  }
@@ -1,7 +1,7 @@
1
1
  import type { PathPermission, DefaultPathAccess, PermissionLevel } from '../config/index.js';
2
2
  import type { CanopyUser } from '../user.js';
3
3
  import type { PathPermissionResult } from './types.js';
4
- import type { PhysicalPath } from '../paths/types.js';
4
+ import type { LogicalPath } from '../paths/types.js';
5
5
  /**
6
6
  * Resolve `defaultPathAccess` to an 'allow'/'deny' verdict for one permission
7
7
  * level. String form applies to every level; object form looks up the level,
@@ -11,19 +11,24 @@ import type { PhysicalPath } from '../paths/types.js';
11
11
  */
12
12
  export declare function resolveDefaultPathAccess(defaultAccess: DefaultPathAccess, level: PermissionLevel): 'allow' | 'deny';
13
13
  /**
14
- * Evaluate access for a relative path against config-defined rules.
15
- * Uses defaultAccess when no rule matches. First matching rule wins.
14
+ * Evaluate access for a path against config-defined rules. First matching rule wins;
15
+ * defaultAccess applies when none matches.
16
+ *
17
+ * Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
18
+ * id-free, the space the Permission Manager writes rules in. An entry's logical path is
19
+ * `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
20
+ * its on-disk `content/blog.<id>/post.my-post.<id>.json`.
16
21
  * @internal Exported for tests.
17
22
  */
18
- export declare function checkPathAccess({ rules, relativePath, user, defaultAccess, level, }: {
23
+ export declare function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }: {
19
24
  rules: PathPermission[];
20
- relativePath: PhysicalPath;
25
+ logicalPath: LogicalPath;
21
26
  user: CanopyUser;
22
27
  defaultAccess: DefaultPathAccess;
23
28
  level: PermissionLevel;
24
29
  }): PathPermissionResult;
25
30
  export declare function createCheckPathAccess(rules: PathPermission[], defaultAccess: DefaultPathAccess): (input: {
26
- relativePath: PhysicalPath;
31
+ logicalPath: LogicalPath;
27
32
  user: CanopyUser;
28
33
  level: PermissionLevel;
29
34
  }) => PathPermissionResult;