@rebasepro/server-postgres 0.10.1-canary.31c773c → 0.10.1-canary.6f89f77

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/index.es.js CHANGED
@@ -5,7 +5,7 @@ import { Client, Pool } from "pg";
5
5
  import { drizzle } from "drizzle-orm/node-postgres";
6
6
  import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
7
7
  import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
8
- import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
8
+ import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
9
9
  import fs, { promises } from "fs";
10
10
  import path from "path";
11
11
  import chokidar from "chokidar";
@@ -518,6 +518,25 @@ function getDataSourceCapabilities(engine) {
518
518
  if (!engine) return POSTGRES_CAPABILITIES;
519
519
  return CAPABILITIES_REGISTRY[engine] ?? DEFAULT_CAPABILITIES;
520
520
  }
521
+ /**
522
+ * Resolve a client-supplied list `limit` into a safe, always-defined value.
523
+ *
524
+ * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
525
+ * so `0`, negatives, and absurd values can never bypass the cap.
526
+ * - An absent / blank / non-numeric limit falls back to the mode default:
527
+ * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
528
+ *
529
+ * The return is never `undefined` — no ingress that routes its client limit
530
+ * through this can produce an unbounded read.
531
+ */
532
+ function resolveClientListLimit(rawLimit, opts = {}) {
533
+ const maxLimit = opts.maxLimit ?? 1e3;
534
+ if (rawLimit != null && String(rawLimit).trim() !== "") {
535
+ const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
536
+ if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
537
+ }
538
+ return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
539
+ }
521
540
  var snakeCaseRegex = /[A-Z]{2,}(?=[A-Z][a-z]|\b)|[A-Z]?[a-z]+|[0-9]+(?:[a-z](?![a-z]))?|[A-Z]/g;
522
541
  var toSnakeCase = (str) => {
523
542
  if (!str || typeof str !== "string") return "";
@@ -9349,21 +9368,59 @@ function createAuthSchema(usersSchemaName = "rebase") {
9349
9368
  isAnonymous: boolean("is_anonymous").default(false).notNull(),
9350
9369
  roles: text("roles").array().default([]).notNull(),
9351
9370
  metadata: jsonb("metadata").$type().default({}).notNull(),
9371
+ /**
9372
+ * Sessions that began before this instant are dead, whatever tokens
9373
+ * they still hold. Password resets and admin revocations stamp it.
9374
+ *
9375
+ * Deleting the user's refresh-token rows (which we also do) is not
9376
+ * sufficient on its own: a request already in flight can insert a
9377
+ * freshly rotated row microseconds after the delete and survive it.
9378
+ * This timestamp cannot be outrun that way — it is checked against
9379
+ * `refresh_tokens.session_started_at`, which rotation carries forward.
9380
+ */
9381
+ tokensValidAfter: timestamp("tokens_valid_after"),
9352
9382
  createdAt: timestamp("created_at").defaultNow().notNull(),
9353
9383
  updatedAt: timestamp("updated_at").defaultNow().notNull()
9354
9384
  });
9355
9385
  /**
9356
- * Refresh tokens for long-lived sessions
9386
+ * Refresh tokens for long-lived sessions.
9387
+ *
9388
+ * A row is one token, not one device. Every token minted from the same
9389
+ * sign-in shares a `sessionId`, and rotation ADDS a row rather than
9390
+ * replacing one: the superseded token stays on file, flagged `revoked`
9391
+ * with a `rotatedAt` stamp. That record is what lets the refresh endpoint
9392
+ * tell a client replaying a token it never got an answer for (a response
9393
+ * lost to a redeploy, a second tab racing on boot) apart from a stranger
9394
+ * presenting a token that was never issued. Deleting the old row on sight
9395
+ * — the previous behaviour — made those two cases indistinguishable, and
9396
+ * the legitimate one is overwhelmingly the common one.
9397
+ *
9398
+ * There is deliberately NO unique constraint on (uid, user_agent,
9399
+ * ip_address). Keying a session on the IP meant one row per "device",
9400
+ * so a second browser profile behind the same NAT silently evicted the
9401
+ * first, and a phone changing networks orphaned a row on every hop.
9402
+ * User agent and IP are descriptive metadata for the sessions list;
9403
+ * `sessionId` is the identity.
9357
9404
  */
9358
9405
  const refreshTokens = tableCreator("refresh_tokens", {
9359
9406
  id: uuid("id").defaultRandom().primaryKey(),
9360
9407
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
9408
+ sessionId: uuid("session_id").defaultRandom().notNull(),
9361
9409
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
9362
9410
  expiresAt: timestamp("expires_at").notNull(),
9411
+ revoked: boolean("revoked").default(false).notNull(),
9412
+ rotatedAt: timestamp("rotated_at"),
9413
+ /**
9414
+ * When the sign-in this token descends from happened — carried across
9415
+ * every rotation, unlike `createdAt`. `users.tokensValidAfter` is
9416
+ * compared against this, so a revocation cannot be outrun by a token
9417
+ * that rotates immediately after it.
9418
+ */
9419
+ sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
9363
9420
  userAgent: varchar("user_agent", { length: 500 }),
9364
9421
  ipAddress: varchar("ip_address", { length: 45 }),
9365
9422
  createdAt: timestamp("created_at").defaultNow().notNull()
9366
- }, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress) }));
9423
+ }, (table) => ({ sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId) }));
9367
9424
  /**
9368
9425
  * Password reset tokens for forgot password flow
9369
9426
  */
@@ -10819,6 +10876,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10819
10876
  this.sendError(clientId, msg, subscriptionId);
10820
10877
  return;
10821
10878
  }
10879
+ const boundedLimit = resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch });
10822
10880
  this._subscriptions.set(subscriptionId, {
10823
10881
  clientId,
10824
10882
  type: "collection",
@@ -10827,7 +10885,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10827
10885
  filter: request.filter,
10828
10886
  orderBy: request.orderBy,
10829
10887
  order: request.order,
10830
- limit: request.limit,
10888
+ limit: boundedLimit,
10831
10889
  startAfter: request.startAfter,
10832
10890
  databaseId: request.collection?.databaseId,
10833
10891
  searchString: request.searchString
@@ -10838,7 +10896,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10838
10896
  filter: request.filter,
10839
10897
  orderBy: request.orderBy,
10840
10898
  order: request.order,
10841
- limit: request.limit,
10899
+ limit: boundedLimit,
10842
10900
  startAfter: request.startAfter,
10843
10901
  searchString: request.searchString
10844
10902
  }, authContext);
@@ -12463,11 +12521,57 @@ function buildPgRestoreArgs(opts) {
12463
12521
  ];
12464
12522
  if (opts.clean) args.push("--clean", "--if-exists");
12465
12523
  if (opts.noOwner) args.push("--no-owner");
12466
- if (opts.exitOnError) args.push("--exit-on-error");
12524
+ if (opts.exitOnError !== false) args.push("--exit-on-error");
12467
12525
  args.push(opts.inputFile);
12468
12526
  return args;
12469
12527
  }
12470
12528
  /**
12529
+ * Assemble the `pg_restore --list` argument vector. Reading a dump's table
12530
+ * of contents parses the whole archive without touching a database, so it is
12531
+ * a cheap integrity check that the file isn't truncated or corrupt.
12532
+ */
12533
+ function buildPgRestoreListArgs(inputFile) {
12534
+ return ["--list", inputFile];
12535
+ }
12536
+ /**
12537
+ * Assemble the `pg_dumpall --globals-only` argument vector. Roles (and other
12538
+ * cluster-wide objects) live outside any single database, so a per-database
12539
+ * `pg_dump` omits them. Without the `rebase_user` role the RLS GRANT
12540
+ * statements in the main dump fail on restore and RLS is silently lost — so
12541
+ * every backup captures the globals into a sidecar `.globals.sql`.
12542
+ *
12543
+ * `--no-role-passwords` keeps role secrets out of the artifact (backups may
12544
+ * be shipped off-box); roles are recreated password-less and re-secured by
12545
+ * the operator.
12546
+ */
12547
+ function buildPgDumpallGlobalsArgs(opts) {
12548
+ return [
12549
+ "--globals-only",
12550
+ "--no-role-passwords",
12551
+ "--no-password",
12552
+ `--file=${opts.outFile}`,
12553
+ `--dbname=${opts.connectionString}`
12554
+ ];
12555
+ }
12556
+ /**
12557
+ * Derive the globals sidecar path/key for a given `.dump` file. Keeps the
12558
+ * two artifacts adjacent so listing, uploading and pruning can find one from
12559
+ * the other. A name that doesn't end in `.dump` is returned unchanged with a
12560
+ * `.globals.sql` suffix appended.
12561
+ */
12562
+ function globalsFileForDump(dumpPath) {
12563
+ return dumpPath.endsWith(".dump") ? dumpPath.slice(0, -5) + ".globals.sql" : dumpPath + ".globals.sql";
12564
+ }
12565
+ /**
12566
+ * Split a `pg_dumpall --globals-only` script into individual statements.
12567
+ * Used when replaying globals on restore so each `CREATE ROLE` / `GRANT`
12568
+ * can run independently and a benign "role already exists" on one doesn't
12569
+ * abort the rest. Drops `--` comment lines and blank statements.
12570
+ */
12571
+ function splitGlobalsStatements(sql) {
12572
+ return sql.split("\n").filter((line) => !line.trim().startsWith("--")).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
12573
+ }
12574
+ /**
12471
12575
  * Resolve the Postgres connection string the backup commands should use,
12472
12576
  * mirroring the precedence the branch command already relies on.
12473
12577
  */
@@ -20756,6 +20860,58 @@ function resolveLocalBin(binName) {
20756
20860
  return null;
20757
20861
  }
20758
20862
  //#endregion
20863
+ //#region src/backup/backup-logic.ts
20864
+ /**
20865
+ * Pure-ish orchestration for the backup/restore paths, kept free of `execa`
20866
+ * and `pg` value imports so it can be unit-tested under jest (the impure
20867
+ * edges — spawning processes, opening connections — live in
20868
+ * `backup-service.ts`, which is vitest/runtime only).
20869
+ *
20870
+ * The functions here take their side-effecting dependency as an argument
20871
+ * (a statement runner, an object deleter) so tests can inject fakes.
20872
+ */
20873
+ /**
20874
+ * Replay a `pg_dumpall --globals-only` script one statement at a time,
20875
+ * tolerating per-statement failures. On a same-cluster restore the roles
20876
+ * usually already exist (`CREATE ROLE` → "already exists") and on a managed
20877
+ * provider an `ALTER ROLE <superuser>` may be refused; neither should abort
20878
+ * recreation of the roles that *are* missing. Returns how many statements
20879
+ * applied vs were skipped.
20880
+ *
20881
+ * `runStatement` executes one SQL statement and rejects on error.
20882
+ */
20883
+ async function applyGlobalsWith(runStatement, globalsSql, log = () => {}) {
20884
+ let applied = 0;
20885
+ let skipped = 0;
20886
+ for (const statement of splitGlobalsStatements(globalsSql)) try {
20887
+ await runStatement(statement);
20888
+ applied++;
20889
+ } catch (err) {
20890
+ skipped++;
20891
+ log(` • Skipped global: ${statement.split("\n")[0].slice(0, 80)} (${err instanceof Error ? err.message : String(err)})`);
20892
+ }
20893
+ return {
20894
+ applied,
20895
+ skipped
20896
+ };
20897
+ }
20898
+ /**
20899
+ * Delete each pruned dump together with its `.globals.sql` roles sidecar, so
20900
+ * pruning never orphans the roles file. The sidecar is best-effort — older
20901
+ * backups predate it, so a missing-sidecar failure is swallowed while a
20902
+ * failure deleting the dump itself propagates.
20903
+ *
20904
+ * `deleteObject` removes one key and rejects if it cannot (e.g. not found).
20905
+ */
20906
+ async function pruneWith(keys, deleteObject) {
20907
+ for (const key of keys) {
20908
+ await deleteObject(key);
20909
+ try {
20910
+ await deleteObject(globalsFileForDump(key));
20911
+ } catch {}
20912
+ }
20913
+ }
20914
+ //#endregion
20759
20915
  //#region src/backup/backup-service.ts
20760
20916
  /**
20761
20917
  * Backup / restore orchestration: thin, well-typed wrappers around
@@ -20768,6 +20924,7 @@ function resolveLocalBin(binName) {
20768
20924
  */
20769
20925
  var backup_service_exports = /* @__PURE__ */ __exportAll({
20770
20926
  BackupToolError: () => BackupToolError,
20927
+ applyGlobals: () => applyGlobals,
20771
20928
  createDump: () => createDump,
20772
20929
  detectToolMajor: () => detectToolMajor,
20773
20930
  ensureDatabaseExists: () => ensureDatabaseExists,
@@ -20777,7 +20934,8 @@ var backup_service_exports = /* @__PURE__ */ __exportAll({
20777
20934
  pruneBackups: () => pruneBackups,
20778
20935
  resolvePgBinary: () => resolvePgBinary,
20779
20936
  restoreDump: () => restoreDump,
20780
- uploadBackup: () => uploadBackup
20937
+ uploadBackup: () => uploadBackup,
20938
+ validateDump: () => validateDump
20781
20939
  });
20782
20940
  var BackupToolError = class extends Error {
20783
20941
  hint;
@@ -20787,10 +20945,10 @@ var BackupToolError = class extends Error {
20787
20945
  this.name = "BackupToolError";
20788
20946
  }
20789
20947
  };
20790
- /** Locate `pg_dump` / `pg_restore`, honouring an env override. */
20948
+ /** Locate `pg_dump` / `pg_restore` / `pg_dumpall`, honouring an env override. */
20791
20949
  function resolvePgBinary(tool, env = process.env) {
20792
- const override = tool === "pg_dump" ? env.PG_DUMP_PATH : env.PG_RESTORE_PATH;
20793
- if (override && fs.existsSync(override)) return override;
20950
+ const overrideVar = tool === "pg_dump" ? env.PG_DUMP_PATH : tool === "pg_restore" ? env.PG_RESTORE_PATH : env.PG_DUMPALL_PATH;
20951
+ if (overrideVar && fs.existsSync(overrideVar)) return overrideVar;
20794
20952
  return resolveLocalBin(tool);
20795
20953
  }
20796
20954
  /** Run `<bin> --version` and extract the major version. */
@@ -20834,6 +20992,11 @@ async function preflight(tool, connectionString, env = process.env) {
20834
20992
  * Produce a custom-format dump on local disk. When `outDir` is omitted the
20835
20993
  * file is written to the OS temp directory (used by the upload path, which
20836
20994
  * cleans it up afterwards).
20995
+ *
20996
+ * Alongside the `-Fc` dump it writes a `<name>.globals.sql` sidecar via
20997
+ * `pg_dumpall --globals-only` so the roles the dump's GRANT/RLS statements
20998
+ * depend on can be recreated on restore. Set `includeGlobals: false` to skip
20999
+ * it (e.g. when the caller has no privilege to read cluster globals).
20837
21000
  */
20838
21001
  async function createDump(opts) {
20839
21002
  const env = opts.env ?? process.env;
@@ -20852,17 +21015,92 @@ async function createDump(opts) {
20852
21015
  stdio: opts.inheritStdio ? "inherit" : "pipe",
20853
21016
  env: { ...env }
20854
21017
  });
20855
- return {
21018
+ const result = {
20856
21019
  localFile,
20857
21020
  fileName,
20858
21021
  sizeBytes: fs.existsSync(localFile) ? fs.statSync(localFile).size : 0
20859
21022
  };
21023
+ if (opts.includeGlobals !== false) {
21024
+ const dumpallBin = resolvePgBinary("pg_dumpall", env);
21025
+ if (!dumpallBin) throw new BackupToolError("Could not find the 'pg_dumpall' binary needed to capture cluster roles.", "Install the PostgreSQL client tools or set PG_DUMPALL_PATH. To take a role-incomplete backup anyway, pass includeGlobals: false.");
21026
+ const globalsFile = globalsFileForDump(localFile);
21027
+ await execa(dumpallBin, buildPgDumpallGlobalsArgs({
21028
+ connectionString: opts.connectionString,
21029
+ outFile: globalsFile
21030
+ }), {
21031
+ stdio: opts.inheritStdio ? "inherit" : "pipe",
21032
+ env: { ...env }
21033
+ });
21034
+ result.globalsFile = globalsFile;
21035
+ result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
21036
+ }
21037
+ return result;
21038
+ }
21039
+ /**
21040
+ * Cheap integrity check on a freshly written dump: it must be non-empty and
21041
+ * `pg_restore --list` must parse its table of contents without error. Used
21042
+ * before pruning older backups so a corrupt-but-exit-0 dump never becomes
21043
+ * the reason the last good backup is deleted.
21044
+ */
21045
+ async function validateDump(localFile, env = process.env) {
21046
+ if (!fs.existsSync(localFile)) return {
21047
+ ok: false,
21048
+ reason: `Dump file does not exist: ${localFile}`
21049
+ };
21050
+ if (fs.statSync(localFile).size === 0) return {
21051
+ ok: false,
21052
+ reason: "Dump file is empty (0 bytes)."
21053
+ };
21054
+ const bin = resolvePgBinary("pg_restore", env);
21055
+ if (!bin) return {
21056
+ ok: false,
21057
+ reason: "Could not find 'pg_restore' to verify the dump."
21058
+ };
21059
+ try {
21060
+ await execa(bin, buildPgRestoreListArgs(localFile), {
21061
+ stdio: "pipe",
21062
+ env: { ...env }
21063
+ });
21064
+ return { ok: true };
21065
+ } catch (err) {
21066
+ return {
21067
+ ok: false,
21068
+ reason: `pg_restore --list failed: ${err instanceof Error ? err.message : String(err)}`
21069
+ };
21070
+ }
21071
+ }
21072
+ /**
21073
+ * Replay a `pg_dumpall --globals-only` script to recreate cluster roles
21074
+ * before a restore, so the dump's GRANT/RLS statements (which reference
21075
+ * `rebase_user` and any owner roles) actually apply. Runs statement by
21076
+ * statement and tolerates per-statement failures — on a same-cluster restore
21077
+ * the roles usually already exist (`CREATE ROLE` → "already exists"), and on
21078
+ * a managed provider an `ALTER ROLE <superuser>` may be refused; neither
21079
+ * should abort role recreation. Returns how many statements applied vs were
21080
+ * skipped.
21081
+ */
21082
+ async function applyGlobals(connectionString, globalsSql, log = () => {}) {
21083
+ const { Client } = await import("pg");
21084
+ const client = new Client({ connectionString });
21085
+ await client.connect();
21086
+ try {
21087
+ return await applyGlobalsWith(async (sql) => {
21088
+ await client.query(sql);
21089
+ }, globalsSql, log);
21090
+ } finally {
21091
+ await client.end();
21092
+ }
20860
21093
  }
20861
21094
  /**
20862
21095
  * Restore a custom-format dump into the database named by
20863
21096
  * `connectionString`. Destructive when `clean` is set (drops objects
20864
21097
  * first). Never called automatically — the CLI gates it behind explicit
20865
21098
  * confirmation.
21099
+ *
21100
+ * Runs with `--exit-on-error` by default: a restore that logs-and-continues
21101
+ * past a failed GRANT (because a role was missing) reports success with RLS
21102
+ * un-enforced. Callers should recreate roles first (see {@link applyGlobals})
21103
+ * and only set `exitOnError: false` deliberately.
20866
21104
  */
20867
21105
  async function restoreDump(opts) {
20868
21106
  const env = opts.env ?? process.env;
@@ -20872,7 +21110,8 @@ async function restoreDump(opts) {
20872
21110
  connectionString: opts.connectionString,
20873
21111
  inputFile: opts.inputFile,
20874
21112
  clean: opts.clean,
20875
- noOwner: opts.noOwner
21113
+ noOwner: opts.noOwner,
21114
+ exitOnError: opts.exitOnError
20876
21115
  }), {
20877
21116
  stdio: opts.inheritStdio ? "inherit" : "pipe",
20878
21117
  env: { ...env }
@@ -20949,9 +21188,13 @@ async function listBackups(dest, storage) {
20949
21188
  */
20950
21189
  async function pruneBackups(dest, options, storage) {
20951
21190
  const toDelete = selectBackupsToPrune(await listBackups(dest, storage), options);
20952
- for (const key of toDelete) if (dest.kind === "local") {
20953
- if (fs.existsSync(key)) fs.unlinkSync(key);
20954
- } else if (storage) await storage.deleteObject(key, dest.bucket);
21191
+ await pruneWith(toDelete, dest.kind === "local" ? async (key) => {
21192
+ if (!fs.existsSync(key)) throw new Error(`not found: ${key}`);
21193
+ fs.unlinkSync(key);
21194
+ } : async (key) => {
21195
+ if (!storage) throw new BackupToolError("Storage backend required to prune object backups.");
21196
+ await storage.deleteObject(key, dest.bucket);
21197
+ });
20955
21198
  return toDelete;
20956
21199
  }
20957
21200
  //#endregion
@@ -21016,7 +21259,7 @@ function createBackupCron(config) {
21016
21259
  enabled: config.enabled ?? true,
21017
21260
  timeoutSeconds: 3600,
21018
21261
  async handler({ log }) {
21019
- const { createDump, pruneBackups, uploadBackup } = await Promise.resolve().then(() => backup_service_exports);
21262
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await Promise.resolve().then(() => backup_service_exports);
21020
21263
  const { destination } = config;
21021
21264
  if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
21022
21265
  log(`Starting backup of "${dbName}"…`);
@@ -21028,15 +21271,23 @@ function createBackupCron(config) {
21028
21271
  excludeSchemas
21029
21272
  });
21030
21273
  log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
21274
+ const check = await validateDump(dump.localFile);
21275
+ if (!check.ok) {
21276
+ if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
21277
+ if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
21278
+ throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
21279
+ }
21031
21280
  let storedKey = dump.localFile;
21032
21281
  try {
21033
21282
  if (destination.kind !== "local") {
21034
21283
  const uploaded = await uploadBackup(config.storage, dump.localFile, destination);
21035
21284
  storedKey = uploaded.storageUrl;
21036
21285
  log(`Uploaded to ${uploaded.storageUrl}`);
21286
+ if (dump.globalsFile && fs.existsSync(dump.globalsFile)) log(`Uploaded roles sidecar to ${(await uploadBackup(config.storage, dump.globalsFile, destination)).storageUrl}`);
21037
21287
  }
21038
21288
  } finally {
21039
21289
  if (destination.kind !== "local" && fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
21290
+ if (dump.globalsFile && destination.kind !== "local" && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
21040
21291
  }
21041
21292
  let pruned = [];
21042
21293
  if (config.retentionDays && config.retentionDays > 0) {
@@ -21153,6 +21404,7 @@ async function ensureAuthTablesExist(db, collection) {
21153
21404
  email_verification_sent_at TIMESTAMP WITH TIME ZONE,
21154
21405
  is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
21155
21406
  metadata JSONB DEFAULT '{}' NOT NULL,
21407
+ tokens_valid_after TIMESTAMP WITH TIME ZONE,
21156
21408
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
21157
21409
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
21158
21410
  )
@@ -21233,12 +21485,15 @@ async function ensureAuthTablesExist(db, collection) {
21233
21485
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
21234
21486
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
21235
21487
  uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21488
+ session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
21236
21489
  token_hash TEXT NOT NULL UNIQUE,
21237
21490
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
21491
+ revoked BOOLEAN DEFAULT FALSE NOT NULL,
21492
+ rotated_at TIMESTAMP WITH TIME ZONE,
21493
+ session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
21238
21494
  user_agent TEXT,
21239
21495
  ip_address TEXT,
21240
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
21241
- CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
21496
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
21242
21497
  )
21243
21498
  `);
21244
21499
  await db.execute(sql`
@@ -21328,6 +21583,7 @@ async function ensureAuthTablesExist(db, collection) {
21328
21583
  "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
21329
21584
  "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
21330
21585
  "metadata JSONB DEFAULT '{}' NOT NULL",
21586
+ "tokens_valid_after TIMESTAMP WITH TIME ZONE",
21331
21587
  "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
21332
21588
  "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
21333
21589
  ]) await db.execute(sql`
@@ -21344,42 +21600,36 @@ async function ensureAuthTablesExist(db, collection) {
21344
21600
  for (const { table_schema } of found) {
21345
21601
  const qualified = `"${table_schema}"."refresh_tokens"`;
21346
21602
  try {
21347
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS user_agent TEXT`);
21348
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS ip_address TEXT`);
21349
- await db.execute(sql`UPDATE ${sql.raw(qualified)} SET user_agent = '' WHERE user_agent IS NULL`);
21350
- await db.execute(sql`UPDATE ${sql.raw(qualified)} SET ip_address = '' WHERE ip_address IS NULL`);
21603
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
21604
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
21605
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
21606
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
21351
21607
  await db.execute(sql`
21352
- DELETE FROM ${sql.raw(qualified)} a
21353
- USING ${sql.raw(qualified)} b
21354
- WHERE a.ctid < b.ctid
21355
- AND a.uid = b.uid
21356
- AND a.user_agent = b.user_agent
21357
- AND a.ip_address = b.ip_address
21608
+ UPDATE ${sql.raw(qualified)}
21609
+ SET session_id = gen_random_uuid()::text
21610
+ WHERE session_id IS NULL
21358
21611
  `);
21359
- const uniques = await db.execute(sql`
21360
- SELECT conname, pg_get_constraintdef(oid) AS def
21361
- FROM pg_constraint
21362
- WHERE conrelid = ${sql.raw(`'${qualified}'`)}::regclass AND contype = 'u'
21612
+ await db.execute(sql`
21613
+ UPDATE ${sql.raw(qualified)}
21614
+ SET session_started_at = COALESCE(created_at, NOW())
21615
+ WHERE session_started_at IS NULL
21363
21616
  `);
21364
- for (const u of uniques.rows) logger.info(` ${qualified} unique: ${u.conname} → ${u.def}`);
21365
- if (uniques.rows.some((u) => {
21366
- const cols = (u.def.match(/\(([^)]*)\)/)?.[1] || "").split(",").map((c) => c.trim().replace(/"/g, ""));
21367
- return cols.length === 3 && cols.includes("uid") && cols.includes("user_agent") && cols.includes("ip_address");
21368
- })) logger.info(`✓ correct device-session unique already present on ${qualified}`);
21369
- else {
21370
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
21371
- await db.execute(sql`
21372
- ALTER TABLE ${sql.raw(qualified)}
21373
- ADD CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
21374
- `);
21375
- logger.info(`✅ Added correct unique_device_session constraint to ${qualified}`);
21376
- }
21617
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
21618
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
21619
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
21620
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
21621
+ await db.execute(sql`
21622
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
21623
+ ON ${sql.raw(qualified)}(session_id)
21624
+ `);
21625
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
21626
+ logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
21377
21627
  } catch (perTableError) {
21378
21628
  logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
21379
21629
  }
21380
21630
  }
21381
21631
  } catch (migrationError) {
21382
- logger.warn(`⚠️ refresh_tokens device-session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
21632
+ logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
21383
21633
  }
21384
21634
  try {
21385
21635
  if ((await db.execute(sql`
@@ -21871,34 +22121,31 @@ var UserService = class {
21871
22121
  var RefreshTokenService = class {
21872
22122
  db;
21873
22123
  refreshTokensTable;
22124
+ usersTable;
21874
22125
  constructor(db, tableOrTables) {
21875
22126
  this.db = db;
21876
- if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
21877
- else this.refreshTokensTable = tableOrTables || refreshTokens;
22127
+ if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) {
22128
+ this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
22129
+ this.usersTable = tableOrTables.users || users;
22130
+ } else {
22131
+ this.refreshTokensTable = tableOrTables || refreshTokens;
22132
+ this.usersTable = users;
22133
+ }
21878
22134
  }
21879
- async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
21880
- const safeUserAgent = userAgent || "";
21881
- const safeIpAddress = ipAddress || "";
21882
- await this.db.insert(this.refreshTokensTable).values({
21883
- uid,
21884
- tokenHash,
21885
- expiresAt,
21886
- userAgent: safeUserAgent,
21887
- ipAddress: safeIpAddress
21888
- }).onConflictDoUpdate({
21889
- target: [
21890
- this.refreshTokensTable.uid,
21891
- this.refreshTokensTable.userAgent,
21892
- this.refreshTokensTable.ipAddress
21893
- ],
21894
- set: {
21895
- tokenHash,
21896
- expiresAt
21897
- }
21898
- });
22135
+ /**
22136
+ * Whether the table actually carries a column, so a host application that
22137
+ * supplied its own `refresh_tokens` table — one that predates session
22138
+ * grouping — degrades instead of throwing on every sign-in.
22139
+ */
22140
+ has(column) {
22141
+ return Boolean(this.refreshTokensTable[column]);
21899
22142
  }
21900
- async findByHash(tokenHash) {
21901
- const [token] = await this.db.select({
22143
+ col(column) {
22144
+ return this.refreshTokensTable[column];
22145
+ }
22146
+ /** The columns to read back, narrowed to the ones this table has. */
22147
+ selection() {
22148
+ const selection = {
21902
22149
  id: this.refreshTokensTable.id,
21903
22150
  uid: this.refreshTokensTable.uid,
21904
22151
  tokenHash: this.refreshTokensTable.tokenHash,
@@ -21906,9 +22153,94 @@ var RefreshTokenService = class {
21906
22153
  createdAt: this.refreshTokensTable.createdAt,
21907
22154
  userAgent: this.refreshTokensTable.userAgent,
21908
22155
  ipAddress: this.refreshTokensTable.ipAddress
21909
- }).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
22156
+ };
22157
+ for (const optional of [
22158
+ "sessionId",
22159
+ "rotatedAt",
22160
+ "revoked",
22161
+ "sessionStartedAt"
22162
+ ]) if (this.has(optional)) selection[optional] = this.col(optional);
22163
+ return selection;
22164
+ }
22165
+ async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
22166
+ const values = {
22167
+ uid,
22168
+ tokenHash,
22169
+ expiresAt,
22170
+ userAgent: userAgent || "",
22171
+ ipAddress: ipAddress || ""
22172
+ };
22173
+ if (session && this.has("sessionId")) values.sessionId = session.id;
22174
+ if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
22175
+ await this.db.insert(this.refreshTokensTable).values(values);
22176
+ }
22177
+ async findByHash(tokenHash) {
22178
+ const [token] = await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
21910
22179
  return token || null;
21911
22180
  }
22181
+ /**
22182
+ * Record that a token was rotated away, keeping the row.
22183
+ *
22184
+ * The row is what lets `/auth/refresh` distinguish "you already used this,
22185
+ * here is a fresh one" from "no idea what this is". Deleting it — which is
22186
+ * what this used to do — collapsed both into a 401 and signed the user out
22187
+ * for the crime of losing a response.
22188
+ */
22189
+ async markRotated(tokenHash) {
22190
+ if (!this.has("rotatedAt")) {
22191
+ await this.deleteByHash(tokenHash);
22192
+ return;
22193
+ }
22194
+ await this.db.update(this.refreshTokensTable).set({ rotatedAt: /* @__PURE__ */ new Date() }).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
22195
+ }
22196
+ /** Final kill of one sign-in: logout, or revoking a device remotely. */
22197
+ async revokeSession(sessionId) {
22198
+ if (!this.has("sessionId")) return;
22199
+ if (this.has("revoked")) {
22200
+ await this.db.update(this.refreshTokensTable).set({
22201
+ revoked: true,
22202
+ ...this.has("rotatedAt") ? { rotatedAt: /* @__PURE__ */ new Date() } : {}
22203
+ }).where(eq(this.col("sessionId"), sessionId));
22204
+ return;
22205
+ }
22206
+ await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
22207
+ }
22208
+ /**
22209
+ * Housekeeping: rotation would otherwise leave a row per refresh forever.
22210
+ * Superseded rows are only needed for as long as a straggler might still
22211
+ * present them, and expired ones are dead weight everywhere.
22212
+ */
22213
+ async prune(uid, sessionId, supersededBefore) {
22214
+ const uidCol = this.refreshTokensTable.uid;
22215
+ const expiresCol = this.refreshTokensTable.expiresAt;
22216
+ if (!this.has("rotatedAt") || !this.has("sessionId")) {
22217
+ await this.db.delete(this.refreshTokensTable).where(sql`${uidCol} = ${uid} AND ${expiresCol} < NOW()`);
22218
+ return;
22219
+ }
22220
+ const rotatedCol = this.col("rotatedAt");
22221
+ const sessionCol = this.col("sessionId");
22222
+ await this.db.delete(this.refreshTokensTable).where(sql`
22223
+ ${uidCol} = ${uid}
22224
+ AND (
22225
+ ${expiresCol} < NOW()
22226
+ OR (
22227
+ ${sessionCol} = ${sessionId}
22228
+ AND ${rotatedCol} IS NOT NULL
22229
+ AND ${rotatedCol} < ${supersededBefore}
22230
+ )
22231
+ )
22232
+ `);
22233
+ }
22234
+ async getTokensValidAfter(uid) {
22235
+ if (!this.usersTable || !this.usersTable.tokensValidAfter) return null;
22236
+ const [row] = await this.db.select({ tokensValidAfter: this.usersTable.tokensValidAfter }).from(this.usersTable).where(eq(this.usersTable.id, uid));
22237
+ const value = row?.tokensValidAfter;
22238
+ return value ? new Date(value) : null;
22239
+ }
22240
+ async setTokensValidAfter(uid, at) {
22241
+ if (!this.usersTable || !this.usersTable.tokensValidAfter) return;
22242
+ await this.db.update(this.usersTable).set({ tokensValidAfter: at }).where(eq(this.usersTable.id, uid));
22243
+ }
21912
22244
  async deleteByHash(tokenHash) {
21913
22245
  await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
21914
22246
  }
@@ -21916,15 +22248,7 @@ var RefreshTokenService = class {
21916
22248
  await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
21917
22249
  }
21918
22250
  async listForUser(uid) {
21919
- return await this.db.select({
21920
- id: this.refreshTokensTable.id,
21921
- uid: this.refreshTokensTable.uid,
21922
- tokenHash: this.refreshTokensTable.tokenHash,
21923
- expiresAt: this.refreshTokensTable.expiresAt,
21924
- createdAt: this.refreshTokensTable.createdAt,
21925
- userAgent: this.refreshTokensTable.userAgent,
21926
- ipAddress: this.refreshTokensTable.ipAddress
21927
- }).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
22251
+ return await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
21928
22252
  }
21929
22253
  async deleteById(id, uid) {
21930
22254
  await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
@@ -22069,8 +22393,23 @@ var PostgresTokenRepository = class {
22069
22393
  this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
22070
22394
  this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
22071
22395
  }
22072
- async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
22073
- await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
22396
+ async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
22397
+ await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
22398
+ }
22399
+ async markRefreshTokenRotated(tokenHash) {
22400
+ await this.refreshTokenService.markRotated(tokenHash);
22401
+ }
22402
+ async revokeRefreshTokenSession(sessionId) {
22403
+ await this.refreshTokenService.revokeSession(sessionId);
22404
+ }
22405
+ async pruneRefreshTokens(uid, sessionId, supersededBefore) {
22406
+ await this.refreshTokenService.prune(uid, sessionId, supersededBefore);
22407
+ }
22408
+ async getTokensValidAfter(uid) {
22409
+ return this.refreshTokenService.getTokensValidAfter(uid);
22410
+ }
22411
+ async setTokensValidAfter(uid, at) {
22412
+ await this.refreshTokenService.setTokensValidAfter(uid, at);
22074
22413
  }
22075
22414
  async findRefreshTokenByHash(tokenHash) {
22076
22415
  return this.refreshTokenService.findByHash(tokenHash);
@@ -22236,8 +22575,23 @@ var PostgresAuthRepository = class {
22236
22575
  };
22237
22576
  }
22238
22577
  async deleteRole(_id) {}
22239
- async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
22240
- await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
22578
+ async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
22579
+ await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
22580
+ }
22581
+ async markRefreshTokenRotated(tokenHash) {
22582
+ await this.tokenRepository.markRefreshTokenRotated(tokenHash);
22583
+ }
22584
+ async revokeRefreshTokenSession(sessionId) {
22585
+ await this.tokenRepository.revokeRefreshTokenSession(sessionId);
22586
+ }
22587
+ async pruneRefreshTokens(uid, sessionId, supersededBefore) {
22588
+ await this.tokenRepository.pruneRefreshTokens(uid, sessionId, supersededBefore);
22589
+ }
22590
+ async getTokensValidAfter(uid) {
22591
+ return this.tokenRepository.getTokensValidAfter(uid);
22592
+ }
22593
+ async setTokensValidAfter(uid, at) {
22594
+ await this.tokenRepository.setTokensValidAfter(uid, at);
22241
22595
  }
22242
22596
  async findRefreshTokenByHash(tokenHash) {
22243
22597
  return this.tokenRepository.findRefreshTokenByHash(tokenHash);
@@ -23516,6 +23870,6 @@ function createPostgresAdapter(pgConfig) {
23516
23870
  };
23517
23871
  }
23518
23872
  //#endregion
23519
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
23873
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, globalsFileForDump, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
23520
23874
 
23521
23875
  //# sourceMappingURL=index.es.js.map