@ctcstack/ctcdocs-core 0.18.0 → 0.20.0

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.
@@ -19,6 +19,8 @@ export interface CorpusDocument {
19
19
  * on its new chain to be confirmed.
20
20
  */
21
21
  readonly publishedReaders?: '*' | readonly string[];
22
+ /** Set while such a move waits, as the manifest's `readersHeld` says. */
23
+ readonly readersHeld?: true;
22
24
  /** The permanent short ID (ADR-022), the title and Drive's modified time. */
23
25
  readonly shortId?: string;
24
26
  readonly title?: string;
@@ -74,6 +74,9 @@ export function parseCorpusStructure(manifest) {
74
74
  ...(title ? { title } : {}),
75
75
  ...(modified ? { modified } : {}),
76
76
  ...(source ? { source } : {}),
77
+ ...(typeof record.readersHeld === 'object' && record.readersHeld !== null
78
+ ? { readersHeld: true }
79
+ : {}),
77
80
  ...(published === '*' ||
78
81
  (Array.isArray(published) &&
79
82
  published.every((group) => typeof group === 'string'))
package/dist/index.d.ts CHANGED
@@ -11,4 +11,4 @@ export type { AccessClass, AccessModel, ChainReaders, PublishedReaders, Readers,
11
11
  export { accessFindings } from './access-findings.js';
12
12
  export type { AccessFinding } from './access-findings.js';
13
13
  export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
14
- export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, McpConfiguration, McpSearchConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
14
+ export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, McpConfiguration, McpRecentConfiguration, McpSearchConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
@@ -26,6 +26,8 @@ export declare const PROJECT_LAYOUT: {
26
26
  readonly publicDirectory: "public";
27
27
  readonly robotsFile: "public/robots.txt";
28
28
  readonly headersFile: "public/_headers";
29
+ /** The pages a project writes by hand; generated ones sit beneath, apart. */
30
+ readonly documentsDirectory: "src/content/docs";
29
31
  /** Generated Markdown, one directory per Drive folder. */
30
32
  readonly generatedDocumentsDirectory: "src/content/docs/_generated";
31
33
  /** Original images, one directory per Google file identifier. */
@@ -68,6 +70,11 @@ export declare const PLATFORM_ROUTES: {
68
70
  * docs/ADR/024-content-health-page.md.
69
71
  */
70
72
  readonly contentHealth: "content-health";
73
+ /**
74
+ * Who may read each folder, for admins. See
75
+ * docs/ADR/045-an-access-review-page-shows-admins-who-may-read-each-folder.md.
76
+ */
77
+ readonly accessReview: "access-review";
71
78
  };
72
79
  /**
73
80
  * The platform's Workers, as a project's Wrangler configuration names them
@@ -100,6 +107,7 @@ export declare const ACCESS_CLASSES_ROUTE = "/_kb/classes";
100
107
  export declare const PLATFORM_ROUTE_HREFS: {
101
108
  readonly fullIndex: "/documents/";
102
109
  readonly contentHealth: "/content-health/";
110
+ readonly accessReview: "/access-review/";
103
111
  };
104
112
  export declare const RESERVED_SLUGS: readonly string[];
105
113
  /**
@@ -26,6 +26,8 @@ export const PROJECT_LAYOUT = {
26
26
  publicDirectory: 'public',
27
27
  robotsFile: 'public/robots.txt',
28
28
  headersFile: 'public/_headers',
29
+ /** The pages a project writes by hand; generated ones sit beneath, apart. */
30
+ documentsDirectory: 'src/content/docs',
29
31
  /** Generated Markdown, one directory per Drive folder. */
30
32
  generatedDocumentsDirectory: 'src/content/docs/_generated',
31
33
  /** Original images, one directory per Google file identifier. */
@@ -68,6 +70,11 @@ export const PLATFORM_ROUTES = {
68
70
  * docs/ADR/024-content-health-page.md.
69
71
  */
70
72
  contentHealth: 'content-health',
73
+ /**
74
+ * Who may read each folder, for admins. See
75
+ * docs/ADR/045-an-access-review-page-shows-admins-who-may-read-each-folder.md.
76
+ */
77
+ accessReview: 'access-review',
71
78
  };
72
79
  /**
73
80
  * The platform's Workers, as a project's Wrangler configuration names them
@@ -100,6 +107,7 @@ export const ACCESS_CLASSES_ROUTE = '/_kb/classes';
100
107
  export const PLATFORM_ROUTE_HREFS = {
101
108
  fullIndex: `/${PLATFORM_ROUTES.fullIndex}/`,
102
109
  contentHealth: `/${PLATFORM_ROUTES.contentHealth}/`,
110
+ accessReview: `/${PLATFORM_ROUTES.accessReview}/`,
103
111
  };
104
112
  /**
105
113
  * Top-level addresses the platform serves beside the corpus: the Worker's
@@ -33,6 +33,14 @@ export interface HomeConfiguration {
33
33
  * outgrown one page may prefer the home page to stay an entrance.
34
34
  */
35
35
  readonly corpusIndex: boolean;
36
+ /**
37
+ * The address of the hand-authored page the home page sends a newcomer to,
38
+ * as a slug: `about` for `src/content/docs/about.md`. The link reads that
39
+ * page's own title, so renaming the page renames the link. Without it the
40
+ * home page offers no such link: the platform does not know what a
41
+ * deployment calls its introduction, or whether it has one.
42
+ */
43
+ readonly start?: string;
36
44
  }
37
45
  /**
38
46
  * Who a deployment is for.
@@ -177,6 +185,13 @@ export interface McpSearchConfiguration {
177
185
  /** Characters of passage text one search returns at most, in all. */
178
186
  readonly passageCharacters: number;
179
187
  }
188
+ /** What `recent` lists (ADR-044). */
189
+ export interface McpRecentConfiguration {
190
+ /** Documents it lists when a call does not ask for a number. */
191
+ readonly defaultResults: number;
192
+ /** Documents a call may ask for at most. */
193
+ readonly results: number;
194
+ }
180
195
  /**
181
196
  * The MCP server through which AI assistants read the site as the person who
182
197
  * connected them (ADR-041). It needs the Worker's sign-in, so it is accepted
@@ -187,6 +202,9 @@ export interface McpConfiguration {
187
202
  readonly search: McpSearchConfiguration;
188
203
  /** Characters `fetch` returns at most; a longer document is cut there. */
189
204
  readonly fetchCharacters: number;
205
+ /** Characters of JSON a `browse` tree takes at most (ADR-044). */
206
+ readonly browseCharacters: number;
207
+ readonly recent: McpRecentConfiguration;
190
208
  }
191
209
  /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
192
210
  export declare const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'>;
@@ -27,11 +27,13 @@ export const MCP_DEFAULTS = Object.freeze({
27
27
  model: '@cf/baai/bge-reranker-base',
28
28
  threshold: 0,
29
29
  }),
30
- results: 10,
30
+ results: 15,
31
31
  passagesPerResult: 3,
32
32
  passageCharacters: 24_000,
33
33
  }),
34
34
  fetchCharacters: 100_000,
35
+ browseCharacters: 24_000,
36
+ recent: Object.freeze({ defaultResults: 20, results: 50 }),
35
37
  });
36
38
  /**
37
39
  * The characters an assistant reads of a document: the cut `fetch` makes
@@ -44,8 +46,6 @@ export function fetchCharacterLimit(mcp) {
44
46
  }
45
47
  /** About 10,000 tokens of English: a starting value, not a measured one. */
46
48
  const LARGE_DOCUMENT_CHARACTERS = 40_000;
47
- /** A passage of fewer characters than this says too little to judge by. */
48
- const SHORTEST_PASSAGE = 100;
49
49
  export class SiteConfigurationError extends Error {
50
50
  name = 'SiteConfigurationError';
51
51
  }
@@ -121,6 +121,21 @@ function flag(source, key, path) {
121
121
  function optionalFlag(source, key, path, fallback) {
122
122
  return source[key] === undefined ? fallback : flag(source, key, path);
123
123
  }
124
+ /**
125
+ * A page's address as Starlight derives it from a file under
126
+ * `src/content/docs`: lowercase segments joined by `/`, with no leading or
127
+ * trailing slash, so it is the same string the content collection knows.
128
+ */
129
+ function optionalPageSlug(source, key, path) {
130
+ if (source[key] === undefined) {
131
+ return {};
132
+ }
133
+ const value = text(source, key, path);
134
+ if (!/^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*$/u.test(value)) {
135
+ fail(path, 'must be a page address such as "about", without slashes at either end');
136
+ }
137
+ return { [key]: value };
138
+ }
124
139
  /**
125
140
  * A list of distinct Unicode script names, as a `\p{Script=…}` property
126
141
  * escape spells them. Each name is proved by compiling that escape, so a typo
@@ -231,7 +246,24 @@ function mcpSearch(value) {
231
246
  reranking,
232
247
  results,
233
248
  passagesPerResult: optionalNumber(source, 'passagesPerResult', 'mcp.search.passagesPerResult', defaults.passagesPerResult, { min: 1, whole: true }),
234
- passageCharacters: optionalNumber(source, 'passageCharacters', 'mcp.search.passageCharacters', defaults.passageCharacters, { min: results * SHORTEST_PASSAGE, whole: true }),
249
+ passageCharacters: optionalNumber(source, 'passageCharacters', 'mcp.search.passageCharacters', defaults.passageCharacters,
250
+ // Chunks are shown whole, so the budget answers to the chunk size, not
251
+ // to the results: those it cannot hold are listed without text.
252
+ { min: 1_000, whole: true }),
253
+ };
254
+ }
255
+ function mcpRecent(value) {
256
+ const defaults = MCP_DEFAULTS.recent;
257
+ if (value === undefined) {
258
+ return defaults;
259
+ }
260
+ const source = record(value, 'mcp.recent');
261
+ knownKeys(source, 'mcp.recent', Object.keys(defaults));
262
+ const results = optionalNumber(source, 'results', 'mcp.recent.results', defaults.results, { min: 1, whole: true });
263
+ // The default, like a set value, is no more than a call may ask for.
264
+ return {
265
+ defaultResults: optionalNumber(source, 'defaultResults', 'mcp.recent.defaultResults', Math.min(defaults.defaultResults, results), { min: 1, max: results, whole: true }),
266
+ results,
235
267
  };
236
268
  }
237
269
  function optionalMegabytes(source, key, path, fallback) {
@@ -378,7 +410,13 @@ export function parseSiteConfiguration(input) {
378
410
  let mcp;
379
411
  if (root.mcp !== undefined) {
380
412
  const source = record(root.mcp, 'mcp');
381
- knownKeys(source, 'mcp', ['enabled', 'search', 'fetchCharacters']);
413
+ knownKeys(source, 'mcp', [
414
+ 'enabled',
415
+ 'search',
416
+ 'fetchCharacters',
417
+ 'browseCharacters',
418
+ 'recent',
419
+ ]);
382
420
  const enabled = flag(source, 'enabled', 'mcp.enabled');
383
421
  if (enabled) {
384
422
  if (!signIn) {
@@ -392,6 +430,8 @@ export function parseSiteConfiguration(input) {
392
430
  enabled,
393
431
  search: mcpSearch(source.search),
394
432
  fetchCharacters: optionalNumber(source, 'fetchCharacters', 'mcp.fetchCharacters', MCP_DEFAULTS.fetchCharacters, { min: 1_000, whole: true }),
433
+ browseCharacters: optionalNumber(source, 'browseCharacters', 'mcp.browseCharacters', MCP_DEFAULTS.browseCharacters, { min: 1_000, whole: true }),
434
+ recent: mcpRecent(source.recent),
395
435
  };
396
436
  }
397
437
  const fetchLimit = fetchCharacterLimit(mcp);
@@ -405,6 +445,7 @@ export function parseSiteConfiguration(input) {
405
445
  corpusIndex: optionalFlag(homeSource, 'corpusIndex', 'home.corpusIndex', true),
406
446
  lede: text(homeSource, 'lede', 'home.lede'),
407
447
  recentLimit: optionalCount(homeSource, 'recentLimit', 'home.recentLimit', 6),
448
+ ...optionalPageSlug(homeSource, 'start', 'home.start'),
408
449
  },
409
450
  navigation: {
410
451
  landingDocumentTitles: titleList(navigationSource, 'landingDocumentTitles', 'navigation.landingDocumentTitles'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctcstack/ctcdocs-core",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Configuration schema, project layout, generated-path allowlist and the published Markdown form shared by the CTCDocs platform packages.",
5
5
  "keywords": [
6
6
  "ctcdocs",
@@ -33,6 +33,8 @@ export interface CorpusDocument {
33
33
  * on its new chain to be confirmed.
34
34
  */
35
35
  readonly publishedReaders?: '*' | readonly string[];
36
+ /** Set while such a move waits, as the manifest's `readersHeld` says. */
37
+ readonly readersHeld?: true;
36
38
  /** The permanent short ID (ADR-022), the title and Drive's modified time. */
37
39
  readonly shortId?: string;
38
40
  readonly title?: string;
@@ -125,6 +127,9 @@ export function parseCorpusStructure(manifest: unknown): CorpusStructure {
125
127
  ...(title ? { title } : {}),
126
128
  ...(modified ? { modified } : {}),
127
129
  ...(source ? { source } : {}),
130
+ ...(typeof record.readersHeld === 'object' && record.readersHeld !== null
131
+ ? { readersHeld: true as const }
132
+ : {}),
128
133
  ...(published === '*' ||
129
134
  (Array.isArray(published) &&
130
135
  published.every((group) => typeof group === 'string'))
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export type {
80
80
  DeploymentVisibility,
81
81
  HomeConfiguration,
82
82
  McpConfiguration,
83
+ McpRecentConfiguration,
83
84
  McpSearchConfiguration,
84
85
  NavigationConfiguration,
85
86
  SignInConfiguration,
@@ -26,6 +26,8 @@ export const PROJECT_LAYOUT = {
26
26
  publicDirectory: 'public',
27
27
  robotsFile: 'public/robots.txt',
28
28
  headersFile: 'public/_headers',
29
+ /** The pages a project writes by hand; generated ones sit beneath, apart. */
30
+ documentsDirectory: 'src/content/docs',
29
31
  /** Generated Markdown, one directory per Drive folder. */
30
32
  generatedDocumentsDirectory: 'src/content/docs/_generated',
31
33
  /** Original images, one directory per Google file identifier. */
@@ -69,6 +71,11 @@ export const PLATFORM_ROUTES = {
69
71
  * docs/ADR/024-content-health-page.md.
70
72
  */
71
73
  contentHealth: 'content-health',
74
+ /**
75
+ * Who may read each folder, for admins. See
76
+ * docs/ADR/045-an-access-review-page-shows-admins-who-may-read-each-folder.md.
77
+ */
78
+ accessReview: 'access-review',
72
79
  } as const;
73
80
 
74
81
  /**
@@ -104,6 +111,7 @@ export const ACCESS_CLASSES_ROUTE = '/_kb/classes';
104
111
  export const PLATFORM_ROUTE_HREFS = {
105
112
  fullIndex: `/${PLATFORM_ROUTES.fullIndex}/`,
106
113
  contentHealth: `/${PLATFORM_ROUTES.contentHealth}/`,
114
+ accessReview: `/${PLATFORM_ROUTES.accessReview}/`,
107
115
  } as const;
108
116
 
109
117
  /**
@@ -55,6 +55,14 @@ export interface HomeConfiguration {
55
55
  * outgrown one page may prefer the home page to stay an entrance.
56
56
  */
57
57
  readonly corpusIndex: boolean;
58
+ /**
59
+ * The address of the hand-authored page the home page sends a newcomer to,
60
+ * as a slug: `about` for `src/content/docs/about.md`. The link reads that
61
+ * page's own title, so renaming the page renames the link. Without it the
62
+ * home page offers no such link: the platform does not know what a
63
+ * deployment calls its introduction, or whether it has one.
64
+ */
65
+ readonly start?: string;
58
66
  }
59
67
 
60
68
  /**
@@ -209,6 +217,14 @@ export interface McpSearchConfiguration {
209
217
  readonly passageCharacters: number;
210
218
  }
211
219
 
220
+ /** What `recent` lists (ADR-044). */
221
+ export interface McpRecentConfiguration {
222
+ /** Documents it lists when a call does not ask for a number. */
223
+ readonly defaultResults: number;
224
+ /** Documents a call may ask for at most. */
225
+ readonly results: number;
226
+ }
227
+
212
228
  /**
213
229
  * The MCP server through which AI assistants read the site as the person who
214
230
  * connected them (ADR-041). It needs the Worker's sign-in, so it is accepted
@@ -219,6 +235,9 @@ export interface McpConfiguration {
219
235
  readonly search: McpSearchConfiguration;
220
236
  /** Characters `fetch` returns at most; a longer document is cut there. */
221
237
  readonly fetchCharacters: number;
238
+ /** Characters of JSON a `browse` tree takes at most (ADR-044). */
239
+ readonly browseCharacters: number;
240
+ readonly recent: McpRecentConfiguration;
222
241
  }
223
242
 
224
243
  /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
@@ -233,11 +252,13 @@ export const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'> = Object.freeze({
233
252
  model: '@cf/baai/bge-reranker-base',
234
253
  threshold: 0,
235
254
  }),
236
- results: 10,
255
+ results: 15,
237
256
  passagesPerResult: 3,
238
257
  passageCharacters: 24_000,
239
258
  }),
240
259
  fetchCharacters: 100_000,
260
+ browseCharacters: 24_000,
261
+ recent: Object.freeze({ defaultResults: 20, results: 50 }),
241
262
  });
242
263
 
243
264
  /**
@@ -253,9 +274,6 @@ export function fetchCharacterLimit(mcp: McpConfiguration | undefined): number {
253
274
  /** About 10,000 tokens of English: a starting value, not a measured one. */
254
275
  const LARGE_DOCUMENT_CHARACTERS = 40_000;
255
276
 
256
- /** A passage of fewer characters than this says too little to judge by. */
257
- const SHORTEST_PASSAGE = 100;
258
-
259
277
  export interface SiteConfiguration {
260
278
  /** Required to serve a private environment through the platform's Worker. */
261
279
  readonly signIn?: SignInConfiguration;
@@ -380,6 +398,29 @@ function optionalFlag(
380
398
  return source[key] === undefined ? fallback : flag(source, key, path);
381
399
  }
382
400
 
401
+ /**
402
+ * A page's address as Starlight derives it from a file under
403
+ * `src/content/docs`: lowercase segments joined by `/`, with no leading or
404
+ * trailing slash, so it is the same string the content collection knows.
405
+ */
406
+ function optionalPageSlug(
407
+ source: Record<string, unknown>,
408
+ key: string,
409
+ path: string,
410
+ ): Record<string, string> {
411
+ if (source[key] === undefined) {
412
+ return {};
413
+ }
414
+ const value = text(source, key, path);
415
+ if (!/^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*$/u.test(value)) {
416
+ fail(
417
+ path,
418
+ 'must be a page address such as "about", without slashes at either end',
419
+ );
420
+ }
421
+ return { [key]: value };
422
+ }
423
+
383
424
  /**
384
425
  * A list of distinct Unicode script names, as a `\p{Script=…}` property
385
426
  * escape spells them. Each name is proved by compiling that escape, so a typo
@@ -570,8 +611,37 @@ function mcpSearch(value: unknown): McpSearchConfiguration {
570
611
  'passageCharacters',
571
612
  'mcp.search.passageCharacters',
572
613
  defaults.passageCharacters,
573
- { min: results * SHORTEST_PASSAGE, whole: true },
614
+ // Chunks are shown whole, so the budget answers to the chunk size, not
615
+ // to the results: those it cannot hold are listed without text.
616
+ { min: 1_000, whole: true },
617
+ ),
618
+ };
619
+ }
620
+
621
+ function mcpRecent(value: unknown): McpRecentConfiguration {
622
+ const defaults = MCP_DEFAULTS.recent;
623
+ if (value === undefined) {
624
+ return defaults;
625
+ }
626
+ const source = record(value, 'mcp.recent');
627
+ knownKeys(source, 'mcp.recent', Object.keys(defaults));
628
+ const results = optionalNumber(
629
+ source,
630
+ 'results',
631
+ 'mcp.recent.results',
632
+ defaults.results,
633
+ { min: 1, whole: true },
634
+ );
635
+ // The default, like a set value, is no more than a call may ask for.
636
+ return {
637
+ defaultResults: optionalNumber(
638
+ source,
639
+ 'defaultResults',
640
+ 'mcp.recent.defaultResults',
641
+ Math.min(defaults.defaultResults, results),
642
+ { min: 1, max: results, whole: true },
574
643
  ),
644
+ results,
575
645
  };
576
646
  }
577
647
 
@@ -775,7 +845,13 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
775
845
  let mcp: McpConfiguration | undefined;
776
846
  if (root.mcp !== undefined) {
777
847
  const source = record(root.mcp, 'mcp');
778
- knownKeys(source, 'mcp', ['enabled', 'search', 'fetchCharacters']);
848
+ knownKeys(source, 'mcp', [
849
+ 'enabled',
850
+ 'search',
851
+ 'fetchCharacters',
852
+ 'browseCharacters',
853
+ 'recent',
854
+ ]);
779
855
  const enabled = flag(source, 'enabled', 'mcp.enabled');
780
856
  if (enabled) {
781
857
  if (!signIn) {
@@ -798,6 +874,14 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
798
874
  MCP_DEFAULTS.fetchCharacters,
799
875
  { min: 1_000, whole: true },
800
876
  ),
877
+ browseCharacters: optionalNumber(
878
+ source,
879
+ 'browseCharacters',
880
+ 'mcp.browseCharacters',
881
+ MCP_DEFAULTS.browseCharacters,
882
+ { min: 1_000, whole: true },
883
+ ),
884
+ recent: mcpRecent(source.recent),
801
885
  };
802
886
  }
803
887
  const fetchLimit = fetchCharacterLimit(mcp);
@@ -822,6 +906,7 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
822
906
  'home.recentLimit',
823
907
  6,
824
908
  ),
909
+ ...optionalPageSlug(homeSource, 'start', 'home.start'),
825
910
  },
826
911
  navigation: {
827
912
  landingDocumentTitles: titleList(