@ctcstack/ctcdocs-core 0.22.3 → 0.23.1
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.
- package/dist/access-configuration.d.ts +2 -0
- package/dist/access-configuration.js +4 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/project-layout.d.ts +14 -1
- package/dist/project-layout.js +14 -1
- package/dist/site-configuration.d.ts +44 -0
- package/dist/site-configuration.js +88 -1
- package/package.json +1 -1
- package/src/access-configuration.ts +5 -0
- package/src/index.ts +3 -0
- package/src/project-layout.ts +14 -1
- package/src/site-configuration.ts +168 -0
|
@@ -29,5 +29,7 @@ export interface AccessConfiguration {
|
|
|
29
29
|
readonly rules: readonly AccessRule[];
|
|
30
30
|
}
|
|
31
31
|
type Fail = (path: string, expectation: string) => never;
|
|
32
|
+
/** Whether a configured value has the shape of a Drive folder ID. */
|
|
33
|
+
export declare function isDriveFolderId(value: unknown): value is string;
|
|
32
34
|
export declare function parseAccessConfiguration(value: unknown, fail: Fail): AccessConfiguration;
|
|
33
35
|
export {};
|
|
@@ -15,6 +15,10 @@ export const EVERY_MEMBER = '*';
|
|
|
15
15
|
const GROUP_ADDRESS = /^[a-z0-9](?:[a-z0-9._%+-]*[a-z0-9])?@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u;
|
|
16
16
|
/** The characters Google uses in file and folder IDs. */
|
|
17
17
|
const FOLDER_ID = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
18
|
+
/** Whether a configured value has the shape of a Drive folder ID. */
|
|
19
|
+
export function isDriveFolderId(value) {
|
|
20
|
+
return typeof value === 'string' && FOLDER_ID.test(value);
|
|
21
|
+
}
|
|
18
22
|
function object(value, path, fail) {
|
|
19
23
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
20
24
|
fail(path, 'must be an object');
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccess
|
|
|
10
10
|
export type { AccessClass, AccessModel, ChainReaders, PublishedReaders, Readers, } from './access-classes.js';
|
|
11
11
|
export { accessFindings } from './access-findings.js';
|
|
12
12
|
export type { AccessFinding } from './access-findings.js';
|
|
13
|
-
export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } 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';
|
|
13
|
+
export { fetchCharacterLimit, INSIGHTS_DEFAULTS, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
|
|
14
|
+
export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, InsightsConfiguration, McpConfiguration, McpRecentConfiguration, McpSearchConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, StaleFolder, SyncConfigurationDefaults, } from './site-configuration.js';
|
|
15
15
|
export { agentDocumentFormat, DOCUMENT_FORMAT_BADGES, DOCUMENT_FORMAT_NOUNS, DOCUMENT_FORMATS, DOCUMENT_SOURCE_TYPES, documentFormatOf, documentSourceType, isDocumentSourceType, } from './document-format.js';
|
|
16
16
|
export type { AgentDocumentFormat, DocumentFormat, DocumentSourceType, } from './document-format.js';
|
package/dist/index.js
CHANGED
|
@@ -6,5 +6,5 @@ export { EVERY_MEMBER, parseAccessConfiguration, } from './access-configuration.
|
|
|
6
6
|
export { CorpusStructureError, EMPTY_CORPUS, parseCorpusStructure, readCorpusStructure, } from './corpus-structure.js';
|
|
7
7
|
export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccessModel, documentClass, folderChain, folderClass, intersectReaders, MEMBERS_CLASS, nextPublishedReaders, widensReaders, } from './access-classes.js';
|
|
8
8
|
export { accessFindings } from './access-findings.js';
|
|
9
|
-
export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
|
|
9
|
+
export { fetchCharacterLimit, INSIGHTS_DEFAULTS, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
|
|
10
10
|
export { agentDocumentFormat, DOCUMENT_FORMAT_BADGES, DOCUMENT_FORMAT_NOUNS, DOCUMENT_FORMATS, DOCUMENT_SOURCE_TYPES, documentFormatOf, documentSourceType, isDocumentSourceType, } from './document-format.js';
|
package/dist/project-layout.d.ts
CHANGED
|
@@ -93,8 +93,21 @@ export declare const PLATFORM_WORKERS: {
|
|
|
93
93
|
readonly documentsBinding: "KB_DOCUMENTS";
|
|
94
94
|
/** The AI Search instance that indexes that bucket. */
|
|
95
95
|
readonly searchBinding: "KB_SEARCH";
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* How often the site's Worker checks that the bucket matches its build,
|
|
98
|
+
* and deletes the records of use past their retention (ADR-050).
|
|
99
|
+
*/
|
|
97
100
|
readonly publishSchedule: "*/5 * * * *";
|
|
101
|
+
/**
|
|
102
|
+
* The database of operational state (ADR-050): records of use, and
|
|
103
|
+
* editors' workflow state. Never content, access or a reader's identity.
|
|
104
|
+
*/
|
|
105
|
+
readonly stateDatabaseBinding: "KB_STATE_DB";
|
|
106
|
+
/**
|
|
107
|
+
* The platform's migrations for that database, which the binding names so
|
|
108
|
+
* that `wrangler d1 migrations apply` finds them.
|
|
109
|
+
*/
|
|
110
|
+
readonly stateMigrations: "node_modules/@ctcstack/ctcdocs/worker/state/migrations";
|
|
98
111
|
/** Lets the OAuth library fetch a client's metadata document safely. */
|
|
99
112
|
readonly oauthCompatibilityFlag: "global_fetch_strictly_public";
|
|
100
113
|
};
|
package/dist/project-layout.js
CHANGED
|
@@ -93,8 +93,21 @@ export const PLATFORM_WORKERS = {
|
|
|
93
93
|
documentsBinding: 'KB_DOCUMENTS',
|
|
94
94
|
/** The AI Search instance that indexes that bucket. */
|
|
95
95
|
searchBinding: 'KB_SEARCH',
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* How often the site's Worker checks that the bucket matches its build,
|
|
98
|
+
* and deletes the records of use past their retention (ADR-050).
|
|
99
|
+
*/
|
|
97
100
|
publishSchedule: '*/5 * * * *',
|
|
101
|
+
/**
|
|
102
|
+
* The database of operational state (ADR-050): records of use, and
|
|
103
|
+
* editors' workflow state. Never content, access or a reader's identity.
|
|
104
|
+
*/
|
|
105
|
+
stateDatabaseBinding: 'KB_STATE_DB',
|
|
106
|
+
/**
|
|
107
|
+
* The platform's migrations for that database, which the binding names so
|
|
108
|
+
* that `wrangler d1 migrations apply` finds them.
|
|
109
|
+
*/
|
|
110
|
+
stateMigrations: 'node_modules/@ctcstack/ctcdocs/worker/state/migrations',
|
|
98
111
|
/** Lets the OAuth library fetch a client's metadata document safely. */
|
|
99
112
|
oauthCompatibilityFlag: 'global_fetch_strictly_public',
|
|
100
113
|
};
|
|
@@ -118,6 +118,22 @@ export interface SyncConfigurationDefaults {
|
|
|
118
118
|
* or to that limit where it is lower.
|
|
119
119
|
*/
|
|
120
120
|
readonly largeDocumentCharacters: number;
|
|
121
|
+
/**
|
|
122
|
+
* Months without an edit in Drive after which the content health page asks
|
|
123
|
+
* for a document to be reviewed (ADR-049). Defaults to 12; 0 asks for none.
|
|
124
|
+
*/
|
|
125
|
+
readonly staleAfterMonths: number;
|
|
126
|
+
/** Folders that keep their own line, for everything below them. */
|
|
127
|
+
readonly staleFolders: readonly StaleFolder[];
|
|
128
|
+
}
|
|
129
|
+
/** A folder whose documents are asked for review on a line of their own. */
|
|
130
|
+
export interface StaleFolder {
|
|
131
|
+
/** The Drive folder, with everything below it. */
|
|
132
|
+
readonly folder: string;
|
|
133
|
+
/** The folder's name as the author knew it, for review. */
|
|
134
|
+
readonly label: string;
|
|
135
|
+
/** Months without an edit; 0 asks for no review below this folder. */
|
|
136
|
+
readonly months: number;
|
|
121
137
|
}
|
|
122
138
|
export interface NavigationConfiguration {
|
|
123
139
|
/**
|
|
@@ -214,6 +230,12 @@ export interface McpConfiguration {
|
|
|
214
230
|
/** Characters of JSON a `browse` tree takes at most (ADR-044). */
|
|
215
231
|
readonly browseCharacters: number;
|
|
216
232
|
readonly recent: McpRecentConfiguration;
|
|
233
|
+
/**
|
|
234
|
+
* The language the documents are written in, as a language tag such as
|
|
235
|
+
* `en`. The MCP server tells assistants to search in it; unset,
|
|
236
|
+
* it says nothing of the language.
|
|
237
|
+
*/
|
|
238
|
+
readonly language?: string;
|
|
217
239
|
}
|
|
218
240
|
/** What `mcp` holds when a project sets only `enabled` (ADR-042). */
|
|
219
241
|
export declare const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'>;
|
|
@@ -224,11 +246,33 @@ export declare const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'>;
|
|
|
224
246
|
* (ADR-043), so the two never derive it apart.
|
|
225
247
|
*/
|
|
226
248
|
export declare function fetchCharacterLimit(mcp: McpConfiguration | undefined): number;
|
|
249
|
+
/**
|
|
250
|
+
* What people and assistants ask, recorded so editors see what the
|
|
251
|
+
* documentation fails to answer (ADR-051). The records live in the Worker's
|
|
252
|
+
* database (ADR-050), so it needs the MCP server, and with it a private
|
|
253
|
+
* deployment.
|
|
254
|
+
*/
|
|
255
|
+
export interface InsightsConfiguration {
|
|
256
|
+
readonly enabled: boolean;
|
|
257
|
+
/** Days a record of use is kept before the Worker's schedule deletes it. */
|
|
258
|
+
readonly retentionDays: number;
|
|
259
|
+
/**
|
|
260
|
+
* The reranker score, 0 to 1, below which a search's best result counts
|
|
261
|
+
* as not found. Unset, the weekly report takes the week's lowest fifth.
|
|
262
|
+
*/
|
|
263
|
+
readonly weakScore?: number;
|
|
264
|
+
/** Whether searches on the site are recorded as well as MCP calls. */
|
|
265
|
+
readonly siteSearch: boolean;
|
|
266
|
+
}
|
|
267
|
+
/** What `insights` holds when a project sets only `enabled` (ADR-051). */
|
|
268
|
+
export declare const INSIGHTS_DEFAULTS: Omit<InsightsConfiguration, 'enabled' | 'weakScore'>;
|
|
227
269
|
export interface SiteConfiguration {
|
|
228
270
|
/** Required to serve a private environment through the platform's Worker. */
|
|
229
271
|
readonly signIn?: SignInConfiguration;
|
|
230
272
|
/** Absent or disabled, the Worker serves no MCP or OAuth route. */
|
|
231
273
|
readonly mcp?: McpConfiguration;
|
|
274
|
+
/** Absent or disabled, the Worker records nothing of what is asked. */
|
|
275
|
+
readonly insights?: InsightsConfiguration;
|
|
232
276
|
/**
|
|
233
277
|
* Who may read which folder (ADR-039). Absent, every document is open to
|
|
234
278
|
* every reader the deployment admits, as before access rules existed.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync } from 'node:fs';
|
|
15
15
|
import { resolve } from 'node:path';
|
|
16
|
-
import { parseAccessConfiguration, } from './access-configuration.js';
|
|
16
|
+
import { isDriveFolderId, parseAccessConfiguration, } from './access-configuration.js';
|
|
17
17
|
import { PROJECT_LAYOUT } from './project-layout.js';
|
|
18
18
|
/** What `mcp` holds when a project sets only `enabled` (ADR-042). */
|
|
19
19
|
export const MCP_DEFAULTS = Object.freeze({
|
|
@@ -44,6 +44,8 @@ export const MCP_DEFAULTS = Object.freeze({
|
|
|
44
44
|
export function fetchCharacterLimit(mcp) {
|
|
45
45
|
return mcp?.enabled ? mcp.fetchCharacters : MCP_DEFAULTS.fetchCharacters;
|
|
46
46
|
}
|
|
47
|
+
/** What `insights` holds when a project sets only `enabled` (ADR-051). */
|
|
48
|
+
export const INSIGHTS_DEFAULTS = Object.freeze({ retentionDays: 90, siteSearch: true });
|
|
47
49
|
/** About 10,000 tokens of English: a starting value, not a measured one. */
|
|
48
50
|
const LARGE_DOCUMENT_CHARACTERS = 40_000;
|
|
49
51
|
export class SiteConfigurationError extends Error {
|
|
@@ -359,6 +361,8 @@ export function parseSiteConfiguration(input) {
|
|
|
359
361
|
'defaultLocale',
|
|
360
362
|
'largeImageMegabytes',
|
|
361
363
|
'largeDocumentCharacters',
|
|
364
|
+
'staleAfterMonths',
|
|
365
|
+
'staleFolders',
|
|
362
366
|
]);
|
|
363
367
|
const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
|
|
364
368
|
// The marker is embedded in an HTML comment in every generated Markdown
|
|
@@ -423,8 +427,10 @@ export function parseSiteConfiguration(input) {
|
|
|
423
427
|
'fetchCharacters',
|
|
424
428
|
'browseCharacters',
|
|
425
429
|
'recent',
|
|
430
|
+
'language',
|
|
426
431
|
]);
|
|
427
432
|
const enabled = flag(source, 'enabled', 'mcp.enabled');
|
|
433
|
+
const language = optionalLanguage(source.language);
|
|
428
434
|
if (enabled) {
|
|
429
435
|
if (!signIn) {
|
|
430
436
|
fail('mcp', 'needs signIn: assistants sign in as the site does');
|
|
@@ -439,12 +445,45 @@ export function parseSiteConfiguration(input) {
|
|
|
439
445
|
fetchCharacters: optionalNumber(source, 'fetchCharacters', 'mcp.fetchCharacters', MCP_DEFAULTS.fetchCharacters, { min: 1_000, whole: true }),
|
|
440
446
|
browseCharacters: optionalNumber(source, 'browseCharacters', 'mcp.browseCharacters', MCP_DEFAULTS.browseCharacters, { min: 1_000, whole: true }),
|
|
441
447
|
recent: mcpRecent(source.recent),
|
|
448
|
+
...(language ? { language } : {}),
|
|
442
449
|
};
|
|
443
450
|
}
|
|
444
451
|
const fetchLimit = fetchCharacterLimit(mcp);
|
|
452
|
+
let insights;
|
|
453
|
+
if (root.insights !== undefined) {
|
|
454
|
+
const source = record(root.insights, 'insights');
|
|
455
|
+
knownKeys(source, 'insights', [
|
|
456
|
+
'enabled',
|
|
457
|
+
'retentionDays',
|
|
458
|
+
'weakScore',
|
|
459
|
+
'siteSearch',
|
|
460
|
+
]);
|
|
461
|
+
const enabled = flag(source, 'enabled', 'insights.enabled');
|
|
462
|
+
if (enabled && mcp?.enabled !== true) {
|
|
463
|
+
fail('insights', 'needs mcp.enabled: it records what assistants ask');
|
|
464
|
+
}
|
|
465
|
+
if (enabled && !access) {
|
|
466
|
+
fail('insights', 'needs access.admins: only admins read the report');
|
|
467
|
+
}
|
|
468
|
+
insights = {
|
|
469
|
+
enabled,
|
|
470
|
+
/*
|
|
471
|
+
* A weekly report goes when the first day it aggregates does, a week
|
|
472
|
+
* after it is made (ADR-051): two weeks keep it readable for one.
|
|
473
|
+
*/
|
|
474
|
+
retentionDays: optionalNumber(source, 'retentionDays', 'insights.retentionDays', INSIGHTS_DEFAULTS.retentionDays, { min: 14, max: 365, whole: true }),
|
|
475
|
+
siteSearch: optionalFlag(source, 'siteSearch', 'insights.siteSearch', INSIGHTS_DEFAULTS.siteSearch),
|
|
476
|
+
...(source.weakScore === undefined
|
|
477
|
+
? {}
|
|
478
|
+
: {
|
|
479
|
+
weakScore: optionalNumber(source, 'weakScore', 'insights.weakScore', 0, { min: 0, max: 1, whole: false }),
|
|
480
|
+
}),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
445
483
|
return {
|
|
446
484
|
...(signIn ? { signIn } : {}),
|
|
447
485
|
...(mcp ? { mcp } : {}),
|
|
486
|
+
...(insights ? { insights } : {}),
|
|
448
487
|
...(access ? { access } : {}),
|
|
449
488
|
brand,
|
|
450
489
|
deployment,
|
|
@@ -466,9 +505,57 @@ export function parseSiteConfiguration(input) {
|
|
|
466
505
|
generatedBy,
|
|
467
506
|
largeImageMegabytes: optionalMegabytes(syncSource, 'largeImageMegabytes', 'sync.largeImageMegabytes', 2),
|
|
468
507
|
largeDocumentCharacters: optionalNumber(syncSource, 'largeDocumentCharacters', 'sync.largeDocumentCharacters', Math.min(LARGE_DOCUMENT_CHARACTERS, fetchLimit), { min: 1, max: fetchLimit, whole: true }),
|
|
508
|
+
staleAfterMonths: optionalNumber(syncSource, 'staleAfterMonths', 'sync.staleAfterMonths', 12, STALE_MONTHS),
|
|
509
|
+
staleFolders: optionalStaleFolders(syncSource.staleFolders),
|
|
469
510
|
},
|
|
470
511
|
};
|
|
471
512
|
}
|
|
513
|
+
/** A language tag Intl can read, such as `en` or `uk`. */
|
|
514
|
+
function optionalLanguage(value) {
|
|
515
|
+
if (value === undefined) {
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
if (typeof value === 'string') {
|
|
520
|
+
return new Intl.Locale(value).toString();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// Reported below.
|
|
525
|
+
}
|
|
526
|
+
return fail('mcp.language', 'must be a language tag such as "en"');
|
|
527
|
+
}
|
|
528
|
+
/** The range a line of months without an edit may take. */
|
|
529
|
+
const STALE_MONTHS = { min: 0, max: 120, whole: true };
|
|
530
|
+
function optionalStaleFolders(value) {
|
|
531
|
+
if (value === undefined) {
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
if (!Array.isArray(value)) {
|
|
535
|
+
fail('sync.staleFolders', 'must be a list');
|
|
536
|
+
}
|
|
537
|
+
const seen = new Set();
|
|
538
|
+
return value.map((entry, index) => {
|
|
539
|
+
const path = `sync.staleFolders[${index}]`;
|
|
540
|
+
const source = record(entry, path);
|
|
541
|
+
knownKeys(source, path, ['folder', 'label', 'months']);
|
|
542
|
+
if (!isDriveFolderId(source.folder)) {
|
|
543
|
+
fail(`${path}.folder`, 'must be a Drive folder ID');
|
|
544
|
+
}
|
|
545
|
+
if (seen.has(source.folder)) {
|
|
546
|
+
fail(`${path}.folder`, 'must not be listed twice');
|
|
547
|
+
}
|
|
548
|
+
seen.add(source.folder);
|
|
549
|
+
if (source.months === undefined) {
|
|
550
|
+
fail(`${path}.months`, 'must be a whole number from 0 to 120');
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
folder: source.folder,
|
|
554
|
+
label: text(source, 'label', `${path}.label`),
|
|
555
|
+
months: optionalNumber(source, 'months', `${path}.months`, 0, STALE_MONTHS),
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
}
|
|
472
559
|
/**
|
|
473
560
|
* Parsed configurations, keyed by absolute project root.
|
|
474
561
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctcstack/ctcdocs-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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",
|
|
@@ -40,6 +40,11 @@ const GROUP_ADDRESS =
|
|
|
40
40
|
/** The characters Google uses in file and folder IDs. */
|
|
41
41
|
const FOLDER_ID = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
42
42
|
|
|
43
|
+
/** Whether a configured value has the shape of a Drive folder ID. */
|
|
44
|
+
export function isDriveFolderId(value: unknown): value is string {
|
|
45
|
+
return typeof value === 'string' && FOLDER_ID.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
43
48
|
function object(
|
|
44
49
|
value: unknown,
|
|
45
50
|
path: string,
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ export { accessFindings } from './access-findings.js';
|
|
|
66
66
|
export type { AccessFinding } from './access-findings.js';
|
|
67
67
|
export {
|
|
68
68
|
fetchCharacterLimit,
|
|
69
|
+
INSIGHTS_DEFAULTS,
|
|
69
70
|
loadSiteConfiguration,
|
|
70
71
|
MCP_DEFAULTS,
|
|
71
72
|
parseSiteConfiguration,
|
|
@@ -79,12 +80,14 @@ export type {
|
|
|
79
80
|
DeploymentEnvironmentConfigurations,
|
|
80
81
|
DeploymentVisibility,
|
|
81
82
|
HomeConfiguration,
|
|
83
|
+
InsightsConfiguration,
|
|
82
84
|
McpConfiguration,
|
|
83
85
|
McpRecentConfiguration,
|
|
84
86
|
McpSearchConfiguration,
|
|
85
87
|
NavigationConfiguration,
|
|
86
88
|
SignInConfiguration,
|
|
87
89
|
SiteConfiguration,
|
|
90
|
+
StaleFolder,
|
|
88
91
|
SyncConfigurationDefaults,
|
|
89
92
|
} from './site-configuration.js';
|
|
90
93
|
export {
|
package/src/project-layout.ts
CHANGED
|
@@ -95,8 +95,21 @@ export const PLATFORM_WORKERS = {
|
|
|
95
95
|
documentsBinding: 'KB_DOCUMENTS',
|
|
96
96
|
/** The AI Search instance that indexes that bucket. */
|
|
97
97
|
searchBinding: 'KB_SEARCH',
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* How often the site's Worker checks that the bucket matches its build,
|
|
100
|
+
* and deletes the records of use past their retention (ADR-050).
|
|
101
|
+
*/
|
|
99
102
|
publishSchedule: '*/5 * * * *',
|
|
103
|
+
/**
|
|
104
|
+
* The database of operational state (ADR-050): records of use, and
|
|
105
|
+
* editors' workflow state. Never content, access or a reader's identity.
|
|
106
|
+
*/
|
|
107
|
+
stateDatabaseBinding: 'KB_STATE_DB',
|
|
108
|
+
/**
|
|
109
|
+
* The platform's migrations for that database, which the binding names so
|
|
110
|
+
* that `wrangler d1 migrations apply` finds them.
|
|
111
|
+
*/
|
|
112
|
+
stateMigrations: 'node_modules/@ctcstack/ctcdocs/worker/state/migrations',
|
|
100
113
|
/** Lets the OAuth library fetch a client's metadata document safely. */
|
|
101
114
|
oauthCompatibilityFlag: 'global_fetch_strictly_public',
|
|
102
115
|
} as const;
|
|
@@ -15,6 +15,7 @@ import { readFileSync } from 'node:fs';
|
|
|
15
15
|
import { resolve } from 'node:path';
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
|
+
isDriveFolderId,
|
|
18
19
|
parseAccessConfiguration,
|
|
19
20
|
type AccessConfiguration,
|
|
20
21
|
} from './access-configuration.js';
|
|
@@ -145,6 +146,23 @@ export interface SyncConfigurationDefaults {
|
|
|
145
146
|
* or to that limit where it is lower.
|
|
146
147
|
*/
|
|
147
148
|
readonly largeDocumentCharacters: number;
|
|
149
|
+
/**
|
|
150
|
+
* Months without an edit in Drive after which the content health page asks
|
|
151
|
+
* for a document to be reviewed (ADR-049). Defaults to 12; 0 asks for none.
|
|
152
|
+
*/
|
|
153
|
+
readonly staleAfterMonths: number;
|
|
154
|
+
/** Folders that keep their own line, for everything below them. */
|
|
155
|
+
readonly staleFolders: readonly StaleFolder[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A folder whose documents are asked for review on a line of their own. */
|
|
159
|
+
export interface StaleFolder {
|
|
160
|
+
/** The Drive folder, with everything below it. */
|
|
161
|
+
readonly folder: string;
|
|
162
|
+
/** The folder's name as the author knew it, for review. */
|
|
163
|
+
readonly label: string;
|
|
164
|
+
/** Months without an edit; 0 asks for no review below this folder. */
|
|
165
|
+
readonly months: number;
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
export interface NavigationConfiguration {
|
|
@@ -247,6 +265,12 @@ export interface McpConfiguration {
|
|
|
247
265
|
/** Characters of JSON a `browse` tree takes at most (ADR-044). */
|
|
248
266
|
readonly browseCharacters: number;
|
|
249
267
|
readonly recent: McpRecentConfiguration;
|
|
268
|
+
/**
|
|
269
|
+
* The language the documents are written in, as a language tag such as
|
|
270
|
+
* `en`. The MCP server tells assistants to search in it; unset,
|
|
271
|
+
* it says nothing of the language.
|
|
272
|
+
*/
|
|
273
|
+
readonly language?: string;
|
|
250
274
|
}
|
|
251
275
|
|
|
252
276
|
/** What `mcp` holds when a project sets only `enabled` (ADR-042). */
|
|
@@ -280,6 +304,31 @@ export function fetchCharacterLimit(mcp: McpConfiguration | undefined): number {
|
|
|
280
304
|
return mcp?.enabled ? mcp.fetchCharacters : MCP_DEFAULTS.fetchCharacters;
|
|
281
305
|
}
|
|
282
306
|
|
|
307
|
+
/**
|
|
308
|
+
* What people and assistants ask, recorded so editors see what the
|
|
309
|
+
* documentation fails to answer (ADR-051). The records live in the Worker's
|
|
310
|
+
* database (ADR-050), so it needs the MCP server, and with it a private
|
|
311
|
+
* deployment.
|
|
312
|
+
*/
|
|
313
|
+
export interface InsightsConfiguration {
|
|
314
|
+
readonly enabled: boolean;
|
|
315
|
+
/** Days a record of use is kept before the Worker's schedule deletes it. */
|
|
316
|
+
readonly retentionDays: number;
|
|
317
|
+
/**
|
|
318
|
+
* The reranker score, 0 to 1, below which a search's best result counts
|
|
319
|
+
* as not found. Unset, the weekly report takes the week's lowest fifth.
|
|
320
|
+
*/
|
|
321
|
+
readonly weakScore?: number;
|
|
322
|
+
/** Whether searches on the site are recorded as well as MCP calls. */
|
|
323
|
+
readonly siteSearch: boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** What `insights` holds when a project sets only `enabled` (ADR-051). */
|
|
327
|
+
export const INSIGHTS_DEFAULTS: Omit<
|
|
328
|
+
InsightsConfiguration,
|
|
329
|
+
'enabled' | 'weakScore'
|
|
330
|
+
> = Object.freeze({ retentionDays: 90, siteSearch: true });
|
|
331
|
+
|
|
283
332
|
/** About 10,000 tokens of English: a starting value, not a measured one. */
|
|
284
333
|
const LARGE_DOCUMENT_CHARACTERS = 40_000;
|
|
285
334
|
|
|
@@ -288,6 +337,8 @@ export interface SiteConfiguration {
|
|
|
288
337
|
readonly signIn?: SignInConfiguration;
|
|
289
338
|
/** Absent or disabled, the Worker serves no MCP or OAuth route. */
|
|
290
339
|
readonly mcp?: McpConfiguration;
|
|
340
|
+
/** Absent or disabled, the Worker records nothing of what is asked. */
|
|
341
|
+
readonly insights?: InsightsConfiguration;
|
|
291
342
|
/**
|
|
292
343
|
* Who may read which folder (ADR-039). Absent, every document is open to
|
|
293
344
|
* every reader the deployment admits, as before access rules existed.
|
|
@@ -786,6 +837,8 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
|
|
|
786
837
|
'defaultLocale',
|
|
787
838
|
'largeImageMegabytes',
|
|
788
839
|
'largeDocumentCharacters',
|
|
840
|
+
'staleAfterMonths',
|
|
841
|
+
'staleFolders',
|
|
789
842
|
]);
|
|
790
843
|
const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
|
|
791
844
|
// The marker is embedded in an HTML comment in every generated Markdown
|
|
@@ -868,8 +921,10 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
|
|
|
868
921
|
'fetchCharacters',
|
|
869
922
|
'browseCharacters',
|
|
870
923
|
'recent',
|
|
924
|
+
'language',
|
|
871
925
|
]);
|
|
872
926
|
const enabled = flag(source, 'enabled', 'mcp.enabled');
|
|
927
|
+
const language = optionalLanguage(source.language);
|
|
873
928
|
if (enabled) {
|
|
874
929
|
if (!signIn) {
|
|
875
930
|
fail('mcp', 'needs signIn: assistants sign in as the site does');
|
|
@@ -899,13 +954,64 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
|
|
|
899
954
|
{ min: 1_000, whole: true },
|
|
900
955
|
),
|
|
901
956
|
recent: mcpRecent(source.recent),
|
|
957
|
+
...(language ? { language } : {}),
|
|
902
958
|
};
|
|
903
959
|
}
|
|
904
960
|
const fetchLimit = fetchCharacterLimit(mcp);
|
|
905
961
|
|
|
962
|
+
let insights: InsightsConfiguration | undefined;
|
|
963
|
+
if (root.insights !== undefined) {
|
|
964
|
+
const source = record(root.insights, 'insights');
|
|
965
|
+
knownKeys(source, 'insights', [
|
|
966
|
+
'enabled',
|
|
967
|
+
'retentionDays',
|
|
968
|
+
'weakScore',
|
|
969
|
+
'siteSearch',
|
|
970
|
+
]);
|
|
971
|
+
const enabled = flag(source, 'enabled', 'insights.enabled');
|
|
972
|
+
if (enabled && mcp?.enabled !== true) {
|
|
973
|
+
fail('insights', 'needs mcp.enabled: it records what assistants ask');
|
|
974
|
+
}
|
|
975
|
+
if (enabled && !access) {
|
|
976
|
+
fail('insights', 'needs access.admins: only admins read the report');
|
|
977
|
+
}
|
|
978
|
+
insights = {
|
|
979
|
+
enabled,
|
|
980
|
+
/*
|
|
981
|
+
* A weekly report goes when the first day it aggregates does, a week
|
|
982
|
+
* after it is made (ADR-051): two weeks keep it readable for one.
|
|
983
|
+
*/
|
|
984
|
+
retentionDays: optionalNumber(
|
|
985
|
+
source,
|
|
986
|
+
'retentionDays',
|
|
987
|
+
'insights.retentionDays',
|
|
988
|
+
INSIGHTS_DEFAULTS.retentionDays,
|
|
989
|
+
{ min: 14, max: 365, whole: true },
|
|
990
|
+
),
|
|
991
|
+
siteSearch: optionalFlag(
|
|
992
|
+
source,
|
|
993
|
+
'siteSearch',
|
|
994
|
+
'insights.siteSearch',
|
|
995
|
+
INSIGHTS_DEFAULTS.siteSearch,
|
|
996
|
+
),
|
|
997
|
+
...(source.weakScore === undefined
|
|
998
|
+
? {}
|
|
999
|
+
: {
|
|
1000
|
+
weakScore: optionalNumber(
|
|
1001
|
+
source,
|
|
1002
|
+
'weakScore',
|
|
1003
|
+
'insights.weakScore',
|
|
1004
|
+
0,
|
|
1005
|
+
{ min: 0, max: 1, whole: false },
|
|
1006
|
+
),
|
|
1007
|
+
}),
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
906
1011
|
return {
|
|
907
1012
|
...(signIn ? { signIn } : {}),
|
|
908
1013
|
...(mcp ? { mcp } : {}),
|
|
1014
|
+
...(insights ? { insights } : {}),
|
|
909
1015
|
...(access ? { access } : {}),
|
|
910
1016
|
brand,
|
|
911
1017
|
deployment,
|
|
@@ -964,10 +1070,72 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
|
|
|
964
1070
|
Math.min(LARGE_DOCUMENT_CHARACTERS, fetchLimit),
|
|
965
1071
|
{ min: 1, max: fetchLimit, whole: true },
|
|
966
1072
|
),
|
|
1073
|
+
staleAfterMonths: optionalNumber(
|
|
1074
|
+
syncSource,
|
|
1075
|
+
'staleAfterMonths',
|
|
1076
|
+
'sync.staleAfterMonths',
|
|
1077
|
+
12,
|
|
1078
|
+
STALE_MONTHS,
|
|
1079
|
+
),
|
|
1080
|
+
staleFolders: optionalStaleFolders(syncSource.staleFolders),
|
|
967
1081
|
},
|
|
968
1082
|
};
|
|
969
1083
|
}
|
|
970
1084
|
|
|
1085
|
+
/** A language tag Intl can read, such as `en` or `uk`. */
|
|
1086
|
+
function optionalLanguage(value: unknown): string | undefined {
|
|
1087
|
+
if (value === undefined) {
|
|
1088
|
+
return undefined;
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
if (typeof value === 'string') {
|
|
1092
|
+
return new Intl.Locale(value).toString();
|
|
1093
|
+
}
|
|
1094
|
+
} catch {
|
|
1095
|
+
// Reported below.
|
|
1096
|
+
}
|
|
1097
|
+
return fail('mcp.language', 'must be a language tag such as "en"');
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** The range a line of months without an edit may take. */
|
|
1101
|
+
const STALE_MONTHS = { min: 0, max: 120, whole: true } as const;
|
|
1102
|
+
|
|
1103
|
+
function optionalStaleFolders(value: unknown): StaleFolder[] {
|
|
1104
|
+
if (value === undefined) {
|
|
1105
|
+
return [];
|
|
1106
|
+
}
|
|
1107
|
+
if (!Array.isArray(value)) {
|
|
1108
|
+
fail('sync.staleFolders', 'must be a list');
|
|
1109
|
+
}
|
|
1110
|
+
const seen = new Set<string>();
|
|
1111
|
+
return value.map((entry: unknown, index) => {
|
|
1112
|
+
const path = `sync.staleFolders[${index}]`;
|
|
1113
|
+
const source = record(entry, path);
|
|
1114
|
+
knownKeys(source, path, ['folder', 'label', 'months']);
|
|
1115
|
+
if (!isDriveFolderId(source.folder)) {
|
|
1116
|
+
fail(`${path}.folder`, 'must be a Drive folder ID');
|
|
1117
|
+
}
|
|
1118
|
+
if (seen.has(source.folder)) {
|
|
1119
|
+
fail(`${path}.folder`, 'must not be listed twice');
|
|
1120
|
+
}
|
|
1121
|
+
seen.add(source.folder);
|
|
1122
|
+
if (source.months === undefined) {
|
|
1123
|
+
fail(`${path}.months`, 'must be a whole number from 0 to 120');
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
folder: source.folder,
|
|
1127
|
+
label: text(source, 'label', `${path}.label`),
|
|
1128
|
+
months: optionalNumber(
|
|
1129
|
+
source,
|
|
1130
|
+
'months',
|
|
1131
|
+
`${path}.months`,
|
|
1132
|
+
0,
|
|
1133
|
+
STALE_MONTHS,
|
|
1134
|
+
),
|
|
1135
|
+
};
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
971
1139
|
/**
|
|
972
1140
|
* Parsed configurations, keyed by absolute project root.
|
|
973
1141
|
*
|