@anteros/core 0.0.2-alpha.5 → 0.0.2-alpha.6

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