thatcher 1.0.34 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.34",
3
+ "version": "1.0.35",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -58,6 +58,7 @@
58
58
  "lucia": "^3.2.2",
59
59
  "nodemailer": "^9.0.1",
60
60
  "webjsx": "^0.0.73",
61
+ "ws": "^8.13.0",
61
62
  "xstate": "^5.0.0"
62
63
  },
63
64
  "devDependencies": {
@@ -0,0 +1,114 @@
1
+ import { google } from 'googleapis';
2
+ import { createLogger } from '../lib/logger.js';
3
+ import { getJWTClient, getOAuth2Client } from './google-auth.js';
4
+ import { buildConfig } from '../config/env.js';
5
+
6
+ const log = createLogger('[GoogleGmail]');
7
+
8
+ export function getGmailClient(user = null) {
9
+ let client;
10
+
11
+ if (user?.oauth_token) {
12
+ // User-delegated access
13
+ client = getOAuth2Client();
14
+ client.setCredentials({ access_token: user.oauth_token });
15
+ } else {
16
+ // Service account (app-wide)
17
+ client = getJWTClient();
18
+ }
19
+
20
+ if (!client) throw new Error('Google Gmail not configured');
21
+
22
+ return google.gmail({ version: 'v1', auth: client });
23
+ }
24
+
25
+ function encodeHeader(value) {
26
+ // RFC 2047 encoded-word for any non-ASCII header value (subject/name).
27
+ if (/^[\x00-\x7F]*$/.test(value)) return value;
28
+ return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
29
+ }
30
+
31
+ function buildRawMessage({ to, from, subject, body, html, cc, bcc, attachments = [], inReplyTo, references }) {
32
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
33
+ const headers = [
34
+ `To: ${to}`,
35
+ `From: ${from}`,
36
+ `Subject: ${encodeHeader(subject || '')}`,
37
+ ];
38
+ if (cc) headers.push(`Cc: ${cc}`);
39
+ if (bcc) headers.push(`Bcc: ${bcc}`);
40
+ if (inReplyTo) headers.push(`In-Reply-To: ${inReplyTo}`);
41
+ if (references) headers.push(`References: ${references}`);
42
+ headers.push('MIME-Version: 1.0');
43
+
44
+ const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
45
+
46
+ let message;
47
+ if (!hasAttachments) {
48
+ if (html) {
49
+ headers.push('Content-Type: text/html; charset="UTF-8"');
50
+ message = `${headers.join('\r\n')}\r\n\r\n${html}`;
51
+ } else {
52
+ headers.push('Content-Type: text/plain; charset="UTF-8"');
53
+ message = `${headers.join('\r\n')}\r\n\r\n${body || ''}`;
54
+ }
55
+ } else {
56
+ headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
57
+ const parts = [];
58
+
59
+ parts.push(
60
+ `--${boundary}`,
61
+ html ? 'Content-Type: text/html; charset="UTF-8"' : 'Content-Type: text/plain; charset="UTF-8"',
62
+ '',
63
+ html || body || '',
64
+ ''
65
+ );
66
+
67
+ for (const att of attachments) {
68
+ const content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(att.content || '', att.encoding || 'base64');
69
+ parts.push(
70
+ `--${boundary}`,
71
+ `Content-Type: ${att.contentType || att.mimeType || 'application/octet-stream'}; name="${att.filename || att.name || 'attachment'}"`,
72
+ 'Content-Transfer-Encoding: base64',
73
+ `Content-Disposition: attachment; filename="${att.filename || att.name || 'attachment'}"`,
74
+ '',
75
+ content.toString('base64'),
76
+ ''
77
+ );
78
+ }
79
+
80
+ parts.push(`--${boundary}--`);
81
+ message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
82
+ }
83
+
84
+ return Buffer.from(message)
85
+ .toString('base64')
86
+ .replace(/\+/g, '-')
87
+ .replace(/\//g, '_')
88
+ .replace(/=+$/, '');
89
+ }
90
+
91
+ export async function sendEmail(options, user = null) {
92
+ const gmail = getGmailClient(user);
93
+ const from = options.from || buildConfig().email.from;
94
+
95
+ const raw = buildRawMessage({ ...options, from });
96
+
97
+ const requestBody = { raw };
98
+ if (options.inReplyTo || options.references) {
99
+ // threadId is optional; Gmail threads by References/In-Reply-To headers
100
+ // already embedded in the raw message.
101
+ }
102
+
103
+ try {
104
+ const res = await gmail.users.messages.send({
105
+ userId: 'me',
106
+ requestBody,
107
+ });
108
+ log.info('sent:', { messageId: res.data.id });
109
+ return { id: res.data.id, messageId: res.data.id, threadId: res.data.threadId };
110
+ } catch (err) {
111
+ log.error('send failed:', { message: err.message });
112
+ throw err;
113
+ }
114
+ }
@@ -1,51 +1,38 @@
1
1
  import { createLogger } from '@/lib/logger.js';
2
- import { runDueJobs } from '@/engine/job-engine';
2
+ import { timingSafeEqual } from 'node:crypto';
3
3
 
4
4
  const log = createLogger('[Cron]');
5
- import { create } from '@/engine';
6
5
 
7
6
  export const runtime = 'nodejs';
8
7
 
9
- export async function POST(request) {
10
- const startTime = Date.now();
8
+ function isValidCronSecret(token) {
9
+ const secret = process.env.CRON_SECRET;
10
+ const tokenBuf = Buffer.from(token || '', 'utf8');
11
+ const secretBuf = Buffer.from(secret || '', 'utf8');
12
+ return !!secret && !!token && tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
13
+ }
11
14
 
15
+ export async function POST(request) {
12
16
  try {
13
17
  const authHeader = request.headers.get('authorization');
14
18
  const token = authHeader?.replace('Bearer ', '');
15
19
 
16
- if (!token || token !== process.env.CRON_SECRET) {
20
+ if (!isValidCronSecret(token)) {
17
21
  return new Response(
18
22
  JSON.stringify({ status: 'error', message: 'Unauthorized' }),
19
23
  { status: 401, headers: { 'Content-Type': 'application/json' } }
20
24
  );
21
25
  }
22
26
 
23
- const results = await runDueJobs();
24
- const duration = Date.now() - startTime;
25
-
26
- await create('job_execution_log', {
27
- timestamp: Math.floor(Date.now() / 1000),
28
- total_jobs: results.total || 0,
29
- executed_jobs: results.executed || 0,
30
- failed_jobs: results.failed || 0,
31
- duration_ms: duration,
32
- status: results.failed > 0 ? 'partial_failure' : 'success',
33
- error_details: results.errors || null
34
- }).catch(err => log.error('log error:', { message: err.message }));
35
-
27
+ // job-engine was removed (the module never existed in this repo); this
28
+ // endpoint is not wired to a real job runner, so return a clean 501
29
+ // instead of crashing on a dead import.
36
30
  return new Response(
37
- JSON.stringify({
38
- status: 'success',
39
- timestamp: new Date().toISOString(),
40
- total_jobs: results.total,
41
- executed_jobs: results.executed,
42
- failed_jobs: results.failed,
43
- duration_ms: duration,
44
- details: results.details || []
45
- }),
46
- { status: 200, headers: { 'Content-Type': 'application/json' } }
31
+ JSON.stringify({ status: 'error', message: 'cron trigger not implemented' }),
32
+ { status: 501, headers: { 'Content-Type': 'application/json' } }
47
33
  );
48
34
  } catch (error) {
35
+ log.error('cron trigger error:', { message: error.message });
49
36
  return new Response(
50
37
  JSON.stringify({ status: 'error', message: error.message, timestamp: new Date().toISOString() }),
51
38
  { status: 500, headers: { 'Content-Type': 'application/json' } }
@@ -2,11 +2,19 @@ import { NextResponse } from '@/lib/next-polyfills';
2
2
  import { createLogger } from '@/lib/logger.js';
3
3
  import { genId, now } from '@/lib/id-helpers';
4
4
  import { create } from '@/engine';
5
+ import { timingSafeEqual } from 'node:crypto';
5
6
 
6
7
  const log = createLogger('[EmailReceive]');
7
8
  import path from 'path';
8
9
  import fs from 'fs';
9
10
 
11
+ function isValidWebhookSecret(token) {
12
+ const secret = process.env.EMAIL_WEBHOOK_SECRET;
13
+ const tokenBuf = Buffer.from(token || '', 'utf8');
14
+ const secretBuf = Buffer.from(secret || '', 'utf8');
15
+ return !!secret && !!token && tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
16
+ }
17
+
10
18
  const EMAIL_ATTACHMENTS_DIR = path.resolve(process.cwd(), 'data', 'temp_email_attachments');
11
19
 
12
20
  if (!fs.existsSync(EMAIL_ATTACHMENTS_DIR)) {
@@ -17,7 +25,7 @@ export async function POST(request) {
17
25
  try {
18
26
  const authHeader = request.headers.get('authorization');
19
27
  const token = authHeader?.replace('Bearer ', '');
20
- if (!token || token !== process.env.EMAIL_WEBHOOK_SECRET) {
28
+ if (!isValidWebhookSecret(token)) {
21
29
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
22
30
  }
23
31
 
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from '@/lib/next-polyfills';
2
2
  import { createLogger } from '@/lib/logger.js';
3
3
  import { now } from '@/lib/id-helpers';
4
+ import { timingSafeEqual } from 'node:crypto';
4
5
 
5
6
  const log = createLogger('[Email]');
6
7
  import { list, update } from '@/engine';
@@ -8,6 +9,13 @@ import { getConfigEngine } from '@/lib/config-generator-engine';
8
9
  import { EMAIL_STATUS } from '@/config/constants';
9
10
  import { sendSingleEmail, checkFailureRate } from '@/lib/email-sender';
10
11
 
12
+ function isValidCronSecret(token) {
13
+ const secret = process.env.CRON_SECRET;
14
+ const tokenBuf = Buffer.from(token || '', 'utf8');
15
+ const secretBuf = Buffer.from(secret || '', 'utf8');
16
+ return !!secret && !!token && tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
17
+ }
18
+
11
19
  let emailConfig = null;
12
20
 
13
21
  async function getEmailConfig() {
@@ -20,7 +28,7 @@ async function getEmailConfig() {
20
28
 
21
29
  export async function POST(request) {
22
30
  const token = request.headers.get('authorization')?.replace('Bearer ', '');
23
- if (!token || token !== process.env.CRON_SECRET)
31
+ if (!isValidCronSecret(token))
24
32
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
25
33
 
26
34
  try {
@@ -61,7 +69,7 @@ export async function POST(request) {
61
69
 
62
70
  export async function GET(request) {
63
71
  const token = request.headers.get('authorization')?.replace('Bearer ', '');
64
- if (!token || token !== process.env.CRON_SECRET)
72
+ if (!isValidCronSecret(token))
65
73
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
66
74
 
67
75
  try {
@@ -20,7 +20,7 @@ export async function GET(request, { params }) {
20
20
  return NextResponse.json({ error: 'Permission denied' }, { status: HTTP.FORBIDDEN });
21
21
  }
22
22
 
23
- const fileRecord = get('file', id);
23
+ const fileRecord = await get('file', id);
24
24
  if (!fileRecord) {
25
25
  return notFound('File not found');
26
26
  }
@@ -25,23 +25,24 @@ export const GET = async (request) => {
25
25
  const start = process.hrtime.bigint()
26
26
 
27
27
  // busybase liveness probe (replaces the SQLite SELECT 1 + wal_checkpoint).
28
- try { await count('user', {}); } catch { /* table may not exist yet; still alive */ }
28
+ let dbConnected = true
29
+ try { await count('user', {}); } catch (e) { dbConnected = false; log.warn('db probe failed', { message: e?.message }) }
29
30
 
30
31
  const dbLatency = Number(process.hrtime.bigint() - start) / 1000000
31
32
  const url = new URL(request.url)
32
33
  const detailed = url.searchParams.get('detailed') === 'true'
33
34
 
34
35
  const health = {
35
- status: 'ok',
36
+ status: dbConnected ? 'ok' : 'degraded',
36
37
  timestamp: new Date().toISOString(),
37
38
  uptime: process.uptime(),
38
39
  uptime_ms: Math.round(process.uptime() * 1000),
39
40
  last_sync_at: getLastSyncAt(),
40
41
  database: {
41
- connected: true,
42
+ connected: dbConnected,
42
43
  latency: dbLatency
43
44
  },
44
- db: 'ok'
45
+ db: dbConnected ? 'ok' : 'error'
45
46
  }
46
47
 
47
48
  if (detailed) {
@@ -65,7 +66,7 @@ export const GET = async (request) => {
65
66
  return new Response(
66
67
  JSON.stringify(health, null, 2),
67
68
  {
68
- status: 200,
69
+ status: dbConnected ? 200 : 503,
69
70
  headers: { 'Content-Type': 'application/json' }
70
71
  }
71
72
  )
package/src/index.js CHANGED
@@ -332,13 +332,19 @@ export class Thatcher {
332
332
 
333
333
  // === Database transaction ===
334
334
 
335
+ /**
336
+ * Non-atomic passthrough: busybase has no transaction primitive, so this simply
337
+ * invokes the callback with NO rollback guarantee on partial failure. Callers
338
+ * must not rely on withTransaction() for atomicity; check supportsTransactions
339
+ * to detect this at runtime.
340
+ */
335
341
  async withTransaction(callback) {
336
- // busybase has no transaction primitive; the old query-engine's withTransaction
337
- // was a pass-through, so preserve that contract rather than import a dead file.
338
342
  return callback();
339
343
  }
340
344
  }
341
345
 
346
+ Thatcher.prototype.supportsTransactions = false;
347
+
342
348
  export function createThatcher(options) {
343
349
  return new Thatcher(options);
344
350
  }
@@ -11,7 +11,7 @@ setInterval(() => {
11
11
  oauthStateStore.delete(key);
12
12
  }
13
13
  }
14
- }, 5 * 60 * 1000);
14
+ }, 5 * 60 * 1000).unref();
15
15
 
16
16
  export function validateOAuthProvider(provider) {
17
17
  if (!provider) {
@@ -3,7 +3,7 @@ import { hookEngine } from '@/lib/hook-engine.js';
3
3
 
4
4
  const log = createLogger('[EventsEngine]');
5
5
  import { list, get, update, create, remove } from '@/engine.js';
6
- import { queueEmail } from '@/engine/notification-engine';
6
+ import { queueEmail } from '@/services/notification-engine.js';
7
7
  import { safeJsonParse } from '@/lib/safe-json.js';
8
8
  import { validateTransition } from '@/lib/workflow-engine.js';
9
9
  import { AppError } from '@/lib/error-handler';
@@ -21,11 +21,12 @@ export class ExportSink {
21
21
  this._exportCount = 0;
22
22
  this._errorCount = 0;
23
23
  this._lastError = null;
24
+ this._options = options;
24
25
  }
25
26
 
26
27
  async init() {
27
28
  if (this.target === 'file') {
28
- this._filePath = options.filePath || process.env.OBSERVABILITY_EXPORT_FILE || path.join(process.cwd(), 'observability.jsonl');
29
+ this._filePath = this._options.filePath || process.env.OBSERVABILITY_EXPORT_FILE || path.join(process.cwd(), 'observability.jsonl');
29
30
  logger.info('File export sink initialized', { path: this._filePath });
30
31
  } else if (this.target === 'http') {
31
32
  if (!this.url) {
@@ -83,6 +83,14 @@ const DOMAINS = ['friday', 'mwr'];
83
83
 
84
84
  export function resolveRoute(__dirname, pathname, url) {
85
85
  const pathParts = pathname.slice(5).split('/').filter(Boolean);
86
+
87
+ // Reject any '..' or '.' segment before it can reach a filesystem path
88
+ // join or a dynamic import -- otherwise a crafted URL can traverse out of
89
+ // src/app/api into arbitrary files on disk.
90
+ if (pathParts.some(seg => seg === '..' || seg === '.')) {
91
+ return { routeFile: null, params: {}, isDomain: false, firstPart: pathParts[0], pathParts };
92
+ }
93
+
86
94
  const firstPart = pathParts[0];
87
95
  const isDomain = DOMAINS.includes(firstPart);
88
96
  let routeFile = null;
@@ -48,4 +48,4 @@ export function cleanupExpiredCSRFTokens() {
48
48
  }
49
49
  }
50
50
 
51
- setInterval(cleanupExpiredCSRFTokens, 60 * 60 * 1000);
51
+ setInterval(cleanupExpiredCSRFTokens, 60 * 60 * 1000).unref();
@@ -28,4 +28,4 @@ setInterval(() => {
28
28
  delete global.rateLimitStore[key];
29
29
  }
30
30
  }
31
- }, 60000);
31
+ }, 60000).unref();
@@ -80,6 +80,89 @@ export async function getUnreadCount(userId) {
80
80
  })).length;
81
81
  }
82
82
 
83
+ // Resolve a `recipients` token (a role/group name used across events-engine.js
84
+ // call sites, e.g. 'client_users', 'team_members', 'collaborator') to a list of
85
+ // user records to notify. There is no dedicated recipient-resolution engine in
86
+ // this codebase yet, so this composes the existing busybase-store queries the
87
+ // same way the rest of the app looks up users -- a single explicit user object
88
+ // on the context (context.recipientUser / a matching *_id field) is honored
89
+ // directly, and a bare role/group token that cannot be resolved to concrete
90
+ // users is logged and skipped rather than silently guessed at.
91
+ async function resolveRecipients(recipients, context) {
92
+ const { list, get } = await import('../lib/busybase-store.js');
93
+
94
+ if (context.recipientUser) return [context.recipientUser];
95
+ if (context.collaborator?.email) return [context.collaborator];
96
+
97
+ if (recipients === 'client_users' && context.engagement?.client_id) {
98
+ return list('user', { client_id: context.engagement.client_id });
99
+ }
100
+ if ((recipients === 'team_members' || recipients === 'team_partners') && context.engagement?.team_id) {
101
+ const team = await get('team', context.engagement.team_id);
102
+ const { safeJsonParse } = await import('../lib/safe-json.js');
103
+ const userIds = safeJsonParse(team?.users, []);
104
+ const users = [];
105
+ for (const id of userIds) {
106
+ const u = await get('user', id);
107
+ if (u) users.push(u);
108
+ }
109
+ return recipients === 'team_partners' ? users.filter(u => u.role === 'partner') : users;
110
+ }
111
+ if (recipients === 'client_admin' && context.engagement?.client_id) {
112
+ return list('user', { client_id: context.engagement.client_id, role: 'admin' });
113
+ }
114
+ if (recipients === 'assigned_users' && (context.rfi?.assigned_to || context.rfi?.assigned_users)) {
115
+ const ids = Array.isArray(context.rfi.assigned_users) ? context.rfi.assigned_users : [context.rfi.assigned_to].filter(Boolean);
116
+ const users = [];
117
+ for (const id of ids) {
118
+ const u = await get('user', id);
119
+ if (u) users.push(u);
120
+ }
121
+ return users;
122
+ }
123
+
124
+ log.warn(`queueEmail: unresolved recipients token "${recipients}"`);
125
+ return [];
126
+ }
127
+
128
+ function templateNameForType(type) {
129
+ // events-engine.js passes descriptive template names (e.g.
130
+ // 'engagement_info_gathering'); the built-in getTemplates() set in
131
+ // email-sender.js is generic ('notification', 'invitation'), so an
132
+ // unrecognized specific name falls back to the generic notification shape.
133
+ return 'notification';
134
+ }
135
+
136
+ /**
137
+ * Send a templated notification email to a resolved set of recipients.
138
+ * Composes the existing sendTemplatedEmail (src/services/email-sender.js)
139
+ * rather than reimplementing delivery -- this is the queueEmail primitive
140
+ * events-engine.js drives its afterCreate/afterUpdate hooks through.
141
+ */
142
+ export async function queueEmail(templateName, context = {}) {
143
+ const { recipients, ...rest } = context;
144
+ try {
145
+ const users = await resolveRecipients(recipients, rest);
146
+ if (!users.length) return { sent: 0, templateName };
147
+
148
+ const { sendTemplatedEmail } = await import('./email-sender.js');
149
+ let sent = 0;
150
+ for (const user of users) {
151
+ if (!user?.email) continue;
152
+ try {
153
+ await sendTemplatedEmail(templateNameForType(templateName), user.email, { ...rest, templateName });
154
+ sent++;
155
+ } catch (err) {
156
+ log.error('queueEmail send failed:', { message: err.message, templateName, to: user.email });
157
+ }
158
+ }
159
+ return { sent, templateName };
160
+ } catch (err) {
161
+ log.error('queueEmail failed:', { message: err.message, templateName });
162
+ return { sent: 0, templateName, error: err.message };
163
+ }
164
+ }
165
+
83
166
  function interpolate(template, context) {
84
167
  if (!template) return '';
85
168
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
@@ -48,7 +48,8 @@ export function quickViewAttachment() {
48
48
  <div class="dialog-footer"><a id="qv-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="quick-view">Close</button></div>
49
49
  </div></div>
50
50
  <script>
51
- window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="display:flex;justify-content:center"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg></div><div class="mt-2">'+name+'</div><div class="text-xs mt-1">Preview not available</div></div>'}};
51
+ ${FD_ESC}
52
+ window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+fdEsc(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="display:flex;justify-content:center"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg></div><div class="mt-2">'+fdEsc(name)+'</div><div class="text-xs mt-1">Preview not available</div></div>'}};
52
53
  </script>`;
53
54
  }
54
55
  export function fetchCachedPdf(fileId) {
@@ -67,9 +68,15 @@ export function fetchCachedPdf(fileId) {
67
68
  };
68
69
  </script>`;
69
70
  }
71
+ function fdEscServer(s) {
72
+ return String(s == null ? '' : s)
73
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
74
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
75
+ }
76
+
70
77
  export function fileAttachmentBar(files = []) {
71
78
  if (!files.length) return '';
72
- const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${f.id}/download","${(f.name || '').replace(/"/g, '&quot;')}","${f.mime_type || ''}"]'><span style="display:inline-flex"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span><span class="text-xs truncate" style="max-width:120px">${f.name || 'file'}</span></div>`).join('');
79
+ const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${fdEscServer(f.id)}/download","${fdEscServer(f.name || '')}","${fdEscServer(f.mime_type || '')}"]'><span style="display:inline-flex"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span><span class="text-xs truncate" style="max-width:120px">${fdEscServer(f.name || 'file')}</span></div>`).join('');
73
80
  return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
74
81
  }
75
82
  export function fileLinksBar(links = []) {
@@ -14,7 +14,7 @@ export function highlightRow(h) {
14
14
  try { tags = JSON.parse(h.tags || '[]') || []; } catch {}
15
15
  const isFlagged = flags.includes('flagged');
16
16
 
17
- const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? 'var(--color-warning)' : 'var(--color-text-muted)'}">${isFlagged ? '' : ''}</button>`;
17
+ const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? 'var(--color-warning)' : 'var(--color-text-muted)'}">${isFlagged ? 'Unflag' : 'Flag'}</button>`;
18
18
  const tagPills = tags.map(t => `<span style="display:inline-flex;align-items:center;gap:3px;background:var(--color-info-bg,#eff6ff);color:var(--color-info,#1e40af);font-size:11px;padding:2px 8px;border-radius:9999px;font-weight:500">${esc(t)}<button data-action="removeTag" data-args='["${esc(h.id)}","${esc(t)}"]' style="background:none;border:none;cursor:pointer;padding:0;margin-left:2px;font-size:11px;color:var(--color-text-muted);line-height:1">&times;</button></span>`).join(' ');
19
19
  const addTagBtn = `<button data-action="openAddTag" data-args='["${esc(h.id)}"]' style="background:none;border:none;cursor:pointer;font-size:11px;color:var(--color-text-muted);padding:1px 5px;border:1px dashed var(--color-border,#e5e7eb);border-radius:9999px">+tag</button>`;
20
20
  const tagsCell = `<div style="display:flex;flex-wrap:wrap;gap:3px;align-items:center;min-width:80px">${tagPills}${addTagBtn}</div>`;
@@ -880,11 +880,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
880
880
  .review-row-name { font-weight: 500; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
881
881
  @media (max-width: 767px) { .review-row-name { max-width: 140px; } }
882
882
 
883
- /* ═══════════════════════════════════════════════════════════
884
- TAILWIND-COMPATIBLE UTILITY CLASSES
883
+ /* ---- TAILWIND-COMPATIBLE UTILITY CLASSES ----
885
884
  Required because rippleui.css is PurgeCSS-built without
886
- general utilities. These cover all classes used in UI files.
887
- ═══════════════════════════════════════════════════════════ */
885
+ general utilities. These cover all classes used in UI files. */
888
886
 
889
887
  /* Display */
890
888
  .flex { display: flex; }
@@ -1336,9 +1334,7 @@ tr.is-read td { color: rgb(var(--base-content, 30 30 35) / 0.7); }
1336
1334
  .italic { font-style: italic; }
1337
1335
  .not-italic { font-style: normal; }
1338
1336
 
1339
- /* ═══════════════════════════════════════════════════════════
1340
- VISUAL POLISH & IMPROVED COMPONENT STYLES
1341
- ═══════════════════════════════════════════════════════════ */
1337
+ /* ---- VISUAL POLISH & IMPROVED COMPONENT STYLES ---- */
1342
1338
 
1343
1339
  /* Nav separator hidden */
1344
1340
  .nav-sep { display: none; }
@@ -1,16 +0,0 @@
1
- import { createCrudHandlers } from '../lib/crud-factory.js';
2
- import { getConfigEngineSync } from '../lib/config-generator-engine.js';
3
-
4
- export function getEntityHandlers(entityName) {
5
- return createCrudHandlers(entityName);
6
- }
7
-
8
- export function hasEntity(entityName) {
9
- try {
10
- const engine = getConfigEngineSync();
11
- engine.generateEntitySpec(entityName);
12
- return true;
13
- } catch {
14
- return false;
15
- }
16
- }