canopycms 0.0.67 → 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
@@ -20,7 +20,7 @@ import { invalidateContentIndexesForRoot } from './content-index-registry.js';
20
20
  import { invalidateBranchContentCaches } from './content-index-generation.js';
21
21
  import { createDebugLogger } from './utils/debug.js';
22
22
  import { getErrorMessage, isNotFoundError } from './utils/error.js';
23
- import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch } from './utils/git.js';
23
+ import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch, stageAllExceptCanopyState, } from './utils/git.js';
24
24
  import { acquireProvisioningLock } from './utils/provisioning-lock.js';
25
25
  const log = createDebugLogger({ prefix: 'GitManager' });
26
26
  /**
@@ -166,6 +166,21 @@ export class GitRemoteRefMissingError extends Error {
166
166
  this.name = 'GitRemoteRefMissingError';
167
167
  }
168
168
  }
169
+ /**
170
+ * No git remote is configured and the one the strategy auto-detects does not
171
+ * exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
172
+ * its first boot, so until then every request that needs a workspace fails
173
+ * here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
174
+ * The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
175
+ */
176
+ export class RemoteNotReadyError extends Error {
177
+ constructor(expectedRemotePath) {
178
+ super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is ` +
179
+ `not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
180
+ this.expectedRemotePath = expectedRemotePath;
181
+ this.name = 'RemoteNotReadyError';
182
+ }
183
+ }
169
184
  export class GitManager {
170
185
  constructor(options, gitOptions) {
171
186
  this.repoPath = path.resolve(options.repoPath);
@@ -696,6 +711,10 @@ export class GitManager {
696
711
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
697
712
  });
698
713
  if (!remoteUrl) {
714
+ const { operatingStrategy } = await import('./operating-mode/index.js');
715
+ const { autoDetectRemotePath } = operatingStrategy(options.mode).getRemoteUrlConfig();
716
+ if (autoDetectRemotePath)
717
+ throw new RemoteNotReadyError(autoDetectRemotePath);
699
718
  throw new Error('CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace');
700
719
  }
701
720
  try {
@@ -970,6 +989,10 @@ export class GitManager {
970
989
  const fileArray = Array.isArray(files) ? files : [files];
971
990
  await this.git.add(fileArray);
972
991
  }
992
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
993
+ async addAllExceptCanopyState() {
994
+ await stageAllExceptCanopyState(this.git);
995
+ }
973
996
  async commit(message) {
974
997
  await this.git.commit(message);
975
998
  }
@@ -1010,12 +1033,14 @@ export class GitManager {
1010
1033
  // `--end-of-options` before every caller-influenced ref name, as in push()
1011
1034
  // above: names are sanitized upstream, but this file's rule is that a
1012
1035
  // positional is guarded where it is passed, not where it was validated.
1013
- const target = branch ?? (await this.git.revparse(['--abbrev-ref', '--end-of-options', 'HEAD']));
1014
- const localSha = (await this.git.revparse(['--end-of-options', target])).trim();
1036
+ // Each rev-parse needs `--verify`: without it, rev-parse echoes
1037
+ // `--end-of-options` as its own output line ahead of the result.
1038
+ const target = branch ?? (await this.git.revparse(['--verify', '--abbrev-ref', '--end-of-options', 'HEAD']));
1039
+ const localSha = (await this.git.revparse(['--verify', '--end-of-options', target])).trim();
1015
1040
  let fetchedTip;
1016
1041
  try {
1017
1042
  await this.git.raw(['fetch', '--end-of-options', this.remote, target]);
1018
- fetchedTip = (await this.git.revparse(['--end-of-options', 'FETCH_HEAD'])).trim();
1043
+ fetchedTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
1019
1044
  }
1020
1045
  catch {
1021
1046
  // No ref on the remote yet -- the branch has never been pushed.
@@ -1028,6 +1053,27 @@ export class GitManager {
1028
1053
  const aheadCount = (await this.git.raw(['rev-list', '--count', `${fetchedTip}..${localSha}`])).trim();
1029
1054
  return aheadCount !== '0';
1030
1055
  }
1056
+ /**
1057
+ * Repo-relative paths that differ between HEAD and its merge base with the
1058
+ * base branch: everything the branch changes, across all of its commits.
1059
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
1060
+ * constraint.
1061
+ */
1062
+ async listChangedPathsSinceBase() {
1063
+ await this.git.raw(['fetch', '--end-of-options', this.remote, this.baseBranch]);
1064
+ // `--verify`: without it rev-parse echoes `--end-of-options` back as output.
1065
+ const baseTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
1066
+ // -z: NUL-separated and unquoted, so no path needs unescaping.
1067
+ const output = await this.git.raw([
1068
+ 'diff',
1069
+ '--name-only',
1070
+ '-z',
1071
+ '--no-renames',
1072
+ `${baseTip}...HEAD`,
1073
+ '--',
1074
+ ]);
1075
+ return output.split('\0').filter((p) => p.length > 0);
1076
+ }
1031
1077
  async ensureAuthor(author) {
1032
1078
  const config = (await this.git.listConfig());
1033
1079
  // Verify this is a CanopyCMS-managed workspace before setting author
@@ -76,6 +76,7 @@ export interface PullRequestDetails {
76
76
  state: 'open' | 'closed';
77
77
  merged: boolean;
78
78
  draft: boolean;
79
+ body: string;
79
80
  }
80
81
  export interface CreateOrUpdatePullRequestParams {
81
82
  octokit: Octokit;
@@ -87,6 +88,11 @@ export interface CreateOrUpdatePullRequestParams {
87
88
  body: string;
88
89
  /** Convert a pre-existing draft PR to ready-for-review after updating it. */
89
90
  markReadyIfDraft?: boolean;
91
+ /**
92
+ * `body` is the canopycms PR section: an existing PR keeps the text outside
93
+ * its section (see mergePrSection) instead of having its body replaced.
94
+ */
95
+ mergeSectionIntoBody?: boolean;
90
96
  /** Forwarded to all GitHub requests (worker task-timeout abort). */
91
97
  signal?: AbortSignal;
92
98
  }
@@ -128,6 +134,8 @@ export declare class GitHubService {
128
134
  body: string;
129
135
  /** Convert a pre-existing draft PR to ready-for-review after updating it. */
130
136
  markReadyIfDraft?: boolean;
137
+ /** See {@link CreateOrUpdatePullRequestParams.mergeSectionIntoBody}. */
138
+ mergeSectionIntoBody?: boolean;
131
139
  }): Promise<{
132
140
  number: number;
133
141
  url: string;
@@ -7,6 +7,7 @@ import { getErrorMessage } from './utils/error.js';
7
7
  // or CloudWatch folds it into the previous event. Under Lambda/dev the helper
8
8
  // is plain console. See utils/logger.ts.
9
9
  import { canopyLogWarn } from './utils/logger.js';
10
+ import { mergePrSection } from './submission-attribution.js';
10
11
  const ThrottledOctokit = Octokit.plugin(throttling);
11
12
  /**
12
13
  * Retry primary rate limits at most twice and only for short waits; beyond
@@ -67,6 +68,7 @@ export function createCanopyOctokit(options) {
67
68
  */
68
69
  export async function createOrUpdatePullRequest(params) {
69
70
  const { octokit, owner, repo, head, base, title, body, markReadyIfDraft, signal } = params;
71
+ const mergeSection = params.mergeSectionIntoBody === true;
70
72
  // CONDITIONAL spread: with no signal, the request objects below carry no
71
73
  // `request` key at all (github-service.test.ts asserts on their exact shape).
72
74
  const requestOption = signal ? { request: { signal } } : {};
@@ -92,7 +94,7 @@ export async function createOrUpdatePullRequest(params) {
92
94
  repo,
93
95
  pull_number: existing.number,
94
96
  title,
95
- body,
97
+ body: mergeSection ? mergePrSection(existing.body, body) : body,
96
98
  ...requestOption,
97
99
  });
98
100
  // Only a pre-existing PR can be a draft on this path — pulls.create
@@ -185,6 +187,7 @@ export class GitHubService {
185
187
  title: options.title,
186
188
  body: options.body,
187
189
  markReadyIfDraft: options.markReadyIfDraft,
190
+ mergeSectionIntoBody: options.mergeSectionIntoBody,
188
191
  });
189
192
  return { number: result.number, url: result.url };
190
193
  }
@@ -200,6 +203,7 @@ export class GitHubService {
200
203
  state: response.data.state,
201
204
  merged: response.data.merged ?? false,
202
205
  draft: response.data.draft ?? false,
206
+ body: response.data.body ?? '',
203
207
  };
204
208
  }
205
209
  async convertToDraft(prNumber) {
@@ -1,4 +1,5 @@
1
1
  import { jsonResponse, isCanopyBinaryResponse } from './types.js';
2
+ import { workerNotReadyResponse } from './worker-not-ready.js';
2
3
  import { createCanopyRouter } from './router.js';
3
4
  import { assertAuthPluginAllowedForMode } from '../auth/plugin.js';
4
5
  import { createCanopyServices } from '../services.js';
@@ -13,6 +14,7 @@ import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../uti
13
14
  // stay out of the worker's runtime import closure, so new log lines here go
14
15
  // through the indirection (utils/logger.ts).
15
16
  import { canopyLogError } from '../utils/logger.js';
17
+ import { runWithRequestTiming, setRequestTimingRoute, timeRequestPhase, } from '../utils/request-timing.js';
16
18
  const buildContext = async (options) => {
17
19
  const services = options.services ?? (options.config ? await createCanopyServices(options.config) : undefined);
18
20
  if (!services) {
@@ -62,7 +64,7 @@ const buildContext = async (options) => {
62
64
  return {
63
65
  services,
64
66
  assetStore: options.assetStore,
65
- getBranchContext,
67
+ getBranchContext: (branch, opts) => timeRequestPhase('branchContext', () => getBranchContext(branch, opts)),
66
68
  authPlugin: options.authPlugin,
67
69
  };
68
70
  };
@@ -109,10 +111,11 @@ export function createCanopyRequestHandler(options) {
109
111
  if (!match) {
110
112
  return jsonResponse({ ok: false, status: 404, error: 'Not found' }, 404);
111
113
  }
112
- const apiCtx = await getContext();
114
+ setRequestTimingRoute(match.pattern.join('/') || '(malformed path)');
115
+ const apiCtx = await timeRequestPhase('context', getContext);
113
116
  // In dev mode, re-check if the developer switched git branches
114
- await apiCtx.services.refreshActiveBranch();
115
- const authResult = await options.authPlugin.authenticate(req);
117
+ await timeRequestPhase('refreshBranch', () => apiCtx.services.refreshActiveBranch());
118
+ const authResult = await timeRequestPhase('auth', () => options.authPlugin.authenticate(req));
116
119
  // API routes require authentication. Anonymous callers are rejected BEFORE
117
120
  // any workspace provisioning below, so they can neither trigger expensive
118
121
  // git operations nor read provisioning error details.
@@ -141,6 +144,9 @@ export function createCanopyRequestHandler(options) {
141
144
  // (authenticated) client. Credentials, which git errors can embed, are
142
145
  // redacted even from the server logs.
143
146
  console.error(`CanopyCMS: Failed to provision workspace for base branch '${baseBranch}': ${redactCredentials(message)}`);
147
+ const notReady = workerNotReadyResponse(err);
148
+ if (notReady)
149
+ return notReady;
144
150
  return jsonResponse({
145
151
  ok: false,
146
152
  status: 503,
@@ -157,15 +163,21 @@ export function createCanopyRequestHandler(options) {
157
163
  // a silent authorization change.
158
164
  let user;
159
165
  try {
160
- user = await resolveCanopyUser(authResult, {
166
+ user = await timeRequestPhase('user', () => resolveCanopyUser(authResult, {
161
167
  getSettingsBranchRoot: apiCtx.services.getSettingsBranchRoot,
162
168
  mode: apiCtx.services.config.mode,
163
169
  bootstrapAdminIds: apiCtx.services.bootstrapAdminIds,
164
- });
170
+ }));
165
171
  }
166
172
  catch (err) {
167
173
  const message = getErrorMessage(err);
168
174
  canopyLogError(`CanopyCMS: Failed to resolve internal groups from the settings workspace: ${redactCredentials(message)}`);
175
+ // No remote means no settings workspace, so /admin cannot load either:
176
+ // the worker has not created the remote yet, so every caller gets the
177
+ // not-ready 503, bootstrap admins included.
178
+ const notReady = workerNotReadyResponse(err);
179
+ if (notReady)
180
+ return notReady;
169
181
  // Same trade as the base-branch degradation above: /admin is the recovery
170
182
  // surface for exactly this failure (a renamed settings branch trips
171
183
  // assertSettingsWorkspaceIdentity until a human intervenes), so 503ing it
@@ -231,7 +243,7 @@ export function createCanopyRequestHandler(options) {
231
243
  if (validationResult.body !== undefined) {
232
244
  handlerArgs.push(validationResult.body);
233
245
  }
234
- const result = await match.handler(...handlerArgs);
246
+ const result = await timeRequestPhase('route', () => match.handler(...handlerArgs));
235
247
  // Binary routes carry their own status and headers and MUST reach the
236
248
  // adapter untouched: jsonResponse would serialize raw bytes and drop
237
249
  // contentType/contentDisposition.
@@ -242,7 +254,7 @@ export function createCanopyRequestHandler(options) {
242
254
  else {
243
255
  // Every route should use defineEndpoint; this is the safety net for one
244
256
  // that carries no validate function.
245
- const result = await match.handler(apiCtx, apiReq, mergedParams);
257
+ const result = await timeRequestPhase('route', () => match.handler(apiCtx, apiReq, mergedParams));
246
258
  if (isCanopyBinaryResponse(result))
247
259
  return result;
248
260
  return jsonResponse(result, result.status);
@@ -250,12 +262,15 @@ export function createCanopyRequestHandler(options) {
250
262
  };
251
263
  return async (req, pathSegments) => {
252
264
  try {
253
- return await handleRequest(req, pathSegments);
265
+ return await runWithRequestTiming(req.method, () => handleRequest(req, pathSegments), (response) => response.status);
254
266
  }
255
267
  catch (err) {
256
268
  // Last-resort boundary (API-C1): see handleRequest's doc comment above.
257
269
  const message = getErrorMessage(err);
258
270
  console.error('CanopyCMS: Unhandled error in API request handler:', message);
271
+ const notReady = workerNotReadyResponse(err);
272
+ if (notReady)
273
+ return notReady;
259
274
  return jsonResponse({ ok: false, status: 500, error: sanitizeErrorMessage(message) }, 500);
260
275
  }
261
276
  };
@@ -4,3 +4,5 @@ export type { CanopyHandler, RouteDefinition, RouteMatch, CanopyRouter } from '.
4
4
  export { createCanopyRouter } from './router.js';
5
5
  export type { CanopyHandlerOptions, CanopyRequestHandler } from './handler.js';
6
6
  export { createCanopyRequestHandler, createCanopyRequestHandlerFromConfig } from './handler.js';
7
+ export { RemoteNotReadyError } from '../git-manager.js';
8
+ export { workerNotReadyResponse, WORKER_NOT_READY_MESSAGE } from './worker-not-ready.js';
@@ -1,3 +1,5 @@
1
1
  export { jsonResponse, isCanopyBinaryResponse } from './types.js';
2
2
  export { createCanopyRouter } from './router.js';
3
3
  export { createCanopyRequestHandler, createCanopyRequestHandlerFromConfig } from './handler.js';
4
+ export { RemoteNotReadyError } from '../git-manager.js';
5
+ export { workerNotReadyResponse, WORKER_NOT_READY_MESSAGE } from './worker-not-ready.js';
@@ -32,6 +32,8 @@ export interface RouteDefinition {
32
32
  }
33
33
  export interface RouteMatch {
34
34
  handler: CanopyHandler;
35
+ /** The matched route's pattern; empty for the synthetic malformed-path match. */
36
+ pattern: readonly string[];
35
37
  params: Record<string, string>;
36
38
  validate?: RouteDefinition['validate'];
37
39
  bodyFormat?: RouteDefinition['bodyFormat'];
@@ -122,10 +122,11 @@ export function matchRoute(routes, method, segments) {
122
122
  decodedParams = Object.fromEntries(Object.entries(best.params).map(([key, value]) => [key, decodeURIComponent(value)]));
123
123
  }
124
124
  catch {
125
- return { handler: malformedPathHandler, params: {} };
125
+ return { handler: malformedPathHandler, pattern: [], params: {} };
126
126
  }
127
127
  return {
128
128
  handler: best.route.handler,
129
+ pattern: best.route.pattern,
129
130
  params: decodedParams,
130
131
  validate: best.route.validate, // Include validation function if present
131
132
  bodyFormat: best.route.bodyFormat,
@@ -0,0 +1,9 @@
1
+ import type { ApiResponse } from '../api/types.js';
2
+ import { type CanopyResponse } from './types.js';
3
+ export declare const WORKER_NOT_READY_MESSAGE = "CMS worker not ready \u2014 it may still be starting. Try again in a minute; if this persists, ask an admin to check the CMS worker.";
4
+ /**
5
+ * The 503 for a request that hit {@link RemoteNotReadyError}, or `undefined`
6
+ * for any other error. The single mapping point for the core handler, the AI route and
7
+ * every framework adapter's backstop, so the message and `Retry-After` cannot diverge.
8
+ */
9
+ export declare function workerNotReadyResponse(err: unknown): CanopyResponse<ApiResponse> | undefined;
@@ -0,0 +1,17 @@
1
+ import { RemoteNotReadyError } from '../git-manager.js';
2
+ import { jsonResponse } from './types.js';
3
+ /** Seconds a client should wait before retrying while the worker boots. */
4
+ const RETRY_AFTER_SECONDS = '30';
5
+ export const WORKER_NOT_READY_MESSAGE = 'CMS worker not ready — it may still be starting. Try again in a minute; if this persists, ask an admin to check the CMS worker.';
6
+ /**
7
+ * The 503 for a request that hit {@link RemoteNotReadyError}, or `undefined`
8
+ * for any other error. The single mapping point for the core handler, the AI route and
9
+ * every framework adapter's backstop, so the message and `Retry-After` cannot diverge.
10
+ */
11
+ export function workerNotReadyResponse(err) {
12
+ if (!(err instanceof RemoteNotReadyError))
13
+ return undefined;
14
+ return jsonResponse({ ok: false, status: 503, error: WORKER_NOT_READY_MESSAGE }, 503, {
15
+ 'Retry-After': RETRY_AFTER_SECONDS,
16
+ });
17
+ }
@@ -57,9 +57,6 @@ class ProdStrategy extends ProdClientSafeStrategy {
57
57
  throw new Error('gitBotAuthorName and gitBotAuthorEmail are required in prod mode');
58
58
  }
59
59
  }
60
- shouldCreateSettingsPR(config) {
61
- return config.autoCreateSettingsPR ?? true;
62
- }
63
60
  }
64
61
  class DevStrategy extends DevClientSafeStrategy {
65
62
  getWorkspaceRoot(sourceRoot) {
@@ -107,9 +104,6 @@ class DevStrategy extends DevClientSafeStrategy {
107
104
  validateConfig(_config) {
108
105
  // No special validation for dev mode
109
106
  }
110
- shouldCreateSettingsPR(_config) {
111
- return false; // No real GitHub in local dev mode
112
- }
113
107
  }
114
108
  const strategyCache = new Map();
115
109
  /**
@@ -89,7 +89,4 @@ export interface ClientUnsafeStrategy extends ClientSafeStrategy {
89
89
  getSettingsRoot(sourceRoot?: string): string;
90
90
  usesSeparateSettingsBranch(): boolean;
91
91
  validateConfig(config: Partial<CanopyConfig>): void;
92
- shouldCreateSettingsPR(config: {
93
- autoCreateSettingsPR?: boolean;
94
- }): boolean;
95
92
  }
@@ -3,7 +3,7 @@
3
3
  * because this barrel pulls in node:fs; `pnpm lint:bundle` enforces that.
4
4
  */
5
5
  export type { LogicalPath, PhysicalPath, SanitizedBranchName, BranchName, ContentId, Slug, PathValidationResult, } from './types.js';
6
- export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, joinPath, trimSlashes, } from './normalize.js';
6
+ export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, entryLogicalPath, joinPath, trimSlashes, } from './normalize.js';
7
7
  export { validateAndNormalizePath } from './normalize-server.js';
8
8
  export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseSlug, } from './validation.js';
9
9
  export { resolveLogicalPath } from './resolve.js';
@@ -3,7 +3,7 @@
3
3
  * because this barrel pulls in node:fs; `pnpm lint:bundle` enforces that.
4
4
  */
5
5
  // Normalization utilities (client-safe)
6
- export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, joinPath, trimSlashes, } from './normalize.js';
6
+ export { normalizeFilesystemPath, normalizeCollectionPath, hasTraversalSequence, createLogicalPath, createPhysicalPath, entryLogicalPath, joinPath, trimSlashes, } from './normalize.js';
7
7
  // Normalization utilities (server-only, requires Node.js path module)
8
8
  export { validateAndNormalizePath } from './normalize-server.js';
9
9
  export { validateContentPath, isValidCollectionPath, sanitizeForPath, hasEmbeddedContentId, looksLikePhysicalPath, looksLikeLogicalPath, parseLogicalPath, parsePhysicalPath, isValidContentId, parseContentId, parseBranchName, parseSlug, } from './validation.js';
@@ -25,6 +25,13 @@ export declare function normalizeCollectionPath(collectionPath: string, contentR
25
25
  export declare function hasTraversalSequence(path: string): boolean;
26
26
  /** Join segments into a LogicalPath, throwing on a traversal sequence. */
27
27
  export declare function createLogicalPath(...segments: string[]): LogicalPath;
28
+ /**
29
+ * An entry's logical path: `<collection logical path>/<slug>`, e.g. `content/blog/my-post`,
30
+ * or `content/about` for an entry of the root collection. Path-permission rules match this
31
+ * form. No traversal check, unlike `createLogicalPath`: a slug read off disk may contain
32
+ * `..`, and listing must not throw on one.
33
+ */
34
+ export declare function entryLogicalPath(collectionLogicalPath: LogicalPath | string, slug: string): LogicalPath;
28
35
  /**
29
36
  * Join segments into a PhysicalPath (segments may carry embedded content IDs,
30
37
  * e.g. `my-post.ABC123.mdx`), throwing on a traversal sequence.
@@ -48,6 +48,15 @@ export function createLogicalPath(...segments) {
48
48
  }
49
49
  return normalized;
50
50
  }
51
+ /**
52
+ * An entry's logical path: `<collection logical path>/<slug>`, e.g. `content/blog/my-post`,
53
+ * or `content/about` for an entry of the root collection. Path-permission rules match this
54
+ * form. No traversal check, unlike `createLogicalPath`: a slug read off disk may contain
55
+ * `..`, and listing must not throw on one.
56
+ */
57
+ export function entryLogicalPath(collectionLogicalPath, slug) {
58
+ return (collectionLogicalPath ? `${collectionLogicalPath}/${slug}` : slug);
59
+ }
51
60
  /**
52
61
  * Join segments into a PhysicalPath (segments may carry embedded content IDs,
53
62
  * e.g. `my-post.ABC123.mdx`), throwing on a traversal sequence.
@@ -1,6 +1,6 @@
1
1
  import type { ContentStore } from './content-store.js';
2
2
  import type { ContentIdIndex } from './content-id-index.js';
3
- import type { LogicalPath, PhysicalPath, Slug } from './paths/index.js';
3
+ import { type LogicalPath, type Slug } from './paths/index.js';
4
4
  export interface ResolvedReference {
5
5
  id: string;
6
6
  exists: boolean;
@@ -32,11 +32,11 @@ export declare class ReferenceResolver {
32
32
  * At least one of `collections` or `entryTypes` should be provided.
33
33
  *
34
34
  * @param canAccess - Optional permission predicate, called with each candidate's
35
- * relative path before it is read. Returning false skips the entry entirely --
35
+ * logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
36
36
  * no file I/O, no label, no option -- so a caller who can't read a path never
37
37
  * triggers a read for content they won't be allowed to see anyway.
38
38
  */
39
- loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (relativePath: PhysicalPath) => boolean): Promise<ReferenceOption[]>;
39
+ loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean): Promise<ReferenceOption[]>;
40
40
  /**
41
41
  * Resolve multiple IDs at once.
42
42
  * Useful for displaying lists of referenced items.
@@ -1,5 +1,6 @@
1
1
  import nodePath from 'node:path';
2
2
  import { extractSlugFromFilename, extractEntryTypeFromFilename } from './content-id-index.js';
3
+ import { entryLogicalPath } from './paths/index.js';
3
4
  /**
4
5
  * ReferenceResolver resolves content IDs to display values for reference fields.
5
6
  */
@@ -48,7 +49,7 @@ export class ReferenceResolver {
48
49
  * At least one of `collections` or `entryTypes` should be provided.
49
50
  *
50
51
  * @param canAccess - Optional permission predicate, called with each candidate's
51
- * relative path before it is read. Returning false skips the entry entirely --
52
+ * logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
52
53
  * no file I/O, no label, no option -- so a caller who can't read a path never
53
54
  * triggers a read for content they won't be allowed to see anyway.
54
55
  */
@@ -74,7 +75,7 @@ export class ReferenceResolver {
74
75
  if (!location.collection || !location.slug)
75
76
  continue;
76
77
  // Skip denied paths before any file I/O.
77
- if (canAccess && !canAccess(location.relativePath))
78
+ if (canAccess && !canAccess(entryLogicalPath(location.collection, location.slug)))
78
79
  continue;
79
80
  const id = this.idIndex.findByPath(location.relativePath);
80
81
  if (!id)
@@ -12,6 +12,7 @@
12
12
  import { canopyLogWarn } from './utils/logger.js';
13
13
  import { authResultToCanopyUser } from './user.js';
14
14
  import { loadInternalGroups, RESERVED_GROUPS } from './authorization/index.js';
15
+ import { timeRequestPhase } from './utils/request-timing.js';
15
16
  /** Module-level: warn at most once per process. */
16
17
  let warnedNoAdmins = false;
17
18
  /**
@@ -39,7 +40,7 @@ function warnIfNoAdmins(internalGroups, bootstrapAdminIds) {
39
40
  */
40
41
  export async function resolveCanopyUser(authResult, deps) {
41
42
  const settingsRoot = await deps.getSettingsBranchRoot();
42
- const internalGroups = await loadInternalGroups(settingsRoot, deps.mode, deps.bootstrapAdminIds);
43
+ const internalGroups = await timeRequestPhase('groups', () => loadInternalGroups(settingsRoot, deps.mode, deps.bootstrapAdminIds));
43
44
  warnIfNoAdmins(internalGroups, deps.bootstrapAdminIds);
44
45
  return authResultToCanopyUser(authResult, deps.bootstrapAdminIds, internalGroups);
45
46
  }
@@ -24,11 +24,16 @@ import { GitManager } from './git-manager.js';
24
24
  import { BranchRegistry } from './branch-registry.js';
25
25
  import { type GitHubService } from './github-service.js';
26
26
  import { BranchSchemaCache } from './branch-schema-cache.js';
27
+ import { type SubmissionEditor } from './submission-attribution.js';
27
28
  /**
28
29
  * Bootstrap admin IDs from the environment. These users are treated as Admins
29
30
  * whatever their group membership.
30
31
  */
31
32
  export declare const getBootstrapAdminIds: () => Set<string>;
33
+ export interface SubmitBranchResult {
34
+ /** Repo-relative paths the branch changes, excluding canopycms runtime metadata. */
35
+ changedPaths: string[];
36
+ }
32
37
  export interface CanopyServices {
33
38
  config: CanopyConfig;
34
39
  /**
@@ -64,21 +69,23 @@ export interface CanopyServices {
64
69
  files: string | string[];
65
70
  message: string;
66
71
  }) => Promise<void>;
67
- /** Submit branch: commit all changes and push to remote */
72
+ /**
73
+ * Submit branch: commit all changes (with the submitter's trailers) and push
74
+ * to remote. Resolves with every path the branch changes against its base.
75
+ */
68
76
  submitBranch: (options: {
69
77
  context: BranchContext;
78
+ submitter?: SubmissionEditor;
70
79
  message?: string;
71
- }) => Promise<void>;
72
- /** Commit to settings branch (for permissions/groups), with optional PR creation */
80
+ }) => Promise<SubmitBranchResult>;
81
+ /** Commit to the settings branch (for permissions/groups) and push it; never opens a PR */
73
82
  commitToSettingsBranch: (options: {
74
83
  branchRoot: string;
75
84
  files: string | string[];
76
85
  message: string;
77
- createPR?: boolean;
78
86
  }) => Promise<{
79
87
  committed: boolean;
80
88
  pushed: boolean;
81
- prUrl?: string;
82
89
  error?: string;
83
90
  syncStatus?: 'pending-sync' | 'synced' | 'sync-failed';
84
91
  }>;