canopycms 0.0.68-int.109 → 0.0.68-int.110

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 (90) hide show
  1. package/README.md +2 -2
  2. package/dist/ai/resolve-branch.js +9 -2
  3. package/dist/api/admin-branch-health.js +12 -3
  4. package/dist/api/admin.d.ts +8 -8
  5. package/dist/api/admin.js +2 -1
  6. package/dist/api/branch-merge.js +1 -1
  7. package/dist/api/branch-review.js +1 -1
  8. package/dist/api/branch-status.js +10 -2
  9. package/dist/api/branch.js +4 -3
  10. package/dist/api/comments.d.ts +4 -3
  11. package/dist/api/comments.js +71 -7
  12. package/dist/api/content.js +9 -7
  13. package/dist/api/entries-constants.d.ts +8 -3
  14. package/dist/api/entries-constants.js +8 -3
  15. package/dist/api/entries.js +2 -4
  16. package/dist/api/github-sync.d.ts +2 -0
  17. package/dist/api/github-sync.js +3 -1
  18. package/dist/api/groups.js +1 -0
  19. package/dist/api/guards.js +4 -0
  20. package/dist/api/permissions.js +1 -0
  21. package/dist/api/route-builder.js +12 -1
  22. package/dist/api/settings-helpers.d.ts +5 -1
  23. package/dist/api/settings-helpers.js +10 -2
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/protected-branch.js +2 -1
  26. package/dist/branch-metadata-file.d.ts +6 -0
  27. package/dist/branch-metadata-file.js +2 -0
  28. package/dist/branch-metadata.d.ts +28 -1
  29. package/dist/branch-metadata.js +71 -2
  30. package/dist/branch-workspace.js +6 -8
  31. package/dist/cli/cli.js +348 -171
  32. package/dist/cli/generate-ai-content.js +260 -134
  33. package/dist/cli/init.js +4 -2
  34. package/dist/cli/template-files/cms-stack.ts.template +3 -2
  35. package/dist/comment-store.d.ts +13 -0
  36. package/dist/comment-store.js +42 -8
  37. package/dist/config/schemas/config.js +2 -2
  38. package/dist/config/schemas/field.js +2 -0
  39. package/dist/config/types.d.ts +14 -3
  40. package/dist/content-reader.js +2 -4
  41. package/dist/content-store.d.ts +5 -1
  42. package/dist/content-store.js +18 -10
  43. package/dist/context.js +3 -1
  44. package/dist/editor/admin/SystemHealthPanel.js +1 -1
  45. package/dist/editor/fields/BlockField.js +2 -1
  46. package/dist/editor/fields/ObjectField.js +2 -1
  47. package/dist/editor/hooks/useDraftManager.js +23 -8
  48. package/dist/editor/hooks/useEntryManager.d.ts +5 -1
  49. package/dist/editor/hooks/useEntryManager.js +5 -3
  50. package/dist/git-manager.d.ts +36 -6
  51. package/dist/git-manager.js +66 -18
  52. package/dist/github-service.js +2 -1
  53. package/dist/http/handler.js +16 -2
  54. package/dist/services.d.ts +16 -2
  55. package/dist/services.js +94 -23
  56. package/dist/submission-attribution.d.ts +8 -0
  57. package/dist/submission-attribution.js +19 -0
  58. package/dist/task-queue/worker-status.d.ts +5 -5
  59. package/dist/task-queue/worker-status.js +7 -6
  60. package/dist/types.d.ts +11 -2
  61. package/dist/utils/base-branch.d.ts +13 -0
  62. package/dist/utils/base-branch.js +23 -0
  63. package/dist/utils/content-write-lock.js +4 -3
  64. package/dist/utils/git.d.ts +13 -1
  65. package/dist/utils/git.js +31 -2
  66. package/dist/utils/occ-json-write.js +8 -10
  67. package/dist/utils/provisioning-lock.d.ts +11 -0
  68. package/dist/utils/provisioning-lock.js +34 -29
  69. package/dist/validation/field-traversal.d.ts +2 -0
  70. package/dist/validation/field-traversal.js +2 -1
  71. package/dist/validation/markdown-safety.js +22 -1
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/dist/worker/cms-worker.d.ts +47 -17
  75. package/dist/worker/cms-worker.js +175 -39
  76. package/dist/worker/git-sync.d.ts +1 -1
  77. package/dist/worker/git-sync.js +44 -47
  78. package/dist/worker/github-mirror.d.ts +90 -0
  79. package/dist/worker/github-mirror.js +297 -0
  80. package/dist/worker/history-rewrite.d.ts +3 -4
  81. package/dist/worker/history-rewrite.js +11 -16
  82. package/dist/worker/rebase.js +14 -17
  83. package/dist/worker/remote-git-maintenance.js +2 -1
  84. package/dist/worker/shared-repo-git.d.ts +77 -0
  85. package/dist/worker/shared-repo-git.js +422 -0
  86. package/dist/worker/sparse-cone.js +7 -3
  87. package/dist/worker/task-runner.d.ts +1 -1
  88. package/dist/worker/task-runner.js +78 -99
  89. package/dist/worker/worker-context.d.ts +6 -0
  90. package/package.json +1 -1
package/README.md CHANGED
@@ -47,7 +47,7 @@ you misunderstood, which is why it is worth resolving the ref rather than guessi
47
47
 
48
48
  - Workspaces resolve per mode: `prod` uses `$CANOPYCMS_WORKSPACE_ROOT/content-branches` (default: `/mnt/efs/content-branches`), `dev` uses `.canopy-dev/content-branches/<branch>`.
49
49
  - For `prod` mode, you must set `defaultRemoteUrl`. For `dev`, `defaultRemoteUrl` is **optional** - if omitted, a local remote is auto-created at `.canopy-dev/remote.git`.
50
- - Optionally configure `defaultRemoteName` (default: `origin`) and `defaultBaseBranch` (default: `main`).
50
+ - Optionally configure `defaultRemoteName` (default: `origin`) and `defaultBaseBranch` (unset: the checked-out git branch in dev, the repository's default branch in prod).
51
51
  - Git author identity is required for `prod` mode: set `gitBotAuthorName` and `gitBotAuthorEmail` so bot commits can be created reliably.
52
52
  - Branch names are sanitized and traversal is blocked before creating directories.
53
53
  - Metadata lives at `<workspace>/.canopy-meta/branch.json`; the registry lives at `<branchesRoot>/branches.json` and records the workspaceRoot for each branch.
@@ -515,7 +515,7 @@ export default defineCanopyConfig({
515
515
  **How it works:**
516
516
 
517
517
  1. When you create your first branch, CanopyCMS automatically creates a bare git repository at `.canopy-dev/remote.git`
518
- 2. Your current `baseBranch` (default: `main`) is pushed to this local remote
518
+ 2. Your current `baseBranch` (unset: the checked-out branch) is pushed to this local remote
519
519
  3. Branch workspaces are cloned from this local remote into `.canopy-dev/content-branches/<branch-name>/`
520
520
  4. All git operations (push, fetch, etc.) work against the local remote
521
521
 
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { loadOrCreateBranchContext } from '../branch-workspace.js';
6
6
  import { readsFromCheckout } from '../build-mode.js';
7
+ import { GitManager } from '../git-manager.js';
8
+ import { baseBranchOf } from '../utils/base-branch.js';
7
9
  import { detectHeadBranch } from '../utils/git.js';
8
10
  /**
9
11
  * Resolve the branch root directory for reading content.
@@ -15,6 +17,11 @@ export async function resolveBranchRoot(config) {
15
17
  if (readsFromCheckout(config)) {
16
18
  return process.cwd();
17
19
  }
20
+ // Prod reads an unset base branch from the remote's HEAD, as services does; provisioning
21
+ // needs it even when the active branch is configured.
22
+ const resolved = config.mode === 'prod' && !config.defaultBaseBranch
23
+ ? { ...config, defaultBaseBranch: await GitManager.detectBaseBranch(config, 'throw') }
24
+ : config;
18
25
  let activeBranch;
19
26
  if (config.defaultActiveBranch) {
20
27
  activeBranch = config.defaultActiveBranch;
@@ -23,10 +30,10 @@ export async function resolveBranchRoot(config) {
23
30
  activeBranch = await detectHeadBranch(process.cwd(), config.defaultBaseBranch ?? 'main');
24
31
  }
25
32
  else {
26
- activeBranch = config.defaultBaseBranch ?? 'main';
33
+ activeBranch = baseBranchOf(resolved);
27
34
  }
28
35
  const context = await loadOrCreateBranchContext({
29
- config,
36
+ config: resolved,
30
37
  branchName: activeBranch,
31
38
  mode: config.mode,
32
39
  createdBy: 'canopycms-ai',
@@ -27,6 +27,7 @@ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils
27
27
  import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
28
28
  import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
29
29
  import { defineEndpoint } from './route-builder.js';
30
+ import { baseBranchOf } from '../utils/base-branch.js';
30
31
  /** [H1] A fresh (< 5 min old) init lock blocks purge -- provisioning may be running. */
31
32
  const PROVISIONING_LOCK_FRESH_MS = 5 * 60_000;
32
33
  // Mirrors deleteTaskHandler's fileName pattern in admin.ts: conservative
@@ -65,7 +66,7 @@ const getBranchHealthHandler = async (_gc, ctx, _req) => {
65
66
  // admin handlers must agree with it or the scan silently looks at the
66
67
  // wrong directory.
67
68
  const baseRoot = getDefaultBranchBase(ctx.services.config.mode);
68
- const baseBranchName = ctx.services.config.defaultBaseBranch ?? 'main';
69
+ const baseBranchName = baseBranchOf(ctx.services.config);
69
70
  const contentRootName = ctx.services.config.contentRoot || 'content';
70
71
  try {
71
72
  const entries = await scanBranchHealth(baseRoot, { baseBranchName, contentRootName });
@@ -102,7 +103,7 @@ const getBranchHealthHandler = async (_gc, ctx, _req) => {
102
103
  */
103
104
  const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
104
105
  const baseRoot = getDefaultBranchBase(ctx.services.config.mode);
105
- const baseBranchName = ctx.services.config.defaultBaseBranch ?? 'main';
106
+ const baseBranchName = baseBranchOf(ctx.services.config);
106
107
  const sanitizedBaseBranchName = sanitizeBranchName(baseBranchName);
107
108
  if (params.dirName === sanitizedBaseBranchName) {
108
109
  return { ok: false, status: 400, error: 'The base branch directory can never be purged' };
@@ -412,7 +413,7 @@ async function checkStillCorrupt(dirPath) {
412
413
  * rebuild) and invalidates any ContentStore already registered in THIS
413
414
  * process for the same root.
414
415
  */
415
- const repairContentDuplicatesHandler = async (_gc, ctx, _req, params) => {
416
+ const repairContentDuplicatesHandler = async (_gc, ctx, req, params) => {
416
417
  const baseRoot = getDefaultBranchBase(ctx.services.config.mode);
417
418
  const dirPath = resolveDirWithinBase(baseRoot, params.dirName);
418
419
  if (!dirPath) {
@@ -485,6 +486,14 @@ const repairContentDuplicatesHandler = async (_gc, ctx, _req, params) => {
485
486
  : getErrorMessage(err),
486
487
  };
487
488
  }
489
+ finally {
490
+ // Archived files leave the branch at its next submit, a partial repair's included, so the
491
+ // admin is one of its editors. No `writableBranch` guard can say so: this route names a
492
+ // directory, not a branch.
493
+ if (resolved.length > 0) {
494
+ await ctx.services.recordBranchEditor({ branchRoot: dirPath, baseRoot }, req.user);
495
+ }
496
+ }
488
497
  };
489
498
  /**
490
499
  * Branch directory health scan (healthy/corrupt-metadata/orphan)
@@ -78,10 +78,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
78
78
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
79
79
  limit: z.ZodOptional<z.ZodNumber>;
80
80
  }, "strip", z.ZodTypeAny, {
81
- status: "failed" | "corrupt" | "pending" | "processing" | "completed";
81
+ status: "failed" | "pending" | "corrupt" | "processing" | "completed";
82
82
  limit?: number | undefined;
83
83
  }, {
84
- status: "failed" | "corrupt" | "pending" | "processing" | "completed";
84
+ status: "failed" | "pending" | "corrupt" | "processing" | "completed";
85
85
  limit?: number | undefined;
86
86
  }>;
87
87
  export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
@@ -89,10 +89,10 @@ declare const deleteTaskParamsSchema: z.ZodObject<{
89
89
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
90
90
  fileName: z.ZodEffects<z.ZodString, string, string>;
91
91
  }, "strip", z.ZodTypeAny, {
92
- status: "failed" | "corrupt" | "pending";
92
+ status: "failed" | "pending" | "corrupt";
93
93
  fileName: string;
94
94
  }, {
95
- status: "failed" | "corrupt" | "pending";
95
+ status: "failed" | "pending" | "corrupt";
96
96
  fileName: string;
97
97
  }>;
98
98
  export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
@@ -132,10 +132,10 @@ export declare const ADMIN_ROUTES: {
132
132
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
133
133
  limit: z.ZodOptional<z.ZodNumber>;
134
134
  }, "strip", z.ZodTypeAny, {
135
- status: "failed" | "corrupt" | "pending" | "processing" | "completed";
135
+ status: "failed" | "pending" | "corrupt" | "processing" | "completed";
136
136
  limit?: number | undefined;
137
137
  }, {
138
- status: "failed" | "corrupt" | "pending" | "processing" | "completed";
138
+ status: "failed" | "pending" | "corrupt" | "processing" | "completed";
139
139
  limit?: number | undefined;
140
140
  }>, undefined, AdminTasksResponse>;
141
141
  readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -149,10 +149,10 @@ export declare const ADMIN_ROUTES: {
149
149
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
150
150
  fileName: z.ZodEffects<z.ZodString, string, string>;
151
151
  }, "strip", z.ZodTypeAny, {
152
- status: "failed" | "corrupt" | "pending";
152
+ status: "failed" | "pending" | "corrupt";
153
153
  fileName: string;
154
154
  }, {
155
- status: "failed" | "corrupt" | "pending";
155
+ status: "failed" | "pending" | "corrupt";
156
156
  fileName: string;
157
157
  }>, undefined, AdminDeleteTaskResponse>;
158
158
  };
package/dist/api/admin.js CHANGED
@@ -16,6 +16,7 @@ import { getErrorMessage, isNotFoundError, redactCredentials } from '../utils/er
16
16
  import { getBuildIdentity } from '../build-identity.js';
17
17
  import { loadSharp } from '../assets/sharp-loader.js';
18
18
  import { ADMIN_BRANCH_HEALTH_ROUTES } from './admin-branch-health.js';
19
+ import { baseBranchOf } from '../utils/base-branch.js';
19
20
  /**
20
21
  * 60_000 = DEFAULT_LOCK_STALE_MS in worker/cms-worker.ts, hardcoded rather
21
22
  * than threaded through config: an adopter who overrides `lockStaleMs` skews
@@ -91,7 +92,7 @@ async function readSettingsWorkspaceError(ctx) {
91
92
  async function readBaseSchemaIssues(ctx) {
92
93
  try {
93
94
  const { config, branchSchemaCache, entrySchemaRegistry } = ctx.services;
94
- const base = await ctx.getBranchContext(config.defaultBaseBranch ?? 'main');
95
+ const base = await ctx.getBranchContext(baseBranchOf(config));
95
96
  if (!base)
96
97
  return [];
97
98
  const { issues } = await branchSchemaCache.getSchema(base.branchRoot, entrySchemaRegistry, config.contentRoot || 'content');
@@ -54,7 +54,7 @@ const markAsMergedHandler = async (gc, ctx, _req, params) => {
54
54
  // console.error(`CanopyCMS: Failed to delete remote branch ${branchName}:`, err)
55
55
  // }
56
56
  // }
57
- // Comments.json is already in the branch workspace at .canopycms/comments.json
57
+ // Comments.json is already in the branch workspace at .canopy-meta/comments.json
58
58
  // It will be preserved with the archived branch - no action needed
59
59
  return {
60
60
  ok: true,
@@ -17,7 +17,7 @@ const requestChangesHandler = async (gc, ctx, _req, _params) => {
17
17
  const updated = await meta.save({
18
18
  branch: { name: branchContext.branch.name, status: 'editing' },
19
19
  });
20
- // TODO: Optionally record comment in .canopycms/comments.json when comment system is implemented
20
+ // TODO: Optionally record comment in .canopy-meta/comments.json when comment system is implemented
21
21
  return {
22
22
  ok: true,
23
23
  status: 200,
@@ -12,6 +12,7 @@ import { isNonFastForwardRejection } from '../utils/git.js';
12
12
  import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
13
13
  import { submissionEditorFromUser } from '../submission-attribution.js';
14
14
  import { NothingToSubmitError } from '../services.js';
15
+ import { baseBranchOf } from '../utils/base-branch.js';
15
16
  const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
16
17
  const { branchContext } = gc;
17
18
  return { ok: true, status: 200, data: { branch: branchContext.branch } };
@@ -53,10 +54,16 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
53
54
  }
54
55
  // Commit and push changes
55
56
  const submitter = submissionEditorFromUser(req.user);
57
+ const { authPlugin } = ctx;
56
58
  let changedPaths;
59
+ let editors;
57
60
  try {
58
61
  ;
59
- ({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
62
+ ({ changedPaths, editors } = await ctx.services.submitBranch({
63
+ context: branchContext,
64
+ submitter,
65
+ lookupEditor: authPlugin ? (id) => authPlugin.getUserMetadata(id) : undefined,
66
+ }));
60
67
  }
61
68
  catch (err) {
62
69
  if (err instanceof NothingToSubmitError) {
@@ -113,6 +120,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
113
120
  const submittedAt = new Date().toISOString();
114
121
  const prResult = await syncSubmitPr(ctx, branchContext, {
115
122
  submitter,
123
+ editors,
116
124
  changedPaths,
117
125
  submittedAt,
118
126
  });
@@ -123,7 +131,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
123
131
  // stays editable; the stamp records that it is now on GitHub, for delete.
124
132
  if (prResult.nothingToSubmit) {
125
133
  await meta.save({ branch: { name: branchContext.branch.name, ...pushed } });
126
- const base = branchContext.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
134
+ const base = branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config);
127
135
  return {
128
136
  ok: false,
129
137
  status: 400,
@@ -16,6 +16,7 @@ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRA
16
16
  import { GitManager } from '../git-manager.js';
17
17
  import { syncDeleteRemoteBranch } from './github-sync.js';
18
18
  import { branchNameSchema, branchParamSchema } from './validators.js';
19
+ import { baseBranchOf } from '../utils/base-branch.js';
19
20
  const log = createDebugLogger({ prefix: 'BranchAPI' });
20
21
  const createBranchBodySchema = z.object({
21
22
  branch: branchNameSchema,
@@ -300,7 +301,7 @@ export const createBranchHandler = async (ctx, req, body) => {
300
301
  // Load path permissions from the base branch's JSON file (the resolved
301
302
  // fork point — baked into config at service creation; dev-mode git HEAD
302
303
  // when not explicitly configured)
303
- const baseBranch = ctx.services.config.defaultBaseBranch ?? 'main';
304
+ const baseBranch = baseBranchOf(ctx.services.config);
304
305
  const baseBranchContext = await ctx.getBranchContext(baseBranch);
305
306
  let pathPermissions = [];
306
307
  if (baseBranchContext) {
@@ -377,7 +378,7 @@ export const listBranchesHandler = async (ctx, req) => {
377
378
  // but registry branch names are filesystem-sanitized ('claude-foo') — the
378
379
  // editor matches defaultBranch against registry names, so return the form
379
380
  // that can actually be found there.
380
- const defaultBranch = sanitizeBranchName(ctx.services.config.defaultActiveBranch ?? ctx.services.config.defaultBaseBranch ?? 'main');
381
+ const defaultBranch = sanitizeBranchName(ctx.services.config.defaultActiveBranch ?? baseBranchOf(ctx.services.config));
381
382
  // Admins and Reviewers see all branches
382
383
  if (isPrivileged(req.user.groups)) {
383
384
  return {
@@ -551,7 +552,7 @@ export const deleteBranchHandler = async (ctx, req, params) => {
551
552
  const sanitizedDeleted = sanitizeBranchName(branchContext.branch.name);
552
553
  // Defense-in-depth only -- isProtected above already rejects the base
553
554
  // branch, and settings branches never resolve through getBranchContext.
554
- const sanitizedBase = sanitizeBranchName(branchContext.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main');
555
+ const sanitizedBase = sanitizeBranchName(branchContext.branch.baseBranch ?? baseBranchOf(ctx.services.config));
555
556
  const deletableHead = sanitizedDeleted !== sanitizedBase &&
556
557
  !sanitizedDeleted.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
557
558
  if (deletableHead &&
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { ApiResponse } from './types.js';
3
3
  import type { CommentThread, CommentType } from '../comment-store.js';
4
+ import type { LogicalPath } from '../paths/types.js';
4
5
  export interface AddCommentBody {
5
6
  text: string;
6
7
  threadId?: string;
@@ -48,18 +49,18 @@ export declare const COMMENT_ROUTES: {
48
49
  text: z.ZodString;
49
50
  threadId: z.ZodOptional<z.ZodString>;
50
51
  type: z.ZodEnum<["field", "entry", "branch"]>;
51
- entryPath: z.ZodOptional<z.ZodType<import("../paths/index.js").LogicalPath, z.ZodTypeDef, import("../paths/index.js").LogicalPath>>;
52
+ entryPath: z.ZodOptional<z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>>;
52
53
  canopyPath: z.ZodOptional<z.ZodString>;
53
54
  }, "strip", z.ZodTypeAny, {
54
55
  type: "branch" | "entry" | "field";
55
56
  text: string;
56
- entryPath?: import("../paths/index.js").LogicalPath | undefined;
57
+ entryPath?: LogicalPath | undefined;
57
58
  threadId?: string | undefined;
58
59
  canopyPath?: string | undefined;
59
60
  }, {
60
61
  type: "branch" | "entry" | "field";
61
62
  text: string;
62
- entryPath?: import("../paths/index.js").LogicalPath | undefined;
63
+ entryPath?: LogicalPath | undefined;
63
64
  threadId?: string | undefined;
64
65
  canopyPath?: string | undefined;
65
66
  }>, AddCommentResponse>;
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { CommentStore } from '../comment-store.js';
3
3
  import { isReviewer } from '../authorization/index.js';
4
+ import { parseLogicalPath } from '../paths/validation.js';
5
+ import { normalizeFilesystemPath } from '../paths/normalize.js';
4
6
  import { defineEndpoint } from './route-builder.js';
5
7
  import { branchParamSchema, branchNameSchema, logicalPathSchema } from './validators.js';
6
8
  // ============================================================================
@@ -17,13 +19,47 @@ const addCommentBodySchema = z.object({
17
19
  entryPath: logicalPathSchema.optional(),
18
20
  canopyPath: z.string().optional(), // Canopy field path, not a file path
19
21
  });
20
- const listCommentsHandler = async (gc, _ctx, _req, _params) => {
22
+ /**
23
+ * Whether `entryPath` is spelled the way path rules expect an entry's logical path: rooted at
24
+ * the content root, with no empty or `.` segment. Rules are globs over that spelling, so
25
+ * `secret/plan` (which the content API reads as `content/secret/plan`) and
26
+ * `content/./secret/plan` both miss a rule written for `content/secret/**`.
27
+ */
28
+ const isCanonicalEntryPath = (entryPath, contentRoot) => entryPath.startsWith(`${normalizeFilesystemPath(contentRoot)}/`) &&
29
+ entryPath.split('/').every((segment) => segment !== '' && segment !== '.');
30
+ /**
31
+ * Whether the user may see a thread: its entry's path rules at `read`, the level a content
32
+ * read needs, so a thread discloses nothing about an entry its reader could not open. A
33
+ * field thread is governed by its entry. A thread with no entry path (a branch thread) is
34
+ * governed by branch access alone, which the `branchAccess` guard has already passed. An
35
+ * entry path that is not a canonical logical path is denied: comments.json is read without
36
+ * schema validation.
37
+ */
38
+ const canReadThreadEntry = (checkAccess, contentRoot, entryPath) => {
39
+ if (entryPath === undefined)
40
+ return true;
41
+ const parsed = parseLogicalPath(entryPath);
42
+ return (parsed.ok &&
43
+ isCanonicalEntryPath(parsed.path, contentRoot) &&
44
+ checkAccess(parsed.path, 'read').allowed);
45
+ };
46
+ /** One checker per request, bound to the content root its paths are canonical against. */
47
+ const createThreadReadCheck = async (ctx, req, branchContext) => {
48
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
49
+ const contentRoot = ctx.services.config.contentRoot || 'content';
50
+ return (entryPath) => canReadThreadEntry(checkAccess, contentRoot, entryPath);
51
+ };
52
+ const listCommentsHandler = async (gc, ctx, req, _params) => {
21
53
  const { branchContext } = gc;
22
54
  const commentStore = new CommentStore(branchContext.branchRoot);
23
- const threads = await commentStore.listThreads({ includeResolved: true });
55
+ const [allThreads, canRead] = await Promise.all([
56
+ commentStore.listThreads({ includeResolved: true }),
57
+ createThreadReadCheck(ctx, req, branchContext),
58
+ ]);
59
+ const threads = allThreads.filter((thread) => canRead(thread.entryPath));
24
60
  return { ok: true, status: 200, data: { threads } };
25
61
  };
26
- const addCommentHandler = async (gc, _ctx, req, _params, body) => {
62
+ const addCommentHandler = async (gc, ctx, req, _params, body) => {
27
63
  const { branchContext } = gc;
28
64
  // Validate required fields based on type
29
65
  if (body.type === 'field' && !body.canopyPath) {
@@ -40,22 +76,50 @@ const addCommentHandler = async (gc, _ctx, req, _params, body) => {
40
76
  error: 'entryPath required for field/entry comments',
41
77
  };
42
78
  }
79
+ // Commenting needs read access to the entry the comment lands on. A reply lands on the
80
+ // stored thread, so it is checked against that thread's entry path, which never changes
81
+ // once the thread exists; a reply to a thread the user cannot read answers as missing, as
82
+ // resolve does.
43
83
  const commentStore = new CommentStore(branchContext.branchRoot);
84
+ const [existing, canRead] = await Promise.all([
85
+ body.threadId ? commentStore.getThread(body.threadId) : Promise.resolve(null),
86
+ createThreadReadCheck(ctx, req, branchContext),
87
+ ]);
88
+ if (body.threadId) {
89
+ if (!existing || !canRead(existing.entryPath)) {
90
+ return { ok: false, status: 404, error: 'Thread not found' };
91
+ }
92
+ const reply = await commentStore.addReply({
93
+ userId: req.user.userId,
94
+ text: body.text,
95
+ threadId: body.threadId,
96
+ });
97
+ if (!reply) {
98
+ return { ok: false, status: 404, error: 'Thread not found' };
99
+ }
100
+ return { ok: true, status: 201, data: reply };
101
+ }
102
+ if (!canRead(body.entryPath)) {
103
+ return { ok: false, status: 403, error: 'Forbidden' };
104
+ }
44
105
  const result = await commentStore.addComment({
45
106
  userId: req.user.userId,
46
107
  text: body.text,
47
- threadId: body.threadId,
48
108
  type: body.type,
49
109
  entryPath: body.entryPath,
50
110
  canopyPath: body.canopyPath,
51
111
  });
52
112
  return { ok: true, status: 201, data: result };
53
113
  };
54
- const resolveCommentHandler = async (gc, _ctx, req, params) => {
114
+ const resolveCommentHandler = async (gc, ctx, req, params) => {
55
115
  const { branchContext } = gc;
56
116
  const commentStore = new CommentStore(branchContext.branchRoot);
57
- const thread = await commentStore.getThread(params.threadId);
58
- if (!thread) {
117
+ const [thread, canRead] = await Promise.all([
118
+ commentStore.getThread(params.threadId),
119
+ createThreadReadCheck(ctx, req, branchContext),
120
+ ]);
121
+ // A thread the user cannot read answers as missing, so its id does not confirm it exists.
122
+ if (!thread || !canRead(thread.entryPath)) {
59
123
  return { ok: false, status: 404, error: 'Thread not found' };
60
124
  }
61
125
  // Check permissions: thread author, reviewer, or admin
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { ENTRY_CHANGED_MESSAGE } from './entries-constants.js';
2
3
  import { BranchSyncingError, ContentStore, ContentStoreError, ContentConflictError, DuplicateContentIdError, UrlPathConflictError, getDefaultEntryType, } from '../content-store.js';
3
4
  import { defineEndpoint } from './route-builder.js';
4
5
  import { SchemaUnavailableError } from '../schema/schema-unavailable-error.js';
@@ -480,7 +481,12 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
480
481
  // refused outright, but a lock compromised mid-write can still land it — each case carries
481
482
  // its own message, so pass `err.message` through rather than the generic conflict below.
482
483
  if (err instanceof BranchSyncingError) {
483
- return { ok: false, status: 409, error: err.message };
484
+ return {
485
+ ok: false,
486
+ status: 409,
487
+ error: err.message,
488
+ ...(err.outcome === 'unknown' ? { code: 'WRITE_OUTCOME_UNKNOWN' } : {}),
489
+ };
484
490
  }
485
491
  // [F1] Also not an editor collision: this content ID is quarantined on two files
486
492
  // (ContentIdIndex's duplicate-ID detection). The generic message below would send the
@@ -500,11 +506,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
500
506
  if (expectedVersion === null) {
501
507
  return { ok: false, status: 409, error: createConflictError };
502
508
  }
503
- return {
504
- ok: false,
505
- status: 409,
506
- error: 'Content conflict: entry was modified by another editor',
507
- };
509
+ return { ok: false, status: 409, error: ENTRY_CHANGED_MESSAGE };
508
510
  }
509
511
  // C2: a ContentStoreError is an expected client fault (bad slug, validation, etc.) and keeps
510
512
  // its 400. Anything else — ENOSPC, EACCES, a bug — is a genuine server fault and must not be
@@ -624,7 +626,7 @@ const renameEntryHandler = async (gc, ctx, req, params, body) => {
624
626
  return {
625
627
  ok: false,
626
628
  status: 409,
627
- error: passThrough ? err.message : 'Content conflict: entry was modified by another editor',
629
+ error: passThrough ? err.message : ENTRY_CHANGED_MESSAGE,
628
630
  };
629
631
  }
630
632
  // C2: same rule as writeContentHandler's catch above (ContentStoreError -> 400, everything
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Pagination constants for the entries list endpoint. Kept dependency-free (no server-only
3
- * imports) so the editor's browser bundle can import the page-size cap without pulling in
4
- * `entries.ts` and its `node:fs`-backed deps; `entries.ts` re-exports these for API discoverability.
2
+ * Constants the entries and content endpoints share with the editor. Kept dependency-free (no
3
+ * server-only imports) so the editor's browser bundle can import them without pulling in
4
+ * `entries.ts` and its `node:fs`-backed deps.
5
5
  */
6
6
  /**
7
7
  * Maximum entries the list endpoint returns per request; larger `limit` values are clamped to
@@ -10,3 +10,8 @@
10
10
  export declare const MAX_ENTRIES_PER_PAGE = 200;
11
11
  /** Default page size when a request omits `limit`. */
12
12
  export declare const DEFAULT_ENTRIES_LIMIT = 50;
13
+ /**
14
+ * The generic 409 for a conflicting change to an entry, usually its version moving on since the
15
+ * caller read it. Shared with the editor, whose own pre-save staleness check reports the same.
16
+ */
17
+ export declare const ENTRY_CHANGED_MESSAGE = "This entry changed since you opened it. Reload to see the latest version (your unsaved edits will be lost).";
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Pagination constants for the entries list endpoint. Kept dependency-free (no server-only
3
- * imports) so the editor's browser bundle can import the page-size cap without pulling in
4
- * `entries.ts` and its `node:fs`-backed deps; `entries.ts` re-exports these for API discoverability.
2
+ * Constants the entries and content endpoints share with the editor. Kept dependency-free (no
3
+ * server-only imports) so the editor's browser bundle can import them without pulling in
4
+ * `entries.ts` and its `node:fs`-backed deps.
5
5
  */
6
6
  /**
7
7
  * Maximum entries the list endpoint returns per request; larger `limit` values are clamped to
@@ -10,3 +10,8 @@
10
10
  export const MAX_ENTRIES_PER_PAGE = 200;
11
11
  /** Default page size when a request omits `limit`. */
12
12
  export const DEFAULT_ENTRIES_LIMIT = 50;
13
+ /**
14
+ * The generic 409 for a conflicting change to an entry, usually its version moving on since the
15
+ * caller read it. Shared with the editor, whose own pre-save staleness check reports the same.
16
+ */
17
+ export const ENTRY_CHANGED_MESSAGE = 'This entry changed since you opened it. Reload to see the latest version (your unsaved edits will be lost).';
@@ -7,7 +7,7 @@ import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils
7
7
  import { createDebugLogger } from '../utils/debug.js';
8
8
  import { resolveEntryTitle } from '../utils/title-field.js';
9
9
  import { branchNameSchema, logicalPathSchema, queryBooleanSchema } from './validators.js';
10
- import { MAX_ENTRIES_PER_PAGE, DEFAULT_ENTRIES_LIMIT } from './entries-constants.js';
10
+ import { MAX_ENTRIES_PER_PAGE, DEFAULT_ENTRIES_LIMIT, ENTRY_CHANGED_MESSAGE, } from './entries-constants.js';
11
11
  import { SchemaOps, SchemaStoreBusyError } from '../schema/schema-store.js';
12
12
  import { listCollectionEntries as listCollectionEntriesShared, listEntries as listContentEntries, sortByOrder, } from '../content-listing.js';
13
13
  import { findReferencingEntries } from '../validation/deletion-checker.js';
@@ -416,9 +416,7 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
416
416
  return {
417
417
  ok: false,
418
418
  status: 409,
419
- error: err instanceof BranchSyncingError
420
- ? err.message
421
- : 'Content conflict: entry was modified by another editor',
419
+ error: err instanceof BranchSyncingError ? err.message : ENTRY_CHANGED_MESSAGE,
422
420
  };
423
421
  }
424
422
  return {
@@ -18,6 +18,8 @@ export interface GitHubSyncResult {
18
18
  /** What the PR body records about a submit. */
19
19
  export interface SubmissionRecord {
20
20
  submitter?: SubmissionEditor;
21
+ /** Every recorded editor of the branch; the PR body lists those other than the submitter. */
22
+ editors?: readonly SubmissionEditor[];
21
23
  changedPaths: readonly string[];
22
24
  /** The submit's `submittedAt`, carried to the worker so its failure handling targets this submit. */
23
25
  submittedAt?: string;
@@ -5,6 +5,7 @@ import { getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
5
5
  import { sanitizeBranchName } from '../paths/branch-name.js';
6
6
  import { buildPrSection, mergePrSection } from '../submission-attribution.js';
7
7
  import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
8
+ import { baseBranchOf } from '../utils/base-branch.js';
8
9
  /**
9
10
  * Uses githubService directly if available, otherwise queues a task for the worker.
10
11
  *
@@ -18,10 +19,11 @@ export async function syncSubmitPr(ctx, context, submission) {
18
19
  const prSection = buildPrSection({
19
20
  description: context.branch.description,
20
21
  submitter: submission.submitter,
22
+ editors: submission.editors,
21
23
  changedPaths: submission.changedPaths,
22
24
  });
23
25
  // Target the fork point recorded at branch creation when available.
24
- const baseBranch = context.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
26
+ const baseBranch = context.branch.baseBranch ?? baseBranchOf(ctx.services.config);
25
27
  if (!clientOperatingStrategy(mode).supportsPullRequests()) {
26
28
  return {};
27
29
  }
@@ -180,6 +180,7 @@ const updateInternalGroupsHandler = async (_gc, ctx, req, body) => {
180
180
  branchRoot: context.branchRoot,
181
181
  fileName: 'groups.json',
182
182
  message: 'Update internal groups',
183
+ actor: req.user,
183
184
  mode,
184
185
  });
185
186
  if (!commitResult.pushed) {
@@ -94,6 +94,10 @@ const runBranchAccessWithSchemaGuard = async (ctx, req, params, accumulated) =>
94
94
  }
95
95
  return { ok: true, context: { branchContext: context } };
96
96
  };
97
+ /**
98
+ * A success records the user as an editor the next submit credits (route-builder.ts), so give
99
+ * this guard only to endpoints that change the branch's working tree.
100
+ */
97
101
  const runWritableBranchGuard = async (ctx, _req, params, accumulated) => {
98
102
  const branch = extractBranchName(params);
99
103
  if (typeof branch !== 'string')
@@ -82,6 +82,7 @@ const updatePermissionsHandler = async (_gc, ctx, req, body) => {
82
82
  branchRoot: context.branchRoot,
83
83
  fileName: 'permissions.json',
84
84
  message: 'Update permissions',
85
+ actor: req.user,
85
86
  mode,
86
87
  });
87
88
  if (!commitResult.pushed) {
@@ -9,6 +9,9 @@
9
9
  * - Self-documents the API surface
10
10
  */
11
11
  import { executeGuards } from './guards.js';
12
+ function isOkResponse(response) {
13
+ return (typeof response === 'object' && response !== null && 'ok' in response && response.ok === true);
14
+ }
12
15
  /**
13
16
  * Global registry - generator reads this
14
17
  */
@@ -71,6 +74,7 @@ export function defineEndpoint(config) {
71
74
  if (config.guards && config.guards.length > 0) {
72
75
  const guards = config.guards;
73
76
  const guardedHandler = config.handler;
77
+ const recordsEditor = guards.includes('writableBranch');
74
78
  handler = async (ctx, req, ...args) => {
75
79
  // Extract params from args (first arg after ctx/req if present)
76
80
  const params = args[0] ?? {};
@@ -79,7 +83,14 @@ export function defineEndpoint(config) {
79
83
  return guardResult.response;
80
84
  }
81
85
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
82
- return guardedHandler(guardResult.guardContext, ctx, req, ...args);
86
+ const response = await guardedHandler(guardResult.guardContext, ctx, req, ...args);
87
+ // Every 'writableBranch' endpoint changes the branch's working tree, so one that succeeds
88
+ // makes its user an editor of the branch.
89
+ if (recordsEditor && isOkResponse(response)) {
90
+ const { branchContext } = guardResult.guardContext;
91
+ await ctx.services.recordBranchEditor(branchContext, req.user);
92
+ }
93
+ return response;
83
94
  };
84
95
  }
85
96
  else {
@@ -1,5 +1,6 @@
1
1
  import type { ApiContext } from './types.js';
2
2
  import type { OperatingMode } from '../operating-mode/index.js';
3
+ import type { CanopyUser } from '../user.js';
3
4
  /**
4
5
  * Get the appropriate root path for settings (permissions/groups).
5
6
  * Returns the settings root managed by the settings workspace.
@@ -30,7 +31,8 @@ export interface CommitSettingsResult {
30
31
  /**
31
32
  * Commit and push settings changes based on the mode.
32
33
  * Both prod and dev use commitToSettingsBranch.
33
- * Settings changes are never reviewed through a PR (see commitToSettingsBranch).
34
+ * Settings changes are never reviewed through a PR (see commitToSettingsBranch), and the bot
35
+ * authors the commit, so its trailers, not its author line, name who made the change.
34
36
  */
35
37
  export declare function commitSettings(ctx: ApiContext, options: {
36
38
  context: {
@@ -39,5 +41,7 @@ export declare function commitSettings(ctx: ApiContext, options: {
39
41
  branchRoot: string;
40
42
  fileName: string;
41
43
  message: string;
44
+ /** The user making the change, named in the commit's trailers as a submit names its editors. */
45
+ actor: CanopyUser;
42
46
  mode: OperatingMode;
43
47
  }): Promise<CommitSettingsResult>;