@lenne.tech/nest-server 11.16.1 → 11.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.env.js +8 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/decorators/response-model.decorator.d.ts +3 -0
- package/dist/core/common/decorators/response-model.decorator.js +8 -0
- package/dist/core/common/decorators/response-model.decorator.js.map +1 -0
- package/dist/core/common/helpers/db.helper.js +2 -2
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/filter.helper.js +3 -3
- package/dist/core/common/helpers/filter.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.js +2 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/interceptor.helper.d.ts +3 -0
- package/dist/core/common/helpers/interceptor.helper.js +84 -0
- package/dist/core/common/helpers/interceptor.helper.js.map +1 -0
- package/dist/core/common/helpers/service.helper.d.ts +1 -0
- package/dist/core/common/helpers/service.helper.js +1 -0
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.d.ts +2 -0
- package/dist/core/common/interceptors/check-security.interceptor.js +43 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interceptors/response-model.interceptor.d.ts +13 -0
- package/dist/core/common/interceptors/response-model.interceptor.js +107 -0
- package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -0
- package/dist/core/common/interceptors/translate-response.interceptor.d.ts +8 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js +85 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +16 -0
- package/dist/core/common/middleware/request-context.middleware.d.ts +5 -0
- package/dist/core/common/middleware/request-context.middleware.js +29 -0
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -0
- package/dist/core/common/pipes/map-and-validate.pipe.js +2 -2
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- package/dist/core/common/plugins/complexity.plugin.d.ts +2 -2
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +51 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-password.plugin.d.ts +4 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js +69 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js +80 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -0
- package/dist/core/common/services/config.service.js +2 -2
- package/dist/core/common/services/config.service.js.map +1 -1
- package/dist/core/common/services/model-registry.service.d.ts +8 -0
- package/dist/core/common/services/model-registry.service.js +20 -0
- package/dist/core/common/services/model-registry.service.js.map +1 -0
- package/dist/core/common/services/module.service.d.ts +2 -0
- package/dist/core/common/services/module.service.js +36 -1
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/common/services/request-context.service.d.ts +18 -0
- package/dist/core/common/services/request-context.service.js +32 -0
- package/dist/core/common/services/request-context.service.js.map +1 -0
- package/dist/core/modules/auth/guards/auth.guard.js +2 -2
- package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/permissions/core-permissions.controller.d.ts +13 -0
- package/dist/core/modules/permissions/core-permissions.controller.js +71 -0
- package/dist/core/modules/permissions/core-permissions.controller.js.map +1 -0
- package/dist/core/modules/permissions/core-permissions.module.d.ts +5 -0
- package/dist/core/modules/permissions/core-permissions.module.js +36 -0
- package/dist/core/modules/permissions/core-permissions.module.js.map +1 -0
- package/dist/core/modules/permissions/core-permissions.service.d.ts +34 -0
- package/dist/core/modules/permissions/core-permissions.service.js +610 -0
- package/dist/core/modules/permissions/core-permissions.service.js.map +1 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.d.ts +93 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.js +3 -0
- package/dist/core/modules/permissions/interfaces/permissions.interface.js.map +1 -0
- package/dist/core/modules/permissions/permissions-scanner.d.ts +25 -0
- package/dist/core/modules/permissions/permissions-scanner.js +817 -0
- package/dist/core/modules/permissions/permissions-scanner.js.map +1 -0
- package/dist/core.module.js +41 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file-info.model.d.ts +12 -12
- package/dist/server/modules/user/user.model.d.ts +33 -33
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +35 -30
- package/src/config.env.ts +8 -2
- package/src/core/common/decorators/response-model.decorator.ts +31 -0
- package/src/core/common/helpers/db.helper.ts +2 -2
- package/src/core/common/helpers/filter.helper.ts +3 -3
- package/src/core/common/helpers/input.helper.ts +2 -2
- package/src/core/common/helpers/interceptor.helper.ts +132 -0
- package/src/core/common/helpers/service.helper.ts +1 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +44 -1
- package/src/core/common/interceptors/response-model.interceptor.ts +135 -0
- package/src/core/common/interceptors/translate-response.interceptor.ts +104 -0
- package/src/core/common/interfaces/server-options.interface.ts +186 -0
- package/src/core/common/middleware/request-context.middleware.ts +25 -0
- package/src/core/common/pipes/map-and-validate.pipe.ts +2 -2
- package/src/core/common/plugins/complexity.plugin.ts +2 -2
- package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +74 -0
- package/src/core/common/plugins/mongoose-password.plugin.ts +100 -0
- package/src/core/common/plugins/mongoose-role-guard.plugin.ts +150 -0
- package/src/core/common/services/config.service.ts +2 -2
- package/src/core/common/services/model-registry.service.ts +25 -0
- package/src/core/common/services/module.service.ts +91 -1
- package/src/core/common/services/request-context.service.ts +69 -0
- package/src/core/modules/auth/guards/auth.guard.ts +2 -2
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +2 -2
- package/src/core/modules/permissions/INTEGRATION-CHECKLIST.md +56 -0
- package/src/core/modules/permissions/README.md +102 -0
- package/src/core/modules/permissions/core-permissions.controller.ts +34 -0
- package/src/core/modules/permissions/core-permissions.module.ts +36 -0
- package/src/core/modules/permissions/core-permissions.service.ts +627 -0
- package/src/core/modules/permissions/interfaces/permissions.interface.ts +125 -0
- package/src/core/modules/permissions/permissions-scanner.ts +1011 -0
- package/src/core.module.ts +62 -4
- package/src/index.ts +20 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import { Inject, Injectable, Logger, OnModuleDestroy, Optional } from '@nestjs/common';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
import type { ModulePermissions, PermissionsReport } from './interfaces/permissions.interface';
|
|
7
|
+
import { findProjectRoot, generateMarkdownReport, scanPermissions } from './permissions-scanner';
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class CorePermissionsService implements OnModuleDestroy {
|
|
11
|
+
private htmlCache: string | null = null;
|
|
12
|
+
private lastScanTime = 0;
|
|
13
|
+
private readonly logger = new Logger(CorePermissionsService.name);
|
|
14
|
+
private markdownCache: string | null = null;
|
|
15
|
+
private readonly basePath: string;
|
|
16
|
+
private report: PermissionsReport | null = null;
|
|
17
|
+
private scanPromise: Promise<PermissionsReport> | null = null;
|
|
18
|
+
private watcher: fs.FSWatcher | null = null;
|
|
19
|
+
|
|
20
|
+
/** Minimum interval between scans in milliseconds (prevents abuse of the expensive ts-morph parse) */
|
|
21
|
+
private readonly SCAN_COOLDOWN_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
constructor(@Optional() @Inject('PERMISSIONS_PATH') basePath?: string) {
|
|
24
|
+
this.basePath = basePath || 'permissions';
|
|
25
|
+
// File watcher invalidates the cached report whenever a .ts file in src/server/ changes,
|
|
26
|
+
// so the next request triggers a fresh scan automatically.
|
|
27
|
+
this.setupWatcher();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
generateHtml(authToken?: string): string {
|
|
31
|
+
if (!this.htmlCache) {
|
|
32
|
+
if (!this.report) return `<p>No report available. Access /${this.basePath} to trigger scan.</p>`;
|
|
33
|
+
this.htmlCache = this.buildHtml(this.report);
|
|
34
|
+
}
|
|
35
|
+
if (authToken) {
|
|
36
|
+
const escaped = authToken.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\u003c');
|
|
37
|
+
return this.htmlCache.replace('</body>', `<script>var AUTH_TOKEN='${escaped}';</script></body>`);
|
|
38
|
+
}
|
|
39
|
+
return this.htmlCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
generateMarkdown(): string {
|
|
43
|
+
if (this.markdownCache) return this.markdownCache;
|
|
44
|
+
if (!this.report) return `# Permissions Report\n\nNo report available. Access /${this.basePath} to trigger scan.`;
|
|
45
|
+
this.markdownCache = generateMarkdownReport(this.report);
|
|
46
|
+
return this.markdownCache;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getReport(): PermissionsReport | null {
|
|
50
|
+
return this.report;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getOrScan(): Promise<PermissionsReport> {
|
|
54
|
+
if (this.report) return this.report;
|
|
55
|
+
if (this.scanPromise) return this.scanPromise;
|
|
56
|
+
this.scanPromise = this.scan();
|
|
57
|
+
return this.scanPromise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
onModuleDestroy() {
|
|
61
|
+
this.watcher?.close();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async scan(): Promise<PermissionsReport> {
|
|
65
|
+
// Rate limiting: prevent expensive ts-morph scans from being triggered too frequently
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (now - this.lastScanTime < this.SCAN_COOLDOWN_MS) {
|
|
68
|
+
if (this.report) return this.report;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
this.lastScanTime = now;
|
|
73
|
+
const projectRoot = findProjectRoot();
|
|
74
|
+
if (!projectRoot) {
|
|
75
|
+
throw new Error('Could not find project root (src/server/modules/ not found)');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Delegate to the standalone scanner (single source of truth for scan logic)
|
|
79
|
+
this.report = scanPermissions(projectRoot, {
|
|
80
|
+
log: (msg) => this.logger.log(msg),
|
|
81
|
+
warn: (msg) => this.logger.warn(msg),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.htmlCache = null;
|
|
85
|
+
this.markdownCache = null;
|
|
86
|
+
return this.report;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.logger.error('Permissions scan failed', error);
|
|
89
|
+
throw error;
|
|
90
|
+
} finally {
|
|
91
|
+
this.scanPromise = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Private: HTML helpers
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** Collect all unique role names from a module's models, controllers, and resolvers */
|
|
100
|
+
private collectModuleRoles(mod: ModulePermissions): string[] {
|
|
101
|
+
const roles = new Set<string>();
|
|
102
|
+
for (const model of mod.models) {
|
|
103
|
+
for (const r of model.classRestriction) roles.add(r);
|
|
104
|
+
for (const f of model.fields) {
|
|
105
|
+
const matches = f.roles.match(/`([^`]+)`/g);
|
|
106
|
+
if (matches) matches.forEach((m) => roles.add(m.replace(/`/g, '')));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const ctrl of mod.controllers) {
|
|
110
|
+
for (const r of ctrl.classRoles) roles.add(r);
|
|
111
|
+
for (const m of ctrl.methods) {
|
|
112
|
+
for (const r of m.roles) roles.add(r);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const res of mod.resolvers) {
|
|
116
|
+
for (const r of res.classRoles) roles.add(r);
|
|
117
|
+
for (const m of res.methods) {
|
|
118
|
+
for (const r of m.roles) roles.add(r);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [...roles].sort();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private badge(role: string): string {
|
|
125
|
+
return `<span class="badge ${this.getBadgeClass(role)}">${this.escapeHtml(role)}</span>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private badgeList(roles: string[]): string {
|
|
129
|
+
return roles.length > 0 ? roles.map((r) => this.badge(r)).join(' ') : '<em>(none)</em>';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private escapeHtml(str: string): string {
|
|
133
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private getBadgeClass(role: string): string {
|
|
137
|
+
if (!role) return 'badge-custom';
|
|
138
|
+
const r = role.toUpperCase();
|
|
139
|
+
if (r === 'S_EVERYONE') return 'badge-everyone';
|
|
140
|
+
if (r === 'S_NO_ONE') return 'badge-noone';
|
|
141
|
+
if (r === 'ADMIN') return 'badge-admin';
|
|
142
|
+
if (r === 'S_USER') return 'badge-user';
|
|
143
|
+
if (r === 'S_SELF') return 'badge-self';
|
|
144
|
+
if (r === 'S_CREATOR') return 'badge-creator';
|
|
145
|
+
return 'badge-custom';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// Private: HTML section builders
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
private buildClientJs(data: string): string {
|
|
153
|
+
const basePath = JSON.stringify('/' + this.basePath);
|
|
154
|
+
return `<script>
|
|
155
|
+
var DATA = ${data};
|
|
156
|
+
var BASE_PATH = ${basePath};
|
|
157
|
+
|
|
158
|
+
(function() {
|
|
159
|
+
var sel = document.getElementById('roleFilter');
|
|
160
|
+
var roles = {};
|
|
161
|
+
|
|
162
|
+
// Collect roles from roleEnums
|
|
163
|
+
DATA.roleEnums.forEach(function(e) { e.values.forEach(function(v) { roles[v.key] = true; }); });
|
|
164
|
+
|
|
165
|
+
// Collect roles from actual scan data
|
|
166
|
+
DATA.modules.forEach(function(mod) {
|
|
167
|
+
mod.models.forEach(function(model) {
|
|
168
|
+
model.classRestriction.forEach(function(r) { roles[r] = true; });
|
|
169
|
+
model.fields.forEach(function(f) {
|
|
170
|
+
var m = f.roles.match(/\x60([^\x60]+)\x60/g);
|
|
171
|
+
if (m) m.forEach(function(r) { roles[r.replace(/\x60/g, '')] = true; });
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
mod.controllers.forEach(function(ep) {
|
|
175
|
+
ep.classRoles.forEach(function(r) { roles[r] = true; });
|
|
176
|
+
ep.methods.forEach(function(m) { m.roles.forEach(function(r) { roles[r] = true; }); });
|
|
177
|
+
});
|
|
178
|
+
mod.resolvers.forEach(function(ep) {
|
|
179
|
+
ep.classRoles.forEach(function(r) { roles[r] = true; });
|
|
180
|
+
ep.methods.forEach(function(m) { m.roles.forEach(function(r) { roles[r] = true; }); });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
Object.keys(roles).sort().forEach(function(r) {
|
|
185
|
+
var o = document.createElement('option');
|
|
186
|
+
o.value = r; o.textContent = r;
|
|
187
|
+
sel.appendChild(o);
|
|
188
|
+
});
|
|
189
|
+
})();
|
|
190
|
+
|
|
191
|
+
function toggle(el) {
|
|
192
|
+
el.classList.toggle('open');
|
|
193
|
+
var content = el.nextElementSibling;
|
|
194
|
+
if (content) content.classList.toggle('open');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function filterAll() {
|
|
198
|
+
var q = document.getElementById('search').value.toLowerCase();
|
|
199
|
+
var role = document.getElementById('roleFilter').value;
|
|
200
|
+
var warnOnly = document.getElementById('warnOnly').checked;
|
|
201
|
+
|
|
202
|
+
// Filter module sections
|
|
203
|
+
document.querySelectorAll('.module-section').forEach(function(section) {
|
|
204
|
+
var text = section.textContent.toLowerCase();
|
|
205
|
+
var hasWarnings = section.dataset.hasWarnings === 'true';
|
|
206
|
+
var sectionRoles = (section.dataset.roles || '').split(',');
|
|
207
|
+
var show = true;
|
|
208
|
+
if (q && !text.includes(q)) show = false;
|
|
209
|
+
if (warnOnly && !hasWarnings) show = false;
|
|
210
|
+
if (role && sectionRoles.indexOf(role) === -1) show = false;
|
|
211
|
+
section.style.display = show ? '' : 'none';
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Filter warnings table rows
|
|
215
|
+
var warningsSection = document.getElementById('warnings');
|
|
216
|
+
if (warningsSection) {
|
|
217
|
+
var rows = warningsSection.querySelectorAll('tbody tr');
|
|
218
|
+
rows.forEach(function(row) {
|
|
219
|
+
var text = row.textContent.toLowerCase();
|
|
220
|
+
var show = true;
|
|
221
|
+
if (q && !text.includes(q)) show = false;
|
|
222
|
+
row.style.display = show ? '' : 'none';
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Filter subobjects section
|
|
227
|
+
var subObjSection = document.getElementById('subobjects');
|
|
228
|
+
if (subObjSection) {
|
|
229
|
+
subObjSection.querySelectorAll('div > h3').forEach(function(h3) {
|
|
230
|
+
var container = h3.parentElement;
|
|
231
|
+
if (!container) return;
|
|
232
|
+
var text = container.textContent.toLowerCase();
|
|
233
|
+
var show = true;
|
|
234
|
+
if (q && !text.includes(q)) show = false;
|
|
235
|
+
container.style.display = show ? '' : 'none';
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
document.querySelectorAll('th').forEach(function(th) {
|
|
241
|
+
th.addEventListener('click', function() {
|
|
242
|
+
var table = this.closest('table');
|
|
243
|
+
var tbody = table.querySelector('tbody');
|
|
244
|
+
if (!tbody) return;
|
|
245
|
+
var idx = Array.from(this.parentNode.children).indexOf(this);
|
|
246
|
+
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
247
|
+
var asc = this.dataset.sort !== 'asc';
|
|
248
|
+
rows.sort(function(a, b) {
|
|
249
|
+
var at = (a.children[idx] || {}).textContent || '';
|
|
250
|
+
var bt = (b.children[idx] || {}).textContent || '';
|
|
251
|
+
return asc ? at.localeCompare(bt) : bt.localeCompare(at);
|
|
252
|
+
});
|
|
253
|
+
this.dataset.sort = asc ? 'asc' : 'desc';
|
|
254
|
+
rows.forEach(function(r) { tbody.appendChild(r); });
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
function exportAs(fmt) {
|
|
259
|
+
var blob = new Blob([JSON.stringify(DATA, null, 2)], { type: 'application/json' });
|
|
260
|
+
var a = document.createElement('a');
|
|
261
|
+
a.href = URL.createObjectURL(blob);
|
|
262
|
+
a.download = 'permissions.' + fmt;
|
|
263
|
+
a.click();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function exportMarkdown() {
|
|
267
|
+
var opts = { credentials: 'same-origin' };
|
|
268
|
+
if (typeof AUTH_TOKEN !== 'undefined' && AUTH_TOKEN) {
|
|
269
|
+
opts.headers = { 'Authorization': AUTH_TOKEN };
|
|
270
|
+
}
|
|
271
|
+
fetch(BASE_PATH + '/markdown', opts)
|
|
272
|
+
.then(function(res) { return res.text(); })
|
|
273
|
+
.then(function(text) {
|
|
274
|
+
var blob = new Blob([text], { type: 'text/plain' });
|
|
275
|
+
var a = document.createElement('a');
|
|
276
|
+
a.href = URL.createObjectURL(blob);
|
|
277
|
+
a.download = 'permissions.md';
|
|
278
|
+
a.click();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function rescan(e) {
|
|
283
|
+
var btn = e ? e.target : this;
|
|
284
|
+
btn.disabled = true;
|
|
285
|
+
btn.textContent = 'Scanning...';
|
|
286
|
+
var opts = { method: 'POST', credentials: 'same-origin' };
|
|
287
|
+
if (typeof AUTH_TOKEN !== 'undefined' && AUTH_TOKEN) {
|
|
288
|
+
opts.headers = { 'Authorization': AUTH_TOKEN };
|
|
289
|
+
}
|
|
290
|
+
fetch(BASE_PATH + '/rescan', opts)
|
|
291
|
+
.then(function(res) { if (!res.ok) throw new Error(res.status); window.location.reload(); })
|
|
292
|
+
.catch(function(err) { alert('Rescan failed: ' + err); btn.disabled = false; btn.textContent = 'Rescan'; });
|
|
293
|
+
}
|
|
294
|
+
</script>`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private buildCss(): string {
|
|
298
|
+
return `<style>
|
|
299
|
+
:root {
|
|
300
|
+
--bg: #ffffff; --fg: #1a1a2e; --bg-card: #f8f9fa; --border: #dee2e6;
|
|
301
|
+
--primary: #0d6efd; --success: #198754; --warning: #ffc107; --danger: #dc3545;
|
|
302
|
+
--role-everyone: #198754; --role-noone: #dc3545; --role-admin: #0d6efd;
|
|
303
|
+
--role-user: #e6a817; --role-self: #6c757d; --role-custom: #fd7e14;
|
|
304
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.12);
|
|
305
|
+
}
|
|
306
|
+
@media (prefers-color-scheme: dark) {
|
|
307
|
+
:root {
|
|
308
|
+
--bg: #1a1a2e; --fg: #e0e0e0; --bg-card: #16213e; --border: #3a3a5c;
|
|
309
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.4);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
313
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; }
|
|
314
|
+
.layout { display: flex; min-height: 100vh; }
|
|
315
|
+
.sidebar { width: 260px; position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--bg-card); border-right: 1px solid var(--border); padding: 1rem; flex-shrink: 0; }
|
|
316
|
+
.sidebar h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary); margin-bottom: 0.5rem; }
|
|
317
|
+
.sidebar a { display: block; padding: 0.25rem 0.5rem; color: var(--fg); text-decoration: none; font-size: 0.85rem; border-radius: 4px; }
|
|
318
|
+
.sidebar a:hover { background: var(--border); }
|
|
319
|
+
.sidebar a.indent { padding-left: 1.5rem; font-size: 0.8rem; opacity: 0.8; }
|
|
320
|
+
.main { flex: 1; padding: 2rem; max-width: 1200px; }
|
|
321
|
+
.dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
322
|
+
.stat { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; text-align: center; box-shadow: var(--shadow); }
|
|
323
|
+
.stat .num { font-size: 2rem; font-weight: 700; color: var(--primary); }
|
|
324
|
+
.stat .label { font-size: 0.8rem; opacity: 0.7; }
|
|
325
|
+
.stat.warn .num { color: var(--warning); }
|
|
326
|
+
.stat.danger .num { color: var(--danger); }
|
|
327
|
+
.controls { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; align-items: center; }
|
|
328
|
+
.controls input, .controls select { padding: 0.4rem 0.8rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font-size: 0.9rem; }
|
|
329
|
+
.controls input { flex: 1; min-width: 200px; }
|
|
330
|
+
.controls label { font-size: 0.85rem; display: flex; align-items: center; gap: 0.3rem; }
|
|
331
|
+
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; color: #fff; }
|
|
332
|
+
.badge-everyone { background: var(--role-everyone); }
|
|
333
|
+
.badge-noone { background: var(--role-noone); }
|
|
334
|
+
.badge-admin { background: var(--role-admin); }
|
|
335
|
+
.badge-user { background: var(--role-user); color: #000; }
|
|
336
|
+
.badge-self, .badge-creator { background: var(--role-self); }
|
|
337
|
+
.badge-custom { background: var(--role-custom); }
|
|
338
|
+
.badge-warn { background: var(--warning); color: #000; }
|
|
339
|
+
section { margin-bottom: 2rem; }
|
|
340
|
+
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
|
|
341
|
+
h2 { font-size: 1.4rem; margin: 1.5rem 0 0.75rem; padding-bottom: 0.3rem; border-bottom: 2px solid var(--primary); }
|
|
342
|
+
h3 { font-size: 1.1rem; margin: 1rem 0 0.5rem; }
|
|
343
|
+
.meta { font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.25rem; }
|
|
344
|
+
table { width: 100%; border-collapse: collapse; margin: 0.5rem 0 1rem; font-size: 0.85rem; }
|
|
345
|
+
th, td { padding: 0.4rem 0.6rem; text-align: left; border: 1px solid var(--border); }
|
|
346
|
+
th { background: var(--bg-card); cursor: pointer; user-select: none; white-space: nowrap; }
|
|
347
|
+
th:hover { background: var(--border); }
|
|
348
|
+
tr:nth-child(even) { background: var(--bg-card); }
|
|
349
|
+
.collapsible { cursor: pointer; }
|
|
350
|
+
.collapsible::before { content: '\\25B6'; display: inline-block; margin-right: 0.5rem; transition: transform 0.2s; font-size: 0.8rem; }
|
|
351
|
+
.collapsible.open::before { transform: rotate(90deg); }
|
|
352
|
+
.collapse-content { display: none; }
|
|
353
|
+
.collapse-content.open { display: block; }
|
|
354
|
+
.warning-row { background: rgba(255, 193, 7, 0.1) !important; }
|
|
355
|
+
.btn { padding: 0.4rem 0.8rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); color: var(--fg); cursor: pointer; font-size: 0.8rem; margin: 0.25rem 0; }
|
|
356
|
+
.btn:hover { background: var(--border); }
|
|
357
|
+
.module-header { position: sticky; top: 0; background: var(--bg); z-index: 10; padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
|
|
358
|
+
@media (max-width: 768px) {
|
|
359
|
+
.sidebar { display: none; }
|
|
360
|
+
.main { padding: 1rem; }
|
|
361
|
+
.dashboard { grid-template-columns: repeat(2, 1fr); }
|
|
362
|
+
}
|
|
363
|
+
</style>`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private buildHtml(report: PermissionsReport): string {
|
|
367
|
+
const data = JSON.stringify(report).replace(/</g, '\\u003c');
|
|
368
|
+
const s = report.stats;
|
|
369
|
+
const coverageClass = (pct: number) => (pct >= 90 ? '' : pct >= 70 ? 'warn' : 'danger');
|
|
370
|
+
|
|
371
|
+
return `<!DOCTYPE html>
|
|
372
|
+
<html lang="en">
|
|
373
|
+
<head>
|
|
374
|
+
<meta charset="UTF-8">
|
|
375
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
376
|
+
<title>Permissions Report</title>
|
|
377
|
+
${this.buildCss()}
|
|
378
|
+
</head>
|
|
379
|
+
<body>
|
|
380
|
+
<div class="layout">
|
|
381
|
+
${this.buildSidebarHtml(report)}
|
|
382
|
+
<div class="main">
|
|
383
|
+
<h1>Permissions Report</h1>
|
|
384
|
+
<p class="meta">Generated: ${this.escapeHtml(report.generated)}</p>
|
|
385
|
+
|
|
386
|
+
<section id="dashboard">
|
|
387
|
+
<div class="dashboard">
|
|
388
|
+
<div class="stat"><div class="num">${s.totalModules}</div><div class="label">Modules</div></div>
|
|
389
|
+
<div class="stat"><div class="num">${s.totalModels}</div><div class="label">Models</div></div>
|
|
390
|
+
<div class="stat"><div class="num">${s.totalEndpoints}</div><div class="label">Endpoints</div></div>
|
|
391
|
+
<div class="stat"><div class="num">${s.totalSubObjects}</div><div class="label">SubObjects</div></div>
|
|
392
|
+
<div class="stat ${s.totalWarnings > 0 ? 'danger' : ''}"><div class="num">${s.totalWarnings}</div><div class="label">Warnings</div></div>
|
|
393
|
+
<div class="stat ${coverageClass(s.endpointCoverage)}"><div class="num">${s.endpointCoverage}%</div><div class="label">Endpoint Coverage</div></div>
|
|
394
|
+
<div class="stat ${coverageClass(s.securityCoverage)}"><div class="num">${s.securityCoverage}%</div><div class="label">Security Coverage</div></div>
|
|
395
|
+
</div>
|
|
396
|
+
</section>
|
|
397
|
+
|
|
398
|
+
<section>
|
|
399
|
+
<div class="controls">
|
|
400
|
+
<input type="text" id="search" placeholder="Search modules, fields, roles..." oninput="filterAll()">
|
|
401
|
+
<select id="roleFilter" onchange="filterAll()">
|
|
402
|
+
<option value="">All Roles</option>
|
|
403
|
+
</select>
|
|
404
|
+
<label><input type="checkbox" id="warnOnly" onchange="filterAll()"> Warnings only</label>
|
|
405
|
+
</div>
|
|
406
|
+
</section>
|
|
407
|
+
|
|
408
|
+
${this.buildRoleIndexSection(report)}
|
|
409
|
+
${this.buildWarningsSection(report)}
|
|
410
|
+
${this.buildModuleSectionsHtml(report)}
|
|
411
|
+
${this.buildObjectsSectionsHtml(report)}
|
|
412
|
+
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
${this.buildClientJs(data)}
|
|
417
|
+
</body>
|
|
418
|
+
</html>`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private buildModuleSectionsHtml(report: PermissionsReport): string {
|
|
422
|
+
let html = '';
|
|
423
|
+
for (const mod of report.modules) {
|
|
424
|
+
const hasWarnings = report.warnings.some((w) => w.module === mod.name);
|
|
425
|
+
const allRoles = this.collectModuleRoles(mod);
|
|
426
|
+
html += `<section class="module-section" id="mod-${this.escapeHtml(mod.name)}" data-module="${this.escapeHtml(mod.name)}" data-has-warnings="${hasWarnings}" data-roles="${this.escapeHtml(allRoles.join(','))}">\n`;
|
|
427
|
+
html += `<div class="module-header"><h2 class="collapsible open" onclick="toggle(this)">Module: ${this.escapeHtml(mod.name)}</h2></div>\n<div class="collapse-content open">\n`;
|
|
428
|
+
|
|
429
|
+
// Models
|
|
430
|
+
for (const model of mod.models) {
|
|
431
|
+
html += `<div id="model-${this.escapeHtml(mod.name)}-${this.escapeHtml(model.className)}">`;
|
|
432
|
+
html += `<h3>Model: ${this.escapeHtml(model.className)}</h3>`;
|
|
433
|
+
html += `<p class="meta">File: ${this.escapeHtml(model.filePath)}</p>`;
|
|
434
|
+
if (model.extendsClass) html += `<p class="meta">Extends: ${this.escapeHtml(model.extendsClass)}</p>`;
|
|
435
|
+
html += `<p class="meta">Class Restriction: ${model.classRestriction.length > 0 ? model.classRestriction.map((r) => this.badge(r)).join(' ') : '<em>(none)</em>'}</p>`;
|
|
436
|
+
html += `<p class="meta">securityCheck: ${model.securityCheck ? this.escapeHtml(model.securityCheck.summary) : '<em>Not present</em>'}</p>`;
|
|
437
|
+
if (model.fields.length > 0) {
|
|
438
|
+
html += '<table><thead><tr><th>Field</th><th>Roles</th><th>Source</th></tr></thead><tbody>';
|
|
439
|
+
for (const f of model.fields) {
|
|
440
|
+
html += `<tr><td>${this.escapeHtml(f.name)}</td><td>${this.escapeHtml(f.roles)}</td><td>${f.inherited ? 'inherited' : 'local'}</td></tr>`;
|
|
441
|
+
}
|
|
442
|
+
html += '</tbody></table>';
|
|
443
|
+
}
|
|
444
|
+
html += '</div>\n';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Inputs
|
|
448
|
+
for (const input of mod.inputs) {
|
|
449
|
+
html += `<div><h3>Input: ${this.escapeHtml(input.className)}</h3>`;
|
|
450
|
+
html += `<p class="meta">File: ${this.escapeHtml(input.filePath)}</p>`;
|
|
451
|
+
if (input.extendsClass) html += `<p class="meta">Extends: ${this.escapeHtml(input.extendsClass)}</p>`;
|
|
452
|
+
if (input.fields.length > 0) {
|
|
453
|
+
html += '<table><thead><tr><th>Field</th><th>Roles</th></tr></thead><tbody>';
|
|
454
|
+
for (const f of input.fields) {
|
|
455
|
+
html += `<tr><td>${this.escapeHtml(f.name)}</td><td>${this.escapeHtml(f.roles)}</td></tr>`;
|
|
456
|
+
}
|
|
457
|
+
html += '</tbody></table>';
|
|
458
|
+
}
|
|
459
|
+
html += '</div>\n';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Controllers
|
|
463
|
+
for (const ctrl of mod.controllers) {
|
|
464
|
+
html += `<div id="ctrl-${this.escapeHtml(mod.name)}-${this.escapeHtml(ctrl.className)}">`;
|
|
465
|
+
html += `<h3>Controller: ${this.escapeHtml(ctrl.className)}</h3>`;
|
|
466
|
+
html += `<p class="meta">File: ${this.escapeHtml(ctrl.filePath)}</p>`;
|
|
467
|
+
html += `<p class="meta">Class Roles: ${this.badgeList(ctrl.classRoles)}</p>`;
|
|
468
|
+
if (ctrl.methods.length > 0) {
|
|
469
|
+
html +=
|
|
470
|
+
'<table><thead><tr><th>Method</th><th>HTTP</th><th>Route</th><th>Roles</th><th>Effective</th></tr></thead><tbody>';
|
|
471
|
+
for (const m of ctrl.methods) {
|
|
472
|
+
const eff = m.roles.length > 0 ? m.roles : ctrl.classRoles;
|
|
473
|
+
html += `<tr><td>${this.escapeHtml(m.name)}</td><td>${this.escapeHtml(m.httpMethod)}</td><td>${this.escapeHtml(m.route || '/')}</td><td>${this.badgeList(m.roles)}</td><td>${this.badgeList(eff)}${m.roles.length === 0 && ctrl.classRoles.length > 0 ? ' (class)' : ''}</td></tr>`;
|
|
474
|
+
}
|
|
475
|
+
html += '</tbody></table>';
|
|
476
|
+
}
|
|
477
|
+
html += '</div>\n';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Resolvers
|
|
481
|
+
for (const res of mod.resolvers) {
|
|
482
|
+
html += `<div id="res-${this.escapeHtml(mod.name)}-${this.escapeHtml(res.className)}">`;
|
|
483
|
+
html += `<h3>Resolver: ${this.escapeHtml(res.className)}</h3>`;
|
|
484
|
+
html += `<p class="meta">File: ${this.escapeHtml(res.filePath)}</p>`;
|
|
485
|
+
html += `<p class="meta">Class Roles: ${this.badgeList(res.classRoles)}</p>`;
|
|
486
|
+
if (res.methods.length > 0) {
|
|
487
|
+
html += '<table><thead><tr><th>Method</th><th>Type</th><th>Roles</th><th>Effective</th></tr></thead><tbody>';
|
|
488
|
+
for (const m of res.methods) {
|
|
489
|
+
const eff = m.roles.length > 0 ? m.roles : res.classRoles;
|
|
490
|
+
html += `<tr><td>${this.escapeHtml(m.name)}</td><td>${this.escapeHtml(m.httpMethod)}</td><td>${this.badgeList(m.roles)}</td><td>${this.badgeList(eff)}${m.roles.length === 0 && res.classRoles.length > 0 ? ' (class)' : ''}</td></tr>`;
|
|
491
|
+
}
|
|
492
|
+
html += '</tbody></table>';
|
|
493
|
+
}
|
|
494
|
+
html += '</div>\n';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
html += '</div></section>\n';
|
|
498
|
+
}
|
|
499
|
+
return html;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private buildObjectsSectionsHtml(report: PermissionsReport): string {
|
|
503
|
+
if (report.objects.length === 0) return '';
|
|
504
|
+
|
|
505
|
+
let html = '<section id="subobjects"><h2>SubObjects</h2>\n';
|
|
506
|
+
for (const obj of report.objects) {
|
|
507
|
+
html += `<div><h3>${this.escapeHtml(obj.className)}</h3>`;
|
|
508
|
+
html += `<p class="meta">File: ${this.escapeHtml(obj.filePath)}</p>`;
|
|
509
|
+
if (obj.extendsClass) html += `<p class="meta">Extends: ${this.escapeHtml(obj.extendsClass)}</p>`;
|
|
510
|
+
if (obj.fields.length > 0) {
|
|
511
|
+
html += '<table><thead><tr><th>Field</th><th>Roles</th><th>Source</th></tr></thead><tbody>';
|
|
512
|
+
for (const f of obj.fields) {
|
|
513
|
+
html += `<tr><td>${this.escapeHtml(f.name)}</td><td>${this.escapeHtml(f.roles)}</td><td>${f.inherited ? 'inherited' : 'local'}</td></tr>`;
|
|
514
|
+
}
|
|
515
|
+
html += '</tbody></table>';
|
|
516
|
+
}
|
|
517
|
+
html += '</div>\n';
|
|
518
|
+
}
|
|
519
|
+
html += '</section>\n';
|
|
520
|
+
return html;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private buildRoleIndexSection(report: PermissionsReport): string {
|
|
524
|
+
let rows = '';
|
|
525
|
+
if (report.roleEnums.length > 0) {
|
|
526
|
+
for (const e of report.roleEnums) {
|
|
527
|
+
for (const v of e.values) {
|
|
528
|
+
const isSystem = v.key.startsWith('S_');
|
|
529
|
+
rows += `<tr><td>${this.escapeHtml(e.name)}.${this.escapeHtml(v.key)}</td><td>${isSystem ? '(system)' : this.escapeHtml(v.value)}</td><td>${this.badge(isSystem ? 'System' : 'Real')}</td></tr>\n`;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const content =
|
|
535
|
+
report.roleEnums.length > 0
|
|
536
|
+
? `<table><thead><tr><th>Enum</th><th>Value</th><th>Type</th></tr></thead><tbody>${rows}</tbody></table>`
|
|
537
|
+
: '<p><em>No role enums found.</em></p>';
|
|
538
|
+
|
|
539
|
+
return `<section id="role-index">
|
|
540
|
+
<h2 class="collapsible open" onclick="toggle(this)">Role Index</h2>
|
|
541
|
+
<div class="collapse-content open">
|
|
542
|
+
${content}
|
|
543
|
+
</div>
|
|
544
|
+
</section>`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private buildSidebarHtml(report: PermissionsReport): string {
|
|
548
|
+
let links = '';
|
|
549
|
+
for (const mod of report.modules) {
|
|
550
|
+
links += `<a href="#mod-${this.escapeHtml(mod.name)}">${this.escapeHtml(mod.name)}</a>\n`;
|
|
551
|
+
for (const model of mod.models) {
|
|
552
|
+
links += `<a href="#model-${this.escapeHtml(mod.name)}-${this.escapeHtml(model.className)}" class="indent">Model: ${this.escapeHtml(model.className)}</a>\n`;
|
|
553
|
+
}
|
|
554
|
+
for (const ctrl of mod.controllers) {
|
|
555
|
+
links += `<a href="#ctrl-${this.escapeHtml(mod.name)}-${this.escapeHtml(ctrl.className)}" class="indent">Ctrl: ${this.escapeHtml(ctrl.className)}</a>\n`;
|
|
556
|
+
}
|
|
557
|
+
for (const res of mod.resolvers) {
|
|
558
|
+
links += `<a href="#res-${this.escapeHtml(mod.name)}-${this.escapeHtml(res.className)}" class="indent">Resolver: ${this.escapeHtml(res.className)}</a>\n`;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return `<nav class="sidebar">
|
|
563
|
+
<h3>Permissions Report</h3>
|
|
564
|
+
<a href="#dashboard">Dashboard</a>
|
|
565
|
+
<a href="#role-index">Role Index</a>
|
|
566
|
+
<a href="#warnings">Warnings (${report.warnings.length})</a>
|
|
567
|
+
<hr style="margin:0.5rem 0;border-color:var(--border)">
|
|
568
|
+
${links}
|
|
569
|
+
${report.objects.length > 0 ? '<hr style="margin:0.5rem 0;border-color:var(--border)"><a href="#subobjects">SubObjects</a>' : ''}
|
|
570
|
+
<hr style="margin:0.5rem 0;border-color:var(--border)">
|
|
571
|
+
<button class="btn" onclick="exportAs('json')">Export JSON</button>
|
|
572
|
+
<button class="btn" onclick="exportMarkdown()">Export Markdown</button>
|
|
573
|
+
<button class="btn" onclick="rescan(event)">Rescan</button>
|
|
574
|
+
</nav>`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private buildWarningsSection(report: PermissionsReport): string {
|
|
578
|
+
let rows = '';
|
|
579
|
+
for (let i = 0; i < report.warnings.length; i++) {
|
|
580
|
+
const w = report.warnings[i];
|
|
581
|
+
const fileName = w.file.split('/').pop() || w.file;
|
|
582
|
+
rows += `<tr class="warning-row"><td>${i + 1}</td><td>${this.escapeHtml(w.module)}</td><td>${this.escapeHtml(fileName)}</td><td><span class="badge badge-warn">${this.escapeHtml(w.type)}</span></td><td>${this.escapeHtml(w.details)}</td></tr>\n`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const content =
|
|
586
|
+
report.warnings.length > 0
|
|
587
|
+
? `<table><thead><tr><th>#</th><th>Module</th><th>File</th><th>Type</th><th>Details</th></tr></thead><tbody>${rows}</tbody></table>`
|
|
588
|
+
: '<p><em>No warnings found.</em></p>';
|
|
589
|
+
|
|
590
|
+
return `<section id="warnings">
|
|
591
|
+
<h2 class="collapsible open" onclick="toggle(this)">Warnings (${report.warnings.length})</h2>
|
|
592
|
+
<div class="collapse-content open">
|
|
593
|
+
${content}
|
|
594
|
+
</div>
|
|
595
|
+
</section>`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
599
|
+
// Private: File watcher
|
|
600
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Watch src/server/ for .ts file changes and invalidate the cached report.
|
|
604
|
+
* This avoids stale data when developers modify decorators while the server is running.
|
|
605
|
+
*/
|
|
606
|
+
private setupWatcher() {
|
|
607
|
+
try {
|
|
608
|
+
const root = findProjectRoot();
|
|
609
|
+
if (!root) return;
|
|
610
|
+
const watchPath = join(root, 'src', 'server');
|
|
611
|
+
if (!existsSync(watchPath)) return;
|
|
612
|
+
this.watcher = fs.watch(watchPath, { recursive: true }, (_eventType, filename) => {
|
|
613
|
+
if (filename?.endsWith('.ts')) {
|
|
614
|
+
this.logger.debug(`File changed: ${filename}, invalidating cache`);
|
|
615
|
+
this.report = null;
|
|
616
|
+
this.htmlCache = null;
|
|
617
|
+
this.markdownCache = null;
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
this.watcher.on('error', (err) => {
|
|
621
|
+
this.logger.warn(`File watcher error: ${err.message}, manual rescan needed`);
|
|
622
|
+
});
|
|
623
|
+
} catch (err) {
|
|
624
|
+
this.logger.warn(`File watcher setup failed: ${err}, manual rescan needed`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|