@anteros/core 0.0.2 → 0.0.4

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