canopycms 0.0.67 → 0.0.68-int.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
@@ -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';
@@ -7,21 +8,22 @@ import { BranchMetadataCorruptError } from '../branch-metadata.js';
7
8
  import { resolveCanopyUser } from '../resolve-canopy-user.js';
8
9
  import { authResultToCanopyUser } from '../user.js';
9
10
  import { isAdmin } from '../authorization/index.js';
11
+ import { isSettingsBranchName } from '../paths/branch-name.js';
10
12
  import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
11
13
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
12
14
  // canopyLogError, not console.error: this is shared code and not guaranteed to
13
15
  // stay out of the worker's runtime import closure, so new log lines here go
14
16
  // through the indirection (utils/logger.ts).
15
17
  import { canopyLogError } from '../utils/logger.js';
18
+ import { runWithRequestTiming, setRequestTimingRoute, timeRequestPhase, } from '../utils/request-timing.js';
16
19
  const buildContext = async (options) => {
17
20
  const services = options.services ?? (options.config ? await createCanopyServices(options.config) : undefined);
18
21
  if (!services) {
19
22
  throw new Error('CanopyCMS: config or services is required');
20
23
  }
21
24
  const operatingMode = services.config.mode;
22
- // Derive from the strategy, which resolves deploymentName; a literal here
23
- // would not match a deployment-namespaced settings branch (say
24
- // canopycms-settings-acme), so getBranchContext could never auto-create it.
25
+ // Derived from the strategy, which resolves deploymentName; a literal here
26
+ // would miss a deployment-namespaced settings branch (say canopycms-settings-acme).
25
27
  const settingsBranch = operatingStrategy(operatingMode).getSettingsBranchName(services.config);
26
28
  const getBranchContext = options.getBranchContext ??
27
29
  (async (branch, opts) => {
@@ -42,7 +44,7 @@ const buildContext = async (options) => {
42
44
  const baseBranch = services.config.defaultBaseBranch ?? 'main';
43
45
  const activeBranch = services.config.defaultActiveBranch ?? baseBranch;
44
46
  const shouldAutoCreate = clientOperatingStrategy(operatingMode).supportsBranching() &&
45
- (branch === baseBranch || branch === activeBranch || branch === settingsBranch);
47
+ (branch === baseBranch || branch === activeBranch);
46
48
  if (shouldAutoCreate) {
47
49
  const manager = new BranchWorkspaceManager(services.config);
48
50
  const context = await manager.openOrCreateBranch({
@@ -62,7 +64,14 @@ const buildContext = async (options) => {
62
64
  return {
63
65
  services,
64
66
  assetStore: options.assetStore,
65
- getBranchContext,
67
+ // The settings branch lives only in the settings workspace. Resolving it as a
68
+ // content branch would let any request clone a content-history copy that a
69
+ // submit pushes under the settings name, which settings provisioning then refuses,
70
+ // leaving settings unavailable.
71
+ // Not found, like any branch the caller cannot resolve, even a workspace left on disk.
72
+ getBranchContext: (branch, opts) => isSettingsBranchName(branch, settingsBranch)
73
+ ? Promise.resolve(null)
74
+ : timeRequestPhase('branchContext', () => getBranchContext(branch, opts)),
66
75
  authPlugin: options.authPlugin,
67
76
  };
68
77
  };
@@ -109,10 +118,11 @@ export function createCanopyRequestHandler(options) {
109
118
  if (!match) {
110
119
  return jsonResponse({ ok: false, status: 404, error: 'Not found' }, 404);
111
120
  }
112
- const apiCtx = await getContext();
121
+ setRequestTimingRoute(match.pattern.join('/') || '(malformed path)');
122
+ const apiCtx = await timeRequestPhase('context', getContext);
113
123
  // In dev mode, re-check if the developer switched git branches
114
- await apiCtx.services.refreshActiveBranch();
115
- const authResult = await options.authPlugin.authenticate(req);
124
+ await timeRequestPhase('refreshBranch', () => apiCtx.services.refreshActiveBranch());
125
+ const authResult = await timeRequestPhase('auth', () => options.authPlugin.authenticate(req));
116
126
  // API routes require authentication. Anonymous callers are rejected BEFORE
117
127
  // any workspace provisioning below, so they can neither trigger expensive
118
128
  // git operations nor read provisioning error details.
@@ -141,6 +151,9 @@ export function createCanopyRequestHandler(options) {
141
151
  // (authenticated) client. Credentials, which git errors can embed, are
142
152
  // redacted even from the server logs.
143
153
  console.error(`CanopyCMS: Failed to provision workspace for base branch '${baseBranch}': ${redactCredentials(message)}`);
154
+ const notReady = workerNotReadyResponse(err);
155
+ if (notReady)
156
+ return notReady;
144
157
  return jsonResponse({
145
158
  ok: false,
146
159
  status: 503,
@@ -157,15 +170,21 @@ export function createCanopyRequestHandler(options) {
157
170
  // a silent authorization change.
158
171
  let user;
159
172
  try {
160
- user = await resolveCanopyUser(authResult, {
173
+ user = await timeRequestPhase('user', () => resolveCanopyUser(authResult, {
161
174
  getSettingsBranchRoot: apiCtx.services.getSettingsBranchRoot,
162
175
  mode: apiCtx.services.config.mode,
163
176
  bootstrapAdminIds: apiCtx.services.bootstrapAdminIds,
164
- });
177
+ }));
165
178
  }
166
179
  catch (err) {
167
180
  const message = getErrorMessage(err);
168
181
  canopyLogError(`CanopyCMS: Failed to resolve internal groups from the settings workspace: ${redactCredentials(message)}`);
182
+ // No remote means no settings workspace, so /admin cannot load either:
183
+ // the worker has not created the remote yet, so every caller gets the
184
+ // not-ready 503, bootstrap admins included.
185
+ const notReady = workerNotReadyResponse(err);
186
+ if (notReady)
187
+ return notReady;
169
188
  // Same trade as the base-branch degradation above: /admin is the recovery
170
189
  // surface for exactly this failure (a renamed settings branch trips
171
190
  // assertSettingsWorkspaceIdentity until a human intervenes), so 503ing it
@@ -231,7 +250,7 @@ export function createCanopyRequestHandler(options) {
231
250
  if (validationResult.body !== undefined) {
232
251
  handlerArgs.push(validationResult.body);
233
252
  }
234
- const result = await match.handler(...handlerArgs);
253
+ const result = await timeRequestPhase('route', () => match.handler(...handlerArgs));
235
254
  // Binary routes carry their own status and headers and MUST reach the
236
255
  // adapter untouched: jsonResponse would serialize raw bytes and drop
237
256
  // contentType/contentDisposition.
@@ -242,7 +261,7 @@ export function createCanopyRequestHandler(options) {
242
261
  else {
243
262
  // Every route should use defineEndpoint; this is the safety net for one
244
263
  // that carries no validate function.
245
- const result = await match.handler(apiCtx, apiReq, mergedParams);
264
+ const result = await timeRequestPhase('route', () => match.handler(apiCtx, apiReq, mergedParams));
246
265
  if (isCanopyBinaryResponse(result))
247
266
  return result;
248
267
  return jsonResponse(result, result.status);
@@ -250,12 +269,15 @@ export function createCanopyRequestHandler(options) {
250
269
  };
251
270
  return async (req, pathSegments) => {
252
271
  try {
253
- return await handleRequest(req, pathSegments);
272
+ return await runWithRequestTiming(req.method, () => handleRequest(req, pathSegments), (response) => response.status);
254
273
  }
255
274
  catch (err) {
256
275
  // Last-resort boundary (API-C1): see handleRequest's doc comment above.
257
276
  const message = getErrorMessage(err);
258
277
  console.error('CanopyCMS: Unhandled error in API request handler:', message);
278
+ const notReady = workerNotReadyResponse(err);
279
+ if (notReady)
280
+ return notReady;
259
281
  return jsonResponse({ ok: false, status: 500, error: sanitizeErrorMessage(message) }, 500);
260
282
  }
261
283
  };
@@ -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
  }
@@ -29,6 +29,11 @@ export declare function sanitizeBranchName(branchName: string): SanitizedBranchN
29
29
  * branch.
30
30
  */
31
31
  export declare const RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
32
+ /**
33
+ * Whether `branchName` is a settings branch, never a content branch: the reserved prefix, or the
34
+ * configured name (which need not carry it). Compared sanitized, the form git and disk use.
35
+ */
36
+ export declare function isSettingsBranchName(branchName: string, settingsBranchName?: string): boolean;
32
37
  /**
33
38
  * Branch names that collide with a static top-level API route namespace.
34
39
  *
@@ -33,6 +33,15 @@ export function sanitizeBranchName(branchName) {
33
33
  * branch.
34
34
  */
35
35
  export const RESERVED_SETTINGS_BRANCH_PREFIX = 'canopycms-settings-';
36
+ /**
37
+ * Whether `branchName` is a settings branch, never a content branch: the reserved prefix, or the
38
+ * configured name (which need not carry it). Compared sanitized, the form git and disk use.
39
+ */
40
+ export function isSettingsBranchName(branchName, settingsBranchName) {
41
+ const sanitized = sanitizeBranchName(branchName);
42
+ return (sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) ||
43
+ (settingsBranchName !== undefined && sanitized === sanitizeBranchName(settingsBranchName)));
44
+ }
36
45
  /**
37
46
  * Branch names that collide with a static top-level API route namespace.
38
47
  *
@@ -14,10 +14,15 @@ export interface BranchPathResult {
14
14
  /** @internal Exported for tests. */
15
15
  export declare class BranchPathError extends Error {
16
16
  }
17
+ /**
18
+ * True when a branch load failed because the name names no workspace: a traversal segment, a name
19
+ * too long for a filename, or a path through a file (ENOTDIR), as `?branch=branches.json` gives.
20
+ */
21
+ export declare function namesNoWorkspace(err: unknown): boolean;
17
22
  import { sanitizeBranchName } from './branch-name.js';
18
23
  /** @internal Exported for tests. */
19
24
  export { sanitizeBranchName };
20
- /** Resolve a branch name to workspace paths, rejecting path traversal. */
25
+ /** Resolve a branch name to workspace paths, rejecting traversal and settings-branch names. */
21
26
  export declare function resolveBranchPath(options: BranchPathOptions): BranchPathResult;
22
27
  export declare function ensureBranchRoot(options: BranchPathOptions): Promise<BranchPathResult>;
23
28
  export declare function getDefaultBranchBase(mode: OperatingMode, override?: string): string;
@@ -2,22 +2,35 @@
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { operatingStrategy } from '../operating-mode/index.js';
5
+ import { isNodeError } from '../utils/error.js';
5
6
  /** @internal Exported for tests. */
6
7
  export class BranchPathError extends Error {
7
8
  }
9
+ /**
10
+ * True when a branch load failed because the name names no workspace: a traversal segment, a name
11
+ * too long for a filename, or a path through a file (ENOTDIR), as `?branch=branches.json` gives.
12
+ */
13
+ export function namesNoWorkspace(err) {
14
+ if (err instanceof BranchPathError)
15
+ return true;
16
+ return isNodeError(err) && (err.code === 'ENAMETOOLONG' || err.code === 'ENOTDIR');
17
+ }
8
18
  // Lives in ./branch-name (dependency-free); re-exported here for server-side
9
19
  // importers, who may safely reach this module's node:fs imports.
10
- import { sanitizeBranchName } from './branch-name.js';
20
+ import { sanitizeBranchName, isSettingsBranchName } from './branch-name.js';
11
21
  /** @internal Exported for tests. */
12
22
  export { sanitizeBranchName };
13
23
  const resolveContentBranchesRoot = (mode, override) => {
14
24
  return operatingStrategy(mode).getContentBranchesRoot(override);
15
25
  };
16
- /** Resolve a branch name to workspace paths, rejecting path traversal. */
26
+ /** Resolve a branch name to workspace paths, rejecting traversal and settings-branch names. */
17
27
  export function resolveBranchPath(options) {
18
28
  if (options.branchName.includes('..')) {
19
29
  throw new BranchPathError('Branch name cannot contain traversal segments');
20
30
  }
31
+ if (isSettingsBranchName(options.branchName)) {
32
+ throw new BranchPathError('Settings branches are not content branches');
33
+ }
21
34
  const safeBranch = sanitizeBranchName(options.branchName);
22
35
  const strategy = operatingStrategy(options.mode);
23
36
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
@@ -3,9 +3,9 @@
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';
10
- export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, type BranchPathOptions, type BranchPathResult, } from './branch.js';
11
- export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, } from './branch-name.js';
10
+ export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, namesNoWorkspace, type BranchPathOptions, type BranchPathResult, } from './branch.js';
11
+ export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from './branch-name.js';
@@ -3,10 +3,10 @@
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';
10
10
  export { resolveLogicalPath } from './resolve.js';
11
- export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, } from './branch.js';
12
- export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, } from './branch-name.js';
11
+ export { resolveBranchPath, ensureBranchRoot, getDefaultBranchBase, resolveBranchPaths, BranchPathError, namesNoWorkspace, } from './branch.js';
12
+ export { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from './branch-name.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,30 +1,19 @@
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';
4
- export interface ResolvedReference {
5
- id: string;
6
- exists: boolean;
7
- displayValue: string;
8
- collection?: LogicalPath;
9
- slug?: Slug;
10
- }
3
+ import { type LogicalPath } from './paths/index.js';
11
4
  export interface ReferenceOption {
12
5
  id: string;
13
6
  label: string;
14
7
  collection: string;
15
8
  }
16
9
  /**
17
- * ReferenceResolver resolves content IDs to display values for reference fields.
10
+ * Loads the options a reference field's picker offers. Resolving a stored reference to its
11
+ * target is `ContentStore`'s job (`read()`, `resolveReferenceTarget`).
18
12
  */
19
13
  export declare class ReferenceResolver {
20
14
  private store;
21
15
  private idIndex;
22
16
  constructor(store: ContentStore, idIndex: ContentIdIndex);
23
- /**
24
- * Resolve a content ID to a display value.
25
- * Returns null if the ID doesn't exist or points to a collection.
26
- */
27
- resolve(id: string, displayField?: string): Promise<ResolvedReference | null>;
28
17
  /**
29
18
  * Load all available reference options for a reference field.
30
19
  *
@@ -32,14 +21,9 @@ export declare class ReferenceResolver {
32
21
  * At least one of `collections` or `entryTypes` should be provided.
33
22
  *
34
23
  * @param canAccess - Optional permission predicate, called with each candidate's
35
- * relative path before it is read. Returning false skips the entry entirely --
24
+ * logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
36
25
  * no file I/O, no label, no option -- so a caller who can't read a path never
37
26
  * triggers a read for content they won't be allowed to see anyway.
38
27
  */
39
- loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (relativePath: PhysicalPath) => boolean): Promise<ReferenceOption[]>;
40
- /**
41
- * Resolve multiple IDs at once.
42
- * Useful for displaying lists of referenced items.
43
- */
44
- resolveMany(ids: string[], displayField?: string): Promise<(ResolvedReference | null)[]>;
28
+ loadReferenceOptions(collections?: LogicalPath[], displayField?: string, search?: string, entryTypes?: string[], canAccess?: (logicalPath: LogicalPath) => boolean): Promise<ReferenceOption[]>;
45
29
  }
@@ -1,46 +1,15 @@
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
- * ReferenceResolver resolves content IDs to display values for reference fields.
5
+ * Loads the options a reference field's picker offers. Resolving a stored reference to its
6
+ * target is `ContentStore`'s job (`read()`, `resolveReferenceTarget`).
5
7
  */
6
8
  export class ReferenceResolver {
7
9
  constructor(store, idIndex) {
8
10
  this.store = store;
9
11
  this.idIndex = idIndex;
10
12
  }
11
- /**
12
- * Resolve a content ID to a display value.
13
- * Returns null if the ID doesn't exist or points to a collection.
14
- */
15
- async resolve(id, displayField = 'title') {
16
- const location = this.idIndex.findById(id);
17
- if (!location || location.type !== 'entry') {
18
- return {
19
- id,
20
- exists: false,
21
- displayValue: id, // Fallback to showing the ID itself
22
- };
23
- }
24
- try {
25
- const doc = await this.store.read(location.collection, location.slug);
26
- const displayValue = String(doc.data[displayField] || doc.data.title || location.slug);
27
- return {
28
- id,
29
- exists: true,
30
- displayValue,
31
- collection: location.collection,
32
- slug: location.slug,
33
- };
34
- }
35
- catch (error) {
36
- console.error('Failed to resolve reference:', { id, error });
37
- return {
38
- id,
39
- exists: false,
40
- displayValue: id,
41
- };
42
- }
43
- }
44
13
  /**
45
14
  * Load all available reference options for a reference field.
46
15
  *
@@ -48,7 +17,7 @@ export class ReferenceResolver {
48
17
  * At least one of `collections` or `entryTypes` should be provided.
49
18
  *
50
19
  * @param canAccess - Optional permission predicate, called with each candidate's
51
- * relative path before it is read. Returning false skips the entry entirely --
20
+ * logical path (`entryLogicalPath`) before it is read. Returning false skips the entry entirely --
52
21
  * no file I/O, no label, no option -- so a caller who can't read a path never
53
22
  * triggers a read for content they won't be allowed to see anyway.
54
23
  */
@@ -74,7 +43,7 @@ export class ReferenceResolver {
74
43
  if (!location.collection || !location.slug)
75
44
  continue;
76
45
  // Skip denied paths before any file I/O.
77
- if (canAccess && !canAccess(location.relativePath))
46
+ if (canAccess && !canAccess(entryLogicalPath(location.collection, location.slug)))
78
47
  continue;
79
48
  const id = this.idIndex.findByPath(location.relativePath);
80
49
  if (!id)
@@ -82,7 +51,10 @@ export class ReferenceResolver {
82
51
  try {
83
52
  const filename = nodePath.basename(location.relativePath);
84
53
  const normalizedSlug = extractSlugFromFilename(filename);
85
- const doc = await this.store.read(location.collection, normalizedSlug);
54
+ // Only the label is read, so the candidate's own references stay unresolved.
55
+ const doc = await this.store.read(location.collection, normalizedSlug, {
56
+ resolveReferences: false,
57
+ });
86
58
  const label = String(doc.data[displayField] || doc.data.title || normalizedSlug);
87
59
  if (search && !label.toLowerCase().includes(search.toLowerCase())) {
88
60
  continue;
@@ -104,11 +76,4 @@ export class ReferenceResolver {
104
76
  }
105
77
  return options.sort((a, b) => a.label.localeCompare(b.label));
106
78
  }
107
- /**
108
- * Resolve multiple IDs at once.
109
- * Useful for displaying lists of referenced items.
110
- */
111
- async resolveMany(ids, displayField = 'title') {
112
- return Promise.all(ids.map((id) => this.resolve(id, displayField)));
113
- }
114
79
  }
@@ -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
  }