@rebasepro/server-core 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/common/src/collections/default-collections.d.ts +9 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/util/permissions.d.ts +1 -0
- package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
- package/dist/index-Cr1D21av.js.map +1 -0
- package/dist/index.es.js +2309 -370
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2298 -355
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/logs-routes.d.ts +37 -0
- package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
- package/dist/server-core/src/api/types.d.ts +6 -1
- package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
- package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
- package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
- package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
- package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
- package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
- package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
- package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
- package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
- package/dist/server-core/src/auth/index.d.ts +5 -3
- package/dist/server-core/src/auth/interfaces.d.ts +93 -3
- package/dist/server-core/src/auth/jwt.d.ts +3 -1
- package/dist/server-core/src/auth/mfa.d.ts +49 -0
- package/dist/server-core/src/auth/middleware.d.ts +7 -0
- package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
- package/dist/server-core/src/auth/routes.d.ts +3 -3
- package/dist/server-core/src/env.d.ts +6 -0
- package/dist/server-core/src/index.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +3 -3
- package/dist/server-core/src/services/webhook-service.d.ts +29 -0
- package/dist/server-core/src/storage/image-transform.d.ts +48 -0
- package/dist/server-core/src/storage/index.d.ts +3 -0
- package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
- package/dist/types/src/controllers/auth.d.ts +4 -26
- package/dist/types/src/controllers/client.d.ts +25 -43
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data.d.ts +4 -0
- package/dist/types/src/controllers/data_driver.d.ts +23 -0
- package/dist/types/src/controllers/registry.d.ts +5 -4
- package/dist/types/src/rebase_context.d.ts +1 -1
- package/dist/types/src/types/auth_adapter.d.ts +5 -60
- package/dist/types/src/types/backend.d.ts +2 -2
- package/dist/types/src/types/backend_hooks.d.ts +2 -17
- package/dist/types/src/types/collections.d.ts +0 -4
- package/dist/types/src/types/component_ref.d.ts +1 -1
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +1 -0
- package/dist/types/src/types/export_import.d.ts +1 -1
- package/dist/types/src/types/formex.d.ts +2 -2
- package/dist/types/src/types/properties.d.ts +9 -7
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +22 -57
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -1
- package/package.json +8 -6
- package/src/api/ast-schema-editor.ts +4 -4
- package/src/api/errors.ts +16 -7
- package/src/api/logs-routes.ts +129 -0
- package/src/api/rest/api-generator.ts +42 -2
- package/src/api/rest/query-parser.ts +37 -1
- package/src/api/types.ts +6 -1
- package/src/auth/adapter-middleware.ts +20 -4
- package/src/auth/admin-routes.ts +36 -100
- package/src/auth/api-keys/api-key-middleware.ts +126 -0
- package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
- package/src/auth/api-keys/api-key-routes.ts +183 -0
- package/src/auth/api-keys/api-key-store.ts +317 -0
- package/src/auth/api-keys/api-key-types.ts +94 -0
- package/src/auth/api-keys/index.ts +37 -0
- package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
- package/src/auth/builtin-auth-adapter.ts +35 -73
- package/src/auth/custom-auth-adapter.ts +1 -1
- package/src/auth/index.ts +7 -3
- package/src/auth/interfaces.ts +111 -3
- package/src/auth/jwt.ts +19 -5
- package/src/auth/mfa.ts +160 -0
- package/src/auth/middleware.ts +20 -1
- package/src/auth/rate-limiter.ts +92 -0
- package/src/auth/routes.ts +455 -24
- package/src/cron/cron-loader.ts +5 -10
- package/src/cron/cron-scheduler.ts +11 -12
- package/src/cron/cron-store.ts +8 -7
- package/src/env.ts +2 -0
- package/src/functions/function-loader.ts +6 -9
- package/src/index.ts +1 -2
- package/src/init.ts +56 -8
- package/src/serve-spa.ts +5 -4
- package/src/services/webhook-service.ts +155 -0
- package/src/storage/image-transform.ts +202 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/routes.ts +56 -3
- package/src/storage/tus-handler.ts +315 -0
- package/src/utils/dev-port.ts +14 -0
- package/src/utils/logging.ts +9 -7
- package/test/admin-routes.test.ts +62 -164
- package/test/api-generator.test.ts +0 -1
- package/test/api-key-permission-guard.test.ts +132 -0
- package/test/ast-schema-editor.test.ts +26 -0
- package/test/auth-routes.test.ts +1 -2
- package/test/backend-hooks-admin.test.ts +3 -29
- package/test/custom-auth-adapter.test.ts +2 -10
- package/test/email-templates.test.ts +169 -0
- package/test/function-loader.test.ts +124 -0
- package/test/jwt.test.ts +4 -2
- package/test/mfa.test.ts +197 -0
- package/test/middleware.test.ts +10 -5
- package/test/webhook-service.test.ts +249 -0
- package/vite.config.ts +3 -2
- package/dist/index-BZoAtuqi.js.map +0 -1
- package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
- package/dist/types/src/users/roles.d.ts +0 -22
- package/src/bootstrappers/index.ts +0 -1
- package/src/singleton.test.ts +0 -28
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import type { RebaseClient } from "@rebasepro/client";
|
|
9
9
|
import type { LoadedCronJob } from "./cron-loader";
|
|
10
10
|
import type { CronStore } from "./cron-store";
|
|
11
|
+
import { logger } from "../utils/logger.js";
|
|
11
12
|
|
|
12
13
|
// ─── Cron expression parser (minimal, no external dependency) ────────
|
|
13
14
|
// Supports standard 5-field cron (minute hour dom month dow).
|
|
@@ -207,15 +208,13 @@ export class CronScheduler {
|
|
|
207
208
|
// Validate schedule up-front — reject invalid schedules
|
|
208
209
|
const validation = validateCronExpression(loaded.definition.schedule);
|
|
209
210
|
if (!validation.valid) {
|
|
210
|
-
|
|
211
|
-
`[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`
|
|
212
|
-
);
|
|
211
|
+
logger.error(`[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`);
|
|
213
212
|
continue;
|
|
214
213
|
}
|
|
215
214
|
|
|
216
215
|
const existing = this.jobs.get(loaded.id);
|
|
217
216
|
if (existing) {
|
|
218
|
-
|
|
217
|
+
logger.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
|
|
219
218
|
this.stopJob(loaded.id);
|
|
220
219
|
}
|
|
221
220
|
|
|
@@ -260,7 +259,7 @@ export class CronScheduler {
|
|
|
260
259
|
}
|
|
261
260
|
}
|
|
262
261
|
}).catch((err) => {
|
|
263
|
-
|
|
262
|
+
logger.warn("[cron] Failed to seed job stats from database", { error: err });
|
|
264
263
|
});
|
|
265
264
|
}
|
|
266
265
|
|
|
@@ -269,7 +268,7 @@ export class CronScheduler {
|
|
|
269
268
|
this.scheduleNext(id);
|
|
270
269
|
}
|
|
271
270
|
}
|
|
272
|
-
|
|
271
|
+
logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
|
|
273
272
|
}
|
|
274
273
|
|
|
275
274
|
/**
|
|
@@ -356,7 +355,7 @@ export class CronScheduler {
|
|
|
356
355
|
|
|
357
356
|
// Concurrency guard — don't run two instances simultaneously
|
|
358
357
|
if (job.executing) {
|
|
359
|
-
|
|
358
|
+
logger.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
|
|
360
359
|
const logEntry: CronJobLogEntry = {
|
|
361
360
|
jobId: id,
|
|
362
361
|
startedAt: new Date().toISOString(),
|
|
@@ -411,7 +410,7 @@ export class CronScheduler {
|
|
|
411
410
|
|
|
412
411
|
// Concurrency guard: if somehow we're already executing, skip
|
|
413
412
|
if (job.executing) {
|
|
414
|
-
|
|
413
|
+
logger.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
|
|
415
414
|
// Re-schedule to try again later
|
|
416
415
|
this.scheduleNext(id);
|
|
417
416
|
return;
|
|
@@ -433,7 +432,7 @@ export class CronScheduler {
|
|
|
433
432
|
|
|
434
433
|
job.timerId = timer;
|
|
435
434
|
} catch (err: unknown) {
|
|
436
|
-
|
|
435
|
+
logger.error(`[cron] Failed to schedule "${id}"`, { error: err });
|
|
437
436
|
job.state = "error";
|
|
438
437
|
job.lastError = err instanceof Error ? err.message : String(err);
|
|
439
438
|
}
|
|
@@ -544,14 +543,14 @@ export class CronScheduler {
|
|
|
544
543
|
// Persist to database (non-blocking)
|
|
545
544
|
if (this.store) {
|
|
546
545
|
this.store.insertLog(logEntry).catch((persistErr) => {
|
|
547
|
-
|
|
546
|
+
logger.error(`[cron] Failed to persist log for "${job.id}"`, { error: persistErr });
|
|
548
547
|
});
|
|
549
548
|
}
|
|
550
549
|
|
|
551
550
|
if (success) {
|
|
552
|
-
|
|
551
|
+
logger.info(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
|
|
553
552
|
} else {
|
|
554
|
-
|
|
553
|
+
logger.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
|
|
555
554
|
}
|
|
556
555
|
|
|
557
556
|
return logEntry;
|
package/src/cron/cron-store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CronJobLogEntry } from "@rebasepro/types";
|
|
2
2
|
import type { DataDriver } from "@rebasepro/types";
|
|
3
3
|
import { isSQLAdmin } from "@rebasepro/types";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Persistence layer for cron job execution logs.
|
|
@@ -38,7 +39,7 @@ const TABLE = "rebase.cron_logs";
|
|
|
38
39
|
export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
39
40
|
const admin = driver.admin;
|
|
40
41
|
if (!isSQLAdmin(admin)) {
|
|
41
|
-
|
|
42
|
+
logger.warn("⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.");
|
|
42
43
|
return undefined;
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -68,10 +69,10 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
|
68
69
|
ON ${TABLE}(job_id, started_at DESC)
|
|
69
70
|
`);
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
logger.info("✅ Cron logs table ready");
|
|
72
73
|
} catch (err) {
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
logger.error("❌ Failed to create cron logs table", { error: err });
|
|
75
|
+
logger.warn("⚠️ Continuing without cron log persistence.");
|
|
75
76
|
}
|
|
76
77
|
},
|
|
77
78
|
|
|
@@ -97,7 +98,7 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
|
97
98
|
`);
|
|
98
99
|
} catch (err) {
|
|
99
100
|
// Non-blocking — log persistence should never crash the scheduler
|
|
100
|
-
|
|
101
|
+
logger.error(`[cron-store] Failed to persist log for "${entry.jobId}"`, { error: err });
|
|
101
102
|
}
|
|
102
103
|
},
|
|
103
104
|
|
|
@@ -113,7 +114,7 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
|
113
114
|
|
|
114
115
|
return rows.map(rowToLogEntry);
|
|
115
116
|
} catch (err) {
|
|
116
|
-
|
|
117
|
+
logger.error(`[cron-store] Failed to fetch logs for "${jobId}"`, { error: err });
|
|
117
118
|
return [];
|
|
118
119
|
}
|
|
119
120
|
},
|
|
@@ -139,7 +140,7 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
|
139
140
|
});
|
|
140
141
|
}
|
|
141
142
|
} catch (err) {
|
|
142
|
-
|
|
143
|
+
logger.error("[cron-store] Failed to fetch job stats", { error: err });
|
|
143
144
|
}
|
|
144
145
|
return stats;
|
|
145
146
|
}
|
package/src/env.ts
CHANGED
|
@@ -102,6 +102,8 @@ const rebaseEnvSchema = z.object({
|
|
|
102
102
|
DB_POOL_MAX: z.string().default("20").transform(Number),
|
|
103
103
|
DB_POOL_IDLE_TIMEOUT: z.string().default("30000").transform(Number),
|
|
104
104
|
DB_POOL_CONNECT_TIMEOUT: z.string().default("10000").transform(Number),
|
|
105
|
+
DATABASE_DIRECT_URL: z.string().url().optional(),
|
|
106
|
+
DATABASE_READ_URL: z.string().url().optional(),
|
|
105
107
|
FORCE_LOCAL_STORAGE: optionalBoolString,
|
|
106
108
|
STORAGE_TYPE: z.enum(["local", "s3"]).default("local"),
|
|
107
109
|
STORAGE_PATH: z.string().optional(),
|
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { pathToFileURL } from "url";
|
|
4
4
|
import { Hono } from "hono";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
5
6
|
|
|
6
7
|
export interface LoadedFunction {
|
|
7
8
|
/** Endpoint name derived from filename (e.g., "send-invoice") */
|
|
@@ -49,9 +50,7 @@ export async function loadFunctionsFromDirectory(
|
|
|
49
50
|
const exported = mod.default;
|
|
50
51
|
|
|
51
52
|
if (!exported) {
|
|
52
|
-
|
|
53
|
-
`[functions] ${file}: no default export. Skipping.`
|
|
54
|
-
);
|
|
53
|
+
logger.warn(`[functions] ${file}: no default export. Skipping.`);
|
|
55
54
|
continue;
|
|
56
55
|
}
|
|
57
56
|
|
|
@@ -61,7 +60,7 @@ export async function loadFunctionsFromDirectory(
|
|
|
61
60
|
const name = path.basename(file, path.extname(file));
|
|
62
61
|
functions.push({ name,
|
|
63
62
|
app: exported as Hono });
|
|
64
|
-
|
|
63
|
+
logger.info(`⚡ Loaded function route: ${name}`);
|
|
65
64
|
continue;
|
|
66
65
|
}
|
|
67
66
|
|
|
@@ -72,7 +71,7 @@ app: exported as Hono });
|
|
|
72
71
|
const name = path.basename(file, path.extname(file));
|
|
73
72
|
functions.push({ name,
|
|
74
73
|
app: result as Hono });
|
|
75
|
-
|
|
74
|
+
logger.info(`⚡ Loaded function route: ${name}`);
|
|
76
75
|
continue;
|
|
77
76
|
}
|
|
78
77
|
}
|
|
@@ -82,7 +81,7 @@ app: result as Hono });
|
|
|
82
81
|
const keys = exported && typeof exported === "object"
|
|
83
82
|
? Object.getOwnPropertyNames(Object.getPrototypeOf(exported)).slice(0, 10).join(", ")
|
|
84
83
|
: "N/A";
|
|
85
|
-
|
|
84
|
+
logger.warn(
|
|
86
85
|
`[functions] ${file}: default export is not a Hono app or factory. Skipping.\n` +
|
|
87
86
|
` export type: ${exportType}${exported?.constructor?.name ? ` (${exported.constructor.name})` : ""}\n` +
|
|
88
87
|
` prototype methods: ${keys}\n` +
|
|
@@ -92,9 +91,7 @@ app: result as Hono });
|
|
|
92
91
|
} catch (err: unknown) {
|
|
93
92
|
const message =
|
|
94
93
|
err instanceof Error ? err.message : String(err);
|
|
95
|
-
|
|
96
|
-
`[functions] Failed to load ${file}: ${message}`
|
|
97
|
-
);
|
|
94
|
+
logger.error(`[functions] Failed to load ${file}: ${message}`);
|
|
98
95
|
}
|
|
99
96
|
}
|
|
100
97
|
}
|
package/src/index.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -18,12 +18,17 @@ import { logger } from "./utils/logger";
|
|
|
18
18
|
import { requestLogger } from "./utils/request-logger";
|
|
19
19
|
import { createAdminRoutes, createAuthRoutes, requireAuth, requireAdmin, configureJwt } from "./auth";
|
|
20
20
|
import { createStorageController, createStorageRoutes, DEFAULT_STORAGE_ID, DefaultStorageRegistry, BackendStorageConfig, StorageController, StorageRegistry } from "./storage";
|
|
21
|
+
import { createApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
22
|
+
import { createApiKeyRoutes } from "./auth/api-keys/api-key-routes";
|
|
23
|
+
import type { ApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
24
|
+
import { createApiKeyRateLimiter } from "./auth/rate-limiter";
|
|
21
25
|
import { createRebaseClient } from "@rebasepro/client";
|
|
26
|
+
import { defaultUsersCollection } from "@rebasepro/common";
|
|
22
27
|
import { createHistoryRoutes } from "./history";
|
|
23
28
|
import { EmailConfig, createEmailService } from "./email";
|
|
24
29
|
import type { EmailService } from "./email";
|
|
25
30
|
import type { OAuthProvider } from "./auth/interfaces";
|
|
26
|
-
import type {
|
|
31
|
+
import type { AuthHooks } from "./auth/auth-hooks";
|
|
27
32
|
import { _initRebase } from "./singleton";
|
|
28
33
|
|
|
29
34
|
export interface RebaseAuthConfig {
|
|
@@ -72,13 +77,13 @@ export interface RebaseAuthConfig {
|
|
|
72
77
|
* ```ts
|
|
73
78
|
* import bcrypt from "bcrypt";
|
|
74
79
|
*
|
|
75
|
-
*
|
|
80
|
+
* hooks: {
|
|
76
81
|
* hashPassword: (pw) => bcrypt.hash(pw, 12),
|
|
77
82
|
* verifyPassword: (pw, hash) => bcrypt.compare(pw, hash),
|
|
78
83
|
* }
|
|
79
84
|
* ```
|
|
80
85
|
*/
|
|
81
|
-
|
|
86
|
+
hooks?: AuthHooks;
|
|
82
87
|
[key: string]: unknown;
|
|
83
88
|
}
|
|
84
89
|
|
|
@@ -288,6 +293,13 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
|
|
|
288
293
|
dir: config.collectionsDir });
|
|
289
294
|
}
|
|
290
295
|
|
|
296
|
+
if (config.auth) {
|
|
297
|
+
// Prepend defaultUsersCollection if not overridden by the developer's collections
|
|
298
|
+
activeCollections = Array.from(
|
|
299
|
+
new Map([defaultUsersCollection, ...activeCollections].map(c => [c.slug, c])).values()
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
291
303
|
const realtimeServices: Record<string, RealtimeProvider> = {};
|
|
292
304
|
const delegates: Record<string, DataDriver> = {};
|
|
293
305
|
|
|
@@ -374,7 +386,6 @@ collectionRegistry });
|
|
|
374
386
|
// (the return type still exposes `auth?: BootstrappedAuth`)
|
|
375
387
|
authConfigResult = {
|
|
376
388
|
userService: authAdapter.userManagement ?? {},
|
|
377
|
-
roleService: authAdapter.roleManagement ?? {},
|
|
378
389
|
};
|
|
379
390
|
} else {
|
|
380
391
|
// ── RebaseAuthConfig — wrap in built-in adapter ──
|
|
@@ -417,7 +428,7 @@ collectionRegistry });
|
|
|
417
428
|
oauthProviders,
|
|
418
429
|
serviceKey,
|
|
419
430
|
hooks: config.hooks,
|
|
420
|
-
|
|
431
|
+
authHooks: safeAuthConfig.hooks,
|
|
421
432
|
});
|
|
422
433
|
}
|
|
423
434
|
|
|
@@ -579,7 +590,7 @@ collectionRegistry });
|
|
|
579
590
|
oauthProviders,
|
|
580
591
|
serviceKey,
|
|
581
592
|
hooks: config.hooks,
|
|
582
|
-
|
|
593
|
+
authHooks: safeAuthConfig.hooks,
|
|
583
594
|
});
|
|
584
595
|
}
|
|
585
596
|
|
|
@@ -601,6 +612,23 @@ collectionRegistry });
|
|
|
601
612
|
}
|
|
602
613
|
}
|
|
603
614
|
|
|
615
|
+
// ─── API Key Store Bootstrap ──────────────────────────────────────────
|
|
616
|
+
let apiKeyStore: ApiKeyStore | undefined;
|
|
617
|
+
const apiKeyStoreResult = createApiKeyStore(defaultDriver);
|
|
618
|
+
if (apiKeyStoreResult) {
|
|
619
|
+
apiKeyStore = apiKeyStoreResult;
|
|
620
|
+
await apiKeyStore.ensureTable();
|
|
621
|
+
logger.info("Service API Keys initialized");
|
|
622
|
+
|
|
623
|
+
// Mount API key admin routes
|
|
624
|
+
const apiKeyRoutes = createApiKeyRoutes({
|
|
625
|
+
store: apiKeyStore,
|
|
626
|
+
serviceKey,
|
|
627
|
+
});
|
|
628
|
+
config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
|
|
629
|
+
logger.info("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
|
|
630
|
+
}
|
|
631
|
+
|
|
604
632
|
if (config.collectionsDir) {
|
|
605
633
|
if (process.env.NODE_ENV !== "production") {
|
|
606
634
|
const { createSchemaEditorRoutes } = await import("./api/schema-editor-routes");
|
|
@@ -676,15 +704,22 @@ collectionRegistry });
|
|
|
676
704
|
adapter: authAdapter,
|
|
677
705
|
driver: defaultDriver,
|
|
678
706
|
requireAuth: dataRequireAuth,
|
|
707
|
+
apiKeyStore,
|
|
679
708
|
}));
|
|
680
709
|
} else {
|
|
681
710
|
dataRouter.use("/*", createAuthMiddleware({
|
|
682
711
|
driver: defaultDriver,
|
|
683
712
|
requireAuth: dataRequireAuth,
|
|
684
|
-
serviceKey
|
|
713
|
+
serviceKey,
|
|
714
|
+
apiKeyStore,
|
|
685
715
|
}));
|
|
686
716
|
}
|
|
687
717
|
|
|
718
|
+
// Per-API-key rate limiting (no-op for non-API-key requests)
|
|
719
|
+
if (apiKeyStore) {
|
|
720
|
+
dataRouter.use("/*", createApiKeyRateLimiter());
|
|
721
|
+
}
|
|
722
|
+
|
|
688
723
|
// Mount history routes BEFORE the REST API subcollection catch-all so
|
|
689
724
|
// that /:slug/:entityId/history is matched by the dedicated handler first.
|
|
690
725
|
if (historyConfigResult && historyConfigResult.historyService) {
|
|
@@ -764,6 +799,17 @@ collectionRegistry });
|
|
|
764
799
|
logger.info("Email service attached to singleton", { configured: emailService.isConfigured() });
|
|
765
800
|
}
|
|
766
801
|
|
|
802
|
+
// Attach raw SQL capability when the driver supports it (Postgres, MySQL).
|
|
803
|
+
// Document databases (MongoDB, Firestore) won't have this.
|
|
804
|
+
const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
|
|
805
|
+
if (isSQLAdmin(driverAdmin)) {
|
|
806
|
+
Object.assign(serverClient, {
|
|
807
|
+
sql: (query: string, options?: { database?: string; role?: string }) =>
|
|
808
|
+
driverAdmin.executeSql(query, options)
|
|
809
|
+
});
|
|
810
|
+
logger.info("SQL capability attached to singleton");
|
|
811
|
+
}
|
|
812
|
+
|
|
767
813
|
_initRebase(serverClient);
|
|
768
814
|
logger.info("Rebase singleton initialized");
|
|
769
815
|
|
|
@@ -800,12 +846,14 @@ collectionRegistry });
|
|
|
800
846
|
adapter: authAdapter,
|
|
801
847
|
driver: defaultDriver,
|
|
802
848
|
requireAuth: functionsRequireAuth,
|
|
849
|
+
apiKeyStore,
|
|
803
850
|
}));
|
|
804
851
|
} else {
|
|
805
852
|
functionsRouter.use("/*", createAuthMiddleware({
|
|
806
853
|
driver: defaultDriver,
|
|
807
854
|
requireAuth: functionsRequireAuth,
|
|
808
|
-
serviceKey
|
|
855
|
+
serviceKey,
|
|
856
|
+
apiKeyStore,
|
|
809
857
|
}));
|
|
810
858
|
}
|
|
811
859
|
|
package/src/serve-spa.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as fs from "fs";
|
|
5
|
+
import { logger } from "./utils/logger.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Configuration for serving a Single Page Application
|
|
@@ -45,8 +46,8 @@ export function serveSPA<E extends import("hono").Env>(app: Hono<E>, config: Ser
|
|
|
45
46
|
|
|
46
47
|
// Validate frontend path exists
|
|
47
48
|
if (!fs.existsSync(frontendPath)) {
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
logger.warn(`⚠️ Frontend build path does not exist: ${frontendPath}`);
|
|
50
|
+
logger.warn(" SPA serving is disabled. Build your frontend first.");
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -68,7 +69,7 @@ export function serveSPA<E extends import("hono").Env>(app: Hono<E>, config: Ser
|
|
|
68
69
|
const indexPath = path.join(frontendPath, indexFile);
|
|
69
70
|
|
|
70
71
|
if (!fs.existsSync(indexPath)) {
|
|
71
|
-
|
|
72
|
+
logger.warn(`⚠️ Index file not found: ${indexPath}`);
|
|
72
73
|
return next();
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -76,6 +77,6 @@ export function serveSPA<E extends import("hono").Env>(app: Hono<E>, config: Ser
|
|
|
76
77
|
return c.html(html);
|
|
77
78
|
});
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
logger.info(`✅ SPA serving enabled from: ${frontendPath}`);
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "crypto";
|
|
2
|
+
|
|
3
|
+
export interface WebhookConfig {
|
|
4
|
+
id: string;
|
|
5
|
+
url: string;
|
|
6
|
+
secret?: string;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
events: string[];
|
|
9
|
+
table: string;
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WebhookDeliveryResult {
|
|
14
|
+
webhookId: string;
|
|
15
|
+
event: string;
|
|
16
|
+
payload: Record<string, unknown>;
|
|
17
|
+
statusCode: number;
|
|
18
|
+
responseBody: string;
|
|
19
|
+
success: boolean;
|
|
20
|
+
attemptNumber: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class WebhookDispatcher {
|
|
24
|
+
private webhooks: WebhookConfig[] = [];
|
|
25
|
+
private maxRetries = 3;
|
|
26
|
+
private retryDelays = [1000, 5000, 15000]; // Exponential backoff
|
|
27
|
+
|
|
28
|
+
/** Register webhooks to watch */
|
|
29
|
+
setWebhooks(webhooks: WebhookConfig[]): void {
|
|
30
|
+
this.webhooks = webhooks.filter(w => w.enabled);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Called when an entity changes — checks if any webhook matches */
|
|
34
|
+
async onEntityChange(
|
|
35
|
+
table: string,
|
|
36
|
+
event: "INSERT" | "UPDATE" | "DELETE",
|
|
37
|
+
entityId: string,
|
|
38
|
+
entity: Record<string, unknown> | null,
|
|
39
|
+
previousEntity?: Record<string, unknown> | null
|
|
40
|
+
): Promise<WebhookDeliveryResult[]> {
|
|
41
|
+
const matchingWebhooks = this.webhooks.filter(
|
|
42
|
+
w => w.table === table && w.events.includes(event)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (matchingWebhooks.length === 0) return [];
|
|
46
|
+
|
|
47
|
+
const results: WebhookDeliveryResult[] = [];
|
|
48
|
+
|
|
49
|
+
for (const webhook of matchingWebhooks) {
|
|
50
|
+
const payload: Record<string, unknown> = {
|
|
51
|
+
type: event,
|
|
52
|
+
table,
|
|
53
|
+
record: entity,
|
|
54
|
+
old_record: event === "UPDATE" ? previousEntity : undefined,
|
|
55
|
+
schema: "public",
|
|
56
|
+
timestamp: new Date().toISOString()
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await this.deliverWithRetry(webhook, event, payload);
|
|
60
|
+
results.push(result);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async deliverWithRetry(
|
|
67
|
+
webhook: WebhookConfig,
|
|
68
|
+
event: string,
|
|
69
|
+
payload: Record<string, unknown>
|
|
70
|
+
): Promise<WebhookDeliveryResult> {
|
|
71
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
72
|
+
const result = await this.deliver(webhook, event, payload, attempt);
|
|
73
|
+
if (result.success) return result;
|
|
74
|
+
|
|
75
|
+
if (attempt < this.maxRetries) {
|
|
76
|
+
await new Promise(r => setTimeout(r, this.retryDelays[attempt - 1]));
|
|
77
|
+
} else {
|
|
78
|
+
return result; // Final failure
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Should never reach here, but satisfies TypeScript
|
|
83
|
+
return {
|
|
84
|
+
webhookId: webhook.id,
|
|
85
|
+
event,
|
|
86
|
+
payload,
|
|
87
|
+
statusCode: 0,
|
|
88
|
+
responseBody: "Max retries exceeded",
|
|
89
|
+
success: false,
|
|
90
|
+
attemptNumber: this.maxRetries
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async deliver(
|
|
95
|
+
webhook: WebhookConfig,
|
|
96
|
+
event: string,
|
|
97
|
+
payload: Record<string, unknown>,
|
|
98
|
+
attemptNumber: number
|
|
99
|
+
): Promise<WebhookDeliveryResult> {
|
|
100
|
+
const body = JSON.stringify(payload);
|
|
101
|
+
|
|
102
|
+
const headers: Record<string, string> = {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"X-Webhook-Id": webhook.id,
|
|
105
|
+
"X-Webhook-Event": event,
|
|
106
|
+
"X-Webhook-Delivery": randomUUID(),
|
|
107
|
+
"X-Webhook-Attempt": String(attemptNumber),
|
|
108
|
+
...(webhook.headers || {})
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// HMAC signature
|
|
112
|
+
if (webhook.secret) {
|
|
113
|
+
const signature = createHmac("sha256", webhook.secret).update(body).digest("hex");
|
|
114
|
+
headers["X-Webhook-Signature"] = `sha256=${signature}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
|
120
|
+
|
|
121
|
+
const response = await fetch(webhook.url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers,
|
|
124
|
+
body,
|
|
125
|
+
signal: controller.signal
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
|
|
130
|
+
const responseBody = await response.text().catch(() => "");
|
|
131
|
+
const success = response.status >= 200 && response.status < 300;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
webhookId: webhook.id,
|
|
135
|
+
event,
|
|
136
|
+
payload,
|
|
137
|
+
statusCode: response.status,
|
|
138
|
+
responseBody: responseBody.slice(0, 1000), // Truncate
|
|
139
|
+
success,
|
|
140
|
+
attemptNumber
|
|
141
|
+
};
|
|
142
|
+
} catch (error: unknown) {
|
|
143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
return {
|
|
145
|
+
webhookId: webhook.id,
|
|
146
|
+
event,
|
|
147
|
+
payload,
|
|
148
|
+
statusCode: 0,
|
|
149
|
+
responseBody: message.slice(0, 1000),
|
|
150
|
+
success: false,
|
|
151
|
+
attemptNumber
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|