@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.
- package/LICENSE +235 -0
- package/bin/hearthlog.mjs +61 -0
- package/package.json +64 -3
- package/src/apply.ts +73 -0
- package/src/check/check-layers.ts +118 -0
- package/src/check/check-modules.ts +198 -0
- package/src/check/check-sdk-exports.ts +79 -0
- package/src/check/check-tenant-config.ts +208 -0
- package/src/cli/hearthlog.ts +212 -0
- package/src/cli/list-sites.ts +15 -0
- package/src/compose/compose-modules.ts +490 -0
- package/src/compose/module-scaffold.ts +195 -0
- package/src/deploy/artifact-compare.ts +93 -0
- package/src/deploy/artifact.ts +127 -0
- package/src/deploy/async-utils.ts +71 -0
- package/src/deploy/backup-tenants.ts +139 -0
- package/src/deploy/cloudflare-access.ts +789 -0
- package/src/deploy/copy-tenant.ts +395 -0
- package/src/deploy/dedicated-ledger.ts +77 -0
- package/src/deploy/deploy-ledger.ts +162 -0
- package/src/deploy/deploy-runner.ts +240 -0
- package/src/deploy/deploy-tenant.ts +170 -0
- package/src/deploy/deployment-security.ts +141 -0
- package/src/deploy/ensure-tenant.ts +988 -0
- package/src/deploy/event-policy.ts +23 -0
- package/src/deploy/frontend-dry-run.ts +18 -0
- package/src/deploy/ledger-credential.ts +48 -0
- package/src/deploy/ledger-scripts.ts +13 -0
- package/src/deploy/split-tenant.ts +295 -0
- package/src/dev/local-development.ts +288 -0
- package/src/dev/provision-local.ts +13 -0
- package/src/generate/build-generated.ts +166 -0
- package/src/generate/generate-brand-icons.ts +128 -0
- package/src/generate/generate-entities.ts +627 -0
- package/src/generate/generate-tenant.ts +161 -0
- package/src/host/core-routes.ts +39 -0
- package/src/host/write-host.ts +119 -0
- package/src/index.ts +8 -0
- package/src/init/check-module.ts +143 -0
- package/src/init/export-tenant.ts +124 -0
- package/src/init/init.ts +168 -0
- package/src/move.ts +189 -0
- package/src/plan.ts +160 -0
- package/src/public-workers/build-gallery-public.ts +114 -0
- package/src/public-workers/calendar-public-config.ts +27 -0
- package/src/public-workers/check-calendar-public-route.ts +41 -0
- package/src/public-workers/check-gallery-public-route.ts +43 -0
- package/src/public-workers/gallery-public-config.ts +22 -0
- package/src/receipt.ts +74 -0
- package/src/root.ts +16 -0
- package/src/security/audit-push.ts +141 -0
- package/src/security/forbidden-identities.ts +107 -0
- package/src/security/forbidden-init.ts +88 -0
- package/src/tenants/host-configs.ts +96 -0
- package/src/tenants/ledger-config.ts +57 -0
- package/src/tenants/lock-file.ts +45 -0
- package/src/tenants/module-lock.ts +58 -0
- package/src/tenants/tenant-config.ts +887 -0
- package/src/tenants/tenant-copy-plan.ts +119 -0
- package/src/tenants/tenant-schema.ts +171 -0
- package/src/tenants/tenant-wrangler.ts +37 -0
- package/src/tenants/wrangler-base.ts +50 -0
- package/src/tenants/wrangler-config.ts +266 -0
- package/src/vite/config.ts +310 -0
- package/src/vite/dev-plugins.ts +338 -0
- package/src/vite/vite-plugin-entity-gen.ts +70 -0
- package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
- package/src/vite/vite-plugin-worker-assets.ts +42 -0
- package/templates/host/app.d.ts.txt +31 -0
- package/templates/host/microlighter.d.ts.txt +11 -0
- package/templates/module/ci.yml.tmpl +44 -0
- package/templates/module/gitignore.tmpl +7 -0
- package/templates/module/gitleaks.toml.tmpl +11 -0
- package/templates/module/release.yml.tmpl +53 -0
- package/templates/tenant/README.md.tmpl +13 -0
- package/templates/tenant/generated-package.json.tmpl +16 -0
- package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
- package/templates/tenant/gitignore.tmpl +11 -0
- package/templates/tenant/package.json.tmpl +11 -0
- package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
- package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
- package/templates/tenant/tenants.json.tmpl +17 -0
- package/templates/tenant/tsconfig.json.tmpl +21 -0
- package/templates/tenant/vite.config.ts.tmpl +5 -0
- package/README.md +0 -3
package/src/init/init.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* `hearthlog init <dir> --id <id> [--title <title>] [--modules <list>] [--workspace] [--check] [--force]`
|
|
4
|
+
*
|
|
5
|
+
* Render `templates/tenant/` into a new site: the package.json, the Vite and TypeScript configuration, a
|
|
6
|
+
* `tenants.json` with placeholder infrastructure (it makes the site load and cannot be deployed), the site's
|
|
7
|
+
* `tenant.json`, its module lock, the committed `.generated/package.json`, and the pnpm settings the install needs (the
|
|
8
|
+
* build scripts to allow and the patch to `sqlocal`, which does not travel with a dependency). The modules are named by package
|
|
9
|
+
* (`@hearthlog/module-calc`), by short name (`calc`), or `none`; their features become the site's features.
|
|
10
|
+
* `--workspace` writes the `@hearthlog` dependencies as `workspace:*` (a site inside a workspace); otherwise
|
|
11
|
+
* they are `^<version of this package>`. `--check` writes nothing and fails when the files on disk differ
|
|
12
|
+
* from what init would write, so the committed examples cannot drift from the template; `--force` rewrites
|
|
13
|
+
* existing files (how an example is brought back in line).
|
|
14
|
+
*
|
|
15
|
+
* Nothing here reads generated files: init is how a site gets started, before `hearthlog build` has run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { loadModules, type LoadedModule } from "../compose/compose-modules.js";
|
|
24
|
+
import { lockFor, renderModuleLock } from "../tenants/lock-file.js";
|
|
25
|
+
|
|
26
|
+
const TOOLING = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
27
|
+
const TEMPLATE = join(TOOLING, "templates", "tenant");
|
|
28
|
+
|
|
29
|
+
/** The @hearthlog packages every site depends on; the modules it uses are added to them. */
|
|
30
|
+
const CORE_PACKAGES = ["cloudflare", "contracts", "ledger", "runtime", "sdk", "shell", "tooling", "ui"] as const;
|
|
31
|
+
|
|
32
|
+
export interface InitOptions {
|
|
33
|
+
directory: string;
|
|
34
|
+
id: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
/** Package names or short names of the modules the site ships. */
|
|
37
|
+
modules?: readonly string[];
|
|
38
|
+
workspace?: boolean;
|
|
39
|
+
/** The installed modules to choose from; defaults to the ones the current host has. */
|
|
40
|
+
available?: readonly LoadedModule[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SITE_ID = /^[a-z][a-z0-9-]{0,38}$/;
|
|
44
|
+
|
|
45
|
+
function render(template: string, values: Record<string, string>): string {
|
|
46
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
|
|
47
|
+
if (!(key in values)) throw new Error(`The template names {{${key}}}, which init does not know`);
|
|
48
|
+
return values[key]!;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A tenant's deploy workflow (`templates/tenant/github-workflows/deploy.yml.tmpl`); `paths` limits the pushes that run it. */
|
|
53
|
+
export function renderWorkflow(id: string, paths: readonly string[] = []): string {
|
|
54
|
+
const pushPaths = paths.length > 0 ? `\n paths:\n${paths.map((path) => ` - "${path}"`).join("\n")}` : "";
|
|
55
|
+
return render(readFileSync(join(TEMPLATE, "github-workflows", "deploy.yml.tmpl"), "utf8"), { id, idUpper: id.toUpperCase().replace(/-/g, "_"), pushPaths });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toolingPackage(): { version: string; peerDependencies?: Record<string, string> } {
|
|
59
|
+
return JSON.parse(readFileSync(join(TOOLING, "package.json"), "utf8")) as { version: string; peerDependencies?: Record<string, string> };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function chooseModules(names: readonly string[], available: readonly LoadedModule[]): LoadedModule[] {
|
|
63
|
+
return names.map((name) => {
|
|
64
|
+
const found = available.find(({ manifest }) => manifest.package === name || manifest.package === `@hearthlog/module-${name}`);
|
|
65
|
+
if (!found) throw new Error(`No installed module is called "${name}" (installed: ${available.map(({ manifest }) => manifest.package).join(", ") || "none"})`);
|
|
66
|
+
return found;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every file init writes, as [path relative to the site, content]. */
|
|
71
|
+
export function renderSite(options: InitOptions): Array<[string, string]> {
|
|
72
|
+
const { id, workspace = false } = options;
|
|
73
|
+
if (!SITE_ID.test(id)) throw new Error(`The site id "${id}" must be lowercase letters, digits, and hyphens, starting with a letter`);
|
|
74
|
+
const title = options.title ?? id;
|
|
75
|
+
const modules = chooseModules(options.modules ?? [], options.available ?? loadModules());
|
|
76
|
+
const features = [...new Set(modules.flatMap(({ manifest }) => manifest.features.map((feature) => feature.id)))].sort();
|
|
77
|
+
const tooling = toolingPackage();
|
|
78
|
+
const own = workspace ? "workspace:*" : `^${tooling.version}`;
|
|
79
|
+
|
|
80
|
+
const manifest = JSON.parse(readFileSync(join(TEMPLATE, "package.json.tmpl"), "utf8")) as Record<string, unknown>;
|
|
81
|
+
const dependencies: Record<string, string> = Object.fromEntries([
|
|
82
|
+
...CORE_PACKAGES.map((name) => [`@hearthlog/${name}`, own]),
|
|
83
|
+
// A module is released on its own version, not on the platform's.
|
|
84
|
+
...modules.map(({ manifest: { package: name }, directory }) => [name, workspace ? own : `^${(JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) as { version: string }).version}`]),
|
|
85
|
+
["@hearthlog/generated", "link:.generated"],
|
|
86
|
+
].sort(([a], [b]) => a!.localeCompare(b!)));
|
|
87
|
+
const devDependencies = Object.fromEntries(Object.entries(tooling.peerDependencies ?? {}).filter(([name]) => !name.startsWith("@hearthlog/")).sort(([a], [b]) => a.localeCompare(b)));
|
|
88
|
+
const packageJson = { name: id, ...manifest, dependencies, devDependencies };
|
|
89
|
+
|
|
90
|
+
const values = { id, title, features: JSON.stringify(features) };
|
|
91
|
+
const template = (name: string) => readFileSync(join(TEMPLATE, name), "utf8");
|
|
92
|
+
return [
|
|
93
|
+
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
|
94
|
+
["vite.config.ts", template("vite.config.ts.tmpl")],
|
|
95
|
+
["tsconfig.json", template("tsconfig.json.tmpl")],
|
|
96
|
+
["tenants.json", template("tenants.json.tmpl")],
|
|
97
|
+
[`tenants/${id}/tenant.json`, render(template("tenants/__id__/tenant.json.tmpl"), values)],
|
|
98
|
+
[`tenants/${id}/modules.lock.json`, renderModuleLock(lockFor(modules, features))],
|
|
99
|
+
[".generated/package.json", template("generated-package.json.tmpl")],
|
|
100
|
+
["pnpm-workspace.yaml", template("pnpm-workspace.yaml.tmpl")],
|
|
101
|
+
["patches/sqlocal@0.18.0.patch", template("patches/sqlocal@0.18.0.patch")],
|
|
102
|
+
[".github/workflows/deploy.yml", renderWorkflow(id)],
|
|
103
|
+
[".gitignore", template("gitignore.tmpl")],
|
|
104
|
+
["README.md", render(template("README.md.tmpl"), values)],
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The files whose content differs from what init would write. */
|
|
109
|
+
export async function initProblems(options: InitOptions): Promise<string[]> {
|
|
110
|
+
const problems: string[] = [];
|
|
111
|
+
for (const [path, content] of renderSite(options)) {
|
|
112
|
+
const current = await readFile(join(options.directory, path), "utf8").catch(() => null);
|
|
113
|
+
if (current === null) problems.push(`${path} is missing`);
|
|
114
|
+
else if (current !== content) problems.push(`${path} differs from the template`);
|
|
115
|
+
}
|
|
116
|
+
return problems;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function init(options: InitOptions & { force?: boolean }): Promise<string[]> {
|
|
120
|
+
const written: string[] = [];
|
|
121
|
+
for (const [path, content] of renderSite(options)) {
|
|
122
|
+
const target = join(options.directory, path);
|
|
123
|
+
if (existsSync(target) && !options.force) throw new Error(`${path} already exists in ${options.directory}; init does not overwrite`);
|
|
124
|
+
await mkdir(dirname(target), { recursive: true });
|
|
125
|
+
await writeFile(target, content, "utf8");
|
|
126
|
+
written.push(path);
|
|
127
|
+
}
|
|
128
|
+
return written;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parse(argv: string[]): { options: InitOptions & { force: boolean }; check: boolean } {
|
|
132
|
+
const value = (flag: string) => {
|
|
133
|
+
const index = argv.indexOf(flag);
|
|
134
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
135
|
+
};
|
|
136
|
+
const directory = argv.find((argument, index) => !argument.startsWith("--") && !["--id", "--title", "--modules"].includes(argv[index - 1] ?? ""));
|
|
137
|
+
const id = value("--id");
|
|
138
|
+
if (!directory || !id) throw new Error("Usage: hearthlog init <dir> --id <id> [--title <title>] [--modules <list|none>] [--workspace] [--check] [--force]");
|
|
139
|
+
const modules = value("--modules");
|
|
140
|
+
return {
|
|
141
|
+
options: {
|
|
142
|
+
directory: resolve(directory),
|
|
143
|
+
id,
|
|
144
|
+
title: value("--title"),
|
|
145
|
+
modules: modules === undefined || modules === "none" ? [] : modules.split(",").map((name) => name.trim()).filter(Boolean),
|
|
146
|
+
workspace: argv.includes("--workspace"),
|
|
147
|
+
force: argv.includes("--force"),
|
|
148
|
+
} as InitOptions & { force: boolean },
|
|
149
|
+
check: argv.includes("--check"),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
154
|
+
try {
|
|
155
|
+
const { options, check } = parse(process.argv.slice(2));
|
|
156
|
+
if (check) {
|
|
157
|
+
const problems = await initProblems(options);
|
|
158
|
+
if (problems.length > 0) throw new Error(`${options.directory} no longer matches the template:\n - ${problems.join("\n - ")}`);
|
|
159
|
+
console.log(`${options.directory} matches the template.`);
|
|
160
|
+
} else {
|
|
161
|
+
const written = await init(options);
|
|
162
|
+
console.log(`Wrote ${written.length} file(s) to ${options.directory}. Next: pnpm install && pnpm build`);
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/move.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moves one Ledger application to another Ledger with every identity intact
|
|
3
|
+
* (`GET .../transfer` on the source, `POST /admin/v1/import` and friends on the
|
|
4
|
+
* destination), and proves the result. Pure over two administrator HTTP
|
|
5
|
+
* callers, so the whole procedure runs in tests with no network.
|
|
6
|
+
*
|
|
7
|
+
* A new attempt first clears an unfinished import that an earlier, interrupted one left on the destination.
|
|
8
|
+
* It only starts against a fenced source: an export taken while writes still
|
|
9
|
+
* land would silently miss them. On any failure after the import began it
|
|
10
|
+
* aborts the half-filled destination so the move can start over, and it never
|
|
11
|
+
* touches the source.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type AdminCall = (method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown) => Promise<Response>;
|
|
15
|
+
|
|
16
|
+
export interface LogChecksum {
|
|
17
|
+
id: string;
|
|
18
|
+
headVersion: number;
|
|
19
|
+
eventCount: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MoveReport {
|
|
24
|
+
appId: string;
|
|
25
|
+
name: string;
|
|
26
|
+
logs: number;
|
|
27
|
+
events: number;
|
|
28
|
+
requests: number;
|
|
29
|
+
checksums: LogChecksum[];
|
|
30
|
+
movedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MoveOptions {
|
|
34
|
+
appId: string;
|
|
35
|
+
source: AdminCall;
|
|
36
|
+
target: AdminCall;
|
|
37
|
+
/** Rehearsals against a copy may run without a fence; a real move never does. */
|
|
38
|
+
allowUnfenced?: boolean;
|
|
39
|
+
maxBatchRecords?: number;
|
|
40
|
+
maxBatchBytes?: number;
|
|
41
|
+
log?: (message: string) => void;
|
|
42
|
+
now?: () => Date;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class MoveError extends Error {
|
|
46
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
47
|
+
super(message, options);
|
|
48
|
+
this.name = 'MoveError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Line = { t: string } & Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
async function json<T>(response: Response, what: string): Promise<T> {
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const detail = await response.text().catch(() => '');
|
|
57
|
+
throw new MoveError(`${what} failed with ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
|
|
58
|
+
}
|
|
59
|
+
return (await response.json()) as T;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function* lines(response: Response): AsyncGenerator<Line> {
|
|
63
|
+
if (!response.body) throw new MoveError('The export has no body');
|
|
64
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
65
|
+
let pending = '';
|
|
66
|
+
for (;;) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (value) pending += value;
|
|
69
|
+
for (let newline = pending.indexOf('\n'); newline !== -1; newline = pending.indexOf('\n')) {
|
|
70
|
+
const text = pending.slice(0, newline);
|
|
71
|
+
pending = pending.slice(newline + 1);
|
|
72
|
+
if (text.trim()) yield JSON.parse(text) as Line;
|
|
73
|
+
}
|
|
74
|
+
if (done) break;
|
|
75
|
+
}
|
|
76
|
+
if (pending.trim()) yield JSON.parse(pending) as Line;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sameChecksums(source: readonly LogChecksum[], target: readonly LogChecksum[]): string[] {
|
|
80
|
+
const problems: string[] = [];
|
|
81
|
+
const left = new Map(source.map((log) => [log.id, log]));
|
|
82
|
+
const right = new Map(target.map((log) => [log.id, log]));
|
|
83
|
+
for (const [id, log] of left) {
|
|
84
|
+
const other = right.get(id);
|
|
85
|
+
if (!other) problems.push(`log ${id} is missing at the destination`);
|
|
86
|
+
else if (other.headVersion !== log.headVersion || other.eventCount !== log.eventCount || other.sha256 !== log.sha256) {
|
|
87
|
+
problems.push(`log ${id} differs: source ${log.eventCount} events at ${log.headVersion} (${log.sha256.slice(0, 12)}), destination ${other.eventCount} at ${other.headVersion} (${other.sha256.slice(0, 12)})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const id of right.keys()) if (!left.has(id)) problems.push(`log ${id} exists only at the destination`);
|
|
91
|
+
return problems;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function moveApplication(options: MoveOptions): Promise<MoveReport> {
|
|
95
|
+
const { appId, source, target } = options;
|
|
96
|
+
const log = options.log ?? (() => undefined);
|
|
97
|
+
const maxRecords = options.maxBatchRecords ?? 200;
|
|
98
|
+
const maxBytes = options.maxBatchBytes ?? 1_500_000;
|
|
99
|
+
|
|
100
|
+
if (!options.allowUnfenced) {
|
|
101
|
+
const state = await json<{ fence: { mode: string } | null }>(await source('GET', `/admin/v1/apps/${appId}/fence`), 'Reading the source fence');
|
|
102
|
+
if (state.fence?.mode !== 'tenant_migrating') {
|
|
103
|
+
throw new MoveError('The source is not fenced. Pause its writes first (mode "tenant_migrating"), or the copy would miss them.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const exported = await source('GET', `/admin/v1/apps/${appId}/transfer`);
|
|
108
|
+
if (!exported.ok) {
|
|
109
|
+
const detail = await exported.text().catch(() => '');
|
|
110
|
+
throw new MoveError(`Exporting the source failed with ${exported.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// An earlier attempt that lost its connection could not abort itself, and leaves an unfinished import that refuses
|
|
114
|
+
// a new one. Aborting is safe here: it only acts on an import in progress and refuses a finished one.
|
|
115
|
+
await target('POST', `/admin/v1/apps/${appId}/import/abort`).catch(() => undefined);
|
|
116
|
+
|
|
117
|
+
let begun = false;
|
|
118
|
+
try {
|
|
119
|
+
let app: Line | null = null;
|
|
120
|
+
let end: Line | null = null;
|
|
121
|
+
const heads: Array<{ id: string; headVersion: number }> = [];
|
|
122
|
+
let batch: Line[] = [];
|
|
123
|
+
let batchBytes = 0;
|
|
124
|
+
const counted = { logs: 0, events: 0, requests: 0 };
|
|
125
|
+
|
|
126
|
+
const flush = async () => {
|
|
127
|
+
if (batch.length === 0) return;
|
|
128
|
+
await json(await target('POST', `/admin/v1/apps/${appId}/import`, { records: batch }), 'Importing a chunk');
|
|
129
|
+
batch = [];
|
|
130
|
+
batchBytes = 0;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for await (const line of lines(exported)) {
|
|
134
|
+
if (line.t === 'app') {
|
|
135
|
+
if (app) throw new MoveError('The export names two applications');
|
|
136
|
+
if (line.id !== appId) throw new MoveError(`The export is for ${String(line.id)}, not ${appId}`);
|
|
137
|
+
app = line;
|
|
138
|
+
await json(await target('POST', '/admin/v1/import', { id: appId, name: line.name, createdAt: line.createdAt, policy: line.policy ?? null }), 'Starting the import');
|
|
139
|
+
begun = true;
|
|
140
|
+
log(`importing "${String(line.name)}" (${appId})`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!app) throw new MoveError('The export does not start with the application');
|
|
144
|
+
if (line.t === 'end') {
|
|
145
|
+
end = line;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (end) throw new MoveError('The export continues after its end');
|
|
149
|
+
if (line.t === 'log') {
|
|
150
|
+
heads.push({ id: String(line.id), headVersion: Number(line.headVersion) });
|
|
151
|
+
counted.logs += 1;
|
|
152
|
+
} else if (line.t === 'events') {
|
|
153
|
+
counted.events += (line.events as unknown[]).length;
|
|
154
|
+
} else if (line.t === 'requests') {
|
|
155
|
+
counted.requests += (line.requests as unknown[]).length;
|
|
156
|
+
}
|
|
157
|
+
const size = JSON.stringify(line).length;
|
|
158
|
+
if (batch.length > 0 && (batch.length >= maxRecords || batchBytes + size > maxBytes)) await flush();
|
|
159
|
+
batch.push(line);
|
|
160
|
+
batchBytes += size;
|
|
161
|
+
}
|
|
162
|
+
if (!app || !end) throw new MoveError('The export was cut short: it has no end record');
|
|
163
|
+
for (const key of ['logs', 'events', 'requests'] as const) {
|
|
164
|
+
if (Number(end[key]) !== counted[key]) throw new MoveError(`The export says ${String(end[key])} ${key} but carried ${counted[key]}`);
|
|
165
|
+
}
|
|
166
|
+
await flush();
|
|
167
|
+
|
|
168
|
+
await json(await target('POST', `/admin/v1/apps/${appId}/import/finish`, { logs: heads }), 'Finishing the import');
|
|
169
|
+
const [before, after] = await Promise.all([
|
|
170
|
+
json<{ logs: LogChecksum[] }>(await source('GET', `/admin/v1/apps/${appId}/checksums`), 'Reading the source checksums'),
|
|
171
|
+
json<{ logs: LogChecksum[] }>(await target('GET', `/admin/v1/apps/${appId}/checksums`), 'Reading the destination checksums'),
|
|
172
|
+
]);
|
|
173
|
+
const problems = sameChecksums(before.logs, after.logs);
|
|
174
|
+
if (problems.length) {
|
|
175
|
+
// The destination is open now and cannot be aborted: the operator must discard it.
|
|
176
|
+
throw new MoveError(`The destination differs from the source:\n - ${problems.join('\n - ')}`);
|
|
177
|
+
}
|
|
178
|
+
log(`verified ${before.logs.length} log(s), ${counted.events} event(s)`);
|
|
179
|
+
return {
|
|
180
|
+
appId, name: String(app.name), ...counted, checksums: after.logs, movedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (begun) {
|
|
184
|
+
// Best effort: a finished import refuses this, and then the error above says what differs.
|
|
185
|
+
await target('POST', `/admin/v1/apps/${appId}/import/abort`).catch(() => undefined);
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hearthlog plan`: what deploying one tenant would create, in what order, and
|
|
3
|
+
* what stops it. Pure: the caller resolves names from `tenants.json`, the
|
|
4
|
+
* tenant files, and the module manifests, so the planner can be tested and
|
|
5
|
+
* run with no credentials and no network.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type StepId = 'resources' | 'ledger' | 'ledger-app' | 'frontend' | 'verify';
|
|
9
|
+
|
|
10
|
+
export interface PlanModule {
|
|
11
|
+
id: string;
|
|
12
|
+
version: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A name the tenant's Workers bind or create, with whether it holds data that must survive. */
|
|
16
|
+
export interface PlannedResource {
|
|
17
|
+
kind: 'frontend-worker' | 'ledger-worker' | 'calendar-feed-worker' | 'public-worker' | 'r2-bucket' | 'ledger-app' | 'access-app';
|
|
18
|
+
name: string;
|
|
19
|
+
/** Never deleted by apply, whatever the plan says. */
|
|
20
|
+
stateful: boolean;
|
|
21
|
+
billable: 'workers' | 'durable-objects' | 'r2' | 'access';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PlannedStep {
|
|
25
|
+
id: StepId;
|
|
26
|
+
summary: string;
|
|
27
|
+
/** The step's work is the first mint of a credential: it must never run again once recorded. */
|
|
28
|
+
mintsCredentials: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TenantResources {
|
|
32
|
+
ledgerWorker: string;
|
|
33
|
+
/** R2 bindings the tenant's gateway gets, by Worker binding name. */
|
|
34
|
+
buckets: Array<{ binding: string; bucket: string }>;
|
|
35
|
+
calendarFeedsBucket: string;
|
|
36
|
+
calendarPublicWorker: string;
|
|
37
|
+
/** Anonymous Workers a module deploys beside the gateway (Gallery's public pages). */
|
|
38
|
+
publicWorkers: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PlanInput {
|
|
42
|
+
site: {
|
|
43
|
+
id: string;
|
|
44
|
+
origin: string;
|
|
45
|
+
features: string[];
|
|
46
|
+
authorizedEmails: string[];
|
|
47
|
+
ledgerAppName: string;
|
|
48
|
+
/** Null: the site is served by the shared Ledger and buckets. */
|
|
49
|
+
deployment: { resourcePrefix: string; accountId?: string } | null;
|
|
50
|
+
};
|
|
51
|
+
modules: PlanModule[];
|
|
52
|
+
resources: TenantResources;
|
|
53
|
+
accessTeamDomain: string;
|
|
54
|
+
/** Every other site's resources, and the shared stack's, for the isolation check. */
|
|
55
|
+
others: Array<{ id: string; resources: TenantResources }>;
|
|
56
|
+
/** Absent in a repository with no shared stack (a site that owns its stack and is the only one in it). */
|
|
57
|
+
shared?: TenantResources;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DeploymentPlan {
|
|
61
|
+
siteId: string;
|
|
62
|
+
origin: string;
|
|
63
|
+
topology: 'dedicated' | 'shared';
|
|
64
|
+
accountId: string | null;
|
|
65
|
+
modules: PlanModule[];
|
|
66
|
+
resources: PlannedResource[];
|
|
67
|
+
steps: PlannedStep[];
|
|
68
|
+
/** Anything here stops `hearthlog apply`. */
|
|
69
|
+
problems: string[];
|
|
70
|
+
/** Worth reading, not blocking. */
|
|
71
|
+
warnings: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function owned(resources: TenantResources): Map<string, string> {
|
|
75
|
+
const names = new Map<string, string>();
|
|
76
|
+
names.set(resources.ledgerWorker, 'ledger Worker');
|
|
77
|
+
for (const { bucket } of resources.buckets) names.set(bucket, 'bucket');
|
|
78
|
+
names.set(resources.calendarFeedsBucket, 'bucket');
|
|
79
|
+
names.set(resources.calendarPublicWorker, 'Worker');
|
|
80
|
+
for (const worker of resources.publicWorkers) names.set(worker, 'Worker');
|
|
81
|
+
return names;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The tenant's own names that another tenant (or the shared stack) also holds. */
|
|
85
|
+
export function isolationProblems(input: Pick<PlanInput, 'site' | 'resources' | 'others' | 'shared'>): string[] {
|
|
86
|
+
const mine = owned(input.resources);
|
|
87
|
+
const problems: string[] = [];
|
|
88
|
+
const against = [...input.others.map((other) => ({ label: `site "${other.id}"`, resources: other.resources })), ...(input.shared ? [{ label: 'the shared stack', resources: input.shared }] : [])];
|
|
89
|
+
for (const other of against) {
|
|
90
|
+
// A tenant on the shared stack is declared to share it; only a dedicated one must not.
|
|
91
|
+
if (!input.site.deployment) continue;
|
|
92
|
+
const theirs = owned(other.resources);
|
|
93
|
+
for (const [name, kind] of mine) {
|
|
94
|
+
if (theirs.has(name)) problems.push(`${kind} "${name}" is also used by ${other.label}: this tenant's gateway would reach its data`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (input.site.deployment) {
|
|
98
|
+
const prefix = `${input.site.deployment.resourcePrefix}-`;
|
|
99
|
+
for (const [name, kind] of mine) {
|
|
100
|
+
if (!name.startsWith(prefix)) problems.push(`${kind} "${name}" does not start with the resource prefix "${prefix}"`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return problems;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function planDeployment(input: PlanInput): DeploymentPlan {
|
|
107
|
+
const { site, resources } = input;
|
|
108
|
+
const dedicated = site.deployment !== null;
|
|
109
|
+
const problems = isolationProblems(input);
|
|
110
|
+
const warnings: string[] = [];
|
|
111
|
+
if (!input.accessTeamDomain) problems.push('No Cloudflare Access team is configured: the site would be deployed unprotected');
|
|
112
|
+
if (site.authorizedEmails.length === 0) problems.push('No user is allowed in: the Access policy would be empty');
|
|
113
|
+
if (dedicated && !site.deployment!.accountId) warnings.push('deployment.json names no accountId, so the CLI login cannot be checked against the intended account');
|
|
114
|
+
|
|
115
|
+
const planned: PlannedResource[] = [
|
|
116
|
+
{ kind: 'frontend-worker', name: site.id, stateful: false, billable: 'workers' },
|
|
117
|
+
{ kind: 'access-app', name: site.origin, stateful: false, billable: 'access' },
|
|
118
|
+
{ kind: 'ledger-app', name: site.ledgerAppName, stateful: true, billable: 'durable-objects' },
|
|
119
|
+
{ kind: 'calendar-feed-worker', name: resources.calendarPublicWorker, stateful: false, billable: 'workers' },
|
|
120
|
+
{ kind: 'r2-bucket', name: resources.calendarFeedsBucket, stateful: true, billable: 'r2' },
|
|
121
|
+
...resources.buckets.map(({ bucket }): PlannedResource => ({ kind: 'r2-bucket', name: bucket, stateful: true, billable: 'r2' })),
|
|
122
|
+
...resources.publicWorkers.map((name): PlannedResource => ({ kind: 'public-worker', name, stateful: false, billable: 'workers' })),
|
|
123
|
+
];
|
|
124
|
+
if (dedicated) planned.unshift({ kind: 'ledger-worker', name: resources.ledgerWorker, stateful: true, billable: 'durable-objects' });
|
|
125
|
+
|
|
126
|
+
const steps: PlannedStep[] = [
|
|
127
|
+
{ id: 'resources', summary: 'Create the buckets and the Access application (existing ones are kept)', mintsCredentials: false },
|
|
128
|
+
...(dedicated ? [{ id: 'ledger' as const, summary: `Deploy ${resources.ledgerWorker} and generate its administrator token and signing key once`, mintsCredentials: true }] : []),
|
|
129
|
+
{ id: 'ledger-app', summary: `Register the Ledger application "${site.ledgerAppName}" with the site's event policy and give the gateway its token (an existing application keeps its token and policy)`, mintsCredentials: true },
|
|
130
|
+
{ id: 'frontend', summary: 'Build and deploy the gateway and the public Workers', mintsCredentials: false },
|
|
131
|
+
{ id: 'verify', summary: 'Check that the origin is not served without signing in', mintsCredentials: false },
|
|
132
|
+
];
|
|
133
|
+
return {
|
|
134
|
+
siteId: site.id,
|
|
135
|
+
origin: site.origin,
|
|
136
|
+
topology: dedicated ? 'dedicated' : 'shared',
|
|
137
|
+
accountId: site.deployment?.accountId ?? null,
|
|
138
|
+
modules: input.modules,
|
|
139
|
+
resources: planned,
|
|
140
|
+
steps,
|
|
141
|
+
problems,
|
|
142
|
+
warnings,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function renderPlan(plan: DeploymentPlan): string {
|
|
147
|
+
const lines = [
|
|
148
|
+
`Tenant ${plan.siteId} (${plan.topology}) at ${plan.origin}`,
|
|
149
|
+
`Modules: ${plan.modules.map((module) => `${module.id}@${module.version}`).join(', ') || 'none'}`,
|
|
150
|
+
'',
|
|
151
|
+
'Resources:',
|
|
152
|
+
...plan.resources.map((resource) => ` ${resource.kind.padEnd(20)} ${resource.name}${resource.stateful ? ' (kept; never deleted)' : ''} [${resource.billable}]`),
|
|
153
|
+
'',
|
|
154
|
+
'Steps:',
|
|
155
|
+
...plan.steps.map((step, index) => ` ${index + 1}. ${step.id}: ${step.summary}`),
|
|
156
|
+
];
|
|
157
|
+
if (plan.warnings.length) lines.push('', 'Warnings:', ...plan.warnings.map((warning) => ` - ${warning}`));
|
|
158
|
+
if (plan.problems.length) lines.push('', 'Problems (apply refuses until fixed):', ...plan.problems.map((problem) => ` - ${problem}`));
|
|
159
|
+
return `${lines.join('\n')}\n`;
|
|
160
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hearthlog gallery-public:build [--site <id>]`: build a stack's public Gallery Worker.
|
|
3
|
+
*
|
|
4
|
+
* The Gallery module ships the Worker's source (`public/` in its package). The browser bundle is built first, then the
|
|
5
|
+
* Worker, which renders the same Svelte components on the server and carries that bundle to serve it. Both go into
|
|
6
|
+
* `dist/` beside the stack's generated `wrangler.jsonc` (`.generated/shared/gallery-public/` or the site's own).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import { loadModules } from "../compose/compose-modules.js";
|
|
14
|
+
import { generateHostConfigs, hostWorkerDirectory } from "../tenants/host-configs.js";
|
|
15
|
+
import { loadTenantConfig } from "../tenants/tenant-config.js";
|
|
16
|
+
|
|
17
|
+
const ASSETS_MODULE = "\0gallery-public-client-assets";
|
|
18
|
+
|
|
19
|
+
export async function buildGalleryPublic(stack: string): Promise<string> {
|
|
20
|
+
await generateHostConfigs(stack);
|
|
21
|
+
const output = hostWorkerDirectory(stack, "gallery-public");
|
|
22
|
+
// A site without the Gallery has no public Gallery Worker to build.
|
|
23
|
+
if (!existsSync(join(output, "wrangler.jsonc"))) {
|
|
24
|
+
console.log(`The ${stack} stack has no public Gallery Worker.`);
|
|
25
|
+
return output;
|
|
26
|
+
}
|
|
27
|
+
const module = loadModules().find(({ manifest }) => manifest.publicWorker?.name === "gallery");
|
|
28
|
+
if (!module) throw new Error("No installed module has a public Gallery Worker");
|
|
29
|
+
const source = join(module.directory, "public");
|
|
30
|
+
const clientOut = join(output, "dist", "client");
|
|
31
|
+
|
|
32
|
+
// Loaded here, not at the top: only a site that builds the Worker needs them.
|
|
33
|
+
const { build } = await import("vite");
|
|
34
|
+
const { svelte } = await import("@sveltejs/vite-plugin-svelte");
|
|
35
|
+
const { default: Icons } = await import("unplugin-icons/vite");
|
|
36
|
+
|
|
37
|
+
/** Replaces the empty `client-assets` module with the built browser files. */
|
|
38
|
+
const clientAssets = {
|
|
39
|
+
name: "gallery-public-client-assets",
|
|
40
|
+
enforce: "pre" as const,
|
|
41
|
+
resolveId(id: string, importer?: string) {
|
|
42
|
+
return id === "./client-assets" && importer?.startsWith(source) ? ASSETS_MODULE : undefined;
|
|
43
|
+
},
|
|
44
|
+
load(id: string) {
|
|
45
|
+
if (id !== ASSETS_MODULE) return undefined;
|
|
46
|
+
const manifest = JSON.parse(readFileSync(join(clientOut, ".vite/manifest.json"), "utf8")) as Record<string, { file: string; css?: string[] }>;
|
|
47
|
+
const entry = manifest["app/client.ts"];
|
|
48
|
+
if (!entry) throw new Error("Build the public Gallery client first.");
|
|
49
|
+
const asset = (file: string, type: string) => ({ path: `/_app/${file.split("/").pop()}`, body: readFileSync(join(clientOut, file), "utf8"), type });
|
|
50
|
+
return `export const clientAssets = ${JSON.stringify({
|
|
51
|
+
script: asset(entry.file, "text/javascript; charset=utf-8"),
|
|
52
|
+
styles: (entry.css ?? []).map((file) => asset(file, "text/css; charset=utf-8")),
|
|
53
|
+
})};`;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const common = {
|
|
58
|
+
root: source,
|
|
59
|
+
configFile: false as const,
|
|
60
|
+
publicDir: false as const,
|
|
61
|
+
logLevel: "warn" as const,
|
|
62
|
+
};
|
|
63
|
+
await build({
|
|
64
|
+
...common,
|
|
65
|
+
plugins: [svelte({ configFile: false }), Icons({ compiler: "svelte" })],
|
|
66
|
+
build: {
|
|
67
|
+
outDir: clientOut,
|
|
68
|
+
emptyOutDir: true,
|
|
69
|
+
manifest: true,
|
|
70
|
+
target: "es2022",
|
|
71
|
+
rolldownOptions: {
|
|
72
|
+
input: "app/client.ts",
|
|
73
|
+
output: { entryFileNames: "assets/app-[hash].js", assetFileNames: "assets/app-[hash][extname]" },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
await build({
|
|
78
|
+
...common,
|
|
79
|
+
plugins: [svelte({ configFile: false }), Icons({ compiler: "svelte" }), clientAssets],
|
|
80
|
+
// Resolve Svelte's server runtime (its `worker` export), never the browser one.
|
|
81
|
+
ssr: { target: "webworker", noExternal: true, resolve: { conditions: ["worker", "workerd", "svelte", "module", "import", "default"] } },
|
|
82
|
+
build: {
|
|
83
|
+
ssr: "index.ts",
|
|
84
|
+
outDir: join(output, "dist", "worker"),
|
|
85
|
+
emptyOutDir: true,
|
|
86
|
+
target: "es2022",
|
|
87
|
+
minify: true,
|
|
88
|
+
// Workers provide AsyncLocalStorage under the `nodejs_als` flag.
|
|
89
|
+
rolldownOptions: { external: ["node:async_hooks"], output: { entryFileNames: "index.js", format: "es" } },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return output;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The stack a command works on: `--site <id>` when that site has its own stack, else the shared one. */
|
|
96
|
+
export async function stackFor(argv: string[]): Promise<string> {
|
|
97
|
+
const flag = argv.indexOf("--site");
|
|
98
|
+
const id = (flag >= 0 ? argv[flag + 1] : undefined) ?? "";
|
|
99
|
+
if (!id) return "shared";
|
|
100
|
+
const { sites } = await loadTenantConfig(undefined, { only: id });
|
|
101
|
+
const site = sites.find((candidate) => candidate.id === id);
|
|
102
|
+
if (!site) throw new Error(`Unknown site id "${id}"`);
|
|
103
|
+
return site.deployment ? id : "shared";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
107
|
+
stackFor(process.argv.slice(2))
|
|
108
|
+
.then(buildGalleryPublic)
|
|
109
|
+
.then((output) => console.log(`Built the public Gallery Worker in ${output}`))
|
|
110
|
+
.catch((error: unknown) => {
|
|
111
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The public calendar feed Worker's config (generated by `host-configs.ts`). */
|
|
2
|
+
import { siteBindingName } from '@hearthlog/contracts/calendar/feed-contract';
|
|
3
|
+
import type { InfrastructureDefinition } from '../tenants/tenant-config.js';
|
|
4
|
+
import { WRANGLER_BASE } from '../tenants/wrangler-base.js';
|
|
5
|
+
|
|
6
|
+
/** Rate-limit namespace ids only have to be unique in the account. */
|
|
7
|
+
const LIMITER = { name: 'FEEDS_LIMITER', namespace_id: '21002', simple: { limit: 60, period: 60 } };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The feed Worker: the feed bucket and one renderer service binding per site it serves (the sites of the shared stack,
|
|
11
|
+
* or the one site of an own stack). It holds nothing else of a site and has no Ledger credential.
|
|
12
|
+
*/
|
|
13
|
+
export function buildCalendarPublicConfig(infrastructure: InfrastructureDefinition, siteIds: readonly string[]) {
|
|
14
|
+
return {
|
|
15
|
+
name: infrastructure.calendarPublicWorker,
|
|
16
|
+
main: './index.ts',
|
|
17
|
+
compatibility_date: WRANGLER_BASE.compatibility_date,
|
|
18
|
+
workers_dev: true,
|
|
19
|
+
preview_urls: false,
|
|
20
|
+
// The feed URL carries the secret, and invocation logs record request paths.
|
|
21
|
+
observability: { enabled: false },
|
|
22
|
+
vars: { PUBLIC_ORIGIN: infrastructure.calendarPublicOrigin },
|
|
23
|
+
r2_buckets: [{ binding: 'CALENDAR_FEEDS', bucket_name: infrastructure.calendarFeedsBucket }],
|
|
24
|
+
services: siteIds.map((id) => ({ binding: siteBindingName(id), service: id, entrypoint: 'CalendarFeedRenderer' })),
|
|
25
|
+
ratelimits: [LIMITER],
|
|
26
|
+
};
|
|
27
|
+
}
|