@hearthlog/tooling 0.1.6 → 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/package.json +2 -2
- package/src/deploy/ensure-tenant.ts +19 -2
- package/src/init/check-module.ts +14 -0
- package/src/security/audit-push.ts +1 -1
- package/src/tenants/tenant-config.ts +49 -2
- package/src/tenants/tenant-schema.ts +8 -0
- package/src/vite/config.ts +2 -0
- package/src/vite/dev-plugins.ts +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hearthlog/tooling",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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.
|
package/src/init/check-module.ts
CHANGED
|
@@ -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-]
|
|
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
|
},
|
package/src/vite/config.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
localLedgerGatewayPath,
|
|
22
22
|
localLedgerOriginGuardPlugin,
|
|
23
23
|
localReceivedInvoiceFilesPlugin,
|
|
24
|
+
localStashMediaPlugin,
|
|
24
25
|
} from './dev-plugins.js';
|
|
25
26
|
|
|
26
27
|
import { defineConfig, searchForWorkspaceRoot, type ProxyOptions, type UserConfig } from 'vite';
|
|
@@ -275,6 +276,7 @@ return defineConfig(({ command, mode }): UserConfig => {
|
|
|
275
276
|
localLedgerOriginGuardPlugin(Boolean(localLedgerCredential)),
|
|
276
277
|
localInvoicePdfGatewayFallbackPlugin(Boolean(invoiceRendererToken)),
|
|
277
278
|
localReceivedInvoiceFilesPlugin(),
|
|
279
|
+
localStashMediaPlugin(localLedgerCredential),
|
|
278
280
|
localInvoiceExtractionPlugin(),
|
|
279
281
|
localAiDecisionsPlugin(isE2E),
|
|
280
282
|
e2eLedgerStubPlugin(isE2E),
|
package/src/vite/dev-plugins.ts
CHANGED
|
@@ -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
|
+
}
|