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
@@ -25,6 +25,12 @@ export declare const ensureReferenceFieldsHaveScope: (fields: unknown) => void;
25
25
  * can run at config-validation and schema-load time, before any entry exists.
26
26
  */
27
27
  export declare const forEachReferenceField: (fields: unknown, visit: (field: Record<string, unknown>) => void) => void;
28
+ /**
29
+ * Check that every `itemTitleField` sits on a `list: true` object and names a direct child
30
+ * of type `string` or `number`, the only values the card heading shows as typed (a reference
31
+ * would show its raw id, a select its stored value). Throws an error when it does not.
32
+ */
33
+ export declare const ensureItemTitleFieldsExist: (fields: unknown) => void;
28
34
  /**
29
35
  * Validate that inline groups don't cause field name collisions within the same scope.
30
36
  * Because inline groups flatten their children into the parent scope, a field name used
@@ -77,6 +77,43 @@ export const forEachReferenceField = (fields, visit) => {
77
77
  }
78
78
  }
79
79
  };
80
+ /**
81
+ * Check that every `itemTitleField` sits on a `list: true` object and names a direct child
82
+ * of type `string` or `number`, the only values the card heading shows as typed (a reference
83
+ * would show its raw id, a select its stored value). Throws an error when it does not.
84
+ */
85
+ export const ensureItemTitleFieldsExist = (fields) => {
86
+ if (!Array.isArray(fields))
87
+ return;
88
+ for (const field of fields) {
89
+ const f = field;
90
+ if (f?.type === 'object') {
91
+ const children = Array.isArray(f.fields) ? f.fields : [];
92
+ if (f.itemTitleField !== undefined) {
93
+ const label = `Object field "${f.name ?? 'unknown'}" has itemTitleField "${String(f.itemTitleField)}"`;
94
+ if (f.list !== true) {
95
+ throw new Error(`${label}, but itemTitleField applies only to list: true objects`);
96
+ }
97
+ const child = children.find((c) => c?.name === f.itemTitleField);
98
+ if (typeof f.itemTitleField !== 'string' || !child) {
99
+ throw new Error(`${label}, which is not one of its child fields`);
100
+ }
101
+ if (child.type !== 'string' && child.type !== 'number') {
102
+ throw new Error(`${label}, which must name a string or number field`);
103
+ }
104
+ }
105
+ ensureItemTitleFieldsExist(children);
106
+ }
107
+ else if (f?.type === 'group') {
108
+ ensureItemTitleFieldsExist(f.fields);
109
+ }
110
+ else if (f?.type === 'block' && Array.isArray(f.templates)) {
111
+ for (const template of f.templates) {
112
+ ensureItemTitleFieldsExist(template.fields);
113
+ }
114
+ }
115
+ }
116
+ };
80
117
  /**
81
118
  * Validate that inline groups don't cause field name collisions within the same scope.
82
119
  * Because inline groups flatten their children into the parent scope, a field name used
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { ContentFormat, FlatSchemaItem, EntrySchema } from './config/index.js';
5
5
  import type { LogicalPath, PhysicalPath, Slug, ContentId } from './paths/types.js';
6
- import { ContentStore, type ReferenceResolveCache } from './content-store.js';
6
+ import { ContentStore, type ReferenceResolveCache, type ReferenceTargetAccess } from './content-store.js';
7
7
  /** An entry listing item with raw filesystem data; no API-specific fields like canEdit. */
8
8
  export interface CollectionListItem {
9
9
  logicalPath: LogicalPath;
@@ -105,9 +105,9 @@ export interface ListEntriesOptions<T = Record<string, unknown>> {
105
105
  * not per referencing entry, since a single {@link ReferenceResolveCache} spans the whole
106
106
  * call. Nothing is constructed and nothing is scanned when this is off.
107
107
  *
108
- * **Path ACLs are not applied to the resolved targets**, matching `read()` exactly: a
109
- * reference can resolve to an entry the user could not `read()` directly. The entries being
110
- * LISTED are still ACL-filtered, and a filtered-out entry is never resolved at all.
108
+ * **Targets get the same path-ACL rule as `read()`**: a target the user may not read
109
+ * resolves to a `RestrictedReference` (title, URL, `unavailable: true`), never its data. A
110
+ * filtered-out entry is never resolved at all.
111
111
  */
112
112
  resolveReferences?: boolean;
113
113
  }
@@ -123,8 +123,8 @@ export interface ListEntriesOptions<T = Record<string, unknown>> {
123
123
  * unfiltered exactly as build-time callers want.
124
124
  */
125
125
  export interface ContentVisibilityOptions {
126
- /** Return false to drop an entry. Receives the entry's branch-root-relative physical path. */
127
- shouldInclude?: (physicalPath: PhysicalPath) => boolean;
126
+ /** Return false to drop an entry. Receives the entry's logical path (see `entryLogicalPath`). */
127
+ shouldInclude?: (logicalPath: LogicalPath) => boolean;
128
128
  }
129
129
  /** A collection node from the flattened schema. */
130
130
  export type CollectionSchemaItem = Extract<FlatSchemaItem, {
@@ -145,10 +145,13 @@ export type CollectionSchemaItem = Extract<FlatSchemaItem, {
145
145
  * whole batch. content-index-registry.ts holds stores through `WeakRef` + a
146
146
  * `FinalizationRegistry`, so these short-lived instances stay collectable.
147
147
  */
148
- export declare const createReferenceResolver: (branchRoot: string, flatSchema: FlatSchemaItem[], contentRootName: string) => {
148
+ export declare const createReferenceResolver: (branchRoot: string, flatSchema: FlatSchemaItem[], contentRootName: string, visibility: ContentVisibilityOptions | undefined) => ListingReferenceResolver;
149
+ /** What a listing call resolves references through; see {@link createReferenceResolver}. */
150
+ export interface ListingReferenceResolver {
149
151
  store: ContentStore;
150
152
  cache: ReferenceResolveCache;
151
- };
153
+ access: ReferenceTargetAccess | undefined;
154
+ }
152
155
  /**
153
156
  * Resolve `reference` fields in a collection's listed entries, in place of their raw data.
154
157
  *
@@ -157,10 +160,7 @@ export declare const createReferenceResolver: (branchRoot: string, flatSchema: F
157
160
  * what says which fields are references at all; an entry whose type has no resolvable schema
158
161
  * is returned untouched rather than guessed at.
159
162
  */
160
- export declare const resolveCollectionItemReferences: (items: CollectionListItem[], collection: CollectionSchemaItem, resolver: {
161
- store: ContentStore;
162
- cache: ReferenceResolveCache;
163
- }) => Promise<CollectionListItem[]>;
163
+ export declare const resolveCollectionItemReferences: (items: CollectionListItem[], collection: CollectionSchemaItem, resolver: ListingReferenceResolver) => Promise<CollectionListItem[]>;
164
164
  /**
165
165
  * List all content entries as a flat array: walks the schema to discover collections, reads
166
166
  * entries from each, and returns a flat list suitable for generateStaticParams, search
@@ -9,7 +9,7 @@ import { findBodyFieldName } from './utils/body-field.js';
9
9
  import { computeEntryUrl } from './utils/entry-url.js';
10
10
  import { asRecord, getFormatExtension } from './utils/format.js';
11
11
  import { resolveCollectionPath } from './content-id-index.js';
12
- import { validateAndNormalizePath } from './paths/index.js';
12
+ import { entryLogicalPath, validateAndNormalizePath } from './paths/index.js';
13
13
  import { isNotFoundError, getErrorMessage } from './utils/error.js';
14
14
  import { createDebugLogger } from './utils/debug.js';
15
15
  import { ContentStore, ContentStoreError, createReferenceResolveCache, } from './content-store.js';
@@ -81,9 +81,13 @@ import { parseTypedFilename } from './utils/typed-filename.js';
81
81
  * whole batch. content-index-registry.ts holds stores through `WeakRef` + a
82
82
  * `FinalizationRegistry`, so these short-lived instances stay collectable.
83
83
  */
84
- export const createReferenceResolver = (branchRoot, flatSchema, contentRootName) => ({
84
+ export const createReferenceResolver = (branchRoot, flatSchema, contentRootName, visibility) => ({
85
85
  store: new ContentStore(branchRoot, flatSchema, { contentRootName }),
86
86
  cache: createReferenceResolveCache(),
87
+ // The predicate that hides an entry from the listing is the one that restricts it as a
88
+ // reference target, so a target is never shown in a sibling's data that its own listing
89
+ // row would have hidden.
90
+ access: visibility?.shouldInclude,
87
91
  });
88
92
  /**
89
93
  * Resolve `reference` fields in a collection's listed entries, in place of their raw data.
@@ -99,7 +103,7 @@ export const resolveCollectionItemReferences = async (items, collection, resolve
99
103
  return item;
100
104
  return {
101
105
  ...item,
102
- data: await resolver.store.resolveReferences(item.data, fields, resolver.cache),
106
+ data: await resolver.store.resolveReferences(item.data, fields, resolver.cache, resolver.access),
103
107
  };
104
108
  }));
105
109
  /**
@@ -135,11 +139,11 @@ export async function listEntries(branchRoot, flatSchema, contentRootName, optio
135
139
  // One store + cache for the whole call, or nothing at all when the caller did not opt in.
136
140
  // See the `resolveReferences` option for the cost this buys back.
137
141
  const resolver = options?.resolveReferences
138
- ? createReferenceResolver(branchRoot, flatSchema, contentRootName)
142
+ ? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility)
139
143
  : null;
140
144
  const collectionResults = await Promise.all(collections.map(async (collection) => {
141
145
  const entries = await listCollectionEntries(branchRoot, collection, (file) => skippedFiles.push(file));
142
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
146
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
143
147
  // Resolve AFTER the visibility filter (a denied entry is never resolved, so its
144
148
  // references cost nothing and leak nothing) and BEFORE the mapping below, so
145
149
  // `extract` and `filter` both see resolved data — which is the entire point.
@@ -330,7 +334,7 @@ export const listCollectionEntries = async (root, collection, onSkip) => {
330
334
  readEntryData(absolutePath, format, bodyField),
331
335
  ]);
332
336
  const item = {
333
- logicalPath: `${collection.logicalPath}/${slug}`,
337
+ logicalPath: entryLogicalPath(collection.logicalPath, slug),
334
338
  contentId,
335
339
  slug,
336
340
  collectionPath: collection.logicalPath,
@@ -7,7 +7,16 @@ export interface ContentReaderOptions {
7
7
  basePathOverride?: string;
8
8
  defaultBranch?: string;
9
9
  createdBy?: string;
10
+ /**
11
+ * Provision a workspace for a branch that has none. Off by default: a `branch` handed in from a
12
+ * request (`?branch=`) would otherwise let any visitor create a workspace per distinct name, so
13
+ * turn it on only when every branch name the reader sees is trusted.
14
+ */
10
15
  allowCreateBranch?: boolean;
16
+ /**
17
+ * The sole branch resolver when given, replacing `allowCreateBranch`: null reads as NOT_FOUND.
18
+ * Reads from the checkout (static deployments, builds) never call it.
19
+ */
11
20
  getBranchContext?: (branch: string) => Promise<BranchContext | null>;
12
21
  }
13
22
  export interface ReadContentInput {
@@ -84,7 +93,7 @@ export interface ContentReader {
84
93
  }>;
85
94
  }
86
95
  /**
87
- * Server-side helper to read content directly from a branch workspace.
88
- * Falls back to creating the branch workspace (metadata + checkout) if missing.
96
+ * Server-side helper to read content directly from a branch workspace. A branch with no workspace
97
+ * reads as NOT_FOUND unless `allowCreateBranch` or a `getBranchContext` resolver says otherwise.
89
98
  */
90
99
  export declare const createContentReader: (options: ContentReaderOptions) => ContentReader;
@@ -1,14 +1,14 @@
1
1
  import { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace.js';
2
2
  import { ContentStore, ContentStoreError } from './content-store.js';
3
- import { resolveBranchPaths, } from './paths/index.js';
3
+ import { namesNoWorkspace, resolveBranchPaths, } from './paths/index.js';
4
4
  import { trimSlashes } from './paths/normalize.js';
5
5
  import { isIndexSlug } from './utils/entry-url.js';
6
6
  import { isDeployedStatic, isBuildMode, readsFromCheckout } from './build-mode.js';
7
7
  import { isNotFoundError } from './utils/error.js';
8
8
  import { resolveEntryLinksInData } from './entry-link-resolver.js';
9
9
  /**
10
- * Server-side helper to read content directly from a branch workspace.
11
- * Falls back to creating the branch workspace (metadata + checkout) if missing.
10
+ * Server-side helper to read content directly from a branch workspace. A branch with no workspace
11
+ * reads as NOT_FOUND unless `allowCreateBranch` or a `getBranchContext` resolver says otherwise.
12
12
  */
13
13
  export const createContentReader = (options) => {
14
14
  const services = options.services;
@@ -18,7 +18,7 @@ export const createContentReader = (options) => {
18
18
  services.config.defaultActiveBranch ??
19
19
  services.config.defaultBaseBranch ??
20
20
  'main';
21
- const allowCreateBranch = options.allowCreateBranch ?? true;
21
+ const allowCreateBranch = options.allowCreateBranch ?? false;
22
22
  const createdBy = options.createdBy ?? 'canopycms-content-reader';
23
23
  const resolveBranchContext = async (branchName) => {
24
24
  // Static deployments and builds read from the checkout: loadOrCreateBranchContext
@@ -34,11 +34,11 @@ export const createContentReader = (options) => {
34
34
  remoteUrl: services.config.defaultRemoteUrl,
35
35
  });
36
36
  }
37
- // Check custom resolver first (e.g., from HTTP handler)
38
37
  if (options.getBranchContext) {
39
- const existing = await options.getBranchContext(branchName);
40
- if (existing)
41
- return existing;
38
+ const resolved = await options.getBranchContext(branchName);
39
+ if (!resolved)
40
+ throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
41
+ return resolved;
42
42
  }
43
43
  if (allowCreateBranch) {
44
44
  return loadOrCreateBranchContext({
@@ -50,11 +50,14 @@ export const createContentReader = (options) => {
50
50
  remoteUrl: services.config.defaultRemoteUrl,
51
51
  });
52
52
  }
53
- // Not allowed to create — must exist
54
53
  const existing = await loadBranchContext({
55
54
  branchName,
56
55
  mode: operatingMode,
57
56
  basePathOverride,
57
+ }).catch((err) => {
58
+ if (namesNoWorkspace(err))
59
+ return null;
60
+ throw err;
58
61
  });
59
62
  if (!existing)
60
63
  throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
@@ -128,10 +131,10 @@ export const createContentReader = (options) => {
128
131
  throw new ContentStoreError(`Path is not a collection: ${entryPath}`, 'NO_SCHEMA_ITEM');
129
132
  }
130
133
  // Get the path WITHOUT reading the file
131
- let relativePath;
134
+ let logicalPath;
132
135
  // Absolute filesystem path to the entry file. Surfaced on read() / readByUrlPath()
133
136
  // for server-side colocated-artifact reads (e.g. a sibling profile.json). Already
134
- // computed here for permission checks; just carried through.
137
+ // computed here by path resolution; just carried through.
135
138
  let physicalPath;
136
139
  // Entry type + content ID come out of path resolution (buildPaths() derives both from the
137
140
  // schema item / filename), surfaced on read() / readByUrlPath() so callers can route or
@@ -140,7 +143,7 @@ export const createContentReader = (options) => {
140
143
  let entryId;
141
144
  try {
142
145
  const resolved = await store.resolveDocumentPath(entryPath, slug ?? '');
143
- relativePath = resolved.relativePath;
146
+ logicalPath = resolved.logicalPath;
144
147
  physicalPath = resolved.absolutePath;
145
148
  // resolveDocumentPath() always sets entryTypeName for a valid schema item, so no
146
149
  // further fallback is needed here; `id` is the one field that can be legitimately
@@ -161,10 +164,14 @@ export const createContentReader = (options) => {
161
164
  if (input.urlAddressableOnly && !store.declaresEntryType(entryPath, entryType)) {
162
165
  throw new ContentStoreError(`Entry type '${entryType}' is not declared by ${entryPath}`, 'NO_SCHEMA_ITEM');
163
166
  }
164
- // Check permissions BEFORE reading the file (security)
167
+ // Check permissions BEFORE reading the file (security). The same checker then judges every
168
+ // reference target, so a reference cannot carry a target's data past these rules.
165
169
  const shouldCheckPermissions = !(isDeployedStatic(services.config) || isBuildMode());
170
+ let referenceAccess;
166
171
  if (shouldCheckPermissions) {
167
- const access = await services.checkContentAccess(context, branchRoot, relativePath, user, 'read');
172
+ const checkAccess = await services.createContentAccessChecker(context, branchRoot, user);
173
+ referenceAccess = (targetPath) => checkAccess(targetPath, 'read').allowed;
174
+ const access = checkAccess(logicalPath, 'read');
168
175
  if (!access.allowed) {
169
176
  if (services.config.mode !== 'prod') {
170
177
  const reasons = [];
@@ -187,6 +194,7 @@ export const createContentReader = (options) => {
187
194
  try {
188
195
  const doc = await store.read(entryPath, slug ?? '', {
189
196
  resolveReferences: input.resolveReferences ?? true,
197
+ referenceAccess,
190
198
  });
191
199
  return { doc, store, physicalPath, entryType, entryId };
192
200
  }
@@ -148,7 +148,8 @@ export interface ContentStoreOptions {
148
148
  * shorter than the `ContentStore` whose memoized `idIndex()` it sits on, so it adds no
149
149
  * staleness window and is out of scope for the generation-marker protocol in
150
150
  * `docs/concurrency.md`. Never make one module-global, persist one, or reuse one across
151
- * requests.
151
+ * requests. A cached value has the batch's {@link ReferenceTargetAccess} already applied, so a
152
+ * cache must never be shared by two readers.
152
153
  *
153
154
  * Misses are memoized alongside hits so one batch stays internally coherent: a shared block
154
155
  * resolving to data on page 1 and `null` on page 40 of one sitemap is worse than either
@@ -157,6 +158,21 @@ export interface ContentStoreOptions {
157
158
  export type ReferenceResolveCache = Map<string, Promise<Record<string, unknown> | null>>;
158
159
  /** Create a cache for one batch of reference resolution. See {@link ReferenceResolveCache}. */
159
160
  export declare const createReferenceResolveCache: () => ReferenceResolveCache;
161
+ /**
162
+ * Whether the reader may see a reference's target in full, given the target's logical path
163
+ * (`entryLogicalPath`). A denied target resolves to a `RestrictedReference` (entry-schema.ts).
164
+ *
165
+ * Built from `services.createContentAccessChecker` for the request's user, so reference
166
+ * targets are judged by the same rules as the entry being read. Omitted, every target resolves
167
+ * in full, which is what a build and a static deployment want (merged content is public there).
168
+ * Every request-time surface that returns resolved data to a user passes one.
169
+ */
170
+ export type ReferenceTargetAccess = (logicalPath: LogicalPath) => boolean;
171
+ export interface ReadOptions {
172
+ resolveReferences?: boolean;
173
+ /** See {@link ReferenceTargetAccess}. */
174
+ referenceAccess?: ReferenceTargetAccess;
175
+ }
160
176
  export declare class ContentStore {
161
177
  private readonly root;
162
178
  /** See ContentStoreOptions.contentRootName — the ID index scan root. */
@@ -347,6 +363,8 @@ export declare class ContentStore {
347
363
  resolveDocumentPath(schemaPath: LogicalPath, slug?: string): Promise<{
348
364
  absolutePath: string;
349
365
  relativePath: PhysicalPath;
366
+ /** The entry's logical path, the form path-permission rules match (`entryLogicalPath`). */
367
+ logicalPath: LogicalPath;
350
368
  id?: string;
351
369
  /**
352
370
  * Always populated for a valid schema item: the collection branch below resolves a name
@@ -363,9 +381,11 @@ export declare class ContentStore {
363
381
  */
364
382
  existed: boolean;
365
383
  }>;
366
- read(collectionPath: LogicalPath, slug?: Slug | '', options?: {
367
- resolveReferences?: boolean;
368
- }): Promise<ContentDocument>;
384
+ /**
385
+ * Read one entry. References resolve unless `resolveReferences: false`; a target that
386
+ * `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
387
+ */
388
+ read(collectionPath: LogicalPath, slug?: Slug | '', options?: ReadOptions): Promise<ContentDocument>;
369
389
  write(collectionPath: LogicalPath, slug: (Slug | "") | undefined, input: WriteInput, entryTypeName?: string, existingId?: ContentId): Promise<ContentDocument>;
370
390
  /**
371
391
  * Read an entry by its ID (UUID).
@@ -457,12 +477,15 @@ export declare class ContentStore {
457
477
  * a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
458
478
  * unmemoized behavior.
459
479
  *
460
- * **Path ACLs are not consulted for the targets.** Resolution goes through this store's own
461
- * `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
462
- * may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
463
- * matched here on purpose so one rule covers both.
480
+ * `access` applies to every target exactly as in `read()`; see {@link ReferenceTargetAccess}.
481
+ */
482
+ resolveReferences(data: Record<string, unknown>, fields: EntrySchema, cache?: ReferenceResolveCache, access?: ReferenceTargetAccess): Promise<Record<string, unknown>>;
483
+ /**
484
+ * Resolve one reference id the way a reference field resolves it, for the editor's
485
+ * live-preview endpoint (api/resolve-references.ts), which has ids but no field to walk. Null
486
+ * when the id names no entry. No body is embedded, since there is no field to ask for one.
464
487
  */
465
- resolveReferences(data: Record<string, unknown>, fields: EntrySchema, cache?: ReferenceResolveCache): Promise<Record<string, unknown>>;
488
+ resolveReferenceTarget(id: string, access?: ReferenceTargetAccess): Promise<Record<string, unknown> | null>;
466
489
  private resolveReferencesInData;
467
490
  /**
468
491
  * Resolve a single reference ID to full entry data, memoized when a batch cache is supplied.
@@ -503,5 +526,10 @@ export declare class ContentStore {
503
526
  * still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
504
527
  */
505
528
  private resolveSingleReferenceUncached;
529
+ /**
530
+ * The entry type an entry file was written as, by its filename's type token, falling back to
531
+ * the collection's default the way `read()` does for a legacy untyped file.
532
+ */
533
+ private entryTypeOf;
506
534
  private resolveSingleReferenceOnce;
507
535
  }
@@ -20,7 +20,8 @@ import { serializeFrontmatter, serializeYaml } from './utils/content-serialize.j
20
20
  import { withLock } from './utils/async-mutex.js';
21
21
  import { ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from './utils/content-write-lock.js';
22
22
  import { findBodyFieldName } from './utils/body-field.js';
23
- import { buildResolvedReference } from './entry-schema.js';
23
+ import { buildResolvedReference, buildRestrictedReference } from './entry-schema.js';
24
+ import { resolveEntryTitle } from './utils/title-field.js';
24
25
  import { computeEntryUrl } from './utils/entry-url.js';
25
26
  import { findUrlPathClaimant } from './url-collision.js';
26
27
  import { ContentIdIndex, extractIdFromFilename, extractSlugFromFilename, extractEntryTypeFromFilename, resolveCollectionPath, } from './content-id-index.js';
@@ -30,7 +31,7 @@ import { generateId } from './id.js';
30
31
  import { isNodeError } from './utils/error.js';
31
32
  import { filePathExists, readFileIfExists } from './utils/fs.js';
32
33
  import { asRecord, getFormatExtension } from './utils/format.js';
33
- import { normalizeFilesystemPath, parseSlug, } from './paths/index.js';
34
+ import { entryLogicalPath, normalizeFilesystemPath, parseSlug, } from './paths/index.js';
34
35
  /**
35
36
  * Acquire `keys` in canonical (sorted) order before running `fn`, ruling out AB-BA
36
37
  * deadlocks between callers with overlapping key sets. Only renameEntry() needs two
@@ -602,6 +603,7 @@ export class ContentStore {
602
603
  return {
603
604
  absolutePath: resolved,
604
605
  relativePath: path.relative(this.root, resolved),
606
+ logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
605
607
  id,
606
608
  entryTypeName: finalEntryTypeName,
607
609
  existed,
@@ -632,6 +634,10 @@ export class ContentStore {
632
634
  const schemaItem = this.assertSchemaItem(schemaPath);
633
635
  return await this.buildPaths(schemaItem, slug);
634
636
  }
637
+ /**
638
+ * Read one entry. References resolve unless `resolveReferences: false`; a target that
639
+ * `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
640
+ */
635
641
  async read(collectionPath, slug = '', options = {}) {
636
642
  const schemaItem = this.assertSchemaItem(collectionPath);
637
643
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName, } = await this.buildPaths(schemaItem, slug);
@@ -702,7 +708,7 @@ export class ContentStore {
702
708
  };
703
709
  }
704
710
  if (options.resolveReferences !== false) {
705
- doc.data = await this.resolveReferencesInData(doc.data, fields);
711
+ doc.data = await this.resolveReferencesInData(doc.data, fields, undefined, options.referenceAccess);
706
712
  }
707
713
  doc.version = stat.mtimeMs;
708
714
  return doc;
@@ -1382,21 +1388,26 @@ export class ContentStore {
1382
1388
  * a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
1383
1389
  * unmemoized behavior.
1384
1390
  *
1385
- * **Path ACLs are not consulted for the targets.** Resolution goes through this store's own
1386
- * `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
1387
- * may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
1388
- * matched here on purpose so one rule covers both.
1391
+ * `access` applies to every target exactly as in `read()`; see {@link ReferenceTargetAccess}.
1392
+ */
1393
+ async resolveReferences(data, fields, cache, access) {
1394
+ return this.resolveReferencesInData(data, fields, cache, access);
1395
+ }
1396
+ /**
1397
+ * Resolve one reference id the way a reference field resolves it, for the editor's
1398
+ * live-preview endpoint (api/resolve-references.ts), which has ids but no field to walk. Null
1399
+ * when the id names no entry. No body is embedded, since there is no field to ask for one.
1389
1400
  */
1390
- async resolveReferences(data, fields, cache) {
1391
- return this.resolveReferencesInData(data, fields, cache);
1401
+ async resolveReferenceTarget(id, access) {
1402
+ return this.resolveSingleReference(id, await this.idIndex(), false, undefined, access);
1392
1403
  }
1393
- async resolveReferencesInData(data, fields, cache) {
1404
+ async resolveReferencesInData(data, fields, cache, access) {
1394
1405
  const resolved = { ...data };
1395
1406
  const idIndex = await this.idIndex();
1396
1407
  for (const field of fields) {
1397
1408
  // Inline groups are transparent — recurse into their children at the same data level
1398
1409
  if (field.type === 'group') {
1399
- const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache);
1410
+ const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache, access);
1400
1411
  Object.assign(resolved, groupResolved);
1401
1412
  continue;
1402
1413
  }
@@ -1407,11 +1418,11 @@ export class ContentStore {
1407
1418
  // which routinely contains both kinds at once. See ReferenceFieldConfig.includeBody.
1408
1419
  const includeBody = field.includeBody === true;
1409
1420
  if (typeof value === 'string' && value) {
1410
- resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
1421
+ resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache, access);
1411
1422
  }
1412
1423
  else if (field.list && Array.isArray(value)) {
1413
1424
  resolved[field.name] = await Promise.all(value.map((id) => typeof id === 'string'
1414
- ? this.resolveSingleReference(id, idIndex, includeBody, cache)
1425
+ ? this.resolveSingleReference(id, idIndex, includeBody, cache, access)
1415
1426
  : null));
1416
1427
  }
1417
1428
  }
@@ -1421,11 +1432,11 @@ export class ContentStore {
1421
1432
  continue;
1422
1433
  if (objectField.list && Array.isArray(value)) {
1423
1434
  resolved[field.name] = await Promise.all(value.map((item) => typeof item === 'object' && item !== null
1424
- ? this.resolveReferencesInData(item, objectField.fields, cache)
1435
+ ? this.resolveReferencesInData(item, objectField.fields, cache, access)
1425
1436
  : item));
1426
1437
  }
1427
1438
  else if (typeof value === 'object') {
1428
- resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
1439
+ resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache, access);
1429
1440
  }
1430
1441
  }
1431
1442
  else if (field.type === 'block' && Array.isArray(value)) {
@@ -1439,7 +1450,7 @@ export class ContentStore {
1439
1450
  return block;
1440
1451
  return {
1441
1452
  ...b,
1442
- value: await this.resolveReferencesInData(b.value, template.fields, cache),
1453
+ value: await this.resolveReferencesInData(b.value, template.fields, cache, access),
1443
1454
  };
1444
1455
  }));
1445
1456
  }
@@ -1470,9 +1481,9 @@ export class ContentStore {
1470
1481
  * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
1471
1482
  * cloned once per referencing entry.
1472
1483
  */
1473
- resolveSingleReference(id, idIndex, includeBody, cache) {
1484
+ resolveSingleReference(id, idIndex, includeBody, cache, access) {
1474
1485
  if (!cache)
1475
- return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
1486
+ return this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
1476
1487
  // The key carries `includeBody`, not just the id: two fields can reference the SAME target
1477
1488
  // with different settings, and sharing one entry between them would make the shape depend
1478
1489
  // on which field the walk reached first.
@@ -1482,7 +1493,7 @@ export class ContentStore {
1482
1493
  // Store the in-flight promise with no `await` in between, so concurrent lookups from
1483
1494
  // one Promise.all batch find it and collapse onto a single read. Memoizing the promise
1484
1495
  // also shares the self-healing retry below rather than repeating it.
1485
- pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
1496
+ pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
1486
1497
  cache.set(key, pending);
1487
1498
  }
1488
1499
  // The cached promise always has this handler attached, so it is never an unhandled
@@ -1503,18 +1514,30 @@ export class ContentStore {
1503
1514
  * A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
1504
1515
  * still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
1505
1516
  */
1506
- async resolveSingleReferenceUncached(id, idIndex, includeBody) {
1507
- const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
1517
+ async resolveSingleReferenceUncached(id, idIndex, includeBody, access) {
1518
+ const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody, access);
1508
1519
  if (first !== STALE_LOOKUP)
1509
1520
  return first;
1510
1521
  // Force a rebuild (throttled). Even when this caller loses the throttle, retry against the
1511
1522
  // live index: a sibling lookup in the same batch may have won it and rebuilt (idIndex()
1512
1523
  // dedupes in-flight builds), so every miss in a Promise.all batch heals.
1513
1524
  await this.refreshIndexForSuspiciousLookup();
1514
- const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
1525
+ const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody, access);
1515
1526
  return second === STALE_LOOKUP ? null : second;
1516
1527
  }
1517
- async resolveSingleReferenceOnce(id, idIndex, includeBody) {
1528
+ /**
1529
+ * The entry type an entry file was written as, by its filename's type token, falling back to
1530
+ * the collection's default the way `read()` does for a legacy untyped file.
1531
+ */
1532
+ entryTypeOf(collectionPath, relativePath) {
1533
+ const schemaItem = this.assertSchemaItem(collectionPath);
1534
+ if (schemaItem.type === 'entry-type')
1535
+ return schemaItem;
1536
+ const entries = schemaItem.entries;
1537
+ const typeName = extractEntryTypeFromFilename(path.basename(relativePath));
1538
+ return entries?.find((e) => e.name === typeName) ?? getDefaultEntryType(schemaItem.entries);
1539
+ }
1540
+ async resolveSingleReferenceOnce(id, idIndex, includeBody, access) {
1518
1541
  try {
1519
1542
  const location = idIndex.findById(id);
1520
1543
  if (!location)
@@ -1526,32 +1549,36 @@ export class ContentStore {
1526
1549
  const doc = await this.read(location.collection, location.slug, {
1527
1550
  resolveReferences: false,
1528
1551
  });
1529
- // `urlPath` is what makes a resolved reference linkable: without it a page rendering
1530
- // "see also: <Target>" as a real anchor has no href from the resolution it already paid
1531
- // for, and had to run a SECOND full listing over the tree to build a contentId -> url
1532
- // table.
1533
- //
1534
- // Deliberately `computeEntryUrl` (utils/entry-url.ts), the same forward
1535
- // collection+slug -> url rule `listEntries` publishes as `item.urlPath` and
1552
+ // `urlPath` is what makes a resolved reference linkable without a second listing pass to
1553
+ // build a contentId -> url table. Deliberately `computeEntryUrl` (utils/entry-url.ts), the
1554
+ // forward collection+slug -> url rule `listEntries` publishes as `item.urlPath` and
1536
1555
  // `entry-link-resolver.ts` uses for `entry:ID` links -- NOT the reverse url -> entry
1537
- // resolver in url-path-resolver.ts. They agree today, but direction matters: this is the
1538
- // surface that DEFINES an entry's URL, so sourcing it from the resolver that consumes
1539
- // that definition would invert the dependency.
1540
- //
1541
- // The assembly -- data, then the embedded body, then the reserved metadata -- is
1542
- // `buildResolvedReference`'s job, because api/resolve-references.ts builds the same
1543
- // object for live preview and the two had drifted; its doc comment carries the ordering.
1556
+ // resolver in url-path-resolver.ts: this is the surface that DEFINES an entry's URL, so
1557
+ // sourcing it from the resolver that consumes that definition would invert the dependency.
1558
+ const meta = {
1559
+ id,
1560
+ slug: location.slug,
1561
+ collection: location.collection,
1562
+ urlPath: computeEntryUrl(location.collection, location.slug, this.contentRootName),
1563
+ };
1564
+ // Checked on the target's logical path, the space path rules are written in. The title is
1565
+ // the same `resolveEntryTitle` chain the entries API labels a listed entry with.
1566
+ if (access && !access(entryLogicalPath(location.collection, location.slug))) {
1567
+ const entryType = this.entryTypeOf(location.collection, location.relativePath);
1568
+ return buildRestrictedReference(meta, resolveEntryTitle(doc.data, {
1569
+ schema: entryType?.schema,
1570
+ entryTypeLabel: entryType?.label,
1571
+ slug: location.slug,
1572
+ }));
1573
+ }
1574
+ // `buildResolvedReference` owns the assembly order (data, embedded body, reserved
1575
+ // metadata); its doc comment says why the order is the contract.
1544
1576
  //
1545
1577
  // `'body' in doc` narrows the ContentDocument union to its markdown variant, so
1546
1578
  // `doc.body`/`doc.bodyFieldName` are reachable at all -- a type guard, not a redundant
1547
1579
  // runtime check. Only a field that asked to EMBED its target passes a body at all.
1548
1580
  const bodyForEmbed = includeBody && 'body' in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : undefined;
1549
- return buildResolvedReference(doc.data, {
1550
- id,
1551
- slug: location.slug,
1552
- collection: location.collection,
1553
- urlPath: computeEntryUrl(location.collection, location.slug, this.contentRootName),
1554
- }, bodyForEmbed);
1581
+ return buildResolvedReference(doc.data, meta, bodyForEmbed);
1555
1582
  }
1556
1583
  catch (error) {
1557
1584
  // Index hit but the file is gone — the typical symptom of an external
@@ -136,7 +136,7 @@ export interface BuildContentTreeOptions<T = unknown, TEntryTypes = DefaultEntry
136
136
  *
137
137
  * Same flag, same default (`false`) and same reasoning as
138
138
  * `ListEntriesOptions.resolveReferences` in content-listing.ts — see it for why the default
139
- * is opt-in, what it costs, and why path ACLs are not applied to the resolved targets.
139
+ * is opt-in, what it costs, and how path ACLs apply to the resolved targets.
140
140
  */
141
141
  resolveReferences?: boolean;
142
142
  }