canopycms 0.0.67-int.91 → 0.0.68-int.93

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 (107) hide show
  1. package/dist/ai/handler.js +8 -0
  2. package/dist/api/admin-branch-health.js +12 -7
  3. package/dist/api/admin.d.ts +8 -8
  4. package/dist/api/branch-status.js +6 -2
  5. package/dist/api/client.d.ts +12 -0
  6. package/dist/api/client.js +35 -11
  7. package/dist/api/content.d.ts +6 -5
  8. package/dist/api/content.js +38 -39
  9. package/dist/api/entries.js +6 -8
  10. package/dist/api/github-sync.d.ts +10 -1
  11. package/dist/api/github-sync.js +20 -10
  12. package/dist/api/reference-options.js +1 -1
  13. package/dist/api/request-url.d.ts +8 -0
  14. package/dist/api/request-url.js +15 -0
  15. package/dist/api/resolve-references.js +2 -2
  16. package/dist/api/settings-helpers.d.ts +1 -1
  17. package/dist/api/settings-helpers.js +1 -4
  18. package/dist/authorization/content.d.ts +5 -4
  19. package/dist/authorization/content.js +5 -5
  20. package/dist/authorization/path.d.ts +11 -6
  21. package/dist/authorization/path.js +11 -6
  22. package/dist/authorization/types.d.ts +2 -2
  23. package/dist/branch-health.js +3 -3
  24. package/dist/branch-schema-cache.d.ts +5 -3
  25. package/dist/branch-schema-cache.js +29 -11
  26. package/dist/branch-workspace.js +2 -2
  27. package/dist/cli/cli.js +285 -168
  28. package/dist/cli/generate-ai-content.js +190 -109
  29. package/dist/cli/github-app-manifest.d.ts +4 -3
  30. package/dist/cli/github-app-manifest.js +4 -3
  31. package/dist/cli/init.js +15 -10
  32. package/dist/cli/sync.js +15 -13
  33. package/dist/config/schemas/config.d.ts +6 -3
  34. package/dist/config/schemas/config.js +3 -1
  35. package/dist/config/schemas/field.js +1 -0
  36. package/dist/config/types.d.ts +11 -2
  37. package/dist/config/validation.d.ts +6 -0
  38. package/dist/config/validation.js +37 -0
  39. package/dist/content-listing.d.ts +2 -2
  40. package/dist/content-listing.js +3 -3
  41. package/dist/content-reader.js +4 -4
  42. package/dist/content-store.d.ts +2 -0
  43. package/dist/content-store.js +2 -1
  44. package/dist/content-tree.js +1 -1
  45. package/dist/context.d.ts +16 -7
  46. package/dist/context.js +116 -67
  47. package/dist/editor/Editor.js +4 -4
  48. package/dist/editor/FormRenderer.js +20 -5
  49. package/dist/editor/admin/SystemHealthPanel.js +37 -6
  50. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  51. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  52. package/dist/editor/hooks/useBranchesData.js +3 -1
  53. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  54. package/dist/editor/hooks/useDraftManager.js +2 -1
  55. package/dist/editor/hooks/useEntriesData.js +2 -2
  56. package/dist/editor/hooks/useEntryManager.js +17 -11
  57. package/dist/entry-schema-registry.js +2 -1
  58. package/dist/git-manager.d.ts +20 -0
  59. package/dist/git-manager.js +50 -4
  60. package/dist/github-service.d.ts +8 -0
  61. package/dist/github-service.js +5 -1
  62. package/dist/http/handler.js +24 -9
  63. package/dist/http/index.d.ts +2 -0
  64. package/dist/http/index.js +2 -0
  65. package/dist/http/router.d.ts +2 -0
  66. package/dist/http/router.js +2 -1
  67. package/dist/http/worker-not-ready.d.ts +9 -0
  68. package/dist/http/worker-not-ready.js +17 -0
  69. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/paths/index.d.ts +1 -1
  72. package/dist/paths/index.js +1 -1
  73. package/dist/paths/normalize.d.ts +7 -0
  74. package/dist/paths/normalize.js +9 -0
  75. package/dist/reference-resolver.d.ts +3 -3
  76. package/dist/reference-resolver.js +3 -2
  77. package/dist/resolve-canopy-user.js +2 -1
  78. package/dist/services.d.ts +12 -5
  79. package/dist/services.js +56 -56
  80. package/dist/settings-workspace.js +36 -0
  81. package/dist/static/seo.d.ts +2 -14
  82. package/dist/static/seo.js +2 -25
  83. package/dist/submission-attribution.d.ts +73 -0
  84. package/dist/submission-attribution.js +221 -0
  85. package/dist/sync-core.js +7 -8
  86. package/dist/types.d.ts +23 -2
  87. package/dist/utils/content-write-lock.d.ts +3 -7
  88. package/dist/utils/content-write-lock.js +6 -4
  89. package/dist/utils/debug.d.ts +8 -0
  90. package/dist/utils/debug.js +10 -2
  91. package/dist/utils/git.d.ts +28 -0
  92. package/dist/utils/git.js +38 -0
  93. package/dist/utils/provisioning-lock.d.ts +15 -5
  94. package/dist/utils/provisioning-lock.js +31 -11
  95. package/dist/utils/request-timing.d.ts +25 -0
  96. package/dist/utils/request-timing.js +101 -0
  97. package/dist/utils/url-prefix.d.ts +15 -0
  98. package/dist/utils/url-prefix.js +26 -0
  99. package/dist/worker/canopy-state.d.ts +45 -0
  100. package/dist/worker/canopy-state.js +75 -0
  101. package/dist/worker/git-sync.d.ts +4 -2
  102. package/dist/worker/git-sync.js +111 -24
  103. package/dist/worker/provisioned-workspace.d.ts +35 -0
  104. package/dist/worker/provisioned-workspace.js +50 -0
  105. package/dist/worker/rebase.js +75 -12
  106. package/dist/worker/task-runner.js +39 -4
  107. package/package.json +1 -1
@@ -13,6 +13,7 @@
13
13
  import { ContentStore } from '../content-store.js';
14
14
  import { BranchSchemaCache } from '../branch-schema-cache.js';
15
15
  import { getErrorMessage } from '../utils/error.js';
16
+ import { workerNotReadyResponse } from '../http/worker-not-ready.js';
16
17
  import { generateAIContent } from './generate.js';
17
18
  import { resolveBranchRoot } from './resolve-branch.js';
18
19
  /**
@@ -76,6 +77,13 @@ export function createAIContentHandler(options) {
76
77
  catch (error) {
77
78
  // Log the real error server-side; don't leak internals to unauthenticated callers
78
79
  console.error('AI content handler error:', getErrorMessage(error));
80
+ const notReady = workerNotReadyResponse(error);
81
+ if (notReady) {
82
+ return new Response(JSON.stringify({ error: notReady.body.error }), {
83
+ status: notReady.status,
84
+ headers: { 'Content-Type': 'application/json', ...notReady.headers },
85
+ });
86
+ }
79
87
  return new Response(JSON.stringify({ error: 'Internal server error' }), {
80
88
  status: 500,
81
89
  headers: { 'Content-Type': 'application/json' },
@@ -22,7 +22,7 @@ import { ContentIdIndex } from '../content-id-index.js';
22
22
  import { invalidateContentIndexesDurable } from '../content-index-generation.js';
23
23
  import { getDefaultBranchBase, sanitizeBranchName } from '../paths/index.js';
24
24
  import { withOccFileLock } from '../utils/occ-json-write.js';
25
- import { tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
25
+ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
26
26
  import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
27
27
  import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
28
28
  import { defineEndpoint } from './route-builder.js';
@@ -147,12 +147,17 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
147
147
  // True orphan (no branch.json, no load error) vs. corrupt-metadata
148
148
  // (loadOnly threw). Only true orphans are subject to the youth rail below.
149
149
  const isTrueOrphan = !hadLoadError;
150
- // [H1] freshness rail: a fresh init lock means provisioning may genuinely
151
- // be in progress; a stale one is just crash debris and does not block.
152
- const lockPath = path.join(baseRoot, `.${params.dirName}.init.lock`);
150
+ // [H1] freshness rail: a fresh init lock means provisioning, or the worker's
151
+ // sync of this branch, may genuinely be in progress; a stale one is just
152
+ // crash debris and does not block.
153
+ const lockPath = path.join(baseRoot, branchProvisioningLockName(params.dirName));
153
154
  const lockStat = await fs.stat(lockPath).catch(() => null);
154
155
  if (lockStat && Date.now() - lockStat.mtimeMs < PROVISIONING_LOCK_FRESH_MS) {
155
- return { ok: false, status: 409, error: 'Provisioning may be in progress' };
156
+ return {
157
+ ok: false,
158
+ status: 409,
159
+ error: 'Provisioning, or the worker syncing this branch, may be in progress',
160
+ };
156
161
  }
157
162
  // Youth rail: a brand-new orphan dir may be a clone that just hasn't
158
163
  // written branch.json yet. Corrupt-metadata dirs are exempt -- a
@@ -171,7 +176,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
171
176
  // contention instead of hanging the request for ~5 minutes.
172
177
  let releaseProvisioningLock;
173
178
  try {
174
- releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, `.${params.dirName}.init.lock`);
179
+ releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
175
180
  }
176
181
  catch (err) {
177
182
  if (isNodeError(err) && err.code === 'ELOCKED') {
@@ -279,7 +284,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
279
284
  // Held until the `finally` below, AFTER save() completes.
280
285
  let releaseProvisioningLock;
281
286
  try {
282
- releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, `.${params.dirName}.init.lock`);
287
+ releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
283
288
  }
284
289
  catch (err) {
285
290
  if (isNodeError(err) && err.code === 'ELOCKED') {
@@ -54,10 +54,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
54
54
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
55
55
  limit: z.ZodOptional<z.ZodNumber>;
56
56
  }, "strip", z.ZodTypeAny, {
57
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
57
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
58
58
  limit?: number | undefined;
59
59
  }, {
60
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
60
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
61
61
  limit?: number | undefined;
62
62
  }>;
63
63
  export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
@@ -65,10 +65,10 @@ declare const deleteTaskParamsSchema: z.ZodObject<{
65
65
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
66
66
  fileName: z.ZodEffects<z.ZodString, string, string>;
67
67
  }, "strip", z.ZodTypeAny, {
68
- status: "pending" | "failed" | "corrupt";
68
+ status: "failed" | "pending" | "corrupt";
69
69
  fileName: string;
70
70
  }, {
71
- status: "pending" | "failed" | "corrupt";
71
+ status: "failed" | "pending" | "corrupt";
72
72
  fileName: string;
73
73
  }>;
74
74
  export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
@@ -108,10 +108,10 @@ export declare const ADMIN_ROUTES: {
108
108
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
109
109
  limit: z.ZodOptional<z.ZodNumber>;
110
110
  }, "strip", z.ZodTypeAny, {
111
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
111
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
112
112
  limit?: number | undefined;
113
113
  }, {
114
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
114
+ status: "failed" | "pending" | "processing" | "completed" | "corrupt";
115
115
  limit?: number | undefined;
116
116
  }>, undefined, AdminTasksResponse>;
117
117
  readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -125,10 +125,10 @@ export declare const ADMIN_ROUTES: {
125
125
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
126
126
  fileName: z.ZodEffects<z.ZodString, string, string>;
127
127
  }, "strip", z.ZodTypeAny, {
128
- status: "pending" | "failed" | "corrupt";
128
+ status: "failed" | "pending" | "corrupt";
129
129
  fileName: string;
130
130
  }, {
131
- status: "pending" | "failed" | "corrupt";
131
+ status: "failed" | "pending" | "corrupt";
132
132
  fileName: string;
133
133
  }>, undefined, AdminDeleteTaskResponse>;
134
134
  };
@@ -8,6 +8,7 @@ import { canPerformWorkflowAction, getBranchProtection } from '../authorization/
8
8
  import { syncSubmitPr } from './github-sync.js';
9
9
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
10
10
  import { isNonFastForwardRejection } from '../utils/git.js';
11
+ import { submissionEditorFromUser } from '../submission-attribution.js';
11
12
  const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
12
13
  const { branchContext } = gc;
13
14
  return { ok: true, status: 200, data: { branch: branchContext.branch } };
@@ -49,8 +50,11 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
49
50
  };
50
51
  }
51
52
  // Commit and push changes
53
+ const submitter = submissionEditorFromUser(req.user);
54
+ let changedPaths;
52
55
  try {
53
- await ctx.services.submitBranch({ context: branchContext });
56
+ ;
57
+ ({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
54
58
  }
55
59
  catch (err) {
56
60
  const message = getErrorMessage(err);
@@ -91,7 +95,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
91
95
  };
92
96
  }
93
97
  // Create or update PR (sync via githubService, or async via task queue)
94
- const prResult = await syncSubmitPr(ctx, branchContext);
98
+ const prResult = await syncSubmitPr(ctx, branchContext, { submitter, changedPaths });
95
99
  // Update metadata with status and PR info
96
100
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
97
101
  const updated = await meta.save({
@@ -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
+ * `request-url.ts`), 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,6 +50,7 @@ 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 */
@@ -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,8 @@
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 { readApiTrailingSlashEnv } from './request-url.js';
10
+ import { withTrailingSlash } from '../utils/url-prefix.js';
9
11
  /**
10
12
  * Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
11
13
  * returning that endpoint's ApiResponse.
@@ -260,6 +262,7 @@ export class CanopyApiClient {
260
262
  this.baseUrl = options.baseUrl ?? '/api/canopycms';
261
263
  // An unbound fetch throws "Illegal invocation" in browsers; Node has no window.
262
264
  this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
265
+ this.trailingSlash = options.trailingSlash ?? readApiTrailingSlashEnv();
263
266
  this.onUnauthorized = options.onUnauthorized;
264
267
  }
265
268
  buildPath(template, params) {
@@ -292,7 +295,7 @@ export class CanopyApiClient {
292
295
  return result;
293
296
  }
294
297
  async request(method, path, body, headers = {}) {
295
- const url = `${this.baseUrl}${path}`;
298
+ const url = `${this.baseUrl}${this.trailingSlash ? withTrailingSlash(path) : path}`;
296
299
  const requestHeaders = {
297
300
  ...headers,
298
301
  };
@@ -325,21 +328,42 @@ export class CanopyApiClient {
325
328
  init.body = requestBody;
326
329
  }
327
330
  const response = await this.fetchFn(url, init);
331
+ // A body from in front of the API (a proxy or CDN error page) may be empty, not JSON, or
332
+ // not an ApiResponse; every such body becomes an `ok: false` ApiResponse, never a throw.
333
+ const parsed = await response.json().catch(() => undefined);
328
334
  if (response.status === 401) {
329
335
  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 };
336
+ return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
337
337
  }
338
- const payload = await response.json();
339
- // All responses use ApiResponse format: { ok, status, data?, error? }
340
- return payload;
338
+ if (isApiResponseBody(parsed))
339
+ return parsed;
340
+ const converted = {
341
+ ok: false,
342
+ status: response.status,
343
+ error: errorFromBody(parsed) ?? `Unexpected response from server (HTTP ${response.status})`,
344
+ };
345
+ convertedResponses.add(converted);
346
+ return converted;
341
347
  }
342
348
  }
349
+ const convertedResponses = new WeakSet();
350
+ /**
351
+ * Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
352
+ * error page), so its status says nothing about the API itself.
353
+ */
354
+ export function isNonApiResponse(result) {
355
+ return convertedResponses.has(result);
356
+ }
357
+ /** The `error` string of a JSON body, when it has one. */
358
+ function errorFromBody(body) {
359
+ return typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string'
360
+ ? body.error
361
+ : undefined;
362
+ }
363
+ /** Whether a parsed body has the `{ ok, status, ... }` shape every handler returns. */
364
+ function isApiResponseBody(body) {
365
+ return typeof body === 'object' && body !== null && 'ok' in body && typeof body.ok === 'boolean';
366
+ }
343
367
  /** Create a {@link CanopyApiClient}; pass a custom fetch for tests. */
344
368
  export function createApiClient(options) {
345
369
  return new CanopyApiClient(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,19 +80,18 @@ 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');
94
+ const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
96
95
  if (!access.allowed) {
97
96
  return { ok: false, status: 403, error: 'Forbidden' };
98
97
  }
@@ -117,19 +116,18 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
117
116
  const logicalPathSegments = parseApiPath(params.path, contentRoot);
118
117
  let schemaItem;
119
118
  let slug;
120
- let relativePath;
121
119
  try {
122
120
  const resolved = store.resolvePath(logicalPathSegments);
123
121
  schemaItem = resolved.schemaItem;
124
122
  slug = resolved.slug;
125
- const pathResult = await store.resolveDocumentPath(schemaItem.logicalPath, slug);
126
- relativePath = pathResult.relativePath;
123
+ // Runs the store's slug and traversal checks before the permission check.
124
+ await store.resolveDocumentPath(schemaItem.logicalPath, slug);
127
125
  }
128
126
  catch (err) {
129
127
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
130
128
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
131
129
  }
132
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, relativePath, req.user, 'edit');
130
+ const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'edit');
133
131
  if (!access.allowed) {
134
132
  return { ok: false, status: 403, error: 'Forbidden' };
135
133
  }
@@ -198,20 +196,21 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
198
196
  }
199
197
  const data = body.data ?? {};
200
198
  const isDataOnly = isDataOnlyFormat(body.format);
199
+ // The store's own `undefined` (blind write) is never reachable from here: an omitted token is
200
+ // a create, so an update that lost its token 409s instead of overwriting unchecked.
201
+ const expectedVersion = body.expectedVersion ?? null;
202
+ const createConflictError = body.expectedVersion === null
203
+ ? `An entry with slug "${slug}" already exists`
204
+ : `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
201
205
  try {
202
206
  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
- };
207
+ // Create guard: a create against a slug that already has content must never silently
208
+ // overwrite it — short-circuit with 409 before field validation runs, so the error names the
209
+ // real problem instead of "field is required" or a bare conflict. store.write() re-enforces
210
+ // this itself inside its per-entry lock against a fresh stat (the race-safe authoritative
211
+ // check); this is just a cheaper fast path for the common case.
212
+ if (expectedVersion === null && exists) {
213
+ return { ok: false, status: 409, error: createConflictError };
215
214
  }
216
215
  // [SLUG] Create-only routability check: `writeContentParamsSchema.path`'s `parseLogicalPath`
217
216
  // has no charset rule, and `ContentStore.resolvePath` only lowercases the slug — so this is
@@ -360,13 +359,13 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
360
359
  ? {
361
360
  format: body.format,
362
361
  data: normalizedData ?? {},
363
- expectedVersion: body.expectedVersion,
362
+ expectedVersion,
364
363
  }
365
364
  : {
366
365
  format: body.format,
367
366
  data: normalizedData,
368
367
  body: body.body ?? '',
369
- expectedVersion: body.expectedVersion,
368
+ expectedVersion,
370
369
  };
371
370
  // Pass the resolved entryTypeName (not the raw, possibly-omitted
372
371
  // params.entryType) so the store's own format check agrees with the type
@@ -412,12 +411,8 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
412
411
  }
413
412
  // Race-safe fallback: the early `exists` check above catches the common case; this covers a
414
413
  // 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
- };
414
+ if (expectedVersion === null) {
415
+ return { ok: false, status: 409, error: createConflictError };
421
416
  }
422
417
  return {
423
418
  ok: false,
@@ -443,19 +438,19 @@ const validateReferencesHandler = async (gc, ctx, req, params, body) => {
443
438
  const contentRoot = ctx.services.config.contentRoot || 'content';
444
439
  const logicalPathSegments = parseApiPath(params.path, contentRoot);
445
440
  let schemaItem;
446
- let relativePath;
441
+ let slug;
447
442
  try {
448
443
  const resolved = store.resolvePath(logicalPathSegments);
449
444
  schemaItem = resolved.schemaItem;
450
- const slug = resolved.slug;
451
- const pathResult = await store.resolveDocumentPath(schemaItem.logicalPath, slug);
452
- relativePath = pathResult.relativePath;
445
+ slug = resolved.slug;
446
+ // Runs the store's slug and traversal checks before the permission check.
447
+ await store.resolveDocumentPath(schemaItem.logicalPath, slug);
453
448
  }
454
449
  catch (err) {
455
450
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
456
451
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
457
452
  }
458
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, relativePath, req.user, 'read');
453
+ const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
459
454
  if (!access.allowed) {
460
455
  return { ok: false, status: 403, error: 'Forbidden' };
461
456
  }
@@ -510,22 +505,26 @@ const renameEntryHandler = async (gc, ctx, req, params, body) => {
510
505
  const logicalPathSegments = parseApiPath(params.path, contentRoot);
511
506
  let schemaItem;
512
507
  let currentSlug;
513
- let relativePath;
514
508
  try {
515
509
  const resolved = store.resolvePath(logicalPathSegments);
516
510
  schemaItem = resolved.schemaItem;
517
511
  currentSlug = resolved.slug;
518
- const pathResult = await store.resolveDocumentPath(schemaItem.logicalPath, currentSlug);
519
- relativePath = pathResult.relativePath;
512
+ // Runs the store's slug and traversal checks before the permission check.
513
+ await store.resolveDocumentPath(schemaItem.logicalPath, currentSlug);
520
514
  }
521
515
  catch (err) {
522
516
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
523
517
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
524
518
  }
525
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, relativePath, req.user, 'edit');
519
+ const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, currentSlug), req.user, 'edit');
526
520
  if (!access.allowed) {
527
521
  return { ok: false, status: 403, error: 'Forbidden' };
528
522
  }
523
+ // A rename creates the entry at its new path, so that path needs edit access too.
524
+ const destinationAccess = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, body.newSlug), req.user, 'edit');
525
+ if (!destinationAccess.allowed) {
526
+ return { ok: false, status: 403, error: 'Forbidden' };
527
+ }
529
528
  try {
530
529
  const result = await store.renameEntry(schemaItem.logicalPath, currentSlug, body.newSlug);
531
530
  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,
@@ -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,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whether the API client ends request paths with `/`: on a `trailingSlash: true` Next host every
3
+ * unslashed call draws a 308 first. `withCanopy` sets `CANOPY_API_TRAILING_SLASH` in Next's `env`
4
+ * config, which Next substitutes for this literal member expression in server and browser bundles
5
+ * (`getNextConfigEnv`, `next/dist/build/define-env.js:54`). The try/catch covers a host whose
6
+ * bundler neither substitutes it nor shims `process` in the browser.
7
+ */
8
+ export declare function readApiTrailingSlashEnv(): boolean;