@hearthlog/tooling 0.1.8 → 0.3.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hearthlog/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"tsx": "^4.23.15",
|
|
31
|
-
"@hearthlog/contracts": "0.
|
|
31
|
+
"@hearthlog/contracts": "0.3.0"
|
|
32
32
|
},
|
|
33
33
|
"bin": {
|
|
34
34
|
"hearthlog": "./bin/hearthlog.mjs"
|
package/src/init/check-module.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { createRequire } from "node:module";
|
|
|
18
18
|
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
|
+
import { moduleSettingPlaceholder } from "@hearthlog/contracts";
|
|
22
|
+
|
|
21
23
|
import { loadModuleAt } from "../compose/compose-modules.js";
|
|
22
24
|
import { renderSite } from "./init.js";
|
|
23
25
|
|
|
@@ -47,8 +49,13 @@ export function assembleCheckSite(options: Required<Pick<CheckModuleOptions, "di
|
|
|
47
49
|
const configPath = join(options.work, "tenants.json");
|
|
48
50
|
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
49
51
|
for (const variable of loaded.manifest.server?.vars ?? []) {
|
|
50
|
-
if (variable.optional
|
|
51
|
-
|
|
52
|
+
if (variable.optional) continue;
|
|
53
|
+
if ("resource" in variable) {
|
|
54
|
+
config.infrastructure[variable.resource] ??= /Origin$/.test(variable.resource) ? 'https://module-check.example.invalid' : 'module-check';
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const settings = ((config.infrastructure.moduleSettings ??= {})[loaded.manifest.id] ??= {});
|
|
58
|
+
settings[variable.setting] ??= moduleSettingPlaceholder(variable);
|
|
52
59
|
}
|
|
53
60
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
54
61
|
const manifestPath = join(options.work, "package.json");
|
|
@@ -53,12 +53,13 @@ export interface SiteTheme {
|
|
|
53
53
|
accent?: string;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Settings a module declares in its manifest (`server.vars[].setting`), by module id and then setting key.
|
|
58
|
+
* The core checks only the shape here; the module's manifest decides which keys exist and what they look like.
|
|
59
|
+
*/
|
|
60
|
+
export type ModuleSettings = Record<string, Record<string, string>>;
|
|
61
61
|
|
|
62
|
+
export interface SiteDefinition {
|
|
62
63
|
id: string;
|
|
63
64
|
ledgerAppName?: string;
|
|
64
65
|
admin?: boolean;
|
|
@@ -82,6 +83,8 @@ export interface SiteDefinition {
|
|
|
82
83
|
dynamicEntities?: boolean;
|
|
83
84
|
theme?: SiteTheme;
|
|
84
85
|
hostname?: string;
|
|
86
|
+
/** Module settings for this site only; they override the deployment's and the shared ones key by key. */
|
|
87
|
+
moduleSettings?: ModuleSettings;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
export interface InfrastructureDefinition {
|
|
@@ -100,12 +103,8 @@ export interface InfrastructureDefinition {
|
|
|
100
103
|
galleryPublicBucket: string;
|
|
101
104
|
galleryPublicWorker: string;
|
|
102
105
|
galleryPublicOrigin: string;
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
stashMediaBucket?: string;
|
|
106
|
-
stashMediaRegion?: string;
|
|
107
|
-
/** "1" streams playback through the gateway instead of redirecting to storage. */
|
|
108
|
-
stashMediaProxy?: string;
|
|
106
|
+
/** Module settings every site shares unless its deployment or its own definition overrides them. */
|
|
107
|
+
moduleSettings?: ModuleSettings;
|
|
109
108
|
/** The account's workers.dev subdomain, for example `example.workers.dev`; Worker URLs derive from it. */
|
|
110
109
|
workersDevDomain: string;
|
|
111
110
|
/** The invoice renderer's HTTPS origin; Invoices answers 503 without one. */
|
|
@@ -130,13 +129,6 @@ export interface InfrastructureDefinition {
|
|
|
130
129
|
|
|
131
130
|
/** `tenants/<site>/deployment.json`: a site that runs on its own stack. */
|
|
132
131
|
export interface SiteDeployment {
|
|
133
|
-
/** Private S3-compatible video storage, provisioned separately by the site operator. */
|
|
134
|
-
stashMediaEndpoint?: string;
|
|
135
|
-
stashMediaBucket?: string;
|
|
136
|
-
stashMediaRegion?: string;
|
|
137
|
-
/** "1" streams playback through the gateway instead of redirecting to storage. */
|
|
138
|
-
stashMediaProxy?: string;
|
|
139
|
-
|
|
140
132
|
schemaVersion: 1;
|
|
141
133
|
/**
|
|
142
134
|
* Prefix of every resource the site owns, starting with the site id, for
|
|
@@ -157,6 +149,8 @@ export interface SiteDeployment {
|
|
|
157
149
|
kalApiOrigin?: string;
|
|
158
150
|
accessSessionDuration?: string;
|
|
159
151
|
accessGlobalSessionDuration?: string;
|
|
152
|
+
/** Module settings of this stack; the site's own override them key by key. */
|
|
153
|
+
moduleSettings?: ModuleSettings;
|
|
160
154
|
}
|
|
161
155
|
|
|
162
156
|
export interface TenantFile {
|
|
@@ -178,11 +172,6 @@ export interface TenantFile {
|
|
|
178
172
|
}
|
|
179
173
|
|
|
180
174
|
export interface ResolvedSite {
|
|
181
|
-
stashMediaEndpoint?: string;
|
|
182
|
-
stashMediaBucket?: string;
|
|
183
|
-
stashMediaRegion?: string;
|
|
184
|
-
stashMediaProxy?: string;
|
|
185
|
-
|
|
186
175
|
id: string;
|
|
187
176
|
ledgerAppName: string;
|
|
188
177
|
features: FeatureId[];
|
|
@@ -202,6 +191,8 @@ export interface ResolvedSite {
|
|
|
202
191
|
deployment: SiteDeployment | null;
|
|
203
192
|
/** The site came from `tenants/<id>/tenant.json` rather than `tenants.json`. */
|
|
204
193
|
fromTenantFile: boolean;
|
|
194
|
+
/** The site's own module settings; `infrastructureFor` layers them over the stack's. */
|
|
195
|
+
moduleSettings?: ModuleSettings;
|
|
205
196
|
}
|
|
206
197
|
|
|
207
198
|
export class TenantConfigError extends Error {
|
|
@@ -231,12 +222,12 @@ const SITE_KEYS = [
|
|
|
231
222
|
"dynamicEntities",
|
|
232
223
|
"theme",
|
|
233
224
|
"hostname",
|
|
234
|
-
"
|
|
225
|
+
"moduleSettings",
|
|
235
226
|
] as const satisfies readonly (keyof SiteDefinition)[];
|
|
236
227
|
const DEPLOYMENT_KEYS = [
|
|
237
228
|
"$schema", "schemaVersion", "resourcePrefix", "ledger", "accountId", "workersDevDomain", "authentication",
|
|
238
229
|
"ledgerAdminEmail", "invoiceRendererOrigin", "kalApiOrigin", "accessSessionDuration", "accessGlobalSessionDuration",
|
|
239
|
-
"
|
|
230
|
+
"moduleSettings",
|
|
240
231
|
] as const;
|
|
241
232
|
const TENANT_FILE_KEYS = ["$schema", "schemaVersion", ...SITE_KEYS] as const;
|
|
242
233
|
const WORKERS_DEV_PATTERN = /^[a-z0-9][a-z0-9-]*\.workers\.dev$/;
|
|
@@ -265,7 +256,7 @@ const INFRASTRUCTURE_KEYS = [
|
|
|
265
256
|
"accessSessionDuration",
|
|
266
257
|
"accessGlobalSessionDuration",
|
|
267
258
|
"gallerySweeper",
|
|
268
|
-
"
|
|
259
|
+
"moduleSettings",
|
|
269
260
|
"accountId",
|
|
270
261
|
] as const satisfies readonly (keyof InfrastructureDefinition)[];
|
|
271
262
|
|
|
@@ -517,7 +508,7 @@ export function parseSiteDeployment(
|
|
|
517
508
|
requireSessionDuration(`${label} ${key}`, duration);
|
|
518
509
|
result[key] = duration;
|
|
519
510
|
}
|
|
520
|
-
|
|
511
|
+
if (deployment.moduleSettings !== undefined) result.moduleSettings = parseModuleSettings(deployment.moduleSettings, `${label} moduleSettings`);
|
|
521
512
|
if (options.standalone) {
|
|
522
513
|
for (const key of ["workersDevDomain", "ledgerAdminEmail", "authentication"] as const) {
|
|
523
514
|
if (result[key] === undefined) throw new TenantConfigError(`${label}: ${key} is required when there is no tenants.json`);
|
|
@@ -526,22 +517,38 @@ export function parseSiteDeployment(
|
|
|
526
517
|
return result;
|
|
527
518
|
}
|
|
528
519
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
520
|
+
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
|
|
521
|
+
const SETTING_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Check the shape of a `moduleSettings` block: module ids to setting keys to non-empty strings. Which keys a
|
|
525
|
+
* module accepts, and their formats, are checked against its manifest when its Worker is configured.
|
|
526
|
+
*/
|
|
527
|
+
export function parseModuleSettings(value: unknown, label: string): ModuleSettings {
|
|
528
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TenantConfigError(`${label} must be an object`);
|
|
529
|
+
const result: ModuleSettings = {};
|
|
530
|
+
for (const [moduleId, settings] of Object.entries(value)) {
|
|
531
|
+
if (!MODULE_ID_PATTERN.test(moduleId)) throw new TenantConfigError(`${label}: "${moduleId}" is not a module id`);
|
|
532
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) throw new TenantConfigError(`${label}.${moduleId} must be an object`);
|
|
533
|
+
result[moduleId] = {};
|
|
534
|
+
for (const [key, setting] of Object.entries(settings)) {
|
|
535
|
+
if (!SETTING_KEY_PATTERN.test(key)) throw new TenantConfigError(`${label}.${moduleId}: "${key}" is not a setting key`);
|
|
536
|
+
if (typeof setting !== "string" || !setting.trim()) throw new TenantConfigError(`${label}.${moduleId}.${key} must be a non-empty string`);
|
|
537
|
+
result[moduleId][key] = setting;
|
|
538
|
+
}
|
|
541
539
|
}
|
|
542
540
|
return result;
|
|
543
541
|
}
|
|
544
542
|
|
|
543
|
+
/** Layer module settings key by key; later layers win. */
|
|
544
|
+
export function mergeModuleSettings(...layers: ReadonlyArray<ModuleSettings | undefined>): ModuleSettings | undefined {
|
|
545
|
+
const present = layers.filter((layer): layer is ModuleSettings => layer !== undefined);
|
|
546
|
+
if (!present.length) return undefined;
|
|
547
|
+
const result: ModuleSettings = {};
|
|
548
|
+
for (const layer of present) for (const [moduleId, settings] of Object.entries(layer)) result[moduleId] = { ...result[moduleId], ...settings };
|
|
549
|
+
return result;
|
|
550
|
+
}
|
|
551
|
+
|
|
545
552
|
function requireSessionDuration(label: string, duration: unknown): asserts duration is string {
|
|
546
553
|
if (typeof duration !== "string" || !/^[1-9]\d*h$/.test(duration) || Number(duration.slice(0, -1)) > 720) {
|
|
547
554
|
throw new TenantConfigError(`${label} must be whole hours between 1h and 720h`);
|
|
@@ -570,10 +577,12 @@ export function workersDevDomainFor(site: Pick<ResolvedSite, "deployment">, shar
|
|
|
570
577
|
* every name from its resource prefix. Account-wide settings stay shared.
|
|
571
578
|
*/
|
|
572
579
|
export function infrastructureFor(
|
|
573
|
-
site: Pick<ResolvedSite, "deployment"> & Partial<Pick<ResolvedSite, "
|
|
580
|
+
site: Pick<ResolvedSite, "deployment"> & Partial<Pick<ResolvedSite, "moduleSettings">>,
|
|
574
581
|
shared: InfrastructureDefinition,
|
|
575
582
|
): InfrastructureDefinition {
|
|
576
|
-
|
|
583
|
+
const moduleSettings = mergeModuleSettings(shared.moduleSettings, site.deployment?.moduleSettings, site.moduleSettings);
|
|
584
|
+
const withSettings = moduleSettings ? { moduleSettings } : {};
|
|
585
|
+
if (!site.deployment) return { ...shared, ...withSettings };
|
|
577
586
|
const prefix = site.deployment.resourcePrefix;
|
|
578
587
|
const origin = (worker: string) => `https://${worker}.${workersDevDomainFor(site, shared)}`;
|
|
579
588
|
const names = {
|
|
@@ -590,8 +599,7 @@ export function infrastructureFor(
|
|
|
590
599
|
return {
|
|
591
600
|
...shared,
|
|
592
601
|
...names,
|
|
593
|
-
...
|
|
594
|
-
...parseStashMediaConfiguration(site, "site"),
|
|
602
|
+
...withSettings,
|
|
595
603
|
workersDevDomain: workersDevDomainFor(site, shared),
|
|
596
604
|
...(site.deployment.authentication ? { accessTeamDomain: site.deployment.authentication.teamDomain } : {}),
|
|
597
605
|
...(site.deployment.ledgerAdminEmail ? { ledgerAdminEmail: site.deployment.ledgerAdminEmail } : {}),
|
|
@@ -715,7 +723,7 @@ export function resolveSite(
|
|
|
715
723
|
return {
|
|
716
724
|
id: site.id,
|
|
717
725
|
ledgerAppName: site.ledgerAppName ?? site.id,
|
|
718
|
-
...
|
|
726
|
+
...(site.moduleSettings !== undefined ? { moduleSettings: parseModuleSettings(site.moduleSettings, `${label} moduleSettings`) } : {}),
|
|
719
727
|
features: [...features],
|
|
720
728
|
ai: [...ai],
|
|
721
729
|
integrations,
|
|
@@ -870,7 +878,7 @@ export function resolveInfrastructure(
|
|
|
870
878
|
const duration = infrastructure[key];
|
|
871
879
|
if (duration !== undefined) requireSessionDuration(`tenants.json infrastructure.${key}`, duration);
|
|
872
880
|
}
|
|
873
|
-
|
|
881
|
+
if (infrastructure.moduleSettings !== undefined) parseModuleSettings(infrastructure.moduleSettings, "tenants.json infrastructure.moduleSettings");
|
|
874
882
|
return infrastructure;
|
|
875
883
|
}
|
|
876
884
|
|
|
@@ -16,6 +16,8 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import { FEATURES } from "@hearthlog/contracts/features/registry";
|
|
17
17
|
import { AI_CAPABILITIES } from "@hearthlog/contracts/ai/capabilities";
|
|
18
18
|
import { INTEGRATION_ID_PATTERN, INTEGRATION_KINDS } from "@hearthlog/contracts/integrations/kinds";
|
|
19
|
+
import type { ModuleSettingFormat } from "@hearthlog/contracts";
|
|
20
|
+
import { loadModules } from "../compose/compose-modules.js";
|
|
19
21
|
import { DEVELOPER_PATH, REPOSITORY_ROOT } from "./tenant-config.js";
|
|
20
22
|
|
|
21
23
|
export const TENANT_SCHEMA_FILE = join(REPOSITORY_ROOT, "tenants.schema.json");
|
|
@@ -26,6 +28,7 @@ const hours = { type: "string", pattern: "^[1-9][0-9]*h$", description: "Whole h
|
|
|
26
28
|
|
|
27
29
|
export function buildTenantSchema(): Record<string, unknown> {
|
|
28
30
|
const featureIds = FEATURES.map((feature) => feature.id);
|
|
31
|
+
const moduleSettings = moduleSettingsSchema();
|
|
29
32
|
const homePaths = [...new Set(["/", DEVELOPER_PATH, ...FEATURES.flatMap((feature) => feature.page ? [feature.page.path] : [])])];
|
|
30
33
|
const shared = {
|
|
31
34
|
features: {
|
|
@@ -78,10 +81,7 @@ export function buildTenantSchema(): Record<string, unknown> {
|
|
|
78
81
|
workersDevDomain: { type: "string", pattern: "^[a-z0-9][a-z0-9-]*\\.workers\\.dev$", description: "The account's workers.dev subdomain." },
|
|
79
82
|
invoiceRendererOrigin: httpsOrigin,
|
|
80
83
|
kalApiOrigin: httpsOrigin,
|
|
81
|
-
|
|
82
|
-
stashMediaBucket: nonEmptyString,
|
|
83
|
-
stashMediaRegion: nonEmptyString,
|
|
84
|
-
stashMediaProxy: { enum: ["0", "1"] },
|
|
84
|
+
moduleSettings,
|
|
85
85
|
ledgerAdminEmail: { type: "string", format: "email" },
|
|
86
86
|
accessTeamDomain: httpsOrigin,
|
|
87
87
|
accessSessionDuration: hours,
|
|
@@ -144,10 +144,7 @@ export function buildTenantSchema(): Record<string, unknown> {
|
|
|
144
144
|
},
|
|
145
145
|
},
|
|
146
146
|
hostname: { type: "string", format: "uri" },
|
|
147
|
-
|
|
148
|
-
stashMediaBucket: nonEmptyString,
|
|
149
|
-
stashMediaRegion: nonEmptyString,
|
|
150
|
-
stashMediaProxy: { enum: ["0", "1"] },
|
|
147
|
+
moduleSettings,
|
|
151
148
|
},
|
|
152
149
|
},
|
|
153
150
|
},
|
|
@@ -155,6 +152,36 @@ export function buildTenantSchema(): Record<string, unknown> {
|
|
|
155
152
|
};
|
|
156
153
|
}
|
|
157
154
|
|
|
155
|
+
/** `moduleSettings`, with a block for each installed module that declares settings. */
|
|
156
|
+
function moduleSettingsSchema(): Record<string, unknown> {
|
|
157
|
+
const properties: Record<string, unknown> = {};
|
|
158
|
+
for (const { manifest } of loadModules()) {
|
|
159
|
+
const settings = (manifest.server?.vars ?? []).filter((variable) => "setting" in variable);
|
|
160
|
+
if (!settings.length) continue;
|
|
161
|
+
properties[manifest.id] = {
|
|
162
|
+
type: "object",
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
properties: Object.fromEntries(settings.map((variable) => [variable.setting, settingSchema(variable)])),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
type: "object",
|
|
169
|
+
description: "Settings of installed modules, by module id. A site's override its deployment's, which override the shared ones.",
|
|
170
|
+
additionalProperties: false,
|
|
171
|
+
properties,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function settingSchema(variable: { format?: ModuleSettingFormat; values?: string[] }): Record<string, unknown> {
|
|
176
|
+
if (variable.values) return { enum: variable.values };
|
|
177
|
+
switch (variable.format ?? "text") {
|
|
178
|
+
case "https-origin": return httpsOrigin;
|
|
179
|
+
case "s3-bucket": return { type: "string", pattern: "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$" };
|
|
180
|
+
case "s3-region": return { type: "string", pattern: "^[a-z0-9-]+$" };
|
|
181
|
+
default: return nonEmptyString;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
158
185
|
export function renderTenantSchema(): string {
|
|
159
186
|
return `${JSON.stringify(buildTenantSchema(), null, 2)}\n`;
|
|
160
187
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
12
|
import { dirname, join, relative } from "node:path";
|
|
13
13
|
|
|
14
|
-
import type
|
|
14
|
+
import { moduleSettingProblem, type ModuleManifest } from "@hearthlog/contracts";
|
|
15
15
|
|
|
16
16
|
import { WRANGLER_BASE } from "./wrangler-base.js";
|
|
17
17
|
import { activeIntegrations } from "@hearthlog/contracts/integrations/kinds";
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
infrastructureFor,
|
|
22
22
|
loadTenantConfig,
|
|
23
23
|
REPOSITORY_ROOT,
|
|
24
|
+
TenantConfigError,
|
|
24
25
|
type InfrastructureDefinition,
|
|
25
26
|
type ResolvedSite,
|
|
26
27
|
type SiteStack,
|
|
@@ -107,7 +108,8 @@ export function siteWorkerFirst(features: readonly string[]): string[] {
|
|
|
107
108
|
|
|
108
109
|
/**
|
|
109
110
|
* The bindings and variables a site's modules need, from their manifests.
|
|
110
|
-
* `resource` names a key of the tenant's infrastructure
|
|
111
|
+
* A `resource` names a key of the tenant's infrastructure; a `setting` names a key of the module's block in
|
|
112
|
+
* `infrastructure.moduleSettings`, checked against the format the manifest declares.
|
|
111
113
|
*/
|
|
112
114
|
export function moduleBindings(
|
|
113
115
|
features: readonly string[],
|
|
@@ -119,17 +121,35 @@ export function moduleBindings(
|
|
|
119
121
|
if (!value) throw new Error(`The infrastructure has no "${resource}"`);
|
|
120
122
|
return value;
|
|
121
123
|
};
|
|
122
|
-
const optional = (resource: string): string | undefined => keyed[resource];
|
|
123
124
|
const result = { r2: [] as Array<{ binding: string; bucket_name: string }>, images: false, vars: {} as Record<string, string> };
|
|
124
|
-
for (const
|
|
125
|
+
for (const { manifest } of modulesForFeatures(loadModules(), features)) {
|
|
126
|
+
const server = manifest.server;
|
|
127
|
+
if (!server) continue;
|
|
125
128
|
for (const binding of server.bindings ?? []) {
|
|
126
129
|
if (binding.type === "r2") result.r2.push({ binding: binding.binding, bucket_name: lookup(binding.resource) });
|
|
127
130
|
else result.images = true;
|
|
128
131
|
}
|
|
132
|
+
const settings = infrastructure.moduleSettings?.[manifest.id] ?? {};
|
|
133
|
+
const declared = new Set<string>();
|
|
129
134
|
for (const variable of server.vars ?? []) {
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
if ("resource" in variable) {
|
|
136
|
+
const value = variable.optional ? keyed[variable.resource] : lookup(variable.resource);
|
|
137
|
+
if (value) result.vars[variable.name] = value;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
declared.add(variable.setting);
|
|
141
|
+
const value = settings[variable.setting];
|
|
142
|
+
const label = `moduleSettings.${manifest.id}.${variable.setting}`;
|
|
143
|
+
if (value === undefined) {
|
|
144
|
+
if (variable.optional) continue;
|
|
145
|
+
throw new TenantConfigError(`${label} is required by ${manifest.id}`);
|
|
146
|
+
}
|
|
147
|
+
const problem = moduleSettingProblem(variable, value);
|
|
148
|
+
if (problem) throw new TenantConfigError(`${label} ${problem}`);
|
|
149
|
+
result.vars[variable.name] = value;
|
|
132
150
|
}
|
|
151
|
+
const unknown = Object.keys(settings).filter((key) => !declared.has(key));
|
|
152
|
+
if (unknown.length) throw new TenantConfigError(`moduleSettings.${manifest.id}: unknown key "${unknown[0]}" (known keys: ${[...declared].join(", ") || "none"})`);
|
|
133
153
|
}
|
|
134
154
|
return result;
|
|
135
155
|
}
|
package/src/vite/config.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
localLedgerGatewayPath,
|
|
22
22
|
localLedgerOriginGuardPlugin,
|
|
23
23
|
localReceivedInvoiceFilesPlugin,
|
|
24
|
-
|
|
24
|
+
localModuleServerPlugin,
|
|
25
25
|
} from './dev-plugins.js';
|
|
26
26
|
|
|
27
27
|
import { defineConfig, searchForWorkspaceRoot, type ProxyOptions, type UserConfig } from 'vite';
|
|
@@ -276,7 +276,7 @@ return defineConfig(({ command, mode }): UserConfig => {
|
|
|
276
276
|
localLedgerOriginGuardPlugin(Boolean(localLedgerCredential)),
|
|
277
277
|
localInvoicePdfGatewayFallbackPlugin(Boolean(invoiceRendererToken)),
|
|
278
278
|
localReceivedInvoiceFilesPlugin(),
|
|
279
|
-
|
|
279
|
+
localModuleServerPlugin(localLedgerCredential),
|
|
280
280
|
localInvoiceExtractionPlugin(),
|
|
281
281
|
localAiDecisionsPlugin(isE2E),
|
|
282
282
|
e2eLedgerStubPlugin(isE2E),
|
package/src/vite/dev-plugins.ts
CHANGED
|
@@ -338,48 +338,57 @@ export async function readJsonBody(request: import('node:http').IncomingMessage)
|
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Serves the routes of modules that declare a `server.dev` entry, for the active site's enabled modules only. The
|
|
343
|
+
* entry gets the local Ledger and a stand-in identity; nothing here, nor any module's dev entry, reaches a deployed build.
|
|
344
|
+
*/
|
|
345
|
+
export function localModuleServerPlugin(credential?: import('../dev/local-development.js').LocalLedgerCredential): Plugin {
|
|
343
346
|
return {
|
|
344
|
-
name: 'local-
|
|
347
|
+
name: 'local-module-server',
|
|
345
348
|
configureServer(server) {
|
|
349
|
+
type DevModule = { id: string; directory: string; dev: string; routes: string[]; features: string[] };
|
|
350
|
+
let declared: Promise<DevModule[]> | undefined;
|
|
351
|
+
// Vite's config bundler follows literal imports even inside this dev-only callback.
|
|
352
|
+
// Load these through Vite so their TypeScript package dependencies stay out of config loading.
|
|
353
|
+
const load = (path: string) => server.ssrLoadModule(fileURLToPath(new URL(path, import.meta.url)));
|
|
354
|
+
const devModules = () => declared ??= load('../compose/compose-modules.js').then(({ loadModules }) =>
|
|
355
|
+
(loadModules() as ReturnType<typeof import('../compose/compose-modules.js').loadModules>).flatMap(({ directory, manifest }) =>
|
|
356
|
+
manifest.server?.dev ? [{ id: manifest.id, directory, dev: manifest.server.dev, routes: manifest.server.routes ?? [], features: manifest.features.map(feature => feature.id) }] : []));
|
|
346
357
|
server.middlewares.use(async (incoming, outgoing, next) => {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
358
|
+
const path = incoming.url?.split('?')[0];
|
|
359
|
+
if (!path?.startsWith('/api/')) return next();
|
|
360
|
+
const respond = (status: number, message: string) => { outgoing.statusCode = status; outgoing.setHeader('Content-Type', 'application/json'); outgoing.end(JSON.stringify({ error: message, code: 'local_module_unavailable' })); };
|
|
361
|
+
let found: DevModule | undefined;
|
|
362
|
+
try {
|
|
363
|
+
found = (await devModules()).find(candidate => candidate.routes.some(prefix => path === prefix || path.startsWith(`${prefix}/`)));
|
|
364
|
+
} catch { return respond(503, 'Local module routes could not be loaded'); }
|
|
365
|
+
if (!found) return next();
|
|
366
|
+
const module = found;
|
|
367
|
+
if (!localGatewayOriginAllowed(incoming.headers.origin, incoming.headers.host) || incoming.headers['sec-fetch-site'] === 'cross-site') return respond(403, 'Cross-origin local module requests are not allowed');
|
|
368
|
+
if (!credential) return respond(503, 'Start the local Ledger and run ledger:provision-local before using this locally');
|
|
351
369
|
try {
|
|
352
|
-
|
|
353
|
-
// Load these through Vite so their TypeScript package dependencies stay out of config loading.
|
|
354
|
-
const [{ loadModules }, { loadTenantConfig }, { activeSiteId }] = await Promise.all([
|
|
355
|
-
server.ssrLoadModule(fileURLToPath(new URL('../compose/compose-modules.js', import.meta.url))),
|
|
356
|
-
server.ssrLoadModule(fileURLToPath(new URL('../tenants/tenant-config.js', import.meta.url))),
|
|
357
|
-
server.ssrLoadModule(fileURLToPath(new URL('../root.js', import.meta.url))),
|
|
358
|
-
]);
|
|
370
|
+
const [{ loadTenantConfig }, { activeSiteId }] = await Promise.all([load('../tenants/tenant-config.js'), load('../root.js')]);
|
|
359
371
|
const config: Awaited<ReturnType<typeof import('../tenants/tenant-config.js').loadTenantConfig>> = await loadTenantConfig();
|
|
360
372
|
const site = config.sites.find(site => site.id === activeSiteId());
|
|
361
|
-
|
|
362
|
-
const module = modules.find(module => module.manifest.server?.routes?.includes('/api/stash-media/v1') && module.manifest.features.some(feature => site?.features.some(enabled => enabled === feature.id)));
|
|
363
|
-
if (!module) return respond(404, 'Video media is not enabled for this site');
|
|
364
|
-
const endpoint = new URL(process.env.STASH_MEDIA_DEV_ENDPOINT || 'http://127.0.0.1:9000');
|
|
365
|
-
if (!['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)) return respond(503, 'Development media storage must use a loopback MinIO endpoint');
|
|
366
|
-
const { handleStashMediaRequest } = await server.ssrLoadModule(`${module.directory}/src/server/media.ts`);
|
|
373
|
+
if (!site?.features.some(enabled => module.features.includes(enabled))) return respond(404, `${module.id} is not enabled for this site`);
|
|
367
374
|
const chunks: Buffer[] = []; let size = 0;
|
|
368
|
-
for await (const chunk of incoming) { size += chunk.length; if (size > 1024 * 1024) return respond(413, '
|
|
375
|
+
for await (const chunk of incoming) { size += chunk.length; if (size > 1024 * 1024) return respond(413, 'Local module request is too large'); chunks.push(Buffer.from(chunk)); }
|
|
369
376
|
const headers = new Headers();
|
|
370
377
|
for (const [name, value] of Object.entries(incoming.headers)) if (typeof value === 'string') headers.set(name, value);
|
|
371
|
-
const request = new Request(new URL(incoming.url
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
const request = new Request(new URL(incoming.url!, `http://${incoming.headers.host}`), { method: incoming.method, headers, ...(size ? { body: Buffer.concat(chunks) } : {}) });
|
|
379
|
+
const { default: dev } = await server.ssrLoadModule(`${module.directory}/${module.dev.slice(2)}`) as { default: import('@hearthlog/contracts').DevServerModule };
|
|
380
|
+
const response = await dev.handle(request, {
|
|
381
|
+
env: {
|
|
382
|
+
LEDGER_AUTH_TOKEN: credential.token,
|
|
383
|
+
LEDGER: { fetch: (request: Request) => { const url = new URL(request.url); return fetch(new URL(`${url.pathname}${url.search}`, credential.baseUrl), { method: request.method, headers: request.headers }); } },
|
|
384
|
+
},
|
|
385
|
+
identity: { email: 'local@example.invalid' },
|
|
386
|
+
});
|
|
378
387
|
outgoing.statusCode = response.status;
|
|
379
388
|
response.headers.forEach((value, name) => outgoing.setHeader(name, value));
|
|
380
389
|
if (response.body) for await (const chunk of response.body) outgoing.write(chunk);
|
|
381
390
|
outgoing.end();
|
|
382
|
-
} catch { respond(503,
|
|
391
|
+
} catch { respond(503, `${module.id} is unavailable locally. Check that its local services and the local Ledger are running`); }
|
|
383
392
|
});
|
|
384
393
|
},
|
|
385
394
|
};
|