@rebasepro/server-core 0.0.1-canary.eae7889 → 0.1.0

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 (132) hide show
  1. package/app/frontend/node_modules/esbuild/LICENSE.md +21 -0
  2. package/app/frontend/node_modules/esbuild/README.md +3 -0
  3. package/app/frontend/node_modules/esbuild/bin/esbuild +220 -0
  4. package/app/frontend/node_modules/esbuild/install.js +285 -0
  5. package/app/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  6. package/app/frontend/node_modules/esbuild/lib/main.js +2239 -0
  7. package/app/frontend/node_modules/esbuild/package.json +46 -0
  8. package/dist/index.es.js +1186 -1673
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +1185 -1672
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/server-core/src/api/rest/api-generator.d.ts +15 -3
  13. package/dist/server-core/src/auth/admin-routes.d.ts +5 -0
  14. package/dist/server-core/src/auth/google-oauth.d.ts +36 -3
  15. package/dist/server-core/src/auth/index.d.ts +1 -0
  16. package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
  17. package/dist/server-core/src/cron/index.d.ts +1 -1
  18. package/dist/server-core/src/init.d.ts +11 -1
  19. package/dist/types/src/controllers/auth.d.ts +8 -2
  20. package/dist/types/src/controllers/client.d.ts +13 -0
  21. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  22. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  23. package/dist/types/src/controllers/navigation.d.ts +18 -6
  24. package/dist/types/src/controllers/registry.d.ts +9 -1
  25. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  26. package/dist/types/src/rebase_context.d.ts +17 -0
  27. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  28. package/dist/types/src/types/collections.d.ts +31 -11
  29. package/dist/types/src/types/component_ref.d.ts +47 -0
  30. package/dist/types/src/types/cron.d.ts +1 -1
  31. package/dist/types/src/types/entity_views.d.ts +6 -7
  32. package/dist/types/src/types/formex.d.ts +40 -0
  33. package/dist/types/src/types/index.d.ts +3 -0
  34. package/dist/types/src/types/plugins.d.ts +6 -3
  35. package/dist/types/src/types/properties.d.ts +72 -88
  36. package/dist/types/src/types/slots.d.ts +20 -10
  37. package/dist/types/src/types/translations.d.ts +6 -0
  38. package/examples/firebase/node_modules/esbuild/LICENSE.md +21 -0
  39. package/examples/firebase/node_modules/esbuild/README.md +3 -0
  40. package/examples/firebase/node_modules/esbuild/bin/esbuild +220 -0
  41. package/examples/firebase/node_modules/esbuild/install.js +285 -0
  42. package/examples/firebase/node_modules/esbuild/lib/main.d.ts +705 -0
  43. package/examples/firebase/node_modules/esbuild/lib/main.js +2239 -0
  44. package/examples/firebase/node_modules/esbuild/package.json +46 -0
  45. package/examples/medmot-staging/frontend/node_modules/esbuild/LICENSE.md +21 -0
  46. package/examples/medmot-staging/frontend/node_modules/esbuild/README.md +3 -0
  47. package/examples/medmot-staging/frontend/node_modules/esbuild/bin/esbuild +220 -0
  48. package/examples/medmot-staging/frontend/node_modules/esbuild/install.js +285 -0
  49. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  50. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.js +2239 -0
  51. package/examples/medmot-staging/frontend/node_modules/esbuild/package.json +46 -0
  52. package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
  53. package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
  54. package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
  55. package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
  56. package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
  57. package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
  58. package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
  59. package/package.json +9 -9
  60. package/packages/client/node_modules/esbuild/LICENSE.md +21 -0
  61. package/packages/client/node_modules/esbuild/README.md +3 -0
  62. package/packages/client/node_modules/esbuild/bin/esbuild +220 -0
  63. package/packages/client/node_modules/esbuild/install.js +285 -0
  64. package/packages/client/node_modules/esbuild/lib/main.d.ts +705 -0
  65. package/packages/client/node_modules/esbuild/lib/main.js +2239 -0
  66. package/packages/client/node_modules/esbuild/package.json +46 -0
  67. package/packages/client-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  68. package/packages/client-postgresql/node_modules/esbuild/README.md +3 -0
  69. package/packages/client-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  70. package/packages/client-postgresql/node_modules/esbuild/install.js +285 -0
  71. package/packages/client-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  72. package/packages/client-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  73. package/packages/client-postgresql/node_modules/esbuild/package.json +46 -0
  74. package/packages/common/node_modules/esbuild/LICENSE.md +21 -0
  75. package/packages/common/node_modules/esbuild/README.md +3 -0
  76. package/packages/common/node_modules/esbuild/bin/esbuild +220 -0
  77. package/packages/common/node_modules/esbuild/install.js +285 -0
  78. package/packages/common/node_modules/esbuild/lib/main.d.ts +705 -0
  79. package/packages/common/node_modules/esbuild/lib/main.js +2239 -0
  80. package/packages/common/node_modules/esbuild/package.json +46 -0
  81. package/packages/server-mongodb/node_modules/esbuild/LICENSE.md +21 -0
  82. package/packages/server-mongodb/node_modules/esbuild/README.md +3 -0
  83. package/packages/server-mongodb/node_modules/esbuild/bin/esbuild +220 -0
  84. package/packages/server-mongodb/node_modules/esbuild/install.js +285 -0
  85. package/packages/server-mongodb/node_modules/esbuild/lib/main.d.ts +705 -0
  86. package/packages/server-mongodb/node_modules/esbuild/lib/main.js +2239 -0
  87. package/packages/server-mongodb/node_modules/esbuild/package.json +46 -0
  88. package/packages/server-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  89. package/packages/server-postgresql/node_modules/esbuild/README.md +3 -0
  90. package/packages/server-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  91. package/packages/server-postgresql/node_modules/esbuild/install.js +285 -0
  92. package/packages/server-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  93. package/packages/server-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  94. package/packages/server-postgresql/node_modules/esbuild/package.json +46 -0
  95. package/packages/types/node_modules/esbuild/LICENSE.md +21 -0
  96. package/packages/types/node_modules/esbuild/README.md +3 -0
  97. package/packages/types/node_modules/esbuild/bin/esbuild +220 -0
  98. package/packages/types/node_modules/esbuild/install.js +285 -0
  99. package/packages/types/node_modules/esbuild/lib/main.d.ts +705 -0
  100. package/packages/types/node_modules/esbuild/lib/main.js +2239 -0
  101. package/packages/types/node_modules/esbuild/package.json +46 -0
  102. package/packages/utils/node_modules/esbuild/LICENSE.md +21 -0
  103. package/packages/utils/node_modules/esbuild/README.md +3 -0
  104. package/packages/utils/node_modules/esbuild/bin/esbuild +220 -0
  105. package/packages/utils/node_modules/esbuild/install.js +285 -0
  106. package/packages/utils/node_modules/esbuild/lib/main.d.ts +705 -0
  107. package/packages/utils/node_modules/esbuild/lib/main.js +2239 -0
  108. package/packages/utils/node_modules/esbuild/package.json +46 -0
  109. package/src/api/errors.ts +3 -2
  110. package/src/api/rest/api-generator-count.test.ts +113 -0
  111. package/src/api/rest/api-generator.ts +123 -22
  112. package/src/api/server.ts +8 -4
  113. package/src/auth/admin-routes.ts +133 -57
  114. package/src/auth/apple-oauth.ts +8 -18
  115. package/src/auth/google-oauth.ts +192 -22
  116. package/src/auth/index.ts +1 -0
  117. package/src/auth/rate-limiter.ts +9 -5
  118. package/src/auth/routes.ts +25 -5
  119. package/src/collections/loader.ts +3 -3
  120. package/src/cron/cron-scheduler.test.ts +301 -175
  121. package/src/cron/cron-scheduler.ts +220 -57
  122. package/src/cron/index.ts +1 -1
  123. package/src/init.ts +27 -5
  124. package/src/storage/LocalStorageController.ts +37 -13
  125. package/src/storage/S3StorageController.ts +4 -1
  126. package/src/storage/routes.ts +51 -5
  127. package/test/backend-hooks-admin.test.ts +394 -0
  128. package/test/backend-hooks-data.test.ts +408 -0
  129. package/history_diff.log +0 -385
  130. package/scratch.ts +0 -9
  131. package/test-ast.ts +0 -28
  132. package/test_output.txt +0 -1133
@@ -9,23 +9,95 @@ import type { RebaseClient } from "@rebasepro/client";
9
9
  import type { LoadedCronJob } from "./cron-loader";
10
10
  import type { CronStore } from "./cron-store";
11
11
 
12
+ // ─── Cron expression parser (minimal, no external dependency) ────────
13
+ // Supports standard 5-field cron (minute hour dom month dow).
14
+ // Returns the next Date after `after` that matches the expression.
15
+
12
16
  /**
13
- * Validates a standard cron expression.
17
+ * Expand a single cron field into an ordered array of allowed values.
18
+ * Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*​/S`, and comma-separated combinations.
14
19
  */
15
- function isValidCronExpression(schedule: string): boolean {
16
- if (!schedule) return false;
17
- const parts = schedule.trim().split(/\s+/);
18
- // Typical cron has 5 fields.
19
- return parts.length === 5 && parts.every(p => p.length > 0);
20
+ function expandCronField(field: string, min: number, max: number): number[] {
21
+ const results = new Set<number>();
22
+ for (const segment of field.split(",")) {
23
+ const trimmed = segment.trim();
24
+ if (trimmed === "*") {
25
+ for (let i = min; i <= max; i++) results.add(i);
26
+ } else if (trimmed.includes("/")) {
27
+ const [rangeStr, stepStr] = trimmed.split("/");
28
+ const step = parseInt(stepStr, 10);
29
+ if (isNaN(step) || step <= 0) {
30
+ throw new Error(`Invalid step value "${stepStr}" in cron field "${field}"`);
31
+ }
32
+ let start = min;
33
+ let end = max;
34
+ if (rangeStr !== "*") {
35
+ if (rangeStr.includes("-")) {
36
+ const [a, b] = rangeStr.split("-").map(Number);
37
+ start = a;
38
+ end = b;
39
+ } else {
40
+ start = parseInt(rangeStr, 10);
41
+ }
42
+ }
43
+ for (let i = start; i <= end; i += step) results.add(i);
44
+ } else if (trimmed.includes("-")) {
45
+ const [a, b] = trimmed.split("-").map(Number);
46
+ for (let i = a; i <= b; i++) results.add(i);
47
+ } else {
48
+ const val = parseInt(trimmed, 10);
49
+ if (isNaN(val)) {
50
+ throw new Error(`Invalid value "${trimmed}" in cron field "${field}"`);
51
+ }
52
+ results.add(val);
53
+ }
54
+ }
55
+ return [...results].sort((a, b) => a - b);
20
56
  }
21
57
 
22
- // ─── Cron expression parser (minimal, no external dependency) ────────
23
- // Supports standard 5-field cron (minute hour dom month dow).
24
- // Returns the next Date after `after` that matches the expression.
58
+ /**
59
+ * Validates a standard 5-field cron expression structurally and semantically.
60
+ * Returns `{ valid: true }` or `{ valid: false, reason: string }`.
61
+ */
62
+ export function validateCronExpression(schedule: string): { valid: true } | { valid: false; reason: string } {
63
+ if (!schedule || typeof schedule !== "string") {
64
+ return { valid: false, reason: "Schedule must be a non-empty string" };
65
+ }
66
+ const parts = schedule.trim().split(/\s+/);
67
+ if (parts.length !== 5) {
68
+ return { valid: false, reason: `Expected 5 fields, got ${parts.length}` };
69
+ }
70
+ const fieldRanges: [string, number, number][] = [
71
+ ["minute", 0, 59],
72
+ ["hour", 0, 23],
73
+ ["day of month", 1, 31],
74
+ ["month", 1, 12],
75
+ ["day of week", 0, 6],
76
+ ];
77
+ for (let i = 0; i < 5; i++) {
78
+ const [name, min, max] = fieldRanges[i];
79
+ try {
80
+ const values = expandCronField(parts[i], min, max);
81
+ if (values.length === 0) {
82
+ return { valid: false, reason: `${name} field "${parts[i]}" produces no values` };
83
+ }
84
+ for (const v of values) {
85
+ if (v < min || v > max) {
86
+ return { valid: false, reason: `${name} field value ${v} out of range [${min}–${max}]` };
87
+ }
88
+ }
89
+ } catch (err) {
90
+ return { valid: false, reason: `${name} field: ${err instanceof Error ? err.message : String(err)}` };
91
+ }
92
+ }
93
+ return { valid: true };
94
+ }
25
95
 
96
+ /**
97
+ * Calculate the next Date after `after` that matches the cron expression.
98
+ * Throws on invalid expressions.
99
+ */
26
100
  function parseCronExpression(expression: string, after: Date): Date {
27
- // We implement a simple forward-search. For production-grade parsing
28
- // one would use a library, but we avoid adding dependencies.
29
101
  const parts = expression.trim().split(/\s+/);
30
102
  if (parts.length < 5) {
31
103
  throw new Error(`Invalid cron expression: "${expression}". Expected 5 fields.`);
@@ -33,41 +105,11 @@ function parseCronExpression(expression: string, after: Date): Date {
33
105
 
34
106
  const [minField, hourField, domField, monField, dowField] = parts;
35
107
 
36
- const expand = (field: string, min: number, max: number): number[] => {
37
- const results = new Set<number>();
38
- for (const segment of field.split(",")) {
39
- if (segment === "*") {
40
- for (let i = min; i <= max; i++) results.add(i);
41
- } else if (segment.includes("/")) {
42
- const [rangeStr, stepStr] = segment.split("/");
43
- const step = parseInt(stepStr, 10);
44
- let start = min;
45
- let end = max;
46
- if (rangeStr !== "*") {
47
- if (rangeStr.includes("-")) {
48
- const [a, b] = rangeStr.split("-").map(Number);
49
- start = a;
50
- end = b;
51
- } else {
52
- start = parseInt(rangeStr, 10);
53
- }
54
- }
55
- for (let i = start; i <= end; i += step) results.add(i);
56
- } else if (segment.includes("-")) {
57
- const [a, b] = segment.split("-").map(Number);
58
- for (let i = a; i <= b; i++) results.add(i);
59
- } else {
60
- results.add(parseInt(segment, 10));
61
- }
62
- }
63
- return [...results].sort((a, b) => a - b);
64
- };
65
-
66
- const minutes = expand(minField, 0, 59);
67
- const hours = expand(hourField, 0, 23);
68
- const doms = expand(domField, 1, 31);
69
- const months = expand(monField, 1, 12);
70
- const dows = expand(dowField, 0, 6); // 0=Sunday
108
+ const minutes = expandCronField(minField, 0, 59);
109
+ const hours = expandCronField(hourField, 0, 23);
110
+ const doms = expandCronField(domField, 1, 31);
111
+ const months = expandCronField(monField, 1, 12);
112
+ const dows = expandCronField(dowField, 0, 6); // 0=Sunday
71
113
 
72
114
  // Forward-search from `after + 1 minute`
73
115
  const candidate = new Date(after);
@@ -104,6 +146,12 @@ function parseCronExpression(expression: string, after: Date): Date {
104
146
 
105
147
  const MAX_LOGS_PER_JOB = 50;
106
148
 
149
+ /**
150
+ * Minimum milliseconds between scheduled executions of the same job.
151
+ * Prevents tight re-execution loops caused by jitter or clock drift.
152
+ */
153
+ const MIN_SCHEDULE_INTERVAL_MS = 5_000; // 5 seconds
154
+
107
155
  // ─── CronScheduler ───────────────────────────────────────────────────
108
156
 
109
157
  interface RegisteredJob {
@@ -119,6 +167,8 @@ interface RegisteredJob {
119
167
  totalFailures: number;
120
168
  timerId?: ReturnType<typeof setTimeout>;
121
169
  logs: CronJobLogEntry[];
170
+ /** True while a handler is actively executing (prevents concurrent runs). */
171
+ executing: boolean;
122
172
  }
123
173
 
124
174
  export class CronScheduler {
@@ -145,24 +195,47 @@ export class CronScheduler {
145
195
 
146
196
  /**
147
197
  * Register a batch of loaded cron jobs.
198
+ *
199
+ * If the scheduler is already started, newly registered jobs are
200
+ * automatically scheduled (so late-registered jobs don't sit idle).
201
+ *
202
+ * Validates the cron schedule on registration — invalid schedules
203
+ * are rejected with a warning and the job is NOT registered.
148
204
  */
149
205
  registerJobs(loadedJobs: LoadedCronJob[]): void {
150
206
  for (const loaded of loadedJobs) {
207
+ // Validate schedule up-front — reject invalid schedules
208
+ const validation = validateCronExpression(loaded.definition.schedule);
209
+ if (!validation.valid) {
210
+ console.error(
211
+ `[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`
212
+ );
213
+ continue;
214
+ }
215
+
151
216
  const existing = this.jobs.get(loaded.id);
152
217
  if (existing) {
153
218
  console.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
154
219
  this.stopJob(loaded.id);
155
220
  }
156
221
 
222
+ const enabled = loaded.definition.enabled !== false;
223
+
157
224
  this.jobs.set(loaded.id, {
158
225
  id: loaded.id,
159
226
  definition: loaded.definition,
160
- enabled: loaded.definition.enabled !== false,
161
- state: loaded.definition.enabled !== false ? "idle" : "disabled",
227
+ enabled,
228
+ state: enabled ? "idle" : "disabled",
162
229
  totalRuns: 0,
163
230
  totalFailures: 0,
164
- logs: []
231
+ logs: [],
232
+ executing: false
165
233
  });
234
+
235
+ // If the scheduler is already running, auto-schedule new jobs
236
+ if (this.started && enabled) {
237
+ this.scheduleNext(loaded.id);
238
+ }
166
239
  }
167
240
  }
168
241
 
@@ -201,6 +274,9 @@ export class CronScheduler {
201
274
 
202
275
  /**
203
276
  * Stop the scheduler and clear all timers.
277
+ *
278
+ * Currently-executing handlers run to completion (they are async),
279
+ * but no further scheduling occurs after stop.
204
280
  */
205
281
  stop(): void {
206
282
  this.started = false;
@@ -269,32 +345,93 @@ export class CronScheduler {
269
345
 
270
346
  /**
271
347
  * Manually trigger a job execution immediately.
348
+ *
349
+ * Returns `undefined` if the job doesn't exist.
350
+ * If the job is currently executing, returns the log entry with
351
+ * a `skipped: true` result rather than running concurrently.
272
352
  */
273
353
  async triggerJob(id: string): Promise<CronJobLogEntry | undefined> {
274
354
  const job = this.jobs.get(id);
275
355
  if (!job) return undefined;
356
+
357
+ // Concurrency guard — don't run two instances simultaneously
358
+ if (job.executing) {
359
+ console.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
360
+ const logEntry: CronJobLogEntry = {
361
+ jobId: id,
362
+ startedAt: new Date().toISOString(),
363
+ finishedAt: new Date().toISOString(),
364
+ durationMs: 0,
365
+ success: true,
366
+ result: { skipped: true, reason: "already_executing" },
367
+ logs: ["Skipped: job is already running"],
368
+ manual: true
369
+ };
370
+ job.logs.push(logEntry);
371
+ if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
372
+ return logEntry;
373
+ }
374
+
276
375
  return this.executeJob(job, true);
277
376
  }
278
377
 
279
378
  // ─── Internal ────────────────────────────────────────────────────
280
379
 
380
+ /**
381
+ * Schedule the next execution for a job.
382
+ *
383
+ * Safety guarantees:
384
+ * 1. Clears any existing timer first (prevents leaked/duplicate timers)
385
+ * 2. Enforces a minimum delay to prevent tight loops from jitter
386
+ * 3. Unref's the timer so it doesn't prevent process exit
387
+ * 4. Re-checks enabled & started state before executing
388
+ * 5. Concurrency guard prevents overlapping handler executions
389
+ */
281
390
  private scheduleNext(id: string): void {
282
391
  const job = this.jobs.get(id);
283
392
  if (!job || !job.enabled || !this.started) return;
284
393
 
394
+ // Clear any previously scheduled timer to prevent double-firing
395
+ this.stopJob(id);
396
+
285
397
  try {
286
398
  const now = new Date();
287
399
  const nextRun = parseCronExpression(job.definition.schedule, now);
288
400
  job.nextRunAt = nextRun;
289
401
 
290
- const delay = Math.max(nextRun.getTime() - now.getTime(), 0);
402
+ const rawDelay = nextRun.getTime() - now.getTime();
403
+ // Enforce a minimum delay to prevent tight re-execution loops
404
+ // from event loop jitter or near-zero setTimeout drift
405
+ const delay = Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS);
291
406
 
292
- job.timerId = setTimeout(async () => {
407
+ const timer = setTimeout(async () => {
408
+ // Re-check state: scheduler may have been stopped or job disabled
409
+ // between when we scheduled and when we fire
293
410
  if (!job.enabled || !this.started) return;
411
+
412
+ // Concurrency guard: if somehow we're already executing, skip
413
+ if (job.executing) {
414
+ console.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
415
+ // Re-schedule to try again later
416
+ this.scheduleNext(id);
417
+ return;
418
+ }
419
+
294
420
  await this.executeJob(job, false);
295
- // Schedule the next tick
296
- this.scheduleNext(id);
421
+
422
+ // Schedule the next tick (only if still started + enabled)
423
+ if (this.started && job.enabled) {
424
+ this.scheduleNext(id);
425
+ }
297
426
  }, delay);
427
+
428
+ // Unref the timer so it doesn't prevent Node.js from exiting
429
+ // during graceful shutdown
430
+ if (timer && typeof timer === "object" && "unref" in timer) {
431
+ timer.unref();
432
+ }
433
+
434
+ job.timerId = timer;
298
435
  } catch (err: unknown) {
299
436
  console.error(`[cron] Failed to schedule "${id}":`, err);
300
437
  job.state = "error";
@@ -302,6 +439,9 @@ export class CronScheduler {
302
439
  }
303
440
  }
304
441
 
442
+ /**
443
+ * Stop a single job's timer and clear its next run state.
444
+ */
305
445
  private stopJob(id: string): void {
306
446
  const job = this.jobs.get(id);
307
447
  if (job?.timerId) {
@@ -311,6 +451,15 @@ export class CronScheduler {
311
451
  }
312
452
  }
313
453
 
454
+ /**
455
+ * Execute a job's handler with full isolation and safety.
456
+ *
457
+ * - Sets a concurrency flag to prevent overlapping runs
458
+ * - Wraps handler in a timeout race
459
+ * - Captures all logs, errors, and results
460
+ * - Persists to store (non-blocking) if available
461
+ * - Always restores state even on catastrophic errors
462
+ */
314
463
  private async executeJob(
315
464
  job: RegisteredJob,
316
465
  manual: boolean
@@ -318,6 +467,9 @@ export class CronScheduler {
318
467
  const startedAt = new Date();
319
468
  const capturedLogs: string[] = [];
320
469
 
470
+ // Set executing flag — prevents concurrent runs
471
+ job.executing = true;
472
+
321
473
  const ctx: CronJobContext = {
322
474
  jobId: job.id,
323
475
  scheduledAt: startedAt,
@@ -342,15 +494,26 @@ export class CronScheduler {
342
494
  // Race with timeout
343
495
  const timeout = (job.definition.timeoutSeconds ?? 300) * 1000;
344
496
  const handlerPromise = Promise.resolve(job.definition.handler(ctx));
497
+ let timeoutHandle: ReturnType<typeof setTimeout>;
345
498
  const timeoutPromise = new Promise<never>((_, reject) => {
346
- setTimeout(() => reject(new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)), timeout);
499
+ timeoutHandle = setTimeout(
500
+ () => reject(new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)),
501
+ timeout
502
+ );
347
503
  });
348
504
 
349
- result = await Promise.race([handlerPromise, timeoutPromise]);
505
+ try {
506
+ result = await Promise.race([handlerPromise, timeoutPromise]);
507
+ } finally {
508
+ clearTimeout(timeoutHandle!);
509
+ }
350
510
  } catch (err: unknown) {
351
511
  success = false;
352
512
  error = err instanceof Error ? err.message : String(err);
353
513
  job.totalFailures++;
514
+ } finally {
515
+ // Always clear executing flag — even on catastrophic errors
516
+ job.executing = false;
354
517
  }
355
518
 
356
519
  const finishedAt = new Date();
@@ -380,8 +543,8 @@ export class CronScheduler {
380
543
 
381
544
  // Persist to database (non-blocking)
382
545
  if (this.store) {
383
- this.store.insertLog(logEntry).catch((err) => {
384
- console.error(`[cron] Failed to persist log for "${job.id}":`, err);
546
+ this.store.insertLog(logEntry).catch((persistErr) => {
547
+ console.error(`[cron] Failed to persist log for "${job.id}":`, persistErr);
385
548
  });
386
549
  }
387
550
 
package/src/cron/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { loadCronJobsFromDirectory } from "./cron-loader";
2
2
  export type { LoadedCronJob } from "./cron-loader";
3
- export { CronScheduler } from "./cron-scheduler";
3
+ export { CronScheduler, validateCronExpression } from "./cron-scheduler";
4
4
  export { createCronRoutes } from "./cron-routes";
5
5
  export { createCronStore } from "./cron-store";
6
6
  export type { CronStore } from "./cron-store";
package/src/init.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { DataDriver, EntityCollection, BackendBootstrapper, BootstrappedAuth, RealtimeProvider, HealthCheckResult, InitializedDriver, isSQLAdmin } from "@rebasepro/types";
1
+ import { DataDriver, EntityCollection, BackendBootstrapper, BootstrappedAuth, RealtimeProvider, HealthCheckResult, InitializedDriver, isSQLAdmin, BackendHooks } from "@rebasepro/types";
2
2
  import { BackendCollectionRegistry } from "./collections/BackendCollectionRegistry";
3
3
  import { loadCollectionsFromDirectory } from "./collections/loader";
4
4
  import { DriverRegistry, DEFAULT_DRIVER_ID, DefaultDriverRegistry } from "./services/driver-registry";
@@ -43,7 +43,7 @@ export interface RebaseAuthConfig {
43
43
  */
44
44
  serviceKey?: string;
45
45
  email?: EmailConfig;
46
- google?: { clientId: string };
46
+ google?: { clientId: string; clientSecret?: string };
47
47
  linkedin?: { clientId: string; clientSecret: string };
48
48
  github?: { clientId: string; clientSecret: string };
49
49
  microsoft?: { clientId: string; clientSecret: string; tenantId?: string };
@@ -107,6 +107,15 @@ export interface RebaseBackendConfig {
107
107
  /** Allowed origins for CSRF validation. */
108
108
  origin: string | string[] | ((origin: string) => boolean);
109
109
  };
110
+ /**
111
+ * Backend-level hooks for intercepting admin data (users, roles)
112
+ * at the API boundary. These run server-side after database reads
113
+ * and before API responses are sent.
114
+ *
115
+ * Complement the per-collection `EntityCallbacks` system which
116
+ * handles collection CRUD operations.
117
+ */
118
+ hooks?: BackendHooks;
110
119
  }
111
120
 
112
121
  export interface RebaseBackendInstance {
@@ -346,7 +355,7 @@ collectionRegistry });
346
355
 
347
356
  if (config.auth.google?.clientId) {
348
357
  const { createGoogleProvider } = await import("./auth");
349
- oauthProviders.push(createGoogleProvider(config.auth.google.clientId));
358
+ oauthProviders.push(createGoogleProvider(config.auth.google));
350
359
  }
351
360
 
352
361
  if (config.auth.linkedin?.clientId && config.auth.linkedin?.clientSecret) {
@@ -418,7 +427,8 @@ collectionRegistry });
418
427
  authRepo: authConfigResult.authRepository as import("./auth/interfaces").AuthRepository ?? authConfigResult.userService as import("./auth/interfaces").AuthRepository,
419
428
  emailService: authConfigResult.emailService as import("./email").EmailService,
420
429
  emailConfig: config.auth.email,
421
- serviceKey
430
+ serviceKey,
431
+ hooks: config.hooks
422
432
  });
423
433
  config.app.route(`${basePath}/admin`, adminRoutes);
424
434
  }
@@ -502,7 +512,7 @@ collectionRegistry });
502
512
  dataRouter.route("/", historyRoutes);
503
513
  }
504
514
 
505
- const restGenerator = new RestApiGenerator(activeCollections, defaultDriver);
515
+ const restGenerator = new RestApiGenerator(activeCollections, defaultDriver, config.hooks?.data);
506
516
  dataRouter.route("/", restGenerator.generateRoutes());
507
517
 
508
518
  config.app.route(`${basePath}/data`, dataRouter);
@@ -572,6 +582,18 @@ collectionRegistry });
572
582
  _initRebase(serverClient);
573
583
  logger.info("Rebase singleton initialized");
574
584
 
585
+ // Retroactively inject the server client into the driver so that
586
+ // entity callbacks receive `context.client` at runtime.
587
+ // The driver is created before the client (which depends on the mounted
588
+ // Hono app), so we set it here, mirroring the historyService injection above.
589
+ if (defaultDriverResult.internals) {
590
+ const internals = defaultDriverResult.internals as Record<string, unknown>;
591
+ const driver = internals.driver as Record<string, unknown> | undefined;
592
+ if (driver && "client" in driver) {
593
+ driver.client = serverClient;
594
+ }
595
+ }
596
+
575
597
  // 5. Mount Custom Functions
576
598
  if (config.functionsDir) {
577
599
  const { loadFunctionsFromDirectory } = await import("./functions/function-loader");
@@ -77,10 +77,10 @@ export class LocalStorageController implements StorageController {
77
77
  * Includes a path traversal guard to prevent escaping the base directory.
78
78
  */
79
79
  private getFullPath(storagePath: string, bucket?: string): string {
80
- const parts = bucket ? [this.basePath, bucket, storagePath] : [this.basePath, storagePath];
81
- const resolved = path.resolve(path.join(...parts));
82
- if (!resolved.startsWith(this.basePath + path.sep) && resolved !== this.basePath) {
83
- throw new Error("Path traversal detected: resolved storage path is outside the base directory.");
80
+ const bucketPath = bucket ? path.join(this.basePath, bucket) : this.basePath;
81
+ const resolved = path.resolve(path.join(bucketPath, storagePath));
82
+ if (!resolved.startsWith(bucketPath + path.sep) && resolved !== bucketPath) {
83
+ throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
84
84
  }
85
85
  return resolved;
86
86
  }
@@ -264,21 +264,45 @@ export class LocalStorageController implements StorageController {
264
264
 
265
265
  // Normalize path to handle leading/trailing slashes
266
266
  resolvedPath = normalizeStoragePath(resolvedPath);
267
+
268
+ if (!resolvedPath) {
269
+ // Safety: never delete the bucket root
270
+ return;
271
+ }
272
+
267
273
  const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
268
274
 
275
+ // Check if path exists before attempting to delete
269
276
  try {
270
- await unlink(fullPath);
271
- // Also delete metadata file if exists
272
- try {
273
- await unlink(`${fullPath}.metadata.json`);
274
- } catch {
275
- // Metadata file might not exist
277
+ await access(fullPath, fs.constants.F_OK);
278
+ } catch {
279
+ // File doesn't exist — nothing to delete
280
+ return;
281
+ }
282
+
283
+ try {
284
+ const stats = await stat(fullPath);
285
+ if (stats.isDirectory()) {
286
+ // Only remove if empty — client must delete contents first
287
+ await fs.promises.rmdir(fullPath);
288
+ } else {
289
+ await unlink(fullPath);
290
+ // Also delete metadata file if exists
291
+ try {
292
+ await unlink(`${fullPath}.metadata.json`);
293
+ } catch {
294
+ // Metadata file might not exist
295
+ }
276
296
  }
277
297
  } catch (error: unknown) {
278
- if (error instanceof Error && (error as NodeJS.ErrnoException).code !== "ENOENT") {
279
- throw error;
298
+ if (error instanceof Error) {
299
+ const code = (error as NodeJS.ErrnoException).code;
300
+ if (code === "ENOENT" || code === "ENOTEMPTY") {
301
+ // File doesn't exist or directory not empty — ignore
302
+ return;
303
+ }
280
304
  }
281
- // File doesn't exist, nothing to delete
305
+ throw error;
282
306
  }
283
307
  }
284
308
 
@@ -72,7 +72,10 @@ export class S3StorageController implements StorageController {
72
72
  * Get the bucket name - either from parameter or config
73
73
  */
74
74
  private getBucket(bucket?: string): string {
75
- return bucket ?? this.config.bucket;
75
+ // "default" is a logical bucket name used by local storage;
76
+ // for S3 it should resolve to the configured bucket.
77
+ if (!bucket || bucket === "default") return this.config.bucket;
78
+ return bucket;
76
79
  }
77
80
 
78
81
  async putObject({
@@ -177,13 +177,20 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
177
177
  return c.body(new Uint8Array(fileContent));
178
178
  }
179
179
 
180
- // For remote storage (S3, GCS, etc.), redirect to a signed URL
181
- const downloadConfig = await controller.getSignedUrl(filePath);
182
- if (downloadConfig.fileNotFound || !downloadConfig.url) {
180
+ // For remote storage (S3, GCS, etc.), proxy the file through the backend.
181
+ // We avoid redirecting to signed URLs because:
182
+ // 1. Mixed-content (HTTPS page → HTTP MinIO) is blocked by browsers
183
+ // 2. Internal IPs / VPC endpoints are unreachable from the browser
184
+ const { bucket: parsedBucket, resolvedPath: parsedPath } = parseBucketAndPath(filePath);
185
+ const fileObject = await controller.getObject(parsedPath, parsedBucket);
186
+ if (!fileObject) {
183
187
  throw ApiError.notFound("File not found");
184
188
  }
185
189
 
186
- return c.redirect(downloadConfig.url);
190
+ c.header("Content-Type", fileObject.type || "application/octet-stream");
191
+ c.header("Cache-Control", "public, max-age=3600, immutable");
192
+ const buf = await fileObject.arrayBuffer();
193
+ return c.body(new Uint8Array(buf));
187
194
  });
188
195
 
189
196
  /**
@@ -248,7 +255,7 @@ message: "No file to delete" });
248
255
  const result = await controller.listObjects(
249
256
  storagePrefix,
250
257
  {
251
- bucket,
258
+ bucket: bucket ?? (controller.getType() === "local" ? "default" : undefined),
252
259
  maxResults: maxResults ? parseInt(maxResults, 10) : undefined,
253
260
  pageToken
254
261
  }
@@ -260,5 +267,44 @@ message: "No file to delete" });
260
267
  });
261
268
  });
262
269
 
270
+ /**
271
+ * POST /folder - Create a new folder
272
+ * Body: { path: string, bucket?: string }
273
+ */
274
+ router.post("/folder", writeAuthMiddleware, async (c) => {
275
+ const body = await c.req.json();
276
+ const folderPath = body.path;
277
+
278
+ if (!folderPath || typeof folderPath !== "string") {
279
+ throw ApiError.badRequest("Folder path is required");
280
+ }
281
+
282
+ const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
283
+
284
+ if (!resolvedPath || resolvedPath.trim() === "") {
285
+ throw ApiError.badRequest("Invalid folder path");
286
+ }
287
+
288
+ if (controller.getType() === "local") {
289
+ // For local storage, create the directory
290
+ const localController = controller as LocalStorageController;
291
+ const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
292
+ fs.mkdirSync(absolutePath, { recursive: true });
293
+ } else {
294
+ // For S3-compatible storage, create a zero-byte marker object with trailing slash
295
+ const key = resolvedPath.endsWith("/") ? resolvedPath : resolvedPath + "/";
296
+ const emptyFile = new File([], key, { type: "application/x-directory" });
297
+ await controller.putObject({
298
+ file: emptyFile,
299
+ key
300
+ });
301
+ }
302
+
303
+ return c.json({
304
+ success: true,
305
+ message: "Folder created"
306
+ }, 201);
307
+ });
308
+
263
309
  return router;
264
310
  }