@hearthlog/tooling 0.0.0-stage → 0.1.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.
Files changed (86) hide show
  1. package/LICENSE +235 -0
  2. package/bin/hearthlog.mjs +61 -0
  3. package/package.json +64 -3
  4. package/src/apply.ts +73 -0
  5. package/src/check/check-layers.ts +118 -0
  6. package/src/check/check-modules.ts +198 -0
  7. package/src/check/check-sdk-exports.ts +79 -0
  8. package/src/check/check-tenant-config.ts +208 -0
  9. package/src/cli/hearthlog.ts +212 -0
  10. package/src/cli/list-sites.ts +15 -0
  11. package/src/compose/compose-modules.ts +490 -0
  12. package/src/compose/module-scaffold.ts +195 -0
  13. package/src/deploy/artifact-compare.ts +93 -0
  14. package/src/deploy/artifact.ts +127 -0
  15. package/src/deploy/async-utils.ts +71 -0
  16. package/src/deploy/backup-tenants.ts +139 -0
  17. package/src/deploy/cloudflare-access.ts +789 -0
  18. package/src/deploy/copy-tenant.ts +395 -0
  19. package/src/deploy/dedicated-ledger.ts +77 -0
  20. package/src/deploy/deploy-ledger.ts +162 -0
  21. package/src/deploy/deploy-runner.ts +240 -0
  22. package/src/deploy/deploy-tenant.ts +170 -0
  23. package/src/deploy/deployment-security.ts +141 -0
  24. package/src/deploy/ensure-tenant.ts +988 -0
  25. package/src/deploy/event-policy.ts +23 -0
  26. package/src/deploy/frontend-dry-run.ts +18 -0
  27. package/src/deploy/ledger-credential.ts +48 -0
  28. package/src/deploy/ledger-scripts.ts +13 -0
  29. package/src/deploy/split-tenant.ts +295 -0
  30. package/src/dev/local-development.ts +288 -0
  31. package/src/dev/provision-local.ts +13 -0
  32. package/src/generate/build-generated.ts +166 -0
  33. package/src/generate/generate-brand-icons.ts +128 -0
  34. package/src/generate/generate-entities.ts +627 -0
  35. package/src/generate/generate-tenant.ts +161 -0
  36. package/src/host/core-routes.ts +39 -0
  37. package/src/host/write-host.ts +119 -0
  38. package/src/index.ts +8 -0
  39. package/src/init/check-module.ts +143 -0
  40. package/src/init/export-tenant.ts +124 -0
  41. package/src/init/init.ts +168 -0
  42. package/src/move.ts +189 -0
  43. package/src/plan.ts +160 -0
  44. package/src/public-workers/build-gallery-public.ts +114 -0
  45. package/src/public-workers/calendar-public-config.ts +27 -0
  46. package/src/public-workers/check-calendar-public-route.ts +41 -0
  47. package/src/public-workers/check-gallery-public-route.ts +43 -0
  48. package/src/public-workers/gallery-public-config.ts +22 -0
  49. package/src/receipt.ts +74 -0
  50. package/src/root.ts +16 -0
  51. package/src/security/audit-push.ts +141 -0
  52. package/src/security/forbidden-identities.ts +107 -0
  53. package/src/security/forbidden-init.ts +88 -0
  54. package/src/tenants/host-configs.ts +96 -0
  55. package/src/tenants/ledger-config.ts +57 -0
  56. package/src/tenants/lock-file.ts +45 -0
  57. package/src/tenants/module-lock.ts +58 -0
  58. package/src/tenants/tenant-config.ts +887 -0
  59. package/src/tenants/tenant-copy-plan.ts +119 -0
  60. package/src/tenants/tenant-schema.ts +171 -0
  61. package/src/tenants/tenant-wrangler.ts +37 -0
  62. package/src/tenants/wrangler-base.ts +50 -0
  63. package/src/tenants/wrangler-config.ts +266 -0
  64. package/src/vite/config.ts +310 -0
  65. package/src/vite/dev-plugins.ts +338 -0
  66. package/src/vite/vite-plugin-entity-gen.ts +70 -0
  67. package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
  68. package/src/vite/vite-plugin-worker-assets.ts +42 -0
  69. package/templates/host/app.d.ts.txt +31 -0
  70. package/templates/host/microlighter.d.ts.txt +11 -0
  71. package/templates/module/ci.yml.tmpl +44 -0
  72. package/templates/module/gitignore.tmpl +7 -0
  73. package/templates/module/gitleaks.toml.tmpl +11 -0
  74. package/templates/module/release.yml.tmpl +53 -0
  75. package/templates/tenant/README.md.tmpl +13 -0
  76. package/templates/tenant/generated-package.json.tmpl +16 -0
  77. package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
  78. package/templates/tenant/gitignore.tmpl +11 -0
  79. package/templates/tenant/package.json.tmpl +11 -0
  80. package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
  81. package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
  82. package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
  83. package/templates/tenant/tenants.json.tmpl +17 -0
  84. package/templates/tenant/tsconfig.json.tmpl +21 -0
  85. package/templates/tenant/vite.config.ts.tmpl +5 -0
  86. package/README.md +0 -3
@@ -0,0 +1,41 @@
1
+ /** Verify that the anonymous calendar feed hostname actually reaches its public Worker. */
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadTenantConfig } from '../tenants/tenant-config.js';
5
+
6
+ type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
7
+
8
+ const MISSING_FEED = `/feed/${'A'.repeat(43)}.ics`;
9
+ const ROBOTS = 'User-agent: *\nDisallow: /\n';
10
+ const NOINDEX = 'noindex, nofollow, noarchive';
11
+
12
+ export async function checkCalendarPublicRoute(origin: string, fetcher: Fetcher = fetch): Promise<void> {
13
+ const options: RequestInit = { redirect: 'manual', signal: AbortSignal.timeout(10_000) };
14
+ const missing = await fetcher(`${origin}${MISSING_FEED}`, options);
15
+ const missingBody = await missing.text();
16
+ if (missing.status !== 404 || missingBody !== 'Not found' || missing.headers.get('X-Robots-Tag') !== NOINDEX) {
17
+ const body = missingBody.includes('There is nothing here yet') ? 'Cloudflare placeholder'
18
+ : missingBody.includes('error code: 1042') ? 'Cloudflare error 1042'
19
+ : missingBody.startsWith('<!DOCTYPE html>') ? 'unexpected HTML'
20
+ : JSON.stringify(missingBody.slice(0, 80));
21
+ throw new Error(`Public calendar route did not reach the Worker at ${origin}/feed/… (HTTP ${missing.status}, ${body}). Check the Worker deployment, workers.dev route, and Access rules.`);
22
+ }
23
+ const robots = await fetcher(`${origin}/robots.txt`, options);
24
+ if (robots.status !== 200 || await robots.text() !== ROBOTS || robots.headers.get('X-Robots-Tag') !== NOINDEX) {
25
+ throw new Error(`Public calendar Worker is not serving ${origin}/robots.txt (HTTP ${robots.status}). Check the Worker deployment and route.`);
26
+ }
27
+ }
28
+
29
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
30
+ const { infrastructure } = await loadTenantConfig();
31
+ for (let attempt = 0; attempt < 6; attempt += 1) {
32
+ try {
33
+ await checkCalendarPublicRoute(infrastructure.calendarPublicOrigin);
34
+ break;
35
+ } catch (cause) {
36
+ if (attempt === 5) throw cause;
37
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
38
+ }
39
+ }
40
+ console.log(`Public calendar Worker is serving anonymously at ${infrastructure.calendarPublicOrigin}`);
41
+ }
@@ -0,0 +1,43 @@
1
+ /** Verify that the anonymous Gallery hostname actually reaches its public Worker. */
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadTenantConfig } from '../tenants/tenant-config.js';
5
+
6
+ type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
7
+
8
+ const MISSING_GALLERY = '/g/00000000-0000-4000-8000-000000000000';
9
+ const ROBOTS = 'User-agent: *\nDisallow: /\n';
10
+
11
+ export async function checkGalleryPublicRoute(origin: string, fetcher: Fetcher = fetch): Promise<void> {
12
+ const options: RequestInit = { redirect: 'manual', signal: AbortSignal.timeout(10_000) };
13
+ const missing = await fetcher(`${origin}${MISSING_GALLERY}`, options);
14
+ const missingBody = await missing.text();
15
+ if (missing.status !== 404 || missingBody !== 'Not found'
16
+ || missing.headers.get('X-Robots-Tag') !== 'noindex, nofollow, noarchive') {
17
+ const body = missingBody.includes('There is nothing here yet') ? 'Cloudflare placeholder'
18
+ : missingBody.includes('error code: 1042') ? 'Cloudflare error 1042'
19
+ : missingBody.startsWith('<!DOCTYPE html>') ? 'unexpected HTML'
20
+ : JSON.stringify(missingBody.slice(0, 80));
21
+ throw new Error(`Public Gallery route did not reach the Worker at ${origin}${MISSING_GALLERY} (HTTP ${missing.status}, ${body}). Check the Worker deployment, workers.dev route, and Access rules.`);
22
+ }
23
+
24
+ const robots = await fetcher(`${origin}/robots.txt`, options);
25
+ if (robots.status !== 200 || await robots.text() !== ROBOTS
26
+ || robots.headers.get('X-Robots-Tag') !== 'noindex, nofollow, noarchive') {
27
+ throw new Error(`Public Gallery Worker is not serving ${origin}/robots.txt (HTTP ${robots.status}). Check the Worker deployment and route.`);
28
+ }
29
+ }
30
+
31
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
32
+ const { infrastructure } = await loadTenantConfig();
33
+ for (let attempt = 0; attempt < 6; attempt += 1) {
34
+ try {
35
+ await checkGalleryPublicRoute(infrastructure.galleryPublicOrigin);
36
+ break;
37
+ } catch (cause) {
38
+ if (attempt === 5) throw cause;
39
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
40
+ }
41
+ }
42
+ console.log(`Public Gallery Worker is serving anonymously at ${infrastructure.galleryPublicOrigin}`);
43
+ }
@@ -0,0 +1,22 @@
1
+ /** The public Gallery Worker's config (generated by `host-configs.ts`). */
2
+ import type { InfrastructureDefinition } from '../tenants/tenant-config.js';
3
+ import { WRANGLER_BASE } from '../tenants/wrangler-base.js';
4
+
5
+ /**
6
+ * The public Gallery Worker: the public bucket and the canonical origin. `main` is the bundle that
7
+ * `hearthlog gallery-public:build` writes beside the config: Svelte renders the page on the server.
8
+ */
9
+ export function buildGalleryPublicConfig(infrastructure: InfrastructureDefinition) {
10
+ return {
11
+ name: infrastructure.galleryPublicWorker,
12
+ main: './dist/worker/index.js',
13
+ compatibility_date: WRANGLER_BASE.compatibility_date,
14
+ // Svelte's server renderer keeps per-render context in AsyncLocalStorage.
15
+ compatibility_flags: ['nodejs_als'],
16
+ workers_dev: true,
17
+ preview_urls: false,
18
+ observability: { enabled: true },
19
+ vars: { PUBLIC_ORIGIN: infrastructure.galleryPublicOrigin },
20
+ r2_buckets: [{ binding: 'GALLERY_PUBLIC', bucket_name: infrastructure.galleryPublicBucket }],
21
+ };
22
+ }
package/src/receipt.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A deployment receipt: which steps of `hearthlog apply` finished and which
3
+ * resources they concern. It lives in the operator's protected state and
4
+ * holds no secret, so it can be read, copied, and attached to a support
5
+ * request. A resumed apply trusts it to skip finished steps, which is how it
6
+ * avoids minting a second set of credentials.
7
+ */
8
+
9
+ import type { DeploymentPlan, StepId } from './plan.js';
10
+
11
+ export interface Receipt {
12
+ schemaVersion: 1;
13
+ siteId: string;
14
+ origin: string;
15
+ /** `kind:name` of every planned resource; a changed plan is not this receipt's deployment. */
16
+ resources: string[];
17
+ modules: Array<{ id: string; version: string }>;
18
+ steps: Partial<Record<StepId, { completedAt: string }>>;
19
+ }
20
+
21
+ const SECRET_KEY = /token|secret|password|credential|private|signing/i;
22
+
23
+ export function newReceipt(plan: DeploymentPlan): Receipt {
24
+ return {
25
+ schemaVersion: 1,
26
+ siteId: plan.siteId,
27
+ origin: plan.origin,
28
+ resources: plan.resources.map((resource) => `${resource.kind}:${resource.name}`).sort(),
29
+ modules: plan.modules.map(({ id, version }) => ({ id, version })),
30
+ steps: {},
31
+ };
32
+ }
33
+
34
+ export function stepDone(receipt: Receipt, step: StepId): boolean {
35
+ return receipt.steps[step] !== undefined;
36
+ }
37
+
38
+ export function recordStep(receipt: Receipt, step: StepId, at: Date): Receipt {
39
+ return { ...receipt, steps: { ...receipt.steps, [step]: { completedAt: at.toISOString() } } };
40
+ }
41
+
42
+ /** Why a saved receipt cannot be resumed against this plan; empty when it can. */
43
+ export function receiptProblems(receipt: Receipt, plan: DeploymentPlan): string[] {
44
+ const problems: string[] = [];
45
+ if (receipt.schemaVersion !== 1) problems.push(`the receipt has schema version ${String(receipt.schemaVersion)}, not 1`);
46
+ if (receipt.siteId !== plan.siteId) problems.push(`the receipt is for site "${receipt.siteId}", not "${plan.siteId}"`);
47
+ const current = newReceipt(plan);
48
+ if (receipt.origin !== current.origin) problems.push(`the receipt is for ${receipt.origin}, the plan for ${current.origin}`);
49
+ if (JSON.stringify(receipt.resources) !== JSON.stringify(current.resources)) {
50
+ problems.push('the planned resources differ from those the receipt recorded; remove the receipt only if you mean to start over');
51
+ }
52
+ return problems;
53
+ }
54
+
55
+ /** Serializes a receipt, refusing anything shaped like a secret. */
56
+ export function serializeReceipt(receipt: Receipt): string {
57
+ const walk = (value: unknown, path: string): void => {
58
+ if (value === null || typeof value !== 'object') return;
59
+ for (const [key, inner] of Object.entries(value)) {
60
+ if (SECRET_KEY.test(key)) throw new Error(`A receipt must hold no secrets (found "${path}${key}")`);
61
+ walk(inner, `${path}${key}.`);
62
+ }
63
+ };
64
+ walk(receipt, '');
65
+ return `${JSON.stringify(receipt, null, 2)}\n`;
66
+ }
67
+
68
+ export function parseReceipt(text: string): Receipt {
69
+ const value = JSON.parse(text) as Receipt;
70
+ if (value === null || typeof value !== 'object' || value.schemaVersion !== 1 || typeof value.siteId !== 'string') {
71
+ throw new Error('Not a deployment receipt');
72
+ }
73
+ return value;
74
+ }
package/src/root.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ /**
5
+ * The repository a command works on: the nearest directory, from the working directory upward, that
6
+ * holds `tenants.json`. Commands act on the host they are run in, not on where this package is installed.
7
+ */
8
+ export function hostRoot(start: string = process.cwd()): string {
9
+ let directory = resolve(start);
10
+ for (;;) {
11
+ if (existsSync(join(directory, "tenants.json")) || existsSync(join(directory, "tenants"))) return directory;
12
+ const parent = dirname(directory);
13
+ if (parent === directory) return resolve(start);
14
+ directory = parent;
15
+ }
16
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * The gate before anything is pushed to a public repository (plan, section 4, rule 7).
4
+ *
5
+ * Run it on the exact directory you are about to push: a snapshot with its first commit made, or
6
+ * a module or platform clone. It reads the forbidden-identities file
7
+ * (`packages/tooling/src/security/forbidden-identities.ts`) and fails when
8
+ *
9
+ * - any file name, file content, or binary file contains a forbidden value;
10
+ * - the directory has git history and any commit's author or committer is not one of the
11
+ * allowed author addresses (GitHub's own `noreply` addresses are accepted);
12
+ * - any commit message, tag, or branch name contains a forbidden value, or any commit's diff does;
13
+ * - the repository's own git identity (`user.email`) is not an allowed address.
14
+ *
15
+ * Nothing it prints is a value from the list: findings name an entry by number.
16
+ *
17
+ * Usage: pnpm audit:push -- <directory> [--json] (exit 1 unless the directory is clean)
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
22
+ import { join, relative, resolve } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { forbiddenIn, forbiddenInBytes, forbiddenLabel, loadForbiddenList, maskForbidden, type ForbiddenList } from "./forbidden-identities.js";
26
+
27
+ export interface PushFinding {
28
+ where: string;
29
+ kind: string;
30
+ detail: string;
31
+ }
32
+
33
+ const SAFE_NOREPLY = /^(?:noreply@github\.com|[0-9]*\+?[a-z0-9-]+@users\.noreply\.github\.com)$/i;
34
+
35
+ /** Every file under the directory except `.git`, as relative paths. */
36
+ export function listFiles(directory: string): string[] {
37
+ // In a repository, what could be pushed is what git tracks or would add: an ignored `node_modules` is not part of it.
38
+ if (existsSync(join(directory, ".git"))) {
39
+ const listed = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: directory, maxBuffer: 256 * 1024 * 1024 });
40
+ return [...new Set(listed.toString("utf8").split("\0").filter(Boolean))].filter((file) => existsSync(join(directory, file)));
41
+ }
42
+ const files: string[] = [];
43
+ const walk = (current: string) => {
44
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
45
+ if (entry.name === ".git") continue;
46
+ const path = join(current, entry.name);
47
+ if (entry.isDirectory()) walk(path);
48
+ else if (entry.isFile()) files.push(relative(directory, path));
49
+ }
50
+ };
51
+ walk(directory);
52
+ return files;
53
+ }
54
+
55
+ export function auditFiles(directory: string, list: ForbiddenList): PushFinding[] {
56
+ const findings: PushFinding[] = [];
57
+ for (const file of listFiles(directory)) {
58
+ const shown = maskForbidden(file, list.forbidden);
59
+ for (const entry of forbiddenIn(file, list.forbidden)) findings.push({ where: shown, kind: "file-name", detail: forbiddenLabel(entry) });
60
+ const path = join(directory, file);
61
+ if (lstatSync(path).size > 64 * 1024 * 1024) {
62
+ findings.push({ where: shown, kind: "too-large-to-scan", detail: "larger than 64 MiB" });
63
+ continue;
64
+ }
65
+ for (const entry of forbiddenInBytes(readFileSync(path), list.forbidden)) findings.push({ where: shown, kind: "content", detail: forbiddenLabel(entry) });
66
+ }
67
+ return findings;
68
+ }
69
+
70
+ function git(directory: string, args: string[]): string {
71
+ return execFileSync("git", ["-C", directory, ...args], { encoding: "utf8", maxBuffer: 1024 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
72
+ }
73
+
74
+ /** Authors, committers, messages, refs, diffs, and the repository's own identity. */
75
+ export function auditHistory(directory: string, list: ForbiddenList): PushFinding[] {
76
+ const findings: PushFinding[] = [];
77
+ if (!existsSync(join(directory, ".git"))) return findings;
78
+ const allowed = new Set(list.allowedAuthorEmails);
79
+ const isAllowed = (email: string) => allowed.has(email.toLowerCase()) || SAFE_NOREPLY.test(email);
80
+
81
+ let log = "";
82
+ try {
83
+ log = git(directory, ["log", "--all", "--format=%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%B%x1e"]);
84
+ } catch {
85
+ // A repository without commits has nothing to check yet.
86
+ }
87
+ for (const record of log.split("\x1e").map((entry) => entry.trim()).filter(Boolean)) {
88
+ const [commit = "", authorName = "", authorEmail = "", committerName = "", committerEmail = "", message = ""] = record.split("\x1f");
89
+ const where = `commit ${commit.slice(0, 10)}`;
90
+ if (!isAllowed(authorEmail)) findings.push({ where, kind: "author-not-allowed", detail: "author address is not in allowedAuthorEmails" });
91
+ if (!isAllowed(committerEmail)) findings.push({ where, kind: "committer-not-allowed", detail: "committer address is not in allowedAuthorEmails" });
92
+ for (const entry of forbiddenIn([authorName, authorEmail, committerName, committerEmail, message].join("\n"), list.forbidden)) {
93
+ findings.push({ where, kind: "commit-metadata", detail: forbiddenLabel(entry) });
94
+ }
95
+ }
96
+ const refs = git(directory, ["for-each-ref", "--format=%(refname)%09%(taggername)%09%(taggeremail)%09%(contents)"]);
97
+ for (const entry of forbiddenIn(refs, list.forbidden)) findings.push({ where: "refs and tags", kind: "ref-metadata", detail: forbiddenLabel(entry) });
98
+ let patches = "";
99
+ try {
100
+ patches = git(directory, ["log", "--all", "-p", "--format=", "--no-color"]);
101
+ } catch {
102
+ // No commits.
103
+ }
104
+ for (const entry of forbiddenIn(patches, list.forbidden)) findings.push({ where: "history diffs", kind: "history-content", detail: forbiddenLabel(entry) });
105
+
106
+ let identity = "";
107
+ try {
108
+ identity = git(directory, ["config", "user.email"]).trim();
109
+ } catch {
110
+ // None configured: the commit would fail, which is the safe outcome.
111
+ }
112
+ if (identity && !isAllowed(identity)) findings.push({ where: "git config", kind: "identity-not-allowed", detail: "user.email in this repository is not in allowedAuthorEmails" });
113
+ return findings;
114
+ }
115
+
116
+ export function auditPush(directory: string, list: ForbiddenList = loadForbiddenList()): PushFinding[] {
117
+ if (list.allowedAuthorEmails.length === 0) {
118
+ return [{ where: list.path, kind: "no-allowed-authors", detail: '"allowedAuthorEmails" must list the address commits may carry' }];
119
+ }
120
+ return [...auditFiles(directory, list), ...auditHistory(directory, list)];
121
+ }
122
+
123
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
124
+ const args = process.argv.slice(2).filter((argument) => argument !== "--");
125
+ const directory = args.find((argument) => !argument.startsWith("--"));
126
+ if (!directory || !existsSync(directory)) {
127
+ console.error("Usage: pnpm audit:push -- <directory> [--json]");
128
+ process.exit(1);
129
+ }
130
+ const root = resolve(directory);
131
+ const findings = auditPush(root);
132
+ const history = existsSync(join(root, ".git")) ? "with git history" : "no git history yet (make the first commit under the personal identity, then run this again)";
133
+ if (args.includes("--json")) {
134
+ process.stdout.write(`${JSON.stringify({ directory: root, findings }, null, 2)}\n`);
135
+ } else {
136
+ for (const finding of findings.slice(0, 40)) console.log(`${finding.where}: ${finding.kind}: ${finding.detail}`);
137
+ if (findings.length > 40) console.log(`… ${findings.length - 40} more`);
138
+ console.log(`\n${findings.length} finding(s) in ${root}, ${history}.`);
139
+ }
140
+ process.exit(findings.length ? 1 : 0);
141
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The identities that must never reach a public repository (plan, section 4).
3
+ *
4
+ * They live in a file in the operator's home directory, never in the repository: a committed
5
+ * list of forbidden values would itself leak them. The audit and the snapshot refuse to run
6
+ * without it, and nothing here, in an error, or in a report prints a value from it; findings name
7
+ * the entry by number.
8
+ *
9
+ * {
10
+ * "forbidden": ["strings, matched case-insensitively anywhere: addresses, domains, company names"],
11
+ * "allowedAuthorEmails": ["the only address commits may carry"]
12
+ * }
13
+ *
14
+ * The location is `$HEARTHLOG_FORBIDDEN_FILE`, else `$XDG_CONFIG_HOME/hearthlog/forbidden.json`,
15
+ * else `~/.config/hearthlog/forbidden.json`.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ export interface ForbiddenList {
23
+ path: string;
24
+ /** Lower-cased, distinct, each at least `MIN_LENGTH` characters. */
25
+ forbidden: string[];
26
+ /** Commit authors and committers the push gate accepts. */
27
+ allowedAuthorEmails: string[];
28
+ }
29
+
30
+ export class ForbiddenListError extends Error {}
31
+
32
+ const MIN_LENGTH = 4;
33
+ const MAX_LENGTH = 200;
34
+
35
+ export function forbiddenListPath(env: NodeJS.ProcessEnv = process.env): string {
36
+ if (env.HEARTHLOG_FORBIDDEN_FILE) return env.HEARTHLOG_FORBIDDEN_FILE;
37
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "hearthlog", "forbidden.json");
38
+ }
39
+
40
+ /** Validates the file's content. Errors say what is wrong, never what the values are. */
41
+ export function parseForbiddenList(text: string, path: string): ForbiddenList {
42
+ let value: unknown;
43
+ try {
44
+ value = JSON.parse(text);
45
+ } catch {
46
+ throw new ForbiddenListError(`${path} is not valid JSON.`);
47
+ }
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ForbiddenListError(`${path} must be a JSON object.`);
49
+ const record = value as Record<string, unknown>;
50
+ const strings = (key: string, minimum: number): string[] => {
51
+ const entries = record[key];
52
+ if (entries === undefined) return [];
53
+ if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) throw new ForbiddenListError(`"${key}" in ${path} must be a list of strings.`);
54
+ const trimmed = (entries as string[]).map((entry) => entry.trim());
55
+ const index = trimmed.findIndex((entry) => entry.length < minimum || entry.length > MAX_LENGTH);
56
+ if (index !== -1) throw new ForbiddenListError(`"${key}" entry #${index + 1} in ${path} must be ${minimum} to ${MAX_LENGTH} characters.`);
57
+ return [...new Set(trimmed.map((entry) => entry.toLowerCase()))];
58
+ };
59
+ const forbidden = strings("forbidden", MIN_LENGTH);
60
+ if (forbidden.length === 0) throw new ForbiddenListError(`"forbidden" in ${path} must list at least one string.`);
61
+ const allowedAuthorEmails = strings("allowedAuthorEmails", 3);
62
+ const bad = allowedAuthorEmails.findIndex((email) => !email.includes("@"));
63
+ if (bad !== -1) throw new ForbiddenListError(`"allowedAuthorEmails" entry #${bad + 1} in ${path} is not an email address.`);
64
+ return { path, forbidden, allowedAuthorEmails };
65
+ }
66
+
67
+ export function loadForbiddenList(env: NodeJS.ProcessEnv = process.env): ForbiddenList {
68
+ const path = forbiddenListPath(env);
69
+ let text: string;
70
+ try {
71
+ text = readFileSync(path, "utf8");
72
+ } catch {
73
+ throw new ForbiddenListError(
74
+ `The forbidden-identities file ${path} does not exist. It lists what must never be published (work addresses, employer and company names). ` +
75
+ "Create it with `pnpm audit:forbidden:init`, review it, and run this again.",
76
+ );
77
+ }
78
+ return parseForbiddenList(text, path);
79
+ }
80
+
81
+ /** Entry numbers (1-based) of the forbidden strings that occur in the text. */
82
+ export function forbiddenIn(text: string, forbidden: readonly string[]): number[] {
83
+ const lower = text.toLowerCase();
84
+ const found: number[] = [];
85
+ forbidden.forEach((entry, index) => {
86
+ if (lower.includes(entry)) found.push(index + 1);
87
+ });
88
+ return found;
89
+ }
90
+
91
+ /** The same search over bytes, so an image's metadata or a binary file cannot hide a value. */
92
+ export function forbiddenInBytes(buffer: Buffer, forbidden: readonly string[]): number[] {
93
+ return forbiddenIn(buffer.toString("latin1"), forbidden.map((entry) => Buffer.from(entry, "utf8").toString("latin1")));
94
+ }
95
+
96
+ /** The text with every forbidden value replaced by its entry number, for paths a report must show. */
97
+ export function maskForbidden(text: string, forbidden: readonly string[]): string {
98
+ let masked = text;
99
+ [...forbidden.entries()]
100
+ .sort((a, b) => b[1].length - a[1].length)
101
+ .forEach(([index, entry]) => {
102
+ masked = masked.replace(new RegExp(entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), `<forbidden #${index + 1}>`);
103
+ });
104
+ return masked;
105
+ }
106
+
107
+ export const forbiddenLabel = (entry: number): string => `forbidden entry #${entry}`;
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Writes a first forbidden-identities file from the identities already in this repository's git
4
+ * history (`packages/tooling/src/security/forbidden-identities.ts` describes the file).
5
+ *
6
+ * Every author and committer address except your personal one, GitHub and tool `noreply`
7
+ * addresses, and bots becomes an entry, together with the domain's name when it is not a free
8
+ * mail provider (so a company name in a file is caught as well as its addresses). Nothing is
9
+ * printed except counts and the path: open the file to review it, and add what history cannot
10
+ * know (company names, tax numbers) with `--add`.
11
+ *
12
+ * Usage: pnpm audit:forbidden:init [--personal <email>] [--add <string>]... [--force]
13
+ *
14
+ * The personal address defaults to the global `git config user.email`. The file is written with
15
+ * mode 0600 and is never overwritten without `--force`.
16
+ */
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ import { forbiddenListPath, parseForbiddenList } from "./forbidden-identities.js";
24
+
25
+ const FREE_MAIL = new Set(["gmail.com", "googlemail.com", "gmx.com", "gmx.net", "gmx.de", "outlook.com", "hotmail.com", "live.com", "icloud.com", "me.com", "yahoo.com", "proton.me", "protonmail.com", "seznam.cz", "email.cz", "centrum.cz", "post.cz"]);
26
+ const NOT_A_PERSON = /(?:noreply|no-reply|\[bot\]|users\.noreply\.github\.com|^bot@)/i;
27
+
28
+ export interface Identity {
29
+ name: string;
30
+ email: string;
31
+ }
32
+
33
+ /** Candidate entries from history identities: addresses plus the name of a non-free-mail domain. */
34
+ export function candidateEntries(identities: readonly Identity[], personal: string): { entries: string[]; addresses: number; names: number } {
35
+ const entries = new Set<string>();
36
+ let addresses = 0;
37
+ let names = 0;
38
+ for (const { name, email } of identities) {
39
+ const address = email.trim().toLowerCase();
40
+ if (!address.includes("@") || address === personal.toLowerCase() || NOT_A_PERSON.test(address) || NOT_A_PERSON.test(name)) continue;
41
+ if (!entries.has(address)) addresses += 1;
42
+ entries.add(address);
43
+ const domain = address.split("@")[1]!;
44
+ if (FREE_MAIL.has(domain)) continue;
45
+ const label = domain.split(".").slice(0, -1).pop() ?? "";
46
+ if (label.length >= 4 && !entries.has(label)) {
47
+ entries.add(label);
48
+ names += 1;
49
+ }
50
+ }
51
+ return { entries: [...entries], addresses, names };
52
+ }
53
+
54
+ function git(args: string[], cwd?: string): string {
55
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
56
+ }
57
+
58
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
59
+ const args = process.argv.slice(2).filter((argument) => argument !== "--");
60
+ const value = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] : undefined);
61
+ const personal = value("--personal") ?? git(["config", "--global", "user.email"]).trim();
62
+ if (!personal.includes("@")) {
63
+ console.error("Pass your personal address with --personal <email>, or set the global git user.email to it.");
64
+ process.exit(1);
65
+ }
66
+ const path = forbiddenListPath();
67
+ if (existsSync(path) && !args.includes("--force")) {
68
+ console.error(`${path} exists. Edit it, or pass --force to replace it.`);
69
+ process.exit(1);
70
+ }
71
+ const log = git(["log", "--all", "--format=%an%x1f%ae%x1e%cn%x1f%ce%x1e"]);
72
+ const identities = log
73
+ .split("\x1e")
74
+ .map((record) => record.trim())
75
+ .filter(Boolean)
76
+ .map((record) => {
77
+ const [name = "", email = ""] = record.split("\x1f");
78
+ return { name, email };
79
+ });
80
+ const { entries, addresses, names } = candidateEntries(identities, personal);
81
+ const added = args.flatMap((argument, index) => (argument === "--add" && args[index + 1] ? [args[index + 1]!] : []));
82
+ const file = { forbidden: [...new Set([...entries, ...added])], allowedAuthorEmails: [personal.toLowerCase()] };
83
+ parseForbiddenList(JSON.stringify(file), path); // refuse to write what the loader would reject
84
+ mkdirSync(dirname(path), { recursive: true });
85
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
86
+ console.log(`Wrote ${path}: ${addresses} address(es) and ${names} domain name(s) from history, ${added.length} added by hand.`);
87
+ console.log("Open the file to review it. Nothing was printed from it.");
88
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Worker configs of a stack's backend: its Ledger, its public calendar feed Worker, and its public Gallery Worker.
3
+ *
4
+ * A stack is the shared one (every site without `tenants/<id>/deployment.json`) or a site's own. Each Worker gets a
5
+ * folder in `.generated/`: `shared/<worker>/` or `sites/<id>/<worker>/`, holding `wrangler.jsonc` (paths relative to
6
+ * the folder, as wrangler resolves them) and, for the two that have one, the entry file that re-exports the
7
+ * platform package. The files are gitignored and deterministic, regenerated by `hearthlog build` and the deploy tools.
8
+ */
9
+
10
+ import { mkdir, writeFile } from "node:fs/promises";
11
+ import { dirname, join, relative } from "node:path";
12
+
13
+ import { GENERATED_DIRECTORY } from "../compose/compose-modules.js";
14
+ import { packageDirectory } from "../host/write-host.js";
15
+ import { buildCalendarPublicConfig } from "../public-workers/calendar-public-config.js";
16
+ import { buildGalleryPublicConfig } from "../public-workers/gallery-public-config.js";
17
+ import { buildLedgerConfig } from "./ledger-config.js";
18
+ import { infrastructureFor, loadTenantConfig, type InfrastructureDefinition } from "./tenant-config.js";
19
+
20
+ export type HostWorker = "ledger" | "calendar-public" | "gallery-public";
21
+ /** "shared" or a site id. */
22
+ export type Stack = string;
23
+
24
+ export function hostWorkerDirectory(stack: Stack, worker: HostWorker): string {
25
+ return stack === "shared"
26
+ ? join(GENERATED_DIRECTORY, "shared", worker)
27
+ : join(GENERATED_DIRECTORY, "sites", stack, worker);
28
+ }
29
+
30
+ export function hostWorkerConfigPath(stack: Stack, worker: HostWorker): string {
31
+ return join(hostWorkerDirectory(stack, worker), "wrangler.jsonc");
32
+ }
33
+
34
+ const LEDGER_ENTRY = `// The Ledger Worker's entry. The code is \`@hearthlog/ledger\`; the Durable Object classes keep these
35
+ // exact names because deployed data is tied to them.
36
+ export { AppRegistry, LedgerApp, default } from "@hearthlog/ledger/index.js";
37
+ `;
38
+
39
+ const CALENDAR_ENTRY = `// The public calendar feed Worker's entry; the code is \`@hearthlog/cloudflare\`.
40
+ export { default, handleCalendarPublicRequest, type CalendarPublicEnv } from "@hearthlog/cloudflare/calendar-public.js";
41
+ `;
42
+
43
+ function calendarTsconfig(directory: string): string {
44
+ const types = join(packageDirectory("@hearthlog/cloudflare"), "types", "worker-configuration.d.ts");
45
+ return `${JSON.stringify({
46
+ compilerOptions: {
47
+ target: "ES2022", module: "ESNext", moduleResolution: "Bundler", lib: ["ES2022", "DOM"],
48
+ strict: true, noEmit: true, skipLibCheck: true, types: [],
49
+ },
50
+ include: ["index.ts", relative(directory, types)],
51
+ }, null, 2)}\n`;
52
+ }
53
+
54
+ async function write(path: string, content: string): Promise<void> {
55
+ await mkdir(dirname(path), { recursive: true });
56
+ await writeFile(path, content, "utf8");
57
+ }
58
+
59
+ const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
60
+
61
+ /**
62
+ * Write the configs of one stack. A build passes its own site's stack ("shared" for a site on the shared stack),
63
+ * because its catalog holds only its own modules; `check:tenants` passes each stack in turn.
64
+ */
65
+ export async function generateHostConfigs(stack: Stack): Promise<string[]> {
66
+ const { infrastructure: shared, sites, stacks } = await loadTenantConfig(undefined, { only: stack === "shared" ? "" : stack });
67
+ const written: string[] = [];
68
+ const emit = async (worker: HostWorker, config: unknown, files: Record<string, string> = {}) => {
69
+ const directory = hostWorkerDirectory(stack, worker);
70
+ await write(join(directory, "wrangler.jsonc"), json(config));
71
+ written.push(join(directory, "wrangler.jsonc"));
72
+ for (const [name, content] of Object.entries(files)) await write(join(directory, name), content);
73
+ };
74
+ let infrastructure: InfrastructureDefinition = shared;
75
+ let ledgerAccount: string | undefined;
76
+ let calendarSites = stacks.filter((entry) => entry.shared).map((entry) => entry.id);
77
+ let gallery = true;
78
+ if (stack !== "shared") {
79
+ const site = sites.find((candidate) => candidate.id === stack);
80
+ if (!site?.deployment) throw new Error(`Site "${stack}" has no tenants/${stack}/deployment.json, so it has no stack of its own`);
81
+ infrastructure = infrastructureFor(site, shared);
82
+ ledgerAccount = site.deployment.accountId;
83
+ calendarSites = [site.id];
84
+ gallery = site.features.includes("gallery");
85
+ } else if (calendarSites.length === 0) {
86
+ return written;
87
+ }
88
+ await emit("ledger", buildLedgerConfig(infrastructure, ledgerAccount), { "index.ts": LEDGER_ENTRY });
89
+ const calendarDirectory = hostWorkerDirectory(stack, "calendar-public");
90
+ await emit("calendar-public", buildCalendarPublicConfig(infrastructure, calendarSites), {
91
+ "index.ts": CALENDAR_ENTRY,
92
+ "tsconfig.json": calendarTsconfig(calendarDirectory),
93
+ });
94
+ if (gallery) await emit("gallery-public", buildGalleryPublicConfig(infrastructure));
95
+ return written;
96
+ }