@rebasepro/server-core 0.2.1 → 0.2.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.
Files changed (113) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +12 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/data/query_builder.d.ts +51 -0
  4. package/dist/common/src/index.d.ts +1 -0
  5. package/dist/common/src/util/permissions.d.ts +1 -0
  6. package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
  7. package/dist/index-Cr1D21av.js.map +1 -0
  8. package/dist/index.es.js +2340 -370
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +2329 -355
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/server-core/src/api/logs-routes.d.ts +37 -0
  13. package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
  14. package/dist/server-core/src/api/types.d.ts +6 -1
  15. package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
  16. package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
  17. package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
  18. package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
  19. package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
  20. package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
  21. package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
  22. package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
  23. package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
  24. package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
  25. package/dist/server-core/src/auth/index.d.ts +5 -3
  26. package/dist/server-core/src/auth/interfaces.d.ts +93 -3
  27. package/dist/server-core/src/auth/jwt.d.ts +3 -1
  28. package/dist/server-core/src/auth/mfa.d.ts +49 -0
  29. package/dist/server-core/src/auth/middleware.d.ts +7 -0
  30. package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
  31. package/dist/server-core/src/auth/routes.d.ts +3 -3
  32. package/dist/server-core/src/env.d.ts +6 -0
  33. package/dist/server-core/src/index.d.ts +1 -0
  34. package/dist/server-core/src/init.d.ts +3 -3
  35. package/dist/server-core/src/services/webhook-service.d.ts +29 -0
  36. package/dist/server-core/src/storage/image-transform.d.ts +48 -0
  37. package/dist/server-core/src/storage/index.d.ts +3 -0
  38. package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
  39. package/dist/types/src/controllers/auth.d.ts +2 -24
  40. package/dist/types/src/controllers/client.d.ts +0 -3
  41. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  42. package/dist/types/src/controllers/data.d.ts +21 -0
  43. package/dist/types/src/controllers/data_driver.d.ts +18 -0
  44. package/dist/types/src/controllers/registry.d.ts +5 -4
  45. package/dist/types/src/rebase_context.d.ts +1 -1
  46. package/dist/types/src/types/auth_adapter.d.ts +2 -4
  47. package/dist/types/src/types/collections.d.ts +0 -4
  48. package/dist/types/src/types/component_ref.d.ts +1 -1
  49. package/dist/types/src/types/cron.d.ts +1 -1
  50. package/dist/types/src/types/entity_views.d.ts +1 -0
  51. package/dist/types/src/types/export_import.d.ts +1 -1
  52. package/dist/types/src/types/formex.d.ts +2 -2
  53. package/dist/types/src/types/properties.d.ts +2 -2
  54. package/dist/types/src/types/translations.d.ts +28 -12
  55. package/dist/types/src/types/user_management_delegate.d.ts +6 -4
  56. package/dist/types/src/users/roles.d.ts +0 -8
  57. package/jest.config.cjs +4 -1
  58. package/package.json +9 -7
  59. package/src/api/ast-schema-editor.ts +4 -4
  60. package/src/api/errors.ts +16 -7
  61. package/src/api/logs-routes.ts +129 -0
  62. package/src/api/rest/api-generator.ts +42 -2
  63. package/src/api/rest/query-parser.ts +37 -1
  64. package/src/api/types.ts +6 -1
  65. package/src/auth/adapter-middleware.ts +20 -4
  66. package/src/auth/admin-routes.ts +39 -14
  67. package/src/auth/api-keys/api-key-middleware.ts +126 -0
  68. package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
  69. package/src/auth/api-keys/api-key-routes.ts +183 -0
  70. package/src/auth/api-keys/api-key-store.ts +317 -0
  71. package/src/auth/api-keys/api-key-types.ts +94 -0
  72. package/src/auth/api-keys/index.ts +37 -0
  73. package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
  74. package/src/auth/builtin-auth-adapter.ts +31 -19
  75. package/src/auth/index.ts +7 -3
  76. package/src/auth/interfaces.ts +111 -3
  77. package/src/auth/jwt.ts +19 -5
  78. package/src/auth/mfa.ts +160 -0
  79. package/src/auth/middleware.ts +20 -1
  80. package/src/auth/rate-limiter.ts +92 -0
  81. package/src/auth/routes.ts +455 -24
  82. package/src/cron/cron-loader.ts +5 -10
  83. package/src/cron/cron-scheduler.ts +11 -12
  84. package/src/cron/cron-store.ts +8 -7
  85. package/src/env.ts +2 -0
  86. package/src/functions/function-loader.ts +6 -9
  87. package/src/index.ts +1 -2
  88. package/src/init.ts +37 -7
  89. package/src/serve-spa.ts +5 -4
  90. package/src/services/webhook-service.ts +155 -0
  91. package/src/storage/image-transform.ts +202 -0
  92. package/src/storage/index.ts +3 -0
  93. package/src/storage/routes.ts +56 -3
  94. package/src/storage/tus-handler.ts +315 -0
  95. package/src/utils/dev-port.ts +14 -0
  96. package/src/utils/logging.ts +9 -7
  97. package/test/admin-routes.test.ts +74 -7
  98. package/test/api-generator.test.ts +0 -1
  99. package/test/api-key-permission-guard.test.ts +132 -0
  100. package/test/ast-schema-editor.test.ts +26 -0
  101. package/test/auth-routes.test.ts +1 -2
  102. package/test/backend-hooks-admin.test.ts +3 -4
  103. package/test/email-templates.test.ts +169 -0
  104. package/test/function-loader.test.ts +124 -0
  105. package/test/jwt.test.ts +4 -2
  106. package/test/mfa.test.ts +197 -0
  107. package/test/middleware.test.ts +10 -5
  108. package/test/webhook-service.test.ts +249 -0
  109. package/vite.config.ts +3 -2
  110. package/dist/index-BZoAtuqi.js.map +0 -1
  111. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  112. package/src/bootstrappers/index.ts +0 -1
  113. 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
- console.error(
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
- console.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
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
- console.warn("[cron] Failed to seed job stats from database:", err);
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
- console.log(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
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
- console.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
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
- console.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
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
- console.error(`[cron] Failed to schedule "${id}":`, err);
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
- console.error(`[cron] Failed to persist log for "${job.id}":`, persistErr);
546
+ logger.error(`[cron] Failed to persist log for "${job.id}"`, { error: persistErr });
548
547
  });
549
548
  }
550
549
 
551
550
  if (success) {
552
- console.log(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
551
+ logger.info(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
553
552
  } else {
554
- console.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
553
+ logger.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
555
554
  }
556
555
 
557
556
  return logEntry;
@@ -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
- console.warn("⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.");
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
- console.log("✅ Cron logs table ready");
72
+ logger.info("✅ Cron logs table ready");
72
73
  } catch (err) {
73
- console.error("❌ Failed to create cron logs table:", err);
74
- console.warn("⚠️ Continuing without cron log persistence.");
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
- console.error(`[cron-store] Failed to persist log for "${entry.jobId}":`, err);
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
- console.error(`[cron-store] Failed to fetch logs for "${jobId}":`, err);
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
- console.error("[cron-store] Failed to fetch job stats:", err);
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
- console.warn(
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
- console.log(`⚡ Loaded function route: ${name}`);
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
- console.log(`⚡ Loaded function route: ${name}`);
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
- console.warn(
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
- console.error(
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
@@ -65,6 +65,5 @@ export * from "./utils/dev-port";
65
65
  // Environment validation
66
66
  export { loadEnv } from "./env";
67
67
  export type { RebaseEnv } from "./env";
68
-
69
- // Backend bootstrappers (pluggable driver initialization)
68
+ export { z } from "zod";
70
69
 
package/src/init.ts CHANGED
@@ -18,12 +18,16 @@ 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";
22
26
  import { createHistoryRoutes } from "./history";
23
27
  import { EmailConfig, createEmailService } from "./email";
24
28
  import type { EmailService } from "./email";
25
29
  import type { OAuthProvider } from "./auth/interfaces";
26
- import type { AuthOverrides } from "./auth/auth-overrides";
30
+ import type { AuthHooks } from "./auth/auth-hooks";
27
31
  import { _initRebase } from "./singleton";
28
32
 
29
33
  export interface RebaseAuthConfig {
@@ -72,13 +76,13 @@ export interface RebaseAuthConfig {
72
76
  * ```ts
73
77
  * import bcrypt from "bcrypt";
74
78
  *
75
- * overrides: {
79
+ * hooks: {
76
80
  * hashPassword: (pw) => bcrypt.hash(pw, 12),
77
81
  * verifyPassword: (pw, hash) => bcrypt.compare(pw, hash),
78
82
  * }
79
83
  * ```
80
84
  */
81
- overrides?: AuthOverrides;
85
+ hooks?: AuthHooks;
82
86
  [key: string]: unknown;
83
87
  }
84
88
 
@@ -417,7 +421,7 @@ collectionRegistry });
417
421
  oauthProviders,
418
422
  serviceKey,
419
423
  hooks: config.hooks,
420
- overrides: safeAuthConfig.overrides,
424
+ authHooks: safeAuthConfig.hooks,
421
425
  });
422
426
  }
423
427
 
@@ -579,7 +583,7 @@ collectionRegistry });
579
583
  oauthProviders,
580
584
  serviceKey,
581
585
  hooks: config.hooks,
582
- overrides: safeAuthConfig.overrides,
586
+ authHooks: safeAuthConfig.hooks,
583
587
  });
584
588
  }
585
589
 
@@ -601,6 +605,23 @@ collectionRegistry });
601
605
  }
602
606
  }
603
607
 
608
+ // ─── API Key Store Bootstrap ──────────────────────────────────────────
609
+ let apiKeyStore: ApiKeyStore | undefined;
610
+ const apiKeyStoreResult = createApiKeyStore(defaultDriver);
611
+ if (apiKeyStoreResult) {
612
+ apiKeyStore = apiKeyStoreResult;
613
+ await apiKeyStore.ensureTable();
614
+ logger.info("Service API Keys initialized");
615
+
616
+ // Mount API key admin routes
617
+ const apiKeyRoutes = createApiKeyRoutes({
618
+ store: apiKeyStore,
619
+ serviceKey,
620
+ });
621
+ config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
622
+ logger.info("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
623
+ }
624
+
604
625
  if (config.collectionsDir) {
605
626
  if (process.env.NODE_ENV !== "production") {
606
627
  const { createSchemaEditorRoutes } = await import("./api/schema-editor-routes");
@@ -676,15 +697,22 @@ collectionRegistry });
676
697
  adapter: authAdapter,
677
698
  driver: defaultDriver,
678
699
  requireAuth: dataRequireAuth,
700
+ apiKeyStore,
679
701
  }));
680
702
  } else {
681
703
  dataRouter.use("/*", createAuthMiddleware({
682
704
  driver: defaultDriver,
683
705
  requireAuth: dataRequireAuth,
684
- serviceKey
706
+ serviceKey,
707
+ apiKeyStore,
685
708
  }));
686
709
  }
687
710
 
711
+ // Per-API-key rate limiting (no-op for non-API-key requests)
712
+ if (apiKeyStore) {
713
+ dataRouter.use("/*", createApiKeyRateLimiter());
714
+ }
715
+
688
716
  // Mount history routes BEFORE the REST API subcollection catch-all so
689
717
  // that /:slug/:entityId/history is matched by the dedicated handler first.
690
718
  if (historyConfigResult && historyConfigResult.historyService) {
@@ -800,12 +828,14 @@ collectionRegistry });
800
828
  adapter: authAdapter,
801
829
  driver: defaultDriver,
802
830
  requireAuth: functionsRequireAuth,
831
+ apiKeyStore,
803
832
  }));
804
833
  } else {
805
834
  functionsRouter.use("/*", createAuthMiddleware({
806
835
  driver: defaultDriver,
807
836
  requireAuth: functionsRequireAuth,
808
- serviceKey
837
+ serviceKey,
838
+ apiKeyStore,
809
839
  }));
810
840
  }
811
841
 
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
- console.warn(`⚠️ Frontend build path does not exist: ${frontendPath}`);
49
- console.warn(" SPA serving is disabled. Build your frontend first.");
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
- console.warn(`⚠️ Index file not found: ${indexPath}`);
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
- console.log(`✅ SPA serving enabled from: ${frontendPath}`);
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
+ }