@hearthlog/tooling 0.1.8 → 0.2.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.1.8",
3
+ "version": "0.2.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.1.8"
31
+ "@hearthlog/contracts": "0.2.0"
32
32
  },
33
33
  "bin": {
34
34
  "hearthlog": "./bin/hearthlog.mjs"
@@ -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 || config.infrastructure[variable.resource]) continue;
51
- config.infrastructure[variable.resource] = /Endpoint$|Origin$/.test(variable.resource) ? 'https://storage.example.invalid' : /Region$/.test(variable.resource) ? 'us-east-1' : 'module-check-storage';
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
- export interface SiteDefinition {
57
- stashMediaEndpoint?: string;
58
- stashMediaBucket?: string;
59
- stashMediaRegion?: string;
60
- stashMediaProxy?: string;
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
- /** Private S3-compatible video storage, provisioned separately by the site operator. */
104
- stashMediaEndpoint?: string;
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
- "stashMediaEndpoint", "stashMediaBucket", "stashMediaRegion", "stashMediaProxy",
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
- "stashMediaEndpoint", "stashMediaBucket", "stashMediaRegion", "stashMediaProxy",
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
- "stashMediaEndpoint", "stashMediaBucket", "stashMediaRegion", "stashMediaProxy",
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
- Object.assign(result, parseStashMediaConfiguration(deployment, label));
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
- /** Validate optional media settings without requiring storage for sites that do not enable it. */
530
- export function parseStashMediaConfiguration(value: Partial<InfrastructureDefinition>, label: string): Partial<InfrastructureDefinition> {
531
- const result: Partial<InfrastructureDefinition> = {};
532
- for (const key of ['stashMediaEndpoint', 'stashMediaBucket', 'stashMediaRegion', 'stashMediaProxy'] as const) {
533
- const text = value[key];
534
- if (text === undefined) continue;
535
- if (typeof text !== 'string' || !text.trim()) throw new TenantConfigError(`${label}.${key} must be a non-empty string`);
536
- if (key === 'stashMediaEndpoint') requireHttpsOrigin(`${label}.${key}`, text);
537
- if (key === 'stashMediaBucket' && !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(text)) throw new TenantConfigError(`${label}.${key} must be an S3 bucket name`);
538
- if (key === 'stashMediaRegion' && !/^[a-z0-9-]+$/.test(text)) throw new TenantConfigError(`${label}.${key} must be an S3 region`);
539
- if (key === 'stashMediaProxy' && !['0', '1'].includes(text)) throw new TenantConfigError(`${label}.${key} must be 0 or 1`);
540
- result[key] = text;
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, "stashMediaEndpoint" | "stashMediaBucket" | "stashMediaRegion" | "stashMediaProxy">>,
580
+ site: Pick<ResolvedSite, "deployment"> & Partial<Pick<ResolvedSite, "moduleSettings">>,
574
581
  shared: InfrastructureDefinition,
575
582
  ): InfrastructureDefinition {
576
- if (!site.deployment) return { ...shared, ...parseStashMediaConfiguration(site, "site") };
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
- ...parseStashMediaConfiguration(site.deployment, "site deployment"),
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
- ...parseStashMediaConfiguration(site, label),
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
- parseStashMediaConfiguration(infrastructure, "tenants.json infrastructure");
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
- stashMediaEndpoint: httpsOrigin,
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
- stashMediaEndpoint: httpsOrigin,
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 { ModuleManifest } from "@hearthlog/contracts";
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 server of serverDeclarations(features)) {
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
- const value = variable.optional ? optional(variable.resource) : lookup(variable.resource);
131
- if (value) result.vars[variable.name] = value;
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
  }
@@ -21,7 +21,7 @@ import {
21
21
  localLedgerGatewayPath,
22
22
  localLedgerOriginGuardPlugin,
23
23
  localReceivedInvoiceFilesPlugin,
24
- localStashMediaPlugin,
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
- localStashMediaPlugin(localLedgerCredential),
279
+ localModuleServerPlugin(localLedgerCredential),
280
280
  localInvoiceExtractionPlugin(),
281
281
  localAiDecisionsPlugin(isE2E),
282
282
  e2eLedgerStubPlugin(isE2E),
@@ -338,48 +338,57 @@ export async function readJsonBody(request: import('node:http').IncomingMessage)
338
338
  }
339
339
  }
340
340
 
341
- /** Real S3 signing against loopback MinIO; no production credentials or Access bypass reach a deployed build. */
342
- export function localStashMediaPlugin(credential?: import('../dev/local-development.js').LocalLedgerCredential): Plugin {
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-stash-media',
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
- if (!incoming.url?.startsWith('/api/stash-media/v1/')) return next();
348
- const respond = (status: number, message: string) => { outgoing.statusCode = status; outgoing.setHeader('Content-Type', 'application/json'); outgoing.end(JSON.stringify({ error: message, code: 'local_media_unavailable' })); };
349
- if (!localGatewayOriginAllowed(incoming.headers.origin, incoming.headers.host) || incoming.headers['sec-fetch-site'] === 'cross-site') return respond(403, 'Cross-origin local media requests are not allowed');
350
- if (!credential) return respond(503, 'Start the local Ledger and run ledger:provision-local before uploading videos locally');
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
- // Vite's config bundler follows literal imports even inside this dev-only callback.
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
- const modules: ReturnType<typeof import('../compose/compose-modules.js').loadModules> = loadModules();
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, 'Media control request is too large'); chunks.push(Buffer.from(chunk)); }
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, `http://${incoming.headers.host}`), { method: incoming.method, headers, ...(size ? { body: Buffer.concat(chunks) } : {}) });
372
- const env = {
373
- STASH_MEDIA_ENDPOINT: endpoint.origin, STASH_MEDIA_BUCKET: 'hearthlog-dev', STASH_MEDIA_REGION: 'us-east-1',
374
- STASH_MEDIA_KEY_ID: 'hearthlog-local', STASH_MEDIA_APP_KEY: 'hearthlog-local-only', LEDGER_AUTH_TOKEN: credential.token,
375
- 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 }); } },
376
- };
377
- const response: Response = await handleStashMediaRequest(request, env, async () => ({ email: 'local@example.invalid' }));
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, 'Local video storage is unavailable. Check that MinIO and the local Ledger are running'); }
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
  };