thatcher 1.0.4
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/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRUD Factory - Creates per-entity CRUD handler objects
|
|
3
|
+
* Adapted from moonlanding/src/lib/crud-factory.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createCrudHandlers as buildHandlers } from './crud-handlers.js';
|
|
7
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create CRUD handlers for an entity
|
|
11
|
+
* @param {string} entityName
|
|
12
|
+
* @returns {object} Handler functions (GET, POST, PUT, PATCH, DELETE)
|
|
13
|
+
*/
|
|
14
|
+
export function createCrudHandlers(entityName) {
|
|
15
|
+
const configEngine = getConfigEngineSync();
|
|
16
|
+
const spec = configEngine.generateEntitySpec(entityName);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
GET: async (request, context) => {
|
|
20
|
+
// Handle list or single get based on presence of id
|
|
21
|
+
const { params } = context;
|
|
22
|
+
const id = params?.id;
|
|
23
|
+
|
|
24
|
+
if (id) {
|
|
25
|
+
return buildHandlers(spec).get(id, request, context);
|
|
26
|
+
} else {
|
|
27
|
+
return buildHandlers(spec).list(request, context);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
POST: async (request, context) => {
|
|
32
|
+
return buildHandlers(spec).create(request, context);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
PUT: async (request, context) => {
|
|
36
|
+
const { params } = context;
|
|
37
|
+
const id = params?.id;
|
|
38
|
+
if (!id) throw new Error('ID required for PUT');
|
|
39
|
+
return buildHandlers(spec).update(id, request, context);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
PATCH: async (request, context) => {
|
|
43
|
+
const { params } = context;
|
|
44
|
+
const id = params?.id;
|
|
45
|
+
if (!id) throw new Error('ID required for PATCH');
|
|
46
|
+
return buildHandlers(spec).update(id, request, context);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
DELETE: async (request, context) => {
|
|
50
|
+
const { params } = context;
|
|
51
|
+
const id = params?.id;
|
|
52
|
+
if (!id) throw new Error('ID required for DELETE');
|
|
53
|
+
return buildHandlers(spec).remove(id, request, context);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create generic controller for an entity
|
|
60
|
+
* @param {string} entityName
|
|
61
|
+
* @returns {object}
|
|
62
|
+
*/
|
|
63
|
+
export function createEntityController(entityName) {
|
|
64
|
+
const configEngine = getConfigEngineSync();
|
|
65
|
+
const spec = configEngine.generateEntitySpec(entityName);
|
|
66
|
+
const handlers = buildHandlers(entityName, spec);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
list: handlers.list,
|
|
70
|
+
get: handlers.get,
|
|
71
|
+
create: handlers.create,
|
|
72
|
+
update: handlers.update,
|
|
73
|
+
delete: handlers.remove,
|
|
74
|
+
|
|
75
|
+
// Custom actions from config
|
|
76
|
+
async customAction(action, request, context) {
|
|
77
|
+
const { params } = context;
|
|
78
|
+
const id = params?.id;
|
|
79
|
+
const body = await request.json();
|
|
80
|
+
return handlers.customAction(action, id, body, context);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRUD Handlers - HTTP handler implementations for entity operations
|
|
3
|
+
* This is where request/response handling, validation, auth, and business logic meet
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { list, get, listWithPagination, searchWithPagination } from './query-engine.js';
|
|
7
|
+
import { create, update, remove } from './query-engine-write.js';
|
|
8
|
+
import { validateEntity, validateUpdate, sanitizeData } from './validate.js';
|
|
9
|
+
import { requirePermission, getSessionToken } from './auth-middleware.js';
|
|
10
|
+
import { executeHook } from './hook-engine.js';
|
|
11
|
+
import { AppError, NotFoundError, ValidationError } from './error-handler.js';
|
|
12
|
+
import { ok, created, paginated, noContent, error } from './response-formatter.js';
|
|
13
|
+
import { HTTP } from '../config/constants.js';
|
|
14
|
+
import { permissionService } from '../services/permission.service.js';
|
|
15
|
+
import { parse as parseQuery } from './query-string-adapter.js';
|
|
16
|
+
import { now } from './database-core.js';
|
|
17
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a complete set of CRUD handlers for an entity
|
|
21
|
+
* @param {object} spec - Entity specification
|
|
22
|
+
* @returns {object}
|
|
23
|
+
*/
|
|
24
|
+
export function createCrudHandlers(entityName, spec) {
|
|
25
|
+
if (!spec) {
|
|
26
|
+
spec = getConfigEngineSync().generateEntitySpec(entityName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
/**
|
|
31
|
+
* List entities (GET /api/:entity)
|
|
32
|
+
*/
|
|
33
|
+
list: async (req, context) => {
|
|
34
|
+
const { user } = context;
|
|
35
|
+
await requirePermission(user, spec, 'list');
|
|
36
|
+
|
|
37
|
+
const { q, page, pageSize, filters } = await parseQuery(req);
|
|
38
|
+
const config = getConfigEngineSync().getConfig();
|
|
39
|
+
const paginationCfg = config.system?.pagination || { default_page_size: 50, max_page_size: 500 };
|
|
40
|
+
|
|
41
|
+
const finalPage = page || 1;
|
|
42
|
+
if (!Number.isInteger(finalPage) || finalPage < 1) {
|
|
43
|
+
throw new AppError('page must be >= 1', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const requestedPageSize = pageSize || paginationCfg.default_page_size;
|
|
47
|
+
if (!Number.isInteger(requestedPageSize) || requestedPageSize < 1) {
|
|
48
|
+
throw new AppError('pageSize must be >= 1', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const finalPageSize = Math.min(requestedPageSize, paginationCfg.max_page_size || 500);
|
|
52
|
+
|
|
53
|
+
let items, pagination;
|
|
54
|
+
|
|
55
|
+
if (q) {
|
|
56
|
+
const result = await searchWithPagination(entityName, q, {}, finalPage, finalPageSize);
|
|
57
|
+
items = result.items;
|
|
58
|
+
pagination = result.pagination;
|
|
59
|
+
} else {
|
|
60
|
+
const coercedFilters = {};
|
|
61
|
+
if (filters) {
|
|
62
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
63
|
+
const fd = spec.fields?.[key];
|
|
64
|
+
coercedFilters[key] = fd ? coerceFieldValue(value, fd.type) : value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const result = await listWithPagination(entityName, coercedFilters, finalPage, finalPageSize);
|
|
68
|
+
items = result.items;
|
|
69
|
+
pagination = result.pagination;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const filtered = permissionService.filterRecords(user, spec, items);
|
|
73
|
+
const filteredItems = filtered.map(i => permissionService.filterFields(user, spec, i));
|
|
74
|
+
|
|
75
|
+
return paginated(filteredItems, pagination);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get single entity (GET /api/:entity/:id)
|
|
80
|
+
*/
|
|
81
|
+
get: async (id, req, context) => {
|
|
82
|
+
const { user } = context;
|
|
83
|
+
await requirePermission(user, spec, 'view');
|
|
84
|
+
|
|
85
|
+
if (!id) throw new AppError('ID required', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
86
|
+
|
|
87
|
+
const item = get(entityName, id);
|
|
88
|
+
if (!item) throw NotFoundError(entityName, id);
|
|
89
|
+
|
|
90
|
+
if (!permissionService.checkRowAccess(user, spec, item)) {
|
|
91
|
+
throw new AppError('Access denied', 'FORBIDDEN', HTTP.FORBIDDEN);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return ok(permissionService.filterFields(user, spec, item));
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create entity (POST /api/:entity)
|
|
99
|
+
*/
|
|
100
|
+
create: async (req, context) => {
|
|
101
|
+
const { user } = context;
|
|
102
|
+
await requirePermission(user, spec, 'create');
|
|
103
|
+
|
|
104
|
+
const rawData = await req.json();
|
|
105
|
+
permissionService.enforceEditPermissions(user, spec, rawData);
|
|
106
|
+
|
|
107
|
+
const errors = await validateEntity(entityName, rawData);
|
|
108
|
+
if (Object.keys(errors).length > 0) {
|
|
109
|
+
throw new ValidationError('Validation failed', errors);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sanitized = sanitizeData(entityName, rawData, spec);
|
|
113
|
+
const record = create(entityName, sanitized, user);
|
|
114
|
+
|
|
115
|
+
// Execute hooks
|
|
116
|
+
executeHook(`create:${entityName}:after`, {
|
|
117
|
+
entity: entityName,
|
|
118
|
+
id: record.id,
|
|
119
|
+
data: record,
|
|
120
|
+
user,
|
|
121
|
+
}).catch(console.error);
|
|
122
|
+
|
|
123
|
+
return created(permissionService.filterFields(user, spec, record));
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Update entity (PUT/PATCH /api/:entity/:id)
|
|
128
|
+
*/
|
|
129
|
+
update: async (id, req, context) => {
|
|
130
|
+
const { user } = context;
|
|
131
|
+
await requirePermission(user, spec, 'edit');
|
|
132
|
+
|
|
133
|
+
if (!id) throw new AppError('ID required', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
134
|
+
|
|
135
|
+
const existing = get(entityName, id);
|
|
136
|
+
if (!existing) throw NotFoundError(entityName, id);
|
|
137
|
+
|
|
138
|
+
if (!permissionService.checkRowAccess(user, spec, existing)) {
|
|
139
|
+
throw new AppError('Access denied', 'FORBIDDEN', HTTP.FORBIDDEN);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rawData = await req.json();
|
|
143
|
+
permissionService.enforceEditPermissions(user, spec, rawData);
|
|
144
|
+
|
|
145
|
+
const errors = await validateUpdate(entityName, rawData, existing);
|
|
146
|
+
if (Object.keys(errors).length > 0) {
|
|
147
|
+
throw new ValidationError('Validation failed', errors);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const sanitized = sanitizeData(entityName, rawData, spec, existing);
|
|
151
|
+
const record = update(entityName, id, sanitized, user);
|
|
152
|
+
|
|
153
|
+
executeHook(`update:${entityName}:after`, {
|
|
154
|
+
entity: entityName,
|
|
155
|
+
id,
|
|
156
|
+
data: record,
|
|
157
|
+
before: existing,
|
|
158
|
+
after: record,
|
|
159
|
+
user,
|
|
160
|
+
}).catch(console.error);
|
|
161
|
+
|
|
162
|
+
return ok(permissionService.filterFields(user, spec, record));
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Delete entity (DELETE /api/:entity/:id)
|
|
167
|
+
*/
|
|
168
|
+
remove: async (id, req, context) => {
|
|
169
|
+
const { user } = context;
|
|
170
|
+
await requirePermission(user, spec, 'delete');
|
|
171
|
+
|
|
172
|
+
if (!id) throw new AppError('ID required', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
173
|
+
|
|
174
|
+
const existing = get(entityName, id);
|
|
175
|
+
if (!existing) throw NotFoundError(entityName, id);
|
|
176
|
+
|
|
177
|
+
if (!permissionService.checkRowAccess(user, spec, existing)) {
|
|
178
|
+
throw new AppError('Access denied', 'FORBIDDEN', HTTP.FORBIDDEN);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = remove(entityName, id);
|
|
182
|
+
|
|
183
|
+
executeHook(`delete:${entityName}:after`, {
|
|
184
|
+
entity: entityName,
|
|
185
|
+
id,
|
|
186
|
+
data: result,
|
|
187
|
+
user,
|
|
188
|
+
}).catch(console.error);
|
|
189
|
+
|
|
190
|
+
return noContent();
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Custom action handler
|
|
195
|
+
*/
|
|
196
|
+
customAction: async (action, id, data, context) => {
|
|
197
|
+
const { user } = context;
|
|
198
|
+
await requirePermission(user, spec, 'edit'); // Simplified permission check
|
|
199
|
+
|
|
200
|
+
const record = get(entityName, id);
|
|
201
|
+
if (!record) throw NotFoundError(entityName, id);
|
|
202
|
+
|
|
203
|
+
// Custom action logic (upload, manage_flags, etc.)
|
|
204
|
+
if (action === 'upload_files') {
|
|
205
|
+
const files = Array.isArray(data.files) ? data.files : [data.files];
|
|
206
|
+
executeHook(`upload_files:${entityName}:after`, {
|
|
207
|
+
entity: entityName,
|
|
208
|
+
id,
|
|
209
|
+
data: { id, uploaded_files: files },
|
|
210
|
+
user,
|
|
211
|
+
});
|
|
212
|
+
return ok({ id, uploaded_files: files });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// More actions can be added here
|
|
216
|
+
throw new AppError(`Unknown action: ${action}`, 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Coerce field value to correct type
|
|
223
|
+
* @param {any} value
|
|
224
|
+
* @param {string} type
|
|
225
|
+
* @returns {any}
|
|
226
|
+
*/
|
|
227
|
+
function coerceFieldValue(value, type) {
|
|
228
|
+
if (value === null || value === undefined) return value;
|
|
229
|
+
|
|
230
|
+
switch (type) {
|
|
231
|
+
case 'int':
|
|
232
|
+
case 'decimal':
|
|
233
|
+
return Number(value);
|
|
234
|
+
case 'bool':
|
|
235
|
+
return Boolean(value);
|
|
236
|
+
case 'json':
|
|
237
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
238
|
+
case 'date':
|
|
239
|
+
case 'timestamp':
|
|
240
|
+
return Number(value);
|
|
241
|
+
default:
|
|
242
|
+
return String(value);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { HTTP } from '@/config/constants';
|
|
3
|
+
|
|
4
|
+
const tokens = new Map();
|
|
5
|
+
const TOKEN_TTL = 3600000;
|
|
6
|
+
|
|
7
|
+
function cleanExpiredTokens() {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
for (const [token, data] of tokens.entries()) {
|
|
10
|
+
if (now - data.createdAt > TOKEN_TTL) {
|
|
11
|
+
tokens.delete(token);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function generateToken() {
|
|
17
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
18
|
+
tokens.set(token, { createdAt: Date.now() });
|
|
19
|
+
if (tokens.size > 10000) cleanExpiredTokens();
|
|
20
|
+
return token;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateToken(token) {
|
|
24
|
+
if (!token || typeof token !== 'string') return false;
|
|
25
|
+
const data = tokens.get(token);
|
|
26
|
+
if (!data) return false;
|
|
27
|
+
if (Date.now() - data.createdAt > TOKEN_TTL) {
|
|
28
|
+
tokens.delete(token);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
tokens.delete(token);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function withCsrfValidation(handler) {
|
|
36
|
+
return async (request, context) => {
|
|
37
|
+
const method = request.method?.toUpperCase();
|
|
38
|
+
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
39
|
+
return handler(request, context);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const contentType = request.headers.get('content-type') || '';
|
|
44
|
+
const token = request.headers.get('x-csrf-token');
|
|
45
|
+
|
|
46
|
+
if (!token || !validateToken(token)) {
|
|
47
|
+
console.warn(`[CSRF] Invalid token for ${method}`);
|
|
48
|
+
return new Response(JSON.stringify({ error: 'Invalid CSRF token' }), {
|
|
49
|
+
status: HTTP.FORBIDDEN,
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('[CSRF] Validation error:', e.message);
|
|
55
|
+
return new Response(JSON.stringify({ error: 'CSRF validation failed' }), {
|
|
56
|
+
status: HTTP.BAD_REQUEST,
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return handler(request, context);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Core - SQLite initialization and schema management
|
|
3
|
+
* Adapted from moonlanding/src/lib/database-core.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
const SQL_TYPES = {
|
|
11
|
+
id: 'TEXT PRIMARY KEY',
|
|
12
|
+
text: 'TEXT',
|
|
13
|
+
textarea: 'TEXT',
|
|
14
|
+
email: 'TEXT',
|
|
15
|
+
int: 'INTEGER',
|
|
16
|
+
decimal: 'REAL',
|
|
17
|
+
bool: 'INTEGER',
|
|
18
|
+
date: 'INTEGER',
|
|
19
|
+
timestamp: 'INTEGER',
|
|
20
|
+
json: 'TEXT',
|
|
21
|
+
image: 'TEXT',
|
|
22
|
+
ref: 'TEXT',
|
|
23
|
+
enum: 'TEXT',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let db = null;
|
|
27
|
+
let migrationComplete = false;
|
|
28
|
+
const moduleCache = new Map();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get or create database instance
|
|
32
|
+
* @param {string} dbPath - Path to SQLite database file
|
|
33
|
+
* @returns {Database} better-sqlite3 database instance
|
|
34
|
+
*/
|
|
35
|
+
export function getDatabase(dbPath = null) {
|
|
36
|
+
if (db && !dbPath) return db;
|
|
37
|
+
|
|
38
|
+
const DB_PATH = dbPath || path.resolve(process.cwd(), 'data', 'app.db');
|
|
39
|
+
const dataDir = path.dirname(DB_PATH);
|
|
40
|
+
|
|
41
|
+
if (!fs.existsSync(dataDir)) {
|
|
42
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
db = new Database(DB_PATH);
|
|
46
|
+
db.pragma('journal_mode = WAL');
|
|
47
|
+
const BUSY_TIMEOUT_MS = process.env.DATABASE_BUSY_TIMEOUT_MS || '5000';
|
|
48
|
+
db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
49
|
+
db.pragma('synchronous = NORMAL');
|
|
50
|
+
db.pragma('foreign_keys = ON');
|
|
51
|
+
db.pragma('auto_vacuum = INCREMENTAL');
|
|
52
|
+
|
|
53
|
+
return db;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Generate unique ID using nanoid-like algorithm
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function genId() {
|
|
61
|
+
// Simple but effective ID generation
|
|
62
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get current Unix timestamp (seconds)
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
export function now() {
|
|
70
|
+
return Math.floor(Date.now() / 1000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build column definition from field spec
|
|
75
|
+
* @param {string} key - Field name
|
|
76
|
+
* @param {object} field - Field definition
|
|
77
|
+
* @returns {string} SQL column definition
|
|
78
|
+
*/
|
|
79
|
+
function buildColumnDef(key, field) {
|
|
80
|
+
let col = `"${key}" ${SQL_TYPES[field.type] || 'TEXT'}`;
|
|
81
|
+
|
|
82
|
+
if (field.required && field.type !== 'id') {
|
|
83
|
+
col += ' NOT NULL';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (field.unique) {
|
|
87
|
+
col += ' UNIQUE';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (field.default !== undefined) {
|
|
91
|
+
if (typeof field.default === 'string' || typeof field.default === 'number' || typeof field.default === 'boolean') {
|
|
92
|
+
const defaultVal = typeof field.default === 'string'
|
|
93
|
+
? `'${field.default.replace(/'/g, "''")}'`
|
|
94
|
+
: field.default;
|
|
95
|
+
col += ` DEFAULT ${defaultVal}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return col;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run migrations to create/update tables from entity specs
|
|
104
|
+
* @param {object} configEngine - Config engine with entity specs
|
|
105
|
+
*/
|
|
106
|
+
export function migrate(configEngine) {
|
|
107
|
+
if (migrationComplete) return;
|
|
108
|
+
|
|
109
|
+
const dbInstance = getDatabase();
|
|
110
|
+
let specsToUse = {};
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const allEntities = configEngine.getAllEntities();
|
|
114
|
+
for (const entityName of allEntities) {
|
|
115
|
+
specsToUse[entityName] = configEngine.generateEntitySpec(entityName);
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error('[Database] Failed to get specs from ConfigEngine during migration:', e.message);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Create tables
|
|
123
|
+
for (const spec of Object.values(specsToUse)) {
|
|
124
|
+
if (!spec) continue;
|
|
125
|
+
|
|
126
|
+
const tableName = spec.name === 'user' ? 'users' : spec.name;
|
|
127
|
+
const columns = [];
|
|
128
|
+
const foreignKeys = [];
|
|
129
|
+
|
|
130
|
+
// Build columns from fields
|
|
131
|
+
for (const [key, field] of Object.entries(spec.fields || {})) {
|
|
132
|
+
columns.push(buildColumnDef(key, field));
|
|
133
|
+
|
|
134
|
+
if (field.type === 'ref' && field.ref) {
|
|
135
|
+
const refTable = field.ref === 'user' ? 'users' : field.ref;
|
|
136
|
+
foreignKeys.push(`FOREIGN KEY ("${key}") REFERENCES "${refTable}"(id)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const fkPart = foreignKeys.length ? (',\n' + foreignKeys.join(',\n')) : '';
|
|
141
|
+
const sql = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.join(',\n')}${fkPart})`;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
dbInstance.exec(sql);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error(`[Database] Table creation failed for ${tableName}:`, e.message, '\nSQL:', sql);
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Add new columns (ALTER TABLE for schema evolution)
|
|
151
|
+
try {
|
|
152
|
+
const existingCols = new Set(
|
|
153
|
+
dbInstance.prepare(`PRAGMA table_info("${tableName}")`).all().map(c => c.name)
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
for (const [key, field] of Object.entries(spec.fields || {})) {
|
|
157
|
+
if (!existingCols.has(key)) {
|
|
158
|
+
let colType = SQL_TYPES[field.type] || 'TEXT';
|
|
159
|
+
let alterSql = `ALTER TABLE "${tableName}" ADD COLUMN "${key}" ${colType}`;
|
|
160
|
+
|
|
161
|
+
if (field.default !== undefined && (typeof field.default === 'string' || typeof field.default === 'number' || typeof field.default === 'boolean')) {
|
|
162
|
+
const defaultVal = typeof field.default === 'string'
|
|
163
|
+
? `'${field.default.replace(/'/g, "''")}'`
|
|
164
|
+
: field.default;
|
|
165
|
+
alterSql += ` DEFAULT ${defaultVal}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
dbInstance.exec(alterSql);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error(`[Database] Column migration failed for ${tableName}:`, e.message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Create indexes
|
|
177
|
+
for (const spec of Object.values(specsToUse)) {
|
|
178
|
+
if (!spec) continue;
|
|
179
|
+
|
|
180
|
+
const tableName = spec.name === 'user' ? 'users' : spec.name;
|
|
181
|
+
const searchFields = [];
|
|
182
|
+
|
|
183
|
+
for (const [key, field] of Object.entries(spec.fields || {})) {
|
|
184
|
+
if (field.type === 'ref' || field.sortable || field.search) {
|
|
185
|
+
try {
|
|
186
|
+
dbInstance.exec(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${key} ON "${tableName}"("${key}")`);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error(`[Database] Index creation failed for ${tableName}.${key}:`, e.message);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (field.search || key === 'name' || key === 'description') {
|
|
193
|
+
searchFields.push(`"${key}"`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Create FTS virtual table for search
|
|
198
|
+
if (searchFields.length > 0) {
|
|
199
|
+
try {
|
|
200
|
+
dbInstance.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${tableName}_fts USING fts5(${searchFields.join(', ')}, content="${tableName}", content_rowid=id)`);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
console.error(`[Database] FTS table creation failed for ${tableName}:`, e.message);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Run additional migrations
|
|
208
|
+
runCustomMigrations(dbInstance, specsToUse);
|
|
209
|
+
|
|
210
|
+
migrationComplete = true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Run custom migration logic (triggers, data migrations, etc.)
|
|
215
|
+
* @param {Database} dbInstance
|
|
216
|
+
* @param {object} specs
|
|
217
|
+
*/
|
|
218
|
+
function runCustomMigrations(dbInstance, specs) {
|
|
219
|
+
// Placeholder for custom migration logic
|
|
220
|
+
// Can be extended via plugins
|
|
221
|
+
try {
|
|
222
|
+
// Create triggers for updated_at timestamps
|
|
223
|
+
for (const entityName of Object.keys(specs)) {
|
|
224
|
+
const tableName = entityName === 'user' ? 'users' : entityName;
|
|
225
|
+
// Add timestamp triggers if needed
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error('[Database] Custom migrations failed:', e.message);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Ensure database is initialized (lazy migration)
|
|
234
|
+
*/
|
|
235
|
+
export function ensureInitialized(configEngine) {
|
|
236
|
+
if (!migrationComplete) {
|
|
237
|
+
migrate(configEngine);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Reset database (for testing)
|
|
243
|
+
*/
|
|
244
|
+
export function resetDatabase() {
|
|
245
|
+
const DB_PATH = path.resolve(process.cwd(), 'data', 'app.db');
|
|
246
|
+
try {
|
|
247
|
+
if (db) db.close();
|
|
248
|
+
if (fs.existsSync(DB_PATH)) fs.unlinkSync(DB_PATH);
|
|
249
|
+
if (fs.existsSync(DB_PATH + '-wal')) fs.unlinkSync(DB_PATH + '-wal');
|
|
250
|
+
if (fs.existsSync(DB_PATH + '-shm')) fs.unlinkSync(DB_PATH + '-shm');
|
|
251
|
+
db = null;
|
|
252
|
+
migrationComplete = false;
|
|
253
|
+
} catch (e) {
|
|
254
|
+
console.error('[Database] Reset failed:', e.message);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export { SQL_TYPES };
|