@ctcstack/ctcdocs-core 0.15.0 → 0.16.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-classes.d.ts +102 -0
- package/dist/access-classes.js +266 -0
- package/dist/access-configuration.d.ts +33 -0
- package/dist/access-configuration.js +89 -0
- package/dist/access-findings.d.ts +38 -0
- package/dist/access-findings.js +126 -0
- package/dist/corpus-structure.d.ts +39 -0
- package/dist/corpus-structure.js +96 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +5 -1
- package/dist/project-layout.d.ts +27 -0
- package/dist/project-layout.js +37 -1
- package/dist/site-configuration.d.ts +17 -0
- package/dist/site-configuration.js +45 -4
- package/package.json +1 -1
- package/src/access-classes.ts +384 -0
- package/src/access-configuration.ts +139 -0
- package/src/access-findings.ts +183 -0
- package/src/corpus-structure.ts +143 -0
- package/src/index.ts +45 -0
- package/src/project-layout.ts +40 -3
- package/src/site-configuration.ts +80 -4
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of the synchronized corpus: which folder holds what.
|
|
3
|
+
*
|
|
4
|
+
* Access is decided by a document's chain of Drive folders (ADR-039), and that
|
|
5
|
+
* chain is in the sync manifest. The sync package owns the manifest's full
|
|
6
|
+
* schema; this reads only the fields the chain needs, so that the build and
|
|
7
|
+
* the configuration checks can use it without depending on the sync.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { PROJECT_LAYOUT } from './project-layout.js';
|
|
13
|
+
|
|
14
|
+
export interface CorpusFolder {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
/** `null` for the Drive root the sync reads. */
|
|
17
|
+
readonly parentId: string | null;
|
|
18
|
+
/** The name in Drive, as typed. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
/** The name the site shows, without an order number. */
|
|
21
|
+
readonly label: string;
|
|
22
|
+
/** The folder's address, when it has one; the root has none. */
|
|
23
|
+
readonly slug: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CorpusDocument {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly parentId: string | null;
|
|
29
|
+
readonly slug: string;
|
|
30
|
+
/**
|
|
31
|
+
* The readers the document was last published with (ADR-039). Its class
|
|
32
|
+
* never reaches beyond them: a move that would widen them waits for a rule
|
|
33
|
+
* on its new chain to be confirmed.
|
|
34
|
+
*/
|
|
35
|
+
readonly publishedReaders?: '*' | readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CorpusStructure {
|
|
39
|
+
readonly rootFolderId: string | null;
|
|
40
|
+
readonly folders: ReadonlyMap<string, CorpusFolder>;
|
|
41
|
+
readonly documents: ReadonlyMap<string, CorpusDocument>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CorpusStructureError extends Error {
|
|
45
|
+
override readonly name = 'CorpusStructureError';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const EMPTY_CORPUS: CorpusStructure = Object.freeze({
|
|
49
|
+
rootFolderId: null,
|
|
50
|
+
folders: new Map(),
|
|
51
|
+
documents: new Map(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function invalid(path: string): never {
|
|
55
|
+
throw new CorpusStructureError(
|
|
56
|
+
`${PROJECT_LAYOUT.manifestFile}: ${path} is not what the sync writes.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function entries(value: unknown, path: string): [string, unknown][] {
|
|
61
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
62
|
+
invalid(path);
|
|
63
|
+
}
|
|
64
|
+
return Object.entries(value as Record<string, unknown>);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function field(
|
|
68
|
+
source: unknown,
|
|
69
|
+
key: string,
|
|
70
|
+
path: string,
|
|
71
|
+
nullable = false,
|
|
72
|
+
): string | null {
|
|
73
|
+
const value = (source as Record<string, unknown>)[key];
|
|
74
|
+
if (nullable && value === null) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
78
|
+
invalid(`${path}.${key}`);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reads the folder chain out of a parsed sync manifest. */
|
|
84
|
+
export function parseCorpusStructure(manifest: unknown): CorpusStructure {
|
|
85
|
+
if (typeof manifest !== 'object' || manifest === null) {
|
|
86
|
+
invalid('the manifest');
|
|
87
|
+
}
|
|
88
|
+
const source = manifest as Record<string, unknown>;
|
|
89
|
+
const folders = new Map<string, CorpusFolder>();
|
|
90
|
+
for (const [id, raw] of entries(source.folders, 'folders')) {
|
|
91
|
+
const path = `folders.${id}`;
|
|
92
|
+
const slug = (raw as Record<string, unknown>).stableSlug;
|
|
93
|
+
folders.set(id, {
|
|
94
|
+
id,
|
|
95
|
+
parentId: field(raw, 'googleParentId', path, true),
|
|
96
|
+
name: field(raw, 'googleName', path) ?? '',
|
|
97
|
+
label: field(raw, 'displayLabel', path) ?? '',
|
|
98
|
+
slug: typeof slug === 'string' && slug.length > 0 ? slug : undefined,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const documents = new Map<string, CorpusDocument>();
|
|
102
|
+
for (const [id, raw] of entries(source.documents, 'documents')) {
|
|
103
|
+
const path = `documents.${id}`;
|
|
104
|
+
const published = (raw as Record<string, unknown>).publishedReaders;
|
|
105
|
+
documents.set(id, {
|
|
106
|
+
id,
|
|
107
|
+
parentId: field(raw, 'googleParentId', path, true),
|
|
108
|
+
slug: field(raw, 'stableSlug', path) ?? '',
|
|
109
|
+
...(published === '*' ||
|
|
110
|
+
(Array.isArray(published) &&
|
|
111
|
+
published.every((group) => typeof group === 'string'))
|
|
112
|
+
? { publishedReaders: published as '*' | readonly string[] }
|
|
113
|
+
: {}),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const root = source.rootFolderId;
|
|
117
|
+
return {
|
|
118
|
+
rootFolderId: typeof root === 'string' && root.length > 0 ? root : null,
|
|
119
|
+
folders,
|
|
120
|
+
documents,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The corpus of a project, or an empty one before its first sync. A manifest
|
|
126
|
+
* that exists but cannot be read is an error: guessing an empty corpus there
|
|
127
|
+
* would decide access without the folders it is about.
|
|
128
|
+
*/
|
|
129
|
+
export function readCorpusStructure(projectRoot: string): CorpusStructure {
|
|
130
|
+
let text: string;
|
|
131
|
+
try {
|
|
132
|
+
text = readFileSync(
|
|
133
|
+
resolve(projectRoot, PROJECT_LAYOUT.manifestFile),
|
|
134
|
+
'utf8',
|
|
135
|
+
);
|
|
136
|
+
} catch (error: unknown) {
|
|
137
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
138
|
+
return EMPTY_CORPUS;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
return parseCorpusStructure(JSON.parse(text));
|
|
143
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export {
|
|
2
|
+
ACCESS_CLASSES_ROUTE,
|
|
2
3
|
GENERATED_DIRECTORY_ALLOWLIST,
|
|
3
4
|
GENERATED_FILE_ALLOWLIST,
|
|
4
5
|
PLATFORM_ROUTE_HREFS,
|
|
5
6
|
PLATFORM_ROUTES,
|
|
7
|
+
PLATFORM_WORKERS,
|
|
6
8
|
permanentLinkPath,
|
|
7
9
|
PROJECT_LAYOUT,
|
|
8
10
|
RESERVED_SLUGS,
|
|
@@ -18,6 +20,48 @@ export {
|
|
|
18
20
|
generatedSourceHeader,
|
|
19
21
|
} from './ownership-markers.js';
|
|
20
22
|
export { findProjectRoot, ProjectRootError } from './project-root.js';
|
|
23
|
+
export {
|
|
24
|
+
EVERY_MEMBER,
|
|
25
|
+
parseAccessConfiguration,
|
|
26
|
+
} from './access-configuration.js';
|
|
27
|
+
export type {
|
|
28
|
+
AccessConfiguration,
|
|
29
|
+
AccessRule,
|
|
30
|
+
} from './access-configuration.js';
|
|
31
|
+
export {
|
|
32
|
+
CorpusStructureError,
|
|
33
|
+
EMPTY_CORPUS,
|
|
34
|
+
parseCorpusStructure,
|
|
35
|
+
readCorpusStructure,
|
|
36
|
+
} from './corpus-structure.js';
|
|
37
|
+
export type {
|
|
38
|
+
CorpusDocument,
|
|
39
|
+
CorpusFolder,
|
|
40
|
+
CorpusStructure,
|
|
41
|
+
} from './corpus-structure.js';
|
|
42
|
+
export {
|
|
43
|
+
ADMINS_CLASS,
|
|
44
|
+
chainReaders,
|
|
45
|
+
chainRules,
|
|
46
|
+
classIdentifiers,
|
|
47
|
+
computeAccessModel,
|
|
48
|
+
documentClass,
|
|
49
|
+
folderChain,
|
|
50
|
+
folderClass,
|
|
51
|
+
intersectReaders,
|
|
52
|
+
MEMBERS_CLASS,
|
|
53
|
+
nextPublishedReaders,
|
|
54
|
+
widensReaders,
|
|
55
|
+
} from './access-classes.js';
|
|
56
|
+
export type {
|
|
57
|
+
AccessClass,
|
|
58
|
+
AccessModel,
|
|
59
|
+
ChainReaders,
|
|
60
|
+
PublishedReaders,
|
|
61
|
+
Readers,
|
|
62
|
+
} from './access-classes.js';
|
|
63
|
+
export { accessFindings } from './access-findings.js';
|
|
64
|
+
export type { AccessFinding } from './access-findings.js';
|
|
21
65
|
export {
|
|
22
66
|
loadSiteConfiguration,
|
|
23
67
|
parseSiteConfiguration,
|
|
@@ -32,6 +76,7 @@ export type {
|
|
|
32
76
|
DeploymentVisibility,
|
|
33
77
|
HomeConfiguration,
|
|
34
78
|
NavigationConfiguration,
|
|
79
|
+
SignInConfiguration,
|
|
35
80
|
SiteConfiguration,
|
|
36
81
|
SyncConfigurationDefaults,
|
|
37
82
|
} from './site-configuration.js';
|
package/src/project-layout.ts
CHANGED
|
@@ -15,6 +15,11 @@ export const PROJECT_LAYOUT = {
|
|
|
15
15
|
configurationFile: 'site.config.json',
|
|
16
16
|
/** Deployment target. Wrangler reads it itself, so it cannot be generated. */
|
|
17
17
|
wranglerConfigurationFile: 'wrangler.jsonc',
|
|
18
|
+
/**
|
|
19
|
+
* The scheduled Worker that keeps the directory snapshot of a private
|
|
20
|
+
* deployment (ADR-040), deployed beside the site's own Worker.
|
|
21
|
+
*/
|
|
22
|
+
directoryWranglerConfigurationFile: 'wrangler.directory.jsonc',
|
|
18
23
|
/** Secret-scanning configuration, whose exemptions `validate` checks. */
|
|
19
24
|
gitleaksConfigurationFile: '.gitleaks.toml',
|
|
20
25
|
/** Served verbatim by the Worker. */
|
|
@@ -32,6 +37,11 @@ export const PROJECT_LAYOUT = {
|
|
|
32
37
|
syncReportFile: 'data/latest-sync-report.json',
|
|
33
38
|
/** What each document's opening says about its title (ADR-023). */
|
|
34
39
|
titleReportFile: 'data/title-report.json',
|
|
40
|
+
/**
|
|
41
|
+
* Which access class every built file belongs to (ADR-039). Written by the
|
|
42
|
+
* build outside `dist`, so it is never published, and read by the Worker.
|
|
43
|
+
*/
|
|
44
|
+
accessMapFile: '.ctcdocs/access-map.json',
|
|
35
45
|
} as const;
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -61,15 +71,42 @@ export const PLATFORM_ROUTES = {
|
|
|
61
71
|
contentHealth: 'content-health',
|
|
62
72
|
} as const;
|
|
63
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The platform's Workers, as a project's Wrangler configuration names them
|
|
76
|
+
* (ADR-038, ADR-040), and the alias under which they import the build's
|
|
77
|
+
* access map.
|
|
78
|
+
*/
|
|
79
|
+
export const PLATFORM_WORKERS = {
|
|
80
|
+
gate: 'node_modules/@ctcstack/ctcdocs/worker/index.ts',
|
|
81
|
+
directory: 'node_modules/@ctcstack/ctcdocs/worker/directory/index.ts',
|
|
82
|
+
accessMapAlias: 'ctcdocs-access-map',
|
|
83
|
+
stateBinding: 'KB_STATE',
|
|
84
|
+
directorySchedule: '*/10 * * * *',
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Where the Worker tells a signed-in reader which search bundles, beyond the
|
|
89
|
+
* members bundle, they may open (ADR-039). Answered `{ "bundles": [...] }`.
|
|
90
|
+
*/
|
|
91
|
+
export const ACCESS_CLASSES_ROUTE = '/_kb/classes';
|
|
92
|
+
|
|
64
93
|
/** Each platform route as the href a link uses. */
|
|
65
94
|
export const PLATFORM_ROUTE_HREFS = {
|
|
66
95
|
fullIndex: `/${PLATFORM_ROUTES.fullIndex}/`,
|
|
67
96
|
contentHealth: `/${PLATFORM_ROUTES.contentHealth}/`,
|
|
68
97
|
} as const;
|
|
69
98
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
99
|
+
/**
|
|
100
|
+
* Top-level addresses the platform serves beside the corpus: the Worker's
|
|
101
|
+
* sign-in routes (ADR-038), the search bundles, `/pagefind/` and
|
|
102
|
+
* `/pagefind-<class>/` (ADR-039), and the original files under `/assets/`.
|
|
103
|
+
*/
|
|
104
|
+
const SERVED_ADDRESSES = ['auth', 'pagefind', 'assets'] as const;
|
|
105
|
+
|
|
106
|
+
export const RESERVED_SLUGS: readonly string[] = Object.freeze([
|
|
107
|
+
...Object.values(PLATFORM_ROUTES),
|
|
108
|
+
...SERVED_ADDRESSES,
|
|
109
|
+
]);
|
|
73
110
|
|
|
74
111
|
/**
|
|
75
112
|
* A short ID: lowercase hexadecimal, six characters unless six collided with
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
import { readFileSync } from 'node:fs';
|
|
15
15
|
import { resolve } from 'node:path';
|
|
16
16
|
|
|
17
|
+
import {
|
|
18
|
+
parseAccessConfiguration,
|
|
19
|
+
type AccessConfiguration,
|
|
20
|
+
} from './access-configuration.js';
|
|
17
21
|
import { PROJECT_LAYOUT } from './project-layout.js';
|
|
18
22
|
|
|
19
23
|
export interface BrandConfiguration {
|
|
@@ -152,7 +156,24 @@ export interface NavigationConfiguration {
|
|
|
152
156
|
|
|
153
157
|
export type AddressPolicy = 'stable' | 'follow-names';
|
|
154
158
|
|
|
159
|
+
/**
|
|
160
|
+
* How a private deployment signs readers in (ADR-038): with Google, admitting
|
|
161
|
+
* only accounts whose `hd` is one of these Workspace domains. An organization
|
|
162
|
+
* with secondary domains lists each, since `hd` names the domain of a
|
|
163
|
+
* reader's own primary address.
|
|
164
|
+
*/
|
|
165
|
+
export interface SignInConfiguration {
|
|
166
|
+
readonly workspaceDomains: readonly string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
export interface SiteConfiguration {
|
|
170
|
+
/** Required to serve a private environment through the platform's Worker. */
|
|
171
|
+
readonly signIn?: SignInConfiguration;
|
|
172
|
+
/**
|
|
173
|
+
* Who may read which folder (ADR-039). Absent, every document is open to
|
|
174
|
+
* every reader the deployment admits, as before access rules existed.
|
|
175
|
+
*/
|
|
176
|
+
readonly access?: AccessConfiguration;
|
|
156
177
|
readonly brand: BrandConfiguration;
|
|
157
178
|
readonly deployment: DeploymentConfiguration;
|
|
158
179
|
readonly home: HomeConfiguration;
|
|
@@ -460,13 +481,68 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
|
|
|
460
481
|
|
|
461
482
|
const navigationSource = record(root.navigation, 'navigation');
|
|
462
483
|
const homeSource = record(root.home, 'home');
|
|
484
|
+
const deployment: DeploymentConfiguration = {
|
|
485
|
+
environments: environments(deploymentSource),
|
|
486
|
+
workerName,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/*
|
|
490
|
+
* Rules close folders to some readers, which a public environment cannot
|
|
491
|
+
* do: everyone may read it. Accepting rules there would promise a boundary
|
|
492
|
+
* that does not exist.
|
|
493
|
+
*/
|
|
494
|
+
let access: AccessConfiguration | undefined;
|
|
495
|
+
if (root.access !== undefined) {
|
|
496
|
+
const publicEnvironment = Object.entries(deployment.environments).find(
|
|
497
|
+
([, environment]) => environment.visibility === 'public',
|
|
498
|
+
);
|
|
499
|
+
if (publicEnvironment) {
|
|
500
|
+
fail(
|
|
501
|
+
'access',
|
|
502
|
+
`must not be set while the ${publicEnvironment[0]} environment is public`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
access = parseAccessConfiguration(root.access, fail);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let signIn: SignInConfiguration | undefined;
|
|
509
|
+
if (root.signIn !== undefined) {
|
|
510
|
+
const source = record(root.signIn, 'signIn');
|
|
511
|
+
for (const key of Object.keys(source)) {
|
|
512
|
+
if (key !== 'workspaceDomains') {
|
|
513
|
+
fail(`signIn.${key}`, 'is not a known setting');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const domains = source.workspaceDomains;
|
|
517
|
+
if (!Array.isArray(domains) || domains.length === 0) {
|
|
518
|
+
fail('signIn.workspaceDomains', 'must be a non-empty array of domains');
|
|
519
|
+
}
|
|
520
|
+
const parsed = domains.map((domain: unknown, index: number) => {
|
|
521
|
+
const value =
|
|
522
|
+
typeof domain === 'string' ? domain.trim().toLowerCase() : '';
|
|
523
|
+
if (
|
|
524
|
+
!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u.test(
|
|
525
|
+
value,
|
|
526
|
+
)
|
|
527
|
+
) {
|
|
528
|
+
fail(
|
|
529
|
+
`signIn.workspaceDomains[${index}]`,
|
|
530
|
+
'must be a domain such as "example.com"',
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return value;
|
|
534
|
+
});
|
|
535
|
+
if (new Set(parsed).size !== parsed.length) {
|
|
536
|
+
fail('signIn.workspaceDomains', 'must not repeat a domain');
|
|
537
|
+
}
|
|
538
|
+
signIn = { workspaceDomains: Object.freeze(parsed) };
|
|
539
|
+
}
|
|
463
540
|
|
|
464
541
|
return {
|
|
542
|
+
...(signIn ? { signIn } : {}),
|
|
543
|
+
...(access ? { access } : {}),
|
|
465
544
|
brand,
|
|
466
|
-
deployment
|
|
467
|
-
environments: environments(deploymentSource),
|
|
468
|
-
workerName,
|
|
469
|
-
},
|
|
545
|
+
deployment,
|
|
470
546
|
home: {
|
|
471
547
|
corpusIndex: optionalFlag(
|
|
472
548
|
homeSource,
|