@hearthlog/tooling 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/bin/hearthlog.mjs +52 -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 +194 -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 +167 -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 +136 -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/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 +110 -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
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant configuration loading, validation, and resolution for Node scripts.
|
|
3
|
+
*
|
|
4
|
+
* A site is defined in `tenants/<id>/tenant.json`. `tenants.json` at the repository root is optional: it holds
|
|
5
|
+
* the shared stack's `infrastructure` and the `defaults` for the sites that use it, and a repository whose sites all
|
|
6
|
+
* run on their own stacks has none. This module is the only place that reads and validates them;
|
|
7
|
+
* `packages/tooling/src/generate/generate-tenant.ts` and `packages/tooling/src/check/check-tenant-config.ts` build on
|
|
8
|
+
* it. It must stay free of browser-only imports: it runs under tsx in Node.
|
|
9
|
+
*
|
|
10
|
+
* Hostnames are the Cloudflare workers.dev defaults: a site's production
|
|
11
|
+
* Worker is named after its id, so its URL is
|
|
12
|
+
* `https://<id>.<infrastructure.workersDevDomain>`. An optional `hostname` override supports custom domains later.
|
|
13
|
+
*
|
|
14
|
+
* A site's directory `tenants/<id>/` holds its definition (`tenant.json`), optionally `deployment.json`, which
|
|
15
|
+
* gives it its own stack, and `modules.lock.json`, which records the modules it ships. `tenants.json` may still
|
|
16
|
+
* list sites in its `sites` array (the same fields as a `tenant.json`); a site named in both places is an error.
|
|
17
|
+
*
|
|
18
|
+
* A site with `tenants/<id>/deployment.json` runs on its own stack: its own
|
|
19
|
+
* Ledger Worker, buckets, and public Workers, all named from the file's
|
|
20
|
+
* `resourcePrefix`, in the account the file names. Every other site uses the
|
|
21
|
+
* shared `infrastructure` block.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { access, readdir, readFile } from "node:fs/promises";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { hostRoot } from "../root.js";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
FEATURES,
|
|
30
|
+
featureById,
|
|
31
|
+
isFeatureId,
|
|
32
|
+
type FeatureId,
|
|
33
|
+
} from "../../../contracts/src/features/registry.js";
|
|
34
|
+
import { IMPLICIT_FEATURE_IDS } from "../../../contracts/src/features/core.js";
|
|
35
|
+
import { AI_CAPABILITIES, isAiCapability, type AiCapability } from "../../../contracts/src/ai/capabilities.js";
|
|
36
|
+
import {
|
|
37
|
+
INTEGRATION_KINDS,
|
|
38
|
+
INTEGRATION_KIND_FEATURE,
|
|
39
|
+
isIntegrationClientId,
|
|
40
|
+
isIntegrationId,
|
|
41
|
+
isIntegrationKind,
|
|
42
|
+
isLogId,
|
|
43
|
+
type IntegrationDefinition,
|
|
44
|
+
} from "../../../contracts/src/integrations/kinds.js";
|
|
45
|
+
|
|
46
|
+
export const REPOSITORY_ROOT = hostRoot();
|
|
47
|
+
export const TENANTS_FILE = join(REPOSITORY_ROOT, "tenants.json");
|
|
48
|
+
export const TENANTS_DIRECTORY = join(REPOSITORY_ROOT, "tenants");
|
|
49
|
+
export const DEVELOPER_PATH = "/developer";
|
|
50
|
+
|
|
51
|
+
export interface SiteTheme {
|
|
52
|
+
/** Accent color as #rgb or #rrggbb; drives the derived palette. */
|
|
53
|
+
accent?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SiteDefinition {
|
|
57
|
+
id: string;
|
|
58
|
+
ledgerAppName?: string;
|
|
59
|
+
admin?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Every email allowed through the site's Cloudflare Access application.
|
|
62
|
+
* Must not be empty; entries are deduplicated case-insensitively and
|
|
63
|
+
* become the Allow policy and the Worker's STOR_AUTHORIZED_EMAILS
|
|
64
|
+
* variable.
|
|
65
|
+
*/
|
|
66
|
+
authorizedEmails: string[];
|
|
67
|
+
features?: FeatureId[];
|
|
68
|
+
/** AI decision capabilities the site enables; none by default. */
|
|
69
|
+
ai?: AiCapability[];
|
|
70
|
+
/** Service sources that append to one log of this site; none by default. */
|
|
71
|
+
integrations?: IntegrationDefinition[];
|
|
72
|
+
home?: string;
|
|
73
|
+
title?: string;
|
|
74
|
+
developerNavVisible?: boolean;
|
|
75
|
+
entitiesNavVisible?: boolean;
|
|
76
|
+
/** Whether the site may create dynamic entity types (meta-entities). */
|
|
77
|
+
dynamicEntities?: boolean;
|
|
78
|
+
theme?: SiteTheme;
|
|
79
|
+
hostname?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface InfrastructureDefinition {
|
|
83
|
+
/** Production Ledger worker behind the LEDGER service binding. */
|
|
84
|
+
ledgerWorker: string;
|
|
85
|
+
/** Private R2 bucket for cached invoice PDFs (bound on every site). */
|
|
86
|
+
invoicePdfsBucket: string;
|
|
87
|
+
/** Private R2 bucket for calendar feed registrations and ICS caches. */
|
|
88
|
+
calendarFeedsBucket: string;
|
|
89
|
+
/** Public Worker that serves calendar feeds on its own origin, with no Ledger credential. */
|
|
90
|
+
calendarPublicWorker: string;
|
|
91
|
+
calendarPublicOrigin: string;
|
|
92
|
+
/** Private app-scoped immutable Gallery originals and derivatives. */
|
|
93
|
+
galleryFilesBucket: string;
|
|
94
|
+
/** Curated public copies; never contains private originals. */
|
|
95
|
+
galleryPublicBucket: string;
|
|
96
|
+
galleryPublicWorker: string;
|
|
97
|
+
galleryPublicOrigin: string;
|
|
98
|
+
/** The account's workers.dev subdomain, for example `example.workers.dev`; Worker URLs derive from it. */
|
|
99
|
+
workersDevDomain: string;
|
|
100
|
+
/** The invoice renderer's HTTPS origin; Invoices answers 503 without one. */
|
|
101
|
+
invoiceRendererOrigin?: string;
|
|
102
|
+
/** The external food catalog's HTTPS origin; Health's catalog search is off without one. */
|
|
103
|
+
kalApiOrigin?: string;
|
|
104
|
+
/** Administrator email configured on every site's Worker. */
|
|
105
|
+
ledgerAdminEmail: string;
|
|
106
|
+
/** Shared Cloudflare Access team origin pushed to every site Worker. */
|
|
107
|
+
accessTeamDomain: string;
|
|
108
|
+
/** The Cloudflare account that holds the shared stack; `tenant:split declare` pins a moved site to it. */
|
|
109
|
+
accountId?: string;
|
|
110
|
+
/** Browser application and team-wide SSO lifetimes, in whole hours. */
|
|
111
|
+
accessSessionDuration?: string;
|
|
112
|
+
accessGlobalSessionDuration?: string;
|
|
113
|
+
/**
|
|
114
|
+
* The one site on the shared stack that sweeps its public Gallery bucket on a schedule (two sweepers would race).
|
|
115
|
+
* Defaults to the first site on the shared stack by id.
|
|
116
|
+
*/
|
|
117
|
+
gallerySweeper?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `tenants/<site>/deployment.json`: a site that runs on its own stack. */
|
|
121
|
+
export interface SiteDeployment {
|
|
122
|
+
schemaVersion: 1;
|
|
123
|
+
/**
|
|
124
|
+
* Prefix of every resource the site owns, starting with the site id, for
|
|
125
|
+
* example "jst-prod": `jst-prod-ledger`, `jst-prod-gallery-files`.
|
|
126
|
+
*/
|
|
127
|
+
resourcePrefix: string;
|
|
128
|
+
/** The site's Ledger Worker serves only this site. */
|
|
129
|
+
ledger: "dedicated";
|
|
130
|
+
/** The Cloudflare account the stack lives in; informational, checked against the CLI's login when it is set. */
|
|
131
|
+
accountId?: string;
|
|
132
|
+
/** The account's workers.dev subdomain when it differs from the shared infrastructure's. */
|
|
133
|
+
workersDevDomain?: string;
|
|
134
|
+
/** Cloudflare Access for this site, when its team differs from the shared infrastructure's. */
|
|
135
|
+
authentication?: { provider: "cloudflare-access"; teamDomain: string };
|
|
136
|
+
/** Account values the shared infrastructure otherwise supplies; a repository without `tenants.json` needs the first three. */
|
|
137
|
+
ledgerAdminEmail?: string;
|
|
138
|
+
invoiceRendererOrigin?: string;
|
|
139
|
+
kalApiOrigin?: string;
|
|
140
|
+
accessSessionDuration?: string;
|
|
141
|
+
accessGlobalSessionDuration?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface TenantFile {
|
|
145
|
+
/** Editor hint pointing at tenants.schema.json (`pnpm generate:tenant-schema`). */
|
|
146
|
+
$schema?: string;
|
|
147
|
+
infrastructure?: InfrastructureDefinition;
|
|
148
|
+
defaults?: Partial<
|
|
149
|
+
Pick<
|
|
150
|
+
SiteDefinition,
|
|
151
|
+
| "features"
|
|
152
|
+
| "home"
|
|
153
|
+
| "title"
|
|
154
|
+
| "developerNavVisible"
|
|
155
|
+
| "entitiesNavVisible"
|
|
156
|
+
>
|
|
157
|
+
>;
|
|
158
|
+
/** Sites defined here rather than in `tenants/<id>/tenant.json`. */
|
|
159
|
+
sites?: SiteDefinition[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ResolvedSite {
|
|
163
|
+
id: string;
|
|
164
|
+
ledgerAppName: string;
|
|
165
|
+
features: FeatureId[];
|
|
166
|
+
ai: AiCapability[];
|
|
167
|
+
integrations: IntegrationDefinition[];
|
|
168
|
+
home: string;
|
|
169
|
+
title: string;
|
|
170
|
+
developerNavVisible: boolean;
|
|
171
|
+
entitiesNavVisible: boolean;
|
|
172
|
+
dynamicEntities: boolean;
|
|
173
|
+
theme: SiteTheme;
|
|
174
|
+
admin: boolean;
|
|
175
|
+
/** Deduplicated case-insensitively, in declaration order. */
|
|
176
|
+
authorizedEmails: string[];
|
|
177
|
+
hostname: string;
|
|
178
|
+
/** Set when `tenants/<id>/deployment.json` gives the site its own stack. */
|
|
179
|
+
deployment: SiteDeployment | null;
|
|
180
|
+
/** The site came from `tenants/<id>/tenant.json` rather than `tenants.json`. */
|
|
181
|
+
fromTenantFile: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class TenantConfigError extends Error {
|
|
185
|
+
constructor(message: string) {
|
|
186
|
+
super(message);
|
|
187
|
+
this.name = "TenantConfigError";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const SITE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
192
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
193
|
+
|
|
194
|
+
const TOP_LEVEL_KEYS = ["$schema", "infrastructure", "defaults", "sites"] as const;
|
|
195
|
+
const DEFAULTS_KEYS = ["features", "home", "title", "developerNavVisible", "entitiesNavVisible"] as const;
|
|
196
|
+
const SITE_KEYS = [
|
|
197
|
+
"id",
|
|
198
|
+
"ledgerAppName",
|
|
199
|
+
"admin",
|
|
200
|
+
"authorizedEmails",
|
|
201
|
+
"features",
|
|
202
|
+
"ai",
|
|
203
|
+
"integrations",
|
|
204
|
+
"home",
|
|
205
|
+
"title",
|
|
206
|
+
"developerNavVisible",
|
|
207
|
+
"entitiesNavVisible",
|
|
208
|
+
"dynamicEntities",
|
|
209
|
+
"theme",
|
|
210
|
+
"hostname",
|
|
211
|
+
] as const satisfies readonly (keyof SiteDefinition)[];
|
|
212
|
+
const DEPLOYMENT_KEYS = [
|
|
213
|
+
"$schema", "schemaVersion", "resourcePrefix", "ledger", "accountId", "workersDevDomain", "authentication",
|
|
214
|
+
"ledgerAdminEmail", "invoiceRendererOrigin", "kalApiOrigin", "accessSessionDuration", "accessGlobalSessionDuration",
|
|
215
|
+
] as const;
|
|
216
|
+
const TENANT_FILE_KEYS = ["$schema", "schemaVersion", ...SITE_KEYS] as const;
|
|
217
|
+
const WORKERS_DEV_PATTERN = /^[a-z0-9][a-z0-9-]*\.workers\.dev$/;
|
|
218
|
+
const ACCOUNT_ID_PATTERN = /^[0-9a-f]{32}$/;
|
|
219
|
+
const RESOURCE_PREFIX_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
220
|
+
const THEME_KEYS = ["accent"] as const satisfies readonly (keyof SiteTheme)[];
|
|
221
|
+
const INTEGRATION_KEYS = ["id", "kind", "logId", "clientId"] as const satisfies readonly (keyof IntegrationDefinition)[];
|
|
222
|
+
const REQUIRED_INFRASTRUCTURE_KEYS = [
|
|
223
|
+
"ledgerWorker",
|
|
224
|
+
"invoicePdfsBucket",
|
|
225
|
+
"calendarFeedsBucket",
|
|
226
|
+
"calendarPublicWorker",
|
|
227
|
+
"calendarPublicOrigin",
|
|
228
|
+
"galleryFilesBucket",
|
|
229
|
+
"galleryPublicBucket",
|
|
230
|
+
"galleryPublicWorker",
|
|
231
|
+
"galleryPublicOrigin",
|
|
232
|
+
"workersDevDomain",
|
|
233
|
+
"ledgerAdminEmail",
|
|
234
|
+
"accessTeamDomain",
|
|
235
|
+
] as const satisfies readonly (keyof InfrastructureDefinition)[];
|
|
236
|
+
const INFRASTRUCTURE_KEYS = [
|
|
237
|
+
...REQUIRED_INFRASTRUCTURE_KEYS,
|
|
238
|
+
"invoiceRendererOrigin",
|
|
239
|
+
"kalApiOrigin",
|
|
240
|
+
"accessSessionDuration",
|
|
241
|
+
"accessGlobalSessionDuration",
|
|
242
|
+
"gallerySweeper",
|
|
243
|
+
"accountId",
|
|
244
|
+
] as const satisfies readonly (keyof InfrastructureDefinition)[];
|
|
245
|
+
|
|
246
|
+
/** Reject keys the loader would silently ignore, such as a misspelt "titel". */
|
|
247
|
+
function rejectUnknownKeys(label: string, value: unknown, known: readonly string[]): void {
|
|
248
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
249
|
+
throw new TenantConfigError(`${label} must be an object`);
|
|
250
|
+
}
|
|
251
|
+
for (const key of Object.keys(value)) {
|
|
252
|
+
if (!known.includes(key)) {
|
|
253
|
+
throw new TenantConfigError(
|
|
254
|
+
`${label}: unknown key "${key}" (known keys: ${known.join(", ")})`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function featurePath(featureId: FeatureId): string {
|
|
261
|
+
return featureById(featureId).page?.path ?? "/";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isPathEnabled(
|
|
265
|
+
features: readonly FeatureId[],
|
|
266
|
+
path: string,
|
|
267
|
+
): boolean {
|
|
268
|
+
if (path === "/") return true;
|
|
269
|
+
if (path === DEVELOPER_PATH) return true;
|
|
270
|
+
return features.some((id) => featurePath(id) === path);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Which stack a site runs on, known without resolving it: a build resolves only its own site. */
|
|
274
|
+
export interface SiteStack {
|
|
275
|
+
id: string;
|
|
276
|
+
shared: boolean;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Stands in for the shared infrastructure when there is no `tenants.json`. Every site then has a
|
|
281
|
+
* `deployment.json`, which supplies all it needs, so none of these empty names is ever read.
|
|
282
|
+
*/
|
|
283
|
+
const NO_SHARED_INFRASTRUCTURE: InfrastructureDefinition = Object.fromEntries(
|
|
284
|
+
REQUIRED_INFRASTRUCTURE_KEYS.map((key) => [key, ""]),
|
|
285
|
+
) as unknown as InfrastructureDefinition;
|
|
286
|
+
|
|
287
|
+
/** Parse, validate, and resolve `tenants.json` (when there is one) and the sites in `tenants/`. */
|
|
288
|
+
export async function loadTenantConfig(
|
|
289
|
+
filePath: string = TENANTS_FILE,
|
|
290
|
+
/** `only` resolves and validates one site: a build knows the modules of its own site only. */
|
|
291
|
+
options: { only?: string } = {},
|
|
292
|
+
): Promise<{
|
|
293
|
+
raw: TenantFile;
|
|
294
|
+
infrastructure: InfrastructureDefinition;
|
|
295
|
+
sites: ResolvedSite[];
|
|
296
|
+
/** The stack of every site, including those `only` leaves unresolved. */
|
|
297
|
+
stacks: SiteStack[];
|
|
298
|
+
}> {
|
|
299
|
+
let raw: TenantFile;
|
|
300
|
+
let shared = true;
|
|
301
|
+
try {
|
|
302
|
+
raw = JSON.parse(await readFile(filePath, "utf8")) as TenantFile;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
305
|
+
throw new TenantConfigError(
|
|
306
|
+
`Cannot read tenant config at ${filePath}: ${
|
|
307
|
+
error instanceof Error ? error.message : String(error)
|
|
308
|
+
}`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
raw = { sites: [] };
|
|
312
|
+
shared = false;
|
|
313
|
+
}
|
|
314
|
+
rejectUnknownKeys("tenants.json", raw, TOP_LEVEL_KEYS);
|
|
315
|
+
if (raw.defaults !== undefined) {
|
|
316
|
+
rejectUnknownKeys("tenants.json defaults", raw.defaults, DEFAULTS_KEYS);
|
|
317
|
+
}
|
|
318
|
+
if (raw.sites !== undefined && !Array.isArray(raw.sites)) {
|
|
319
|
+
throw new TenantConfigError('tenants.json "sites" must be an array');
|
|
320
|
+
}
|
|
321
|
+
const infrastructure = shared ? resolveInfrastructure(raw) : NO_SHARED_INFRASTRUCTURE;
|
|
322
|
+
const definitions: Array<{ site: SiteDefinition; fromTenantFile: boolean }> = (raw.sites ?? []).map((site) => ({ site, fromTenantFile: false }));
|
|
323
|
+
const tenantsDirectory = filePath === TENANTS_FILE ? TENANTS_DIRECTORY : join(dirname(filePath), "tenants");
|
|
324
|
+
for (const site of await loadTenantFiles(tenantsDirectory)) {
|
|
325
|
+
if (definitions.some((existing) => existing.site.id === site.id)) {
|
|
326
|
+
throw new TenantConfigError(`Site "${site.id}" is defined in both tenants.json and tenants/${site.id}/tenant.json`);
|
|
327
|
+
}
|
|
328
|
+
definitions.push({ site, fromTenantFile: true });
|
|
329
|
+
}
|
|
330
|
+
const sites: ResolvedSite[] = [];
|
|
331
|
+
const stacks: SiteStack[] = [];
|
|
332
|
+
for (const { site, fromTenantFile } of definitions) {
|
|
333
|
+
const own = await access(siteDeploymentPath(site.id, tenantsDirectory)).then(() => true, () => false);
|
|
334
|
+
stacks.push({ id: site.id, shared: !own });
|
|
335
|
+
if (!shared && !own) {
|
|
336
|
+
throw new TenantConfigError(
|
|
337
|
+
`Site "${site.id}" has no tenants/${site.id}/deployment.json and there is no tenants.json to give it shared infrastructure`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (options.only !== undefined && site.id !== options.only) continue;
|
|
341
|
+
// A build holds the modules of its own site only (VITE_STOR_TENANT), so only that site is checked against them.
|
|
342
|
+
const active = process.env.VITE_STOR_TENANT?.trim();
|
|
343
|
+
const resolved = resolveSite(raw.defaults, site, infrastructure.workersDevDomain, { lenient: Boolean(active) && site.id !== active });
|
|
344
|
+
const deployment = await loadSiteDeployment(resolved, tenantsDirectory, { standalone: !shared });
|
|
345
|
+
// An account of its own brings its own workers.dev subdomain, unless the site names a hostname.
|
|
346
|
+
const domain = deployment?.workersDevDomain;
|
|
347
|
+
sites.push({
|
|
348
|
+
...resolved,
|
|
349
|
+
deployment,
|
|
350
|
+
fromTenantFile,
|
|
351
|
+
hostname: site.hostname ?? (domain ? `https://${site.id}.${domain}` : resolved.hostname),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (infrastructure.gallerySweeper !== undefined && !stacks.some((stack) => stack.shared && stack.id === infrastructure.gallerySweeper)) {
|
|
355
|
+
throw new TenantConfigError(`tenants.json infrastructure.gallerySweeper "${infrastructure.gallerySweeper}" is not a site on the shared stack`);
|
|
356
|
+
}
|
|
357
|
+
return { raw, infrastructure, sites, stacks };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The shared stack's one site that sweeps its public Gallery bucket on a schedule: the configured one, else the
|
|
362
|
+
* first by id. Sites on their own stacks sweep their own buckets.
|
|
363
|
+
*/
|
|
364
|
+
export function gallerySweeperId(stacks: readonly SiteStack[], infrastructure: Pick<InfrastructureDefinition, "gallerySweeper">): string | undefined {
|
|
365
|
+
return infrastructure.gallerySweeper ?? stacks.filter((stack) => stack.shared).map((stack) => stack.id).sort()[0];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** `tenants/<id>/tenant.json` files: a site's definition kept next to its deployment file. */
|
|
369
|
+
async function loadTenantFiles(directory: string): Promise<SiteDefinition[]> {
|
|
370
|
+
let names: string[];
|
|
371
|
+
try {
|
|
372
|
+
names = (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
373
|
+
} catch {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
const definitions: SiteDefinition[] = [];
|
|
377
|
+
for (const name of names) {
|
|
378
|
+
const path = join(directory, name, "tenant.json");
|
|
379
|
+
let value: unknown;
|
|
380
|
+
try {
|
|
381
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
384
|
+
throw new TenantConfigError(`Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
385
|
+
}
|
|
386
|
+
const label = `tenants/${name}/tenant.json`;
|
|
387
|
+
rejectUnknownKeys(label, value, TENANT_FILE_KEYS);
|
|
388
|
+
const { schemaVersion, $schema: _schema, ...site } = value as { schemaVersion?: unknown; $schema?: unknown } & SiteDefinition;
|
|
389
|
+
if (schemaVersion !== 1) throw new TenantConfigError(`${label}: schemaVersion must be 1`);
|
|
390
|
+
if (site.id !== name) throw new TenantConfigError(`${label}: id must be "${name}", the name of its directory`);
|
|
391
|
+
definitions.push(site);
|
|
392
|
+
}
|
|
393
|
+
return definitions;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The loaded site with this id; throws when the id is unknown. */
|
|
397
|
+
export function siteById(sites: readonly ResolvedSite[], siteId: string): ResolvedSite {
|
|
398
|
+
const site = sites.find((candidate) => candidate.id === siteId);
|
|
399
|
+
if (!site) {
|
|
400
|
+
throw new TenantConfigError(
|
|
401
|
+
`Unknown site id "${siteId}" (known ids: ${sites.map((candidate) => candidate.id).join(", ")})`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
return site;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function siteDeploymentPath(siteId: string, directory: string = TENANTS_DIRECTORY): string {
|
|
408
|
+
return join(directory, siteId, "deployment.json");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Read and validate `tenants/<id>/deployment.json`; null when the site has none. */
|
|
412
|
+
export async function loadSiteDeployment(
|
|
413
|
+
site: ResolvedSite,
|
|
414
|
+
directory: string = TENANTS_DIRECTORY,
|
|
415
|
+
/** No `tenants.json`: the file must then carry the account values the shared infrastructure would. */
|
|
416
|
+
options: { standalone?: boolean } = {},
|
|
417
|
+
): Promise<SiteDeployment | null> {
|
|
418
|
+
const path = siteDeploymentPath(site.id, directory);
|
|
419
|
+
try {
|
|
420
|
+
await access(path);
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
let value: unknown;
|
|
425
|
+
try {
|
|
426
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
throw new TenantConfigError(
|
|
429
|
+
`Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
return parseSiteDeployment(site, value, options);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function parseSiteDeployment(
|
|
436
|
+
site: Pick<ResolvedSite, "id">,
|
|
437
|
+
value: unknown,
|
|
438
|
+
options: { standalone?: boolean } = {},
|
|
439
|
+
): SiteDeployment {
|
|
440
|
+
const label = `tenants/${site.id}/deployment.json`;
|
|
441
|
+
rejectUnknownKeys(label, value, DEPLOYMENT_KEYS);
|
|
442
|
+
const deployment = value as Partial<SiteDeployment>;
|
|
443
|
+
if (deployment.schemaVersion !== 1) {
|
|
444
|
+
throw new TenantConfigError(`${label}: schemaVersion must be 1`);
|
|
445
|
+
}
|
|
446
|
+
const prefix = deployment.resourcePrefix;
|
|
447
|
+
if (typeof prefix !== "string" || !RESOURCE_PREFIX_PATTERN.test(prefix) || !prefix.startsWith(`${site.id}-`)) {
|
|
448
|
+
throw new TenantConfigError(
|
|
449
|
+
`${label}: resourcePrefix must start with "${site.id}-" and use lowercase letters, digits, and hyphens (up to 40 characters)`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (deployment.ledger !== "dedicated") {
|
|
453
|
+
throw new TenantConfigError(`${label}: ledger must be "dedicated"`);
|
|
454
|
+
}
|
|
455
|
+
const result: SiteDeployment = { schemaVersion: 1, resourcePrefix: prefix, ledger: "dedicated" };
|
|
456
|
+
if (deployment.accountId !== undefined) {
|
|
457
|
+
if (typeof deployment.accountId !== "string" || !ACCOUNT_ID_PATTERN.test(deployment.accountId)) {
|
|
458
|
+
throw new TenantConfigError(`${label}: accountId must be a 32-character hexadecimal Cloudflare account id`);
|
|
459
|
+
}
|
|
460
|
+
result.accountId = deployment.accountId;
|
|
461
|
+
}
|
|
462
|
+
if (deployment.workersDevDomain !== undefined) {
|
|
463
|
+
if (typeof deployment.workersDevDomain !== "string" || !WORKERS_DEV_PATTERN.test(deployment.workersDevDomain)) {
|
|
464
|
+
throw new TenantConfigError(`${label}: workersDevDomain must look like "your-subdomain.workers.dev"`);
|
|
465
|
+
}
|
|
466
|
+
result.workersDevDomain = deployment.workersDevDomain;
|
|
467
|
+
}
|
|
468
|
+
if (deployment.authentication !== undefined) {
|
|
469
|
+
rejectUnknownKeys(`${label} authentication`, deployment.authentication, ["provider", "teamDomain"]);
|
|
470
|
+
if (deployment.authentication.provider !== "cloudflare-access") {
|
|
471
|
+
throw new TenantConfigError(`${label}: authentication.provider must be "cloudflare-access"`);
|
|
472
|
+
}
|
|
473
|
+
requireHttpsOrigin(`${label} authentication.teamDomain`, deployment.authentication.teamDomain);
|
|
474
|
+
result.authentication = { provider: "cloudflare-access", teamDomain: deployment.authentication.teamDomain };
|
|
475
|
+
}
|
|
476
|
+
if (deployment.ledgerAdminEmail !== undefined) {
|
|
477
|
+
if (typeof deployment.ledgerAdminEmail !== "string" || !EMAIL_PATTERN.test(deployment.ledgerAdminEmail)) {
|
|
478
|
+
throw new TenantConfigError(`${label}: ledgerAdminEmail must be an email address`);
|
|
479
|
+
}
|
|
480
|
+
result.ledgerAdminEmail = deployment.ledgerAdminEmail;
|
|
481
|
+
}
|
|
482
|
+
for (const key of ["invoiceRendererOrigin", "kalApiOrigin"] as const) {
|
|
483
|
+
const origin = deployment[key];
|
|
484
|
+
if (origin === undefined) continue;
|
|
485
|
+
requireHttpsOrigin(`${label} ${key}`, origin);
|
|
486
|
+
result[key] = origin;
|
|
487
|
+
}
|
|
488
|
+
for (const key of ["accessSessionDuration", "accessGlobalSessionDuration"] as const) {
|
|
489
|
+
const duration = deployment[key];
|
|
490
|
+
if (duration === undefined) continue;
|
|
491
|
+
requireSessionDuration(`${label} ${key}`, duration);
|
|
492
|
+
result[key] = duration;
|
|
493
|
+
}
|
|
494
|
+
if (options.standalone) {
|
|
495
|
+
for (const key of ["workersDevDomain", "ledgerAdminEmail", "authentication"] as const) {
|
|
496
|
+
if (result[key] === undefined) throw new TenantConfigError(`${label}: ${key} is required when there is no tenants.json`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function requireSessionDuration(label: string, duration: unknown): asserts duration is string {
|
|
503
|
+
if (typeof duration !== "string" || !/^[1-9]\d*h$/.test(duration) || Number(duration.slice(0, -1)) > 720) {
|
|
504
|
+
throw new TenantConfigError(`${label} must be whole hours between 1h and 720h`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function requireHttpsOrigin(label: string, value: unknown): asserts value is string {
|
|
509
|
+
let url: URL | null = null;
|
|
510
|
+
try {
|
|
511
|
+
url = typeof value === "string" ? new URL(value) : null;
|
|
512
|
+
} catch {
|
|
513
|
+
url = null;
|
|
514
|
+
}
|
|
515
|
+
if (!url || url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
516
|
+
throw new TenantConfigError(`${label} must be an HTTPS origin`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The workers.dev subdomain a site's Workers live under: its own account's, else the shared one. */
|
|
521
|
+
export function workersDevDomainFor(site: Pick<ResolvedSite, "deployment">, shared: Pick<InfrastructureDefinition, "workersDevDomain">): string {
|
|
522
|
+
return site.deployment?.workersDevDomain ?? shared.workersDevDomain;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The resources a site's Workers bind to. A site on its own stack derives
|
|
527
|
+
* every name from its resource prefix. Account-wide settings stay shared.
|
|
528
|
+
*/
|
|
529
|
+
export function infrastructureFor(
|
|
530
|
+
site: Pick<ResolvedSite, "deployment">,
|
|
531
|
+
shared: InfrastructureDefinition,
|
|
532
|
+
): InfrastructureDefinition {
|
|
533
|
+
if (!site.deployment) return shared;
|
|
534
|
+
const prefix = site.deployment.resourcePrefix;
|
|
535
|
+
const origin = (worker: string) => `https://${worker}.${workersDevDomainFor(site, shared)}`;
|
|
536
|
+
const names = {
|
|
537
|
+
ledgerWorker: `${prefix}-ledger`,
|
|
538
|
+
invoicePdfsBucket: `${prefix}-invoice-pdfs`,
|
|
539
|
+
calendarFeedsBucket: `${prefix}-calendar-feeds`,
|
|
540
|
+
calendarPublicWorker: `${prefix}-calendar-public`,
|
|
541
|
+
calendarPublicOrigin: origin(`${prefix}-calendar-public`),
|
|
542
|
+
galleryFilesBucket: `${prefix}-gallery-files`,
|
|
543
|
+
galleryPublicBucket: `${prefix}-gallery-public`,
|
|
544
|
+
galleryPublicWorker: `${prefix}-gallery-public`,
|
|
545
|
+
galleryPublicOrigin: origin(`${prefix}-gallery-public`),
|
|
546
|
+
};
|
|
547
|
+
return {
|
|
548
|
+
...shared,
|
|
549
|
+
...names,
|
|
550
|
+
workersDevDomain: workersDevDomainFor(site, shared),
|
|
551
|
+
...(site.deployment.authentication ? { accessTeamDomain: site.deployment.authentication.teamDomain } : {}),
|
|
552
|
+
...(site.deployment.ledgerAdminEmail ? { ledgerAdminEmail: site.deployment.ledgerAdminEmail } : {}),
|
|
553
|
+
...(site.deployment.invoiceRendererOrigin ? { invoiceRendererOrigin: site.deployment.invoiceRendererOrigin } : {}),
|
|
554
|
+
...(site.deployment.kalApiOrigin ? { kalApiOrigin: site.deployment.kalApiOrigin } : {}),
|
|
555
|
+
...(site.deployment.accessSessionDuration ? { accessSessionDuration: site.deployment.accessSessionDuration } : {}),
|
|
556
|
+
...(site.deployment.accessGlobalSessionDuration ? { accessGlobalSessionDuration: site.deployment.accessGlobalSessionDuration } : {}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Sites served by the shared Ledger, buckets, and public Workers. */
|
|
561
|
+
export function sharedSites(sites: readonly ResolvedSite[]): ResolvedSite[] {
|
|
562
|
+
return sites.filter((site) => !site.deployment);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Validate one site definition and resolve it against the defaults. */
|
|
566
|
+
export function resolveSite(
|
|
567
|
+
defaults: TenantFile["defaults"] | undefined,
|
|
568
|
+
site: SiteDefinition,
|
|
569
|
+
workersDevDomain: string,
|
|
570
|
+
/**
|
|
571
|
+
* Another site's features may be unknown to the catalog this build holds (it has only its own site's modules): keep
|
|
572
|
+
* them as written and skip the checks that need them.
|
|
573
|
+
*/
|
|
574
|
+
options: { lenient?: boolean } = {},
|
|
575
|
+
): ResolvedSite {
|
|
576
|
+
const label = `site "${site.id ?? "<missing id>"}"`;
|
|
577
|
+
rejectUnknownKeys(label, site, SITE_KEYS);
|
|
578
|
+
if (site.theme !== undefined) rejectUnknownKeys(`${label} theme`, site.theme, THEME_KEYS);
|
|
579
|
+
if (!site.id || !SITE_ID_PATTERN.test(site.id)) {
|
|
580
|
+
throw new TenantConfigError(
|
|
581
|
+
`${label}: id must match ${SITE_ID_PATTERN} (it becomes the ` +
|
|
582
|
+
`wrangler env name, the Worker name, and the workers.dev ` +
|
|
583
|
+
`hostname)`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (!site.authorizedEmails || site.authorizedEmails.length === 0) {
|
|
587
|
+
throw new TenantConfigError(
|
|
588
|
+
`${label}: authorizedEmails must list at least one email address`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const seenEmails = new Set<string>();
|
|
593
|
+
const authorizedEmails: string[] = [];
|
|
594
|
+
for (const email of site.authorizedEmails) {
|
|
595
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
596
|
+
throw new TenantConfigError(
|
|
597
|
+
`${label}: authorizedEmails entry "${email}" is not a valid email address`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const key = email.toLowerCase();
|
|
601
|
+
if (seenEmails.has(key)) {
|
|
602
|
+
throw new TenantConfigError(
|
|
603
|
+
`${label}: authorizedEmails contains a duplicate entry "${email}"`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
seenEmails.add(key);
|
|
607
|
+
authorizedEmails.push(email);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const listed = site.features ?? defaults?.features;
|
|
611
|
+
if (!listed) {
|
|
612
|
+
throw new TenantConfigError(`${label}: features must be listed (an empty list is fine: Calendar is always on)`);
|
|
613
|
+
}
|
|
614
|
+
for (const featureId of listed) {
|
|
615
|
+
if (!options.lenient && !isFeatureId(featureId)) {
|
|
616
|
+
throw new TenantConfigError(
|
|
617
|
+
`${label}: unknown feature id "${featureId}" (known ids: ` +
|
|
618
|
+
`${FEATURES.map((feature) => feature.id).join(", ")})`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (new Set(listed).size !== listed.length) {
|
|
623
|
+
throw new TenantConfigError(`${label}: duplicate feature ids`);
|
|
624
|
+
}
|
|
625
|
+
// Implicit features are always on, whether or not the list names them.
|
|
626
|
+
const features = [...listed.filter((id) => !(IMPLICIT_FEATURE_IDS as readonly string[]).includes(id)), ...IMPLICIT_FEATURE_IDS];
|
|
627
|
+
|
|
628
|
+
const ai = site.ai ?? [];
|
|
629
|
+
for (const capability of ai) {
|
|
630
|
+
if (!options.lenient && !isAiCapability(capability)) {
|
|
631
|
+
throw new TenantConfigError(
|
|
632
|
+
`${label}: unknown ai capability "${capability}" (known ids: ` +
|
|
633
|
+
`${AI_CAPABILITIES.join(", ")})`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (new Set(ai).size !== ai.length) {
|
|
638
|
+
throw new TenantConfigError(`${label}: duplicate ai capabilities`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const integrations = resolveIntegrations(label, site.integrations ?? [], features);
|
|
642
|
+
|
|
643
|
+
const home = site.home ?? defaults?.home ?? "/";
|
|
644
|
+
if (!options.lenient && !isPathEnabled(features, home)) {
|
|
645
|
+
throw new TenantConfigError(
|
|
646
|
+
`${label}: home "${home}" is not an enabled feature of this site`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const title = site.title ?? defaults?.title ?? "stor";
|
|
651
|
+
const developerNavVisible =
|
|
652
|
+
site.developerNavVisible ?? defaults?.developerNavVisible ?? true;
|
|
653
|
+
const entitiesNavVisible =
|
|
654
|
+
site.entitiesNavVisible ?? defaults?.entitiesNavVisible ?? true;
|
|
655
|
+
const dynamicEntities = site.dynamicEntities ?? false;
|
|
656
|
+
|
|
657
|
+
const theme: SiteTheme = {};
|
|
658
|
+
const accent = site.theme?.accent;
|
|
659
|
+
if (accent !== undefined) {
|
|
660
|
+
if (!/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(accent)) {
|
|
661
|
+
throw new TenantConfigError(
|
|
662
|
+
`${label}: theme.accent "${accent}" must be a #rgb or #rrggbb color`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
theme.accent = accent.toLowerCase();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const hostname = site.hostname ?? `https://${site.id}.${workersDevDomain}`;
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
id: site.id,
|
|
672
|
+
ledgerAppName: site.ledgerAppName ?? site.id,
|
|
673
|
+
features: [...features],
|
|
674
|
+
ai: [...ai],
|
|
675
|
+
integrations,
|
|
676
|
+
home,
|
|
677
|
+
title,
|
|
678
|
+
developerNavVisible,
|
|
679
|
+
entitiesNavVisible,
|
|
680
|
+
dynamicEntities,
|
|
681
|
+
theme,
|
|
682
|
+
admin: site.admin ?? false,
|
|
683
|
+
authorizedEmails,
|
|
684
|
+
hostname,
|
|
685
|
+
deployment: null,
|
|
686
|
+
fromTenantFile: false,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function resolveIntegrations(
|
|
691
|
+
label: string,
|
|
692
|
+
integrations: readonly IntegrationDefinition[],
|
|
693
|
+
features: readonly FeatureId[],
|
|
694
|
+
): IntegrationDefinition[] {
|
|
695
|
+
const resolved: IntegrationDefinition[] = [];
|
|
696
|
+
const seenIds = new Set<string>();
|
|
697
|
+
const seenClients = new Set<string>();
|
|
698
|
+
for (const integration of integrations) {
|
|
699
|
+
const name = `${label}: integration "${integration?.id ?? "<missing id>"}"`;
|
|
700
|
+
rejectUnknownKeys(name, integration, INTEGRATION_KEYS);
|
|
701
|
+
if (!isIntegrationId(integration?.id)) {
|
|
702
|
+
throw new TenantConfigError(
|
|
703
|
+
`${name}: id must be lowercase letters, digits and hyphens (up to 40 characters)`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (seenIds.has(integration.id)) {
|
|
707
|
+
throw new TenantConfigError(`${name}: duplicate integration id`);
|
|
708
|
+
}
|
|
709
|
+
seenIds.add(integration.id);
|
|
710
|
+
if (!isIntegrationKind(integration.kind)) {
|
|
711
|
+
throw new TenantConfigError(
|
|
712
|
+
`${name}: unknown kind "${String(integration.kind)}" (known kinds: ` +
|
|
713
|
+
`${INTEGRATION_KINDS.join(", ")})`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const requiredFeature = INTEGRATION_KIND_FEATURE[integration.kind];
|
|
717
|
+
if (!features.includes(requiredFeature)) {
|
|
718
|
+
throw new TenantConfigError(
|
|
719
|
+
`${name}: kind "${integration.kind}" needs the "${requiredFeature}" feature enabled`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (!isLogId(integration.logId)) {
|
|
723
|
+
throw new TenantConfigError(`${name}: logId must be a UUID`);
|
|
724
|
+
}
|
|
725
|
+
if (integration.clientId !== undefined) {
|
|
726
|
+
if (!isIntegrationClientId(integration.clientId)) {
|
|
727
|
+
throw new TenantConfigError(`${name}: clientId is not a valid Access service-token client id`);
|
|
728
|
+
}
|
|
729
|
+
if (seenClients.has(integration.clientId)) {
|
|
730
|
+
throw new TenantConfigError(`${name}: clientId is already used by another integration`);
|
|
731
|
+
}
|
|
732
|
+
seenClients.add(integration.clientId);
|
|
733
|
+
}
|
|
734
|
+
resolved.push({
|
|
735
|
+
id: integration.id,
|
|
736
|
+
kind: integration.kind,
|
|
737
|
+
logId: integration.logId.toLowerCase(),
|
|
738
|
+
...(integration.clientId ? { clientId: integration.clientId } : {}),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
return resolved;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Resolve one site by id; throws when the id is unknown. */
|
|
745
|
+
export function resolveSiteById(
|
|
746
|
+
raw: TenantFile,
|
|
747
|
+
siteId: string,
|
|
748
|
+
): ResolvedSite {
|
|
749
|
+
const site = (raw.sites ?? []).find((candidate) => candidate.id === siteId);
|
|
750
|
+
if (!site) {
|
|
751
|
+
throw new TenantConfigError(
|
|
752
|
+
`Unknown site id "${siteId}" (known ids: ` +
|
|
753
|
+
`${(raw.sites ?? []).map((candidate) => candidate.id).join(", ")})`,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
return resolveSite(raw.defaults, site, resolveInfrastructure(raw).workersDevDomain);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Validate the shared infrastructure identifiers. */
|
|
760
|
+
export function resolveInfrastructure(
|
|
761
|
+
raw: TenantFile,
|
|
762
|
+
): InfrastructureDefinition {
|
|
763
|
+
const infrastructure = raw.infrastructure;
|
|
764
|
+
if (!infrastructure) {
|
|
765
|
+
throw new TenantConfigError(
|
|
766
|
+
"tenants.json is missing the required \"infrastructure\" block " +
|
|
767
|
+
"(ledgerWorker, invoicePdfsBucket, calendarFeedsBucket, " +
|
|
768
|
+
"calendarPublicWorker, calendarPublicOrigin, galleryFilesBucket, " +
|
|
769
|
+
"galleryPublicBucket, galleryPublicWorker, galleryPublicOrigin, " +
|
|
770
|
+
"workersDevDomain, ledgerAdminEmail, " +
|
|
771
|
+
"accessTeamDomain)",
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
rejectUnknownKeys("tenants.json infrastructure", infrastructure, INFRASTRUCTURE_KEYS);
|
|
775
|
+
for (const key of REQUIRED_INFRASTRUCTURE_KEYS) {
|
|
776
|
+
const value = infrastructure[key];
|
|
777
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
778
|
+
throw new TenantConfigError(
|
|
779
|
+
`tenants.json infrastructure.${key} must be a non-empty string`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
if (
|
|
783
|
+
key === "accessTeamDomain" ||
|
|
784
|
+
key === "galleryPublicOrigin" ||
|
|
785
|
+
key === "calendarPublicOrigin"
|
|
786
|
+
) {
|
|
787
|
+
let url: URL;
|
|
788
|
+
try {
|
|
789
|
+
url = new URL(value);
|
|
790
|
+
} catch {
|
|
791
|
+
throw new TenantConfigError(
|
|
792
|
+
`tenants.json infrastructure.${key} must be an ` +
|
|
793
|
+
"HTTPS origin",
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
if (
|
|
797
|
+
url.protocol !== "https:" ||
|
|
798
|
+
url.username ||
|
|
799
|
+
url.password ||
|
|
800
|
+
url.search ||
|
|
801
|
+
url.hash ||
|
|
802
|
+
url.pathname !== "/"
|
|
803
|
+
) {
|
|
804
|
+
throw new TenantConfigError(
|
|
805
|
+
`tenants.json infrastructure.${key} must be an ` +
|
|
806
|
+
"HTTPS origin",
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (!WORKERS_DEV_PATTERN.test(infrastructure.workersDevDomain)) {
|
|
812
|
+
throw new TenantConfigError('tenants.json infrastructure.workersDevDomain must look like "your-subdomain.workers.dev"');
|
|
813
|
+
}
|
|
814
|
+
if (infrastructure.invoiceRendererOrigin !== undefined) {
|
|
815
|
+
requireHttpsOrigin("tenants.json infrastructure.invoiceRendererOrigin", infrastructure.invoiceRendererOrigin);
|
|
816
|
+
}
|
|
817
|
+
if (infrastructure.kalApiOrigin !== undefined) {
|
|
818
|
+
requireHttpsOrigin("tenants.json infrastructure.kalApiOrigin", infrastructure.kalApiOrigin);
|
|
819
|
+
}
|
|
820
|
+
if (infrastructure.accountId !== undefined && (typeof infrastructure.accountId !== "string" || !ACCOUNT_ID_PATTERN.test(infrastructure.accountId))) {
|
|
821
|
+
throw new TenantConfigError("tenants.json infrastructure.accountId must be a 32-character hexadecimal Cloudflare account id");
|
|
822
|
+
}
|
|
823
|
+
for (const key of ['accessSessionDuration', 'accessGlobalSessionDuration'] as const) {
|
|
824
|
+
const duration = infrastructure[key];
|
|
825
|
+
if (duration !== undefined) requireSessionDuration(`tenants.json infrastructure.${key}`, duration);
|
|
826
|
+
}
|
|
827
|
+
return infrastructure;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* A site on its own stack must not reuse a name of the shared stack or of
|
|
832
|
+
* another site's stack: a binding would then reach someone else's data.
|
|
833
|
+
*/
|
|
834
|
+
export function validateDeploymentIsolation(
|
|
835
|
+
sites: readonly ResolvedSite[],
|
|
836
|
+
shared: InfrastructureDefinition,
|
|
837
|
+
): void {
|
|
838
|
+
const resourceKeys = [
|
|
839
|
+
"ledgerWorker", "invoicePdfsBucket", "calendarFeedsBucket", "calendarPublicWorker",
|
|
840
|
+
"galleryFilesBucket", "galleryPublicBucket", "galleryPublicWorker",
|
|
841
|
+
] as const;
|
|
842
|
+
const owners = new Map<string, string>();
|
|
843
|
+
const claim = (name: string, owner: string) => {
|
|
844
|
+
const existing = owners.get(name);
|
|
845
|
+
if (existing && existing !== owner) {
|
|
846
|
+
throw new TenantConfigError(`Resource "${name}" is used by both ${existing} and ${owner}`);
|
|
847
|
+
}
|
|
848
|
+
owners.set(name, owner);
|
|
849
|
+
};
|
|
850
|
+
for (const key of resourceKeys) claim(shared[key], "the shared infrastructure");
|
|
851
|
+
for (const site of sites) {
|
|
852
|
+
if (!site.deployment) continue;
|
|
853
|
+
const own = infrastructureFor(site, shared);
|
|
854
|
+
for (const key of resourceKeys) claim(own[key], `site "${site.id}"`);
|
|
855
|
+
for (const other of sites) {
|
|
856
|
+
if (other.id !== site.id && (other.hostname === site.hostname || other.id === own.ledgerWorker)) {
|
|
857
|
+
throw new TenantConfigError(`Site "${site.id}" collides with site "${other.id}"`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Cross-site consistency checks beyond per-site validation. */
|
|
864
|
+
export function validateCrossSite(sites: readonly ResolvedSite[]): void {
|
|
865
|
+
const seenWorkers = new Map<string, string>();
|
|
866
|
+
for (const site of sites) {
|
|
867
|
+
const productionName = site.id;
|
|
868
|
+
if (seenWorkers.has(productionName)) {
|
|
869
|
+
throw new TenantConfigError(
|
|
870
|
+
`Worker name "${productionName}" is used by both ` +
|
|
871
|
+
`"${seenWorkers.get(productionName)}" and "${site.id}"`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
seenWorkers.set(productionName, site.id);
|
|
875
|
+
|
|
876
|
+
const hostnames = new Set([site.hostname]);
|
|
877
|
+
for (const other of sites) {
|
|
878
|
+
if (other.id === site.id) continue;
|
|
879
|
+
if (hostnames.has(other.hostname)) {
|
|
880
|
+
throw new TenantConfigError(
|
|
881
|
+
`Sites "${site.id}" and "${other.id}" share hostname ` +
|
|
882
|
+
`${other.hostname}`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|