@ctcstack/ctcdocs-core 0.15.0 → 0.16.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.
@@ -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';
@@ -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
- export const RESERVED_SLUGS: readonly string[] = Object.freeze(
71
- Object.values(PLATFORM_ROUTES),
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,