thatcher 1.0.47 → 1.0.48

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/README.md CHANGED
@@ -194,7 +194,7 @@ Each entity derives from config:
194
194
  | Config Key | Purpose |
195
195
  |-----------|---------|
196
196
  | `fields` | Column definitions (type, required, ref, enum, etc.) |
197
- | `permission_template` | Maps roles allowed actions |
197
+ | `permission_template` | Maps roles -> allowed actions |
198
198
  | `workflow` | State machine name for lifecycle |
199
199
  | `row_access` | Scoping: `team`, `assigned`, `client` |
200
200
  | `list.defaultSort` | Default list ordering |
@@ -342,30 +342,30 @@ Use via `@/lib/email-sender` (included).
342
342
  All APIs accessible via thatcher instance:
343
343
 
344
344
  ### CRUD
345
- - `thatcher.list(entity, where, opts)` Array
346
- - `thatcher.get(entity, id)` object
347
- - `thatcher.create(entity, data, user)` object
348
- - `thatcher.update(entity, id, data, user)` object
349
- - `thatcher.delete(entity, id)` void
345
+ - `thatcher.list(entity, where, opts)` -> Array
346
+ - `thatcher.get(entity, id)` -> object
347
+ - `thatcher.create(entity, data, user)` -> object
348
+ - `thatcher.update(entity, id, data, user)` -> object
349
+ - `thatcher.delete(entity, id)` -> void
350
350
 
351
351
  ### Search
352
- - `thatcher.search(entity, query, where, opts)` Array (uses FTS)
352
+ - `thatcher.search(entity, query, where, opts)` -> Array (uses FTS)
353
353
 
354
354
  ### Workflow
355
- - `thatcher.transition(entityType, entityId, workflowName, toState, user, reason)` object
356
- - `thatcher.getAvailableTransitions(workflowName, currentState, user, record)` Array
355
+ - `thatcher.transition(entityType, entityId, workflowName, toState, user, reason)` -> object
356
+ - `thatcher.getAvailableTransitions(workflowName, currentState, user, record)` -> Array
357
357
 
358
358
  ### AuthZ
359
- - `thatcher.can(user, spec, action)` boolean
360
- - `thatcher.requirePermission(user, spec, action)` throws if denied
359
+ - `thatcher.can(user, spec, action)` -> boolean
360
+ - `thatcher.requirePermission(user, spec, action)` -> throws if denied
361
361
 
362
362
  ### Config
363
- - `thatcher.getConfigEngine()` ConfigGeneratorEngine
364
- - `thatcher.getEntitySpec(entityName)` spec object
365
- - `thatcher.getAllEntities()` Array<string>
363
+ - `thatcher.getConfigEngine()` -> ConfigGeneratorEngine
364
+ - `thatcher.getEntitySpec(entityName)` -> spec object
365
+ - `thatcher.getAllEntities()` -> Array<string>
366
366
 
367
367
  ### Direct DB
368
- - `thatcher.withTransaction(cb)` Promise<result>
368
+ - `thatcher.withTransaction(cb)` -> Promise<result>
369
369
 
370
370
  ## Environment Variables
371
371
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.47",
3
+ "version": "1.0.48",
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",
@@ -203,13 +203,17 @@ export async function count(entity, where = {}, options = {}) {
203
203
  const tbl = tableName(entity);
204
204
  let rows = unwrap(await applyWhere(client().from(tbl).select('*'), where), 'count');
205
205
  rows = applyVisibility(spec, rows, where, options);
206
+ if (options.user && (spec.rowAccess || spec.row_access)) {
207
+ const { permissionService } = await import('../services/permission.service.js');
208
+ rows = permissionService.filterRecords(options.user, spec, rows);
209
+ }
206
210
  return rows.length;
207
211
  }
208
212
 
209
- export async function listWithPagination(entity, where = {}, page = 1, pageSize = 50) {
213
+ export async function listWithPagination(entity, where = {}, page = 1, pageSize = 50, options = {}) {
210
214
  const finalPage = Math.max(1, page);
211
- const total = await count(entity, where);
212
- const items = await list(entity, where, { offset: (finalPage - 1) * pageSize, limit: pageSize });
215
+ const total = await count(entity, where, options);
216
+ const items = await list(entity, where, { ...options, offset: (finalPage - 1) * pageSize, limit: pageSize });
213
217
  return { items, pagination: { page: finalPage, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
214
218
  }
215
219
 
@@ -337,11 +341,11 @@ export async function search(entity, query, where = {}, options = {}) {
337
341
  return matched;
338
342
  }
339
343
 
340
- export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null) {
344
+ export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null, options = {}) {
341
345
  const spec = specOf(entity);
342
346
  const finalPageSize = pageSize || spec.list?.pageSize || 50;
343
347
  const finalPage = Math.max(1, page);
344
- const all = await search(entity, query, where);
348
+ const all = await search(entity, query, where, options);
345
349
  const total = all.length;
346
350
  const items = all.slice((finalPage - 1) * finalPageSize, finalPage * finalPageSize);
347
351
  return { items, pagination: { page: finalPage, pageSize: finalPageSize, total, totalPages: Math.ceil(total / finalPageSize) } };
@@ -43,7 +43,7 @@ export function createCrudHandlers(entityName, spec) {
43
43
  let items, pagination;
44
44
 
45
45
  if (q) {
46
- const result = await searchWithPagination(entityName, q, {}, finalPage, finalPageSize);
46
+ const result = await searchWithPagination(entityName, q, {}, finalPage, finalPageSize, { user });
47
47
  items = result.items;
48
48
  pagination = result.pagination;
49
49
  } else {
@@ -54,7 +54,7 @@ export function createCrudHandlers(entityName, spec) {
54
54
  coercedFilters[key] = fd ? coerceFieldValue(value, fd.type) : value;
55
55
  }
56
56
  }
57
- const result = await listWithPagination(entityName, coercedFilters, finalPage, finalPageSize);
57
+ const result = await listWithPagination(entityName, coercedFilters, finalPage, finalPageSize, { user });
58
58
  items = result.items;
59
59
  pagination = result.pagination;
60
60
  }
@@ -200,7 +200,7 @@ export function createCrudHandlers(entityName, spec) {
200
200
  id,
201
201
  data: { id, uploaded_files: files },
202
202
  user,
203
- });
203
+ }).catch(e => log.error(e.message));
204
204
  return ok({ id, uploaded_files: files });
205
205
  }
206
206
 
@@ -3,6 +3,31 @@ import path from 'path';
3
3
  import fs from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createLogger } from '../lib/logger.js';
6
+ import { getLucia } from '../engine.server.js';
7
+ import { requirePermission } from '../lib/auth-middleware.js';
8
+
9
+ // Request-scoped session resolution: NOT engine.server.js's getUser(), which
10
+ // reads a module-level _currentRequest global -- unsafe here since this raw
11
+ // http server handles requests concurrently and that global would let one
12
+ // in-flight request's cookie leak into another's session lookup mid-await.
13
+ // getLucia() itself holds no per-request state, so calling it directly per
14
+ // request and validating against this request's own cookie header is safe.
15
+ async function resolveRequestUser(req) {
16
+ let lucia;
17
+ try { lucia = getLucia(); } catch { return null; }
18
+ const cookieHeader = req.headers?.cookie || '';
19
+ if (!cookieHeader) return null;
20
+ const cookieName = lucia.sessionCookieName || 'thatcher_session';
21
+ const match = cookieHeader.split(';').find(c => c.trim().startsWith(cookieName + '='));
22
+ if (!match) return null;
23
+ const sessionId = decodeURIComponent(match.split('=')[1] || '');
24
+ if (!sessionId) return null;
25
+ try {
26
+ const { user, session } = await lucia.validateSession(sessionId);
27
+ if (!user || !session) return null;
28
+ return user;
29
+ } catch { return null; }
30
+ }
6
31
 
7
32
  const log = createLogger('[Server]');
8
33
  const pageLog = createLogger('[Page]');
@@ -203,11 +228,12 @@ async function sendResponse(res, response) {
203
228
  }
204
229
 
205
230
  async function handleGenericCrud(req, res, entity, id, action, thatcher, configEngineArg) {
206
- // Simple auth: get from cookie or header
207
- let user = null;
208
- // In a real implementation, we'd decode session token
209
- // For now, default to system user for testing
210
- user = { id: 'system', role: 'admin' };
231
+ const user = await resolveRequestUser(req);
232
+ if (!user) {
233
+ res.writeHead(401, { 'Content-Type': 'application/json' });
234
+ res.end(JSON.stringify({ error: 'Authentication required' }));
235
+ return;
236
+ }
211
237
 
212
238
  // Prefer the engine passed down from createServer options (guaranteed the
213
239
  // same instance that was initialized at startup); fall back to thatcher /
@@ -217,15 +243,23 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
217
243
  const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
218
244
  configEngine = getConfigEngineSync();
219
245
  }
246
+ let spec;
220
247
  try {
221
- configEngine.generateEntitySpec(entity);
248
+ spec = configEngine.generateEntitySpec(entity);
222
249
  } catch (e) {
223
250
  res.writeHead(404);
224
251
  res.end(JSON.stringify({ error: `Entity '${entity}' not found` }));
225
252
  return;
226
253
  }
227
254
 
228
- // Permission check would go here via thatcher.can(user, spec, action)
255
+ const methodAction = { GET: 'list', POST: 'create', PUT: 'edit', PATCH: 'edit', DELETE: 'delete' }[req.method] || 'view';
256
+ try {
257
+ await requirePermission(user, spec, id && req.method === 'GET' ? 'view' : methodAction);
258
+ } catch (e) {
259
+ res.writeHead(e?.status || 403, { 'Content-Type': 'application/json' });
260
+ res.end(JSON.stringify({ error: e?.message || 'Forbidden' }));
261
+ return;
262
+ }
229
263
 
230
264
  let body;
231
265
  try {
@@ -250,7 +284,7 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
250
284
  return;
251
285
  }
252
286
  } else {
253
- result = await thatcher.list(entity);
287
+ result = await thatcher.list(entity, {}, { user });
254
288
  }
255
289
  break;
256
290
 
@@ -47,12 +47,12 @@ class PermissionService {
47
47
  return false;
48
48
  }
49
49
 
50
- if (scope === 'assigned' && record[ownerField] && record[ownerField] !== user.id && user.role !== partnerRole) {
50
+ if (scope === 'assigned' && user.role !== partnerRole && record[ownerField] !== user.id) {
51
51
  return false;
52
52
  }
53
53
 
54
54
  if (scope === 'assigned_or_team' && user.role !== partnerRole) {
55
- const assignedMatch = record[ownerField] && record[ownerField] === user.id;
55
+ const assignedMatch = record[ownerField] === user.id;
56
56
  const teamMatch = record.team_id && user.team_id && record.team_id === user.team_id;
57
57
  if (!assignedMatch && !teamMatch) return false;
58
58
  }
@@ -1,44 +0,0 @@
1
- import zlib from 'zlib';
2
-
3
- const COMPRESSION_THRESHOLD = 1024; // Only compress files >1KB
4
-
5
- export function compress(content, acceptEncoding = '') {
6
- const size = Buffer.byteLength(content, 'utf-8');
7
- if (size < COMPRESSION_THRESHOLD) return { content, encoding: null };
8
-
9
- const ae = acceptEncoding.toLowerCase();
10
-
11
- if (ae.includes('br')) {
12
- const compressed = zlib.brotliCompressSync(content, {
13
- params: {
14
- [zlib.constants.BROTLI_PARAM_QUALITY]: 6,
15
- [zlib.constants.BROTLI_PARAM_SIZE_HINT]: size
16
- }
17
- });
18
- return { content: compressed, encoding: 'br' };
19
- }
20
-
21
- if (ae.includes('gzip')) {
22
- const compressed = zlib.gzipSync(content, { level: 6 });
23
- return { content: compressed, encoding: 'gzip' };
24
- }
25
-
26
- return { content, encoding: null };
27
- }
28
-
29
- export function getCacheHeaders(type, maxAge = 86400) {
30
- if (type === 'static') {
31
- return {
32
- 'Cache-Control': `public, max-age=${maxAge}, immutable`,
33
- 'Expires': new Date(Date.now() + maxAge * 1000).toUTCString()
34
- };
35
- }
36
- if (type === 'dynamic') {
37
- return {
38
- 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
39
- 'Pragma': 'no-cache',
40
- 'Expires': '0'
41
- };
42
- }
43
- return {};
44
- }
@@ -1,160 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
-
4
- export function resolveParamRoute(baseDir, segments) {
5
- if (!fs.existsSync(baseDir)) return null;
6
- if (segments.length === 0) {
7
- const r = path.join(baseDir, 'route.js');
8
- return fs.existsSync(r) ? r : null;
9
- }
10
- const [seg, ...rest] = segments;
11
- const entries = fs.readdirSync(baseDir, { withFileTypes: true }).filter(e => e.isDirectory());
12
- for (const entry of entries) {
13
- const name = entry.name;
14
- if (name === seg || name.startsWith('[')) {
15
- const found = resolveParamRoute(path.join(baseDir, name), rest);
16
- if (found) return found;
17
- }
18
- }
19
- return null;
20
- }
21
-
22
- export function resolveSpecificParams(routeFile, pathParts) {
23
- const result = {};
24
- const routeRelative = routeFile.replace(/.*src\/app\/api\//, '').replace(/\/route\.js$/, '');
25
- const routeSegments = routeRelative.split('/');
26
- const urlSegments = pathParts.slice(0);
27
- for (let i = 0; i < routeSegments.length && i < urlSegments.length; i++) {
28
- const seg = routeSegments[i];
29
- if (seg.startsWith('[') && seg.endsWith(']')) {
30
- const paramName = seg.replace(/^\[\.\.\./, '').replace(/[[\]]/g, '');
31
- result[paramName] = urlSegments[i];
32
- }
33
- }
34
- return result;
35
- }
36
-
37
- function singularize(name) {
38
- if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
39
- if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) return name.slice(0, -2);
40
- if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
41
- return name;
42
- }
43
-
44
- export function buildNestedRoutePath(baseDir, domain, parentEntity, childParts) {
45
- if (childParts.length === 0) return null;
46
-
47
- function buildSegments(parentParam, childParamFn) {
48
- const segs = [domain, parentEntity, parentParam];
49
- for (let i = 0; i < childParts.length; i++) {
50
- if (i % 2 === 0) segs.push(childParts[i]);
51
- else segs.push(childParamFn(i));
52
- }
53
- return path.join(baseDir, 'src/app/api', ...segs, 'route.js');
54
- }
55
-
56
- const variants = [
57
- () => buildSegments('[id]', (i) => `[${childParts[i - 1]}Id]`),
58
- () => buildSegments('[id]', (i) => `[${singularize(childParts[i - 1])}Id]`),
59
- () => buildSegments(`[${parentEntity}Id]`, (i) => `[${childParts[i - 1]}Id]`),
60
- () => buildSegments(`[${parentEntity}Id]`, (i) => `[${singularize(childParts[i - 1])}Id]`),
61
- () => buildSegments('[id]', () => `[${childParts[0]}Id]`),
62
- () => buildSegments('[id]', () => `[${singularize(childParts[0])}Id]`),
63
- ];
64
-
65
- // Also try a "leaf-action" shape with no further id after the child segment.
66
- // Routes like /mwr/review/[id]/export-pdf/route.js (parent + id + leaf action)
67
- // were missed by the segment-pair variants above.
68
- if (childParts.length === 1) {
69
- const leaf = path.join(baseDir, 'src/app/api', domain, parentEntity, '[id]', childParts[0], 'route.js');
70
- if (fs.existsSync(leaf)) return leaf;
71
- const leafByEntity = path.join(baseDir, 'src/app/api', domain, parentEntity, `[${parentEntity}Id]`, childParts[0], 'route.js');
72
- if (fs.existsSync(leafByEntity)) return leafByEntity;
73
- }
74
-
75
- for (const variant of variants) {
76
- const candidate = variant();
77
- if (fs.existsSync(candidate)) return candidate;
78
- }
79
- return variants[0]();
80
- }
81
-
82
- const DOMAINS = ['friday', 'mwr'];
83
-
84
- export function resolveRoute(__dirname, pathname, url) {
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
-
94
- const firstPart = pathParts[0];
95
- const isDomain = DOMAINS.includes(firstPart);
96
- let routeFile = null;
97
- let params = {};
98
-
99
- if (isDomain) {
100
- const domain = firstPart;
101
- const domainParts = pathParts.slice(1);
102
-
103
- const specificCheck = path.join(__dirname, `src/app/api/${domain}/${domainParts.join('/')}/route.js`);
104
- if (fs.existsSync(specificCheck)) {
105
- routeFile = specificCheck;
106
- params = resolveSpecificParams(specificCheck, pathParts);
107
- }
108
-
109
- if (!routeFile && domainParts.length >= 3) {
110
- const parentEntity = domainParts[0];
111
- const childParts = domainParts.slice(2);
112
- const parentId = domainParts[1];
113
- const childEntity = childParts[0];
114
- const childId = childParts[1] || null;
115
-
116
- const nestedSpecific = buildNestedRoutePath(__dirname, domain, parentEntity, childParts);
117
- if (nestedSpecific && fs.existsSync(nestedSpecific)) {
118
- routeFile = nestedSpecific;
119
- params = resolveSpecificParams(nestedSpecific, pathParts);
120
- }
121
-
122
- if (!routeFile) {
123
- routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
124
- url.searchParams.set('domain', domain);
125
- params = { entity: childEntity, path: childId ? [childId] : [], parentEntity, parentId };
126
- }
127
- }
128
-
129
- if (!routeFile && domainParts.length >= 1) {
130
- const entity = domainParts[0];
131
- const entityPath = domainParts.slice(1);
132
- routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
133
- url.searchParams.set('domain', domain);
134
- params = { entity, path: entityPath };
135
- }
136
- }
137
-
138
- if (!routeFile && firstPart) {
139
- const exactRoute = path.join(__dirname, `src/app/api/${pathParts.join('/')}/route.js`);
140
- if (fs.existsSync(exactRoute)) {
141
- routeFile = exactRoute;
142
- params = resolveSpecificParams(exactRoute, pathParts);
143
- }
144
- }
145
-
146
- if (!routeFile && firstPart) {
147
- const paramRouteFile = resolveParamRoute(path.join(__dirname, 'src/app/api', firstPart), pathParts.slice(1));
148
- if (paramRouteFile) {
149
- routeFile = paramRouteFile;
150
- params = resolveSpecificParams(paramRouteFile, pathParts);
151
- }
152
- }
153
-
154
- if (!routeFile) {
155
- routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
156
- params = { entity: firstPart, path: pathParts.slice(1) };
157
- }
158
-
159
- return { routeFile, params, isDomain, firstPart, pathParts };
160
- }
@@ -1,184 +0,0 @@
1
- // state-sync channel: message-type vocabulary + VectorClock + schema validation
2
- // shared by state-transport-server.js / state-transport-client.js /
3
- // state-transport-reconnect.js. This is a structured, reconnect-aware
4
- // WebSocket protocol (real `ws` transport, IP/rate-limited via
5
- // connection-guard.js, exponential-backoff client reconnect with a polling
6
- // fallback) -- distinct from the simpler in-process pub/sub in
7
- // realtime-server.js (the "ws-broadcast" channel actually wired into the CRUD
8
- // write path via src/lib/api.js). As of this writing, grepping the whole repo
9
- // found ZERO importers of this quartet outside their own internal cross-imports
10
- // (state-transport-client.js imports state-transport-reconnect.js; both import
11
- // this file) -- nothing external constructs a StateTransportServer/Client, so
12
- // this stack is currently unwired/dormant rather than superseding
13
- // realtime-server.js. Keep both; re-check importers before deleting either.
14
- const MESSAGE_TYPES = {
15
- STATE_UPDATE: 'state_update',
16
- STATE_SNAPSHOT: 'state_snapshot',
17
- STATE_REQUEST: 'state_request',
18
- STATE_ACK: 'state_ack',
19
- STATE_NACK: 'state_nack',
20
- PING: 'ping',
21
- PONG: 'pong'
22
- }
23
-
24
- const ERROR_CODES = {
25
- INVALID_MESSAGE: 'invalid_message',
26
- INVALID_VERSION: 'invalid_version',
27
- CONFLICT_DETECTED: 'conflict_detected',
28
- RATE_LIMITED: 'rate_limited',
29
- VALIDATION_FAILED: 'validation_failed'
30
- }
31
-
32
- class VectorClock {
33
- constructor(nodeId) {
34
- this.nodeId = nodeId
35
- this.clock = { [nodeId]: 0 }
36
- }
37
-
38
- increment() {
39
- this.clock[this.nodeId] = (this.clock[this.nodeId] || 0) + 1
40
- return this.clock[this.nodeId]
41
- }
42
-
43
- update(otherClock) {
44
- for (const [nodeId, timestamp] of Object.entries(otherClock)) {
45
- this.clock[nodeId] = Math.max(this.clock[nodeId] || 0, timestamp)
46
- }
47
- }
48
-
49
- compare(otherClock) {
50
- const keys = new Set([...Object.keys(this.clock), ...Object.keys(otherClock)])
51
- let hasGreater = false
52
- let hasLess = false
53
-
54
- for (const key of keys) {
55
- const mine = this.clock[key] || 0
56
- const theirs = otherClock[key] || 0
57
- if (mine > theirs) hasGreater = true
58
- if (mine < theirs) hasLess = true
59
- }
60
-
61
- if (hasGreater && !hasLess) return 1
62
- if (hasLess && !hasGreater) return -1
63
- if (!hasGreater && !hasLess) return 0
64
- return null // Concurrent
65
- }
66
-
67
- serialize() {
68
- return { ...this.clock }
69
- }
70
-
71
- static deserialize(data, nodeId) {
72
- const vc = new VectorClock(nodeId)
73
- vc.clock = { ...data }
74
- return vc
75
- }
76
- }
77
-
78
- const MessageSchema = {
79
- validate(message) {
80
- if (!message || typeof message !== 'object') {
81
- return { valid: false, error: 'Message must be an object' }
82
- }
83
- const validTypes = Object.values(MESSAGE_TYPES)
84
- if (!validTypes.includes(message.type)) {
85
- return { valid: false, error: 'Invalid message type' }
86
- }
87
- if (message.type !== MESSAGE_TYPES.PING && message.type !== MESSAGE_TYPES.PONG) {
88
- if (!message.version || typeof message.version !== 'object') {
89
- return { valid: false, error: 'Missing or invalid version vector' }
90
- }
91
- }
92
- if (message.type === MESSAGE_TYPES.STATE_UPDATE || message.type === MESSAGE_TYPES.STATE_SNAPSHOT) {
93
- if (message.data === undefined) {
94
- return { valid: false, error: 'Missing data field' }
95
- }
96
- }
97
- return { valid: true }
98
- }
99
- }
100
-
101
- function createMessage(type, payload = {}) {
102
- return {
103
- type,
104
- timestamp: Date.now(),
105
- ...payload
106
- }
107
- }
108
-
109
- function createStateUpdate(vectorClock, data, operations = []) {
110
- return createMessage(MESSAGE_TYPES.STATE_UPDATE, {
111
- version: vectorClock.serialize(),
112
- data,
113
- operations
114
- })
115
- }
116
-
117
- function createStateSnapshot(vectorClock, data) {
118
- return createMessage(MESSAGE_TYPES.STATE_SNAPSHOT, {
119
- version: vectorClock.serialize(),
120
- data
121
- })
122
- }
123
-
124
- function createStateRequest(vectorClock) {
125
- return createMessage(MESSAGE_TYPES.STATE_REQUEST, {
126
- version: vectorClock.serialize()
127
- })
128
- }
129
-
130
- function createStateAck(messageId, vectorClock) {
131
- return createMessage(MESSAGE_TYPES.STATE_ACK, {
132
- messageId,
133
- version: vectorClock.serialize()
134
- })
135
- }
136
-
137
- function createStateNack(messageId, error, errorCode) {
138
- return createMessage(MESSAGE_TYPES.STATE_NACK, {
139
- messageId,
140
- error,
141
- errorCode
142
- })
143
- }
144
-
145
- function createPing() {
146
- return createMessage(MESSAGE_TYPES.PING)
147
- }
148
-
149
- function createPong(pingTimestamp) {
150
- return createMessage(MESSAGE_TYPES.PONG, { pingTimestamp })
151
- }
152
-
153
- const Protocol = {
154
- MESSAGE_TYPES,
155
- ERROR_CODES,
156
- VectorClock,
157
- MessageSchema,
158
- createMessage,
159
- createStateUpdate,
160
- createStateSnapshot,
161
- createStateRequest,
162
- createStateAck,
163
- createStateNack,
164
- createPing,
165
- createPong
166
- }
167
-
168
- export {
169
- MESSAGE_TYPES,
170
- ERROR_CODES,
171
- VectorClock,
172
- MessageSchema,
173
- createMessage,
174
- createStateUpdate,
175
- createStateSnapshot,
176
- createStateRequest,
177
- createStateAck,
178
- createStateNack,
179
- createPing,
180
- createPong,
181
- Protocol
182
- }
183
-
184
- export default Protocol
@@ -1,178 +0,0 @@
1
- // state-sync channel (client side): browser WebSocket client with
2
- // exponential-backoff reconnect + polling fallback, part of the
3
- // state-protocol.js quartet. See the header comment in state-protocol.js --
4
- // this is a structured, reconnect-aware protocol distinct from
5
- // realtime-server.js's simple in-process pub/sub. Zero real importers found
6
- // repo-wide as of this writing; currently dormant/unwired.
7
- import Protocol from './state-protocol.js'
8
- import { CONFIG, ReconnectManager } from './state-transport-reconnect.js'
9
- import { createLogger } from './logger.js'
10
-
11
- const log = createLogger('[StateTransport]')
12
-
13
- class StateTransportClient {
14
- constructor(config = {}) {
15
- this.config = { ...CONFIG, ...config }
16
- this.ws = null
17
- this.state = 'disconnected'
18
- this.listeners = new Map()
19
- this.messageQueue = []
20
- this.connectionTimeoutTimer = null
21
-
22
- this.reconnect = new ReconnectManager(this.config, {
23
- onReconnecting: (data) => { this.emit('reconnecting', data); this.connect() },
24
- onFallback: (msg) => this.emit('fallback', msg),
25
- onPollStart: () => this.emit('poll_start'),
26
- onPollSuccess: (result) => this.emit('poll_success', result),
27
- onPollError: (error) => this.emit('poll_error', error)
28
- })
29
- }
30
-
31
- connect(url) {
32
- this.url = url || this.url
33
- if (!this.url) {
34
- this.emit('error', new Error('No URL provided for connection'))
35
- return
36
- }
37
- this.reconnect.wsUrl = this.url
38
-
39
- if (this.state === 'connecting' || this.state === 'connected') return
40
-
41
- this.state = 'connecting'
42
- this.emit('connecting')
43
-
44
- try {
45
- this.ws = new WebSocket(this.url)
46
-
47
- this.connectionTimeoutTimer = setTimeout(() => {
48
- if (this.state === 'connecting') this.handleConnectionTimeout()
49
- }, this.config.connectionTimeout)
50
-
51
- this.ws.onopen = () => this.handleOpen()
52
- this.ws.onclose = (event) => this.handleClose(event)
53
- this.ws.onerror = (error) => this.handleError(error)
54
- this.ws.onmessage = (event) => this.handleMessage(event)
55
- } catch (error) {
56
- this.handleConnectionFailure(error)
57
- }
58
- }
59
-
60
- handleOpen() {
61
- clearTimeout(this.connectionTimeoutTimer)
62
- this.state = 'connected'
63
- this.reconnect.resetAttempts()
64
- this.emit('connected')
65
- this.reconnect.startPingInterval(
66
- (msg) => this.send(msg),
67
- () => this.ws.close()
68
- )
69
- this.flushMessageQueue()
70
- this.reconnect.stopPolling()
71
- }
72
-
73
- handleClose(event) {
74
- clearTimeout(this.connectionTimeoutTimer)
75
- this.state = 'disconnected'
76
- this.reconnect.stopPingInterval()
77
- this.emit('disconnected', { code: event.code, reason: event.reason })
78
- this.reconnect.scheduleReconnect()
79
- }
80
-
81
- handleError(error) {
82
- this.emit('error', error)
83
- }
84
-
85
- handleConnectionTimeout() {
86
- if (this.ws) this.ws.close()
87
- this.handleConnectionFailure(new Error('Connection timeout'))
88
- }
89
-
90
- handleConnectionFailure(error) {
91
- this.emit('error', error)
92
- this.state = 'disconnected'
93
-
94
- if (this.reconnect.shouldFallback) {
95
- this.reconnect.activateFallback()
96
- } else {
97
- this.reconnect.scheduleReconnect()
98
- }
99
- }
100
-
101
- handleMessage(event) {
102
- try {
103
- const message = JSON.parse(event.data)
104
- if (message.type === Protocol.MESSAGE_TYPES.PONG) {
105
- this.reconnect.recordPong()
106
- return
107
- }
108
- this.emit('message', message)
109
- } catch (error) {
110
- this.emit('error', { error, phase: 'message_parsing', data: event.data })
111
- }
112
- }
113
-
114
- send(message) {
115
- if (this.state === 'connected' && this.ws && this.ws.readyState === WebSocket.OPEN) {
116
- try {
117
- this.ws.send(JSON.stringify(message))
118
- return true
119
- } catch (error) {
120
- this.emit('error', { error, phase: 'send' })
121
- this.messageQueue.push(message)
122
- return false
123
- }
124
- }
125
- this.messageQueue.push(message)
126
- return false
127
- }
128
-
129
- flushMessageQueue() {
130
- while (this.messageQueue.length > 0 && this.state === 'connected') {
131
- const message = this.messageQueue.shift()
132
- this.send(message)
133
- }
134
- }
135
-
136
- disconnect() {
137
- this.reconnect.cleanup()
138
- if (this.ws) {
139
- this.ws.close()
140
- this.ws = null
141
- }
142
- this.state = 'disconnected'
143
- }
144
-
145
- on(event, handler) {
146
- if (!this.listeners.has(event)) this.listeners.set(event, [])
147
- this.listeners.get(event).push(handler)
148
- }
149
-
150
- off(event, handler) {
151
- if (!this.listeners.has(event)) return
152
- const handlers = this.listeners.get(event)
153
- const index = handlers.indexOf(handler)
154
- if (index > -1) handlers.splice(index, 1)
155
- }
156
-
157
- emit(event, data) {
158
- if (!this.listeners.has(event)) return
159
- for (const handler of this.listeners.get(event)) {
160
- try {
161
- handler(data)
162
- } catch (error) {
163
- log.error(`event handler error for ${event}:`, { message: error?.message || String(error) })
164
- }
165
- }
166
- }
167
-
168
- getState() {
169
- return this.state
170
- }
171
-
172
- isConnected() {
173
- return this.state === 'connected'
174
- }
175
- }
176
-
177
- export { StateTransportClient }
178
- export default StateTransportClient
@@ -1,126 +0,0 @@
1
- // state-sync channel (reconnect logic): exponential-backoff + polling-fallback
2
- // manager consumed by state-transport-client.js, part of the state-protocol.js
3
- // quartet. See the header comment in state-protocol.js for the full
4
- // ws-broadcast vs state-sync distinction. Zero real importers repo-wide
5
- // outside this quartet's own internal cross-imports as of this writing.
6
- import Protocol from './state-protocol.js'
7
-
8
- const CONFIG = {
9
- reconnectInitialDelay: 1000,
10
- reconnectMaxDelay: 30000,
11
- reconnectBackoffFactor: 2,
12
- pingInterval: 25000,
13
- connectionTimeout: 10000,
14
- pollingInterval: 5000,
15
- pollingFallbackDelay: 3000
16
- }
17
-
18
- class ReconnectManager {
19
- constructor(config, callbacks) {
20
- this.config = config
21
- this.callbacks = callbacks
22
- this.reconnectAttempts = 0
23
- this.reconnectTimer = null
24
- this.pingTimer = null
25
- this.pollingTimer = null
26
- this.lastPongTime = 0
27
- this.useFallback = false
28
- this.wsUrl = null
29
- }
30
-
31
- get shouldFallback() {
32
- return this.reconnectAttempts >= 3 && !this.useFallback
33
- }
34
-
35
- resetAttempts() {
36
- this.reconnectAttempts = 0
37
- this.useFallback = false
38
- }
39
-
40
- recordPong() {
41
- this.lastPongTime = Date.now()
42
- }
43
-
44
- scheduleReconnect() {
45
- if (this.reconnectTimer) {
46
- clearTimeout(this.reconnectTimer)
47
- }
48
-
49
- const delay = Math.min(
50
- this.config.reconnectInitialDelay * Math.pow(this.config.reconnectBackoffFactor, this.reconnectAttempts),
51
- this.config.reconnectMaxDelay
52
- )
53
-
54
- const jitter = Math.random() * 1000
55
- this.reconnectAttempts++
56
-
57
- this.reconnectTimer = setTimeout(() => {
58
- this.callbacks.onReconnecting({ attempt: this.reconnectAttempts })
59
- }, delay + jitter)
60
- }
61
-
62
- activateFallback() {
63
- this.useFallback = true
64
- this.callbacks.onFallback('Switching to polling mode')
65
- this.startPolling()
66
- }
67
-
68
- startPolling() {
69
- if (this.pollingTimer) return
70
-
71
- this.pollingTimer = setInterval(async () => {
72
- try {
73
- this.callbacks.onPollStart()
74
- const result = await this.pollState()
75
- this.callbacks.onPollSuccess(result)
76
- } catch (error) {
77
- this.callbacks.onPollError(error)
78
- }
79
- }, this.config.pollingInterval)
80
- }
81
-
82
- async pollState() {
83
- const httpUrl = this.wsUrl.replace('ws://', 'http://').replace('wss://', 'https://')
84
- const response = await fetch(`${httpUrl}/poll`)
85
- if (!response.ok) {
86
- throw new Error(`Polling failed: ${response.status}`)
87
- }
88
- return await response.json()
89
- }
90
-
91
- startPingInterval(sendFn, closeFn) {
92
- this.pingTimer = setInterval(() => {
93
- sendFn(Protocol.createPing())
94
- const timeSinceLastPong = Date.now() - this.lastPongTime
95
- if (timeSinceLastPong > this.config.pingInterval * 2) {
96
- closeFn()
97
- }
98
- }, this.config.pingInterval)
99
- }
100
-
101
- stopPingInterval() {
102
- if (this.pingTimer) {
103
- clearInterval(this.pingTimer)
104
- this.pingTimer = null
105
- }
106
- }
107
-
108
- stopPolling() {
109
- if (this.pollingTimer) {
110
- clearInterval(this.pollingTimer)
111
- this.pollingTimer = null
112
- }
113
- }
114
-
115
- cleanup() {
116
- if (this.reconnectTimer) {
117
- clearTimeout(this.reconnectTimer)
118
- this.reconnectTimer = null
119
- }
120
- this.stopPolling()
121
- this.stopPingInterval()
122
- }
123
- }
124
-
125
- export { CONFIG, ReconnectManager }
126
- export default ReconnectManager
@@ -1,189 +0,0 @@
1
- // state-sync channel (server side): real `ws`-backed WebSocketServer with
2
- // connection-guard IP/rate limiting, part of the state-protocol.js quartet.
3
- // See the header comment in state-protocol.js for the full picture -- this is
4
- // a structured, reconnect-aware state-sync protocol, DIFFERENT from and not a
5
- // replacement for realtime-server.js's simple in-process pub/sub
6
- // (the "ws-broadcast" channel actually used by the CRUD write path). As of
7
- // this writing this file has zero real importers repo-wide; nothing
8
- // constructs a StateTransportServer today.
9
- import { WebSocketServer } from 'ws'
10
- import { EventEmitter } from 'events'
11
- import Protocol from '@/lib/state-protocol.js'
12
- import ConnectionGuard from '@/lib/connection-guard.js'
13
-
14
- const CONFIG = {
15
- pingInterval: 30000,
16
- connectionTimeout: 60000,
17
- maxConnectionsPerIP: 10,
18
- messageRateLimit: 100
19
- }
20
-
21
- class StateTransportServer extends EventEmitter {
22
- constructor(server, config = {}) {
23
- super()
24
- this.config = { ...CONFIG, ...config }
25
- this.wss = null
26
- this.clients = new Map()
27
- this.server = server
28
- this.guard = new ConnectionGuard(this.config)
29
- this.setupServer()
30
- }
31
-
32
- setupServer() {
33
- try {
34
- this.wss = new WebSocketServer({ server: this.server, path: '/state-sync' })
35
- this.wss.on('connection', (ws, req) => this.handleConnection(ws, req))
36
- this.wss.on('error', (error) => this.handleError(error))
37
- this.guard.startPingInterval(this.clients, (id) => this.handleClose(id))
38
- this.emit('ready')
39
- } catch (error) {
40
- this.emit('error', error)
41
- setTimeout(() => this.setupServer(), 5000)
42
- }
43
- }
44
-
45
- handleConnection(ws, req) {
46
- const clientId = this.guard.generateClientId()
47
- const ip = req.socket.remoteAddress
48
-
49
- if (!this.guard.checkConnectionLimit(ip)) {
50
- ws.close(1008, 'Too many connections from this IP')
51
- return
52
- }
53
-
54
- const client = {
55
- id: clientId,
56
- ws,
57
- ip,
58
- alive: true,
59
- connectedAt: Date.now(),
60
- messageCount: 0
61
- }
62
-
63
- this.clients.set(clientId, client)
64
- this.guard.trackIPConnection(ip)
65
-
66
- ws.on('message', (data) => this.handleMessage(clientId, data))
67
- ws.on('close', () => this.handleClose(clientId))
68
- ws.on('error', (error) => this.handleClientError(clientId, error))
69
- ws.on('pong', () => { client.alive = true })
70
-
71
- this.emit('client_connected', clientId)
72
- }
73
-
74
- handleMessage(clientId, data) {
75
- try {
76
- const client = this.clients.get(clientId)
77
- if (!client) return
78
-
79
- if (!this.guard.checkRateLimit(clientId)) {
80
- this.sendMessage(clientId, Protocol.createStateNack(
81
- null,
82
- 'Rate limit exceeded',
83
- Protocol.ERROR_CODES.RATE_LIMITED
84
- ))
85
- return
86
- }
87
-
88
- const message = JSON.parse(data.toString())
89
- const validation = Protocol.MessageSchema.validate(message)
90
-
91
- if (!validation.valid) {
92
- this.sendMessage(clientId, Protocol.createStateNack(
93
- message.id,
94
- validation.error,
95
- Protocol.ERROR_CODES.INVALID_MESSAGE
96
- ))
97
- return
98
- }
99
-
100
- client.messageCount++
101
- this.emit('message', clientId, message)
102
-
103
- if (message.type === Protocol.MESSAGE_TYPES.PING) {
104
- this.sendMessage(clientId, Protocol.createPong(message.timestamp))
105
- }
106
- } catch (error) {
107
- this.emit('error', { clientId, error, phase: 'message_handling' })
108
- }
109
- }
110
-
111
- handleClose(clientId) {
112
- const client = this.clients.get(clientId)
113
- if (client) {
114
- this.guard.untrackIPConnection(client.ip)
115
- this.clients.delete(clientId)
116
- this.guard.clearRateLimit(clientId)
117
- this.emit('client_disconnected', clientId)
118
- }
119
- }
120
-
121
- handleClientError(clientId, error) {
122
- this.emit('error', { clientId, error, phase: 'client_error' })
123
- }
124
-
125
- handleError(error) {
126
- this.emit('error', { error, phase: 'server_error' })
127
- }
128
-
129
- sendMessage(clientId, message) {
130
- try {
131
- const client = this.clients.get(clientId)
132
- if (client && client.ws.readyState === 1) {
133
- client.ws.send(JSON.stringify(message))
134
- return true
135
- }
136
- return false
137
- } catch (error) {
138
- this.emit('error', { clientId, error, phase: 'send_message' })
139
- return false
140
- }
141
- }
142
-
143
- broadcast(message, excludeClientId = null) {
144
- const results = { sent: 0, failed: 0 }
145
- for (const [clientId] of this.clients) {
146
- if (clientId === excludeClientId) continue
147
- if (this.sendMessage(clientId, message)) {
148
- results.sent++
149
- } else {
150
- results.failed++
151
- }
152
- }
153
- return results
154
- }
155
-
156
- getClientCount() {
157
- return this.clients.size
158
- }
159
-
160
- getClient(clientId) {
161
- return this.clients.get(clientId)
162
- }
163
-
164
- close() {
165
- this.guard.destroy()
166
- for (const client of this.clients.values()) {
167
- client.ws.close()
168
- }
169
- this.clients.clear()
170
- if (this.wss) {
171
- this.wss.close()
172
- }
173
- }
174
- }
175
-
176
- if (!global.stateTransportServer) {
177
- global.stateTransportServer = null
178
- }
179
-
180
- export function createStateTransportServer(server, config) {
181
- if (global.stateTransportServer) {
182
- global.stateTransportServer.close()
183
- }
184
- global.stateTransportServer = new StateTransportServer(server, config)
185
- return global.stateTransportServer
186
- }
187
-
188
- export { StateTransportServer }
189
- export default StateTransportServer
@@ -1,97 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { fileURLToPath } from 'url';
4
-
5
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
- const ROOT = path.join(__dirname, '../..');
7
-
8
- export function serveStatic(pathname, req, res, compress, getCacheHeaders, _loadModule) {
9
- const acceptEncoding = req.headers['accept-encoding'] || '';
10
-
11
- if (pathname === '/favicon.ico') {
12
- res.setHeader('Content-Type', 'image/x-icon');
13
- res.setHeader('Cache-Control', 'public, max-age=86400');
14
- res.setHeader('Content-Length', '0');
15
- res.writeHead(200);
16
- res.end();
17
- return true;
18
- }
19
-
20
- if (pathname === '/manifest.json') {
21
- const manifest = JSON.stringify({ name: 'Thatcher', short_name: 'Thatcher', start_url: '/', display: 'standalone', background_color: '#f1f5f9', theme_color: '#04141f' });
22
- res.setHeader('Content-Type', 'application/json');
23
- res.setHeader('Cache-Control', 'public, max-age=86400');
24
- res.setHeader('Content-Length', Buffer.byteLength(manifest, 'utf-8'));
25
- res.writeHead(200);
26
- res.end(manifest);
27
- return true;
28
- }
29
-
30
- if (pathname === '/service-worker.js') {
31
- const swPath = path.join(ROOT, 'src/service-worker.js');
32
- if (fs.existsSync(swPath)) {
33
- const content = fs.readFileSync(swPath, 'utf-8');
34
- const cacheHeaders = getCacheHeaders('dynamic');
35
- Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
36
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
37
- res.setHeader('Content-Length', Buffer.byteLength(content, 'utf-8'));
38
- res.writeHead(200);
39
- res.end(content);
40
- return true;
41
- }
42
- }
43
-
44
- if (pathname.startsWith('/lib/webjsx/')) {
45
- const file = pathname.slice(12);
46
- const filePath = path.join(ROOT, 'node_modules/webjsx/dist', file);
47
- if (!fs.existsSync(filePath)) return false;
48
- const content = fs.readFileSync(filePath, 'utf-8');
49
- const cacheHeaders = getCacheHeaders('static', 31536000);
50
- Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
51
- const { content: finalContent, encoding } = compress(content, acceptEncoding);
52
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
53
- if (encoding) res.setHeader('Content-Encoding', encoding);
54
- res.setHeader('Content-Length', Buffer.byteLength(finalContent));
55
- res.writeHead(200);
56
- res.end(finalContent);
57
- return true;
58
- }
59
-
60
- if (pathname.startsWith('/ui/') && pathname.endsWith('.css')) {
61
- const cssPath = path.join(ROOT, 'src/ui', path.basename(pathname));
62
- if (!fs.existsSync(cssPath)) return false;
63
- let content = fs.readFileSync(cssPath, 'utf-8');
64
- const etag = `"${content.length}-${fs.statSync(cssPath).mtimeMs.toString(36)}"`;
65
- if (req.headers['if-none-match'] === etag) { res.writeHead(304); res.end(); return true; }
66
- res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
67
- res.setHeader('ETag', etag);
68
- const { content: finalContent, encoding } = compress(content, acceptEncoding);
69
- res.setHeader('Content-Type', 'text/css; charset=utf-8');
70
- if (encoding) res.setHeader('Content-Encoding', encoding);
71
- res.setHeader('Content-Length', Buffer.byteLength(finalContent));
72
- res.writeHead(200);
73
- res.end(finalContent);
74
- return true;
75
- }
76
-
77
- if (pathname === '/ui/client.js' || pathname === '/ui/event-delegation.js' || pathname === '/ui/common-handlers.js') {
78
- const jsPath = path.join(ROOT, 'src/ui', pathname.split('/').pop());
79
- if (!fs.existsSync(jsPath)) return false;
80
- const content = fs.readFileSync(jsPath, 'utf-8');
81
- const cacheHeaders = getCacheHeaders('static', 86400);
82
- Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
83
- const { content: finalContent, encoding } = compress(content, acceptEncoding);
84
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
85
- if (encoding) res.setHeader('Content-Encoding', encoding);
86
- res.setHeader('Content-Length', Buffer.byteLength(finalContent));
87
- res.writeHead(200);
88
- res.end(finalContent);
89
- return true;
90
- }
91
-
92
- return false;
93
- }
94
-
95
- export function html404() {
96
- return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><script>(function(){try{var t=localStorage.getItem('thatcher-theme')||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light');document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script><meta name="viewport" content="width=device-width,initial-scale=1"><title>404 - Page Not Found | Thatcher</title><link href="/ui/rippleui.css" rel="stylesheet"><link href="/ui/styles2.css" rel="stylesheet"><style>body{margin:0;background:var(--color-bg,#f1f5f9);font-family:system-ui,sans-serif}.nav-shell{background:#04141f;padding:0 2rem;height:56px;display:flex;align-items:center}a.logo-link{color:#fff;text-decoration:none;font-weight:700;font-size:1.1rem}.error-shell{min-height:calc(100vh - 56px);display:flex;align-items:center;justify-content:center}.error-card{background:#fff;border-radius:12px;padding:3rem 4rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.1)}.error-code{font-size:4rem;font-weight:900;color:#04141f;line-height:1}.error-msg{font-size:1.2rem;color:#64748b;margin:0.5rem 0 2rem}.home-btn{display:inline-block;padding:0.75rem 2rem;background:#04141f;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.95rem}</style></head><body><nav class="nav-shell"><a href="/" class="logo-link">Thatcher</a></nav><div class="error-shell"><div class="error-card"><div class="error-code">404</div><p class="error-msg">Page not found</p><a href="/" class="home-btn">Go to Dashboard</a></div></div></body></html>`;
97
- }