@dokebi/template-inertia 0.4.2 → 0.4.5

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.
@@ -0,0 +1,633 @@
1
+ // D-0129: which files the Vite dev server of an Inertia app may serve. Vite's default
2
+ // `server.fs.allow` is the workspace root, so before this the dev routes of `dokebi dev` answered
3
+ // every file of the project: `GET /build/dokebi.yaml?raw` returned the whole configuration with
4
+ // its secrets, `/build/src/main.ts?raw` the server code, and through a `devHosts` tunnel all of it
5
+ // was public. The policy is enforced by the framework, not by each app's vite.config:
6
+ //
7
+ // - `config` hook: `server.fs.strict: true`, an allow list narrowed to what the browser needs (the
8
+ // views folders of the apps, the folders of the browser entries, node_modules, Vite's client,
9
+ // `allow` of the plugin options) and a deny list that always holds DEV_FILES_DENY. Vite merges
10
+ // the patch into the app's config: the app may add to allow and deny, never remove an entry.
11
+ // - `configResolved`: strict turned off or deny entries removed by another plugin are put back,
12
+ // with a warning.
13
+ // - a middleware before Vite's own, which judges every request again on its decoded, normalized
14
+ // path and on the real path of symlinks (the deny list, the allow list, the modules the served
15
+ // modules import), refuses Vite's open-in-editor endpoint to remote hosts, and answers a remote
16
+ // host (a `devHosts` tunnel) a short 403 without paths, also in place of Vite's own 403 page.
17
+ // On loopback the 403 keeps the details for the developer.
18
+ //
19
+ // `dokebiInertia()` carries this policy; the Vite host of `dokebi dev` adds `dokebiDevFiles()` for
20
+ // a vite.config without it (older projects), so upgrading the package is enough.
21
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
22
+ import { realpath } from 'node:fs/promises';
23
+ import { isIP } from 'node:net';
24
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
25
+ import { diagnostics } from '@dokebi/core/libraries';
26
+ const { describeError } = diagnostics;
27
+ /**
28
+ * Files the dev server never serves, whatever the app's config says (Vite globs: a pattern
29
+ * without `/` matches the name in every folder; letter case is ignored). Vite's own defaults are
30
+ * included, because a deny list replaces them.
31
+ */
32
+ export const DEV_FILES_DENY = Object.freeze([
33
+ // Vite 8's defaults.
34
+ '.env',
35
+ '.env.*',
36
+ '*.{crt,pem,key,p12,pfx,cer,der}',
37
+ '.npmrc',
38
+ '.yarnrc.yml',
39
+ '**/.git/**',
40
+ // The framework's configuration (every secret of the app) and its state and backups.
41
+ 'dokebi.yaml',
42
+ 'dokebi.*.yaml',
43
+ 'dokebi.yml',
44
+ 'dokebi.*.yml',
45
+ 'dokebi.json',
46
+ '**/.dokebi/**',
47
+ // Compose files with database passwords (dokebi init / `dokebi dev` infra).
48
+ 'compose.yaml',
49
+ 'compose.yml',
50
+ 'compose.*.yaml',
51
+ 'compose.*.yml',
52
+ 'docker-compose*.yaml',
53
+ 'docker-compose*.yml',
54
+ // Keys, credentials and local databases.
55
+ '*.{p8,jks,keystore,csr,kdbx}',
56
+ 'id_{rsa,dsa,ecdsa,ed25519}*',
57
+ '**/.ssh/**',
58
+ '.yarnrc',
59
+ '.netrc',
60
+ '.pgpass',
61
+ '.htpasswd',
62
+ '*.{sqlite,sqlite3,db}',
63
+ '*.backup',
64
+ ]);
65
+ /**
66
+ * Header the app process sets on every request it forwards to the Vite host: `loopback` or
67
+ * `remote`, from the browser's socket peer, the forwarding headers and the host name it judged
68
+ * (isRemoteRequest, §12). Any value the browser sent is replaced. Without it (Vite run on its
69
+ * own) the Vite host judges its own socket, the forwarding headers and the Host header.
70
+ */
71
+ export const DEV_CLIENT_HEADER = 'x-dokebi-dev-client';
72
+ /** The whole 403 a remote host gets: no path, no allow list. */
73
+ export const FORBIDDEN_SHORT = '403 Forbidden\n';
74
+ /** Whether a host name (no port, no brackets) is this machine: a remote one gets no details. */
75
+ export function isLoopbackHost(name) {
76
+ const host = name.toLowerCase().replace(/\.$/, '');
77
+ if (host === 'localhost' || host.endsWith('.localhost'))
78
+ return true;
79
+ if (isIP(host) === 4)
80
+ return host.startsWith('127.');
81
+ if (isIP(host) === 6)
82
+ return host === '::1' || host === '0:0:0:0:0:0:0:1' || /^::ffff:127\./.test(host);
83
+ return false;
84
+ }
85
+ /** `Host` header → host name without port and brackets. */
86
+ function hostName(header) {
87
+ const value = Array.isArray(header) ? (header[0] ?? '') : (header ?? '');
88
+ return value
89
+ .trim()
90
+ .replace(/^\[([^\]]*)\](?::\d+)?$/, '$1')
91
+ .replace(/:\d+$/, '');
92
+ }
93
+ /**
94
+ * Headers a proxy or tunnel adds (D-0129 §12). Any of them makes a request remote: a tunnel
95
+ * client runs on this machine (its socket is loopback) and may rewrite Host to localhost, so
96
+ * neither the socket nor the Host header alone tells a local browser from a forwarded one.
97
+ */
98
+ export const FORWARDING_HEADERS = Object.freeze([
99
+ 'forwarded',
100
+ 'x-forwarded-for',
101
+ 'x-forwarded-host',
102
+ 'x-forwarded-proto',
103
+ 'x-forwarded-port',
104
+ 'x-forwarded-server',
105
+ 'x-original-forwarded-for',
106
+ 'x-real-ip',
107
+ 'x-client-ip',
108
+ 'x-cluster-client-ip',
109
+ 'true-client-ip',
110
+ 'cf-connecting-ip',
111
+ 'cf-connecting-ipv6',
112
+ 'cf-ray',
113
+ 'cf-visitor',
114
+ 'cdn-loop',
115
+ 'fastly-client-ip',
116
+ 'fly-client-ip',
117
+ 'x-azure-clientip',
118
+ 'x-appengine-user-ip',
119
+ 'via',
120
+ ]);
121
+ /** Whether a socket peer address is this machine (127.0.0.0/8, ::1, ::ffff:127.x). */
122
+ export function isLoopbackAddress(address) {
123
+ return address !== undefined && isIP(address) !== 0 && isLoopbackHost(address);
124
+ }
125
+ /**
126
+ * Whether a request comes from another machine (a `devHosts` tunnel, a proxy, a LAN address).
127
+ * Local, and so shown details, only when the socket peer is loopback, no forwarding header is
128
+ * present, the Host is a loopback name and the app process (DEV_CLIENT_HEADER, when set) said
129
+ * loopback too. An unknown peer counts as remote.
130
+ */
131
+ export function isRemoteRequest(headers, peer) {
132
+ const marker = headers[DEV_CLIENT_HEADER];
133
+ if (marker !== undefined && marker !== 'loopback')
134
+ return true;
135
+ if (FORWARDING_HEADERS.some((name) => headers[name] !== undefined))
136
+ return true;
137
+ if (!isLoopbackAddress(peer))
138
+ return true;
139
+ return !isLoopbackHost(hostName(headers.host));
140
+ }
141
+ /**
142
+ * A Vite deny glob as a regular expression on an absolute `/` path, matched like Vite matches it
143
+ * (picomatch with `dot` and `nocase`; a pattern without `/` matches in every folder). Supports
144
+ * `*`, `**`, `?` and `{a,b}`, which is all DEV_FILES_DENY and ordinary app entries use.
145
+ */
146
+ export function denyPattern(glob) {
147
+ const pattern = glob.includes('/') ? glob : `**/${glob}`;
148
+ const literal = (text) => text
149
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
150
+ .replace(/\*/g, '[^/]*')
151
+ .replace(/\?/g, '[^/]');
152
+ let source = '';
153
+ let index = 0;
154
+ while (index < pattern.length) {
155
+ if (pattern.startsWith('**/', index)) {
156
+ source += '(?:.*/)?';
157
+ index += 3;
158
+ }
159
+ else if (pattern.startsWith('/**', index) && index + 3 === pattern.length) {
160
+ // A folder pattern (`**/.git/**`) matches the folder itself and everything in it.
161
+ source += '(?:/.*)?';
162
+ index += 3;
163
+ }
164
+ else if (pattern.startsWith('**', index)) {
165
+ source += '.*';
166
+ index += 2;
167
+ }
168
+ else if (pattern[index] === '{' && pattern.indexOf('}', index) !== -1) {
169
+ const close = pattern.indexOf('}', index);
170
+ source += `(?:${pattern
171
+ .slice(index + 1, close)
172
+ .split(',')
173
+ .map(literal)
174
+ .join('|')})`;
175
+ index = close + 1;
176
+ }
177
+ else {
178
+ source += literal(pattern[index]);
179
+ index += 1;
180
+ }
181
+ }
182
+ return new RegExp(`^${source}$`, 'i');
183
+ }
184
+ /** Folders under the Vite root that hold server code: never allowed as a whole for an entry. */
185
+ const SERVER_FOLDERS = /^(?:|src|src\/apps|src\/apps\/[^/]+|src\/shared|src\/infra|tests?|scripts|tools)$/;
186
+ /** An app's views folder in the framework layout (D-0031): `src/apps/<app>/views/…`. */
187
+ const VIEWS = /^src\/apps\/[^/]+\/views(?:\/|$)/;
188
+ /** Vite's own and plugin URLs that name no file: passed to Vite as they are. */
189
+ const VIRTUAL = ['/@vite/', '/@id/__x00__', '/@dokebi-served/', '/@react-refresh'];
190
+ /** Vite's open-in-editor endpoint (launch-editor), which answers this machine only. */
191
+ const isEditor = (path) => path === '/__open-in-editor' || path.startsWith('/__open-in-editor/');
192
+ const slash = (path) => (sep === '/' ? path : path.split(sep).join('/'));
193
+ const within = (dir, path) => path === dir || path.startsWith(dir.endsWith('/') ? dir : `${dir}/`);
194
+ /** The project-relative path of `path` with `/`, or undefined when it is outside `root`. */
195
+ function inside(root, path) {
196
+ const rel = slash(relative(root, path));
197
+ return rel === '..' || rel.startsWith('../') || isAbsolute(rel) ? undefined : rel;
198
+ }
199
+ /** Whether `plugins` (Vite's raw, possibly nested list) holds a plugin named `name`. */
200
+ function hasPlugin(plugins, name) {
201
+ if (Array.isArray(plugins))
202
+ return plugins.some((item) => hasPlugin(item, name));
203
+ return (typeof plugins === 'object' && plugins !== null && plugins.name === name);
204
+ }
205
+ /** The entry files of the browser build: `build.rolldownOptions.input` (or rollup's), literal `optimizeDeps.entries`. */
206
+ function entryFiles(config, root) {
207
+ const values = (input) => typeof input === 'string'
208
+ ? [input]
209
+ : Array.isArray(input)
210
+ ? input
211
+ : input && typeof input === 'object'
212
+ ? Object.values(input)
213
+ : [];
214
+ const files = [
215
+ ...values(config.build?.rolldownOptions?.input ?? config.build?.rollupOptions?.input),
216
+ ...values(config.optimizeDeps?.entries).filter((entry) => typeof entry === 'string' && !/[*?{}[\]!]/.test(entry)),
217
+ ];
218
+ return files
219
+ .filter((file) => typeof file === 'string' && file.length > 0)
220
+ .map((file) => slash(resolve(root, file)));
221
+ }
222
+ /** The views folders of the apps under `root` (the framework layout), as absolute paths. */
223
+ function viewFolders(root) {
224
+ const apps = join(root, 'src', 'apps');
225
+ if (!existsSync(apps))
226
+ return [];
227
+ return readdirSync(apps, { withFileTypes: true })
228
+ .filter((entry) => entry.isDirectory() && existsSync(join(apps, entry.name, 'views')))
229
+ .map((entry) => slash(join(apps, entry.name, 'views')))
230
+ .sort();
231
+ }
232
+ /** Every node_modules from `root` up (hoisted workspace installs), as absolute paths. */
233
+ function moduleFolders(root) {
234
+ const found = [];
235
+ for (let dir = root;; dir = dirname(dir)) {
236
+ const candidate = join(dir, 'node_modules');
237
+ if (existsSync(candidate))
238
+ found.push(slash(candidate));
239
+ if (dirname(dir) === dir)
240
+ return found;
241
+ }
242
+ }
243
+ function realOr(path) {
244
+ try {
245
+ return slash(realpathSync(path));
246
+ }
247
+ catch (error) {
248
+ const code = error.code;
249
+ if (code === 'ENOENT' || code === 'ENOTDIR')
250
+ return path;
251
+ throw error;
252
+ }
253
+ }
254
+ /**
255
+ * The dev file policy of one Vite config (one plugin instance). `owner` `dev-host` is the copy the
256
+ * Vite host of `dokebi dev` adds: it stays inactive when the app's config has `dokebiInertia()`.
257
+ */
258
+ export class DevFilesPolicy {
259
+ #logger;
260
+ #options;
261
+ #owner;
262
+ #active = false;
263
+ #root = '';
264
+ #realRoot = '';
265
+ #base = '/';
266
+ #fs;
267
+ #deny = [];
268
+ #safe;
269
+ #allowSeen = -1;
270
+ #allowReal = [];
271
+ constructor(logger, options, owner) {
272
+ this.#logger = logger;
273
+ this.#options = options;
274
+ this.#owner = owner;
275
+ }
276
+ /** The deny entries this policy always keeps (DEV_FILES_DENY and the plugin's `deny`). */
277
+ get required() {
278
+ return [...new Set([...DEV_FILES_DENY, ...(this.#options.deny ?? [])])];
279
+ }
280
+ /** The `config` hook (dev server only): the policy as a patch, and warnings for a weaker config. */
281
+ config(raw, command) {
282
+ this.#active = false;
283
+ if (command !== 'serve')
284
+ return null;
285
+ const config = (raw ?? {});
286
+ if (this.#owner === 'dev-host' && hasPlugin(config.plugins, 'dokebi-inertia')) {
287
+ this.#logger.log('debug', 'Inertia dev file policy comes from dokebiInertia() of the config');
288
+ return null;
289
+ }
290
+ this.#active = true;
291
+ const root = slash(resolve(typeof config.root === 'string' ? config.root : process.cwd()));
292
+ const fs = config.server?.fs;
293
+ if (fs?.strict === false)
294
+ this.#logger.log('warn', 'server.fs.strict: false is ignored: the Inertia dev server keeps strict (D-0129)', { code: 'INERTIA_DEV_FILES_STRICT' });
295
+ if (Array.isArray(fs?.deny)) {
296
+ const own = new Set(fs.deny);
297
+ const kept = this.required.filter((entry) => !own.has(entry));
298
+ if (kept.length)
299
+ this.#logger.log('warn', "server.fs.deny of the Vite config does not replace the framework's deny list: its entries are added and the framework's stay (D-0129)", { code: 'INERTIA_DEV_FILES_DENY', kept });
300
+ }
301
+ if (Array.isArray(fs?.allow))
302
+ for (const entry of fs.allow) {
303
+ if (typeof entry !== 'string')
304
+ continue;
305
+ const path = slash(resolve(root, entry));
306
+ const rel = inside(root, path);
307
+ if (within(path, root) || (rel !== undefined && SERVER_FOLDERS.test(rel)))
308
+ this.#logger.log('warn', 'server.fs.allow of the Vite config lets the dev server serve server code; allow only the folders the browser loads (D-0129)', { code: 'INERTIA_DEV_FILES_ALLOW', allow: entry });
309
+ }
310
+ const views = viewFolders(root);
311
+ const entries = entryFiles(config, root);
312
+ const allow = new Set(views);
313
+ for (const file of entries) {
314
+ const folder = slash(dirname(file));
315
+ const rel = inside(root, folder);
316
+ // An entry beside server code (the root, src/, an app folder) allows only itself; the
317
+ // modules it imports are served because it imports them.
318
+ if (within(folder, root) || (rel !== undefined && SERVER_FOLDERS.test(rel)))
319
+ allow.add(file);
320
+ else if (!views.some((view) => within(view, folder)))
321
+ allow.add(folder);
322
+ }
323
+ if (views.length === 0 && entries.length === 0) {
324
+ // No browser folder to narrow to (not the framework layout, no build input): the root,
325
+ // minus the deny list.
326
+ this.#logger.log('warn', 'No views folder and no build input found: the Inertia dev server serves the Vite root, minus the deny list (D-0129). Set build.rollupOptions.input or dokebiInertia({ allow })', { code: 'INERTIA_DEV_FILES_ROOT', root });
327
+ allow.add(root);
328
+ }
329
+ for (const folder of moduleFolders(root))
330
+ allow.add(folder);
331
+ for (const extra of this.#options.allow ?? [])
332
+ allow.add(slash(resolve(root, extra)));
333
+ const patch = {
334
+ server: { fs: { strict: true, allow: [...allow], deny: this.required } },
335
+ };
336
+ this.#logger.log('debug', 'Inertia dev file policy prepared', {
337
+ owner: this.#owner,
338
+ root,
339
+ allow: patch.server.fs.allow,
340
+ deny: patch.server.fs.deny.length,
341
+ });
342
+ return patch;
343
+ }
344
+ /** The `configResolved` hook: repairs what a later plugin loosened and keeps the live config. */
345
+ configResolved(config) {
346
+ if (config.command !== 'serve')
347
+ this.#active = false;
348
+ if (this.#active &&
349
+ this.#owner === 'dev-host' &&
350
+ config.plugins?.some((plugin) => plugin.name === 'dokebi-inertia'))
351
+ this.#active = false;
352
+ const fs = config.server?.fs;
353
+ if (!this.#active || !fs) {
354
+ this.#active = false;
355
+ return;
356
+ }
357
+ this.#root = slash(config.root ?? process.cwd());
358
+ this.#realRoot = realOr(this.#root);
359
+ this.#base = config.base || '/';
360
+ this.#fs = fs;
361
+ this.#deny = [];
362
+ this.#safe = config.safeModulePaths instanceof Set ? config.safeModulePaths : undefined;
363
+ this.#repair();
364
+ if (config.cacheDir && !fs.allow.some((dir) => within(dir, slash(config.cacheDir ?? ''))))
365
+ fs.allow.push(slash(config.cacheDir));
366
+ this.#allowSeen = -1;
367
+ this.#logger.log('debug', 'Inertia dev file policy active', {
368
+ owner: this.#owner,
369
+ root: this.#root,
370
+ base: this.#base,
371
+ allow: fs.allow,
372
+ deny: fs.deny.length,
373
+ importGraph: this.#safe !== undefined,
374
+ });
375
+ }
376
+ /**
377
+ * Puts back strict and the required deny entries when a plugin loosened them (its
378
+ * `configResolved` runs after this one's, so this runs again when the server is configured and
379
+ * when a request finds strict off), with a warning; compiles the deny list the middleware uses.
380
+ */
381
+ #repair() {
382
+ const fs = this.#fs;
383
+ if (!fs)
384
+ return;
385
+ if (fs.strict !== true) {
386
+ this.#logger.log('warn', 'server.fs.strict was turned off after the Inertia dev file policy; it is on again (D-0129)', { code: 'INERTIA_DEV_FILES_STRICT', restored: true });
387
+ fs.strict = true;
388
+ }
389
+ const present = new Set(fs.deny);
390
+ const restored = this.required.filter((entry) => !present.has(entry));
391
+ if (restored.length) {
392
+ this.#logger.log('warn', 'Deny entries of the Inertia dev file policy were removed from server.fs.deny; they are kept (D-0129)', { code: 'INERTIA_DEV_FILES_DENY', restored });
393
+ fs.deny.push(...restored);
394
+ }
395
+ if (restored.length || this.#deny.length !== fs.deny.length)
396
+ this.#deny = fs.deny.map((glob) => ({ glob, pattern: denyPattern(glob) }));
397
+ }
398
+ /** The middleware to add before Vite's own (undefined when this copy is inactive). */
399
+ middleware() {
400
+ if (!this.#active)
401
+ return undefined;
402
+ this.#repair();
403
+ return (request, response, next) => {
404
+ const fs = this.#fs;
405
+ if (fs && (fs.strict !== true || fs.deny.length !== this.#deny.length))
406
+ this.#repair();
407
+ const headers = request.headers ?? {};
408
+ const remote = isRemoteRequest(headers, request.socket?.remoteAddress);
409
+ if (remote)
410
+ quietForbidden(response);
411
+ const url = String(request.url ?? '/');
412
+ // Every method is judged: Vite's file middlewares (sirv: the root, `/@fs/`, public) answer
413
+ // any method, so a POST to a symlink in a views folder returned its target (Vite checks the
414
+ // link's own path only, audit R5). Only Vite's ping (Accept: text/x-vite-ping), which Vite
415
+ // answers 204 for every path and method before any file, skips the judgement; its
416
+ // open-in-editor middleware runs before the ping answer, so the editor rule holds for it too.
417
+ if (headers.accept === 'text/x-vite-ping') {
418
+ const path = remote ? this.#normalize(url) : undefined;
419
+ if (typeof path === 'string' && isEditor(path))
420
+ this.#refuse(response, { reason: 'editor', path }, remote);
421
+ else
422
+ next();
423
+ return;
424
+ }
425
+ this.#judge(url, remote).then((refusal) => {
426
+ if (refusal === undefined)
427
+ next();
428
+ else
429
+ this.#refuse(response, refusal, remote);
430
+ }, (error) => {
431
+ // Fail closed: a check that cannot decide serves nothing.
432
+ this.#logger.log('error', 'Inertia dev file check failed', {
433
+ code: 'INERTIA_DEV_FILES_FAILED',
434
+ path: url.slice(0, 200),
435
+ error: describeError(error),
436
+ });
437
+ this.#refuse(response, { reason: 'failed', path: url }, remote);
438
+ });
439
+ };
440
+ }
441
+ /**
442
+ * The path of a request URL as any later reader could make of it: the base removed, decoded
443
+ * until it no longer changes (`%252e` too), separators and dot segments normalized; a refusal
444
+ * when it does not decode to a path.
445
+ */
446
+ #normalize(url) {
447
+ let path = url.split('?', 1)[0]?.split('#', 1)[0] ?? '/';
448
+ if (this.#base !== '/' && path.startsWith(this.#base))
449
+ path = path.slice(this.#base.length - 1);
450
+ // Decoded until it no longer changes (`%252e` too), separators and dot segments normalized:
451
+ // the policy judges what any later reader could make of the URL.
452
+ let decoded = path;
453
+ for (let round = 0; round < 4; round++) {
454
+ let next;
455
+ try {
456
+ next = decodeURIComponent(decoded);
457
+ }
458
+ catch (error) {
459
+ if (!(error instanceof URIError))
460
+ throw error;
461
+ return { reason: 'malformed', path };
462
+ }
463
+ if (next === decoded)
464
+ break;
465
+ decoded = next;
466
+ }
467
+ if (decoded.includes('\0'))
468
+ return { reason: 'malformed', path };
469
+ let normalized = posix.normalize(decoded.replaceAll('\\', '/'));
470
+ if (!normalized.startsWith('/'))
471
+ normalized = `/${normalized}`;
472
+ return normalized;
473
+ }
474
+ async #judge(url, remote) {
475
+ const normalized = this.#normalize(url);
476
+ if (typeof normalized !== 'string')
477
+ return normalized;
478
+ if (isEditor(normalized))
479
+ return remote ? { reason: 'editor', path: normalized } : undefined;
480
+ if (VIRTUAL.some((prefix) => normalized.startsWith(prefix)))
481
+ return undefined;
482
+ if (normalized.startsWith('/@fs/'))
483
+ return this.#file(normalized.slice(4), normalized);
484
+ if (normalized.startsWith('/@id/')) {
485
+ const id = normalized.slice(5);
486
+ const rule = this.#denied(id);
487
+ if (rule)
488
+ return { reason: 'denied', path: normalized, rule };
489
+ return id.startsWith('/') ? this.#file(join(this.#root, id), normalized) : undefined;
490
+ }
491
+ if (normalized.startsWith('/@')) {
492
+ // A plugin's virtual URL, unless it names a file under the root.
493
+ const rule = this.#denied(normalized);
494
+ if (rule)
495
+ return { reason: 'denied', path: normalized, rule };
496
+ const file = join(this.#root, normalized);
497
+ return existsSync(file) ? this.#file(file, normalized) : undefined;
498
+ }
499
+ return this.#file(join(this.#root, normalized), normalized);
500
+ }
501
+ /** A file path (lexical, absolute): the deny list on it and on its real path, then allow. */
502
+ async #file(lexical, shown) {
503
+ const file = slash(lexical);
504
+ const lexicalRule = this.#denied(file);
505
+ if (lexicalRule)
506
+ return { reason: 'denied', path: shown, rule: lexicalRule };
507
+ let real;
508
+ try {
509
+ real = slash(await realpath(file));
510
+ }
511
+ catch (error) {
512
+ const code = error.code;
513
+ if (code === 'ENOENT' || code === 'ENOTDIR')
514
+ real = null;
515
+ else {
516
+ if (this.#logger.isEnabled('debug'))
517
+ this.#logger.log('debug', 'Inertia dev file check cannot read a path', {
518
+ path: shown.slice(0, 200),
519
+ error: describeError(error),
520
+ });
521
+ return { reason: 'unreadable', path: shown };
522
+ }
523
+ }
524
+ // Missing: Vite answers 404, or resolves it (an extension) and checks the allow list itself.
525
+ if (real === null)
526
+ return undefined;
527
+ const realRule = this.#denied(real);
528
+ if (realRule)
529
+ return { reason: 'denied', path: shown, rule: realRule };
530
+ // The real path decides: a link inside a views folder to server code is server code.
531
+ return this.#allowed(real) ? undefined : { reason: 'outside', path: shown };
532
+ }
533
+ #denied(path) {
534
+ const subject = path.startsWith('/') ? path : `/${path}`;
535
+ return this.#deny.find(({ pattern }) => pattern.test(subject))?.glob;
536
+ }
537
+ #allowed(path) {
538
+ const safe = this.#safe;
539
+ if (safe && (safe.has(path) || (path.endsWith('.map') && safe.has(path.slice(0, -4)))))
540
+ return true;
541
+ const allow = this.#fs?.allow ?? [];
542
+ if (this.#allowSeen !== allow.length) {
543
+ this.#allowReal = allow.map(realOr);
544
+ this.#allowSeen = allow.length;
545
+ }
546
+ if (allow.some((dir) => within(dir, path)) || this.#allowReal.some((dir) => within(dir, path)))
547
+ return true;
548
+ // An app added while the dev server runs: its views folder is allowed like the others.
549
+ for (const root of new Set([this.#root, this.#realRoot])) {
550
+ const rel = inside(root, path);
551
+ const match = rel === undefined ? null : VIEWS.exec(rel);
552
+ if (!match)
553
+ continue;
554
+ const folder = slash(join(root, match[0].replace(/\/$/, '')));
555
+ if (existsSync(folder) && statSync(folder).isDirectory()) {
556
+ allow.push(folder);
557
+ this.#logger.log('debug', 'Inertia dev file policy allows a new views folder', {
558
+ folder,
559
+ });
560
+ return true;
561
+ }
562
+ }
563
+ return false;
564
+ }
565
+ #refuse(response, refusal, remote) {
566
+ if (this.#logger.isEnabled('debug'))
567
+ this.#logger.log('debug', 'Inertia dev server refused a file', {
568
+ path: refusal.path.slice(0, 200),
569
+ reason: refusal.reason,
570
+ rule: refusal.rule,
571
+ remote,
572
+ });
573
+ if (response.headersSent) {
574
+ response.end();
575
+ return;
576
+ }
577
+ response.statusCode = 403;
578
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
579
+ response.setHeader('cache-control', 'no-store');
580
+ if (remote) {
581
+ response.end(FORBIDDEN_SHORT);
582
+ return;
583
+ }
584
+ const why = {
585
+ denied: `it matches ${refusal.rule ?? 'an entry'} of server.fs.deny`,
586
+ outside: 'it is outside the folders the browser loads (server.fs.allow)',
587
+ malformed: 'the URL does not decode to a path',
588
+ editor: 'open-in-editor answers this machine only',
589
+ unreadable: 'its path cannot be read',
590
+ failed: 'the file check failed (see the dokebi dev output)',
591
+ }[refusal.reason];
592
+ response.end(`403 Restricted (D-0129): ${refusal.path} is not served by the Vite dev server: ${why}.\n` +
593
+ `Served folders (server.fs.allow):\n${(this.#fs?.allow ?? []).map((dir) => `- ${dir}`).join('\n')}\n` +
594
+ 'Add a folder the browser loads with dokebiInertia({ allow: [...] }) in vite.config.ts; files that hold secrets or server code stay out.\n');
595
+ }
596
+ }
597
+ /**
598
+ * For a remote request: any 403 (Vite's own page names the absolute path and the allow list)
599
+ * goes out as FORBIDDEN_SHORT instead.
600
+ */
601
+ function quietForbidden(response) {
602
+ const write = response.write?.bind(response);
603
+ const end = response.end.bind(response);
604
+ const forbidden = () => response.statusCode === 403 && response.headersSent !== true;
605
+ if (write)
606
+ response.write = (chunk, ...rest) => forbidden() ? true : write(chunk, ...rest);
607
+ response.end = (...args) => {
608
+ if (!forbidden())
609
+ return end(...args);
610
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
611
+ response.removeHeader?.('content-length');
612
+ return end(FORBIDDEN_SHORT);
613
+ };
614
+ }
615
+ /**
616
+ * The dev file policy as a plugin of its own, for a vite.config without `dokebiInertia()` (which
617
+ * carries the same policy): the Vite host of `dokebi dev` adds it; it stays inactive next to
618
+ * `dokebiInertia()`.
619
+ */
620
+ export function dokebiDevFiles(logger, options = {}) {
621
+ const policy = new DevFilesPolicy(logger, options, 'dev-host');
622
+ return {
623
+ name: 'dokebi-inertia:dev-files',
624
+ enforce: 'pre',
625
+ config: (config, env) => policy.config(config, env.command),
626
+ configResolved: (config) => policy.configResolved(config),
627
+ configureServer(server) {
628
+ const guard = policy.middleware();
629
+ if (guard)
630
+ server.middlewares.use(guard);
631
+ },
632
+ };
633
+ }