@hearthlog/tooling 0.1.5 → 0.1.7

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/bin/hearthlog.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  // The `hearthlog` command: runs the TypeScript tools through tsx. `build`, `init`, `export`, `check-module`, `lock`, `artifact`, and
3
3
  // `gallery-public:build` start before a site has generated files (or write their own), so they run alone; the
4
4
  // `apple:*` commands run the build and then the tools of `@hearthlog/native` (a dependency of the site, not of this
5
- // package); `ensure`, `deploy`, and `deploy-ledger` run the build and then a deploy tool; every other
6
- // command goes to the CLI.
5
+ // package); `ensure`, `deploy`, and `deploy-ledger` run the build and then a deploy tool; `dev-ledger` runs the build
6
+ // and then the dev server with a local Ledger; every other command goes to the CLI.
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { createRequire } from 'node:module';
9
9
  import { join } from 'node:path';
@@ -41,6 +41,12 @@ if (deployTools[command]) {
41
41
  process.exit(built === 0 ? (spawnSync(process.execPath, [tsx, source(deployTools[command]), ...rest], { stdio: 'inherit', env }).status ?? 1) : built);
42
42
  }
43
43
 
44
+ // The dev server with a local Ledger starts that Ledger from the generated configuration: build it first.
45
+ if (command === 'dev-ledger') {
46
+ const built = run([source('generate/build-generated.ts'), '--quiet']);
47
+ process.exit(built === 0 ? run([source('dev/dev-ledger.ts'), ...rest]) : built);
48
+ }
49
+
44
50
  // The plan reads the site's generated files too: build them first (a no-op when they are current). `apply` does not,
45
51
  // because `apply --skip-build` deploys the release already in place and must not rewrite it.
46
52
  if (command === 'plan') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hearthlog/tooling",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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.5"
31
+ "@hearthlog/contracts": "0.1.7"
32
32
  },
33
33
  "bin": {
34
34
  "hearthlog": "./bin/hearthlog.mjs"
@@ -108,6 +108,7 @@ import {
108
108
  import {
109
109
  generateTenantWranglerConfigs,
110
110
  moduleBindings,
111
+ serverDeclarations,
111
112
  resolveFrontendWranglerTarget,
112
113
  } from "../tenants/wrangler-config.js";
113
114
 
@@ -694,7 +695,7 @@ async function ensureLedgerApp(
694
695
  * `LEDGER_ADMIN_TOKEN` environment variable is only the fallback when no Ledger was contacted (`--skip-ledger`).
695
696
  */
696
697
  export async function collectSecrets(
697
- site: Pick<ResolvedSite, "id" | "admin" | "integrations">,
698
+ site: Pick<ResolvedSite, "id" | "admin" | "integrations"> & Partial<Pick<ResolvedSite, "features">>,
698
699
  infrastructure: Pick<InfrastructureDefinition, "accessTeamDomain">,
699
700
  ledgerToken: string | null,
700
701
  accessAud: string | undefined,
@@ -718,10 +719,26 @@ export async function collectSecrets(
718
719
  // The renderer's credential comes from the operator's environment; without one the renderer routes answer 503.
719
720
  const rendererToken = process.env.INVOICE_RENDERER_TOKEN?.trim();
720
721
  if (rendererToken) secrets.INVOICE_RENDERER_TOKEN = rendererToken;
721
- Object.assign(secrets, extractionSecretsFromEnvironment());
722
+ Object.assign(secrets, extractionSecretsFromEnvironment(), moduleSecretsFromEnvironment(site.id, serverDeclarations(site.features ?? [])));
722
723
  return secrets;
723
724
  }
724
725
 
726
+ /** Site-scoped operator variables take precedence when provisioning several gateways. Values never enter generated files. */
727
+ export function moduleSecretsFromEnvironment(
728
+ siteId: string,
729
+ servers: ReadonlyArray<{ secrets?: ReadonlyArray<{ name: string }> }>,
730
+ environment: Record<string, string | undefined> = process.env,
731
+ ): Record<string, string> {
732
+ const result: Record<string, string> = {};
733
+ const suffix = siteId.toUpperCase().replace(/-/g, '_');
734
+ for (const server of servers) for (const { name } of server.secrets ?? []) {
735
+ const value = environment[`${name}_${suffix}`]?.trim() || environment[name]?.trim();
736
+ if (!value) throw new TenantConfigError(`Site ${siteId}: supply ${name}_${suffix} (or ${name}) in the operator environment before setting module secrets`);
737
+ result[name] = value;
738
+ }
739
+ return result;
740
+ }
741
+
725
742
  /**
726
743
  * Vertex AI credentials for AI invoice reading. Absent credentials are not a
727
744
  * failure: the feature answers 503 and the UI hides itself.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `hearthlog dev-ledger [vite options]`: the site against a Ledger of its own on this machine, in one command. The
3
+ * `hearthlog` command builds the generated files first; this starts the site's Ledger Worker (`wrangler dev` on
4
+ * 127.0.0.1:8787, its data in `.wrangler/development/ledger-state/`), provisions the gateway credential the dev server
5
+ * reads (as `provision-local` does), then runs `vite dev` with the arguments given. Stopping either stops both.
6
+ *
7
+ * The Ledger's local secrets are written once to `.wrangler/development/ledger.dev.vars`: random, mode 600, and never
8
+ * committed (a site ignores `.wrangler/`). Delete `.wrangler/development/` to start from an empty Ledger.
9
+ */
10
+ import { spawn, type ChildProcess } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { GENERATED_DIRECTORY, siteDirectory } from "../compose/compose-modules.js";
18
+ import { activeSiteId, hostRoot } from "../root.js";
19
+ import { DEFAULT_LOCAL_LEDGER_URL, ensureLocalLedgerCredential, localLedgerCredentialPath } from "./local-development.js";
20
+
21
+ export const DEVELOPMENT_DIRECTORY = join(".wrangler", "development");
22
+ export const LEDGER_DEV_VARS = join(DEVELOPMENT_DIRECTORY, "ledger.dev.vars");
23
+
24
+ /** A new site's local Ledger secrets, in the `.dev.vars` format `wrangler dev --env-file` reads. */
25
+ export function ledgerDevVars(secret: () => string = () => randomBytes(32).toString("hex")): string {
26
+ return `ADMIN_TOKEN=${secret()}\nTOKEN_SIGNING_KEY_CURRENT=${secret()}\nALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173\n`;
27
+ }
28
+
29
+ /** The `KEY=VALUE` lines of a `.dev.vars` file; blank lines and `#` comments are skipped. */
30
+ export function parseDevVars(text: string): Record<string, string> {
31
+ const values: Record<string, string> = {};
32
+ for (const line of text.split(/\r?\n/)) {
33
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
34
+ if (match && !line.trimStart().startsWith("#")) values[match[1]!] = match[2]!.replace(/^(["'])(.*)\1$/, "$2");
35
+ }
36
+ return values;
37
+ }
38
+
39
+ /** The Ledger configuration the build wrote for a site: its own (a dedicated Ledger), else the host's shared one. */
40
+ export function ledgerConfigPath(siteId: string): string {
41
+ for (const candidate of [join(siteDirectory(siteId), "ledger", "wrangler.jsonc"), join(GENERATED_DIRECTORY, "shared", "ledger", "wrangler.jsonc")]) {
42
+ if (existsSync(candidate)) return candidate;
43
+ }
44
+ throw new Error(`No Ledger configuration for ${siteId} in .generated/: run hearthlog build`);
45
+ }
46
+
47
+ /** A tool's entry script as the site installs it, run with this Node rather than through a package manager. */
48
+ function binary(root: string, name: string, bin: string): string {
49
+ const manifestPath = createRequire(join(root, "package.json")).resolve(`${name}/package.json`);
50
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { bin?: string | Record<string, string> };
51
+ const entry = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[bin];
52
+ if (!entry) throw new Error(`${name} has no ${bin} binary`);
53
+ return join(dirname(manifestPath), entry);
54
+ }
55
+
56
+ async function waitForHealth(url: string, stopped: () => boolean): Promise<boolean> {
57
+ for (let attempt = 0; attempt < 120 && !stopped(); attempt++) {
58
+ try {
59
+ if ((await fetch(new URL("/health", url))).ok) return true;
60
+ } catch {
61
+ // Not listening yet.
62
+ }
63
+ await new Promise((done) => setTimeout(done, 500));
64
+ }
65
+ return false;
66
+ }
67
+
68
+ export async function devLedger(viteArguments: readonly string[]): Promise<number> {
69
+ const root = hostRoot();
70
+ const site = activeSiteId(root);
71
+ const varsPath = join(root, LEDGER_DEV_VARS);
72
+ mkdirSync(join(root, DEVELOPMENT_DIRECTORY), { recursive: true });
73
+ if (!existsSync(varsPath)) {
74
+ writeFileSync(varsPath, ledgerDevVars(), { mode: 0o600 });
75
+ console.log(`Wrote the local Ledger's secrets to ${LEDGER_DEV_VARS}`);
76
+ }
77
+ const adminToken = parseDevVars(readFileSync(varsPath, "utf8")).ADMIN_TOKEN ?? "";
78
+ const ledgerUrl = new URL(DEFAULT_LOCAL_LEDGER_URL);
79
+
80
+ const children = new Set<ChildProcess>();
81
+ let stopping = false;
82
+ let exitCode = 0;
83
+ let finished: (code: number) => void = () => {};
84
+ const done = new Promise<number>((resolveDone) => (finished = resolveDone));
85
+ const stop = (code: number) => {
86
+ if (stopping) return;
87
+ stopping = true;
88
+ exitCode = code;
89
+ // Each child leads a process group of its own, so this stops what it started too (wrangler's workerd).
90
+ for (const child of children) {
91
+ try {
92
+ process.kill(-child.pid!, "SIGTERM");
93
+ } catch {
94
+ // Already gone.
95
+ }
96
+ }
97
+ if (children.size === 0) finished(exitCode);
98
+ };
99
+ const start = (script: string, args: readonly string[]) => {
100
+ const child = spawn(process.execPath, [script, ...args], { cwd: root, stdio: "inherit", detached: true });
101
+ children.add(child);
102
+ child.on("exit", (code) => {
103
+ children.delete(child);
104
+ stop(code ?? 0);
105
+ if (children.size === 0) finished(exitCode);
106
+ });
107
+ };
108
+ process.on("SIGINT", () => stop(0));
109
+ process.on("SIGTERM", () => stop(0));
110
+
111
+ start(binary(root, "wrangler", "wrangler"), [
112
+ "dev",
113
+ "--config", ledgerConfigPath(site),
114
+ "--env-file", varsPath,
115
+ "--persist-to", join(root, DEVELOPMENT_DIRECTORY, "ledger-state"),
116
+ "--ip", ledgerUrl.hostname,
117
+ "--port", ledgerUrl.port,
118
+ ]);
119
+ if (!(await waitForHealth(ledgerUrl.href, () => stopping))) {
120
+ if (!stopping) console.error(`The local Ledger did not answer at ${new URL("/health", ledgerUrl).href}`);
121
+ stop(1);
122
+ return done;
123
+ }
124
+ try {
125
+ const { reused } = await ensureLocalLedgerCredential({ adminToken, baseUrl: ledgerUrl.href, credentialPath: localLedgerCredentialPath(root) });
126
+ console.log(`${reused ? "Reused" : "Provisioned"} the dev server's credential for the local Ledger (token not printed)`);
127
+ } catch (error) {
128
+ console.error(error instanceof Error ? error.message : String(error));
129
+ stop(1);
130
+ return done;
131
+ }
132
+ start(binary(root, "vite", "vite"), ["dev", ...viteArguments]);
133
+ return done;
134
+ }
135
+
136
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
137
+ process.exitCode = await devLedger(process.argv.slice(2).filter((argument) => argument !== "--"));
138
+ }
@@ -43,6 +43,14 @@ export function assembleCheckSite(options: Required<Pick<CheckModuleOptions, "di
43
43
  mkdirSync(dirname(target), { recursive: true });
44
44
  writeFileSync(target, content, "utf8");
45
45
  }
46
+ // Required external module variables need harmless placeholders in this isolated build, not operator storage.
47
+ const configPath = join(options.work, "tenants.json");
48
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
49
+ 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
+ }
53
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
46
54
  const manifestPath = join(options.work, "package.json");
47
55
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { dependencies: Record<string, string> };
48
56
  manifest.dependencies[name] = `file:${directory}`;
@@ -82,6 +90,12 @@ export function testConfig(directory: string, work: string): string {
82
90
  { find: "$tenant/generated", replacement: join(work, ".generated", "sites", "st", "tenant.ts") },
83
91
  { find: /^\$app\/.*/ as unknown as string, replacement: join(work, "app-stub.mjs") },
84
92
  ];
93
+ // Test the same platform the composed site built, including unreleased --overrides, rather than the module's older dev dependency.
94
+ const sdk = join(work, "node_modules", "@hearthlog", "sdk");
95
+ const sdkManifest = JSON.parse(readFileSync(join(sdk, "package.json"), "utf8")) as { exports: Record<string, string> };
96
+ for (const [entry, file] of Object.entries(sdkManifest.exports)) {
97
+ if (entry.startsWith('./') && typeof file === 'string') alias.push({ find: `@hearthlog/sdk/${entry.slice(2)}`, replacement: join(sdk, file) });
98
+ }
85
99
  const stub = join(work, "node_modules", "@hearthlog", "cloudflare", "src", "testing", "cloudflare-workers.ts");
86
100
  if (existsSync(stub)) alias.push({ find: "cloudflare:workers", replacement: stub });
87
101
  const options = JSON.stringify({ root: directory, resolve: { alias } }, null, 2).replace('"find": {}', '"find": /^\\$app\\/.*/');
@@ -30,7 +30,7 @@ export interface PushFinding {
30
30
  detail: string;
31
31
  }
32
32
 
33
- const SAFE_NOREPLY = /^(?:noreply@github\.com|[0-9]*\+?[a-z0-9-]+@users\.noreply\.github\.com)$/i;
33
+ const SAFE_NOREPLY = /^(?:noreply@github\.com|[0-9]*\+?[a-z0-9-]+(?:\[bot\])?@users\.noreply\.github\.com)$/i;
34
34
 
35
35
  /** Every file under the directory except `.git`, as relative paths. */
36
36
  export function listFiles(directory: string): string[] {
@@ -54,6 +54,11 @@ export interface SiteTheme {
54
54
  }
55
55
 
56
56
  export interface SiteDefinition {
57
+ stashMediaEndpoint?: string;
58
+ stashMediaBucket?: string;
59
+ stashMediaRegion?: string;
60
+ stashMediaProxy?: string;
61
+
57
62
  id: string;
58
63
  ledgerAppName?: string;
59
64
  admin?: boolean;
@@ -95,6 +100,12 @@ export interface InfrastructureDefinition {
95
100
  galleryPublicBucket: string;
96
101
  galleryPublicWorker: string;
97
102
  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;
98
109
  /** The account's workers.dev subdomain, for example `example.workers.dev`; Worker URLs derive from it. */
99
110
  workersDevDomain: string;
100
111
  /** The invoice renderer's HTTPS origin; Invoices answers 503 without one. */
@@ -119,6 +130,13 @@ export interface InfrastructureDefinition {
119
130
 
120
131
  /** `tenants/<site>/deployment.json`: a site that runs on its own stack. */
121
132
  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
+
122
140
  schemaVersion: 1;
123
141
  /**
124
142
  * Prefix of every resource the site owns, starting with the site id, for
@@ -160,6 +178,11 @@ export interface TenantFile {
160
178
  }
161
179
 
162
180
  export interface ResolvedSite {
181
+ stashMediaEndpoint?: string;
182
+ stashMediaBucket?: string;
183
+ stashMediaRegion?: string;
184
+ stashMediaProxy?: string;
185
+
163
186
  id: string;
164
187
  ledgerAppName: string;
165
188
  features: FeatureId[];
@@ -208,10 +231,12 @@ const SITE_KEYS = [
208
231
  "dynamicEntities",
209
232
  "theme",
210
233
  "hostname",
234
+ "stashMediaEndpoint", "stashMediaBucket", "stashMediaRegion", "stashMediaProxy",
211
235
  ] as const satisfies readonly (keyof SiteDefinition)[];
212
236
  const DEPLOYMENT_KEYS = [
213
237
  "$schema", "schemaVersion", "resourcePrefix", "ledger", "accountId", "workersDevDomain", "authentication",
214
238
  "ledgerAdminEmail", "invoiceRendererOrigin", "kalApiOrigin", "accessSessionDuration", "accessGlobalSessionDuration",
239
+ "stashMediaEndpoint", "stashMediaBucket", "stashMediaRegion", "stashMediaProxy",
215
240
  ] as const;
216
241
  const TENANT_FILE_KEYS = ["$schema", "schemaVersion", ...SITE_KEYS] as const;
217
242
  const WORKERS_DEV_PATTERN = /^[a-z0-9][a-z0-9-]*\.workers\.dev$/;
@@ -240,6 +265,7 @@ const INFRASTRUCTURE_KEYS = [
240
265
  "accessSessionDuration",
241
266
  "accessGlobalSessionDuration",
242
267
  "gallerySweeper",
268
+ "stashMediaEndpoint", "stashMediaBucket", "stashMediaRegion", "stashMediaProxy",
243
269
  "accountId",
244
270
  ] as const satisfies readonly (keyof InfrastructureDefinition)[];
245
271
 
@@ -491,6 +517,7 @@ export function parseSiteDeployment(
491
517
  requireSessionDuration(`${label} ${key}`, duration);
492
518
  result[key] = duration;
493
519
  }
520
+ Object.assign(result, parseStashMediaConfiguration(deployment, label));
494
521
  if (options.standalone) {
495
522
  for (const key of ["workersDevDomain", "ledgerAdminEmail", "authentication"] as const) {
496
523
  if (result[key] === undefined) throw new TenantConfigError(`${label}: ${key} is required when there is no tenants.json`);
@@ -499,6 +526,22 @@ export function parseSiteDeployment(
499
526
  return result;
500
527
  }
501
528
 
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;
541
+ }
542
+ return result;
543
+ }
544
+
502
545
  function requireSessionDuration(label: string, duration: unknown): asserts duration is string {
503
546
  if (typeof duration !== "string" || !/^[1-9]\d*h$/.test(duration) || Number(duration.slice(0, -1)) > 720) {
504
547
  throw new TenantConfigError(`${label} must be whole hours between 1h and 720h`);
@@ -527,10 +570,10 @@ export function workersDevDomainFor(site: Pick<ResolvedSite, "deployment">, shar
527
570
  * every name from its resource prefix. Account-wide settings stay shared.
528
571
  */
529
572
  export function infrastructureFor(
530
- site: Pick<ResolvedSite, "deployment">,
573
+ site: Pick<ResolvedSite, "deployment"> & Partial<Pick<ResolvedSite, "stashMediaEndpoint" | "stashMediaBucket" | "stashMediaRegion" | "stashMediaProxy">>,
531
574
  shared: InfrastructureDefinition,
532
575
  ): InfrastructureDefinition {
533
- if (!site.deployment) return shared;
576
+ if (!site.deployment) return { ...shared, ...parseStashMediaConfiguration(site, "site") };
534
577
  const prefix = site.deployment.resourcePrefix;
535
578
  const origin = (worker: string) => `https://${worker}.${workersDevDomainFor(site, shared)}`;
536
579
  const names = {
@@ -547,6 +590,8 @@ export function infrastructureFor(
547
590
  return {
548
591
  ...shared,
549
592
  ...names,
593
+ ...parseStashMediaConfiguration(site.deployment, "site deployment"),
594
+ ...parseStashMediaConfiguration(site, "site"),
550
595
  workersDevDomain: workersDevDomainFor(site, shared),
551
596
  ...(site.deployment.authentication ? { accessTeamDomain: site.deployment.authentication.teamDomain } : {}),
552
597
  ...(site.deployment.ledgerAdminEmail ? { ledgerAdminEmail: site.deployment.ledgerAdminEmail } : {}),
@@ -670,6 +715,7 @@ export function resolveSite(
670
715
  return {
671
716
  id: site.id,
672
717
  ledgerAppName: site.ledgerAppName ?? site.id,
718
+ ...parseStashMediaConfiguration(site, label),
673
719
  features: [...features],
674
720
  ai: [...ai],
675
721
  integrations,
@@ -824,6 +870,7 @@ export function resolveInfrastructure(
824
870
  const duration = infrastructure[key];
825
871
  if (duration !== undefined) requireSessionDuration(`tenants.json infrastructure.${key}`, duration);
826
872
  }
873
+ parseStashMediaConfiguration(infrastructure, "tenants.json infrastructure");
827
874
  return infrastructure;
828
875
  }
829
876
 
@@ -78,6 +78,10 @@ export function buildTenantSchema(): Record<string, unknown> {
78
78
  workersDevDomain: { type: "string", pattern: "^[a-z0-9][a-z0-9-]*\\.workers\\.dev$", description: "The account's workers.dev subdomain." },
79
79
  invoiceRendererOrigin: httpsOrigin,
80
80
  kalApiOrigin: httpsOrigin,
81
+ stashMediaEndpoint: httpsOrigin,
82
+ stashMediaBucket: nonEmptyString,
83
+ stashMediaRegion: nonEmptyString,
84
+ stashMediaProxy: { enum: ["0", "1"] },
81
85
  ledgerAdminEmail: { type: "string", format: "email" },
82
86
  accessTeamDomain: httpsOrigin,
83
87
  accessSessionDuration: hours,
@@ -140,6 +144,10 @@ export function buildTenantSchema(): Record<string, unknown> {
140
144
  },
141
145
  },
142
146
  hostname: { type: "string", format: "uri" },
147
+ stashMediaEndpoint: httpsOrigin,
148
+ stashMediaBucket: nonEmptyString,
149
+ stashMediaRegion: nonEmptyString,
150
+ stashMediaProxy: { enum: ["0", "1"] },
143
151
  },
144
152
  },
145
153
  },
@@ -6,6 +6,7 @@ import Icons from 'unplugin-icons/vite'
6
6
  import { entityGeneratorPlugin } from './vite-plugin-entity-gen.js';
7
7
  import { tenantManifestPlugin } from './vite-plugin-tenant-manifest.js';
8
8
  import { workerAssetsPlugin } from './vite-plugin-worker-assets.js';
9
+ import { sourcePackages } from './source-packages.js';
9
10
  import { readFileSync } from 'node:fs';
10
11
  import { execFileSync } from 'node:child_process';
11
12
  import { join, resolve } from 'node:path';
@@ -20,20 +21,11 @@ import {
20
21
  localLedgerGatewayPath,
21
22
  localLedgerOriginGuardPlugin,
22
23
  localReceivedInvoiceFilesPlugin,
24
+ localStashMediaPlugin,
23
25
  } from './dev-plugins.js';
24
26
 
25
27
  import { defineConfig, searchForWorkspaceRoot, type ProxyOptions, type UserConfig } from 'vite';
26
28
 
27
- /** The `@hearthlog/*` packages the site depends on (the platform and its modules). */
28
- function hearthlogDependencies(): string[] {
29
- try {
30
- const manifest = JSON.parse(readFileSync('package.json', 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
31
- return Object.keys({ ...manifest.dependencies, ...manifest.devDependencies }).filter((name) => name.startsWith('@hearthlog/') && name !== '@hearthlog/generated');
32
- } catch {
33
- return [];
34
- }
35
- }
36
-
37
29
  function installedVersion(name: string): string {
38
30
  try {
39
31
  return JSON.parse(readFileSync(join('node_modules', name, 'package.json'), 'utf8')).version;
@@ -134,6 +126,7 @@ return defineConfig(({ command, mode }): UserConfig => {
134
126
  ? readLocalLedgerCredentialSync()
135
127
  : undefined;
136
128
  const isE2E = process.env.VITE_E2E === '1';
129
+ const sources = sourcePackages();
137
130
  const usesSameOriginLedger = Boolean(localLedgerCredential) || isE2E;
138
131
  // The dev proxy needs both: the renderer's address and its bearer token (from .dev.vars or the environment).
139
132
  const invoiceRendererOrigin = readDevVar('INVOICE_RENDERER_ORIGIN');
@@ -283,6 +276,7 @@ return defineConfig(({ command, mode }): UserConfig => {
283
276
  localLedgerOriginGuardPlugin(Boolean(localLedgerCredential)),
284
277
  localInvoicePdfGatewayFallbackPlugin(Boolean(invoiceRendererToken)),
285
278
  localReceivedInvoiceFilesPlugin(),
279
+ localStashMediaPlugin(localLedgerCredential),
286
280
  localInvoiceExtractionPlugin(),
287
281
  localAiDecisionsPlugin(isE2E),
288
282
  e2eLedgerStubPlugin(isE2E),
@@ -310,7 +304,12 @@ return defineConfig(({ command, mode }): UserConfig => {
310
304
  optimizeDeps: {
311
305
  // The platform and the modules ship Svelte and TypeScript source; installed from the registry they sit in node_modules,
312
306
  // where the dependency scan cannot resolve their relative imports. They are compiled with the site, not pre-bundled.
313
- exclude: ['sqlocal', 'microlighter', ...hearthlogDependencies()]
307
+ exclude: ['sqlocal', 'microlighter', ...sources.filter((name) => name !== '@hearthlog/generated')]
308
+ },
309
+ ssr: {
310
+ // For the same reason server rendering must compile them: externalized, Node would load their TypeScript from
311
+ // node_modules, which it refuses to strip. The pattern also catches a platform package the walk did not reach.
312
+ noExternal: [/^@hearthlog\//, ...sources],
314
313
  },
315
314
  build: {
316
315
  // Bundle Microlighter's lazy grammars, which Vite skips in dependencies by default.
@@ -336,3 +336,42 @@ export async function readJsonBody(request: import('node:http').IncomingMessage)
336
336
  return undefined;
337
337
  }
338
338
  }
339
+
340
+ /** Real S3 signing against loopback MinIO; no production credentials or Access bypass reach a deployed build. */
341
+ export function localStashMediaPlugin(credential?: import('../dev/local-development.js').LocalLedgerCredential): Plugin {
342
+ return {
343
+ name: 'local-stash-media',
344
+ configureServer(server) {
345
+ server.middlewares.use(async (incoming, outgoing, next) => {
346
+ if (!incoming.url?.startsWith('/api/stash-media/v1/')) return next();
347
+ 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' })); };
348
+ 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');
349
+ if (!credential) return respond(503, 'Start the local Ledger and run ledger:provision-local before uploading videos locally');
350
+ try {
351
+ const [{ loadModules }, { loadTenantConfig }, { activeSiteId }] = await Promise.all([import('../compose/compose-modules.js'), import('../tenants/tenant-config.js'), import('../root.js')]);
352
+ const config = await loadTenantConfig(), site = config.sites.find(site => site.id === activeSiteId());
353
+ const module = loadModules().find(module => module.manifest.server?.routes?.includes('/api/stash-media/v1') && module.manifest.features.some(feature => site?.features.some(enabled => enabled === feature.id)));
354
+ if (!module) return respond(404, 'Video media is not enabled for this site');
355
+ const endpoint = new URL(process.env.STASH_MEDIA_DEV_ENDPOINT || 'http://127.0.0.1:9000');
356
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)) return respond(503, 'Development media storage must use a loopback MinIO endpoint');
357
+ const { handleStashMediaRequest } = await server.ssrLoadModule(`${module.directory}/src/server/media.ts`);
358
+ const chunks: Buffer[] = []; let size = 0;
359
+ 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)); }
360
+ const headers = new Headers();
361
+ for (const [name, value] of Object.entries(incoming.headers)) if (typeof value === 'string') headers.set(name, value);
362
+ const request = new Request(new URL(incoming.url, `http://${incoming.headers.host}`), { method: incoming.method, headers, ...(size ? { body: Buffer.concat(chunks) } : {}) });
363
+ const env = {
364
+ STASH_MEDIA_ENDPOINT: endpoint.origin, STASH_MEDIA_BUCKET: 'hearthlog-dev', STASH_MEDIA_REGION: 'us-east-1',
365
+ STASH_MEDIA_KEY_ID: 'hearthlog-local', STASH_MEDIA_APP_KEY: 'hearthlog-local-only', LEDGER_AUTH_TOKEN: credential.token,
366
+ 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 }); } },
367
+ };
368
+ const response: Response = await handleStashMediaRequest(request, env, async () => ({ email: 'local@example.invalid' }));
369
+ outgoing.statusCode = response.status;
370
+ response.headers.forEach((value, name) => outgoing.setHeader(name, value));
371
+ if (response.body) for await (const chunk of response.body) outgoing.write(chunk);
372
+ outgoing.end();
373
+ } catch { respond(503, 'Local video storage is unavailable. Check that MinIO and the local Ledger are running'); }
374
+ });
375
+ },
376
+ };
377
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+
4
+ interface PackageManifest {
5
+ dependencies?: Record<string, string>;
6
+ devDependencies?: Record<string, string>;
7
+ peerDependencies?: Record<string, string>;
8
+ optionalDependencies?: Record<string, string>;
9
+ }
10
+
11
+ function readManifest(directory: string): PackageManifest | undefined {
12
+ try {
13
+ return JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as PackageManifest;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ /** Node's lookup of `name` from `directory`: the nearest `node_modules/<name>` up the tree, as its real path. */
20
+ function packageDirectory(name: string, directory: string): string | undefined {
21
+ for (let current = directory; ; current = dirname(current)) {
22
+ const candidate = basename(current) === 'node_modules' ? join(current, name) : join(current, 'node_modules', name);
23
+ if (existsSync(join(candidate, 'package.json'))) return realpathSync(candidate);
24
+ if (dirname(current) === current) return undefined;
25
+ }
26
+ }
27
+
28
+ /** The platform's packages and the modules (any package with a module manifest, whatever its scope) ship source. */
29
+ function shipsSource(name: string, directory: string): boolean {
30
+ return name.startsWith('@hearthlog/') || existsSync(join(directory, 'hearthlog.module.json'));
31
+ }
32
+
33
+ /**
34
+ * The packages a site installs that ship Svelte and TypeScript source instead of JavaScript: every `@hearthlog/*`
35
+ * package and every module, direct or transitive. They are reached through the site's dependencies and, from each
36
+ * of them, through their own (peer) dependencies, so a module's `@hearthlog/czech` or a private-scope module counts.
37
+ */
38
+ export function sourcePackages(root = process.cwd()): string[] {
39
+ const site = readManifest(root);
40
+ if (!site) return [];
41
+ const found = new Set<string>();
42
+ const visited = new Set<string>();
43
+ const visit = (names: Iterable<string>, from: string): void => {
44
+ for (const name of names) {
45
+ const directory = packageDirectory(name, from);
46
+ if (!directory || !shipsSource(name, directory)) continue;
47
+ found.add(name);
48
+ if (visited.has(directory)) continue;
49
+ visited.add(directory);
50
+ const manifest = readManifest(directory);
51
+ visit(Object.keys({ ...manifest?.dependencies, ...manifest?.peerDependencies, ...manifest?.optionalDependencies }), directory);
52
+ }
53
+ };
54
+ visit(Object.keys({ ...site.dependencies, ...site.devDependencies }), resolve(root));
55
+ return [...found].sort();
56
+ }
@@ -6,8 +6,13 @@ modules it ships, and everything else the app needs is written by `pnpm build:ge
6
6
  ```bash
7
7
  pnpm install
8
8
  pnpm dev # builds the generated files, then starts Vite
9
+ pnpm dev:ledger # the same, with a Ledger of its own on this machine (127.0.0.1:8787)
9
10
  pnpm build # a static build in build/
10
11
  ```
11
12
 
12
13
  `tenants.json` holds placeholder infrastructure names: it makes the site load, and it cannot be deployed until real
13
14
  values replace them.
15
+
16
+ `pnpm dev:ledger` runs the site's Ledger Worker with `wrangler dev`, gives the dev server a credential for it, and
17
+ starts Vite; Ctrl+C stops both. Its secrets, data, and credential live in `.wrangler/development/` (ignored): delete
18
+ that folder to start from an empty Ledger.
@@ -9,3 +9,6 @@ node_modules/
9
9
  # Native apps (`hearthlog apple:*`): build output and personal device settings
10
10
  /.apple-build/
11
11
  /resign.local.json
12
+ # Deployment token files and the local Ledger's secrets, data and credential (`pnpm dev:ledger`): never committed
13
+ /.wrangler/
14
+ .dev.vars
@@ -5,6 +5,7 @@
5
5
  "scripts": {
6
6
  "build:generated": "hearthlog build",
7
7
  "dev": "hearthlog build && vite dev",
8
+ "dev:ledger": "hearthlog dev-ledger",
8
9
  "build": "hearthlog build && vite build",
9
10
  "check": "hearthlog build && svelte-kit sync && tsc --noEmit -p tsconfig.json --skipLibCheck"
10
11
  }