@anteros/core 0.0.1-alpha.1

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.
Files changed (55) hide show
  1. package/README.md +143 -0
  2. package/database/collection.ts +160 -0
  3. package/database/decorator.ts +172 -0
  4. package/database/file.ts +93 -0
  5. package/database/mongodbadapter.ts +1128 -0
  6. package/database/rest.ts +14 -0
  7. package/database/schema.ts +160 -0
  8. package/database/tenant.ts +37 -0
  9. package/database/workflow.ts +384 -0
  10. package/index.ts +28 -0
  11. package/lib/asyncContextStorage.ts +68 -0
  12. package/lib/define.ts +114 -0
  13. package/lib/error.ts +21 -0
  14. package/lib/files.ts +459 -0
  15. package/lib/middleware.ts +66 -0
  16. package/lib/routes.ts +44 -0
  17. package/lib/scripts.ts +47 -0
  18. package/lib/services.ts +45 -0
  19. package/lib/sockets.ts +44 -0
  20. package/lib/workflow.ts +60 -0
  21. package/package.json +31 -0
  22. package/server/api.ts +789 -0
  23. package/server/boot.ts +101 -0
  24. package/server/config.ts +107 -0
  25. package/server/env.ts +16 -0
  26. package/server/hono.ts +176 -0
  27. package/server/io.ts +15 -0
  28. package/server/routes.ts +48 -0
  29. package/server/security.ts +138 -0
  30. package/tests/api.test.ts +281 -0
  31. package/tsconfig.json +36 -0
  32. package/types/activity.d.ts +45 -0
  33. package/types/api.d.ts +85 -0
  34. package/types/collection.d.ts +82 -0
  35. package/types/config.d.ts +55 -0
  36. package/types/field.d.ts +72 -0
  37. package/types/file.d.ts +120 -0
  38. package/types/hook.d.ts +30 -0
  39. package/types/middleware.d.ts +18 -0
  40. package/types/mongo.d.ts +61 -0
  41. package/types/options.d.ts +7 -0
  42. package/types/rest.d.ts +18 -0
  43. package/types/route.d.ts +19 -0
  44. package/types/schema.d.ts +0 -0
  45. package/types/scripts.d.ts +10 -0
  46. package/types/service.d.ts +37 -0
  47. package/types/task.d.ts +12 -0
  48. package/types/tenant.d.ts +16 -0
  49. package/types/token.d.ts +14 -0
  50. package/types/websocket.d.ts +15 -0
  51. package/types/workflow.d.ts +91 -0
  52. package/utils/cache.ts +96 -0
  53. package/utils/crypto.ts +226 -0
  54. package/utils/func.ts +1037 -0
  55. package/utils/index.ts +17 -0
package/server/api.ts ADDED
@@ -0,0 +1,789 @@
1
+ import type { Hono } from "hono"
2
+ import { cfg, safePublicConfig } from "./config";
3
+ import type { ApiOptions } from "../types/api";
4
+ import { getCollection } from "../database/collection";
5
+ import { getFileCollection } from "../database/file";
6
+ import { AppError, fn } from "../lib/error";
7
+ import * as cookie from "hono/cookie"
8
+ import { useRest } from "../database/rest";
9
+ import * as func from "../utils/func";
10
+ import type { HonoVariables } from "./env";
11
+ import { io } from "./io";
12
+ import type { Service } from "../types/service";
13
+ import { requestCtxStorage } from "../lib/asyncContextStorage";
14
+ import { getTenantMiddlewares } from "../lib/middleware";
15
+ import type { ActivityInput } from "../types/activity";
16
+ import * as os from 'node:os';
17
+ import { basename } from 'node:path';
18
+ import { handleUpload, handleServe, handleDelete } from "../lib/files";
19
+ const API_PREFIX = '/api/:tenant_id/:collection/:action';
20
+ const SERVICE_PREFIX = '/services/:tenant_id/:service/:action';
21
+ const UPLOAD_PREFIX = '/upload/:tenant_id/:collection';
22
+ const FILES_PREFIX = '/files/:tenant_id/:collection/:file';
23
+
24
+
25
+ const ActionsValues = [
26
+ 'insertOne',
27
+ 'insertMany',
28
+ 'updateOne',
29
+ 'updateMany',
30
+ 'deleteOne',
31
+ 'deleteMany',
32
+ 'findOne',
33
+ 'find',
34
+ 'findOneAndUpdate',
35
+ 'runService',
36
+ 'upload',
37
+ 'auth',
38
+ 'login',
39
+ 'logout',
40
+ 'aggregate',
41
+ ] as const;
42
+
43
+
44
+
45
+ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
46
+
47
+ // Crud API
48
+
49
+ // tenant middlewares (scoped to their tenant only)
50
+ const tenantMiddlewares = getTenantMiddlewares();
51
+ for (const mw of tenantMiddlewares) {
52
+ app.use(async (c, next) => {
53
+ const url = new URL(c.req.url);
54
+ const segments = url.pathname.split('/').filter(Boolean);
55
+ const tenantInUrl = segments[1]; // /api/:tenant/... or /services/:tenant/... etc.
56
+
57
+ if (mw._tenant_ === tenantInUrl) {
58
+ return mw.handler(c, next);
59
+ }
60
+ return next();
61
+ });
62
+ }
63
+
64
+ async function logActivity({
65
+ rest, tenant_id, action, collection, collectionType,
66
+ status, input, result, error, duration, token, transaction = false
67
+ }: {
68
+ rest: InstanceType<typeof useRest>;
69
+ tenant_id: string;
70
+ action: string;
71
+ collection: string;
72
+ collectionType?: string;
73
+ status: string;
74
+ input?: any;
75
+ result?: any;
76
+ error?: { message: string; code: string } | null;
77
+ duration: number;
78
+ transaction?: boolean;
79
+ token?: { decoded: any; value: any; provided: boolean; expired: boolean };
80
+ }) {
81
+ const logTraceId = requestCtxStorage.get<{ id: string }>('trace')?.id ?? crypto.randomUUID();
82
+ const ctxMeta = requestCtxStorage.get<Record<string, any>>('meta');
83
+ const { request: logRequest, ...logMeta } = ctxMeta ?? {};
84
+
85
+ await rest.audit.addActivities([{
86
+ internal: false,
87
+ trace: { id: logTraceId },
88
+ request: logRequest,
89
+ meta: { ...logMeta, platform: logMeta?.platform ?? os.platform(), core_version: cfg.version ?? logMeta?.core_version },
90
+ operation: {
91
+ tenant: tenant_id,
92
+ action,
93
+ collection,
94
+ collectionType: collectionType || undefined,
95
+ status,
96
+ input: input ?? null,
97
+ result: result ?? null,
98
+ error: error ?? null,
99
+ duration,
100
+ transaction,
101
+ ...(token && { token }),
102
+ },
103
+ ts: new Date(),
104
+ }]);
105
+ }
106
+
107
+ app.post(API_PREFIX, async (c) => {
108
+ let isMultipartOrFormData = false;
109
+ let response: any;
110
+ let body: any;
111
+ let parseBody: any;
112
+ let rest: InstanceType<typeof useRest>;
113
+ let canAccessToAction: boolean | undefined;
114
+
115
+ try {
116
+ const ContentType = c.req.header('Content-Type');
117
+ const { action, collection, tenant_id } = c.req.param() as { action: typeof ActionsValues[number], collection: string, tenant_id: string };
118
+ const { cleanDeep, useCache } = c.req.query() as ApiOptions;
119
+
120
+
121
+
122
+ // Control request params
123
+ if (!tenant_id) {
124
+ throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
125
+ }
126
+
127
+ if (!collection) {
128
+ throw new AppError('Collection is required', { status: 400, code: 'COLLECTION_REQUIRED' });
129
+ }
130
+
131
+ if (!action) {
132
+ throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
133
+ }
134
+
135
+
136
+
137
+ // Content Type
138
+ if (ContentType?.includes('application/x-www-form-urlencoded') || ContentType?.includes('multipart/form-data')) {
139
+ isMultipartOrFormData = true;
140
+ try {
141
+ parseBody = await c.req.parseBody({ all: true });
142
+ } catch {
143
+ throw new AppError('Invalid form data', { status: 400, code: 'INVALID_FORM_DATA' });
144
+ }
145
+ }
146
+ if (ContentType?.includes('application/json')) {
147
+ isMultipartOrFormData = false;
148
+ try {
149
+ body = await c.req.json();
150
+ } catch {
151
+ throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
152
+ }
153
+ }
154
+
155
+
156
+
157
+ if (!cfg.tenants.find(t => t.id === tenant_id)) {
158
+ throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
159
+ }
160
+
161
+
162
+ // Collection
163
+ let col = getCollection(collection, tenant_id)
164
+ if (!col) {
165
+ throw new AppError('Collection `' + collection + '` not found', { status: 400, code: 'COLLECTION_NOT_FOUND' });
166
+ }
167
+
168
+ //console.log('Has action:', col?.actions?.hasOwnProperty(action))
169
+
170
+ if (!ActionsValues.includes(action as (typeof ActionsValues)[number]) && !Object.hasOwn(col?.actions ?? {}, action)) {
171
+ throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
172
+ }
173
+
174
+ const fieldOrder = ['_id', ...col.fields.map(f => f.name)];
175
+ const privateFields = col.api?.privateFields || [];
176
+
177
+
178
+ // Rest Instance
179
+ rest = new useRest({
180
+ internal: false,
181
+ tenant_id: tenant_id,
182
+ useHook: true,
183
+ })
184
+
185
+ // Logout Action
186
+ if (action == 'logout') {
187
+ const logStart = Date.now()
188
+ try {
189
+ if (!col?.api?.auth?.enabled) {
190
+ throw new AppError('Auth is not enabled', { status: 400, code: 'AUTH_NOT_ENABLED' });
191
+ }
192
+ if (!col?.api?.auth?.onLogout) {
193
+ throw new AppError('Auth onLogout is not defined', { status: 400, code: 'AUTH_HANDLER_NOT_DEFINED' });
194
+ }
195
+ await col.api?.auth?.onLogout({
196
+ rest: rest,
197
+ payload: body?.payload,
198
+ error: fn.error,
199
+ jwt: func.jwt,
200
+ req: {
201
+ cookies: {
202
+ delete: (name: string) => {
203
+ cookie.deleteCookie(c, name)
204
+ },
205
+ }
206
+ }
207
+ })
208
+
209
+ await logActivity({
210
+ rest, tenant_id, action: 'logout', collection,
211
+ status: 'success',
212
+ input: { payload: body?.payload },
213
+ duration: Date.now() - logStart,
214
+ })
215
+
216
+ return c.json({ message: 'Logout successful', ok: true })
217
+ } catch (err: any) {
218
+ await logActivity({
219
+ rest, tenant_id, action: 'logout', collection,
220
+ status: 'error',
221
+ input: { payload: body?.payload },
222
+ error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' },
223
+ duration: Date.now() - logStart,
224
+ }).catch(() => {})
225
+ throw err
226
+ }
227
+ }
228
+
229
+
230
+
231
+ // Login Action
232
+ if (action == 'login') {
233
+ const logStart = Date.now()
234
+ try {
235
+ if (!col?.api?.auth?.enabled) {
236
+ throw new AppError('Auth is not enabled', { status: 400, code: 'AUTH_NOT_ENABLED' });
237
+ }
238
+ if (!col?.api?.auth?.onLogin) {
239
+ throw new AppError('Auth onLogin is not defined', { status: 400, code: 'AUTH_HANDLER_NOT_DEFINED' });
240
+ }
241
+ const authResult = await col.api?.auth?.onLogin({
242
+ rest: rest,
243
+ payload: body?.payload,
244
+ error: fn.error,
245
+ jwt: func.jwt,
246
+ req: {
247
+ cookies: {
248
+ set: (name: string, value: string, options?: {
249
+ httpOnly?: boolean;
250
+ secure?: boolean;
251
+ maxAge?: number;
252
+ path?: string;
253
+ domain?: string;
254
+ sameSite?: 'lax' | 'strict' | 'none';
255
+ }) => {
256
+ cookie.setCookie(c, name, value, options)
257
+ },
258
+ get: (name: string) => {
259
+ return cookie.getCookie(c, name)
260
+ },
261
+ delete: (name: string) => {
262
+ cookie.deleteCookie(c, name)
263
+ },
264
+
265
+ }
266
+ }
267
+
268
+ })
269
+
270
+
271
+
272
+ if (!authResult || typeof authResult === 'function' || !('token' in authResult)) {
273
+ throw new AppError('Invalid login response', { status: 401, code: 'INVALID_LOGIN' });
274
+ }
275
+
276
+ if (!authResult.token) {
277
+ throw new AppError('Invalid token assigned', { status: 401, code: 'INVALID_TOKEN_ASSIGNED' });
278
+ }
279
+
280
+ const { value, error } = await func.jwt.verify(authResult.token)
281
+ if (error) {
282
+ throw new AppError('Invalid token', { status: 401, code: 'INVALID_TOKEN' });
283
+ }
284
+
285
+ await logActivity({
286
+ rest, tenant_id, action: 'login', collection,
287
+ status: 'success',
288
+ input: { payload: body?.payload },
289
+ result: { message: 'Login successful' },
290
+ duration: Date.now() - logStart,
291
+ token: { decoded: value, value: null, provided: true, expired: false },
292
+ })
293
+
294
+ return c.json({ token: authResult.token, data: authResult.data })
295
+ } catch (err: any) {
296
+ await logActivity({
297
+ rest, tenant_id, action: 'login', collection,
298
+ status: 'error',
299
+ input: { payload: body?.payload },
300
+ error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' },
301
+ duration: Date.now() - logStart,
302
+ }).catch(() => {})
303
+ throw err
304
+ }
305
+ }
306
+
307
+ // check access for sensitive actions
308
+ const t = c.get('token');
309
+ const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
310
+
311
+ // For all actions
312
+ if (Object.hasOwn(col?.api?.access ?? {}, '*') && !col?.api?.access?.[action]) {
313
+
314
+ if (typeof col?.api?.access?.['*'] === 'function') {
315
+ canAccessToAction = await col?.api?.access?.['*']({ rest: rest, error: fn.error, jwt: func.jwt, token: accessToken })
316
+ }
317
+ if (typeof col?.api?.access?.['*'] === 'boolean') {
318
+
319
+ canAccessToAction = col?.api?.access?.['*']
320
+ }
321
+ if (canAccessToAction !== true) {
322
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
323
+ }
324
+ }
325
+
326
+ // For specific actions
327
+ if (Object.hasOwn(col?.api?.access ?? {}, action) && col?.api?.access) {
328
+ if (typeof col?.api?.access?.[action] === 'function') {
329
+ canAccessToAction = await col?.api?.access?.[action]({ rest: rest, error: fn.error, jwt: func.jwt, token: accessToken })
330
+ } else {
331
+ canAccessToAction = col?.api?.access?.[action]
332
+ }
333
+ if (canAccessToAction !== true) {
334
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
335
+ }
336
+ }
337
+
338
+ if (canAccessToAction !== true) {
339
+ throw new AppError('Unauthorized', { status: 401, code: 'ACCESS_DENIED_TO_PERFORM_THIS_ACTION' });
340
+ }
341
+
342
+
343
+
344
+ if (Object.hasOwn(col?.actions ?? {}, action) && col?.actions?.[action]) {
345
+ response = await col.actions?.[action]({ rest: rest, data: body?.data, error: fn.error })
346
+ } else if (action == 'aggregate') {
347
+ /* Aggregate */
348
+ response = await rest.aggregate(collection, body?.pipeline || [])
349
+ } else if (action == 'find') {
350
+ /* Find */
351
+ response = await rest.find(collection, body?.params || {})
352
+ } else if (action == 'findOne') {
353
+ /* Find One */
354
+ response = await rest.findOne(collection, body?.id, body?.params || {})
355
+ } else if (action == 'insertOne') {
356
+ /* Insert One */
357
+ if (col.api?.readOnlyFields?.length) {
358
+ body.data = func.omit(body.data, col.api?.readOnlyFields)
359
+ }
360
+ response = await rest.insertOne(collection, body?.data)
361
+ } else if (action == 'insertMany') {
362
+ /* Insert Many */
363
+ if (col.api?.readOnlyFields?.length) {
364
+ body.data = func.omit(body.data, col.api?.readOnlyFields)
365
+ }
366
+ response = await rest.insertMany(collection, body?.data)
367
+ } else if (action == 'updateOne') {
368
+ /* Update One */
369
+ if (col.api?.readOnlyFields?.length) {
370
+ body.update = func.omit(body.update, col.api?.readOnlyFields)
371
+ }
372
+ response = await rest.updateOne(collection, body?.id || body?._id, body?.update || {})
373
+ } else if (action == 'updateMany') {
374
+ /* Update Many */
375
+ if (col.api?.readOnlyFields?.length) {
376
+ body.update = func.omit(body.update, col.api?.readOnlyFields)
377
+ }
378
+ response = await rest.updateMany(collection, body?.ids || body?._ids || [], body?.update || {})
379
+ } else if (action == 'findOneAndUpdate') {
380
+ /* Find One And Update */
381
+ if (col.api?.readOnlyFields?.length) {
382
+ body.update = func.omit(body.update, col.api?.readOnlyFields)
383
+ }
384
+ response = await rest.findOneAndUpdate(collection, body?.filter || {}, body?.update || {}, body?.options || {})
385
+ } else if (action == 'deleteOne') {
386
+ /* Delete One */
387
+ response = await rest.deleteOne(collection, body?.id || body?._id)
388
+ } else if (action == 'deleteMany') {
389
+ /* Delete Many */
390
+ response = await rest.deleteMany(collection, body?.ids || body?._ids || [])
391
+ }
392
+
393
+
394
+ /* Private Fields */
395
+ if (privateFields.length > 0) {
396
+ response = func.omit(response!, privateFields, fieldOrder)
397
+ }
398
+
399
+ return c.json(response)
400
+
401
+
402
+ } catch (err: any) {
403
+ console.error(err?.message || err)
404
+ const isAppError = err instanceof AppError;
405
+ const status = isAppError ? Number(err.status) : 500;
406
+ return c.json({
407
+ message: isAppError ? err.message : 'Internal server error',
408
+ code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
409
+ meta: isAppError ? err.meta : undefined,
410
+ }, status as any);
411
+ }
412
+
413
+ })
414
+
415
+
416
+ // Service API
417
+ app.post(SERVICE_PREFIX, async (c) => {
418
+ try {
419
+ const ContentType = c.req.header('Content-Type');
420
+ let isMultipartOrFormData = false;
421
+ let response: any;
422
+ let body: any;
423
+ let parseBody: any;
424
+ let rest: InstanceType<typeof useRest>;
425
+
426
+ // Content Type
427
+ if (ContentType?.includes('application/x-www-form-urlencoded') || ContentType?.includes('multipart/form-data')) {
428
+ isMultipartOrFormData = true;
429
+ try {
430
+ parseBody = await c.req.parseBody({ all: true });
431
+ } catch {
432
+ throw new AppError('Invalid form data', { status: 400, code: 'INVALID_FORM_DATA' });
433
+ }
434
+ }
435
+ if (ContentType?.includes('application/json')) {
436
+ isMultipartOrFormData = false;
437
+ try {
438
+ body = await c.req.json();
439
+ } catch {
440
+ throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
441
+ }
442
+ }
443
+
444
+ const { service, tenant_id } = c.req.param() as { service: string, tenant_id: string };
445
+ if (!tenant_id) {
446
+ throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
447
+ }
448
+ if (!service) {
449
+ throw new AppError('Service is required', { status: 400, code: 'SERVICE_REQUIRED' });
450
+ }
451
+
452
+ let serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === tenant_id) as Service;
453
+ const { action } = c.req.param() as { action: string };
454
+ if (!serviceInstance) {
455
+ throw new AppError('Service `' + service + '` not found', { status: 400, code: 'SERVICE_NOT_FOUND' });
456
+ }
457
+ if (!serviceInstance.enabled) {
458
+ throw new AppError('Service `' + service + '` is not enabled', { status: 400, code: 'SERVICE_NOT_ENABLED' });
459
+ }
460
+ if (!Object.hasOwn(serviceInstance.actions, action) || !serviceInstance.actions?.[action]) {
461
+ throw new AppError('Service `' + service + '` is not defined', { status: 400, code: 'SERVICE_NOT_DEFINED' });
462
+ }
463
+
464
+ // Token verification
465
+ const t = c.get('token');
466
+ const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
467
+
468
+ if (accessToken.expired) {
469
+ throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
470
+ }
471
+ if (!accessToken.value) {
472
+ throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
473
+ }
474
+
475
+ rest = new useRest({
476
+ internal: false,
477
+ tenant_id: tenant_id,
478
+ })
479
+
480
+ // Access control
481
+ let canAccessToService: boolean | undefined;
482
+
483
+ if (Object.hasOwn(serviceInstance?.api?.access ?? {}, '*') && !serviceInstance?.api?.access?.[action]) {
484
+ if (typeof serviceInstance?.api?.access?.['*'] === 'function') {
485
+ canAccessToService = await serviceInstance.api.access['*']({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
486
+ }
487
+ if (typeof serviceInstance?.api?.access?.['*'] === 'boolean') {
488
+ canAccessToService = serviceInstance.api.access['*'];
489
+ }
490
+ if (!canAccessToService) {
491
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
492
+ }
493
+ }
494
+
495
+ if (Object.hasOwn(serviceInstance?.api?.access ?? {}, action) && serviceInstance?.api?.access) {
496
+ if (typeof serviceInstance?.api?.access?.[action] === 'function') {
497
+ canAccessToService = await serviceInstance.api.access[action]({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
498
+ } else {
499
+ canAccessToService = serviceInstance.api.access[action];
500
+ }
501
+ if (!canAccessToService) {
502
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
503
+ }
504
+ }
505
+
506
+ if (canAccessToService !== true) {
507
+ throw new AppError('Unauthorized', { status: 401, code: 'ACCESS_DENIED_TO_PERFORM_THIS_ACTION' });
508
+ }
509
+
510
+ const logStart = Date.now()
511
+
512
+ try {
513
+ response = await serviceInstance.actions?.[action]({
514
+ data: body?.data,
515
+ error: fn.error,
516
+ io: io,
517
+ jwt: func.jwt,
518
+ token: accessToken,
519
+ rest,
520
+ })
521
+
522
+ await logActivity({
523
+ rest, tenant_id, action, collection: service,
524
+ status: 'success',
525
+ input: body?.data,
526
+ result: response,
527
+ duration: Date.now() - logStart,
528
+ token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
529
+ })
530
+
531
+ return c.json(response)
532
+ } catch (err: any) {
533
+ await logActivity({
534
+ rest, tenant_id, action, collection: service,
535
+ status: 'error',
536
+ input: body?.data,
537
+ error: { message: err?.message, code: err?.code || 'INTERNAL_SERVICE_ERROR' },
538
+ duration: Date.now() - logStart,
539
+ token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
540
+ }).catch(() => {})
541
+ throw err
542
+ }
543
+ } catch (err: any) {
544
+ console.error(err?.message || err)
545
+ const isAppError = err instanceof AppError;
546
+ const status = isAppError ? Number(err.status) : 500;
547
+ return c.json({
548
+ message: isAppError ? err.message : 'Internal server error',
549
+ code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
550
+ meta: isAppError ? err.meta : undefined,
551
+ }, status as any);
552
+ }
553
+ })
554
+
555
+
556
+ // Upload API
557
+ app.post(UPLOAD_PREFIX, async (c) => {
558
+ try {
559
+ const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
560
+
561
+ if (!cfg.tenants.find(t => t.id === tenant_id)) {
562
+ throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
563
+ }
564
+
565
+ // Check access
566
+ const colUpload = getFileCollection(collection, tenant_id);
567
+ if (!colUpload) {
568
+ throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
569
+ }
570
+ if (colUpload?.api?.access?.upload) {
571
+ const t = c.get('token');
572
+ const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
573
+
574
+ if (accessToken.expired) {
575
+ throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
576
+ }
577
+ if (!accessToken.value) {
578
+ throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
579
+ }
580
+
581
+ const accessCheck = colUpload.api.access.upload;
582
+ let allowed: boolean;
583
+ if (typeof accessCheck === 'function') {
584
+ const rest = new useRest({ internal: false, tenant_id });
585
+ allowed = await accessCheck({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
586
+ } else {
587
+ allowed = accessCheck;
588
+ }
589
+ if (!allowed) {
590
+ throw new AppError('Upload not allowed', { status: 401, code: 'ACCESS_DENIED' });
591
+ }
592
+ }
593
+
594
+ const contentType = c.req.header('Content-Type') || '';
595
+ if (!contentType.includes('multipart/form-data')) {
596
+ throw new AppError('Content-Type must be multipart/form-data', {
597
+ code: 'INVALID_CONTENT_TYPE', status: 400,
598
+ });
599
+ }
600
+
601
+ const formData = await c.req.parseBody();
602
+ const fileField = formData['file'] || formData['upload'];
603
+ if (!fileField || !(fileField instanceof File)) {
604
+ throw new AppError('No file provided. Use field name "file" or "upload".', {
605
+ code: 'FILE_REQUIRED', status: 400,
606
+ });
607
+ }
608
+
609
+ // Extract custom fields from form data (exclude file keys)
610
+ const data: Record<string, any> = {};
611
+ for (const [key, value] of Object.entries(formData)) {
612
+ if (key !== 'file' && key !== 'upload' && !(value instanceof File)) {
613
+ data[key] = value;
614
+ }
615
+ }
616
+
617
+ const result = await handleUpload({
618
+ collection,
619
+ tenant_id,
620
+ file: fileField,
621
+ data,
622
+ });
623
+
624
+ return c.json(result);
625
+ } catch (err: any) {
626
+ const isAppError = err instanceof AppError;
627
+ const status = isAppError ? Number(err.status) : 500;
628
+ return c.json({
629
+ message: isAppError ? err.message : 'Internal server error',
630
+ code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
631
+ }, status as any);
632
+ }
633
+ })
634
+
635
+ // Serve files
636
+ app.get(FILES_PREFIX, async (c) => {
637
+ try {
638
+ const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
639
+
640
+ if (!cfg.tenants.find(t => t.id === tenant_id)) {
641
+ throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
642
+ }
643
+
644
+ // Check access
645
+ const colServe = getFileCollection(collection, tenant_id);
646
+ if (!colServe) {
647
+ throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
648
+ }
649
+ if (colServe?.api?.access?.read) {
650
+ const t = c.get('token');
651
+ const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
652
+
653
+ if (accessToken.expired) {
654
+ throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
655
+ }
656
+ if (!accessToken.value) {
657
+ throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
658
+ }
659
+
660
+ const accessCheck = colServe.api.access.read;
661
+ let allowed: boolean;
662
+ if (typeof accessCheck === 'function') {
663
+ const rest = new useRest({ internal: false, tenant_id });
664
+ allowed = await accessCheck({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
665
+ } else {
666
+ allowed = accessCheck;
667
+ }
668
+ if (!allowed) {
669
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
670
+ }
671
+ }
672
+
673
+ const filename = basename(c.req.param('file') as string);
674
+ if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
675
+ throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
676
+ }
677
+ const fileId = filename.replace(/\.[^.]+$/, ''); // remove extension to get the ID
678
+
679
+ // Parse optional transformation query params
680
+ const query = c.req.query();
681
+ const transform = query.w || query.width || query.h || query.height || query.format
682
+ ? {
683
+ width: query.w ? Number(query.w) : query.width ? Number(query.width) : undefined,
684
+ height: query.h ? Number(query.h) : query.height ? Number(query.height) : undefined,
685
+ format: (query.format as 'webp' | 'jpeg' | 'png' | 'avif') || undefined,
686
+ quality: query.q ? Number(query.q) : query.quality ? Number(query.quality) : undefined,
687
+ }
688
+ : undefined;
689
+
690
+ const result = await handleServe(tenant_id, collection, fileId, filename, transform);
691
+ if (!result) {
692
+ throw new AppError('File not found', { code: 'FILE_NOT_FOUND', status: 404 });
693
+ }
694
+
695
+ c.header('Content-Type', result.mimetype);
696
+ if (result.size) c.header('Content-Length', String(result.size));
697
+ c.header('Cache-Control', 'public, max-age=31536000, immutable');
698
+
699
+ return c.newResponse(result.stream);
700
+ } catch (err: any) {
701
+ const isAppError = err instanceof AppError;
702
+ const status = isAppError ? Number(err.status) : 500;
703
+ return c.json({
704
+ message: isAppError ? err.message : 'Internal server error',
705
+ code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
706
+ }, status as any);
707
+ }
708
+ })
709
+
710
+ // Delete file
711
+ app.delete(FILES_PREFIX, async (c) => {
712
+ try {
713
+ const { tenant_id, collection } = c.req.param() as { tenant_id: string; collection: string };
714
+
715
+ if (!cfg.tenants.find(t => t.id === tenant_id)) {
716
+ throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
717
+ }
718
+
719
+ // Check access
720
+ const colDelete = getFileCollection(collection, tenant_id);
721
+ if (!colDelete) {
722
+ throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
723
+ }
724
+ if (colDelete?.api?.access?.delete) {
725
+ const t = c.get('token');
726
+ const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
727
+
728
+ if (accessToken.expired) {
729
+ throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
730
+ }
731
+ if (!accessToken.value) {
732
+ throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
733
+ }
734
+
735
+ const accessCheck = colDelete.api.access.delete;
736
+ let allowed: boolean;
737
+ if (typeof accessCheck === 'function') {
738
+ const rest = new useRest({ internal: false, tenant_id });
739
+ allowed = await accessCheck({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
740
+ } else {
741
+ allowed = accessCheck;
742
+ }
743
+ if (!allowed) {
744
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
745
+ }
746
+ }
747
+
748
+ const filename = basename(c.req.param('file') as string);
749
+ if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
750
+ throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
751
+ }
752
+ const fileId = filename.replace(/\.[^.]+$/, '');
753
+
754
+ await handleDelete(tenant_id, collection, fileId, filename);
755
+
756
+ return c.json({ message: 'File deleted', ok: true });
757
+ } catch (err: any) {
758
+ const isAppError = err instanceof AppError;
759
+ const status = isAppError ? Number(err.status) : 500;
760
+ return c.json({
761
+ message: isAppError ? err.message : 'Internal server error',
762
+ code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
763
+ }, status as any);
764
+ }
765
+ })
766
+
767
+ // Public config endpoint — filtered by tenant (auth required)
768
+ app.get('/_dnax/config/:tenant_id', (c) => {
769
+ const t = c.get('token');
770
+ if (!t?.value) {
771
+ return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
772
+ }
773
+ const { tenant_id } = c.req.param() as { tenant_id: string };
774
+ const config = safePublicConfig();
775
+
776
+ return c.json({
777
+ tenants: config?.tenants?.filter(t => t.id === tenant_id),
778
+ collections: config?.collections?.filter(c => c._tenant_ === tenant_id),
779
+ services: config?.services?.filter(s => s._tenant_ === tenant_id),
780
+ fileCollections: config?.fileCollections?.filter(fc => fc._tenant_ === tenant_id),
781
+ });
782
+ })
783
+
784
+ }
785
+
786
+
787
+ export {
788
+ initializeApi
789
+ }