canopycms 0.0.68-int.93 → 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 (91) hide show
  1. package/README.md +3 -2
  2. package/dist/api/admin.d.ts +13 -1
  3. package/dist/api/admin.js +18 -2
  4. package/dist/api/branch-status.js +13 -2
  5. package/dist/api/branch.js +11 -11
  6. package/dist/api/client.d.ts +1 -1
  7. package/dist/api/client.js +2 -3
  8. package/dist/api/content.js +7 -3
  9. package/dist/api/entries.js +10 -7
  10. package/dist/api/resolve-references.js +14 -40
  11. package/dist/api/user.d.ts +3 -0
  12. package/dist/api/user.js +3 -0
  13. package/dist/assets/factory.d.ts +5 -0
  14. package/dist/assets/factory.js +5 -0
  15. package/dist/branch-workspace.js +6 -1
  16. package/dist/build-identity.d.ts +3 -0
  17. package/dist/build-identity.js +13 -0
  18. package/dist/cli/cli.js +800 -235
  19. package/dist/cli/generate-ai-content.js +353 -47
  20. package/dist/cli/init.js +6 -1
  21. package/dist/cli/migrate.js +12 -8
  22. package/dist/cli/sync.js +70 -26
  23. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  24. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  25. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  26. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  27. package/dist/config/schemas/config.d.ts +5 -0
  28. package/dist/config/schemas/config.js +2 -0
  29. package/dist/config/schemas/url.d.ts +2 -0
  30. package/dist/config/schemas/url.js +7 -0
  31. package/dist/config/types.d.ts +17 -2
  32. package/dist/content-listing.d.ts +10 -10
  33. package/dist/content-listing.js +7 -3
  34. package/dist/content-reader.d.ts +11 -2
  35. package/dist/content-reader.js +19 -11
  36. package/dist/content-store.d.ts +35 -9
  37. package/dist/content-store.js +68 -42
  38. package/dist/content-tree.d.ts +1 -1
  39. package/dist/content-tree.js +1 -1
  40. package/dist/context.d.ts +12 -3
  41. package/dist/context.js +93 -58
  42. package/dist/editor/CanopyEditor.d.ts +1 -1
  43. package/dist/editor/CanopyEditor.js +1 -1
  44. package/dist/editor/Editor.d.ts +2 -0
  45. package/dist/editor/Editor.js +2 -1
  46. package/dist/editor/admin/SystemHealthPanel.js +8 -1
  47. package/dist/editor/editor-utils.d.ts +17 -15
  48. package/dist/editor/editor-utils.js +43 -36
  49. package/dist/editor/hooks/useCommentSystem.js +5 -1
  50. package/dist/editor/preview-bridge.js +4 -1
  51. package/dist/editor/preview-path.d.ts +10 -0
  52. package/dist/editor/preview-path.js +20 -0
  53. package/dist/entry-schema-registry.d.ts +4 -4
  54. package/dist/entry-schema-registry.js +8 -5
  55. package/dist/entry-schema.d.ts +27 -4
  56. package/dist/entry-schema.js +20 -3
  57. package/dist/git-manager.d.ts +59 -5
  58. package/dist/git-manager.js +262 -18
  59. package/dist/http/handler.js +12 -5
  60. package/dist/paths/branch-name.d.ts +5 -0
  61. package/dist/paths/branch-name.js +9 -0
  62. package/dist/paths/branch.d.ts +6 -1
  63. package/dist/paths/branch.js +15 -2
  64. package/dist/paths/index.d.ts +2 -2
  65. package/dist/paths/index.js +2 -2
  66. package/dist/reference-resolver.d.ts +3 -19
  67. package/dist/reference-resolver.js +6 -42
  68. package/dist/schema/schema-store.d.ts +48 -22
  69. package/dist/schema/schema-store.js +66 -22
  70. package/dist/services.d.ts +6 -1
  71. package/dist/services.js +41 -26
  72. package/dist/settings-workspace.js +9 -7
  73. package/dist/sync-core.d.ts +12 -1
  74. package/dist/sync-core.js +25 -6
  75. package/dist/task-queue/worker-status.d.ts +8 -0
  76. package/dist/task-queue/worker-status.js +17 -0
  77. package/dist/types.d.ts +8 -0
  78. package/dist/utils/content-write-lock.d.ts +21 -6
  79. package/dist/utils/content-write-lock.js +16 -9
  80. package/dist/utils/url-prefix.d.ts +15 -0
  81. package/dist/utils/url-prefix.js +35 -0
  82. package/dist/version.d.ts +1 -0
  83. package/dist/version.js +3 -0
  84. package/dist/worker/cms-worker.js +23 -2
  85. package/dist/worker/git-sync.d.ts +4 -0
  86. package/dist/worker/git-sync.js +31 -4
  87. package/dist/worker/rebase.d.ts +1 -1
  88. package/dist/worker/rebase.js +8 -0
  89. package/package.json +1 -1
  90. package/dist/api/request-url.d.ts +0 -8
  91. package/dist/api/request-url.js +0 -15
package/README.md CHANGED
@@ -104,6 +104,7 @@ export default defineCanopyConfig({
104
104
  colors: { brand: '#4f46e5' },
105
105
  },
106
106
  // previewBase: { 'content/posts': '/blog' }, // optional overrides
107
+ // previewPrefix: '/preview', // optional, in front of every preview URL
107
108
  },
108
109
  // For prod mode, defaultRemoteUrl is required.
109
110
  // For dev, it's optional - if omitted, uses auto-initialized local remote at .canopy-dev/remote.git
@@ -323,7 +324,7 @@ name) works, but leaves the entry's real `urlPath` as
323
324
  URL-derived surface then has to be told about one at a time. The example app in this repo used to
324
325
  do that and no longer does.
325
326
 
326
- Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to your configured base branch. Both enforce the same branch/path access rules as the API handlers.
327
+ Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to the active branch: `defaultActiveBranch`, or when that is unset, git HEAD in `dev` and your base branch in `prod`. Any other branch must already exist and be readable by the current user, or it reads as not found. Both enforce the same branch/path access rules as the API handlers.
327
328
 
328
329
  **Index entries and URL resolution**
329
330
 
@@ -401,7 +402,7 @@ _TODO_ show real examples of what to do
401
402
 
402
403
  ### Preview branch awareness
403
404
 
404
- - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param automatically to `previewBaseByCollection`; your page loaders should read `searchParams.branch` and pass it into `createContentReader`.
405
+ - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param automatically to `previewBaseByCollection`; your page loaders should read `searchParams.branch` and pass it as the `branch` option to `getCanopy()`'s `read`/`readByUrlPath`.
405
406
  - For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
406
407
  - Likewise, include `branch` in your editor route (e.g., `/edit?branch=feature-foo`) and have your editor page pass it to `<Editor>` so reloads/links preserve the selected branch. The `Editor` will also reflect branch switches back into the query string.
407
408
 
@@ -8,7 +8,7 @@
8
8
  import { z } from 'zod';
9
9
  import type { ApiResponse } from './types.js';
10
10
  import type { Task, QueueStats, CorruptTaskFile } from '../task-queue/cms-task-queue.js';
11
- import type { WorkerStatusReport } from '../types.js';
11
+ import type { BuildIdentity, WorkerStatusReport } from '../types.js';
12
12
  import type { OperatingMode } from '../operating-mode/index.js';
13
13
  export type { BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse, } from './admin-branch-health.js';
14
14
  type WorkerLivenessState = 'alive' | 'stale' | 'absent';
@@ -31,6 +31,18 @@ export interface AdminStatusData {
31
31
  worker: WorkerLiveness;
32
32
  workerStatus: WorkerStatusReport | null;
33
33
  statusReadError?: string;
34
+ /**
35
+ * Why this process cannot provision the settings workspace (groups and path rules), when it
36
+ * cannot. Its requests that resolve a user answer 503 meanwhile; bootstrap admins still reach
37
+ * /admin.
38
+ */
39
+ settingsWorkspaceError?: string;
40
+ /** Build of the API process answering this request. */
41
+ build: BuildIdentity;
42
+ /** Whether `media` is configured; without it every upload returns 501. */
43
+ assetStore: {
44
+ configured: boolean;
45
+ };
34
46
  }
35
47
  /** Response type for GET /admin/status */
36
48
  export type AdminStatusResponse = ApiResponse<AdminStatusData>;
package/dist/api/admin.js CHANGED
@@ -12,7 +12,8 @@ import { getQueueStats, listTasks, listCorruptTaskFiles, requeueFailedTask, } fr
12
12
  import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
13
13
  import { WORKER_STATUS_FILE } from '../task-queue/worker-status.js';
14
14
  import { defineEndpoint } from './route-builder.js';
15
- import { getErrorMessage, isNotFoundError } from '../utils/error.js';
15
+ import { getErrorMessage, isNotFoundError, redactCredentials } from '../utils/error.js';
16
+ import { getBuildIdentity } from '../build-identity.js';
16
17
  import { ADMIN_BRANCH_HEALTH_ROUTES } from './admin-branch-health.js';
17
18
  /**
18
19
  * 60_000 = DEFAULT_LOCK_STALE_MS in worker/cms-worker.ts, hardcoded rather
@@ -72,6 +73,15 @@ async function readWorkerStatus(taskDir) {
72
73
  return { workerStatus: null, statusReadError: getErrorMessage(err) };
73
74
  }
74
75
  }
76
+ async function readSettingsWorkspaceError(ctx) {
77
+ try {
78
+ await ctx.services.getSettingsBranchRoot();
79
+ return undefined;
80
+ }
81
+ catch (err) {
82
+ return redactCredentials(getErrorMessage(err));
83
+ }
84
+ }
75
85
  /** Age (ms) of the oldest file in pending/, or undefined if empty/missing. */
76
86
  async function getOldestPendingAgeMs(taskDir) {
77
87
  const pendingDir = path.join(taskDir, 'pending');
@@ -126,11 +136,12 @@ const deleteTaskParamsSchema = z.object({
126
136
  const getAdminStatusHandler = async (_gc, ctx, _req) => {
127
137
  const taskDir = getTaskQueueDir(ctx.services.config);
128
138
  try {
129
- const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }] = await Promise.all([
139
+ const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }, settingsWorkspaceError,] = await Promise.all([
130
140
  getQueueStats(taskDir),
131
141
  getOldestPendingAgeMs(taskDir),
132
142
  classifyWorkerLiveness(taskDir),
133
143
  readWorkerStatus(taskDir),
144
+ readSettingsWorkspaceError(ctx),
134
145
  ]);
135
146
  return {
136
147
  ok: true,
@@ -145,6 +156,9 @@ const getAdminStatusHandler = async (_gc, ctx, _req) => {
145
156
  worker,
146
157
  workerStatus,
147
158
  ...(statusReadError ? { statusReadError } : {}),
159
+ ...(settingsWorkspaceError ? { settingsWorkspaceError } : {}),
160
+ build: getBuildIdentity(),
161
+ assetStore: { configured: !!ctx.assetStore },
148
162
  },
149
163
  };
150
164
  }
@@ -255,6 +269,8 @@ const getAdminStatus = defineEndpoint({
255
269
  queue: { pending: 0, processing: 0, completed: 0, failed: 0, corrupt: 0 },
256
270
  worker: { state: 'absent' },
257
271
  workerStatus: null,
272
+ build: { canopycmsVersion: '0.0.0' },
273
+ assetStore: { configured: false },
258
274
  },
259
275
  guards: ['admin'],
260
276
  handler: getAdminStatusHandler,
@@ -8,6 +8,7 @@ import { canPerformWorkflowAction, getBranchProtection } from '../authorization/
8
8
  import { syncSubmitPr } from './github-sync.js';
9
9
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
10
10
  import { isNonFastForwardRejection } from '../utils/git.js';
11
+ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
11
12
  import { submissionEditorFromUser } from '../submission-attribution.js';
12
13
  const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
13
14
  const { branchContext } = gc;
@@ -57,6 +58,17 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
57
58
  ({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
58
59
  }
59
60
  catch (err) {
61
+ // Retriable either way: a resubmit commits nothing new and pushes only
62
+ // what has not reached the remote.
63
+ if (err instanceof ContentWriteLockBusyError) {
64
+ return {
65
+ ok: false,
66
+ status: 409,
67
+ error: err.outcome === 'not-run'
68
+ ? `Could not submit "${branchContext.branch.name}" right now: it is being synced with its base branch, or a save is in flight. Nothing was submitted; try again in a moment.`
69
+ : `"${branchContext.branch.name}" was being synced while it was submitted, so the submit may not have completed. Try submitting again.`,
70
+ };
71
+ }
60
72
  const message = getErrorMessage(err);
61
73
  // Full path detail (including branchRoot, an absolute path) to server logs
62
74
  // only; the client only ever sees the sanitized form (API-H2). Credentials
@@ -66,8 +78,7 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
66
78
  // A non-fast-forward rejection means this branch and the deployment's
67
79
  // local repository have diverged. Retrying the identical push can never
68
80
  // succeed (see isNonFastForwardRejection), so surface 409 instead of the
69
- // generic 500 below. Everything else (network, auth, lock contention)
70
- // keeps the existing 500 path unchanged.
81
+ // generic 500 below. Everything else (network, auth) keeps the 500 path.
71
82
  //
72
83
  // This push targets the deployment's OWN local origin (remote.git), not
73
84
  // GitHub, so the message deliberately states only the observable fact and
@@ -10,7 +10,7 @@ import { clientOperatingStrategy } from '../operating-mode/index.js';
10
10
  import { isNotFoundError, getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
11
11
  import { filePathExists } from '../utils/fs.js';
12
12
  import { isNetworkRemoteUrl } from '../utils/git.js';
13
- import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, } from '../paths/index.js';
13
+ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, RESERVED_ROUTE_BRANCH_NAMES, isSettingsBranchName, } from '../paths/index.js';
14
14
  import { GitManager } from '../git-manager.js';
15
15
  import { branchNameSchema, branchParamSchema } from './validators.js';
16
16
  const log = createDebugLogger({ prefix: 'BranchAPI' });
@@ -95,15 +95,12 @@ export const createBranchHandler = async (ctx, req, body) => {
95
95
  branchName,
96
96
  userId: req.user.userId,
97
97
  });
98
- // Scope note: the collision guards below (settings-branch collision,
99
- // reserved canopycms-settings- prefix, and the remote-mirror check
100
- // further down) apply ONLY to this user-facing creation path.
101
- // http/handler.ts's auto-create (base/active/settings branches) and
102
- // branch-workspace.ts's loadOrCreateBranchContext (reached from run-time
103
- // content reads and the AI pipeline; build-time reads return the checkout
104
- // and never provision) provision system/known branch names, not
105
- // user-chosen ones, and deliberately stay uncovered -- intentional, not
106
- // an oversight.
98
+ // Scope note: the remote-mirror check further down applies ONLY to this
99
+ // user-facing creation path; http/handler.ts's auto-create (base/active
100
+ // branches) and loadOrCreateBranchContext provision known names. The two
101
+ // settings-branch checks below give this path a specific 400; every
102
+ // provisioning path, this one included, also refuses a settings branch in
103
+ // BranchWorkspaceManager.openOrCreateBranch.
107
104
  // Prevent git branch name collision with the settings branch. Settings
108
105
  // live in a separate directory but share the same git remote, and
109
106
  // openOrCreateBranch (branch-workspace.ts) uses the SANITIZED name as the
@@ -337,7 +334,10 @@ export const listBranchesHandler = async (ctx, req) => {
337
334
  error: 'Branch registry not initialized — ensure the workspace has been initialized',
338
335
  };
339
336
  }
340
- const allBranches = await ctx.services.registry.list();
337
+ // A settings-branch workspace is never resolvable as a content branch (see
338
+ // http/handler.ts), so one left on disk is hidden rather than listed unopenable.
339
+ const settingsBranch = operatingStrategy(ctx.services.config.mode).getSettingsBranchName(ctx.services.config);
340
+ const allBranches = (await ctx.services.registry.list()).filter((context) => !isSettingsBranchName(context.branch.name, settingsBranch));
341
341
  // The branch the editor should open when none is pinned via URL/config.
342
342
  // Read per-request so dev-mode refreshActiveBranch() updates are reflected.
343
343
  // Sanitized: dev mode detects the RAW git HEAD name (e.g. 'claude/foo'),
@@ -28,7 +28,7 @@ export interface ApiClientOptions {
28
28
  /**
29
29
  * End request paths with `/` (by `withTrailingSlash`'s rule), matching a Next host built with
30
30
  * `trailingSlash: true`. Defaults to the value `withCanopy` inlines at build time (see
31
- * `request-url.ts`), else false.
31
+ * `readTrailingSlashEnv`), else false.
32
32
  */
33
33
  trailingSlash?: boolean;
34
34
  /**
@@ -6,8 +6,7 @@
6
6
  * (changes will be overwritten), edit the generator's templates instead.
7
7
  */
8
8
  import { computeContentSha256Hex } from './request-body-hash.js';
9
- import { readApiTrailingSlashEnv } from './request-url.js';
10
- import { withTrailingSlash } from '../utils/url-prefix.js';
9
+ import { readTrailingSlashEnv, withTrailingSlash } from '../utils/url-prefix.js';
11
10
  /**
12
11
  * Typed client for the CanopyCMS API: one method per endpoint, grouped by namespace, each
13
12
  * returning that endpoint's ApiResponse.
@@ -262,7 +261,7 @@ export class CanopyApiClient {
262
261
  this.baseUrl = options.baseUrl ?? '/api/canopycms';
263
262
  // An unbound fetch throws "Illegal invocation" in browsers; Node has no window.
264
263
  this.fetchFn = options.fetch ?? (typeof window !== 'undefined' ? fetch.bind(window) : fetch);
265
- this.trailingSlash = options.trailingSlash ?? readApiTrailingSlashEnv();
264
+ this.trailingSlash = options.trailingSlash ?? readTrailingSlashEnv();
266
265
  this.onUnauthorized = options.onUnauthorized;
267
266
  }
268
267
  buildPath(template, params) {
@@ -91,12 +91,16 @@ const readContentHandler = async (gc, ctx, req, params) => {
91
91
  const message = err instanceof ContentStoreError ? err.message : 'Invalid content request';
92
92
  return { ok: false, status: 400, error: sanitizeErrorMessage(message) };
93
93
  }
94
- const access = await ctx.services.checkContentAccess(branchContext, branchContext.branchRoot, entryLogicalPath(schemaItem.logicalPath, slug), req.user, 'read');
95
- if (!access.allowed) {
94
+ // One checker for the entry and every reference target it resolves, so a reference cannot
95
+ // carry a target's data past the rules that would refuse a direct read of it.
96
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
97
+ if (!checkAccess(entryLogicalPath(schemaItem.logicalPath, slug), 'read').allowed) {
96
98
  return { ok: false, status: 403, error: 'Forbidden' };
97
99
  }
98
100
  try {
99
- const doc = await store.read(schemaItem.logicalPath, slug);
101
+ const doc = await store.read(schemaItem.logicalPath, slug, {
102
+ referenceAccess: (targetPath) => checkAccess(targetPath, 'read').allowed,
103
+ });
100
104
  return { ok: true, status: 200, data: doc };
101
105
  }
102
106
  catch (err) {
@@ -299,13 +299,16 @@ const deleteEntryHandler = async (gc, ctx, req, params) => {
299
299
  // C6: order cleanup is best-effort hygiene after the entry is already deleted, so no
300
300
  // failure here may turn an otherwise-successful delete into an error (a retry would
301
301
  // just 404). Surface it as a warning instead (mirrors content.ts's `validationWarnings`).
302
- const reason = err instanceof SchemaStoreBusyError ? 'schema is busy' : getErrorMessage(err);
303
- log.warn('delete-entry', 'Skipped order cleanup', {
304
- collectionPath,
305
- contentId,
306
- error: getErrorMessage(err),
307
- });
308
- orderCleanupWarning = `Entry deleted, but the collection order list could not be updated (${sanitizeErrorMessage(reason)}); it will still list this entry until the next schema change.`;
302
+ // An `'unknown'` outcome means the update ran to completion: nothing to warn of.
303
+ if (!(err instanceof SchemaStoreBusyError && err.outcome === 'unknown')) {
304
+ const reason = err instanceof SchemaStoreBusyError ? 'schema is busy' : getErrorMessage(err);
305
+ log.warn('delete-entry', 'Skipped order cleanup', {
306
+ collectionPath,
307
+ contentId,
308
+ error: getErrorMessage(err),
309
+ });
310
+ orderCleanupWarning = `Entry deleted, but the collection order list could not be updated (${sanitizeErrorMessage(reason)}); it will still list this entry until the next schema change.`;
311
+ }
309
312
  }
310
313
  }
311
314
  }
@@ -1,10 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ContentStore } from '../content-store.js';
3
3
  import { defineEndpoint } from './route-builder.js';
4
- import { ReferenceResolver } from '../reference-resolver.js';
5
- import { buildResolvedReference } from '../entry-schema.js';
6
- import { computeEntryUrl } from '../utils/entry-url.js';
7
- import { entryLogicalPath } from '../paths/index.js';
8
4
  import { branchNameSchema, contentIdSchema } from './validators.js';
9
5
  /**
10
6
  * Resolution does sequential per-ID file I/O, so the request body caps how much filesystem work
@@ -21,48 +17,25 @@ const resolveReferencesBodySchema = z.object({
21
17
  const resolveReferencesHandler = async (gc, ctx, req, params, body) => {
22
18
  const { branchContext } = gc;
23
19
  const { ids } = body;
24
- const flatSchema = branchContext.flatSchema;
25
- const contentRootName = ctx.services.config.contentRoot || 'content';
26
- const store = new ContentStore(branchContext.branchRoot, flatSchema, {
27
- contentRootName,
20
+ const store = new ContentStore(branchContext.branchRoot, branchContext.flatSchema, {
21
+ contentRootName: ctx.services.config.contentRoot || 'content',
28
22
  });
29
- // Get ID index (automatically loads if needed)
30
- const idIndex = await store.idIndex();
31
- // Resolve each ID to full document
32
- const resolver = new ReferenceResolver(store, idIndex);
33
- // Build the access checker once, reused for every id instead of re-loading per id in the loop.
34
- // A failure here (e.g. settings workspace unavailable) surfaces as a handler error, not
35
- // swallowed silently per id.
23
+ // Built once for every id. A failure here (e.g. settings workspace unavailable) surfaces as a
24
+ // handler error rather than being swallowed per id.
36
25
  const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
26
+ const access = (logicalPath) => checkAccess(logicalPath, 'read').allowed;
27
+ // The resolution `read()` applies to a reference field, so live preview shows this user what
28
+ // `read()` would: the target's data, or a `RestrictedReference` for a target they may not
29
+ // read. The target's own references stay ids, as in `read()`.
37
30
  const resolved = {};
38
31
  for (const id of ids) {
39
32
  try {
40
- const result = await resolver.resolve(id);
41
- if (result && result.exists && result.collection && result.slug) {
42
- // Check path-level read permission before returning content
43
- const access = checkAccess(entryLogicalPath(result.collection, result.slug), 'read');
44
- if (!access.allowed)
45
- continue;
46
- // `resolveReferences: false` matches the server-side resolver (content-store.ts's
47
- // resolveSingleReferenceOnce), so a nested reference inside a target renders the same way
48
- // in live preview as on the published site, instead of resolving one level deeper.
49
- const doc = await store.read(result.collection, result.slug, { resolveReferences: false });
50
- if (doc && doc.data) {
51
- // Same shape as the server-side resolver: target data first, then the reserved keys.
52
- // Order is the corruption guard (a target modelling `id` as content must not shadow the
53
- // real content ID); the extra keys (incl. `urlPath`) keep live preview and production
54
- // in sync for consumers like client-reference-resolver.ts.
55
- resolved[id] = buildResolvedReference(doc.data, {
56
- id,
57
- slug: result.slug,
58
- collection: result.collection,
59
- urlPath: computeEntryUrl(result.collection, result.slug, contentRootName),
60
- });
61
- }
62
- }
33
+ const value = await store.resolveReferenceTarget(id, access);
34
+ if (value)
35
+ resolved[id] = value;
63
36
  }
64
37
  catch (error) {
65
- // Skip failed resolutions, don't block entire request
38
+ // An id that fails to resolve is omitted rather than failing the whole request.
66
39
  console.error(`Failed to resolve reference ID ${id}:`, error);
67
40
  }
68
41
  }
@@ -73,7 +46,8 @@ const resolveReferencesHandler = async (gc, ctx, req, params, body) => {
73
46
  };
74
47
  };
75
48
  /**
76
- * Resolve reference IDs to full document objects
49
+ * Resolve reference IDs as a reference field would: full target data, or title + URL tagged
50
+ * `unavailable` for a target the user may not read. An id naming no entry is omitted.
77
51
  * POST /:branch/resolve-references
78
52
  * Body: { ids: string[] }
79
53
  */
@@ -1,7 +1,10 @@
1
1
  import type { ApiResponse } from './types.js';
2
+ import type { BuildIdentity } from '../types.js';
2
3
  export type UserInfoResponse = ApiResponse<{
3
4
  userId: string;
4
5
  groups: string[];
6
+ /** Admins only: the deployed version is reconnaissance for anyone else. */
7
+ build?: BuildIdentity;
5
8
  }>;
6
9
  export declare const USER_ROUTES: {
7
10
  readonly whoami: import("./route-builder.js").RouteDefinition<undefined, undefined, UserInfoResponse>;
package/dist/api/user.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { defineEndpoint } from './route-builder.js';
2
+ import { isAdmin } from '../authorization/helpers.js';
3
+ import { getBuildIdentity } from '../build-identity.js';
2
4
  /**
3
5
  * This is a PUBLIC endpoint - no special permissions required
4
6
  */
@@ -10,6 +12,7 @@ const getUserInfoHandler = async (ctx, req) => {
10
12
  data: {
11
13
  userId: req.user.userId,
12
14
  groups: req.user.groups,
15
+ ...(isAdmin(req.user.groups) ? { build: getBuildIdentity() } : {}),
13
16
  },
14
17
  };
15
18
  };
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * This is the one place that turns the (currently-unconsumed) `media` config
5
5
  * into a real store — see .claude/future-tasks/resolved/assets-media-system.md.
6
+ *
7
+ * The store is branch-agnostic: a local one is rooted at the dev workspace's
8
+ * `assets/` or at `media.directory` resolved against the process's working
9
+ * directory, never inside a branch clone. So asset writes take no [SYNC-C1]
10
+ * content-write lock — the worker's rebase never touches them.
6
11
  */
7
12
  import type { MediaConfig } from '../config/types.js';
8
13
  import type { AssetStore } from './types.js';
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * This is the one place that turns the (currently-unconsumed) `media` config
5
5
  * into a real store — see .claude/future-tasks/resolved/assets-media-system.md.
6
+ *
7
+ * The store is branch-agnostic: a local one is rooted at the dev workspace's
8
+ * `assets/` or at `media.directory` resolved against the process's working
9
+ * directory, never inside a branch clone. So asset writes take no [SYNC-C1]
10
+ * content-write lock — the worker's rebase never touches them.
6
11
  */
7
12
  import { LocalAssetStore } from './store-local.js';
8
13
  import { S3AssetStore } from './store-s3.js';
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { ensureBranchRoot } from './paths/index.js';
2
+ import { BranchPathError, ensureBranchRoot, isSettingsBranchName } from './paths/index.js';
3
3
  import { getBranchMetadataFileManager, loadBranchContext } from './branch-metadata.js';
4
4
  import { readsFromCheckout } from './build-mode.js';
5
5
  import { operatingStrategy } from './operating-mode/index.js';
@@ -77,6 +77,11 @@ export class BranchWorkspaceManager {
77
77
  }
78
78
  async openOrCreateBranch(options) {
79
79
  const { branchName, mode, basePathOverride, title, description, access, createdBy, remoteUrl } = options;
80
+ // resolveBranchPath refuses the reserved prefix; this also refuses an adopter's
81
+ // configured settings branch name, which only the config knows.
82
+ if (isSettingsBranchName(branchName, operatingStrategy(mode).getSettingsBranchName(this.config))) {
83
+ throw new BranchPathError('Settings branches are not content branches');
84
+ }
80
85
  const { branchRoot, baseRoot, branchName: safeName, } = await ensureBranchRoot({
81
86
  mode,
82
87
  branchName,
@@ -0,0 +1,3 @@
1
+ import type { BuildIdentity } from './types.js';
2
+ export type { BuildIdentity };
3
+ export declare function getBuildIdentity(env?: NodeJS.ProcessEnv): BuildIdentity;
@@ -0,0 +1,13 @@
1
+ import { CANOPYCMS_VERSION } from './version.js';
2
+ /**
3
+ * Set by the image build (Dockerfile.cms `ARG`), not by the infrastructure: a Lambda env var
4
+ * would name the infrastructure's commit and hide an image/infrastructure skew.
5
+ */
6
+ const SOURCE_REVISION_ENV = 'CANOPY_SOURCE_SHA';
7
+ export function getBuildIdentity(env = process.env) {
8
+ // The Dockerfile sets the variable to the empty string when the build arg is not passed.
9
+ const sourceRevision = env[SOURCE_REVISION_ENV]?.trim();
10
+ return sourceRevision
11
+ ? { canopycmsVersion: CANOPYCMS_VERSION, sourceRevision }
12
+ : { canopycmsVersion: CANOPYCMS_VERSION };
13
+ }