@rebasepro/server 0.9.1-canary.e3f810f → 0.9.1-canary.ed943fa

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.
@@ -28,5 +28,23 @@ export interface CronStore {
28
28
  totalFailures: number;
29
29
  lastRunAt?: string;
30
30
  }>>;
31
+ /**
32
+ * Atomically claim a scheduled run slot for a job.
33
+ *
34
+ * `slot` is the *scheduled* fire time (ISO string) derived from the cron
35
+ * expression — deterministic across instances regardless of timer drift,
36
+ * so all instances contend on the same (jobId, slot) key. Exactly one
37
+ * caller wins the insert against the unique constraint and executes;
38
+ * the rest skip.
39
+ *
40
+ * Fails open (returns true) on unexpected store errors, so a broken
41
+ * claims table degrades to uncoordinated execution rather than silently
42
+ * never running jobs.
43
+ *
44
+ * Optional so custom stores written against the pre-claims interface
45
+ * keep working — the scheduler treats a missing implementation as
46
+ * uncoordinated (always run).
47
+ */
48
+ tryClaimRun?(jobId: string, slot: string): Promise<boolean>;
31
49
  }
32
50
  export declare function createCronStore(driver: DataDriver): CronStore | undefined;
package/dist/env.d.ts CHANGED
@@ -55,6 +55,9 @@ declare const rebaseEnvSchema: z.ZodObject<{
55
55
  true: "true";
56
56
  false: "false";
57
57
  }>>, z.ZodTransform<boolean, "" | "true" | "false" | undefined>>;
58
+ GCS_BUCKET: z.ZodOptional<z.ZodString>;
59
+ GCS_PROJECT_ID: z.ZodOptional<z.ZodString>;
60
+ GCS_KEY_FILENAME: z.ZodOptional<z.ZodString>;
58
61
  }, z.core.$strip>;
59
62
  /** Inferred type of the validated environment. */
60
63
  export type RebaseEnv = z.infer<typeof rebaseEnvSchema>;
package/dist/index.es.js CHANGED
@@ -11623,11 +11623,21 @@ var LocalStorageController = class {
11623
11623
  }
11624
11624
  }
11625
11625
  /**
11626
- * Get the full filesystem path for a storage path.
11627
- * Includes a path traversal guard to prevent escaping the base directory.
11626
+ * Get the full filesystem path for a storage path, with a traversal guard
11627
+ * that keeps the result inside the bucket directory.
11628
+ *
11629
+ * Defaults the bucket the way `putObject` does.
11630
+ *
11631
+ * `putObject` has always written into `default` when given no bucket, while
11632
+ * the read side resolved a bare key against the storage root — where
11633
+ * nothing is. The two disagreed silently: `getObject` returned null (reads
11634
+ * as "file missing"), `deleteObject` deleted nothing (404s are swallowed by
11635
+ * design), and `listObjects` returned an empty page. So the obvious
11636
+ * `putObject({ key })` → `getObject(key)` did not round-trip and nothing
11637
+ * said why. One default, applied everywhere, removes the whole class.
11628
11638
  */
11629
11639
  getFullPath(storagePath, bucket) {
11630
- const bucketPath = bucket ? path$1.join(this.basePath, bucket) : this.basePath;
11640
+ const bucketPath = path$1.join(this.basePath, bucket ?? "default");
11631
11641
  const resolved = path$1.resolve(path$1.join(bucketPath, storagePath));
11632
11642
  if (!resolved.startsWith(bucketPath + path$1.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
11633
11643
  return resolved;
@@ -11788,8 +11798,10 @@ var LocalStorageController = class {
11788
11798
  let count = 0;
11789
11799
  const maxResults = options?.maxResults ?? 1e3;
11790
11800
  const startIndex = options?.pageToken ? parseInt(options.pageToken, 10) : 0;
11801
+ let scanned = startIndex;
11791
11802
  for (let i = startIndex; i < entries.length && count < maxResults; i++) {
11792
11803
  const entry = entries[i];
11804
+ scanned = i + 1;
11793
11805
  if (entry.name.endsWith(".metadata.json")) continue;
11794
11806
  const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
11795
11807
  const bucket = options?.bucket ?? "default";
@@ -11808,7 +11820,7 @@ var LocalStorageController = class {
11808
11820
  return {
11809
11821
  items,
11810
11822
  prefixes,
11811
- nextPageToken: startIndex + count < entries.length ? String(startIndex + count) : void 0
11823
+ nextPageToken: scanned < entries.length ? String(scanned) : void 0
11812
11824
  };
11813
11825
  } catch (error) {
11814
11826
  const code = error?.code;
@@ -13759,12 +13771,14 @@ var UPLOAD_EXPIRY_MS = 1440 * 60 * 1e3;
13759
13771
  var TusHandler = class {
13760
13772
  storageController;
13761
13773
  storageRegistry;
13774
+ authorizeUpload;
13762
13775
  uploads = /* @__PURE__ */ new Map();
13763
13776
  tusDir;
13764
13777
  cleanupTimer;
13765
- constructor(storageBaseDir, storageController, storageRegistry) {
13778
+ constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload) {
13766
13779
  this.storageController = storageController;
13767
13780
  this.storageRegistry = storageRegistry;
13781
+ this.authorizeUpload = authorizeUpload;
13768
13782
  this.tusDir = join(storageBaseDir, ".tus-uploads");
13769
13783
  }
13770
13784
  /** Ensure the temp directory exists. */
@@ -13828,6 +13842,10 @@ var TusHandler = class {
13828
13842
  if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
13829
13843
  if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
13830
13844
  const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
13845
+ if (this.authorizeUpload) {
13846
+ const key = metadata.key || metadata.filename || "";
13847
+ await this.authorizeUpload(c, key, metadata.bucket || "default");
13848
+ }
13831
13849
  const id = randomUUID$1();
13832
13850
  const filePath = join(this.tusDir, id);
13833
13851
  await writeFile(filePath, Buffer.alloc(0));
@@ -14035,7 +14053,34 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
14035
14053
  function createStorageRoutes(config) {
14036
14054
  const router = new Hono();
14037
14055
  router.onError(errorHandler);
14038
- const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter } = config;
14056
+ const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize } = config;
14057
+ /**
14058
+ * Run the per-object authorization hook, if one is configured.
14059
+ *
14060
+ * Denials are 403 rather than 404: the route already established that the
14061
+ * caller is authenticated, so hiding existence buys nothing, and a
14062
+ * distinguishable status is what makes a misconfigured policy debuggable.
14063
+ * A hook that throws denies too — an ownership lookup that fails must not
14064
+ * fall open.
14065
+ */
14066
+ const checkAuthorized = async (c, operation, key, bucket, storageId) => {
14067
+ if (!authorize) return;
14068
+ const user = c.get("user") ?? null;
14069
+ if (user?.userId === "download-token" || user?.userId === "public") return;
14070
+ let allowed;
14071
+ try {
14072
+ allowed = await authorize({
14073
+ key,
14074
+ bucket,
14075
+ operation,
14076
+ user,
14077
+ storageId: storageId ?? void 0
14078
+ });
14079
+ } catch {
14080
+ allowed = false;
14081
+ }
14082
+ if (!allowed) throw ApiError.forbidden("Not authorized for this object");
14083
+ };
14039
14084
  /**
14040
14085
  * Resolve the storage controller for a request.
14041
14086
  * Looks up by `storageId` in the registry, falls back to the single
@@ -14085,6 +14130,7 @@ function createStorageRoutes(config) {
14085
14130
  const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
14086
14131
  const metadata = {};
14087
14132
  for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
14133
+ await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
14088
14134
  const result = await resolveController(storageId).putObject({
14089
14135
  file: uploadedFile,
14090
14136
  key: finalKey,
@@ -14105,7 +14151,12 @@ function createStorageRoutes(config) {
14105
14151
  const rawPath = extractWildcardPath(c);
14106
14152
  if (!rawPath) throw ApiError.notFound("File not found");
14107
14153
  const filePath = decodeURIComponent(rawPath);
14108
- const resolved = resolveController(c.req.query("storageId"));
14154
+ const storageId = c.req.query("storageId");
14155
+ const resolved = resolveController(storageId);
14156
+ {
14157
+ const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14158
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
14159
+ }
14109
14160
  const transformOpts = parseTransformOptions(c.req.query());
14110
14161
  if (resolved.getType() === "local") {
14111
14162
  const localController = resolved;
@@ -14168,8 +14219,10 @@ function createStorageRoutes(config) {
14168
14219
  fileNotFound: true
14169
14220
  }, 404);
14170
14221
  const filePath = decodeURIComponent(rawPath);
14171
- const resolved = resolveController(c.req.query("storageId"));
14222
+ const storageId = c.req.query("storageId");
14223
+ const resolved = resolveController(storageId);
14172
14224
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14225
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
14173
14226
  const downloadConfig = await resolved.getSignedUrl(resolvedPath, bucket);
14174
14227
  if (downloadConfig.fileNotFound) throw ApiError.notFound("File not found");
14175
14228
  if (downloadConfig.metadata) {
@@ -14195,8 +14248,10 @@ function createStorageRoutes(config) {
14195
14248
  message: "No file to delete"
14196
14249
  });
14197
14250
  const filePath = decodeURIComponent(rawPath);
14198
- const resolved = resolveController(c.req.query("storageId"));
14251
+ const storageId = c.req.query("storageId");
14252
+ const resolved = resolveController(storageId);
14199
14253
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14254
+ await checkAuthorized(c, "delete", resolvedPath, bucket, storageId);
14200
14255
  await resolved.deleteObject(resolvedPath, bucket);
14201
14256
  return c.json({
14202
14257
  success: true,
@@ -14211,7 +14266,9 @@ function createStorageRoutes(config) {
14211
14266
  const bucket = c.req.query("bucket");
14212
14267
  const maxResults = c.req.query("maxResults");
14213
14268
  const pageToken = c.req.query("pageToken");
14214
- const resolved = resolveController(c.req.query("storageId"));
14269
+ const storageId = c.req.query("storageId");
14270
+ const resolved = resolveController(storageId);
14271
+ await checkAuthorized(c, "list", storagePrefix, bucket ?? "default", storageId);
14215
14272
  const result = await resolved.listObjects(storagePrefix, {
14216
14273
  bucket: bucket ?? (resolved.getType() === "local" ? "default" : void 0),
14217
14274
  maxResults: maxResults ? parseInt(maxResults, 10) : void 0,
@@ -14234,6 +14291,7 @@ function createStorageRoutes(config) {
14234
14291
  const resolved = resolveController(storageId);
14235
14292
  const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
14236
14293
  if (!resolvedPath || resolvedPath.trim() === "") throw ApiError.badRequest("Invalid folder path");
14294
+ await checkAuthorized(c, "write", resolvedPath, bucket, storageId);
14237
14295
  if (resolved.getType() === "local") {
14238
14296
  const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
14239
14297
  fs$1.mkdirSync(absolutePath, { recursive: true });
@@ -14251,7 +14309,9 @@ function createStorageRoutes(config) {
14251
14309
  }, 201);
14252
14310
  });
14253
14311
  const defaultCtrl = getDefaultController();
14254
- const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry);
14312
+ const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
14313
+ await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
14314
+ } : void 0);
14255
14315
  tusHandler.startCleanup();
14256
14316
  router.options("/tus", (_c) => tusHandler.options());
14257
14317
  router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
@@ -14392,7 +14452,7 @@ async function initializeStorage(storageConfig, isProduction) {
14392
14452
  const toController = async (entry, label) => {
14393
14453
  if (typeof entry.putObject === "function") return entry;
14394
14454
  const conf = entry;
14395
- if (isProduction && conf.type === "local") logger.warn(`Storage backend "${label}" uses local filesystem in production. Files will be lost on container restart. Configure S3-compatible storage or a custom StorageController.`);
14455
+ if (isProduction && conf.type === "local" && !process.env.FORCE_LOCAL_STORAGE) throw new Error(`Storage backend "${label}" is set to "local" in production. Local storage is the container filesystem, so uploaded files are destroyed on the next restart or redeploy. Configure S3-compatible storage (STORAGE_TYPE=s3) or GCS (STORAGE_TYPE=gcs), or pass a custom StorageController. If this deployment really does have a durable volume mounted at the storage path, set FORCE_LOCAL_STORAGE=true to proceed.`);
14396
14456
  return await createStorageController(conf);
14397
14457
  };
14398
14458
  if (typeof storageConfig === "object" && ("type" in storageConfig || typeof storageConfig.putObject === "function")) controllers[DEFAULT_STORAGE_ID] = await toController(storageConfig, DEFAULT_STORAGE_ID);
@@ -15955,6 +16015,23 @@ var RebaseWebSocketClient = class {
15955
16015
  getAuthToken;
15956
16016
  subscriptions = /* @__PURE__ */ new Map();
15957
16017
  listeners = /* @__PURE__ */ new Map();
16018
+ /** Channel-name → handlers, for broadcast and presence frames. */
16019
+ channelHandlers = /* @__PURE__ */ new Map();
16020
+ /** Subscribe to broadcast/presence frames for one channel. */
16021
+ onChannelMessage(channel, handler) {
16022
+ if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
16023
+ this.channelHandlers.get(channel).add(handler);
16024
+ return () => {
16025
+ const handlers = this.channelHandlers.get(channel);
16026
+ if (!handlers) return;
16027
+ handlers.delete(handler);
16028
+ if (handlers.size === 0) this.channelHandlers.delete(channel);
16029
+ };
16030
+ }
16031
+ /** Notified after the socket comes back, so channels can re-join. */
16032
+ onReconnect(handler) {
16033
+ return this.on("reconnect", handler);
16034
+ }
15958
16035
  on(event, cb) {
15959
16036
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
15960
16037
  this.listeners.get(event).add(cb);
@@ -16221,6 +16298,15 @@ var RebaseWebSocketClient = class {
16221
16298
  }
16222
16299
  return;
16223
16300
  }
16301
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
16302
+ const handlers = this.channelHandlers.get(message.channel);
16303
+ if (handlers) for (const handler of [...handlers]) try {
16304
+ handler(message);
16305
+ } catch (error) {
16306
+ console.error("Error in channel handler:", error);
16307
+ }
16308
+ return;
16309
+ }
16224
16310
  if (subscriptionId && type === "collection_update") {
16225
16311
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
16226
16312
  if (subscriptionKey) {
@@ -16409,6 +16495,10 @@ var RebaseWebSocketClient = class {
16409
16495
  throw error;
16410
16496
  }
16411
16497
  }
16498
+ /**
16499
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
16500
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
16501
+ */
16412
16502
  sendMessage(message) {
16413
16503
  const queuedMsg = message;
16414
16504
  if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
@@ -16989,6 +17079,188 @@ var RebaseWebSocketClient = class {
16989
17079
  }
16990
17080
  };
16991
17081
  /**
17082
+ * Re-send presence comfortably inside the server's 30s expiry.
17083
+ *
17084
+ * Two-thirds of the window: one lost heartbeat still leaves time for the next
17085
+ * before the entry is reaped, so a single dropped frame is not a disappearance.
17086
+ */
17087
+ var PRESENCE_HEARTBEAT_MS = 2e4;
17088
+ var RebaseRealtimeChannel = class {
17089
+ name;
17090
+ transport;
17091
+ presenceHandlers = /* @__PURE__ */ new Set();
17092
+ broadcastHandlers = /* @__PURE__ */ new Set();
17093
+ unsubscribers = [];
17094
+ /** Last known roster, kept so handlers always get a full picture. */
17095
+ presences = {};
17096
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
17097
+ trackedState = null;
17098
+ heartbeat = null;
17099
+ joined = false;
17100
+ constructor(name, transport) {
17101
+ this.name = name;
17102
+ this.transport = transport;
17103
+ }
17104
+ /**
17105
+ * Join the channel and ask for the current roster.
17106
+ *
17107
+ * Called automatically by `track`, `broadcast`, `onPresence` and
17108
+ * `onBroadcast`; calling it directly is only needed to start receiving
17109
+ * before there is anything to send.
17110
+ */
17111
+ async join() {
17112
+ if (this.joined) return;
17113
+ this.joined = true;
17114
+ this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
17115
+ this.unsubscribers.push(this.transport.onReconnect(() => {
17116
+ this.rejoin();
17117
+ }));
17118
+ await this.transport.sendMessage({
17119
+ type: "join_channel",
17120
+ channel: this.name
17121
+ });
17122
+ await this.transport.sendMessage({
17123
+ type: "presence_state",
17124
+ channel: this.name
17125
+ });
17126
+ }
17127
+ async rejoin() {
17128
+ try {
17129
+ await this.transport.sendMessage({
17130
+ type: "join_channel",
17131
+ channel: this.name
17132
+ });
17133
+ await this.transport.sendMessage({
17134
+ type: "presence_state",
17135
+ channel: this.name
17136
+ });
17137
+ if (this.trackedState) await this.transport.sendMessage({
17138
+ type: "presence_track",
17139
+ channel: this.name,
17140
+ state: this.trackedState
17141
+ });
17142
+ } catch {}
17143
+ }
17144
+ /**
17145
+ * Publish this client's presence state, and keep publishing it.
17146
+ *
17147
+ * Calling `track` again replaces the state (and restarts the heartbeat),
17148
+ * which is how you update e.g. a cursor position.
17149
+ */
17150
+ async track(state) {
17151
+ await this.join();
17152
+ this.trackedState = state;
17153
+ await this.transport.sendMessage({
17154
+ type: "presence_track",
17155
+ channel: this.name,
17156
+ state
17157
+ });
17158
+ if (!this.heartbeat) {
17159
+ this.heartbeat = setInterval(() => {
17160
+ if (!this.trackedState) return;
17161
+ this.transport.sendMessage({
17162
+ type: "presence_track",
17163
+ channel: this.name,
17164
+ state: this.trackedState
17165
+ }).catch(() => {});
17166
+ }, PRESENCE_HEARTBEAT_MS);
17167
+ this.heartbeat.unref?.();
17168
+ }
17169
+ }
17170
+ /** Stop publishing presence, without leaving the channel. */
17171
+ async untrack() {
17172
+ this.stopHeartbeat();
17173
+ this.trackedState = null;
17174
+ if (this.joined) await this.transport.sendMessage({
17175
+ type: "presence_untrack",
17176
+ channel: this.name
17177
+ });
17178
+ }
17179
+ /**
17180
+ * Observe the roster. The handler fires immediately with what is already
17181
+ * known, then on every change.
17182
+ */
17183
+ onPresence(handler) {
17184
+ this.presenceHandlers.add(handler);
17185
+ this.join();
17186
+ if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
17187
+ return () => this.presenceHandlers.delete(handler);
17188
+ }
17189
+ /** Send a broadcast. The sender does not receive its own message. */
17190
+ async broadcast(event, payload) {
17191
+ await this.join();
17192
+ await this.transport.sendMessage({
17193
+ type: "broadcast",
17194
+ channel: this.name,
17195
+ event,
17196
+ payload
17197
+ });
17198
+ }
17199
+ onBroadcast(eventOrHandler, maybeHandler) {
17200
+ const wrapped = typeof eventOrHandler === "string" ? (e) => {
17201
+ if (e.event === eventOrHandler) maybeHandler(e.payload);
17202
+ } : eventOrHandler;
17203
+ this.broadcastHandlers.add(wrapped);
17204
+ this.join();
17205
+ return () => this.broadcastHandlers.delete(wrapped);
17206
+ }
17207
+ /** Leave the channel and release every listener and timer. */
17208
+ async leave() {
17209
+ this.stopHeartbeat();
17210
+ this.trackedState = null;
17211
+ this.presences = {};
17212
+ this.presenceHandlers.clear();
17213
+ this.broadcastHandlers.clear();
17214
+ for (const off of this.unsubscribers) off();
17215
+ this.unsubscribers = [];
17216
+ if (this.joined) {
17217
+ this.joined = false;
17218
+ await this.transport.sendMessage({
17219
+ type: "leave_channel",
17220
+ channel: this.name
17221
+ });
17222
+ }
17223
+ }
17224
+ stopHeartbeat() {
17225
+ if (this.heartbeat) {
17226
+ clearInterval(this.heartbeat);
17227
+ this.heartbeat = null;
17228
+ }
17229
+ }
17230
+ /** Fold an incoming frame into the roster and fan it out. */
17231
+ handle(message) {
17232
+ switch (message.type) {
17233
+ case "presence_state":
17234
+ this.presences = message.presences ?? {};
17235
+ this.emitPresence();
17236
+ break;
17237
+ case "presence_diff": {
17238
+ const joins = message.joins ?? {};
17239
+ const leaves = message.leaves ?? {};
17240
+ for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
17241
+ for (const id of Object.keys(leaves)) delete this.presences[id];
17242
+ this.emitPresence({
17243
+ joins,
17244
+ leaves
17245
+ });
17246
+ break;
17247
+ }
17248
+ case "broadcast": {
17249
+ const event = {
17250
+ event: message.event,
17251
+ payload: message.payload
17252
+ };
17253
+ for (const handler of this.broadcastHandlers) handler(event);
17254
+ break;
17255
+ }
17256
+ }
17257
+ }
17258
+ emitPresence(diff) {
17259
+ const snapshot = { ...this.presences };
17260
+ for (const handler of this.presenceHandlers) handler(snapshot, diff);
17261
+ }
17262
+ };
17263
+ /**
16992
17264
  * Derive a WebSocket URL from an HTTP base URL.
16993
17265
  * `http://` → `ws://`, `https://` → `wss://`.
16994
17266
  */
@@ -17037,6 +17309,8 @@ function createRebaseClient(options) {
17037
17309
  };
17038
17310
  const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
17039
17311
  let ws;
17312
+ /** One channel object per name — see `realtime.channel`. */
17313
+ const realtimeChannels = /* @__PURE__ */ new Map();
17040
17314
  if (resolvedWsUrl) {
17041
17315
  ws = new RebaseWebSocketClient({
17042
17316
  websocketUrl: resolvedWsUrl,
@@ -17147,6 +17421,24 @@ function createRebaseClient(options) {
17147
17421
  createStorageSource,
17148
17422
  fetchStorageSources,
17149
17423
  ws,
17424
+ realtime: {
17425
+ /**
17426
+ * Join a broadcast/presence channel.
17427
+ *
17428
+ * Repeated calls with the same name return the same channel, so
17429
+ * separate components can attach handlers without each opening its
17430
+ * own membership — and `leave()` from one would otherwise silently
17431
+ * cut off the others.
17432
+ */
17433
+ channel: (name) => {
17434
+ if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
17435
+ let existing = realtimeChannels.get(name);
17436
+ if (!existing) {
17437
+ existing = new RebaseRealtimeChannel(name, ws);
17438
+ realtimeChannels.set(name, existing);
17439
+ }
17440
+ return existing;
17441
+ } },
17150
17442
  /**
17151
17443
  * Release the realtime socket and its reconnect timer.
17152
17444
  *
@@ -17155,6 +17447,8 @@ function createRebaseClient(options) {
17155
17447
  * was never started, and safe to call twice.
17156
17448
  */
17157
17449
  close: () => {
17450
+ for (const channel of realtimeChannels.values()) channel.leave();
17451
+ realtimeChannels.clear();
17158
17452
  ws?.disconnect();
17159
17453
  },
17160
17454
  setToken: transport.setToken,
@@ -17796,7 +18090,8 @@ async function _initializeRebaseBackend(config) {
17796
18090
  registry: storageRegistry,
17797
18091
  sources: config.storageSources,
17798
18092
  requireAuth: resolveRequireAuth(config.auth),
17799
- authAdapter
18093
+ authAdapter,
18094
+ authorize: config.storageAuthorize
17800
18095
  });
17801
18096
  const storageRouter = new Hono();
17802
18097
  if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
@@ -18468,6 +18763,7 @@ var CronScheduler = class {
18468
18763
  logger.warn("[cron] Failed to seed job stats from database", { error: err });
18469
18764
  });
18470
18765
  for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
18766
+ if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
18471
18767
  logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
18472
18768
  }
18473
18769
  /**
@@ -18586,6 +18882,19 @@ var CronScheduler = class {
18586
18882
  this.scheduleNext(id);
18587
18883
  return;
18588
18884
  }
18885
+ if (this.store?.tryClaimRun) {
18886
+ let claimed = true;
18887
+ try {
18888
+ claimed = await this.store.tryClaimRun(id, nextRun.toISOString());
18889
+ } catch (err) {
18890
+ logger.warn(`[cron] Claim check threw for "${id}" — running uncoordinated`, { error: err });
18891
+ }
18892
+ if (!claimed) {
18893
+ logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
18894
+ if (this.started && job.enabled) this.scheduleNext(id);
18895
+ return;
18896
+ }
18897
+ }
18589
18898
  await this.executeJob(job, false);
18590
18899
  if (this.started && job.enabled) this.scheduleNext(id);
18591
18900
  }, Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS));
@@ -18754,6 +19063,25 @@ function createCronRoutes(scheduler) {
18754
19063
  //#region src/cron/cron-store.ts
18755
19064
  var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
18756
19065
  var TABLE = "rebase.cron_logs";
19066
+ var CLAIMS_TABLE = "rebase.cron_claims";
19067
+ /** Claims older than this are garbage-collected on startup. */
19068
+ var CLAIM_RETENTION_DAYS = 7;
19069
+ /**
19070
+ * Detect a unique-constraint violation anywhere in an error's cause chain.
19071
+ * Drizzle wraps the PG error — match the SQLSTATE code, never message text.
19072
+ * Also covers SQLite ("UNIQUE constraint failed") and MySQL (ER_DUP_ENTRY 1062)
19073
+ * for future SQL drivers.
19074
+ */
19075
+ function isUniqueViolation(err) {
19076
+ let current = err;
19077
+ for (let depth = 0; depth < 10 && current; depth++) if (typeof current === "object") {
19078
+ const e = current;
19079
+ if (e.code === "23505" || e.errno === 1062) return true;
19080
+ if (typeof e.message === "string" && e.message.includes("UNIQUE constraint failed")) return true;
19081
+ current = e.cause;
19082
+ } else break;
19083
+ return false;
19084
+ }
18757
19085
  function createCronStore(driver) {
18758
19086
  const admin = driver.admin;
18759
19087
  if (!isSQLAdmin(admin)) {
@@ -18783,6 +19111,15 @@ function createCronStore(driver) {
18783
19111
  CREATE INDEX IF NOT EXISTS idx_cron_logs_job
18784
19112
  ON ${TABLE}(job_id, started_at DESC)
18785
19113
  `);
19114
+ await exec(`
19115
+ CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (
19116
+ job_id TEXT NOT NULL,
19117
+ slot TIMESTAMPTZ NOT NULL,
19118
+ claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
19119
+ PRIMARY KEY (job_id, slot)
19120
+ )
19121
+ `);
19122
+ await exec(`DELETE FROM ${CLAIMS_TABLE} WHERE claimed_at < now() - make_interval(days => $1)`, { params: [CLAIM_RETENTION_DAYS] });
18786
19123
  logger.info("✅ Cron logs table ready");
18787
19124
  } catch (err) {
18788
19125
  logger.error("❌ Failed to create cron logs table", { error: err });
@@ -18842,6 +19179,18 @@ function createCronStore(driver) {
18842
19179
  logger.error("[cron-store] Failed to fetch job stats", { error: err });
18843
19180
  }
18844
19181
  return stats;
19182
+ },
19183
+ async tryClaimRun(jobId, slot) {
19184
+ try {
19185
+ return (await exec(`INSERT INTO ${CLAIMS_TABLE} (job_id, slot)
19186
+ VALUES ($1, $2)
19187
+ ON CONFLICT (job_id, slot) DO NOTHING
19188
+ RETURNING job_id`, { params: [jobId, slot] })).length > 0;
19189
+ } catch (err) {
19190
+ if (isUniqueViolation(err)) return false;
19191
+ logger.warn(`[cron-store] Claim check failed for "${jobId}" — running uncoordinated`, { error: err });
19192
+ return true;
19193
+ }
18845
19194
  }
18846
19195
  };
18847
19196
  }
@@ -19141,7 +19490,10 @@ var rebaseEnvSchema = object({
19141
19490
  S3_ACCESS_KEY_ID: string().optional(),
19142
19491
  S3_SECRET_ACCESS_KEY: string().optional(),
19143
19492
  S3_ENDPOINT: string().url().optional(),
19144
- S3_FORCE_PATH_STYLE: optionalBoolString
19493
+ S3_FORCE_PATH_STYLE: optionalBoolString,
19494
+ GCS_BUCKET: string().optional(),
19495
+ GCS_PROJECT_ID: string().optional(),
19496
+ GCS_KEY_FILENAME: string().optional()
19145
19497
  });
19146
19498
  function loadEnv(options) {
19147
19499
  const isProduction = process.env.NODE_ENV === "production";