@hearthlog/tooling 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +235 -0
  2. package/bin/hearthlog.mjs +52 -0
  3. package/package.json +64 -3
  4. package/src/apply.ts +73 -0
  5. package/src/check/check-layers.ts +118 -0
  6. package/src/check/check-modules.ts +198 -0
  7. package/src/check/check-sdk-exports.ts +79 -0
  8. package/src/check/check-tenant-config.ts +208 -0
  9. package/src/cli/hearthlog.ts +212 -0
  10. package/src/cli/list-sites.ts +15 -0
  11. package/src/compose/compose-modules.ts +490 -0
  12. package/src/compose/module-scaffold.ts +194 -0
  13. package/src/deploy/artifact-compare.ts +93 -0
  14. package/src/deploy/artifact.ts +127 -0
  15. package/src/deploy/async-utils.ts +71 -0
  16. package/src/deploy/backup-tenants.ts +139 -0
  17. package/src/deploy/cloudflare-access.ts +789 -0
  18. package/src/deploy/copy-tenant.ts +395 -0
  19. package/src/deploy/dedicated-ledger.ts +77 -0
  20. package/src/deploy/deploy-ledger.ts +162 -0
  21. package/src/deploy/deploy-runner.ts +240 -0
  22. package/src/deploy/deploy-tenant.ts +170 -0
  23. package/src/deploy/deployment-security.ts +141 -0
  24. package/src/deploy/ensure-tenant.ts +988 -0
  25. package/src/deploy/event-policy.ts +23 -0
  26. package/src/deploy/frontend-dry-run.ts +18 -0
  27. package/src/deploy/ledger-credential.ts +48 -0
  28. package/src/deploy/ledger-scripts.ts +13 -0
  29. package/src/deploy/split-tenant.ts +295 -0
  30. package/src/dev/local-development.ts +288 -0
  31. package/src/dev/provision-local.ts +13 -0
  32. package/src/generate/build-generated.ts +166 -0
  33. package/src/generate/generate-brand-icons.ts +128 -0
  34. package/src/generate/generate-entities.ts +627 -0
  35. package/src/generate/generate-tenant.ts +161 -0
  36. package/src/host/core-routes.ts +39 -0
  37. package/src/host/write-host.ts +119 -0
  38. package/src/index.ts +8 -0
  39. package/src/init/check-module.ts +143 -0
  40. package/src/init/export-tenant.ts +124 -0
  41. package/src/init/init.ts +167 -0
  42. package/src/move.ts +189 -0
  43. package/src/plan.ts +160 -0
  44. package/src/public-workers/build-gallery-public.ts +114 -0
  45. package/src/public-workers/calendar-public-config.ts +27 -0
  46. package/src/public-workers/check-calendar-public-route.ts +41 -0
  47. package/src/public-workers/check-gallery-public-route.ts +43 -0
  48. package/src/public-workers/gallery-public-config.ts +22 -0
  49. package/src/receipt.ts +74 -0
  50. package/src/root.ts +16 -0
  51. package/src/security/audit-push.ts +136 -0
  52. package/src/security/forbidden-identities.ts +107 -0
  53. package/src/security/forbidden-init.ts +88 -0
  54. package/src/tenants/host-configs.ts +96 -0
  55. package/src/tenants/ledger-config.ts +57 -0
  56. package/src/tenants/lock-file.ts +45 -0
  57. package/src/tenants/module-lock.ts +58 -0
  58. package/src/tenants/tenant-config.ts +887 -0
  59. package/src/tenants/tenant-copy-plan.ts +119 -0
  60. package/src/tenants/tenant-schema.ts +171 -0
  61. package/src/tenants/tenant-wrangler.ts +37 -0
  62. package/src/tenants/wrangler-base.ts +50 -0
  63. package/src/tenants/wrangler-config.ts +266 -0
  64. package/src/vite/config.ts +310 -0
  65. package/src/vite/dev-plugins.ts +338 -0
  66. package/src/vite/vite-plugin-entity-gen.ts +70 -0
  67. package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
  68. package/src/vite/vite-plugin-worker-assets.ts +42 -0
  69. package/templates/host/app.d.ts.txt +31 -0
  70. package/templates/host/microlighter.d.ts.txt +11 -0
  71. package/templates/module/ci.yml.tmpl +44 -0
  72. package/templates/module/gitignore.tmpl +7 -0
  73. package/templates/module/release.yml.tmpl +53 -0
  74. package/templates/tenant/README.md.tmpl +13 -0
  75. package/templates/tenant/generated-package.json.tmpl +16 -0
  76. package/templates/tenant/github-workflows/deploy.yml.tmpl +110 -0
  77. package/templates/tenant/gitignore.tmpl +11 -0
  78. package/templates/tenant/package.json.tmpl +11 -0
  79. package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
  80. package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
  81. package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
  82. package/templates/tenant/tenants.json.tmpl +17 -0
  83. package/templates/tenant/tsconfig.json.tmpl +21 -0
  84. package/templates/tenant/vite.config.ts.tmpl +5 -0
  85. package/README.md +0 -3
@@ -0,0 +1,310 @@
1
+ import adapter from '@sveltejs/adapter-static';
2
+ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3
+ import { sveltekit } from '@sveltejs/kit/vite';
4
+ import tailwindcss from '@tailwindcss/vite';
5
+ import Icons from 'unplugin-icons/vite'
6
+ import { entityGeneratorPlugin } from './vite-plugin-entity-gen.js';
7
+ import { tenantManifestPlugin } from './vite-plugin-tenant-manifest.js';
8
+ import { workerAssetsPlugin } from './vite-plugin-worker-assets.js';
9
+ import { readFileSync } from 'node:fs';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { join, resolve } from 'node:path';
12
+ import { localGatewayOriginAllowed, readLocalLedgerCredentialSync } from '../dev/local-development.js';
13
+ import {
14
+ e2eLedgerStubPlugin,
15
+ localAiDecisionsPlugin,
16
+ localInvoiceExtractionPlugin,
17
+ localInvoicePdfGatewayFallbackPlugin,
18
+ localInvoiceRendererPath,
19
+ localLedgerGatewayPath,
20
+ localLedgerOriginGuardPlugin,
21
+ localReceivedInvoiceFilesPlugin,
22
+ } from './dev-plugins.js';
23
+
24
+ import { defineConfig, type ProxyOptions, type UserConfig } from 'vite';
25
+
26
+ function installedVersion(name: string): string {
27
+ try {
28
+ return JSON.parse(readFileSync(join('node_modules', name, 'package.json'), 'utf8')).version;
29
+ } catch {
30
+ return 'unknown';
31
+ }
32
+ }
33
+
34
+ function gitOutput(...args: string[]): string {
35
+ try {
36
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
37
+ } catch {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ const stackInfo = {
43
+ packages: Object.fromEntries(
44
+ [
45
+ 'svelte',
46
+ '@sveltejs/kit',
47
+ 'vite',
48
+ 'typescript',
49
+ 'tailwindcss',
50
+ 'bits-ui',
51
+ '@tanstack/svelte-table',
52
+ '@tanstack/svelte-virtual',
53
+ 'kysely',
54
+ 'sqlocal',
55
+ 'date-fns',
56
+ ].map((name) => [name, installedVersion(name)]),
57
+ ),
58
+ node: process.version.replace(/^v/, ''),
59
+ commit: gitOutput('rev-parse', '--short', 'HEAD'),
60
+ branch: gitOutput('rev-parse', '--abbrev-ref', 'HEAD'),
61
+ dirty: gitOutput('status', '--porcelain', '--untracked-files=no') !== '',
62
+ };
63
+
64
+ const buildTimestamp = process.env.STOR_BUILD_TIMESTAMP?.trim() || new Date().toISOString();
65
+ // Tenant build selection: VITE_STOR_TENANT=bravo writes the site into
66
+ // build/bravo (matching wrangler env.bravo assets.directory). The default
67
+ // tenant keeps the historical ./build output used by the top-level Worker.
68
+ const tenantId = process.env.VITE_STOR_TENANT?.trim() || 'st';
69
+ const buildDirectory = process.env.STOR_BUILD_OUTPUT_ROOT?.trim() ||
70
+ (tenantId === 'st' ? 'build' : `build/${tenantId}`);
71
+ const svelteKitOutDir = process.env.STOR_SVELTEKIT_OUT_DIR?.trim() || '.svelte-kit';
72
+ // `pnpm build:generated` writes the site's boot shell and tenant module under .generated/sites/<id>/.
73
+ const appTemplate = `.generated/sites/${tenantId}/app.html`;
74
+ // The host files `pnpm build:generated` writes (see tooling/src/host/write-host.ts).
75
+ const hostFiles = {
76
+ routes: '.generated/routes',
77
+ serviceWorker: '.generated/service-worker',
78
+ assets: '.generated/static',
79
+ appTemplate,
80
+ };
81
+ const tenantGeneratedModule = `.generated/sites/${tenantId}/tenant.ts`;
82
+
83
+ /**
84
+ * Read a Worker-style secret for the local dev proxy. Vite only exposes
85
+ * `VITE_`-prefixed values to the client, so server-only credentials come from
86
+ * the process environment or the gitignored `.dev.vars` / `.env` files.
87
+ */
88
+ function readDevVar(name: string): string | undefined {
89
+ const fromEnvironment = process.env[name]?.trim();
90
+ if (fromEnvironment) return fromEnvironment;
91
+ for (const file of ['.dev.vars', '.env']) {
92
+ try {
93
+ const line = readFileSync(file, 'utf8')
94
+ .split('\n')
95
+ .find((entry) => entry.trimStart().startsWith(`${name}=`));
96
+ if (!line) continue;
97
+ const value = line.slice(line.indexOf('=') + 1).trim();
98
+ if (value) return value.replace(/^(['"])(.*)\1$/, '$2');
99
+ } catch {
100
+ // The file is optional; try the next source.
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ export interface HearthlogConfigOptions {
107
+ /** Vitest options; only the repository that tests its packages passes them. */
108
+ test?: Record<string, unknown>;
109
+ }
110
+
111
+ /**
112
+ * The Vite configuration of a Hearthlog site: SvelteKit with the static adapter, Tailwind, icons, the site's
113
+ * manifest and entity plugins, and the local development stand-ins for the gateway Worker. A site's
114
+ * `vite.config.ts` calls this and nothing else.
115
+ */
116
+ export function hearthlogConfig(options: HearthlogConfigOptions = {}) {
117
+ return defineConfig(({ command, mode }): UserConfig => {
118
+ const localLedgerCredential =
119
+ command === 'serve' && mode === 'development'
120
+ ? readLocalLedgerCredentialSync()
121
+ : undefined;
122
+ const isE2E = process.env.VITE_E2E === '1';
123
+ const usesSameOriginLedger = Boolean(localLedgerCredential) || isE2E;
124
+ // The dev proxy needs both: the renderer's address and its bearer token (from .dev.vars or the environment).
125
+ const invoiceRendererOrigin = readDevVar('INVOICE_RENDERER_ORIGIN');
126
+ const invoiceRendererToken =
127
+ command === 'serve' && mode === 'development' && invoiceRendererOrigin
128
+ ? readDevVar('INVOICE_RENDERER_TOKEN')
129
+ : undefined;
130
+
131
+ const localLedgerProxy: Record<string, ProxyOptions> = localLedgerCredential
132
+ ? {
133
+ [localLedgerGatewayPath]: {
134
+ target: localLedgerCredential.baseUrl,
135
+ changeOrigin: false,
136
+ ws: true,
137
+ rewrite: (path) =>
138
+ path.slice(localLedgerGatewayPath.length) || '/',
139
+ configure: (proxy) => {
140
+ proxy.on('proxyReq', (proxyRequest, request) => {
141
+ if (
142
+ !localGatewayOriginAllowed(
143
+ request.headers.origin,
144
+ request.headers.host,
145
+ )
146
+ ) {
147
+ proxyRequest.destroy();
148
+ return;
149
+ }
150
+ proxyRequest.setHeader(
151
+ 'Authorization',
152
+ `Bearer ${localLedgerCredential.token}`,
153
+ );
154
+ // The deployed Worker stamps the verified Access email; locally
155
+ // there is no Access, so use the same identity the client shows.
156
+ proxyRequest.setHeader('X-Ledger-Actor', 'local@localhost');
157
+ proxyRequest.removeHeader('Origin');
158
+ });
159
+ proxy.on('proxyReqWs', (proxyRequest, request) => {
160
+ if (
161
+ !localGatewayOriginAllowed(
162
+ request.headers.origin,
163
+ request.headers.host,
164
+ )
165
+ ) {
166
+ proxyRequest.destroy();
167
+ return;
168
+ }
169
+ proxyRequest.setHeader(
170
+ 'Authorization',
171
+ `Bearer ${localLedgerCredential.token}`,
172
+ );
173
+ proxyRequest.removeHeader('Origin');
174
+ });
175
+ },
176
+ },
177
+ }
178
+ : {};
179
+ const localInvoiceRendererProxy: Record<string, ProxyOptions> =
180
+ invoiceRendererToken
181
+ ? {
182
+ [localInvoiceRendererPath]: {
183
+ target: invoiceRendererOrigin as string,
184
+ changeOrigin: true,
185
+ configure: (proxy) => {
186
+ proxy.on('proxyReq', (proxyRequest, request) => {
187
+ if (
188
+ !localGatewayOriginAllowed(
189
+ request.headers.origin,
190
+ request.headers.host,
191
+ )
192
+ ) {
193
+ proxyRequest.destroy();
194
+ return;
195
+ }
196
+ proxyRequest.setHeader(
197
+ 'Authorization',
198
+ `Bearer ${invoiceRendererToken}`,
199
+ );
200
+ proxyRequest.removeHeader('Origin');
201
+ proxyRequest.removeHeader('Referer');
202
+ });
203
+ },
204
+ },
205
+ }
206
+ : {};
207
+ const e2eLedgerProxy = isE2E ? {} : localLedgerProxy;
208
+ const devServerProxy =
209
+ (localLedgerCredential && !isE2E) || invoiceRendererToken
210
+ ? { proxy: { ...e2eLedgerProxy, ...localInvoiceRendererProxy } }
211
+ : undefined;
212
+
213
+ return {
214
+ define: {
215
+ __BUILD_TIMESTAMP__: JSON.stringify(buildTimestamp),
216
+ __STACK_INFO__: JSON.stringify(stackInfo),
217
+ // The browser always uses a same-origin gateway. The ignored
218
+ // deployment credential stays in the Vite server process.
219
+ ...(usesSameOriginLedger
220
+ ? {
221
+ 'import.meta.env.VITE_LEDGER_SERVICE_URL':
222
+ JSON.stringify(localLedgerGatewayPath),
223
+ }
224
+ : {}),
225
+ ...(usesSameOriginLedger || command === 'build'
226
+ ? { 'import.meta.env.VITE_LEDGER_AUTH_TOKEN': 'undefined' }
227
+ : {}),
228
+ },
229
+ server: {
230
+ ...devServerProxy,
231
+ // Module and SDK sources live outside src/; Kit's default list omits them.
232
+ fs: { allow: [resolve('modules'), resolve('packages')] }
233
+ },
234
+ resolve: {
235
+ alias: {
236
+ // Dedicated namespace so a tenant build can swap in its generated module.
237
+ '$tenant/generated': resolve(tenantGeneratedModule),
238
+ },
239
+ },
240
+ cacheDir: process.env.STOR_VITE_CACHE_DIR?.trim() || 'node_modules/.vite',
241
+ plugins: [
242
+ tailwindcss(),
243
+ sveltekit({
244
+ preprocess: vitePreprocess({ script: true }),
245
+ outDir: svelteKitOutDir,
246
+ files: hostFiles,
247
+ // Registration is deliberately scheduled by #lib/pwa/registration
248
+ // after local-store bootstrap; Kit's eager registration would
249
+ // bypass it.
250
+ serviceWorker: {
251
+ register: false
252
+ },
253
+ adapter: adapter({
254
+ pages: buildDirectory,
255
+ assets: buildDirectory,
256
+ fallback: 'index.html'
257
+ }),
258
+ compilerOptions: {
259
+ experimental: {
260
+ async: true
261
+ },
262
+ warningFilter: (warning) => !warning.filename?.includes('node_modules') && !warning.code.startsWith('a11y')
263
+ }
264
+ }),
265
+ entityGeneratorPlugin(),
266
+ tenantManifestPlugin(),
267
+ workerAssetsPlugin(svelteKitOutDir),
268
+ localLedgerOriginGuardPlugin(Boolean(localLedgerCredential)),
269
+ localInvoicePdfGatewayFallbackPlugin(Boolean(invoiceRendererToken)),
270
+ localReceivedInvoiceFilesPlugin(),
271
+ localInvoiceExtractionPlugin(),
272
+ localAiDecisionsPlugin(isE2E),
273
+ e2eLedgerStubPlugin(isE2E),
274
+ Icons({
275
+ compiler: 'svelte',
276
+ }),
277
+ {
278
+ name: 'configure-response-headers',
279
+ configureServer: (server) => {
280
+ server.middlewares.use((_req, res, next) => {
281
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
282
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
283
+ next();
284
+ });
285
+ },
286
+ configurePreviewServer: (server) => {
287
+ server.middlewares.use((_req, res, next) => {
288
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
289
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
290
+ next();
291
+ });
292
+ },
293
+ },
294
+ ],
295
+ optimizeDeps: {
296
+ exclude: ['sqlocal', 'microlighter']
297
+ },
298
+ build: {
299
+ // Bundle Microlighter's lazy grammars, which Vite skips in dependencies by default.
300
+ dynamicImportVarsOptions: {
301
+ exclude: [/node_modules\/(?!microlighter\/|\.pnpm\/microlighter@)/],
302
+ },
303
+ },
304
+ worker: {
305
+ format: "es"
306
+ },
307
+ ...(options.test ? { test: options.test } : {}),
308
+ };
309
+ });
310
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The Vite plugins that stand in for the Worker during local development and the hermetic browser journeys:
3
+ * the Ledger gateway origin guard, the invoice PDF, received-file, and extraction routes, the AI decisions
4
+ * route, and the Ledger stub. None of them runs in a build.
5
+ */
6
+
7
+ import type { Plugin } from 'vite';
8
+
9
+ import { localGatewayOriginAllowed } from '../dev/local-development.js';
10
+
11
+ export const localLedgerGatewayPath = '/api/ledger';
12
+ export const localInvoiceRendererPath = '/api/invoice-generation';
13
+
14
+ export function localLedgerOriginGuardPlugin(enabled: boolean): Plugin {
15
+ return {
16
+ name: 'local-ledger-origin-guard',
17
+ configureServer(server) {
18
+ if (!enabled) return;
19
+ server.middlewares.use((request, response, next) => {
20
+ if (!isLedgerGatewayRequest(request.url)) return next();
21
+ if (
22
+ localGatewayOriginAllowed(
23
+ request.headers.origin,
24
+ request.headers.host,
25
+ )
26
+ ) {
27
+ return next();
28
+ }
29
+ response.statusCode = 403;
30
+ response.setHeader('Content-Type', 'application/json');
31
+ response.end(
32
+ JSON.stringify({
33
+ error: 'Cross-origin local Ledger requests are not allowed',
34
+ code: 'origin_not_allowed',
35
+ }),
36
+ );
37
+ });
38
+ server.httpServer?.prependListener('upgrade', (request, socket) => {
39
+ if (
40
+ !isLedgerGatewayRequest(request.url) ||
41
+ localGatewayOriginAllowed(
42
+ request.headers.origin,
43
+ request.headers.host,
44
+ )
45
+ ) {
46
+ return;
47
+ }
48
+ request.url = '/__rejected-local-ledger-upgrade';
49
+ socket.write(
50
+ 'HTTP/1.1 403 Forbidden\r\n' +
51
+ 'Connection: close\r\n' +
52
+ 'Content-Length: 0\r\n\r\n',
53
+ );
54
+ socket.destroy();
55
+ });
56
+ },
57
+ };
58
+ }
59
+
60
+ export function isLedgerGatewayRequest(url: string | undefined): boolean {
61
+ if (!url) return false;
62
+ const pathname = new URL(url, 'http://local.invalid').pathname;
63
+ return (
64
+ pathname === localLedgerGatewayPath ||
65
+ pathname.startsWith(`${localLedgerGatewayPath}/`)
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Local dev runs the static app without the Stor Worker or R2. The cached
71
+ * `/api/invoice-generation/logs/*` routes therefore fall back to the
72
+ * stateless renderer (POST) and accept scoped cleanup (DELETE) locally, while
73
+ * production and staging always use the Worker's cache.
74
+ */
75
+ export function localInvoicePdfGatewayFallbackPlugin(enabled: boolean): Plugin {
76
+ return {
77
+ name: 'local-invoice-pdf-gateway-fallback',
78
+ configureServer(server) {
79
+ if (!enabled) return;
80
+ server.middlewares.use((request, response, next) => {
81
+ if (!request.url) return next();
82
+ const method = request.method?.toUpperCase();
83
+ if (method !== 'POST' && method !== 'DELETE') return next();
84
+ const url = new URL(request.url, 'http://local.invalid');
85
+ const scoped = url.pathname.match(
86
+ /^\/api\/invoice-generation\/logs\/[0-9a-fA-F-]+\/(?:pdf|invoices\/[0-9a-zA-Z_-]+)$/,
87
+ );
88
+ if (!scoped) return next();
89
+ if (method === 'DELETE') {
90
+ response.statusCode = 204;
91
+ response.setHeader('Cache-Control', 'no-store');
92
+ response.end();
93
+ return;
94
+ }
95
+ request.url = `${localInvoiceRendererPath}/pdf${url.search}`;
96
+ next();
97
+ });
98
+ },
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The app's Worker/R2 binding is absent in Vite dev. Keep original received
104
+ * PDF and ISDOC originals in this server process so the same-origin upload and
105
+ * preview flow can be exercised locally; the browser's IndexedDB copy remains
106
+ * the offline one.
107
+ */
108
+ export function localReceivedInvoiceFilesPlugin(): Plugin {
109
+ const files = new Map<string, { bytes: Buffer; name: string; contentType: string }>();
110
+ const pdfRoute = /^\/api\/received-invoice-files\/v1\/logs\/[0-9a-fA-F-]{36}\/invoices\/[0-9a-fA-F-]{36}\/files\/[0-9a-fA-F-]{36}$/;
111
+ const isdocRoute = /^\/api\/received-invoice-files\/v1\/logs\/[0-9a-fA-F-]{36}\/invoices\/[0-9a-fA-F-]{36}\/isdoc\/[0-9a-fA-F-]{36}$/;
112
+ return {
113
+ name: 'local-received-invoice-files',
114
+ configureServer(server) {
115
+ server.middlewares.use(async (request, response, next) => {
116
+ if (!request.url) return next();
117
+ const url = new URL(request.url, 'http://local.invalid');
118
+ const isPdf = pdfRoute.test(url.pathname);
119
+ const isIsdoc = !isPdf && isdocRoute.test(url.pathname);
120
+ if (!isPdf && !isIsdoc) return next();
121
+ const maxBytes = isPdf ? 25 * 1024 * 1024 : 5 * 1024 * 1024;
122
+ const contentType = isPdf ? 'application/pdf' : 'application/xml; charset=utf-8';
123
+ const defaultName = isPdf ? 'invoice.pdf' : 'invoice.isdoc';
124
+ response.setHeader('Cache-Control', 'no-store');
125
+ response.setHeader('X-Content-Type-Options', 'nosniff');
126
+ if (request.method === 'GET') {
127
+ const file = files.get(url.pathname);
128
+ if (!file) { response.statusCode = 404; response.end('File not found'); return; }
129
+ response.setHeader('Content-Type', file.contentType);
130
+ response.setHeader('Content-Disposition', `${url.searchParams.get('download') === '1' ? 'attachment' : 'inline'}; filename="${file.name.replace(/["\\]/g, '_')}"`);
131
+ response.end(file.bytes);
132
+ return;
133
+ }
134
+ if (request.method !== 'PUT') { response.statusCode = 405; response.end(); return; }
135
+ const incomingType = request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase();
136
+ const typeAccepted = isPdf
137
+ ? incomingType === 'application/pdf'
138
+ : ['application/xml', 'application/x-isdoc', 'text/xml'].includes(incomingType ?? '');
139
+ if (!typeAccepted) { response.statusCode = 415; response.end(isPdf ? 'Upload a PDF' : 'Upload ISDOC XML'); return; }
140
+ const chunks: Buffer[] = [];
141
+ let size = 0;
142
+ for await (const chunk of request) {
143
+ size += chunk.length;
144
+ if (size > maxBytes) { response.statusCode = 413; response.end('File too large'); return; }
145
+ chunks.push(chunk);
146
+ }
147
+ const bytes = Buffer.concat(chunks);
148
+ if (!hasValidSignature(bytes, isPdf)) {
149
+ response.statusCode = 415;
150
+ response.end(isPdf ? 'Invalid PDF' : 'Invalid ISDOC XML');
151
+ return;
152
+ }
153
+ let name = String(request.headers['x-stor-filename'] ?? defaultName);
154
+ try { name = decodeURIComponent(name); } catch { /* keep encoded value */ }
155
+ name = name.replace(/[\u0000-\u001f\u007f/\\]/g, '_').slice(0, 180) || defaultName;
156
+ if (!files.has(url.pathname)) files.set(url.pathname, { bytes, name, contentType });
157
+ response.setHeader('Content-Type', 'application/json');
158
+ response.end(JSON.stringify({ fileId: url.pathname.split('/').at(-1), size: files.get(url.pathname)!.bytes.length, etag: 'local' }));
159
+ });
160
+ },
161
+ };
162
+ }
163
+
164
+ /** Matches the Worker's upload signature checks for local dev. */
165
+ export function hasValidSignature(bytes: Buffer, isPdf: boolean): boolean {
166
+ if (isPdf) return bytes.subarray(0, 5).toString() === '%PDF-';
167
+ let index = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;
168
+ while (index < bytes.length && [0x09, 0x0a, 0x0d, 0x20].includes(bytes[index]!)) index += 1;
169
+ return index < bytes.length && bytes[index] === 0x3c;
170
+ }
171
+
172
+ /**
173
+ * Local dev runs without the Worker, its Access session and its secrets. The
174
+ * extraction route answers 503 with the same error shape as the deployed
175
+ * Worker when no credential is configured, so the UI hides its AI button
176
+ * instead of parsing the SPA fallback. AI reading is exercised against a
177
+ * deployed environment where INVOICE_EXTRACTION_* is provisioned.
178
+ */
179
+ export function localInvoiceExtractionPlugin(): Plugin {
180
+ const route = /^\/api\/received-invoice-extraction\/v1\/logs\/[0-9a-fA-F-]{36}$/;
181
+ return {
182
+ name: 'local-invoice-extraction',
183
+ configureServer(server) {
184
+ server.middlewares.use((request, response, next) => {
185
+ if (!request.url) return next();
186
+ const url = new URL(request.url, 'http://local.invalid');
187
+ if (!route.test(url.pathname)) return next();
188
+ response.setHeader('Cache-Control', 'no-store');
189
+ response.setHeader('X-Content-Type-Options', 'nosniff');
190
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
191
+ response.statusCode = 503;
192
+ response.end(JSON.stringify({
193
+ code: 'extraction_unconfigured',
194
+ error: 'AI invoice reading runs in the deployed Worker; complete the form manually in local development.',
195
+ }));
196
+ });
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Local dev has no Workers AI binding. The decision route answers 503 with the
203
+ * deployed Worker's error shape, so the UI hides its suggestions. With
204
+ * VITE_E2E=1 it answers from a small deterministic table instead, keeping the
205
+ * browser journeys hermetic: known fixture names get a category at 0.95, and
206
+ * everything else is `none`.
207
+ */
208
+ export function localAiDecisionsPlugin(deterministic: boolean): Plugin {
209
+ const route = /^\/api\/ai\/v1\/logs\/[0-9a-fA-F-]{36}\/decisions\/(category-pick|invoice-expense-type)$/;
210
+ const categories: Array<[RegExp, string]> = [
211
+ [/albert|billa|tesco|grocer/i, 'food'],
212
+ [/shell|fuel|taxi/i, 'transport'],
213
+ [/netflix|spotify/i, 'subscriptions'],
214
+ [/pharmacy|doctor/i, 'health'],
215
+ ];
216
+ const expenseTypes: Array<[RegExp, string]> = [
217
+ [/jetbrains|software|licen[cs]e/i, 'software'],
218
+ [/telecom|internet|mobile/i, 'telecom'],
219
+ ];
220
+ return {
221
+ name: 'local-ai-decisions',
222
+ configureServer(server) {
223
+ server.middlewares.use(async (request, response, next) => {
224
+ if (!request.url) return next();
225
+ const url = new URL(request.url, 'http://local.invalid');
226
+ const match = route.exec(url.pathname);
227
+ if (!match) return next();
228
+ response.setHeader('Cache-Control', 'no-store');
229
+ response.setHeader('X-Content-Type-Options', 'nosniff');
230
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
231
+ if (!deterministic) {
232
+ response.statusCode = 503;
233
+ response.end(JSON.stringify({
234
+ code: 'ai_unconfigured',
235
+ error: 'AI decisions run in the deployed Worker; choose values manually in local development.',
236
+ }));
237
+ return;
238
+ }
239
+ const body = await readJsonBody(request);
240
+ const items = Array.isArray(body?.items) ? body.items as Array<Record<string, unknown>> : [];
241
+ const table = match[1] === 'category-pick' ? categories : expenseTypes;
242
+ response.end(JSON.stringify({
243
+ model: 'e2e-table',
244
+ decisions: items.map((item) => {
245
+ const text = `${item.name ?? ''} ${item.supplier ?? ''} ${item.description ?? ''}`;
246
+ const hit = table.find(([pattern]) => pattern.test(text));
247
+ return { id: item.id, option: hit ? hit[1] : 'none', probability: hit ? 0.95 : 0 };
248
+ }),
249
+ }));
250
+ });
251
+ },
252
+ };
253
+ }
254
+
255
+ /**
256
+ * E2E runs without the Ledger Worker. The app still initializes its Ledger
257
+ * client (session, log selection, event downloads) before local data opens, so
258
+ * the dev server answers the read-only contract deterministically. Network
259
+ * sync is disabled in E2E, and reference tests overlay their own stateful
260
+ * browser-level mocks; this keeps `pnpm test` from depending on `ledger:dev`.
261
+ */
262
+ export function e2eLedgerStubPlugin(enabled: boolean): Plugin {
263
+ const gateway = '/api/ledger';
264
+ // A UUID-shaped log id keeps the local file and PDF middlewares
265
+ // (received-invoice files, invoice generation, extraction) matching.
266
+ const logId = 'e2e00000-0000-4000-8000-000000000000';
267
+ const session = {
268
+ appId: 'e2e',
269
+ principalId: 'owner',
270
+ capabilities: ['action-metadata-v1', 'workspace-restore-v1', 'event-excision-v1'],
271
+ };
272
+ const logs = {
273
+ logs: [{ id: logId, name: 'default', headVersion: 0, lastUpdatedAt: null }],
274
+ };
275
+ return {
276
+ name: 'e2e-ledger-stub',
277
+ configureServer(server) {
278
+ if (!enabled) return;
279
+ server.middlewares.use(async (request, response, next) => {
280
+ if (!request.url) return next();
281
+ const url = new URL(request.url, 'http://local.invalid');
282
+ if (url.pathname !== gateway && !url.pathname.startsWith(`${gateway}/`)) {
283
+ return next();
284
+ }
285
+ const method = (request.method ?? 'GET').toUpperCase();
286
+ const suffix = url.pathname.slice(gateway.length);
287
+ const respond = (status: number, body: unknown) => {
288
+ response.statusCode = status;
289
+ response.setHeader('Cache-Control', 'no-store');
290
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
291
+ response.end(JSON.stringify(body));
292
+ };
293
+ if (method === 'POST' && suffix.endsWith('/events')) {
294
+ // Acknowledge appends so the durable outbox can drain without
295
+ // a real Ledger. The stub is intentionally stateless: E2E
296
+ // resets its local database per test, so replayed events
297
+ // must not leak between tests.
298
+ const body = await readJsonBody(request);
299
+ const events = Array.isArray(body?.events) ? body.events : [];
300
+ const actionId = typeof body?.actionId === 'string' ? body.actionId : undefined;
301
+ const stored = events.map((event: Record<string, unknown>, index: number) => ({
302
+ ...event,
303
+ entityVersion: (Number(event.baseEntityVersion) || 0) + 1,
304
+ version: index + 1,
305
+ ...(actionId
306
+ ? { action: { id: actionId, index, size: events.length } }
307
+ : {}),
308
+ }));
309
+ respond(200, { newVersion: stored.length, events: stored });
310
+ return;
311
+ }
312
+ if (method !== 'GET') return next();
313
+ let body: unknown;
314
+ if (suffix === '/session') body = session;
315
+ else if (suffix === '/logs') body = logs;
316
+ else if (suffix.endsWith('/events')) body = { events: [] };
317
+ else {
318
+ respond(404, { error: 'Not found', code: 'not_found' });
319
+ return;
320
+ }
321
+ respond(200, body);
322
+ });
323
+ },
324
+ };
325
+ }
326
+
327
+ export async function readJsonBody(request: import('node:http').IncomingMessage): Promise<Record<string, unknown> | undefined> {
328
+ const chunks: Buffer[] = [];
329
+ for await (const chunk of request) {
330
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
331
+ }
332
+ if (chunks.length === 0) return undefined;
333
+ try {
334
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>;
335
+ } catch {
336
+ return undefined;
337
+ }
338
+ }