@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.
@@ -0,0 +1,39 @@
1
+ export interface CorpusFolder {
2
+ readonly id: string;
3
+ /** `null` for the Drive root the sync reads. */
4
+ readonly parentId: string | null;
5
+ /** The name in Drive, as typed. */
6
+ readonly name: string;
7
+ /** The name the site shows, without an order number. */
8
+ readonly label: string;
9
+ /** The folder's address, when it has one; the root has none. */
10
+ readonly slug: string | undefined;
11
+ }
12
+ export interface CorpusDocument {
13
+ readonly id: string;
14
+ readonly parentId: string | null;
15
+ readonly slug: string;
16
+ /**
17
+ * The readers the document was last published with (ADR-039). Its class
18
+ * never reaches beyond them: a move that would widen them waits for a rule
19
+ * on its new chain to be confirmed.
20
+ */
21
+ readonly publishedReaders?: '*' | readonly string[];
22
+ }
23
+ export interface CorpusStructure {
24
+ readonly rootFolderId: string | null;
25
+ readonly folders: ReadonlyMap<string, CorpusFolder>;
26
+ readonly documents: ReadonlyMap<string, CorpusDocument>;
27
+ }
28
+ export declare class CorpusStructureError extends Error {
29
+ readonly name = "CorpusStructureError";
30
+ }
31
+ export declare const EMPTY_CORPUS: CorpusStructure;
32
+ /** Reads the folder chain out of a parsed sync manifest. */
33
+ export declare function parseCorpusStructure(manifest: unknown): CorpusStructure;
34
+ /**
35
+ * The corpus of a project, or an empty one before its first sync. A manifest
36
+ * that exists but cannot be read is an error: guessing an empty corpus there
37
+ * would decide access without the folders it is about.
38
+ */
39
+ export declare function readCorpusStructure(projectRoot: string): CorpusStructure;
@@ -0,0 +1,96 @@
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
+ import { PROJECT_LAYOUT } from './project-layout.js';
12
+ export class CorpusStructureError extends Error {
13
+ name = 'CorpusStructureError';
14
+ }
15
+ export const EMPTY_CORPUS = Object.freeze({
16
+ rootFolderId: null,
17
+ folders: new Map(),
18
+ documents: new Map(),
19
+ });
20
+ function invalid(path) {
21
+ throw new CorpusStructureError(`${PROJECT_LAYOUT.manifestFile}: ${path} is not what the sync writes.`);
22
+ }
23
+ function entries(value, path) {
24
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
25
+ invalid(path);
26
+ }
27
+ return Object.entries(value);
28
+ }
29
+ function field(source, key, path, nullable = false) {
30
+ const value = source[key];
31
+ if (nullable && value === null) {
32
+ return null;
33
+ }
34
+ if (typeof value !== 'string' || value.length === 0) {
35
+ invalid(`${path}.${key}`);
36
+ }
37
+ return value;
38
+ }
39
+ /** Reads the folder chain out of a parsed sync manifest. */
40
+ export function parseCorpusStructure(manifest) {
41
+ if (typeof manifest !== 'object' || manifest === null) {
42
+ invalid('the manifest');
43
+ }
44
+ const source = manifest;
45
+ const folders = new Map();
46
+ for (const [id, raw] of entries(source.folders, 'folders')) {
47
+ const path = `folders.${id}`;
48
+ const slug = raw.stableSlug;
49
+ folders.set(id, {
50
+ id,
51
+ parentId: field(raw, 'googleParentId', path, true),
52
+ name: field(raw, 'googleName', path) ?? '',
53
+ label: field(raw, 'displayLabel', path) ?? '',
54
+ slug: typeof slug === 'string' && slug.length > 0 ? slug : undefined,
55
+ });
56
+ }
57
+ const documents = new Map();
58
+ for (const [id, raw] of entries(source.documents, 'documents')) {
59
+ const path = `documents.${id}`;
60
+ const published = raw.publishedReaders;
61
+ documents.set(id, {
62
+ id,
63
+ parentId: field(raw, 'googleParentId', path, true),
64
+ slug: field(raw, 'stableSlug', path) ?? '',
65
+ ...(published === '*' ||
66
+ (Array.isArray(published) &&
67
+ published.every((group) => typeof group === 'string'))
68
+ ? { publishedReaders: published }
69
+ : {}),
70
+ });
71
+ }
72
+ const root = source.rootFolderId;
73
+ return {
74
+ rootFolderId: typeof root === 'string' && root.length > 0 ? root : null,
75
+ folders,
76
+ documents,
77
+ };
78
+ }
79
+ /**
80
+ * The corpus of a project, or an empty one before its first sync. A manifest
81
+ * that exists but cannot be read is an error: guessing an empty corpus there
82
+ * would decide access without the folders it is about.
83
+ */
84
+ export function readCorpusStructure(projectRoot) {
85
+ let text;
86
+ try {
87
+ text = readFileSync(resolve(projectRoot, PROJECT_LAYOUT.manifestFile), 'utf8');
88
+ }
89
+ catch (error) {
90
+ if (error.code === 'ENOENT') {
91
+ return EMPTY_CORPUS;
92
+ }
93
+ throw error;
94
+ }
95
+ return parseCorpusStructure(JSON.parse(text));
96
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,14 @@
1
- export { GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, SHORT_ID_PATTERN, } from './project-layout.js';
1
+ export { ACCESS_CLASSES_ROUTE, GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PLATFORM_WORKERS, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, SHORT_ID_PATTERN, } from './project-layout.js';
2
2
  export { assertGeneratedPathAllowed, isGeneratedPathAllowed, normalizeRepositoryPath, } from './generated-paths.js';
3
3
  export { generatedMarkdownHeader, generatedSourceHeader, } from './ownership-markers.js';
4
4
  export { findProjectRoot, ProjectRootError } from './project-root.js';
5
+ export { EVERY_MEMBER, parseAccessConfiguration, } from './access-configuration.js';
6
+ export type { AccessConfiguration, AccessRule, } from './access-configuration.js';
7
+ export { CorpusStructureError, EMPTY_CORPUS, parseCorpusStructure, readCorpusStructure, } from './corpus-structure.js';
8
+ export type { CorpusDocument, CorpusFolder, CorpusStructure, } from './corpus-structure.js';
9
+ export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccessModel, documentClass, folderChain, folderClass, intersectReaders, MEMBERS_CLASS, nextPublishedReaders, widensReaders, } from './access-classes.js';
10
+ export type { AccessClass, AccessModel, ChainReaders, PublishedReaders, Readers, } from './access-classes.js';
11
+ export { accessFindings } from './access-findings.js';
12
+ export type { AccessFinding } from './access-findings.js';
5
13
  export { loadSiteConfiguration, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
6
- export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, NavigationConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
14
+ export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
- export { GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, SHORT_ID_PATTERN, } from './project-layout.js';
1
+ export { ACCESS_CLASSES_ROUTE, GENERATED_DIRECTORY_ALLOWLIST, GENERATED_FILE_ALLOWLIST, PLATFORM_ROUTE_HREFS, PLATFORM_ROUTES, PLATFORM_WORKERS, permanentLinkPath, PROJECT_LAYOUT, RESERVED_SLUGS, SHORT_ID_PATTERN, } from './project-layout.js';
2
2
  export { assertGeneratedPathAllowed, isGeneratedPathAllowed, normalizeRepositoryPath, } from './generated-paths.js';
3
3
  export { generatedMarkdownHeader, generatedSourceHeader, } from './ownership-markers.js';
4
4
  export { findProjectRoot, ProjectRootError } from './project-root.js';
5
+ export { EVERY_MEMBER, parseAccessConfiguration, } from './access-configuration.js';
6
+ export { CorpusStructureError, EMPTY_CORPUS, parseCorpusStructure, readCorpusStructure, } from './corpus-structure.js';
7
+ export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccessModel, documentClass, folderChain, folderClass, intersectReaders, MEMBERS_CLASS, nextPublishedReaders, widensReaders, } from './access-classes.js';
8
+ export { accessFindings } from './access-findings.js';
5
9
  export { loadSiteConfiguration, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
@@ -15,6 +15,11 @@ export declare const PROJECT_LAYOUT: {
15
15
  readonly configurationFile: "site.config.json";
16
16
  /** Deployment target. Wrangler reads it itself, so it cannot be generated. */
17
17
  readonly 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
+ readonly directoryWranglerConfigurationFile: "wrangler.directory.jsonc";
18
23
  /** Secret-scanning configuration, whose exemptions `validate` checks. */
19
24
  readonly gitleaksConfigurationFile: ".gitleaks.toml";
20
25
  /** Served verbatim by the Worker. */
@@ -32,6 +37,11 @@ export declare const PROJECT_LAYOUT: {
32
37
  readonly syncReportFile: "data/latest-sync-report.json";
33
38
  /** What each document's opening says about its title (ADR-023). */
34
39
  readonly 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
+ readonly accessMapFile: ".ctcdocs/access-map.json";
35
45
  };
36
46
  /**
37
47
  * Addresses the platform serves itself.
@@ -59,6 +69,23 @@ export declare const PLATFORM_ROUTES: {
59
69
  */
60
70
  readonly contentHealth: "content-health";
61
71
  };
72
+ /**
73
+ * The platform's Workers, as a project's Wrangler configuration names them
74
+ * (ADR-038, ADR-040), and the alias under which they import the build's
75
+ * access map.
76
+ */
77
+ export declare const PLATFORM_WORKERS: {
78
+ readonly gate: "node_modules/@ctcstack/ctcdocs/worker/index.ts";
79
+ readonly directory: "node_modules/@ctcstack/ctcdocs/worker/directory/index.ts";
80
+ readonly accessMapAlias: "ctcdocs-access-map";
81
+ readonly stateBinding: "KB_STATE";
82
+ readonly directorySchedule: "*/10 * * * *";
83
+ };
84
+ /**
85
+ * Where the Worker tells a signed-in reader which search bundles, beyond the
86
+ * members bundle, they may open (ADR-039). Answered `{ "bundles": [...] }`.
87
+ */
88
+ export declare const ACCESS_CLASSES_ROUTE = "/_kb/classes";
62
89
  /** Each platform route as the href a link uses. */
63
90
  export declare const PLATFORM_ROUTE_HREFS: {
64
91
  readonly fullIndex: "/documents/";
@@ -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
  };
36
46
  /**
37
47
  * Addresses the platform serves itself.
@@ -59,12 +69,38 @@ export const PLATFORM_ROUTES = {
59
69
  */
60
70
  contentHealth: 'content-health',
61
71
  };
72
+ /**
73
+ * The platform's Workers, as a project's Wrangler configuration names them
74
+ * (ADR-038, ADR-040), and the alias under which they import the build's
75
+ * access map.
76
+ */
77
+ export const PLATFORM_WORKERS = {
78
+ gate: 'node_modules/@ctcstack/ctcdocs/worker/index.ts',
79
+ directory: 'node_modules/@ctcstack/ctcdocs/worker/directory/index.ts',
80
+ accessMapAlias: 'ctcdocs-access-map',
81
+ stateBinding: 'KB_STATE',
82
+ directorySchedule: '*/10 * * * *',
83
+ };
84
+ /**
85
+ * Where the Worker tells a signed-in reader which search bundles, beyond the
86
+ * members bundle, they may open (ADR-039). Answered `{ "bundles": [...] }`.
87
+ */
88
+ export const ACCESS_CLASSES_ROUTE = '/_kb/classes';
62
89
  /** Each platform route as the href a link uses. */
63
90
  export const PLATFORM_ROUTE_HREFS = {
64
91
  fullIndex: `/${PLATFORM_ROUTES.fullIndex}/`,
65
92
  contentHealth: `/${PLATFORM_ROUTES.contentHealth}/`,
66
93
  };
67
- export const RESERVED_SLUGS = Object.freeze(Object.values(PLATFORM_ROUTES));
94
+ /**
95
+ * Top-level addresses the platform serves beside the corpus: the Worker's
96
+ * sign-in routes (ADR-038), the search bundles, `/pagefind/` and
97
+ * `/pagefind-<class>/` (ADR-039), and the original files under `/assets/`.
98
+ */
99
+ const SERVED_ADDRESSES = ['auth', 'pagefind', 'assets'];
100
+ export const RESERVED_SLUGS = Object.freeze([
101
+ ...Object.values(PLATFORM_ROUTES),
102
+ ...SERVED_ADDRESSES,
103
+ ]);
68
104
  /**
69
105
  * A short ID: lowercase hexadecimal, six characters unless six collided with
70
106
  * another item's, in which case it is longer. It is recorded once per item and
@@ -1,3 +1,4 @@
1
+ import { type AccessConfiguration } from './access-configuration.js';
1
2
  export interface BrandConfiguration {
2
3
  /** The organization or product the documentation belongs to. */
3
4
  readonly name: string;
@@ -125,7 +126,23 @@ export interface NavigationConfiguration {
125
126
  readonly addresses: AddressPolicy;
126
127
  }
127
128
  export type AddressPolicy = 'stable' | 'follow-names';
129
+ /**
130
+ * How a private deployment signs readers in (ADR-038): with Google, admitting
131
+ * only accounts whose `hd` is one of these Workspace domains. An organization
132
+ * with secondary domains lists each, since `hd` names the domain of a
133
+ * reader's own primary address.
134
+ */
135
+ export interface SignInConfiguration {
136
+ readonly workspaceDomains: readonly string[];
137
+ }
128
138
  export interface SiteConfiguration {
139
+ /** Required to serve a private environment through the platform's Worker. */
140
+ readonly signIn?: SignInConfiguration;
141
+ /**
142
+ * Who may read which folder (ADR-039). Absent, every document is open to
143
+ * every reader the deployment admits, as before access rules existed.
144
+ */
145
+ readonly access?: AccessConfiguration;
129
146
  readonly brand: BrandConfiguration;
130
147
  readonly deployment: DeploymentConfiguration;
131
148
  readonly home: HomeConfiguration;
@@ -13,6 +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
17
  import { PROJECT_LAYOUT } from './project-layout.js';
17
18
  export class SiteConfigurationError extends Error {
18
19
  name = 'SiteConfigurationError';
@@ -227,12 +228,52 @@ export function parseSiteConfiguration(input) {
227
228
  }
228
229
  const navigationSource = record(root.navigation, 'navigation');
229
230
  const homeSource = record(root.home, 'home');
231
+ const deployment = {
232
+ environments: environments(deploymentSource),
233
+ workerName,
234
+ };
235
+ /*
236
+ * Rules close folders to some readers, which a public environment cannot
237
+ * do: everyone may read it. Accepting rules there would promise a boundary
238
+ * that does not exist.
239
+ */
240
+ let access;
241
+ if (root.access !== undefined) {
242
+ const publicEnvironment = Object.entries(deployment.environments).find(([, environment]) => environment.visibility === 'public');
243
+ if (publicEnvironment) {
244
+ fail('access', `must not be set while the ${publicEnvironment[0]} environment is public`);
245
+ }
246
+ access = parseAccessConfiguration(root.access, fail);
247
+ }
248
+ let signIn;
249
+ if (root.signIn !== undefined) {
250
+ const source = record(root.signIn, 'signIn');
251
+ for (const key of Object.keys(source)) {
252
+ if (key !== 'workspaceDomains') {
253
+ fail(`signIn.${key}`, 'is not a known setting');
254
+ }
255
+ }
256
+ const domains = source.workspaceDomains;
257
+ if (!Array.isArray(domains) || domains.length === 0) {
258
+ fail('signIn.workspaceDomains', 'must be a non-empty array of domains');
259
+ }
260
+ const parsed = domains.map((domain, index) => {
261
+ const value = typeof domain === 'string' ? domain.trim().toLowerCase() : '';
262
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u.test(value)) {
263
+ fail(`signIn.workspaceDomains[${index}]`, 'must be a domain such as "example.com"');
264
+ }
265
+ return value;
266
+ });
267
+ if (new Set(parsed).size !== parsed.length) {
268
+ fail('signIn.workspaceDomains', 'must not repeat a domain');
269
+ }
270
+ signIn = { workspaceDomains: Object.freeze(parsed) };
271
+ }
230
272
  return {
273
+ ...(signIn ? { signIn } : {}),
274
+ ...(access ? { access } : {}),
231
275
  brand,
232
- deployment: {
233
- environments: environments(deploymentSource),
234
- workerName,
235
- },
276
+ deployment,
236
277
  home: {
237
278
  corpusIndex: optionalFlag(homeSource, 'corpusIndex', 'home.corpusIndex', true),
238
279
  lede: text(homeSource, 'lede', 'home.lede'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctcstack/ctcdocs-core",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Configuration schema, project layout, and generated-path allowlist shared by the CTCDocs platform packages.",
5
5
  "keywords": [
6
6
  "ctcdocs",