@edgebasejs/worker 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter-d1/src/d1-adapter.d.ts +29 -0
- package/dist/adapter-d1/src/d1-adapter.d.ts.map +1 -0
- package/dist/adapter-d1/src/d1-adapter.js +36 -0
- package/dist/adapter-d1/src/d1-adapter.js.map +1 -0
- package/dist/adapter-d1/src/index.d.ts +3 -0
- package/dist/adapter-d1/src/index.d.ts.map +1 -0
- package/dist/adapter-d1/src/index.js +3 -0
- package/dist/adapter-d1/src/index.js.map +1 -0
- package/dist/adapter-d1/src/schema-to-sql.d.ts +18 -0
- package/dist/adapter-d1/src/schema-to-sql.d.ts.map +1 -0
- package/dist/adapter-d1/src/schema-to-sql.js +304 -0
- package/dist/adapter-d1/src/schema-to-sql.js.map +1 -0
- package/dist/core/src/access-rules/column-security.d.ts +80 -0
- package/dist/core/src/access-rules/column-security.d.ts.map +1 -0
- package/dist/core/src/access-rules/column-security.js +191 -0
- package/dist/core/src/access-rules/column-security.js.map +1 -0
- package/dist/core/src/access-rules/engine.d.ts +26 -0
- package/dist/core/src/access-rules/engine.d.ts.map +1 -0
- package/dist/core/src/access-rules/engine.js +76 -0
- package/dist/core/src/access-rules/engine.js.map +1 -0
- package/dist/core/src/access-rules/index.d.ts +3 -0
- package/dist/core/src/access-rules/index.d.ts.map +1 -0
- package/dist/core/src/access-rules/index.js +3 -0
- package/dist/core/src/access-rules/index.js.map +1 -0
- package/dist/core/src/audit/audit-manager.d.ts +108 -0
- package/dist/core/src/audit/audit-manager.d.ts.map +1 -0
- package/dist/core/src/audit/audit-manager.js +265 -0
- package/dist/core/src/audit/audit-manager.js.map +1 -0
- package/dist/core/src/auth/auth-service.d.ts +71 -0
- package/dist/core/src/auth/auth-service.d.ts.map +1 -0
- package/dist/core/src/auth/auth-service.js +177 -0
- package/dist/core/src/auth/auth-service.js.map +1 -0
- package/dist/core/src/auth/index.d.ts +4 -0
- package/dist/core/src/auth/index.d.ts.map +1 -0
- package/dist/core/src/auth/index.js +4 -0
- package/dist/core/src/auth/index.js.map +1 -0
- package/dist/core/src/encryption/encryption-manager.d.ts +97 -0
- package/dist/core/src/encryption/encryption-manager.d.ts.map +1 -0
- package/dist/core/src/encryption/encryption-manager.js +224 -0
- package/dist/core/src/encryption/encryption-manager.js.map +1 -0
- package/dist/core/src/index.d.ts +16 -0
- package/dist/core/src/index.d.ts.map +1 -0
- package/dist/core/src/index.js +16 -0
- package/dist/core/src/index.js.map +1 -0
- package/dist/core/src/realtime/change-notifier.d.ts +50 -0
- package/dist/core/src/realtime/change-notifier.d.ts.map +1 -0
- package/dist/core/src/realtime/change-notifier.js +145 -0
- package/dist/core/src/realtime/change-notifier.js.map +1 -0
- package/dist/core/src/realtime/message-types.d.ts +39 -0
- package/dist/core/src/realtime/message-types.d.ts.map +1 -0
- package/dist/core/src/realtime/message-types.js +5 -0
- package/dist/core/src/realtime/message-types.js.map +1 -0
- package/dist/core/src/realtime/subscription-manager.d.ts +67 -0
- package/dist/core/src/realtime/subscription-manager.d.ts.map +1 -0
- package/dist/core/src/realtime/subscription-manager.js +229 -0
- package/dist/core/src/realtime/subscription-manager.js.map +1 -0
- package/dist/core/src/search/search-manager.d.ts +93 -0
- package/dist/core/src/search/search-manager.d.ts.map +1 -0
- package/dist/core/src/search/search-manager.js +258 -0
- package/dist/core/src/search/search-manager.js.map +1 -0
- package/dist/core/src/storage/file-manager.d.ts +138 -0
- package/dist/core/src/storage/file-manager.d.ts.map +1 -0
- package/dist/core/src/storage/file-manager.js +224 -0
- package/dist/core/src/storage/file-manager.js.map +1 -0
- package/dist/core/src/sync/batch-processor.d.ts +97 -0
- package/dist/core/src/sync/batch-processor.d.ts.map +1 -0
- package/dist/core/src/sync/batch-processor.js +313 -0
- package/dist/core/src/sync/batch-processor.js.map +1 -0
- package/dist/core/src/sync/csv-processor.d.ts +66 -0
- package/dist/core/src/sync/csv-processor.d.ts.map +1 -0
- package/dist/core/src/sync/csv-processor.js +223 -0
- package/dist/core/src/sync/csv-processor.js.map +1 -0
- package/dist/core/src/sync/index.d.ts +3 -0
- package/dist/core/src/sync/index.d.ts.map +1 -0
- package/dist/core/src/sync/index.js +3 -0
- package/dist/core/src/sync/index.js.map +1 -0
- package/dist/core/src/sync/sync-engine.d.ts +68 -0
- package/dist/core/src/sync/sync-engine.d.ts.map +1 -0
- package/dist/core/src/sync/sync-engine.js +317 -0
- package/dist/core/src/sync/sync-engine.js.map +1 -0
- package/dist/core/src/sync/transaction-manager.d.ts +83 -0
- package/dist/core/src/sync/transaction-manager.d.ts.map +1 -0
- package/dist/core/src/sync/transaction-manager.js +227 -0
- package/dist/core/src/sync/transaction-manager.js.map +1 -0
- package/dist/core/src/webhooks/webhook-manager.d.ts +137 -0
- package/dist/core/src/webhooks/webhook-manager.d.ts.map +1 -0
- package/dist/core/src/webhooks/webhook-manager.js +334 -0
- package/dist/core/src/webhooks/webhook-manager.js.map +1 -0
- package/dist/shared-types/src/admin.d.ts +101 -0
- package/dist/shared-types/src/admin.d.ts.map +1 -0
- package/dist/shared-types/src/admin.js +3 -0
- package/dist/shared-types/src/admin.js.map +1 -0
- package/dist/shared-types/src/auth.d.ts +27 -0
- package/dist/shared-types/src/auth.d.ts.map +1 -0
- package/dist/shared-types/src/auth.js +2 -0
- package/dist/shared-types/src/auth.js.map +1 -0
- package/dist/shared-types/src/index.d.ts +5 -0
- package/dist/shared-types/src/index.d.ts.map +1 -0
- package/dist/shared-types/src/index.js +5 -0
- package/dist/shared-types/src/index.js.map +1 -0
- package/dist/shared-types/src/schema.d.ts +34 -0
- package/dist/shared-types/src/schema.d.ts.map +1 -0
- package/dist/shared-types/src/schema.js +2 -0
- package/dist/shared-types/src/schema.js.map +1 -0
- package/dist/shared-types/src/sync.d.ts +37 -0
- package/dist/shared-types/src/sync.d.ts.map +1 -0
- package/dist/shared-types/src/sync.js +2 -0
- package/dist/shared-types/src/sync.js.map +1 -0
- package/dist/worker/src/index.d.ts +18 -0
- package/dist/worker/src/index.d.ts.map +1 -0
- package/dist/worker/src/index.js +2470 -0
- package/dist/worker/src/index.js.map +1 -0
- package/package.json +30 -0
|
@@ -0,0 +1,2470 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { cors } from 'hono/cors';
|
|
3
|
+
import { ServerSyncEngine, hashPassword, verifyPassword, createJWT, parseJWT, SubscriptionManager, ChangeNotifier, TransactionManager, BatchProcessor, CSVProcessor, FileStorageManager, WebhookManager, SearchManager, AuditManager, EncryptionManager, } from '@edgebasejs/core';
|
|
4
|
+
import { D1SyncDatabase, initializeDatabase } from '@edgebasejs/adapter-d1';
|
|
5
|
+
function jsonError(c, status, error, code) {
|
|
6
|
+
return c.json({ error, ...(code ? { code } : {}) }, status);
|
|
7
|
+
}
|
|
8
|
+
function getBearerToken(c) {
|
|
9
|
+
const auth = c.req.header('Authorization');
|
|
10
|
+
if (!auth) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return auth.replace(/^Bearer\s+/i, '').trim() || null;
|
|
14
|
+
}
|
|
15
|
+
export function createEdgeBaseWorker(options) {
|
|
16
|
+
const { schema, getDb, getJwtSecret, getEnvironment, cors: corsOptions, apiVersion = '0.1.0', autoInitialize = false, } = options;
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
app.use('*', cors(corsOptions));
|
|
19
|
+
let dbInitialized = false;
|
|
20
|
+
let subscriptionManager = null;
|
|
21
|
+
let changeNotifier = null;
|
|
22
|
+
let transactionManager = null;
|
|
23
|
+
let webhookManager = null;
|
|
24
|
+
let searchManager = null;
|
|
25
|
+
let auditManager = null;
|
|
26
|
+
let encryptionManager = null;
|
|
27
|
+
app.use('*', async (c, next) => {
|
|
28
|
+
const shouldAutoInit = typeof autoInitialize === 'function' ? autoInitialize(c) : autoInitialize;
|
|
29
|
+
if (shouldAutoInit && !dbInitialized) {
|
|
30
|
+
try {
|
|
31
|
+
await initializeDatabase(getDb(c), schema);
|
|
32
|
+
dbInitialized = true;
|
|
33
|
+
// Initialize managers
|
|
34
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
35
|
+
subscriptionManager = new SubscriptionManager(db);
|
|
36
|
+
changeNotifier = new ChangeNotifier(subscriptionManager);
|
|
37
|
+
transactionManager = new TransactionManager(db);
|
|
38
|
+
webhookManager = new WebhookManager(db);
|
|
39
|
+
searchManager = new SearchManager(db);
|
|
40
|
+
auditManager = new AuditManager(db);
|
|
41
|
+
encryptionManager = new EncryptionManager();
|
|
42
|
+
// Initialize encryption key if provided
|
|
43
|
+
const getEncryptionKey = options.getEncryptionKey;
|
|
44
|
+
if (getEncryptionKey) {
|
|
45
|
+
const encryptionKey = getEncryptionKey(c);
|
|
46
|
+
if (encryptionKey) {
|
|
47
|
+
await encryptionManager.initializeKey(encryptionKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Load existing subscriptions from database
|
|
51
|
+
await subscriptionManager.loadFromDatabase();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error('Database initialization error:', error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await next();
|
|
58
|
+
});
|
|
59
|
+
async function requireAccessUser(c) {
|
|
60
|
+
const token = getBearerToken(c);
|
|
61
|
+
if (!token) {
|
|
62
|
+
return jsonError(c, 401, 'Unauthorized', 'UNAUTHORIZED');
|
|
63
|
+
}
|
|
64
|
+
const payload = parseJWT(token);
|
|
65
|
+
if (!payload || payload.type !== 'access') {
|
|
66
|
+
return jsonError(c, 401, 'Invalid token', 'UNAUTHORIZED');
|
|
67
|
+
}
|
|
68
|
+
const db = getDb(c);
|
|
69
|
+
const userRow = await db
|
|
70
|
+
.prepare('SELECT id, email, created_at, updated_at FROM users WHERE id = ?')
|
|
71
|
+
.bind(payload.userId)
|
|
72
|
+
.first();
|
|
73
|
+
if (!userRow) {
|
|
74
|
+
return jsonError(c, 401, 'User not found', 'UNAUTHORIZED');
|
|
75
|
+
}
|
|
76
|
+
const user = {
|
|
77
|
+
id: userRow.id,
|
|
78
|
+
email: userRow.email,
|
|
79
|
+
createdAt: userRow.created_at,
|
|
80
|
+
updatedAt: userRow.updated_at,
|
|
81
|
+
};
|
|
82
|
+
return { user, payload };
|
|
83
|
+
}
|
|
84
|
+
async function requireAccessAdmin(c) {
|
|
85
|
+
const token = getBearerToken(c);
|
|
86
|
+
if (!token) {
|
|
87
|
+
return jsonError(c, 401, 'Unauthorized', 'UNAUTHORIZED');
|
|
88
|
+
}
|
|
89
|
+
const payload = parseJWT(token);
|
|
90
|
+
if (!payload || payload.type !== 'access') {
|
|
91
|
+
return jsonError(c, 401, 'Invalid token', 'UNAUTHORIZED');
|
|
92
|
+
}
|
|
93
|
+
const db = getDb(c);
|
|
94
|
+
const adminRow = await db
|
|
95
|
+
.prepare('SELECT id, email, role, is_active, created_at, updated_at FROM admins WHERE id = ?')
|
|
96
|
+
.bind(payload.userId)
|
|
97
|
+
.first();
|
|
98
|
+
if (!adminRow || !adminRow.is_active) {
|
|
99
|
+
return jsonError(c, 401, 'Admin not found', 'UNAUTHORIZED');
|
|
100
|
+
}
|
|
101
|
+
const admin = {
|
|
102
|
+
id: adminRow.id,
|
|
103
|
+
email: adminRow.email,
|
|
104
|
+
role: adminRow.role,
|
|
105
|
+
isActive: adminRow.is_active === 1,
|
|
106
|
+
createdAt: adminRow.created_at,
|
|
107
|
+
updatedAt: adminRow.updated_at,
|
|
108
|
+
};
|
|
109
|
+
return { admin, payload };
|
|
110
|
+
}
|
|
111
|
+
async function hasAnyAdmin(db) {
|
|
112
|
+
const row = await db.prepare('SELECT id FROM admins LIMIT 1').first();
|
|
113
|
+
return !!row;
|
|
114
|
+
}
|
|
115
|
+
async function hasUserId(db, userId) {
|
|
116
|
+
const row = await db.prepare('SELECT id FROM users WHERE id = ?').bind(userId).first();
|
|
117
|
+
return !!row;
|
|
118
|
+
}
|
|
119
|
+
async function listTables(db) {
|
|
120
|
+
const tablesResult = await db
|
|
121
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
122
|
+
.all();
|
|
123
|
+
return (tablesResult?.results || []).map((row) => row.name);
|
|
124
|
+
}
|
|
125
|
+
function requireValidTableName(tableName, allowed) {
|
|
126
|
+
if (!allowed.includes(tableName)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return tableName.replace(/"/g, '""');
|
|
130
|
+
}
|
|
131
|
+
function isEntityTable(tableName) {
|
|
132
|
+
return !!schema.entities[tableName];
|
|
133
|
+
}
|
|
134
|
+
function getEntityPrimaryKey(tableName) {
|
|
135
|
+
const entity = schema.entities[tableName];
|
|
136
|
+
if (!entity)
|
|
137
|
+
return null;
|
|
138
|
+
const primary = Object.entries(entity.fields).find(([, field]) => field.primary);
|
|
139
|
+
return primary ? primary[0] : 'id';
|
|
140
|
+
}
|
|
141
|
+
async function updateSyncMetadata(db, entity, recordId, deletedAt) {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const existing = await db
|
|
144
|
+
.prepare('SELECT version FROM sync_metadata WHERE entity = ? AND record_id = ?')
|
|
145
|
+
.bind(entity, recordId)
|
|
146
|
+
.first();
|
|
147
|
+
const currentVersion = existing?.version || 0;
|
|
148
|
+
const nextVersion = currentVersion + 1;
|
|
149
|
+
if (deletedAt) {
|
|
150
|
+
await db
|
|
151
|
+
.prepare('INSERT OR REPLACE INTO sync_metadata (entity, record_id, version, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?)')
|
|
152
|
+
.bind(entity, recordId, nextVersion, now, deletedAt)
|
|
153
|
+
.run();
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
await db
|
|
157
|
+
.prepare('INSERT OR REPLACE INTO sync_metadata (entity, record_id, version, updated_at) VALUES (?, ?, ?, ?)')
|
|
158
|
+
.bind(entity, recordId, nextVersion, now)
|
|
159
|
+
.run();
|
|
160
|
+
}
|
|
161
|
+
return nextVersion;
|
|
162
|
+
}
|
|
163
|
+
async function notifyAdminChange(entity, operation, record, recordId, version) {
|
|
164
|
+
if (!subscriptionManager || !changeNotifier) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const subscriptions = subscriptionManager.getSubscriptionsForEntity(entity);
|
|
168
|
+
if (subscriptions.length === 0) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const message = {
|
|
172
|
+
type: 'change',
|
|
173
|
+
entity,
|
|
174
|
+
operation,
|
|
175
|
+
record: operation !== 'delete' ? record : undefined,
|
|
176
|
+
recordId,
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
version,
|
|
179
|
+
};
|
|
180
|
+
changeNotifier.broadcastToAll(message, subscriptions.map((sub) => sub.subscriptionId));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* GET /health
|
|
184
|
+
*/
|
|
185
|
+
app.get('/health', (c) => {
|
|
186
|
+
return c.json({ status: 'ok', version: apiVersion });
|
|
187
|
+
});
|
|
188
|
+
/**
|
|
189
|
+
* GET /info
|
|
190
|
+
*/
|
|
191
|
+
app.get('/info', (c) => {
|
|
192
|
+
return c.json({
|
|
193
|
+
name: 'EdgeBase Worker',
|
|
194
|
+
version: apiVersion,
|
|
195
|
+
environment: getEnvironment ? getEnvironment(c) || 'unknown' : 'unknown',
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* POST /auth/register
|
|
200
|
+
*/
|
|
201
|
+
app.post('/auth/register', async (c) => {
|
|
202
|
+
try {
|
|
203
|
+
const { email, password } = await c.req.json();
|
|
204
|
+
if (!email || !password) {
|
|
205
|
+
return jsonError(c, 400, 'Email and password are required', 'VALIDATION_ERROR');
|
|
206
|
+
}
|
|
207
|
+
const db = getDb(c);
|
|
208
|
+
const jwtSecret = getJwtSecret(c);
|
|
209
|
+
// Check if user already exists
|
|
210
|
+
const existing = await db.prepare('SELECT id FROM users WHERE email = ?').bind(email).first();
|
|
211
|
+
if (existing) {
|
|
212
|
+
return jsonError(c, 409, 'User already exists', 'CONFLICT');
|
|
213
|
+
}
|
|
214
|
+
// Hash password
|
|
215
|
+
const passwordHash = await hashPassword(password);
|
|
216
|
+
// Create user
|
|
217
|
+
const userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
await db
|
|
220
|
+
.prepare('INSERT INTO users (id, email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
|
|
221
|
+
.bind(userId, email, passwordHash, now, now)
|
|
222
|
+
.run();
|
|
223
|
+
// Generate tokens
|
|
224
|
+
const accessToken = createJWT({ userId, email, type: 'access' }, jwtSecret, 3600);
|
|
225
|
+
const refreshToken = createJWT({ userId, email, type: 'refresh' }, jwtSecret, 604800);
|
|
226
|
+
// Store refresh token
|
|
227
|
+
const refreshTokenId = `rt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
228
|
+
await db
|
|
229
|
+
.prepare('INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
230
|
+
.bind(refreshTokenId, userId, refreshToken, now + 604800000, now)
|
|
231
|
+
.run();
|
|
232
|
+
const user = {
|
|
233
|
+
id: userId,
|
|
234
|
+
email,
|
|
235
|
+
createdAt: now,
|
|
236
|
+
updatedAt: now,
|
|
237
|
+
};
|
|
238
|
+
const response = {
|
|
239
|
+
user,
|
|
240
|
+
tokens: {
|
|
241
|
+
accessToken,
|
|
242
|
+
refreshToken,
|
|
243
|
+
expiresIn: 3600,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
return c.json(response, 201);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
console.error('Register error:', error);
|
|
250
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Registration failed', 'VALIDATION_ERROR');
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
/**
|
|
254
|
+
* POST /auth/login
|
|
255
|
+
*/
|
|
256
|
+
app.post('/auth/login', async (c) => {
|
|
257
|
+
try {
|
|
258
|
+
const { email, password } = await c.req.json();
|
|
259
|
+
if (!email || !password) {
|
|
260
|
+
return jsonError(c, 400, 'Email and password are required', 'VALIDATION_ERROR');
|
|
261
|
+
}
|
|
262
|
+
const db = getDb(c);
|
|
263
|
+
const jwtSecret = getJwtSecret(c);
|
|
264
|
+
// Get user from DB
|
|
265
|
+
const userRow = await db
|
|
266
|
+
.prepare('SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = ?')
|
|
267
|
+
.bind(email)
|
|
268
|
+
.first();
|
|
269
|
+
if (!userRow) {
|
|
270
|
+
return jsonError(c, 401, 'Invalid credentials', 'UNAUTHORIZED');
|
|
271
|
+
}
|
|
272
|
+
// Verify password
|
|
273
|
+
const passwordValid = await verifyPassword(password, userRow.password_hash);
|
|
274
|
+
if (!passwordValid) {
|
|
275
|
+
return jsonError(c, 401, 'Invalid credentials', 'UNAUTHORIZED');
|
|
276
|
+
}
|
|
277
|
+
// Generate tokens
|
|
278
|
+
const accessToken = createJWT({ userId: userRow.id, email: userRow.email, type: 'access' }, jwtSecret, 3600);
|
|
279
|
+
const refreshToken = createJWT({ userId: userRow.id, email: userRow.email, type: 'refresh' }, jwtSecret, 604800);
|
|
280
|
+
// Store refresh token
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const refreshTokenId = `rt_${now}_${Math.random().toString(36).substr(2, 9)}`;
|
|
283
|
+
await db
|
|
284
|
+
.prepare('INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
285
|
+
.bind(refreshTokenId, userRow.id, refreshToken, now + 604800000, now)
|
|
286
|
+
.run();
|
|
287
|
+
const user = {
|
|
288
|
+
id: userRow.id,
|
|
289
|
+
email: userRow.email,
|
|
290
|
+
createdAt: userRow.created_at,
|
|
291
|
+
updatedAt: userRow.updated_at,
|
|
292
|
+
};
|
|
293
|
+
const response = {
|
|
294
|
+
user,
|
|
295
|
+
tokens: {
|
|
296
|
+
accessToken,
|
|
297
|
+
refreshToken,
|
|
298
|
+
expiresIn: 3600,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
return c.json(response);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
console.error('Login error:', error);
|
|
305
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Login failed', 'API_ERROR');
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
/**
|
|
309
|
+
* POST /admin/auth/login
|
|
310
|
+
*/
|
|
311
|
+
app.post('/admin/auth/login', async (c) => {
|
|
312
|
+
try {
|
|
313
|
+
const body = await c.req.json();
|
|
314
|
+
const { email, password } = body || {};
|
|
315
|
+
if (!email || !password) {
|
|
316
|
+
return jsonError(c, 400, 'Email and password are required', 'VALIDATION_ERROR');
|
|
317
|
+
}
|
|
318
|
+
const db = getDb(c);
|
|
319
|
+
const jwtSecret = getJwtSecret(c);
|
|
320
|
+
const adminRow = await db
|
|
321
|
+
.prepare('SELECT id, email, password_hash, role, is_active, created_at, updated_at FROM admins WHERE email = ?')
|
|
322
|
+
.bind(email)
|
|
323
|
+
.first();
|
|
324
|
+
if (!adminRow || !adminRow.is_active) {
|
|
325
|
+
return jsonError(c, 401, 'Invalid credentials', 'UNAUTHORIZED');
|
|
326
|
+
}
|
|
327
|
+
const passwordValid = await verifyPassword(password, adminRow.password_hash);
|
|
328
|
+
if (!passwordValid) {
|
|
329
|
+
return jsonError(c, 401, 'Invalid credentials', 'UNAUTHORIZED');
|
|
330
|
+
}
|
|
331
|
+
const accessToken = createJWT({ userId: adminRow.id, email: adminRow.email, type: 'access' }, jwtSecret, 3600);
|
|
332
|
+
const refreshToken = createJWT({ userId: adminRow.id, email: adminRow.email, type: 'refresh' }, jwtSecret, 604800);
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
const refreshTokenId = `art_${now}_${Math.random().toString(36).substr(2, 9)}`;
|
|
335
|
+
await db
|
|
336
|
+
.prepare('INSERT INTO admin_refresh_tokens (id, admin_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
337
|
+
.bind(refreshTokenId, adminRow.id, refreshToken, now + 604800000, now)
|
|
338
|
+
.run();
|
|
339
|
+
const admin = {
|
|
340
|
+
id: adminRow.id,
|
|
341
|
+
email: adminRow.email,
|
|
342
|
+
role: adminRow.role,
|
|
343
|
+
isActive: adminRow.is_active === 1,
|
|
344
|
+
createdAt: adminRow.created_at,
|
|
345
|
+
updatedAt: adminRow.updated_at,
|
|
346
|
+
};
|
|
347
|
+
const response = {
|
|
348
|
+
admin,
|
|
349
|
+
accessToken,
|
|
350
|
+
refreshToken,
|
|
351
|
+
expiresIn: 3600,
|
|
352
|
+
};
|
|
353
|
+
return c.json(response);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
console.error('Admin login error:', error);
|
|
357
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Login failed', 'API_ERROR');
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
/**
|
|
361
|
+
* POST /admin/auth/refresh
|
|
362
|
+
*/
|
|
363
|
+
app.post('/admin/auth/refresh', async (c) => {
|
|
364
|
+
try {
|
|
365
|
+
const body = await c.req.json();
|
|
366
|
+
const refreshToken = body?.refreshToken;
|
|
367
|
+
if (!refreshToken) {
|
|
368
|
+
return jsonError(c, 400, 'Refresh token is required', 'VALIDATION_ERROR');
|
|
369
|
+
}
|
|
370
|
+
const payload = parseJWT(refreshToken);
|
|
371
|
+
if (!payload || payload.type !== 'refresh') {
|
|
372
|
+
return jsonError(c, 401, 'Invalid refresh token', 'UNAUTHORIZED');
|
|
373
|
+
}
|
|
374
|
+
const db = getDb(c);
|
|
375
|
+
const jwtSecret = getJwtSecret(c);
|
|
376
|
+
const tokenRow = await db
|
|
377
|
+
.prepare(`SELECT r.admin_id, r.expires_at, a.email
|
|
378
|
+
FROM admin_refresh_tokens r
|
|
379
|
+
JOIN admins a ON a.id = r.admin_id
|
|
380
|
+
WHERE r.token = ?`)
|
|
381
|
+
.bind(refreshToken)
|
|
382
|
+
.first();
|
|
383
|
+
if (!tokenRow) {
|
|
384
|
+
return jsonError(c, 401, 'Invalid refresh token', 'UNAUTHORIZED');
|
|
385
|
+
}
|
|
386
|
+
if (tokenRow.expires_at < Date.now()) {
|
|
387
|
+
await db
|
|
388
|
+
.prepare('DELETE FROM admin_refresh_tokens WHERE token = ?')
|
|
389
|
+
.bind(refreshToken)
|
|
390
|
+
.run();
|
|
391
|
+
return jsonError(c, 401, 'Refresh token expired', 'UNAUTHORIZED');
|
|
392
|
+
}
|
|
393
|
+
const accessToken = createJWT({ userId: tokenRow.admin_id, email: tokenRow.email, type: 'access' }, jwtSecret, 3600);
|
|
394
|
+
return c.json({ accessToken, expiresIn: 3600 });
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
console.error('Admin refresh token error:', error);
|
|
398
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Token refresh failed', 'API_ERROR');
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
/**
|
|
402
|
+
* POST /admin/auth/logout
|
|
403
|
+
*/
|
|
404
|
+
app.post('/admin/auth/logout', async (c) => {
|
|
405
|
+
const auth = await requireAccessAdmin(c);
|
|
406
|
+
if (auth instanceof Response) {
|
|
407
|
+
return auth;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const db = getDb(c);
|
|
411
|
+
await db
|
|
412
|
+
.prepare('DELETE FROM admin_refresh_tokens WHERE admin_id = ?')
|
|
413
|
+
.bind(auth.admin.id)
|
|
414
|
+
.run();
|
|
415
|
+
return c.json({ message: 'Logged out successfully', adminId: auth.admin.id });
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
console.error('Admin logout error:', error);
|
|
419
|
+
return jsonError(c, 500, 'Logout failed', 'API_ERROR');
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
/**
|
|
423
|
+
* GET /admin/admins
|
|
424
|
+
*/
|
|
425
|
+
app.get('/admin/admins', async (c) => {
|
|
426
|
+
try {
|
|
427
|
+
const db = getDb(c);
|
|
428
|
+
const anyAdmin = await hasAnyAdmin(db);
|
|
429
|
+
if (!anyAdmin) {
|
|
430
|
+
return c.json([]);
|
|
431
|
+
}
|
|
432
|
+
const auth = await requireAccessAdmin(c);
|
|
433
|
+
if (auth instanceof Response) {
|
|
434
|
+
return auth;
|
|
435
|
+
}
|
|
436
|
+
const rows = await db
|
|
437
|
+
.prepare('SELECT id, email, role, is_active, created_at, updated_at FROM admins')
|
|
438
|
+
.all();
|
|
439
|
+
const admins = (rows?.results || []).map((row) => ({
|
|
440
|
+
id: row.id,
|
|
441
|
+
email: row.email,
|
|
442
|
+
role: row.role,
|
|
443
|
+
isActive: row.is_active === 1,
|
|
444
|
+
createdAt: row.created_at,
|
|
445
|
+
updatedAt: row.updated_at,
|
|
446
|
+
}));
|
|
447
|
+
return c.json(admins);
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
console.error('Admin list error:', error);
|
|
451
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to fetch admins', 'API_ERROR');
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
/**
|
|
455
|
+
* POST /admin/admins
|
|
456
|
+
*/
|
|
457
|
+
app.post('/admin/admins', async (c) => {
|
|
458
|
+
try {
|
|
459
|
+
const db = getDb(c);
|
|
460
|
+
const anyAdmin = await hasAnyAdmin(db);
|
|
461
|
+
if (anyAdmin) {
|
|
462
|
+
const auth = await requireAccessAdmin(c);
|
|
463
|
+
if (auth instanceof Response) {
|
|
464
|
+
return auth;
|
|
465
|
+
}
|
|
466
|
+
if (auth.admin.role !== 'super_admin') {
|
|
467
|
+
return jsonError(c, 403, 'Insufficient permissions', 'FORBIDDEN');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const body = await c.req.json();
|
|
471
|
+
const { email, password, role } = body || {};
|
|
472
|
+
if (!email || !password || !role) {
|
|
473
|
+
return jsonError(c, 400, 'Email, password, and role are required', 'VALIDATION_ERROR');
|
|
474
|
+
}
|
|
475
|
+
const existing = await db.prepare('SELECT id FROM admins WHERE email = ?').bind(email).first();
|
|
476
|
+
if (existing) {
|
|
477
|
+
return jsonError(c, 409, 'Admin already exists', 'CONFLICT');
|
|
478
|
+
}
|
|
479
|
+
const passwordHash = await hashPassword(password);
|
|
480
|
+
const adminId = `admin_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
await db
|
|
483
|
+
.prepare('INSERT INTO admins (id, email, password_hash, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
484
|
+
.bind(adminId, email, passwordHash, role, 1, now, now)
|
|
485
|
+
.run();
|
|
486
|
+
const admin = {
|
|
487
|
+
id: adminId,
|
|
488
|
+
email,
|
|
489
|
+
role,
|
|
490
|
+
isActive: true,
|
|
491
|
+
createdAt: now,
|
|
492
|
+
updatedAt: now,
|
|
493
|
+
};
|
|
494
|
+
return c.json(admin, 201);
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
console.error('Admin create error:', error);
|
|
498
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Admin creation failed', 'API_ERROR');
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
/**
|
|
502
|
+
* GET /admin/users
|
|
503
|
+
*/
|
|
504
|
+
app.get('/admin/users', async (c) => {
|
|
505
|
+
const auth = await requireAccessAdmin(c);
|
|
506
|
+
if (auth instanceof Response) {
|
|
507
|
+
return auth;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
const db = getDb(c);
|
|
511
|
+
const page = Math.max(parseInt(c.req.query('page') || '1', 10), 1);
|
|
512
|
+
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '20', 10), 1), 100);
|
|
513
|
+
const search = c.req.query('search');
|
|
514
|
+
const offset = (page - 1) * pageSize;
|
|
515
|
+
const params = [];
|
|
516
|
+
let whereClause = '';
|
|
517
|
+
if (search) {
|
|
518
|
+
whereClause = 'WHERE email LIKE ?';
|
|
519
|
+
params.push(`%${search}%`);
|
|
520
|
+
}
|
|
521
|
+
const countRow = await db
|
|
522
|
+
.prepare(`SELECT COUNT(*) as total FROM users ${whereClause}`)
|
|
523
|
+
.bind(...params)
|
|
524
|
+
.first();
|
|
525
|
+
const total = countRow?.total || 0;
|
|
526
|
+
const usersResult = await db
|
|
527
|
+
.prepare(`SELECT id, email, created_at, updated_at FROM users ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
528
|
+
.bind(...params, pageSize, offset)
|
|
529
|
+
.all();
|
|
530
|
+
const data = (usersResult?.results || []).map((row) => ({
|
|
531
|
+
id: row.id,
|
|
532
|
+
email: row.email,
|
|
533
|
+
createdAt: row.created_at,
|
|
534
|
+
updatedAt: row.updated_at,
|
|
535
|
+
}));
|
|
536
|
+
return c.json({
|
|
537
|
+
data,
|
|
538
|
+
pagination: {
|
|
539
|
+
page,
|
|
540
|
+
pageSize,
|
|
541
|
+
total,
|
|
542
|
+
totalPages: Math.ceil(total / pageSize) || 1,
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
console.error('Admin users list error:', error);
|
|
548
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to fetch users', 'API_ERROR');
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
/**
|
|
552
|
+
* GET /admin/users/:id
|
|
553
|
+
*/
|
|
554
|
+
app.get('/admin/users/:id', async (c) => {
|
|
555
|
+
const auth = await requireAccessAdmin(c);
|
|
556
|
+
if (auth instanceof Response) {
|
|
557
|
+
return auth;
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
const db = getDb(c);
|
|
561
|
+
const userId = c.req.param('id');
|
|
562
|
+
const row = await db
|
|
563
|
+
.prepare('SELECT id, email, created_at, updated_at FROM users WHERE id = ?')
|
|
564
|
+
.bind(userId)
|
|
565
|
+
.first();
|
|
566
|
+
if (!row) {
|
|
567
|
+
return jsonError(c, 404, 'User not found', 'NOT_FOUND');
|
|
568
|
+
}
|
|
569
|
+
return c.json({
|
|
570
|
+
id: row.id,
|
|
571
|
+
email: row.email,
|
|
572
|
+
createdAt: row.created_at,
|
|
573
|
+
updatedAt: row.updated_at,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
console.error('Admin get user error:', error);
|
|
578
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to fetch user', 'API_ERROR');
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
/**
|
|
582
|
+
* PATCH /admin/users/:id
|
|
583
|
+
*/
|
|
584
|
+
app.patch('/admin/users/:id', async (c) => {
|
|
585
|
+
const auth = await requireAccessAdmin(c);
|
|
586
|
+
if (auth instanceof Response) {
|
|
587
|
+
return auth;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const db = getDb(c);
|
|
591
|
+
const userId = c.req.param('id');
|
|
592
|
+
const updates = await c.req.json();
|
|
593
|
+
const fields = [];
|
|
594
|
+
const values = [];
|
|
595
|
+
if (typeof updates.email === 'string' && updates.email.trim()) {
|
|
596
|
+
fields.push('email = ?');
|
|
597
|
+
values.push(updates.email.trim());
|
|
598
|
+
}
|
|
599
|
+
if (fields.length === 0) {
|
|
600
|
+
return jsonError(c, 400, 'No valid fields to update', 'VALIDATION_ERROR');
|
|
601
|
+
}
|
|
602
|
+
fields.push('updated_at = ?');
|
|
603
|
+
values.push(Date.now());
|
|
604
|
+
await db
|
|
605
|
+
.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`)
|
|
606
|
+
.bind(...values, userId)
|
|
607
|
+
.run();
|
|
608
|
+
const row = await db
|
|
609
|
+
.prepare('SELECT id, email, created_at, updated_at FROM users WHERE id = ?')
|
|
610
|
+
.bind(userId)
|
|
611
|
+
.first();
|
|
612
|
+
if (!row) {
|
|
613
|
+
return jsonError(c, 404, 'User not found', 'NOT_FOUND');
|
|
614
|
+
}
|
|
615
|
+
return c.json({
|
|
616
|
+
id: row.id,
|
|
617
|
+
email: row.email,
|
|
618
|
+
createdAt: row.created_at,
|
|
619
|
+
updatedAt: row.updated_at,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
console.error('Admin update user error:', error);
|
|
624
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to update user', 'API_ERROR');
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
/**
|
|
628
|
+
* DELETE /admin/users/:id
|
|
629
|
+
*/
|
|
630
|
+
app.delete('/admin/users/:id', async (c) => {
|
|
631
|
+
const auth = await requireAccessAdmin(c);
|
|
632
|
+
if (auth instanceof Response) {
|
|
633
|
+
return auth;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
const db = getDb(c);
|
|
637
|
+
const userId = c.req.param('id');
|
|
638
|
+
const existing = await db.prepare('SELECT id FROM users WHERE id = ?').bind(userId).first();
|
|
639
|
+
if (!existing) {
|
|
640
|
+
return jsonError(c, 404, 'User not found', 'NOT_FOUND');
|
|
641
|
+
}
|
|
642
|
+
await db.prepare('DELETE FROM users WHERE id = ?').bind(userId).run();
|
|
643
|
+
return c.json({ success: true, message: 'User deleted' });
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
console.error('Admin delete user error:', error);
|
|
647
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to delete user', 'API_ERROR');
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
/**
|
|
651
|
+
* GET /admin/database/tables
|
|
652
|
+
*/
|
|
653
|
+
app.get('/admin/database/tables', async (c) => {
|
|
654
|
+
const auth = await requireAccessAdmin(c);
|
|
655
|
+
if (auth instanceof Response) {
|
|
656
|
+
return auth;
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const db = getDb(c);
|
|
660
|
+
const tableNames = await listTables(db);
|
|
661
|
+
const tables = [];
|
|
662
|
+
for (const name of tableNames) {
|
|
663
|
+
const safeName = requireValidTableName(name, tableNames);
|
|
664
|
+
if (!safeName)
|
|
665
|
+
continue;
|
|
666
|
+
const countRow = await db
|
|
667
|
+
.prepare(`SELECT COUNT(*) as count FROM "${safeName}"`)
|
|
668
|
+
.first();
|
|
669
|
+
tables.push({ name, rowCount: countRow?.count || 0 });
|
|
670
|
+
}
|
|
671
|
+
return c.json(tables);
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
console.error('Admin tables error:', error);
|
|
675
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to fetch tables', 'API_ERROR');
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
/**
|
|
679
|
+
* GET /admin/database/tables/:name/schema
|
|
680
|
+
*/
|
|
681
|
+
app.get('/admin/database/tables/:name/schema', async (c) => {
|
|
682
|
+
const auth = await requireAccessAdmin(c);
|
|
683
|
+
if (auth instanceof Response) {
|
|
684
|
+
return auth;
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
const db = getDb(c);
|
|
688
|
+
const tableName = c.req.param('name');
|
|
689
|
+
const tableNames = await listTables(db);
|
|
690
|
+
const safeName = requireValidTableName(tableName, tableNames);
|
|
691
|
+
if (!safeName) {
|
|
692
|
+
return jsonError(c, 404, 'Table not found', 'NOT_FOUND');
|
|
693
|
+
}
|
|
694
|
+
const columnsResult = await db.prepare(`PRAGMA table_info("${safeName}")`).all();
|
|
695
|
+
const columns = (columnsResult?.results || []).map((row) => ({
|
|
696
|
+
name: row.name,
|
|
697
|
+
type: row.type,
|
|
698
|
+
nullable: row.notnull === 0,
|
|
699
|
+
primaryKey: row.pk === 1,
|
|
700
|
+
defaultValue: row.dflt_value,
|
|
701
|
+
}));
|
|
702
|
+
const primaryKey = columns.filter((col) => col.primaryKey).map((col) => col.name);
|
|
703
|
+
return c.json({
|
|
704
|
+
name: tableName,
|
|
705
|
+
columns,
|
|
706
|
+
primaryKey,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
console.error('Admin table schema error:', error);
|
|
711
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to fetch schema', 'API_ERROR');
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
/**
|
|
715
|
+
* GET /admin/database/tables/:name/data
|
|
716
|
+
*/
|
|
717
|
+
app.get('/admin/database/tables/:name/data', async (c) => {
|
|
718
|
+
const auth = await requireAccessAdmin(c);
|
|
719
|
+
if (auth instanceof Response) {
|
|
720
|
+
return auth;
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
const db = getDb(c);
|
|
724
|
+
const tableName = c.req.param('name');
|
|
725
|
+
const tableNames = await listTables(db);
|
|
726
|
+
const safeName = requireValidTableName(tableName, tableNames);
|
|
727
|
+
if (!safeName) {
|
|
728
|
+
return jsonError(c, 404, 'Table not found', 'NOT_FOUND');
|
|
729
|
+
}
|
|
730
|
+
const page = Math.max(parseInt(c.req.query('page') || '1', 10), 1);
|
|
731
|
+
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '20', 10), 1), 200);
|
|
732
|
+
const offset = (page - 1) * pageSize;
|
|
733
|
+
const columnsResult = await db.prepare(`PRAGMA table_info("${safeName}")`).all();
|
|
734
|
+
const columns = (columnsResult?.results || []).map((row) => ({
|
|
735
|
+
name: row.name,
|
|
736
|
+
type: row.type,
|
|
737
|
+
nullable: row.notnull === 0,
|
|
738
|
+
primaryKey: row.pk === 1,
|
|
739
|
+
defaultValue: row.dflt_value,
|
|
740
|
+
}));
|
|
741
|
+
const countRow = await db
|
|
742
|
+
.prepare(`SELECT COUNT(*) as count FROM "${safeName}"`)
|
|
743
|
+
.first();
|
|
744
|
+
const total = countRow?.count || 0;
|
|
745
|
+
const dataResult = await db
|
|
746
|
+
.prepare(`SELECT * FROM "${safeName}" LIMIT ? OFFSET ?`)
|
|
747
|
+
.bind(pageSize, offset)
|
|
748
|
+
.all();
|
|
749
|
+
return c.json({
|
|
750
|
+
tableName,
|
|
751
|
+
data: dataResult?.results || [],
|
|
752
|
+
columns,
|
|
753
|
+
pagination: {
|
|
754
|
+
page,
|
|
755
|
+
pageSize,
|
|
756
|
+
total,
|
|
757
|
+
totalPages: Math.ceil(total / pageSize) || 1,
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
console.error('Admin table data error:', error);
|
|
763
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to fetch table data', 'API_ERROR');
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
/**
|
|
767
|
+
* POST /admin/database/tables/:name/rows
|
|
768
|
+
*/
|
|
769
|
+
app.post('/admin/database/tables/:name/rows', async (c) => {
|
|
770
|
+
const auth = await requireAccessAdmin(c);
|
|
771
|
+
if (auth instanceof Response) {
|
|
772
|
+
return auth;
|
|
773
|
+
}
|
|
774
|
+
const tableName = c.req.param('name');
|
|
775
|
+
if (!isEntityTable(tableName)) {
|
|
776
|
+
return jsonError(c, 403, 'Table is not editable via admin API', 'FORBIDDEN');
|
|
777
|
+
}
|
|
778
|
+
const primaryKey = getEntityPrimaryKey(tableName);
|
|
779
|
+
if (!primaryKey || primaryKey !== 'id') {
|
|
780
|
+
return jsonError(c, 400, 'Unsupported primary key for sync', 'VALIDATION_ERROR');
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
const db = getDb(c);
|
|
784
|
+
const tableNames = await listTables(db);
|
|
785
|
+
const safeName = requireValidTableName(tableName, tableNames);
|
|
786
|
+
if (!safeName) {
|
|
787
|
+
return jsonError(c, 404, 'Table not found', 'NOT_FOUND');
|
|
788
|
+
}
|
|
789
|
+
const data = (await c.req.json());
|
|
790
|
+
const columnsResult = await db.prepare(`PRAGMA table_info("${safeName}")`).all();
|
|
791
|
+
const columns = (columnsResult?.results || []).map((row) => row.name);
|
|
792
|
+
if (!data.id) {
|
|
793
|
+
data.id = `${tableName}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
794
|
+
}
|
|
795
|
+
if (columns.includes('createdAt') && data.createdAt == null)
|
|
796
|
+
data.createdAt = Date.now();
|
|
797
|
+
if (columns.includes('updatedAt') && data.updatedAt == null)
|
|
798
|
+
data.updatedAt = Date.now();
|
|
799
|
+
if (columns.includes('created_at') && data.created_at == null)
|
|
800
|
+
data.created_at = Date.now();
|
|
801
|
+
if (columns.includes('updated_at') && data.updated_at == null)
|
|
802
|
+
data.updated_at = Date.now();
|
|
803
|
+
const insertColumns = Object.keys(data).filter((key) => columns.includes(key));
|
|
804
|
+
if (!insertColumns.includes('id')) {
|
|
805
|
+
insertColumns.unshift('id');
|
|
806
|
+
data.id = data.id || `${tableName}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
807
|
+
}
|
|
808
|
+
const placeholders = insertColumns.map(() => '?').join(', ');
|
|
809
|
+
const values = insertColumns.map((key) => data[key]);
|
|
810
|
+
await db
|
|
811
|
+
.prepare(`INSERT INTO "${safeName}" (${insertColumns.join(', ')}) VALUES (${placeholders})`)
|
|
812
|
+
.bind(...values)
|
|
813
|
+
.run();
|
|
814
|
+
const version = await updateSyncMetadata(db, tableName, data.id);
|
|
815
|
+
const row = await db
|
|
816
|
+
.prepare(`SELECT * FROM "${safeName}" WHERE id = ?`)
|
|
817
|
+
.bind(data.id)
|
|
818
|
+
.first();
|
|
819
|
+
await notifyAdminChange(tableName, 'create', row || data, data.id, version);
|
|
820
|
+
if (webhookManager) {
|
|
821
|
+
await webhookManager.triggerEvent({
|
|
822
|
+
eventType: 'sync.create',
|
|
823
|
+
entity: tableName,
|
|
824
|
+
recordId: data.id,
|
|
825
|
+
operation: 'create',
|
|
826
|
+
data: row || data,
|
|
827
|
+
userId: auth.admin.id,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (auditManager && (await hasUserId(db, auth.admin.id))) {
|
|
831
|
+
await auditManager.logChange({
|
|
832
|
+
id: auth.admin.id,
|
|
833
|
+
email: auth.admin.email,
|
|
834
|
+
createdAt: Date.now(),
|
|
835
|
+
updatedAt: Date.now(),
|
|
836
|
+
}, tableName, data.id, 'create', undefined, row || data);
|
|
837
|
+
}
|
|
838
|
+
return c.json(row || data, 201);
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
console.error('Admin create row error:', error);
|
|
842
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to create row', 'API_ERROR');
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
/**
|
|
846
|
+
* PATCH /admin/database/tables/:name/rows/:id
|
|
847
|
+
*/
|
|
848
|
+
app.patch('/admin/database/tables/:name/rows/:id', async (c) => {
|
|
849
|
+
const auth = await requireAccessAdmin(c);
|
|
850
|
+
if (auth instanceof Response) {
|
|
851
|
+
return auth;
|
|
852
|
+
}
|
|
853
|
+
const tableName = c.req.param('name');
|
|
854
|
+
if (!isEntityTable(tableName)) {
|
|
855
|
+
return jsonError(c, 403, 'Table is not editable via admin API', 'FORBIDDEN');
|
|
856
|
+
}
|
|
857
|
+
const primaryKey = getEntityPrimaryKey(tableName);
|
|
858
|
+
if (!primaryKey || primaryKey !== 'id') {
|
|
859
|
+
return jsonError(c, 400, 'Unsupported primary key for sync', 'VALIDATION_ERROR');
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
const db = getDb(c);
|
|
863
|
+
const tableNames = await listTables(db);
|
|
864
|
+
const safeName = requireValidTableName(tableName, tableNames);
|
|
865
|
+
if (!safeName) {
|
|
866
|
+
return jsonError(c, 404, 'Table not found', 'NOT_FOUND');
|
|
867
|
+
}
|
|
868
|
+
const recordId = c.req.param('id');
|
|
869
|
+
const updates = (await c.req.json());
|
|
870
|
+
const existing = await db
|
|
871
|
+
.prepare(`SELECT * FROM "${safeName}" WHERE id = ?`)
|
|
872
|
+
.bind(recordId)
|
|
873
|
+
.first();
|
|
874
|
+
if (!existing) {
|
|
875
|
+
return jsonError(c, 404, 'Record not found', 'NOT_FOUND');
|
|
876
|
+
}
|
|
877
|
+
const columnsResult = await db.prepare(`PRAGMA table_info("${safeName}")`).all();
|
|
878
|
+
const columns = (columnsResult?.results || []).map((row) => row.name);
|
|
879
|
+
if (columns.includes('updatedAt') && updates.updatedAt == null)
|
|
880
|
+
updates.updatedAt = Date.now();
|
|
881
|
+
if (columns.includes('updated_at') && updates.updated_at == null)
|
|
882
|
+
updates.updated_at = Date.now();
|
|
883
|
+
const updateColumns = Object.keys(updates).filter((key) => key !== 'id' && columns.includes(key));
|
|
884
|
+
if (updateColumns.length === 0) {
|
|
885
|
+
return jsonError(c, 400, 'No valid fields to update', 'VALIDATION_ERROR');
|
|
886
|
+
}
|
|
887
|
+
const assignments = updateColumns.map((key) => `${key} = ?`).join(', ');
|
|
888
|
+
const values = updateColumns.map((key) => updates[key]);
|
|
889
|
+
await db
|
|
890
|
+
.prepare(`UPDATE "${safeName}" SET ${assignments} WHERE id = ?`)
|
|
891
|
+
.bind(...values, recordId)
|
|
892
|
+
.run();
|
|
893
|
+
const version = await updateSyncMetadata(db, tableName, recordId);
|
|
894
|
+
const row = await db
|
|
895
|
+
.prepare(`SELECT * FROM "${safeName}" WHERE id = ?`)
|
|
896
|
+
.bind(recordId)
|
|
897
|
+
.first();
|
|
898
|
+
await notifyAdminChange(tableName, 'update', row || updates, recordId, version);
|
|
899
|
+
if (webhookManager) {
|
|
900
|
+
await webhookManager.triggerEvent({
|
|
901
|
+
eventType: 'sync.update',
|
|
902
|
+
entity: tableName,
|
|
903
|
+
recordId,
|
|
904
|
+
operation: 'update',
|
|
905
|
+
data: row || updates,
|
|
906
|
+
userId: auth.admin.id,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
if (auditManager && (await hasUserId(db, auth.admin.id))) {
|
|
910
|
+
await auditManager.logChange({
|
|
911
|
+
id: auth.admin.id,
|
|
912
|
+
email: auth.admin.email,
|
|
913
|
+
createdAt: Date.now(),
|
|
914
|
+
updatedAt: Date.now(),
|
|
915
|
+
}, tableName, recordId, 'update', existing, row || updates);
|
|
916
|
+
}
|
|
917
|
+
return c.json(row || updates);
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
console.error('Admin update row error:', error);
|
|
921
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to update row', 'API_ERROR');
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
/**
|
|
925
|
+
* DELETE /admin/database/tables/:name/rows/:id
|
|
926
|
+
*/
|
|
927
|
+
app.delete('/admin/database/tables/:name/rows/:id', async (c) => {
|
|
928
|
+
const auth = await requireAccessAdmin(c);
|
|
929
|
+
if (auth instanceof Response) {
|
|
930
|
+
return auth;
|
|
931
|
+
}
|
|
932
|
+
const tableName = c.req.param('name');
|
|
933
|
+
if (!isEntityTable(tableName)) {
|
|
934
|
+
return jsonError(c, 403, 'Table is not editable via admin API', 'FORBIDDEN');
|
|
935
|
+
}
|
|
936
|
+
const primaryKey = getEntityPrimaryKey(tableName);
|
|
937
|
+
if (!primaryKey || primaryKey !== 'id') {
|
|
938
|
+
return jsonError(c, 400, 'Unsupported primary key for sync', 'VALIDATION_ERROR');
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
const db = getDb(c);
|
|
942
|
+
const tableNames = await listTables(db);
|
|
943
|
+
const safeName = requireValidTableName(tableName, tableNames);
|
|
944
|
+
if (!safeName) {
|
|
945
|
+
return jsonError(c, 404, 'Table not found', 'NOT_FOUND');
|
|
946
|
+
}
|
|
947
|
+
const recordId = c.req.param('id');
|
|
948
|
+
const existing = await db
|
|
949
|
+
.prepare(`SELECT * FROM "${safeName}" WHERE id = ?`)
|
|
950
|
+
.bind(recordId)
|
|
951
|
+
.first();
|
|
952
|
+
if (!existing) {
|
|
953
|
+
return jsonError(c, 404, 'Record not found', 'NOT_FOUND');
|
|
954
|
+
}
|
|
955
|
+
await db
|
|
956
|
+
.prepare(`DELETE FROM "${safeName}" WHERE id = ?`)
|
|
957
|
+
.bind(recordId)
|
|
958
|
+
.run();
|
|
959
|
+
const version = await updateSyncMetadata(db, tableName, recordId, Date.now());
|
|
960
|
+
await notifyAdminChange(tableName, 'delete', {}, recordId, version);
|
|
961
|
+
if (webhookManager) {
|
|
962
|
+
await webhookManager.triggerEvent({
|
|
963
|
+
eventType: 'sync.delete',
|
|
964
|
+
entity: tableName,
|
|
965
|
+
recordId,
|
|
966
|
+
operation: 'delete',
|
|
967
|
+
data: {},
|
|
968
|
+
userId: auth.admin.id,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
if (auditManager && (await hasUserId(db, auth.admin.id))) {
|
|
972
|
+
await auditManager.logChange({
|
|
973
|
+
id: auth.admin.id,
|
|
974
|
+
email: auth.admin.email,
|
|
975
|
+
createdAt: Date.now(),
|
|
976
|
+
updatedAt: Date.now(),
|
|
977
|
+
}, tableName, recordId, 'delete', existing, undefined);
|
|
978
|
+
}
|
|
979
|
+
return c.json({ success: true });
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
console.error('Admin delete row error:', error);
|
|
983
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to delete row', 'API_ERROR');
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
/**
|
|
987
|
+
* GET /admin/schemas
|
|
988
|
+
*/
|
|
989
|
+
app.get('/admin/schemas', async (c) => {
|
|
990
|
+
const auth = await requireAccessAdmin(c);
|
|
991
|
+
if (auth instanceof Response) {
|
|
992
|
+
return auth;
|
|
993
|
+
}
|
|
994
|
+
return c.json({
|
|
995
|
+
version: 1,
|
|
996
|
+
timestamp: Date.now(),
|
|
997
|
+
schemas: schema.entities,
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
/**
|
|
1001
|
+
* GET /admin/activity
|
|
1002
|
+
*/
|
|
1003
|
+
app.get('/admin/activity', async (c) => {
|
|
1004
|
+
const auth = await requireAccessAdmin(c);
|
|
1005
|
+
if (auth instanceof Response) {
|
|
1006
|
+
return auth;
|
|
1007
|
+
}
|
|
1008
|
+
try {
|
|
1009
|
+
const db = getDb(c);
|
|
1010
|
+
const page = Math.max(parseInt(c.req.query('page') || '1', 10), 1);
|
|
1011
|
+
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '20', 10), 1), 100);
|
|
1012
|
+
const offset = (page - 1) * pageSize;
|
|
1013
|
+
const conditions = [];
|
|
1014
|
+
const params = [];
|
|
1015
|
+
const adminId = c.req.query('adminId');
|
|
1016
|
+
if (adminId) {
|
|
1017
|
+
conditions.push('user_id = ?');
|
|
1018
|
+
params.push(adminId);
|
|
1019
|
+
}
|
|
1020
|
+
const entityType = c.req.query('entityType');
|
|
1021
|
+
if (entityType) {
|
|
1022
|
+
conditions.push('entity = ?');
|
|
1023
|
+
params.push(entityType);
|
|
1024
|
+
}
|
|
1025
|
+
const action = c.req.query('action');
|
|
1026
|
+
if (action) {
|
|
1027
|
+
conditions.push('operation = ?');
|
|
1028
|
+
params.push(action);
|
|
1029
|
+
}
|
|
1030
|
+
const dateFrom = c.req.query('dateFrom');
|
|
1031
|
+
if (dateFrom) {
|
|
1032
|
+
conditions.push('created_at >= ?');
|
|
1033
|
+
params.push(Number(dateFrom));
|
|
1034
|
+
}
|
|
1035
|
+
const dateTo = c.req.query('dateTo');
|
|
1036
|
+
if (dateTo) {
|
|
1037
|
+
conditions.push('created_at <= ?');
|
|
1038
|
+
params.push(Number(dateTo));
|
|
1039
|
+
}
|
|
1040
|
+
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
1041
|
+
const countRow = await db
|
|
1042
|
+
.prepare(`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`)
|
|
1043
|
+
.bind(...params)
|
|
1044
|
+
.first();
|
|
1045
|
+
const total = countRow?.total || 0;
|
|
1046
|
+
const rows = await db
|
|
1047
|
+
.prepare(`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
1048
|
+
.bind(...params, pageSize, offset)
|
|
1049
|
+
.all();
|
|
1050
|
+
const entries = [];
|
|
1051
|
+
for (const row of rows?.results || []) {
|
|
1052
|
+
const entity = row.entity;
|
|
1053
|
+
const entityTypeValue = entity === 'admins' ? 'admin' : entity === 'users' ? 'user' : 'database';
|
|
1054
|
+
entries.push({
|
|
1055
|
+
id: row.id,
|
|
1056
|
+
adminId: row.user_id,
|
|
1057
|
+
adminEmail: undefined,
|
|
1058
|
+
action: row.operation,
|
|
1059
|
+
entityType: entityTypeValue,
|
|
1060
|
+
entityId: row.record_id,
|
|
1061
|
+
details: row.changes ? JSON.parse(row.changes) : {},
|
|
1062
|
+
ipAddress: '',
|
|
1063
|
+
createdAt: row.created_at,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return c.json({
|
|
1067
|
+
data: entries,
|
|
1068
|
+
pagination: {
|
|
1069
|
+
page,
|
|
1070
|
+
pageSize,
|
|
1071
|
+
total,
|
|
1072
|
+
totalPages: Math.ceil(total / pageSize) || 1,
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
console.error('Admin activity error:', error);
|
|
1078
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to fetch activity log', 'API_ERROR');
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
/**
|
|
1082
|
+
* POST /auth/refresh
|
|
1083
|
+
*/
|
|
1084
|
+
app.post('/auth/refresh', async (c) => {
|
|
1085
|
+
try {
|
|
1086
|
+
const body = await c.req.json();
|
|
1087
|
+
const refreshToken = body?.refreshToken;
|
|
1088
|
+
if (!refreshToken) {
|
|
1089
|
+
return jsonError(c, 400, 'Refresh token is required', 'VALIDATION_ERROR');
|
|
1090
|
+
}
|
|
1091
|
+
const payload = parseJWT(refreshToken);
|
|
1092
|
+
if (!payload || payload.type !== 'refresh') {
|
|
1093
|
+
return jsonError(c, 401, 'Invalid refresh token', 'UNAUTHORIZED');
|
|
1094
|
+
}
|
|
1095
|
+
const db = getDb(c);
|
|
1096
|
+
const jwtSecret = getJwtSecret(c);
|
|
1097
|
+
const tokenRow = await db
|
|
1098
|
+
.prepare(`SELECT r.user_id, r.expires_at, u.email
|
|
1099
|
+
FROM refresh_tokens r
|
|
1100
|
+
JOIN users u ON u.id = r.user_id
|
|
1101
|
+
WHERE r.token = ?`)
|
|
1102
|
+
.bind(refreshToken)
|
|
1103
|
+
.first();
|
|
1104
|
+
if (!tokenRow) {
|
|
1105
|
+
return jsonError(c, 401, 'Invalid refresh token', 'UNAUTHORIZED');
|
|
1106
|
+
}
|
|
1107
|
+
if (tokenRow.expires_at < Date.now()) {
|
|
1108
|
+
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(refreshToken).run();
|
|
1109
|
+
return jsonError(c, 401, 'Refresh token expired', 'UNAUTHORIZED');
|
|
1110
|
+
}
|
|
1111
|
+
const accessToken = createJWT({ userId: tokenRow.user_id, email: tokenRow.email, type: 'access' }, jwtSecret, 3600);
|
|
1112
|
+
return c.json({ accessToken, expiresIn: 3600 });
|
|
1113
|
+
}
|
|
1114
|
+
catch (error) {
|
|
1115
|
+
console.error('Refresh token error:', error);
|
|
1116
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Token refresh failed', 'API_ERROR');
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
/**
|
|
1120
|
+
* POST /auth/logout
|
|
1121
|
+
*/
|
|
1122
|
+
app.post('/auth/logout', async (c) => {
|
|
1123
|
+
const auth = await requireAccessUser(c);
|
|
1124
|
+
if (auth instanceof Response) {
|
|
1125
|
+
return auth;
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
const db = getDb(c);
|
|
1129
|
+
await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(auth.user.id).run();
|
|
1130
|
+
return c.json({ message: 'Logged out successfully', userId: auth.user.id });
|
|
1131
|
+
}
|
|
1132
|
+
catch (error) {
|
|
1133
|
+
console.error('Logout error:', error);
|
|
1134
|
+
return jsonError(c, 500, 'Logout failed', 'API_ERROR');
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
/**
|
|
1138
|
+
* POST /sync
|
|
1139
|
+
*/
|
|
1140
|
+
app.post('/sync', async (c) => {
|
|
1141
|
+
try {
|
|
1142
|
+
const auth = await requireAccessUser(c);
|
|
1143
|
+
if (auth instanceof Response) {
|
|
1144
|
+
return auth;
|
|
1145
|
+
}
|
|
1146
|
+
// Create sync database adapter
|
|
1147
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
1148
|
+
// Create schemas map for ServerSyncEngine
|
|
1149
|
+
const schemas = new Map();
|
|
1150
|
+
for (const [entityName, entitySchema] of Object.entries(schema.entities)) {
|
|
1151
|
+
schemas.set(entityName, entitySchema);
|
|
1152
|
+
}
|
|
1153
|
+
// Create sync engine
|
|
1154
|
+
const syncEngine = new ServerSyncEngine({
|
|
1155
|
+
schemas,
|
|
1156
|
+
db,
|
|
1157
|
+
user: auth.user,
|
|
1158
|
+
encryption: encryptionManager || undefined,
|
|
1159
|
+
});
|
|
1160
|
+
// Process sync request
|
|
1161
|
+
const request = await c.req.json();
|
|
1162
|
+
const response = await syncEngine.sync(request);
|
|
1163
|
+
// Broadcast changes to subscribed clients in real-time
|
|
1164
|
+
if (changeNotifier && subscriptionManager) {
|
|
1165
|
+
for (const change of response.changes) {
|
|
1166
|
+
// For each applied change, notify subscribed clients
|
|
1167
|
+
if (change.operation !== 'delete') {
|
|
1168
|
+
// For create/update, we have the full record
|
|
1169
|
+
await changeNotifier.notifyChange(change.entity, change.operation, change.data || {}, change.id, auth.user, change.version);
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
// For delete, notify with just the ID
|
|
1173
|
+
await changeNotifier.notifyChange(change.entity, 'delete', {}, change.id, auth.user, change.version);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// Trigger webhooks for applied changes
|
|
1178
|
+
if (webhookManager) {
|
|
1179
|
+
for (const change of response.changes) {
|
|
1180
|
+
await webhookManager.triggerEvent({
|
|
1181
|
+
eventType: `sync.${change.operation}`,
|
|
1182
|
+
entity: change.entity,
|
|
1183
|
+
recordId: change.id,
|
|
1184
|
+
operation: change.operation,
|
|
1185
|
+
data: change.data,
|
|
1186
|
+
userId: auth.user.id,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
// Log changes for audit trail
|
|
1191
|
+
if (auditManager) {
|
|
1192
|
+
for (const change of response.changes) {
|
|
1193
|
+
const before = change.operation === 'update' || change.operation === 'delete' ? change.data : undefined;
|
|
1194
|
+
const after = change.operation === 'create' || change.operation === 'update' ? change.data : undefined;
|
|
1195
|
+
await auditManager.logChange(auth.user, change.entity, change.id, change.operation, before, after);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return c.json(response);
|
|
1199
|
+
}
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
console.error('Sync error:', error);
|
|
1202
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Sync failed', 'API_ERROR');
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
/**
|
|
1206
|
+
* GET /sync/:entity
|
|
1207
|
+
*/
|
|
1208
|
+
app.get('/sync/:entity', async (c) => {
|
|
1209
|
+
const auth = await requireAccessUser(c);
|
|
1210
|
+
if (auth instanceof Response) {
|
|
1211
|
+
return auth;
|
|
1212
|
+
}
|
|
1213
|
+
const entity = c.req.param('entity');
|
|
1214
|
+
if (!schema.entities[entity]) {
|
|
1215
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
const db = getDb(c);
|
|
1219
|
+
const result = await db
|
|
1220
|
+
.prepare('SELECT entity, record_id, version, updated_at, deleted_at FROM sync_metadata WHERE entity = ?')
|
|
1221
|
+
.bind(entity)
|
|
1222
|
+
.all();
|
|
1223
|
+
return c.json({
|
|
1224
|
+
entity,
|
|
1225
|
+
metadata: result.results || [],
|
|
1226
|
+
timestamp: Date.now(),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
console.error('Sync metadata error:', error);
|
|
1231
|
+
return jsonError(c, 500, 'Failed to load sync metadata', 'API_ERROR');
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
/**
|
|
1235
|
+
* POST /sync/batch
|
|
1236
|
+
* Batch process multiple CRUD operations
|
|
1237
|
+
*/
|
|
1238
|
+
app.post('/sync/batch', async (c) => {
|
|
1239
|
+
try {
|
|
1240
|
+
const auth = await requireAccessUser(c);
|
|
1241
|
+
if (auth instanceof Response) {
|
|
1242
|
+
return auth;
|
|
1243
|
+
}
|
|
1244
|
+
// Create sync database adapter
|
|
1245
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
1246
|
+
// Create schemas map for BatchProcessor
|
|
1247
|
+
const schemas = new Map();
|
|
1248
|
+
for (const [entityName, entitySchema] of Object.entries(schema.entities)) {
|
|
1249
|
+
schemas.set(entityName, entitySchema);
|
|
1250
|
+
}
|
|
1251
|
+
// Create batch processor
|
|
1252
|
+
const batchProcessor = new BatchProcessor({
|
|
1253
|
+
schemas,
|
|
1254
|
+
db,
|
|
1255
|
+
user: auth.user,
|
|
1256
|
+
maxBatchSize: 1000,
|
|
1257
|
+
encryption: encryptionManager || undefined,
|
|
1258
|
+
});
|
|
1259
|
+
// Process batch request
|
|
1260
|
+
const request = await c.req.json();
|
|
1261
|
+
const response = await batchProcessor.processBatch(request);
|
|
1262
|
+
// Broadcast changes to subscribed clients in real-time
|
|
1263
|
+
if (changeNotifier && subscriptionManager) {
|
|
1264
|
+
for (const change of response.applied) {
|
|
1265
|
+
// For each applied change, notify subscribed clients
|
|
1266
|
+
if (change.operation !== 'delete') {
|
|
1267
|
+
// For create/update, we have the full record
|
|
1268
|
+
await changeNotifier.notifyChange(change.entity, change.operation, change.data || {}, change.id, auth.user, change.version);
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
// For delete, notify with just the ID
|
|
1272
|
+
await changeNotifier.notifyChange(change.entity, 'delete', {}, change.id, auth.user, change.version);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
// Trigger webhooks for applied changes
|
|
1277
|
+
if (webhookManager) {
|
|
1278
|
+
for (const change of response.applied) {
|
|
1279
|
+
await webhookManager.triggerEvent({
|
|
1280
|
+
eventType: `sync.${change.operation}`,
|
|
1281
|
+
entity: change.entity,
|
|
1282
|
+
recordId: change.id,
|
|
1283
|
+
operation: change.operation,
|
|
1284
|
+
data: change.data,
|
|
1285
|
+
userId: auth.user.id,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
// Log changes for audit trail
|
|
1290
|
+
if (auditManager) {
|
|
1291
|
+
for (const change of response.applied) {
|
|
1292
|
+
const before = change.operation === 'update' || change.operation === 'delete' ? change.data : undefined;
|
|
1293
|
+
const after = change.operation === 'create' || change.operation === 'update' ? change.data : undefined;
|
|
1294
|
+
await auditManager.logChange(auth.user, change.entity, change.id, change.operation, before, after);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return c.json(response);
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
console.error('Batch error:', error);
|
|
1301
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Batch processing failed', 'API_ERROR');
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
/**
|
|
1305
|
+
* POST /import/csv
|
|
1306
|
+
* Import data from CSV file
|
|
1307
|
+
*/
|
|
1308
|
+
app.post('/import/csv', async (c) => {
|
|
1309
|
+
try {
|
|
1310
|
+
const auth = await requireAccessUser(c);
|
|
1311
|
+
if (auth instanceof Response) {
|
|
1312
|
+
return auth;
|
|
1313
|
+
}
|
|
1314
|
+
const formData = await c.req.formData();
|
|
1315
|
+
const entity = formData.get('entity');
|
|
1316
|
+
const file = formData.get('file');
|
|
1317
|
+
if (!entity) {
|
|
1318
|
+
return jsonError(c, 400, 'Missing entity parameter', 'VALIDATION_ERROR');
|
|
1319
|
+
}
|
|
1320
|
+
if (!schema.entities[entity]) {
|
|
1321
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
1322
|
+
}
|
|
1323
|
+
if (!file || !(file instanceof Blob)) {
|
|
1324
|
+
return jsonError(c, 400, 'Missing or invalid file', 'VALIDATION_ERROR');
|
|
1325
|
+
}
|
|
1326
|
+
// Read CSV file
|
|
1327
|
+
const csvContent = await file.text();
|
|
1328
|
+
// Parse CSV
|
|
1329
|
+
const importResult = await CSVProcessor.importFromCSV({
|
|
1330
|
+
entity,
|
|
1331
|
+
data: csvContent,
|
|
1332
|
+
hasHeader: true,
|
|
1333
|
+
});
|
|
1334
|
+
return c.json(importResult);
|
|
1335
|
+
}
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
console.error('CSV import error:', error);
|
|
1338
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'CSV import failed', 'API_ERROR');
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
/**
|
|
1342
|
+
* POST /export/csv
|
|
1343
|
+
* Export data to CSV format
|
|
1344
|
+
*/
|
|
1345
|
+
app.post('/export/csv', async (c) => {
|
|
1346
|
+
try {
|
|
1347
|
+
const auth = await requireAccessUser(c);
|
|
1348
|
+
if (auth instanceof Response) {
|
|
1349
|
+
return auth;
|
|
1350
|
+
}
|
|
1351
|
+
const { entity, filter, columns } = await c.req.json();
|
|
1352
|
+
if (!entity) {
|
|
1353
|
+
return jsonError(c, 400, 'Missing entity parameter', 'VALIDATION_ERROR');
|
|
1354
|
+
}
|
|
1355
|
+
if (!schema.entities[entity]) {
|
|
1356
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
1357
|
+
}
|
|
1358
|
+
try {
|
|
1359
|
+
const db = getDb(c);
|
|
1360
|
+
let query = `SELECT * FROM ${entity}`;
|
|
1361
|
+
// Apply filter if provided
|
|
1362
|
+
if (filter && typeof filter === 'object') {
|
|
1363
|
+
const whereConditions = [];
|
|
1364
|
+
const params = [];
|
|
1365
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1366
|
+
whereConditions.push(`${key} = ?`);
|
|
1367
|
+
params.push(value);
|
|
1368
|
+
}
|
|
1369
|
+
if (whereConditions.length > 0) {
|
|
1370
|
+
query += ` WHERE ${whereConditions.join(' AND ')}`;
|
|
1371
|
+
}
|
|
1372
|
+
// Execute with parameters
|
|
1373
|
+
const result = await db.prepare(query).bind(...params).all();
|
|
1374
|
+
const data = result.results || [];
|
|
1375
|
+
// Export to CSV
|
|
1376
|
+
const exportResult = CSVProcessor.exportToCSV({
|
|
1377
|
+
entity,
|
|
1378
|
+
data,
|
|
1379
|
+
columns,
|
|
1380
|
+
includeMetadata: false,
|
|
1381
|
+
});
|
|
1382
|
+
// Return as downloadable CSV
|
|
1383
|
+
return new Response(exportResult.csv, {
|
|
1384
|
+
headers: {
|
|
1385
|
+
'Content-Type': 'text/csv',
|
|
1386
|
+
'Content-Disposition': `attachment; filename="${entity}.csv"`,
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
else {
|
|
1391
|
+
// No filter, get all records
|
|
1392
|
+
const result = await db.prepare(query).all();
|
|
1393
|
+
const data = result.results || [];
|
|
1394
|
+
// Export to CSV
|
|
1395
|
+
const exportResult = CSVProcessor.exportToCSV({
|
|
1396
|
+
entity,
|
|
1397
|
+
data,
|
|
1398
|
+
columns,
|
|
1399
|
+
includeMetadata: false,
|
|
1400
|
+
});
|
|
1401
|
+
// Return as downloadable CSV
|
|
1402
|
+
return new Response(exportResult.csv, {
|
|
1403
|
+
headers: {
|
|
1404
|
+
'Content-Type': 'text/csv',
|
|
1405
|
+
'Content-Disposition': `attachment; filename="${entity}.csv"`,
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
catch (error) {
|
|
1411
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Export failed', 'API_ERROR');
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
console.error('CSV export error:', error);
|
|
1416
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'CSV export failed', 'API_ERROR');
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
/**
|
|
1420
|
+
* GET /column-permissions/:entity
|
|
1421
|
+
* Get column permissions for an entity
|
|
1422
|
+
*/
|
|
1423
|
+
app.get('/column-permissions/:entity', async (c) => {
|
|
1424
|
+
try {
|
|
1425
|
+
const auth = await requireAccessUser(c);
|
|
1426
|
+
if (auth instanceof Response) {
|
|
1427
|
+
return auth;
|
|
1428
|
+
}
|
|
1429
|
+
const entity = c.req.param('entity');
|
|
1430
|
+
if (!schema.entities[entity]) {
|
|
1431
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
1432
|
+
}
|
|
1433
|
+
const db = getDb(c);
|
|
1434
|
+
const result = await db
|
|
1435
|
+
.prepare('SELECT * FROM column_permissions WHERE entity = ? ORDER BY column_name')
|
|
1436
|
+
.bind(entity)
|
|
1437
|
+
.all();
|
|
1438
|
+
return c.json({
|
|
1439
|
+
entity,
|
|
1440
|
+
permissions: result.results || [],
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
catch (error) {
|
|
1444
|
+
console.error('Column permissions fetch error:', error);
|
|
1445
|
+
return jsonError(c, 500, 'Failed to fetch column permissions', 'API_ERROR');
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
/**
|
|
1449
|
+
* POST /column-permissions
|
|
1450
|
+
* Create or update column permission
|
|
1451
|
+
*/
|
|
1452
|
+
app.post('/column-permissions', async (c) => {
|
|
1453
|
+
try {
|
|
1454
|
+
const auth = await requireAccessUser(c);
|
|
1455
|
+
if (auth instanceof Response) {
|
|
1456
|
+
return auth;
|
|
1457
|
+
}
|
|
1458
|
+
const { entity, columnName, role, visible, readable, writable, encrypted, maskValue } = await c.req.json();
|
|
1459
|
+
if (!entity || !columnName) {
|
|
1460
|
+
return jsonError(c, 400, 'Missing entity or columnName', 'VALIDATION_ERROR');
|
|
1461
|
+
}
|
|
1462
|
+
if (!schema.entities[entity]) {
|
|
1463
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
1464
|
+
}
|
|
1465
|
+
const db = getDb(c);
|
|
1466
|
+
const now = Date.now();
|
|
1467
|
+
const permissionId = `${entity}_${columnName}_${role || 'default'}_${now}`;
|
|
1468
|
+
// Check if permission already exists
|
|
1469
|
+
const existing = await db
|
|
1470
|
+
.prepare('SELECT id FROM column_permissions WHERE entity = ? AND column_name = ? AND role IS ?')
|
|
1471
|
+
.bind(entity, columnName, role || null)
|
|
1472
|
+
.first();
|
|
1473
|
+
if (existing) {
|
|
1474
|
+
// Update existing permission
|
|
1475
|
+
await db
|
|
1476
|
+
.prepare(`
|
|
1477
|
+
UPDATE column_permissions
|
|
1478
|
+
SET visible = ?, readable = ?, writable = ?, encrypted = ?, mask_value = ?, updated_at = ?
|
|
1479
|
+
WHERE entity = ? AND column_name = ? AND role IS ?
|
|
1480
|
+
`)
|
|
1481
|
+
.bind(visible ? 1 : 0, readable ? 1 : 0, writable ? 1 : 0, encrypted ? 1 : 0, maskValue || null, now, entity, columnName, role || null)
|
|
1482
|
+
.run();
|
|
1483
|
+
}
|
|
1484
|
+
else {
|
|
1485
|
+
// Create new permission
|
|
1486
|
+
await db
|
|
1487
|
+
.prepare(`
|
|
1488
|
+
INSERT INTO column_permissions (id, entity, column_name, role, visible, readable, writable, encrypted, mask_value, created_at, updated_at)
|
|
1489
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1490
|
+
`)
|
|
1491
|
+
.bind(permissionId, entity, columnName, role || null, visible ? 1 : 0, readable ? 1 : 0, writable ? 1 : 0, encrypted ? 1 : 0, maskValue || null, now, now)
|
|
1492
|
+
.run();
|
|
1493
|
+
}
|
|
1494
|
+
return c.json({ success: true, id: existing?.id || permissionId });
|
|
1495
|
+
}
|
|
1496
|
+
catch (error) {
|
|
1497
|
+
console.error('Column permission update error:', error);
|
|
1498
|
+
return jsonError(c, 500, 'Failed to update column permission', 'API_ERROR');
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
/**
|
|
1502
|
+
* DELETE /column-permissions/:id
|
|
1503
|
+
* Delete a column permission
|
|
1504
|
+
*/
|
|
1505
|
+
app.delete('/column-permissions/:id', async (c) => {
|
|
1506
|
+
try {
|
|
1507
|
+
const auth = await requireAccessUser(c);
|
|
1508
|
+
if (auth instanceof Response) {
|
|
1509
|
+
return auth;
|
|
1510
|
+
}
|
|
1511
|
+
const id = c.req.param('id');
|
|
1512
|
+
const db = getDb(c);
|
|
1513
|
+
await db
|
|
1514
|
+
.prepare('DELETE FROM column_permissions WHERE id = ?')
|
|
1515
|
+
.bind(id)
|
|
1516
|
+
.run();
|
|
1517
|
+
return c.json({ success: true });
|
|
1518
|
+
}
|
|
1519
|
+
catch (error) {
|
|
1520
|
+
console.error('Column permission delete error:', error);
|
|
1521
|
+
return jsonError(c, 500, 'Failed to delete column permission', 'API_ERROR');
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
/**
|
|
1525
|
+
* POST /files/upload
|
|
1526
|
+
* Upload a file to R2 storage
|
|
1527
|
+
*/
|
|
1528
|
+
app.post('/files/upload', async (c) => {
|
|
1529
|
+
try {
|
|
1530
|
+
const auth = await requireAccessUser(c);
|
|
1531
|
+
if (auth instanceof Response) {
|
|
1532
|
+
return auth;
|
|
1533
|
+
}
|
|
1534
|
+
const getR2Bucket = options.getR2Bucket;
|
|
1535
|
+
if (!getR2Bucket) {
|
|
1536
|
+
return jsonError(c, 501, 'File storage not configured', 'NOT_CONFIGURED');
|
|
1537
|
+
}
|
|
1538
|
+
const formData = await c.req.formData();
|
|
1539
|
+
const file = formData.get('file');
|
|
1540
|
+
const entityType = formData.get('entityType');
|
|
1541
|
+
const entityId = formData.get('entityId');
|
|
1542
|
+
const isPublic = formData.get('isPublic') === 'true';
|
|
1543
|
+
if (!file || !(file instanceof Blob)) {
|
|
1544
|
+
return jsonError(c, 400, 'Missing or invalid file', 'VALIDATION_ERROR');
|
|
1545
|
+
}
|
|
1546
|
+
const bucket = getR2Bucket(c);
|
|
1547
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
1548
|
+
const fileManager = new FileStorageManager(bucket, db, {
|
|
1549
|
+
maxFileSize: 100 * 1024 * 1024, // 100MB
|
|
1550
|
+
});
|
|
1551
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
1552
|
+
const metadata = await fileManager.uploadFile(auth.user, arrayBuffer, {
|
|
1553
|
+
fileName: file.name || 'unnamed',
|
|
1554
|
+
mimeType: file.type || 'application/octet-stream',
|
|
1555
|
+
size: file.size,
|
|
1556
|
+
entityType: entityType || undefined,
|
|
1557
|
+
entityId: entityId || undefined,
|
|
1558
|
+
isPublic,
|
|
1559
|
+
});
|
|
1560
|
+
return c.json(metadata);
|
|
1561
|
+
}
|
|
1562
|
+
catch (error) {
|
|
1563
|
+
console.error('File upload error:', error);
|
|
1564
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Upload failed', 'API_ERROR');
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
/**
|
|
1568
|
+
* GET /files/download/:fileId
|
|
1569
|
+
* Download a file from R2 storage
|
|
1570
|
+
*/
|
|
1571
|
+
app.get('/files/download/:fileId', async (c) => {
|
|
1572
|
+
try {
|
|
1573
|
+
const fileId = c.req.param('fileId');
|
|
1574
|
+
const token = c.req.query('token');
|
|
1575
|
+
const getR2Bucket = options.getR2Bucket;
|
|
1576
|
+
if (!getR2Bucket) {
|
|
1577
|
+
return jsonError(c, 501, 'File storage not configured', 'NOT_CONFIGURED');
|
|
1578
|
+
}
|
|
1579
|
+
const bucket = getR2Bucket(c);
|
|
1580
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
1581
|
+
const fileManager = new FileStorageManager(bucket, db);
|
|
1582
|
+
// If token is provided, verify it
|
|
1583
|
+
if (token) {
|
|
1584
|
+
const isValid = await fileManager.verifyToken(fileId, token);
|
|
1585
|
+
if (!isValid) {
|
|
1586
|
+
return jsonError(c, 401, 'Invalid or expired token', 'UNAUTHORIZED');
|
|
1587
|
+
}
|
|
1588
|
+
// Get file and return
|
|
1589
|
+
const metadata = await fileManager.getFileMetadata(fileId);
|
|
1590
|
+
if (!metadata) {
|
|
1591
|
+
return jsonError(c, 404, 'File not found', 'NOT_FOUND');
|
|
1592
|
+
}
|
|
1593
|
+
const file = await bucket.get(metadata.key);
|
|
1594
|
+
if (!file) {
|
|
1595
|
+
return jsonError(c, 404, 'File not found in storage', 'NOT_FOUND');
|
|
1596
|
+
}
|
|
1597
|
+
return new Response(file.body, {
|
|
1598
|
+
headers: {
|
|
1599
|
+
'Content-Type': metadata.mimeType,
|
|
1600
|
+
'Content-Disposition': `attachment; filename="${metadata.fileName}"`,
|
|
1601
|
+
'Content-Length': metadata.size.toString(),
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
// Otherwise, require authentication
|
|
1606
|
+
const auth = await requireAccessUser(c);
|
|
1607
|
+
if (auth instanceof Response) {
|
|
1608
|
+
return auth;
|
|
1609
|
+
}
|
|
1610
|
+
const file = await fileManager.downloadFile(fileId, auth.user);
|
|
1611
|
+
if (!file) {
|
|
1612
|
+
return jsonError(c, 404, 'File not found', 'NOT_FOUND');
|
|
1613
|
+
}
|
|
1614
|
+
const metadata = await fileManager.getFileMetadata(fileId);
|
|
1615
|
+
if (!metadata) {
|
|
1616
|
+
return jsonError(c, 404, 'File metadata not found', 'NOT_FOUND');
|
|
1617
|
+
}
|
|
1618
|
+
return new Response(file.body, {
|
|
1619
|
+
headers: {
|
|
1620
|
+
'Content-Type': metadata.mimeType,
|
|
1621
|
+
'Content-Disposition': `attachment; filename="${metadata.fileName}"`,
|
|
1622
|
+
'Content-Length': metadata.size.toString(),
|
|
1623
|
+
},
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
catch (error) {
|
|
1627
|
+
console.error('File download error:', error);
|
|
1628
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Download failed', 'API_ERROR');
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
/**
|
|
1632
|
+
* DELETE /files/:fileId
|
|
1633
|
+
* Delete a file from R2 storage
|
|
1634
|
+
*/
|
|
1635
|
+
app.delete('/files/:fileId', async (c) => {
|
|
1636
|
+
try {
|
|
1637
|
+
const auth = await requireAccessUser(c);
|
|
1638
|
+
if (auth instanceof Response) {
|
|
1639
|
+
return auth;
|
|
1640
|
+
}
|
|
1641
|
+
const getR2Bucket = options.getR2Bucket;
|
|
1642
|
+
if (!getR2Bucket) {
|
|
1643
|
+
return jsonError(c, 501, 'File storage not configured', 'NOT_CONFIGURED');
|
|
1644
|
+
}
|
|
1645
|
+
const fileId = c.req.param('fileId');
|
|
1646
|
+
const bucket = getR2Bucket(c);
|
|
1647
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
1648
|
+
const fileManager = new FileStorageManager(bucket, db);
|
|
1649
|
+
await fileManager.deleteFile(fileId, auth.user);
|
|
1650
|
+
return c.json({ success: true });
|
|
1651
|
+
}
|
|
1652
|
+
catch (error) {
|
|
1653
|
+
console.error('File delete error:', error);
|
|
1654
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Delete failed', 'API_ERROR');
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
/**
|
|
1658
|
+
* GET /files
|
|
1659
|
+
* List files for the authenticated user
|
|
1660
|
+
*/
|
|
1661
|
+
app.get('/files', async (c) => {
|
|
1662
|
+
try {
|
|
1663
|
+
const auth = await requireAccessUser(c);
|
|
1664
|
+
if (auth instanceof Response) {
|
|
1665
|
+
return auth;
|
|
1666
|
+
}
|
|
1667
|
+
const getR2Bucket = options.getR2Bucket;
|
|
1668
|
+
if (!getR2Bucket) {
|
|
1669
|
+
return jsonError(c, 501, 'File storage not configured', 'NOT_CONFIGURED');
|
|
1670
|
+
}
|
|
1671
|
+
const entityType = c.req.query('entityType');
|
|
1672
|
+
const entityId = c.req.query('entityId');
|
|
1673
|
+
const limit = c.req.query('limit') ? parseInt(c.req.query('limit')) : undefined;
|
|
1674
|
+
const offset = c.req.query('offset') ? parseInt(c.req.query('offset')) : undefined;
|
|
1675
|
+
const bucket = getR2Bucket(c);
|
|
1676
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
1677
|
+
const fileManager = new FileStorageManager(bucket, db);
|
|
1678
|
+
const files = await fileManager.listFiles(auth.user, {
|
|
1679
|
+
entityType: entityType || undefined,
|
|
1680
|
+
entityId: entityId || undefined,
|
|
1681
|
+
limit,
|
|
1682
|
+
offset,
|
|
1683
|
+
});
|
|
1684
|
+
return c.json({ files });
|
|
1685
|
+
}
|
|
1686
|
+
catch (error) {
|
|
1687
|
+
console.error('File list error:', error);
|
|
1688
|
+
return jsonError(c, 500, 'Failed to list files', 'API_ERROR');
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
/**
|
|
1692
|
+
* POST /files/:fileId/signed-url
|
|
1693
|
+
* Generate a signed URL for temporary access to a file
|
|
1694
|
+
*/
|
|
1695
|
+
app.post('/files/:fileId/signed-url', async (c) => {
|
|
1696
|
+
try {
|
|
1697
|
+
const auth = await requireAccessUser(c);
|
|
1698
|
+
if (auth instanceof Response) {
|
|
1699
|
+
return auth;
|
|
1700
|
+
}
|
|
1701
|
+
const getR2Bucket = options.getR2Bucket;
|
|
1702
|
+
if (!getR2Bucket) {
|
|
1703
|
+
return jsonError(c, 501, 'File storage not configured', 'NOT_CONFIGURED');
|
|
1704
|
+
}
|
|
1705
|
+
const fileId = c.req.param('fileId');
|
|
1706
|
+
const { expiresIn } = await c.req.json();
|
|
1707
|
+
const bucket = getR2Bucket(c);
|
|
1708
|
+
const db = new D1SyncDatabase(getDb(c));
|
|
1709
|
+
const fileManager = new FileStorageManager(bucket, db);
|
|
1710
|
+
const url = await fileManager.generateSignedUrl(fileId, auth.user, expiresIn || 3600);
|
|
1711
|
+
return c.json({ url, expiresIn: expiresIn || 3600 });
|
|
1712
|
+
}
|
|
1713
|
+
catch (error) {
|
|
1714
|
+
console.error('Signed URL generation error:', error);
|
|
1715
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to generate signed URL', 'API_ERROR');
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
/**
|
|
1719
|
+
* POST /webhooks
|
|
1720
|
+
* Register a new webhook
|
|
1721
|
+
*/
|
|
1722
|
+
app.post('/webhooks', async (c) => {
|
|
1723
|
+
try {
|
|
1724
|
+
const auth = await requireAccessUser(c);
|
|
1725
|
+
if (auth instanceof Response) {
|
|
1726
|
+
return auth;
|
|
1727
|
+
}
|
|
1728
|
+
if (!webhookManager) {
|
|
1729
|
+
return jsonError(c, 501, 'Webhooks not configured', 'NOT_CONFIGURED');
|
|
1730
|
+
}
|
|
1731
|
+
const { url, events, description, headers } = await c.req.json();
|
|
1732
|
+
const webhook = await webhookManager.registerWebhook(auth.user, {
|
|
1733
|
+
url,
|
|
1734
|
+
events,
|
|
1735
|
+
description,
|
|
1736
|
+
headers,
|
|
1737
|
+
});
|
|
1738
|
+
// Don't expose the secret in the response for security
|
|
1739
|
+
return c.json({ ...webhook, secret: undefined });
|
|
1740
|
+
}
|
|
1741
|
+
catch (error) {
|
|
1742
|
+
console.error('Webhook registration error:', error);
|
|
1743
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to register webhook', 'API_ERROR');
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
/**
|
|
1747
|
+
* GET /webhooks
|
|
1748
|
+
* List user's webhooks
|
|
1749
|
+
*/
|
|
1750
|
+
app.get('/webhooks', async (c) => {
|
|
1751
|
+
try {
|
|
1752
|
+
const auth = await requireAccessUser(c);
|
|
1753
|
+
if (auth instanceof Response) {
|
|
1754
|
+
return auth;
|
|
1755
|
+
}
|
|
1756
|
+
if (!webhookManager) {
|
|
1757
|
+
return jsonError(c, 501, 'Webhooks not configured', 'NOT_CONFIGURED');
|
|
1758
|
+
}
|
|
1759
|
+
const webhooks = await webhookManager.listWebhooks(auth.user.id);
|
|
1760
|
+
// Don't expose secrets
|
|
1761
|
+
const safeWebhooks = webhooks.map((wh) => ({ ...wh, secret: undefined }));
|
|
1762
|
+
return c.json({ webhooks: safeWebhooks });
|
|
1763
|
+
}
|
|
1764
|
+
catch (error) {
|
|
1765
|
+
console.error('Webhook list error:', error);
|
|
1766
|
+
return jsonError(c, 500, 'Failed to list webhooks', 'API_ERROR');
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
/**
|
|
1770
|
+
* GET /webhooks/:webhookId
|
|
1771
|
+
* Get a specific webhook
|
|
1772
|
+
*/
|
|
1773
|
+
app.get('/webhooks/:webhookId', async (c) => {
|
|
1774
|
+
try {
|
|
1775
|
+
const auth = await requireAccessUser(c);
|
|
1776
|
+
if (auth instanceof Response) {
|
|
1777
|
+
return auth;
|
|
1778
|
+
}
|
|
1779
|
+
if (!webhookManager) {
|
|
1780
|
+
return jsonError(c, 501, 'Webhooks not configured', 'NOT_CONFIGURED');
|
|
1781
|
+
}
|
|
1782
|
+
const webhookId = c.req.param('webhookId');
|
|
1783
|
+
const webhook = await webhookManager.getWebhook(webhookId, auth.user.id);
|
|
1784
|
+
if (!webhook) {
|
|
1785
|
+
return jsonError(c, 404, 'Webhook not found', 'NOT_FOUND');
|
|
1786
|
+
}
|
|
1787
|
+
return c.json({ ...webhook, secret: undefined });
|
|
1788
|
+
}
|
|
1789
|
+
catch (error) {
|
|
1790
|
+
console.error('Webhook fetch error:', error);
|
|
1791
|
+
return jsonError(c, 500, 'Failed to fetch webhook', 'API_ERROR');
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
/**
|
|
1795
|
+
* PATCH /webhooks/:webhookId
|
|
1796
|
+
* Update a webhook
|
|
1797
|
+
*/
|
|
1798
|
+
app.patch('/webhooks/:webhookId', async (c) => {
|
|
1799
|
+
try {
|
|
1800
|
+
const auth = await requireAccessUser(c);
|
|
1801
|
+
if (auth instanceof Response) {
|
|
1802
|
+
return auth;
|
|
1803
|
+
}
|
|
1804
|
+
if (!webhookManager) {
|
|
1805
|
+
return jsonError(c, 501, 'Webhooks not configured', 'NOT_CONFIGURED');
|
|
1806
|
+
}
|
|
1807
|
+
const webhookId = c.req.param('webhookId');
|
|
1808
|
+
const updates = await c.req.json();
|
|
1809
|
+
const webhook = await webhookManager.updateWebhook(webhookId, auth.user.id, updates);
|
|
1810
|
+
return c.json({ ...webhook, secret: undefined });
|
|
1811
|
+
}
|
|
1812
|
+
catch (error) {
|
|
1813
|
+
console.error('Webhook update error:', error);
|
|
1814
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to update webhook', 'API_ERROR');
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
/**
|
|
1818
|
+
* DELETE /webhooks/:webhookId
|
|
1819
|
+
* Delete a webhook
|
|
1820
|
+
*/
|
|
1821
|
+
app.delete('/webhooks/:webhookId', async (c) => {
|
|
1822
|
+
try {
|
|
1823
|
+
const auth = await requireAccessUser(c);
|
|
1824
|
+
if (auth instanceof Response) {
|
|
1825
|
+
return auth;
|
|
1826
|
+
}
|
|
1827
|
+
if (!webhookManager) {
|
|
1828
|
+
return jsonError(c, 501, 'Webhooks not configured', 'NOT_CONFIGURED');
|
|
1829
|
+
}
|
|
1830
|
+
const webhookId = c.req.param('webhookId');
|
|
1831
|
+
await webhookManager.deleteWebhook(webhookId, auth.user.id);
|
|
1832
|
+
return c.json({ success: true });
|
|
1833
|
+
}
|
|
1834
|
+
catch (error) {
|
|
1835
|
+
console.error('Webhook delete error:', error);
|
|
1836
|
+
return jsonError(c, 500, 'Failed to delete webhook', 'API_ERROR');
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
/**
|
|
1840
|
+
* POST /search/:entity
|
|
1841
|
+
* Full-text search for an entity
|
|
1842
|
+
*/
|
|
1843
|
+
app.post('/search/:entity', async (c) => {
|
|
1844
|
+
try {
|
|
1845
|
+
const auth = await requireAccessUser(c);
|
|
1846
|
+
if (auth instanceof Response) {
|
|
1847
|
+
return auth;
|
|
1848
|
+
}
|
|
1849
|
+
if (!searchManager) {
|
|
1850
|
+
return jsonError(c, 501, 'Search not configured', 'NOT_CONFIGURED');
|
|
1851
|
+
}
|
|
1852
|
+
const entity = c.req.param('entity');
|
|
1853
|
+
if (!schema.entities[entity]) {
|
|
1854
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
1855
|
+
}
|
|
1856
|
+
if (!searchManager.hasIndex(entity)) {
|
|
1857
|
+
return jsonError(c, 404, `No search index for entity: ${entity}`, 'NOT_FOUND');
|
|
1858
|
+
}
|
|
1859
|
+
const { query, columns, limit, offset, highlight, rank } = await c.req.json();
|
|
1860
|
+
if (!query || typeof query !== 'string') {
|
|
1861
|
+
return jsonError(c, 400, 'Missing or invalid query', 'VALIDATION_ERROR');
|
|
1862
|
+
}
|
|
1863
|
+
const results = await searchManager.search({
|
|
1864
|
+
entity,
|
|
1865
|
+
query,
|
|
1866
|
+
columns,
|
|
1867
|
+
limit,
|
|
1868
|
+
offset,
|
|
1869
|
+
highlight: highlight !== false, // Default true
|
|
1870
|
+
rank: rank !== false, // Default true
|
|
1871
|
+
});
|
|
1872
|
+
return c.json(results);
|
|
1873
|
+
}
|
|
1874
|
+
catch (error) {
|
|
1875
|
+
console.error('Search error:', error);
|
|
1876
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Search failed', 'API_ERROR');
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
/**
|
|
1880
|
+
* POST /search/:entity/index
|
|
1881
|
+
* Create or rebuild search index for an entity
|
|
1882
|
+
*/
|
|
1883
|
+
app.post('/search/:entity/index', async (c) => {
|
|
1884
|
+
try {
|
|
1885
|
+
const auth = await requireAccessUser(c);
|
|
1886
|
+
if (auth instanceof Response) {
|
|
1887
|
+
return auth;
|
|
1888
|
+
}
|
|
1889
|
+
if (!searchManager) {
|
|
1890
|
+
return jsonError(c, 501, 'Search not configured', 'NOT_CONFIGURED');
|
|
1891
|
+
}
|
|
1892
|
+
const entity = c.req.param('entity');
|
|
1893
|
+
if (!schema.entities[entity]) {
|
|
1894
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
1895
|
+
}
|
|
1896
|
+
const { columns, tokenize, prefix, rebuild } = await c.req.json();
|
|
1897
|
+
if (!columns || !Array.isArray(columns) || columns.length === 0) {
|
|
1898
|
+
return jsonError(c, 400, 'Missing or invalid columns', 'VALIDATION_ERROR');
|
|
1899
|
+
}
|
|
1900
|
+
// Register index configuration
|
|
1901
|
+
searchManager.registerIndex({
|
|
1902
|
+
entity,
|
|
1903
|
+
columns,
|
|
1904
|
+
tokenize,
|
|
1905
|
+
prefix,
|
|
1906
|
+
});
|
|
1907
|
+
// Create or rebuild index
|
|
1908
|
+
if (rebuild) {
|
|
1909
|
+
const count = await searchManager.rebuildIndex(entity);
|
|
1910
|
+
return c.json({ success: true, documentCount: count, rebuilt: true });
|
|
1911
|
+
}
|
|
1912
|
+
else {
|
|
1913
|
+
await searchManager.createSearchIndex(entity);
|
|
1914
|
+
return c.json({ success: true, created: true });
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
catch (error) {
|
|
1918
|
+
console.error('Search index creation error:', error);
|
|
1919
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to create search index', 'API_ERROR');
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
/**
|
|
1923
|
+
* DELETE /search/:entity/index
|
|
1924
|
+
* Delete search index for an entity
|
|
1925
|
+
*/
|
|
1926
|
+
app.delete('/search/:entity/index', async (c) => {
|
|
1927
|
+
try {
|
|
1928
|
+
const auth = await requireAccessUser(c);
|
|
1929
|
+
if (auth instanceof Response) {
|
|
1930
|
+
return auth;
|
|
1931
|
+
}
|
|
1932
|
+
if (!searchManager) {
|
|
1933
|
+
return jsonError(c, 501, 'Search not configured', 'NOT_CONFIGURED');
|
|
1934
|
+
}
|
|
1935
|
+
const entity = c.req.param('entity');
|
|
1936
|
+
await searchManager.deleteSearchIndex(entity);
|
|
1937
|
+
return c.json({ success: true });
|
|
1938
|
+
}
|
|
1939
|
+
catch (error) {
|
|
1940
|
+
console.error('Search index deletion error:', error);
|
|
1941
|
+
return jsonError(c, 500, 'Failed to delete search index', 'API_ERROR');
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
/**
|
|
1945
|
+
* GET /search/:entity/stats
|
|
1946
|
+
* Get search index statistics
|
|
1947
|
+
*/
|
|
1948
|
+
app.get('/search/:entity/stats', async (c) => {
|
|
1949
|
+
try {
|
|
1950
|
+
const auth = await requireAccessUser(c);
|
|
1951
|
+
if (auth instanceof Response) {
|
|
1952
|
+
return auth;
|
|
1953
|
+
}
|
|
1954
|
+
if (!searchManager) {
|
|
1955
|
+
return jsonError(c, 501, 'Search not configured', 'NOT_CONFIGURED');
|
|
1956
|
+
}
|
|
1957
|
+
const entity = c.req.param('entity');
|
|
1958
|
+
const stats = await searchManager.getIndexStats(entity);
|
|
1959
|
+
return c.json(stats);
|
|
1960
|
+
}
|
|
1961
|
+
catch (error) {
|
|
1962
|
+
console.error('Search stats error:', error);
|
|
1963
|
+
return jsonError(c, 500, 'Failed to get search stats', 'API_ERROR');
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
/**
|
|
1967
|
+
* POST /audit/query
|
|
1968
|
+
* Query audit logs with filters
|
|
1969
|
+
*/
|
|
1970
|
+
app.post('/audit/query', async (c) => {
|
|
1971
|
+
try {
|
|
1972
|
+
const auth = await requireAccessUser(c);
|
|
1973
|
+
if (auth instanceof Response) {
|
|
1974
|
+
return auth;
|
|
1975
|
+
}
|
|
1976
|
+
if (!auditManager) {
|
|
1977
|
+
return jsonError(c, 501, 'Audit not configured', 'NOT_CONFIGURED');
|
|
1978
|
+
}
|
|
1979
|
+
const query = await c.req.json();
|
|
1980
|
+
const result = await auditManager.queryLogs(query);
|
|
1981
|
+
return c.json(result);
|
|
1982
|
+
}
|
|
1983
|
+
catch (error) {
|
|
1984
|
+
console.error('Audit query error:', error);
|
|
1985
|
+
return jsonError(c, 500, 'Failed to query audit logs', 'API_ERROR');
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
/**
|
|
1989
|
+
* GET /audit/:auditId
|
|
1990
|
+
* Get a specific audit log
|
|
1991
|
+
*/
|
|
1992
|
+
app.get('/audit/:auditId', async (c) => {
|
|
1993
|
+
try {
|
|
1994
|
+
const auth = await requireAccessUser(c);
|
|
1995
|
+
if (auth instanceof Response) {
|
|
1996
|
+
return auth;
|
|
1997
|
+
}
|
|
1998
|
+
if (!auditManager) {
|
|
1999
|
+
return jsonError(c, 501, 'Audit not configured', 'NOT_CONFIGURED');
|
|
2000
|
+
}
|
|
2001
|
+
const auditId = c.req.param('auditId');
|
|
2002
|
+
const log = await auditManager.getLog(auditId);
|
|
2003
|
+
if (!log) {
|
|
2004
|
+
return jsonError(c, 404, 'Audit log not found', 'NOT_FOUND');
|
|
2005
|
+
}
|
|
2006
|
+
return c.json(log);
|
|
2007
|
+
}
|
|
2008
|
+
catch (error) {
|
|
2009
|
+
console.error('Audit log fetch error:', error);
|
|
2010
|
+
return jsonError(c, 500, 'Failed to fetch audit log', 'API_ERROR');
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
/**
|
|
2014
|
+
* GET /audit/record/:entity/:recordId
|
|
2015
|
+
* Get audit history for a specific record
|
|
2016
|
+
*/
|
|
2017
|
+
app.get('/audit/record/:entity/:recordId', async (c) => {
|
|
2018
|
+
try {
|
|
2019
|
+
const auth = await requireAccessUser(c);
|
|
2020
|
+
if (auth instanceof Response) {
|
|
2021
|
+
return auth;
|
|
2022
|
+
}
|
|
2023
|
+
if (!auditManager) {
|
|
2024
|
+
return jsonError(c, 501, 'Audit not configured', 'NOT_CONFIGURED');
|
|
2025
|
+
}
|
|
2026
|
+
const entity = c.req.param('entity');
|
|
2027
|
+
const recordId = c.req.param('recordId');
|
|
2028
|
+
const history = await auditManager.getRecordHistory(entity, recordId);
|
|
2029
|
+
return c.json({ history });
|
|
2030
|
+
}
|
|
2031
|
+
catch (error) {
|
|
2032
|
+
console.error('Record history error:', error);
|
|
2033
|
+
return jsonError(c, 500, 'Failed to get record history', 'API_ERROR');
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
/**
|
|
2037
|
+
* GET /audit/stats
|
|
2038
|
+
* Get audit statistics
|
|
2039
|
+
*/
|
|
2040
|
+
app.get('/audit/stats', async (c) => {
|
|
2041
|
+
try {
|
|
2042
|
+
const auth = await requireAccessUser(c);
|
|
2043
|
+
if (auth instanceof Response) {
|
|
2044
|
+
return auth;
|
|
2045
|
+
}
|
|
2046
|
+
if (!auditManager) {
|
|
2047
|
+
return jsonError(c, 501, 'Audit not configured', 'NOT_CONFIGURED');
|
|
2048
|
+
}
|
|
2049
|
+
const entity = c.req.query('entity');
|
|
2050
|
+
const userId = c.req.query('userId');
|
|
2051
|
+
const startDate = c.req.query('startDate') ? parseInt(c.req.query('startDate')) : undefined;
|
|
2052
|
+
const endDate = c.req.query('endDate') ? parseInt(c.req.query('endDate')) : undefined;
|
|
2053
|
+
const stats = await auditManager.getStatistics({
|
|
2054
|
+
entity: entity || undefined,
|
|
2055
|
+
userId: userId || undefined,
|
|
2056
|
+
startDate,
|
|
2057
|
+
endDate,
|
|
2058
|
+
});
|
|
2059
|
+
return c.json(stats);
|
|
2060
|
+
}
|
|
2061
|
+
catch (error) {
|
|
2062
|
+
console.error('Audit stats error:', error);
|
|
2063
|
+
return jsonError(c, 500, 'Failed to get audit stats', 'API_ERROR');
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
/**
|
|
2067
|
+
* POST /encryption/config
|
|
2068
|
+
* Register encryption configuration for an entity
|
|
2069
|
+
*/
|
|
2070
|
+
app.post('/encryption/config', async (c) => {
|
|
2071
|
+
try {
|
|
2072
|
+
const auth = await requireAccessUser(c);
|
|
2073
|
+
if (auth instanceof Response) {
|
|
2074
|
+
return auth;
|
|
2075
|
+
}
|
|
2076
|
+
if (!encryptionManager) {
|
|
2077
|
+
return jsonError(c, 501, 'Encryption not configured', 'NOT_CONFIGURED');
|
|
2078
|
+
}
|
|
2079
|
+
const { entity, fields, algorithm, keyRotation } = await c.req.json();
|
|
2080
|
+
if (!entity || !fields || !Array.isArray(fields)) {
|
|
2081
|
+
return jsonError(c, 400, 'Missing or invalid entity or fields', 'VALIDATION_ERROR');
|
|
2082
|
+
}
|
|
2083
|
+
if (!schema.entities[entity]) {
|
|
2084
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
2085
|
+
}
|
|
2086
|
+
encryptionManager.registerConfig({
|
|
2087
|
+
entity,
|
|
2088
|
+
fields,
|
|
2089
|
+
algorithm: algorithm || 'AES-GCM',
|
|
2090
|
+
keyRotation: keyRotation !== false,
|
|
2091
|
+
});
|
|
2092
|
+
return c.json({
|
|
2093
|
+
success: true,
|
|
2094
|
+
entity,
|
|
2095
|
+
fields,
|
|
2096
|
+
algorithm: algorithm || 'AES-GCM',
|
|
2097
|
+
keyRotation: keyRotation !== false,
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
catch (error) {
|
|
2101
|
+
console.error('Encryption config error:', error);
|
|
2102
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to configure encryption', 'API_ERROR');
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
/**
|
|
2106
|
+
* GET /encryption/config/:entity
|
|
2107
|
+
* Get encryption configuration for an entity
|
|
2108
|
+
*/
|
|
2109
|
+
app.get('/encryption/config/:entity', async (c) => {
|
|
2110
|
+
try {
|
|
2111
|
+
const auth = await requireAccessUser(c);
|
|
2112
|
+
if (auth instanceof Response) {
|
|
2113
|
+
return auth;
|
|
2114
|
+
}
|
|
2115
|
+
if (!encryptionManager) {
|
|
2116
|
+
return jsonError(c, 501, 'Encryption not configured', 'NOT_CONFIGURED');
|
|
2117
|
+
}
|
|
2118
|
+
const entity = c.req.param('entity');
|
|
2119
|
+
if (!schema.entities[entity]) {
|
|
2120
|
+
return jsonError(c, 404, `Entity not found: ${entity}`, 'NOT_FOUND');
|
|
2121
|
+
}
|
|
2122
|
+
const config = encryptionManager.getConfig(entity);
|
|
2123
|
+
if (!config) {
|
|
2124
|
+
return c.json({
|
|
2125
|
+
entity,
|
|
2126
|
+
enabled: false,
|
|
2127
|
+
fields: [],
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
return c.json({
|
|
2131
|
+
entity,
|
|
2132
|
+
enabled: true,
|
|
2133
|
+
fields: config.fields,
|
|
2134
|
+
algorithm: config.algorithm || 'AES-GCM',
|
|
2135
|
+
keyRotation: config.keyRotation !== false,
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
catch (error) {
|
|
2139
|
+
console.error('Encryption config fetch error:', error);
|
|
2140
|
+
return jsonError(c, 500, 'Failed to fetch encryption config', 'API_ERROR');
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
/**
|
|
2144
|
+
* POST /encryption/rotate-key
|
|
2145
|
+
* Rotate encryption key (requires master key)
|
|
2146
|
+
*/
|
|
2147
|
+
app.post('/encryption/rotate-key', async (c) => {
|
|
2148
|
+
try {
|
|
2149
|
+
const auth = await requireAccessUser(c);
|
|
2150
|
+
if (auth instanceof Response) {
|
|
2151
|
+
return auth;
|
|
2152
|
+
}
|
|
2153
|
+
if (!encryptionManager) {
|
|
2154
|
+
return jsonError(c, 501, 'Encryption not configured', 'NOT_CONFIGURED');
|
|
2155
|
+
}
|
|
2156
|
+
const { newMasterKey } = await c.req.json();
|
|
2157
|
+
if (!newMasterKey || typeof newMasterKey !== 'string') {
|
|
2158
|
+
return jsonError(c, 400, 'Missing or invalid newMasterKey', 'VALIDATION_ERROR');
|
|
2159
|
+
}
|
|
2160
|
+
await encryptionManager.rotateKey(newMasterKey);
|
|
2161
|
+
return c.json({
|
|
2162
|
+
success: true,
|
|
2163
|
+
message: 'Encryption key rotated successfully',
|
|
2164
|
+
timestamp: Date.now(),
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
catch (error) {
|
|
2168
|
+
console.error('Key rotation error:', error);
|
|
2169
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to rotate key', 'API_ERROR');
|
|
2170
|
+
}
|
|
2171
|
+
});
|
|
2172
|
+
/**
|
|
2173
|
+
* WebSocket upgrade endpoint for real-time subscriptions
|
|
2174
|
+
* GET /realtime
|
|
2175
|
+
*/
|
|
2176
|
+
app.get('/realtime', async (c) => {
|
|
2177
|
+
if (!c.req.header('upgrade')?.toLowerCase().includes('websocket')) {
|
|
2178
|
+
return jsonError(c, 400, 'WebSocket upgrade required', 'INVALID_UPGRADE');
|
|
2179
|
+
}
|
|
2180
|
+
// Authenticate the connection
|
|
2181
|
+
const token = getBearerToken(c);
|
|
2182
|
+
if (!token) {
|
|
2183
|
+
return jsonError(c, 401, 'Unauthorized', 'UNAUTHORIZED');
|
|
2184
|
+
}
|
|
2185
|
+
const payload = parseJWT(token);
|
|
2186
|
+
if (!payload || payload.type !== 'access') {
|
|
2187
|
+
return jsonError(c, 401, 'Invalid token', 'UNAUTHORIZED');
|
|
2188
|
+
}
|
|
2189
|
+
const db = getDb(c);
|
|
2190
|
+
const userRow = await db
|
|
2191
|
+
.prepare('SELECT id, email, created_at, updated_at FROM users WHERE id = ?')
|
|
2192
|
+
.bind(payload.userId)
|
|
2193
|
+
.first();
|
|
2194
|
+
if (!userRow) {
|
|
2195
|
+
return jsonError(c, 401, 'User not found', 'UNAUTHORIZED');
|
|
2196
|
+
}
|
|
2197
|
+
const user = {
|
|
2198
|
+
id: userRow.id,
|
|
2199
|
+
email: userRow.email,
|
|
2200
|
+
createdAt: userRow.created_at,
|
|
2201
|
+
updatedAt: userRow.updated_at,
|
|
2202
|
+
};
|
|
2203
|
+
// Initialize managers if not done yet (fallback for non-auto-init)
|
|
2204
|
+
if (!subscriptionManager) {
|
|
2205
|
+
const syncDb = new D1SyncDatabase(db);
|
|
2206
|
+
subscriptionManager = new SubscriptionManager(syncDb);
|
|
2207
|
+
changeNotifier = new ChangeNotifier(subscriptionManager);
|
|
2208
|
+
await subscriptionManager.loadFromDatabase();
|
|
2209
|
+
}
|
|
2210
|
+
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
2211
|
+
try {
|
|
2212
|
+
// In Cloudflare Workers, WebSocketPair is available globally
|
|
2213
|
+
const upgradeHeader = c.req.header('upgrade');
|
|
2214
|
+
if (!upgradeHeader?.toLowerCase().includes('websocket')) {
|
|
2215
|
+
return jsonError(c, 400, 'WebSocket upgrade required', 'INVALID_UPGRADE');
|
|
2216
|
+
}
|
|
2217
|
+
// Create WebSocket pair (client/server)
|
|
2218
|
+
// @ts-ignore - WebSocketPair is available in Cloudflare Workers runtime
|
|
2219
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
2220
|
+
// Register the WebSocket client
|
|
2221
|
+
const wsClient = {
|
|
2222
|
+
send(message) {
|
|
2223
|
+
server.send(message);
|
|
2224
|
+
},
|
|
2225
|
+
close() {
|
|
2226
|
+
server.close();
|
|
2227
|
+
},
|
|
2228
|
+
isOpen() {
|
|
2229
|
+
return server.readyState === 1; // OPEN
|
|
2230
|
+
},
|
|
2231
|
+
};
|
|
2232
|
+
changeNotifier.registerClient(connectionId, wsClient);
|
|
2233
|
+
// Handle incoming messages using addEventListener (Cloudflare Workers API)
|
|
2234
|
+
server.addEventListener('message', async (event) => {
|
|
2235
|
+
try {
|
|
2236
|
+
const message = JSON.parse(event.data);
|
|
2237
|
+
switch (message.type) {
|
|
2238
|
+
case 'subscribe':
|
|
2239
|
+
await subscriptionManager.subscribe(user.id, connectionId, message.entity, message.filters);
|
|
2240
|
+
server.send(JSON.stringify({
|
|
2241
|
+
type: 'subscribed',
|
|
2242
|
+
subscriptionId: `${message.entity}:${connectionId}`,
|
|
2243
|
+
entity: message.entity,
|
|
2244
|
+
timestamp: Date.now(),
|
|
2245
|
+
}));
|
|
2246
|
+
break;
|
|
2247
|
+
case 'unsubscribe':
|
|
2248
|
+
const subs = subscriptionManager.getSubscriptionsForConnection(connectionId);
|
|
2249
|
+
for (const sub of subs) {
|
|
2250
|
+
if (sub.entity === message.entity && sub.userId === user.id) {
|
|
2251
|
+
await subscriptionManager.unsubscribe(sub.subscriptionId);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
server.send(JSON.stringify({
|
|
2255
|
+
type: 'unsubscribed',
|
|
2256
|
+
entity: message.entity,
|
|
2257
|
+
timestamp: Date.now(),
|
|
2258
|
+
}));
|
|
2259
|
+
break;
|
|
2260
|
+
case 'heartbeat':
|
|
2261
|
+
const userSubs = subscriptionManager.getSubscriptionsForConnection(connectionId);
|
|
2262
|
+
for (const sub of userSubs) {
|
|
2263
|
+
await subscriptionManager.updateHeartbeat(sub.subscriptionId);
|
|
2264
|
+
}
|
|
2265
|
+
break;
|
|
2266
|
+
default:
|
|
2267
|
+
server.send(JSON.stringify({
|
|
2268
|
+
type: 'error',
|
|
2269
|
+
code: 'UNKNOWN_MESSAGE_TYPE',
|
|
2270
|
+
message: `Unknown message type: ${message.type}`,
|
|
2271
|
+
}));
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
catch (error) {
|
|
2275
|
+
console.error('WebSocket message error:', error);
|
|
2276
|
+
server.send(JSON.stringify({
|
|
2277
|
+
type: 'error',
|
|
2278
|
+
code: 'MESSAGE_ERROR',
|
|
2279
|
+
message: 'Failed to process message',
|
|
2280
|
+
}));
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
// Handle disconnect using addEventListener
|
|
2284
|
+
server.addEventListener('close', async () => {
|
|
2285
|
+
console.log(`Client ${connectionId} disconnected`);
|
|
2286
|
+
await subscriptionManager.disconnectClient(connectionId);
|
|
2287
|
+
changeNotifier.unregisterClient(connectionId);
|
|
2288
|
+
});
|
|
2289
|
+
// Handle errors using addEventListener
|
|
2290
|
+
server.addEventListener('error', (error) => {
|
|
2291
|
+
console.error(`WebSocket error for client ${connectionId}:`, error);
|
|
2292
|
+
changeNotifier.unregisterClient(connectionId);
|
|
2293
|
+
});
|
|
2294
|
+
// Send initial greeting
|
|
2295
|
+
server.send(JSON.stringify({
|
|
2296
|
+
type: 'connected',
|
|
2297
|
+
connectionId,
|
|
2298
|
+
timestamp: Date.now(),
|
|
2299
|
+
}));
|
|
2300
|
+
return new Response(client, {
|
|
2301
|
+
status: 101,
|
|
2302
|
+
statusText: 'Switching Protocols',
|
|
2303
|
+
headers: {
|
|
2304
|
+
'Upgrade': 'websocket',
|
|
2305
|
+
'Connection': 'Upgrade',
|
|
2306
|
+
},
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
catch (error) {
|
|
2310
|
+
console.error('WebSocket connection error:', error);
|
|
2311
|
+
return jsonError(c, 500, 'Failed to establish WebSocket connection', 'CONNECTION_ERROR');
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
/**
|
|
2315
|
+
* Get real-time statistics
|
|
2316
|
+
* GET /realtime/stats
|
|
2317
|
+
*/
|
|
2318
|
+
app.get('/realtime/stats', async (c) => {
|
|
2319
|
+
const auth = await requireAccessUser(c);
|
|
2320
|
+
if (auth instanceof Response) {
|
|
2321
|
+
return auth;
|
|
2322
|
+
}
|
|
2323
|
+
if (!subscriptionManager || !changeNotifier) {
|
|
2324
|
+
return c.json({
|
|
2325
|
+
connectedClients: 0,
|
|
2326
|
+
subscriptions: { totalSubscriptions: 0, activeConnections: 0, entitiesWithSubscriptions: 0 },
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
return c.json({
|
|
2330
|
+
connectedClients: changeNotifier.getConnectedClientsCount(),
|
|
2331
|
+
subscriptions: subscriptionManager.getStats(),
|
|
2332
|
+
timestamp: Date.now(),
|
|
2333
|
+
});
|
|
2334
|
+
});
|
|
2335
|
+
/**
|
|
2336
|
+
* POST /transactions/begin
|
|
2337
|
+
* Begin a new transaction
|
|
2338
|
+
*/
|
|
2339
|
+
app.post('/transactions/begin', async (c) => {
|
|
2340
|
+
const auth = await requireAccessUser(c);
|
|
2341
|
+
if (auth instanceof Response) {
|
|
2342
|
+
return auth;
|
|
2343
|
+
}
|
|
2344
|
+
if (!transactionManager) {
|
|
2345
|
+
return jsonError(c, 503, 'Transaction manager not available', 'SERVICE_UNAVAILABLE');
|
|
2346
|
+
}
|
|
2347
|
+
try {
|
|
2348
|
+
const body = await c.req.json();
|
|
2349
|
+
const isolationLevel = body.isolationLevel || 'READ_COMMITTED';
|
|
2350
|
+
const transactionId = await transactionManager.begin(auth.user, isolationLevel);
|
|
2351
|
+
return c.json({
|
|
2352
|
+
transactionId,
|
|
2353
|
+
userId: auth.user.id,
|
|
2354
|
+
isolationLevel,
|
|
2355
|
+
createdAt: Date.now(),
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
catch (error) {
|
|
2359
|
+
console.error('Transaction begin error:', error);
|
|
2360
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to begin transaction', 'TRANSACTION_ERROR');
|
|
2361
|
+
}
|
|
2362
|
+
});
|
|
2363
|
+
/**
|
|
2364
|
+
* POST /transactions/:txnId/commit
|
|
2365
|
+
* Commit a transaction
|
|
2366
|
+
*/
|
|
2367
|
+
app.post('/transactions/:txnId/commit', async (c) => {
|
|
2368
|
+
const auth = await requireAccessUser(c);
|
|
2369
|
+
if (auth instanceof Response) {
|
|
2370
|
+
return auth;
|
|
2371
|
+
}
|
|
2372
|
+
if (!transactionManager) {
|
|
2373
|
+
return jsonError(c, 503, 'Transaction manager not available', 'SERVICE_UNAVAILABLE');
|
|
2374
|
+
}
|
|
2375
|
+
try {
|
|
2376
|
+
const transactionId = c.req.param('txnId');
|
|
2377
|
+
const transaction = transactionManager.getTransaction(transactionId);
|
|
2378
|
+
if (!transaction) {
|
|
2379
|
+
return jsonError(c, 404, 'Transaction not found', 'NOT_FOUND');
|
|
2380
|
+
}
|
|
2381
|
+
if (transaction.userId !== auth.user.id) {
|
|
2382
|
+
return jsonError(c, 403, 'Cannot commit transaction of another user', 'FORBIDDEN');
|
|
2383
|
+
}
|
|
2384
|
+
const result = await transactionManager.commit(transactionId);
|
|
2385
|
+
return c.json({
|
|
2386
|
+
transactionId,
|
|
2387
|
+
status: 'committed',
|
|
2388
|
+
appliedCount: result.appliedCount,
|
|
2389
|
+
errors: result.errors,
|
|
2390
|
+
timestamp: Date.now(),
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
catch (error) {
|
|
2394
|
+
console.error('Transaction commit error:', error);
|
|
2395
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to commit transaction', 'TRANSACTION_ERROR');
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
/**
|
|
2399
|
+
* POST /transactions/:txnId/rollback
|
|
2400
|
+
* Rollback a transaction
|
|
2401
|
+
*/
|
|
2402
|
+
app.post('/transactions/:txnId/rollback', async (c) => {
|
|
2403
|
+
const auth = await requireAccessUser(c);
|
|
2404
|
+
if (auth instanceof Response) {
|
|
2405
|
+
return auth;
|
|
2406
|
+
}
|
|
2407
|
+
if (!transactionManager) {
|
|
2408
|
+
return jsonError(c, 503, 'Transaction manager not available', 'SERVICE_UNAVAILABLE');
|
|
2409
|
+
}
|
|
2410
|
+
try {
|
|
2411
|
+
const transactionId = c.req.param('txnId');
|
|
2412
|
+
const transaction = transactionManager.getTransaction(transactionId);
|
|
2413
|
+
if (!transaction) {
|
|
2414
|
+
return jsonError(c, 404, 'Transaction not found', 'NOT_FOUND');
|
|
2415
|
+
}
|
|
2416
|
+
if (transaction.userId !== auth.user.id) {
|
|
2417
|
+
return jsonError(c, 403, 'Cannot rollback transaction of another user', 'FORBIDDEN');
|
|
2418
|
+
}
|
|
2419
|
+
await transactionManager.rollback(transactionId);
|
|
2420
|
+
return c.json({
|
|
2421
|
+
transactionId,
|
|
2422
|
+
status: 'rolled_back',
|
|
2423
|
+
timestamp: Date.now(),
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
catch (error) {
|
|
2427
|
+
console.error('Transaction rollback error:', error);
|
|
2428
|
+
return jsonError(c, 400, error instanceof Error ? error.message : 'Failed to rollback transaction', 'TRANSACTION_ERROR');
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
/**
|
|
2432
|
+
* GET /transactions/:txnId
|
|
2433
|
+
* Get transaction status
|
|
2434
|
+
*/
|
|
2435
|
+
app.get('/transactions/:txnId', async (c) => {
|
|
2436
|
+
const auth = await requireAccessUser(c);
|
|
2437
|
+
if (auth instanceof Response) {
|
|
2438
|
+
return auth;
|
|
2439
|
+
}
|
|
2440
|
+
if (!transactionManager) {
|
|
2441
|
+
return jsonError(c, 503, 'Transaction manager not available', 'SERVICE_UNAVAILABLE');
|
|
2442
|
+
}
|
|
2443
|
+
try {
|
|
2444
|
+
const transactionId = c.req.param('txnId');
|
|
2445
|
+
const transaction = transactionManager.getTransaction(transactionId);
|
|
2446
|
+
if (!transaction) {
|
|
2447
|
+
return jsonError(c, 404, 'Transaction not found', 'NOT_FOUND');
|
|
2448
|
+
}
|
|
2449
|
+
if (transaction.userId !== auth.user.id) {
|
|
2450
|
+
return jsonError(c, 403, 'Cannot access transaction of another user', 'FORBIDDEN');
|
|
2451
|
+
}
|
|
2452
|
+
return c.json({
|
|
2453
|
+
transactionId: transaction.id,
|
|
2454
|
+
status: transaction.status,
|
|
2455
|
+
isolationLevel: transaction.isolationLevel,
|
|
2456
|
+
changeCount: transaction.changes.length,
|
|
2457
|
+
createdAt: transaction.createdAt,
|
|
2458
|
+
expiresAt: transaction.expiresAt,
|
|
2459
|
+
completedAt: transaction.completedAt,
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
catch (error) {
|
|
2463
|
+
console.error('Transaction status error:', error);
|
|
2464
|
+
return jsonError(c, 500, 'Failed to get transaction status', 'TRANSACTION_ERROR');
|
|
2465
|
+
}
|
|
2466
|
+
});
|
|
2467
|
+
return app;
|
|
2468
|
+
}
|
|
2469
|
+
export default createEdgeBaseWorker;
|
|
2470
|
+
//# sourceMappingURL=index.js.map
|