@abloatai/ablo 0.9.4 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Scoped-role automation + tenant-routing fix. `ablo migrate` now auto-creates the RLS-gated scoped role (zero SQL) with a log-safe SCRAM-SHA-256 password verifier, plus a Neon/Supabase scoped-role `databaseUrl` recipe. Fix a jsonb double-encode that corrupted per-tenant routing and silently fell back to the shared pool.
8
+
3
9
  ## 0.9.4
4
10
 
5
11
  ### Patch Changes
package/dist/cli.cjs CHANGED
@@ -125060,7 +125060,7 @@ ${lanes.join("\n")}
125060
125060
  }
125061
125061
  }
125062
125062
  function createImportCallExpressionAMD(arg, containsLexicalThis) {
125063
- const resolve5 = factory2.createUniqueName("resolve");
125063
+ const resolve6 = factory2.createUniqueName("resolve");
125064
125064
  const reject = factory2.createUniqueName("reject");
125065
125065
  const parameters = [
125066
125066
  factory2.createParameterDeclaration(
@@ -125069,7 +125069,7 @@ ${lanes.join("\n")}
125069
125069
  /*dotDotDotToken*/
125070
125070
  void 0,
125071
125071
  /*name*/
125072
- resolve5
125072
+ resolve6
125073
125073
  ),
125074
125074
  factory2.createParameterDeclaration(
125075
125075
  /*modifiers*/
@@ -125086,7 +125086,7 @@ ${lanes.join("\n")}
125086
125086
  factory2.createIdentifier("require"),
125087
125087
  /*typeArguments*/
125088
125088
  void 0,
125089
- [factory2.createArrayLiteralExpression([arg || factory2.createOmittedExpression()]), resolve5, reject]
125089
+ [factory2.createArrayLiteralExpression([arg || factory2.createOmittedExpression()]), resolve6, reject]
125090
125090
  )
125091
125091
  )
125092
125092
  ]);
@@ -211695,8 +211695,8 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range
211695
211695
  installPackage(options) {
211696
211696
  this.packageInstallId++;
211697
211697
  const request = { kind: "installPackage", ...options, id: this.packageInstallId };
211698
- const promise = new Promise((resolve5, reject) => {
211699
- (this.packageInstalledPromise ?? (this.packageInstalledPromise = /* @__PURE__ */ new Map())).set(this.packageInstallId, { resolve: resolve5, reject });
211698
+ const promise = new Promise((resolve6, reject) => {
211699
+ (this.packageInstalledPromise ?? (this.packageInstalledPromise = /* @__PURE__ */ new Map())).set(this.packageInstallId, { resolve: resolve6, reject });
211700
211700
  });
211701
211701
  this.installer.send(request);
211702
211702
  return promise;
@@ -213965,7 +213965,7 @@ var require_path_browserify = __commonJS({
213965
213965
  }
213966
213966
  var posix = {
213967
213967
  // path.resolve([from ...], to)
213968
- resolve: function resolve5() {
213968
+ resolve: function resolve6() {
213969
213969
  var resolvedPath = "";
213970
213970
  var resolvedAbsolute = false;
213971
213971
  var cwd;
@@ -218865,41 +218865,41 @@ var require_queue = __commonJS({
218865
218865
  queue.drained = drained;
218866
218866
  return queue;
218867
218867
  function push2(value) {
218868
- var p2 = new Promise(function(resolve5, reject) {
218868
+ var p2 = new Promise(function(resolve6, reject) {
218869
218869
  pushCb(value, function(err, result) {
218870
218870
  if (err) {
218871
218871
  reject(err);
218872
218872
  return;
218873
218873
  }
218874
- resolve5(result);
218874
+ resolve6(result);
218875
218875
  });
218876
218876
  });
218877
218877
  p2.catch(noop3);
218878
218878
  return p2;
218879
218879
  }
218880
218880
  function unshift(value) {
218881
- var p2 = new Promise(function(resolve5, reject) {
218881
+ var p2 = new Promise(function(resolve6, reject) {
218882
218882
  unshiftCb(value, function(err, result) {
218883
218883
  if (err) {
218884
218884
  reject(err);
218885
218885
  return;
218886
218886
  }
218887
- resolve5(result);
218887
+ resolve6(result);
218888
218888
  });
218889
218889
  });
218890
218890
  p2.catch(noop3);
218891
218891
  return p2;
218892
218892
  }
218893
218893
  function drained() {
218894
- var p2 = new Promise(function(resolve5) {
218894
+ var p2 = new Promise(function(resolve6) {
218895
218895
  process.nextTick(function() {
218896
218896
  if (queue.idle()) {
218897
- resolve5();
218897
+ resolve6();
218898
218898
  } else {
218899
218899
  var previousDrain = queue.drain;
218900
218900
  queue.drain = function() {
218901
218901
  if (typeof previousDrain === "function") previousDrain();
218902
- resolve5();
218902
+ resolve6();
218903
218903
  queue.drain = previousDrain;
218904
218904
  };
218905
218905
  }
@@ -219396,9 +219396,9 @@ var require_stream3 = __commonJS({
219396
219396
  });
219397
219397
  }
219398
219398
  _getStat(filepath) {
219399
- return new Promise((resolve5, reject) => {
219399
+ return new Promise((resolve6, reject) => {
219400
219400
  this._stat(filepath, this._fsStatSettings, (error, stats) => {
219401
- return error === null ? resolve5(stats) : reject(error);
219401
+ return error === null ? resolve6(stats) : reject(error);
219402
219402
  });
219403
219403
  });
219404
219404
  }
@@ -219423,10 +219423,10 @@ var require_async5 = __commonJS({
219423
219423
  this._readerStream = new stream_1.default(this._settings);
219424
219424
  }
219425
219425
  dynamic(root, options) {
219426
- return new Promise((resolve5, reject) => {
219426
+ return new Promise((resolve6, reject) => {
219427
219427
  this._walkAsync(root, options, (error, entries) => {
219428
219428
  if (error === null) {
219429
- resolve5(entries);
219429
+ resolve6(entries);
219430
219430
  } else {
219431
219431
  reject(error);
219432
219432
  }
@@ -219436,10 +219436,10 @@ var require_async5 = __commonJS({
219436
219436
  async static(patterns, options) {
219437
219437
  const entries = [];
219438
219438
  const stream = this._readerStream.static(patterns, options);
219439
- return new Promise((resolve5, reject) => {
219439
+ return new Promise((resolve6, reject) => {
219440
219440
  stream.once("error", reject);
219441
219441
  stream.on("data", (entry) => entries.push(entry));
219442
- stream.once("end", () => resolve5(entries));
219442
+ stream.once("end", () => resolve6(entries));
219443
219443
  });
219444
219444
  }
219445
219445
  };
@@ -252345,12 +252345,12 @@ type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "js
252345
252345
  };
252346
252346
  var NodeRuntimeFileSystem = class {
252347
252347
  delete(path2) {
252348
- return new Promise((resolve5, reject) => {
252348
+ return new Promise((resolve6, reject) => {
252349
252349
  fs__namespace.rm(path2, { recursive: true }, (err) => {
252350
252350
  if (err)
252351
252351
  reject(err);
252352
252352
  else
252353
- resolve5();
252353
+ resolve6();
252354
252354
  });
252355
252355
  });
252356
252356
  }
@@ -252369,12 +252369,12 @@ type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "js
252369
252369
  }));
252370
252370
  }
252371
252371
  readFile(filePath, encoding = "utf-8") {
252372
- return new Promise((resolve5, reject) => {
252372
+ return new Promise((resolve6, reject) => {
252373
252373
  fs__namespace.readFile(filePath, encoding, (err, data) => {
252374
252374
  if (err)
252375
252375
  reject(err);
252376
252376
  else
252377
- resolve5(data);
252377
+ resolve6(data);
252378
252378
  });
252379
252379
  });
252380
252380
  }
@@ -252382,12 +252382,12 @@ type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "js
252382
252382
  return fs__namespace.readFileSync(filePath, encoding);
252383
252383
  }
252384
252384
  async writeFile(filePath, fileText) {
252385
- await new Promise((resolve5, reject) => {
252385
+ await new Promise((resolve6, reject) => {
252386
252386
  fs__namespace.writeFile(filePath, fileText, (err) => {
252387
252387
  if (err)
252388
252388
  reject(err);
252389
252389
  else
252390
- resolve5();
252390
+ resolve6();
252391
252391
  });
252392
252392
  });
252393
252393
  }
@@ -252401,12 +252401,12 @@ type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "js
252401
252401
  fs__namespace.mkdirSync(dirPath, { recursive: true });
252402
252402
  }
252403
252403
  move(srcPath, destPath) {
252404
- return new Promise((resolve5, reject) => {
252404
+ return new Promise((resolve6, reject) => {
252405
252405
  fs__namespace.rename(srcPath, destPath, (err) => {
252406
252406
  if (err)
252407
252407
  reject(err);
252408
252408
  else
252409
- resolve5();
252409
+ resolve6();
252410
252410
  });
252411
252411
  });
252412
252412
  }
@@ -252414,12 +252414,12 @@ type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "js
252414
252414
  fs__namespace.renameSync(srcPath, destPath);
252415
252415
  }
252416
252416
  copy(srcPath, destPath) {
252417
- return new Promise((resolve5, reject) => {
252417
+ return new Promise((resolve6, reject) => {
252418
252418
  fs__namespace.copyFile(srcPath, destPath, (err) => {
252419
252419
  if (err)
252420
252420
  reject(err);
252421
252421
  else
252422
- resolve5();
252422
+ resolve6();
252423
252423
  });
252424
252424
  });
252425
252425
  }
@@ -252427,15 +252427,15 @@ type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "js
252427
252427
  fs__namespace.copyFileSync(srcPath, destPath);
252428
252428
  }
252429
252429
  stat(path2) {
252430
- return new Promise((resolve5, reject) => {
252430
+ return new Promise((resolve6, reject) => {
252431
252431
  fs__namespace.stat(path2, (err, stat) => {
252432
252432
  if (err) {
252433
252433
  if (err.code === "ENOENT" || err.code === "ENOTDIR")
252434
- resolve5(void 0);
252434
+ resolve6(void 0);
252435
252435
  else
252436
252436
  reject(err);
252437
252437
  } else {
252438
- resolve5(stat);
252438
+ resolve6(stat);
252439
252439
  }
252440
252440
  });
252441
252441
  });
@@ -276702,7 +276702,7 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
276702
276702
  // src/cli/index.ts
276703
276703
  var import_picocolors16 = __toESM(require_picocolors(), 1);
276704
276704
  var import_fs11 = require("fs");
276705
- var import_path6 = require("path");
276705
+ var import_path7 = require("path");
276706
276706
  var import_child_process2 = require("child_process");
276707
276707
 
276708
276708
  // src/cli/migrate.ts
@@ -276775,6 +276775,7 @@ var ERROR_CODES = {
276775
276775
  issuer_register_forbidden: wire("permission", 403, false, "Registering a trusted issuer requires a secret (sk_) API key."),
276776
276776
  capability_invalid: wire("capability", 403, false, "The capability is unknown, revoked, or expired."),
276777
276777
  test_database_not_registered: wire("permission", 403, false, "Test mode requires a registered dev database for this org \u2014 run `npx ablo init`, or construct the client with `databaseUrl` using your test key."),
276778
+ tenant_routing_failed: wire("server", 500, true, "The org's registered database could not be resolved or dialed. Ablo never falls back to shared storage for a dedicated tenant \u2014 retry, and check the datasource status if it persists."),
276778
276779
  database_role_cannot_enforce_rls: wire("permission", 403, false, "The connected database role cannot enforce row-level security (superuser or BYPASSRLS)."),
276779
276780
  database_role_unreadable: wire("permission", 403, false, "The connected database role could not be introspected."),
276780
276781
  database_tables_unforced_rls: wire("permission", 403, false, "Synced tables in the connected database do not have FORCE ROW LEVEL SECURITY applied."),
@@ -277089,6 +277090,7 @@ var ErrorBodyShapeSchema = import_zod2.z.object({
277089
277090
  // src/cli/migrate.ts
277090
277091
  var import_picocolors3 = __toESM(require_picocolors(), 1);
277091
277092
  var import_fs4 = require("fs");
277093
+ var import_path3 = require("path");
277092
277094
 
277093
277095
  // node_modules/postgres/src/index.js
277094
277096
  init_cjs_shims();
@@ -277106,9 +277108,9 @@ var originError = /* @__PURE__ */ Symbol("OriginError");
277106
277108
  var CLOSE = {};
277107
277109
  var Query = class extends Promise {
277108
277110
  constructor(strings, args, handler, canceller, options = {}) {
277109
- let resolve5, reject;
277111
+ let resolve6, reject;
277110
277112
  super((a, b4) => {
277111
- resolve5 = a;
277113
+ resolve6 = a;
277112
277114
  reject = b4;
277113
277115
  });
277114
277116
  this.tagged = Array.isArray(strings.raw);
@@ -277119,7 +277121,7 @@ var Query = class extends Promise {
277119
277121
  this.options = options;
277120
277122
  this.state = null;
277121
277123
  this.statement = null;
277122
- this.resolve = (x2) => (this.active = false, resolve5(x2));
277124
+ this.resolve = (x2) => (this.active = false, resolve6(x2));
277123
277125
  this.reject = (x2) => (this.active = false, reject(x2));
277124
277126
  this.active = false;
277125
277127
  this.cancelled = null;
@@ -277167,12 +277169,12 @@ var Query = class extends Promise {
277167
277169
  if (this.executed && !this.active)
277168
277170
  return { done: true };
277169
277171
  prev && prev();
277170
- const promise = new Promise((resolve5, reject) => {
277172
+ const promise = new Promise((resolve6, reject) => {
277171
277173
  this.cursorFn = (value) => {
277172
- resolve5({ value, done: false });
277174
+ resolve6({ value, done: false });
277173
277175
  return new Promise((r2) => prev = r2);
277174
277176
  };
277175
- this.resolve = () => (this.active = false, resolve5({ done: true }));
277177
+ this.resolve = () => (this.active = false, resolve6({ done: true }));
277176
277178
  this.reject = (x2) => (this.active = false, reject(x2));
277177
277179
  });
277178
277180
  this.execute();
@@ -277804,12 +277806,12 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
277804
277806
  x2.on("drain", drain);
277805
277807
  return x2;
277806
277808
  }
277807
- async function cancel({ pid, secret }, resolve5, reject) {
277809
+ async function cancel({ pid, secret }, resolve6, reject) {
277808
277810
  try {
277809
277811
  cancelMessage = bytes_default().i32(16).i32(80877102).i32(pid).i32(secret).end(16);
277810
277812
  await connect();
277811
277813
  socket.once("error", reject);
277812
- socket.once("close", resolve5);
277814
+ socket.once("close", resolve6);
277813
277815
  } catch (error2) {
277814
277816
  reject(error2);
277815
277817
  }
@@ -278758,7 +278760,7 @@ function parseEvent(x2) {
278758
278760
  init_cjs_shims();
278759
278761
  var import_stream2 = __toESM(require("stream"), 1);
278760
278762
  function largeObject(sql, oid, mode2 = 131072 | 262144) {
278761
- return new Promise(async (resolve5, reject) => {
278763
+ return new Promise(async (resolve6, reject) => {
278762
278764
  await sql.begin(async (sql2) => {
278763
278765
  let finish;
278764
278766
  !oid && ([{ oid }] = await sql2`select lo_creat(-1) as oid`);
@@ -278784,7 +278786,7 @@ function largeObject(sql, oid, mode2 = 131072 | 262144) {
278784
278786
  ) seek
278785
278787
  `
278786
278788
  };
278787
- resolve5(lo);
278789
+ resolve6(lo);
278788
278790
  return new Promise(async (r2) => finish = r2);
278789
278791
  async function readable({
278790
278792
  highWaterMark = 2048 * 8,
@@ -278958,8 +278960,8 @@ function Postgres(a, b4) {
278958
278960
  }
278959
278961
  async function reserve() {
278960
278962
  const queue = queue_default();
278961
- const c = open.length ? open.shift() : await new Promise((resolve5, reject) => {
278962
- const query = { reserve: resolve5, reject };
278963
+ const c = open.length ? open.shift() : await new Promise((resolve6, reject) => {
278964
+ const query = { reserve: resolve6, reject };
278963
278965
  queries.push(query);
278964
278966
  closed.length && connect(closed.shift(), query);
278965
278967
  });
@@ -278996,9 +278998,9 @@ function Postgres(a, b4) {
278996
278998
  let uncaughtError, result;
278997
278999
  name && await sql2`savepoint ${sql2(name)}`;
278998
279000
  try {
278999
- result = await new Promise((resolve5, reject) => {
279001
+ result = await new Promise((resolve6, reject) => {
279000
279002
  const x2 = fn2(sql2);
279001
- Promise.resolve(Array.isArray(x2) ? Promise.all(x2) : x2).then(resolve5, reject);
279003
+ Promise.resolve(Array.isArray(x2) ? Promise.all(x2) : x2).then(resolve6, reject);
279002
279004
  });
279003
279005
  if (uncaughtError)
279004
279006
  throw uncaughtError;
@@ -279055,8 +279057,8 @@ function Postgres(a, b4) {
279055
279057
  return c.execute(query) ? move(c, busy) : move(c, full);
279056
279058
  }
279057
279059
  function cancel(query) {
279058
- return new Promise((resolve5, reject) => {
279059
- query.state ? query.active ? connection_default(options).cancel(query.state, resolve5, reject) : query.cancelled = { resolve: resolve5, reject } : (queries.remove(query), query.cancelled = true, query.reject(Errors.generic("57014", "canceling statement due to user request")), resolve5());
279060
+ return new Promise((resolve6, reject) => {
279061
+ query.state ? query.active ? connection_default(options).cancel(query.state, resolve6, reject) : query.cancelled = { resolve: resolve6, reject } : (queries.remove(query), query.cancelled = true, query.reject(Errors.generic("57014", "canceling statement due to user request")), resolve6());
279060
279062
  });
279061
279063
  }
279062
279064
  async function end({ timeout = null } = {}) {
@@ -279075,11 +279077,11 @@ function Postgres(a, b4) {
279075
279077
  async function close() {
279076
279078
  await Promise.all(connections.map((c) => c.end()));
279077
279079
  }
279078
- async function destroy(resolve5) {
279080
+ async function destroy(resolve6) {
279079
279081
  await Promise.all(connections.map((c) => c.terminate()));
279080
279082
  while (queries.length)
279081
279083
  queries.shift().reject(Errors.connection("CONNECTION_DESTROYED", options));
279082
- resolve5();
279084
+ resolve6();
279083
279085
  }
279084
279086
  function connect(c, query) {
279085
279087
  move(c, connecting);
@@ -279224,6 +279226,81 @@ function osUsername() {
279224
279226
  }
279225
279227
  }
279226
279228
 
279229
+ // src/cli/dbRole.ts
279230
+ init_cjs_shims();
279231
+ var import_crypto2 = require("crypto");
279232
+ var DEFAULT_SCOPED_ROLE = "ablo_app";
279233
+ async function detectRoleSafety(sql) {
279234
+ const rows = await sql`SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`;
279235
+ const row = rows[0];
279236
+ if (!row) return { role: "unknown", superuser: false, bypassRls: false, unsafe: false };
279237
+ return {
279238
+ role: row.rolname,
279239
+ superuser: row.rolsuper,
279240
+ bypassRls: row.rolbypassrls,
279241
+ unsafe: row.rolsuper || row.rolbypassrls
279242
+ };
279243
+ }
279244
+ function generateRolePassword() {
279245
+ return (0, import_crypto2.randomBytes)(24).toString("base64url");
279246
+ }
279247
+ function scramSha256Verifier(password, iterations = 4096) {
279248
+ const salt = (0, import_crypto2.randomBytes)(16);
279249
+ const saltedPassword = (0, import_crypto2.pbkdf2Sync)(password, salt, iterations, 32, "sha256");
279250
+ const clientKey = (0, import_crypto2.createHmac)("sha256", saltedPassword).update("Client Key").digest();
279251
+ const storedKey = (0, import_crypto2.createHash)("sha256").update(clientKey).digest();
279252
+ const serverKey = (0, import_crypto2.createHmac)("sha256", saltedPassword).update("Server Key").digest();
279253
+ return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${storedKey.toString("base64")}:${serverKey.toString("base64")}`;
279254
+ }
279255
+ function scopedRoleStatements(input) {
279256
+ const role = input.role ?? DEFAULT_SCOPED_ROLE;
279257
+ const q2 = (id) => `"${id.replace(/"/g, '""')}"`;
279258
+ const pw = (input.passwordMode ?? "scram-verifier") === "scram-verifier" ? scramSha256Verifier(input.password) : input.password.replace(/'/g, "''");
279259
+ return [
279260
+ `DO $$ BEGIN
279261
+ CREATE ROLE ${q2(role)} LOGIN PASSWORD '${pw}'
279262
+ NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
279263
+ EXCEPTION WHEN duplicate_object THEN
279264
+ -- Rerun: rotate ONLY the password. Re-asserting attributes here trips
279265
+ -- managed-Postgres permission walls (Neon: "permission denied to alter
279266
+ -- role" for attribute changes by non-superusers); the attributes were set
279267
+ -- at creation, and the server-side probe still audits the live role.
279268
+ ALTER ROLE ${q2(role)} WITH LOGIN PASSWORD '${pw}';
279269
+ END $$;`,
279270
+ `GRANT CREATE, CONNECT ON DATABASE ${q2(input.database)} TO ${q2(role)};`,
279271
+ `GRANT CREATE, USAGE ON SCHEMA public TO ${q2(role)};`
279272
+ ];
279273
+ }
279274
+ function rewriteDatabaseUrl(ownerUrl, role, password) {
279275
+ const url = new URL(ownerUrl);
279276
+ url.username = role;
279277
+ url.password = password;
279278
+ return url.toString();
279279
+ }
279280
+ async function createScopedRole(ownerUrl, options) {
279281
+ const role = options?.role ?? DEFAULT_SCOPED_ROLE;
279282
+ const password = generateRolePassword();
279283
+ const database = new URL(ownerUrl).pathname.replace(/^\//, "") || "postgres";
279284
+ const sql = src_default(ownerUrl, { max: 1, prepare: false, onnotice: () => {
279285
+ } });
279286
+ try {
279287
+ try {
279288
+ for (const statement of scopedRoleStatements({ database, role, password })) {
279289
+ await sql.unsafe(statement);
279290
+ }
279291
+ } catch (err) {
279292
+ const message = err instanceof Error ? err.message : String(err);
279293
+ if (!/plaintext password/i.test(message)) throw err;
279294
+ for (const statement of scopedRoleStatements({ database, role, password, passwordMode: "plaintext" })) {
279295
+ await sql.unsafe(statement);
279296
+ }
279297
+ }
279298
+ } finally {
279299
+ await sql.end({ timeout: 5 });
279300
+ }
279301
+ return { role, databaseUrl: rewriteDatabaseUrl(ownerUrl, role, password) };
279302
+ }
279303
+
279227
279304
  // src/cli/migrate.ts
279228
279305
  var import_schema2 = require("@abloatai/ablo/schema");
279229
279306
  var import_source = require("@abloatai/ablo/source");
@@ -279500,9 +279577,9 @@ async function push(argv) {
279500
279577
  }
279501
279578
  console.error(import_picocolors2.default.dim(` Re-push with ${import_picocolors2.default.bold("--force")} to override, or use ${import_picocolors2.default.bold("--rename old:new")} if you renamed a model.`));
279502
279579
  } else if (status2 === 403) {
279503
- console.error(import_picocolors2.default.red(` Forbidden: ${body.reason ?? "key lacks schema:push scope"}.`));
279580
+ console.error(import_picocolors2.default.red(` Forbidden: ${body.message ?? body.reason ?? "key lacks schema:push scope"}`));
279504
279581
  } else {
279505
- console.error(import_picocolors2.default.red(` Push failed (${status2}): ${body.reason ?? bodyText}`));
279582
+ console.error(import_picocolors2.default.red(` Push failed (${status2}): ${body.message ?? body.reason ?? bodyText}`));
279506
279583
  }
279507
279584
  process.exit(1);
279508
279585
  }
@@ -279595,7 +279672,7 @@ async function applyStatements(dbUrl, targetSchema, statements, concurrent = [])
279595
279672
  if (pg.code === PG_LOCK_NOT_AVAILABLE && attempt < MAX_LOCK_ATTEMPTS) {
279596
279673
  const backoffMs = Math.min(6e4, 10 * 2 ** attempt) + Math.floor(Math.random() * 50);
279597
279674
  log.warn("schema change blocked by a lock; backing off and retrying", { targetSchema, attempt, backoffMs });
279598
- await new Promise((resolve5) => setTimeout(resolve5, backoffMs));
279675
+ await new Promise((resolve6) => setTimeout(resolve6, backoffMs));
279599
279676
  continue;
279600
279677
  }
279601
279678
  throw err;
@@ -279653,18 +279730,96 @@ async function migrate(argv) {
279653
279730
  console.error(import_picocolors3.default.red(" Set DATABASE_URL (or ABLO_DATABASE_URL) to apply, or use --dry-run to preview."));
279654
279731
  process.exit(1);
279655
279732
  }
279733
+ const effectiveUrl = await ensureScopedRole(dbUrl);
279656
279734
  try {
279657
- await applyStatements(dbUrl, args.targetSchema, plan.statements, plan.concurrent);
279735
+ await applyStatements(effectiveUrl, args.targetSchema, plan.statements, plan.concurrent);
279658
279736
  console.log(` ${import_picocolors3.default.green("\u2713")} Migration complete`);
279659
279737
  } catch {
279660
279738
  process.exit(1);
279661
279739
  }
279662
279740
  }
279741
+ async function ensureScopedRole(dbUrl) {
279742
+ let safety;
279743
+ try {
279744
+ const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
279745
+ } });
279746
+ try {
279747
+ safety = await detectRoleSafety(sql);
279748
+ } finally {
279749
+ await sql.end({ timeout: 5 });
279750
+ }
279751
+ } catch {
279752
+ return dbUrl;
279753
+ }
279754
+ if (!safety.unsafe) return dbUrl;
279755
+ const why = safety.superuser ? "a superuser" : "BYPASSRLS";
279756
+ console.log(
279757
+ `
279758
+ ${import_picocolors3.default.yellow("!")} DATABASE_URL connects as ${import_picocolors3.default.bold(safety.role)} \u2014 ${why}, so row-level security can't be enforced.
279759
+ Ablo's server will refuse this connection (${import_picocolors3.default.bold("database_role_cannot_enforce_rls")}).`
279760
+ );
279761
+ if (!process.stdout.isTTY) {
279762
+ console.log(
279763
+ import_picocolors3.default.dim(
279764
+ ` Create a scoped role and update DATABASE_URL \u2014 run \`npx ablo migrate\` interactively
279765
+ to do it automatically, or see https://docs.abloatai.com/quickstart#scoped-role`
279766
+ )
279767
+ );
279768
+ return dbUrl;
279769
+ }
279770
+ const proceed = await ye({
279771
+ message: `Create a scoped role ${DEFAULT_SCOPED_ROLE} (NOSUPERUSER, NOBYPASSRLS) and update DATABASE_URL?`,
279772
+ initialValue: true
279773
+ });
279774
+ if (pD(proceed) || !proceed) {
279775
+ console.log(import_picocolors3.default.dim(" Skipped \u2014 see https://docs.abloatai.com/quickstart#scoped-role for the manual recipe."));
279776
+ return dbUrl;
279777
+ }
279778
+ const { role, databaseUrl } = await createScopedRole(dbUrl);
279779
+ const where = persistDatabaseUrl(databaseUrl);
279780
+ console.log(
279781
+ ` ${import_picocolors3.default.green("\u2713")} Created role ${import_picocolors3.default.bold(role)} and updated ${import_picocolors3.default.bold("DATABASE_URL")} in ${import_picocolors3.default.bold(where)}.
279782
+ ` + import_picocolors3.default.dim(` The owner credential never left this machine; the new password was written, not printed.`)
279783
+ );
279784
+ return databaseUrl;
279785
+ }
279786
+ function persistDatabaseUrl(databaseUrl, cwd = process.cwd()) {
279787
+ const line = `DATABASE_URL=${databaseUrl}`;
279788
+ for (const name of [".env.local", ".env"]) {
279789
+ const path = (0, import_path3.resolve)(cwd, name);
279790
+ if (!(0, import_fs4.existsSync)(path)) continue;
279791
+ const content = (0, import_fs4.readFileSync)(path, "utf8");
279792
+ if (/^DATABASE_URL=/m.test(content)) {
279793
+ (0, import_fs4.writeFileSync)(path, content.replace(/^DATABASE_URL=.*$/m, line));
279794
+ return name;
279795
+ }
279796
+ }
279797
+ const envLocal = (0, import_path3.resolve)(cwd, ".env.local");
279798
+ if ((0, import_fs4.existsSync)(envLocal)) {
279799
+ const content = (0, import_fs4.readFileSync)(envLocal, "utf8");
279800
+ (0, import_fs4.appendFileSync)(envLocal, `${content.endsWith("\n") || content.length === 0 ? "" : "\n"}${line}
279801
+ `);
279802
+ } else {
279803
+ (0, import_fs4.writeFileSync)(envLocal, `${line}
279804
+ `, { mode: 384 });
279805
+ }
279806
+ const gitignorePath = (0, import_path3.resolve)(cwd, ".gitignore");
279807
+ const gitignore = (0, import_fs4.existsSync)(gitignorePath) ? (0, import_fs4.readFileSync)(gitignorePath, "utf8") : "";
279808
+ if (!/^(\.env\.local|\.env\*|\.env\.\*|\.env.*)$/m.test(gitignore)) {
279809
+ (0, import_fs4.writeFileSync)(
279810
+ gitignorePath,
279811
+ `${gitignore.endsWith("\n") || gitignore.length === 0 ? gitignore : `${gitignore}
279812
+ `}.env.local
279813
+ `
279814
+ );
279815
+ }
279816
+ return ".env.local";
279817
+ }
279663
279818
 
279664
279819
  // src/cli/generate.ts
279665
279820
  init_cjs_shims();
279666
279821
  var import_fs5 = require("fs");
279667
- var import_path3 = require("path");
279822
+ var import_path4 = require("path");
279668
279823
  var import_picocolors4 = __toESM(require_picocolors(), 1);
279669
279824
  var import_schema3 = require("@abloatai/ablo/schema");
279670
279825
  var DEFAULT_SCHEMA_PATH3 = "ablo/schema.ts";
@@ -279709,8 +279864,8 @@ async function generate(argv) {
279709
279864
  console.error(import_picocolors4.default.red(` ${err instanceof Error ? err.message : String(err)}`));
279710
279865
  process.exit(1);
279711
279866
  }
279712
- const abs = (0, import_path3.resolve)(process.cwd(), args.out);
279713
- (0, import_fs5.mkdirSync)((0, import_path3.dirname)(abs), { recursive: true });
279867
+ const abs = (0, import_path4.resolve)(process.cwd(), args.out);
279868
+ (0, import_fs5.mkdirSync)((0, import_path4.dirname)(abs), { recursive: true });
279714
279869
  (0, import_fs5.writeFileSync)(abs, source);
279715
279870
  console.log(` ${import_picocolors4.default.green("\u2713")} Generated types \u2192 ${import_picocolors4.default.bold(args.out)}`);
279716
279871
  }
@@ -279719,7 +279874,7 @@ async function generate(argv) {
279719
279874
  init_cjs_shims();
279720
279875
  var import_picocolors5 = __toESM(require_picocolors(), 1);
279721
279876
  var import_fs6 = require("fs");
279722
- var import_path4 = require("path");
279877
+ var import_path5 = require("path");
279723
279878
  var import_schema4 = require("@abloatai/ablo/schema");
279724
279879
 
279725
279880
  // src/cli/theme.ts
@@ -279786,7 +279941,7 @@ function classifyKey(apiKey) {
279786
279941
  return { ok: false, reason: `${import_picocolors5.default.bold("ABLO_API_KEY")} is not an Ablo key (expected ${import_picocolors5.default.bold("sk_test_\u2026")}).` };
279787
279942
  }
279788
279943
  function wireEnvLocal(apiKey, cwd = process.cwd()) {
279789
- const envPath = (0, import_path4.resolve)(cwd, ".env.local");
279944
+ const envPath = (0, import_path5.resolve)(cwd, ".env.local");
279790
279945
  const line = `ABLO_API_KEY=${apiKey}`;
279791
279946
  let action;
279792
279947
  if (!(0, import_fs6.existsSync)(envPath)) {
@@ -279807,7 +279962,7 @@ function wireEnvLocal(apiKey, cwd = process.cwd()) {
279807
279962
  action = `Updated ${import_picocolors5.default.bold("ABLO_API_KEY")} in ${import_picocolors5.default.bold(".env.local")} ${import_picocolors5.default.dim(`(was ${match[1].slice(0, 12)}\u2026)`)}`;
279808
279963
  }
279809
279964
  }
279810
- const gitignorePath = (0, import_path4.resolve)(cwd, ".gitignore");
279965
+ const gitignorePath = (0, import_path5.resolve)(cwd, ".gitignore");
279811
279966
  const gitignore = (0, import_fs6.existsSync)(gitignorePath) ? (0, import_fs6.readFileSync)(gitignorePath, "utf8") : "";
279812
279967
  const ignored = /^(\.env\.local|\.env\*|\.env\.\*|\.env.*)$/m.test(gitignore);
279813
279968
  let gitignoreNote = "";
@@ -279848,15 +280003,15 @@ async function runPush(schema, args) {
279848
280003
  return { ok: false, message: lines.join("\n") };
279849
280004
  }
279850
280005
  if (status2 === 403) {
280006
+ const serverSays = body.message ?? body.reason;
280007
+ const hint = body.code === "database_role_cannot_enforce_rls" ? `Run ${import_picocolors5.default.bold("npx ablo migrate")} \u2014 it creates the scoped role for you (your DB credential never leaves this machine).` : `Schema authoring needs a ${import_picocolors5.default.bold("sandbox")} key with ${import_picocolors5.default.bold("schema:push")} \u2014 manage keys at ${import_picocolors5.default.cyan("https://abloatai.com")}.`;
279851
280008
  return {
279852
280009
  ok: false,
279853
- message: `This key can't author schema (${body.reason ?? "missing schema:push scope"}).
279854
- ` + import_picocolors5.default.dim(
279855
- `Use a ${import_picocolors5.default.bold("sandbox")} key, or one with ${import_picocolors5.default.bold("schema authoring")} enabled at ${import_picocolors5.default.cyan("https://abloatai.com")}.`
279856
- )
280010
+ message: `${serverSays ?? "This key can't author schema (missing schema:push scope)."}
280011
+ ` + import_picocolors5.default.dim(hint)
279857
280012
  };
279858
280013
  }
279859
- return { ok: false, message: `Push failed (${status2}): ${body.reason ?? bodyText}` };
280014
+ return { ok: false, message: `Push failed (${status2}): ${body.message ?? body.reason ?? bodyText}` };
279860
280015
  }
279861
280016
  async function dev(argv) {
279862
280017
  let args;
@@ -279898,7 +280053,7 @@ async function dev(argv) {
279898
280053
  }
279899
280054
  console.log(` Your app is wired for the sandbox.`);
279900
280055
  if (!args.watch) return;
279901
- const abs = (0, import_path4.resolve)(process.cwd(), args.schemaPath);
280056
+ const abs = (0, import_path5.resolve)(process.cwd(), args.schemaPath);
279902
280057
  console.log(` ${import_picocolors5.default.dim(`watching ${args.schemaPath} \u2026 (Ctrl-C to stop)`)}
279903
280058
  `);
279904
280059
  let timer2 = null;
@@ -281297,7 +281452,7 @@ async function prismaPull(argv) {
281297
281452
  init_cjs_shims();
281298
281453
  var import_picocolors15 = __toESM(require_picocolors(), 1);
281299
281454
  var import_fs10 = require("fs");
281300
- var import_path5 = require("path");
281455
+ var import_path6 = require("path");
281301
281456
  var DEFAULT_OUT4 = "ablo/schema.ts";
281302
281457
  var DEFAULT_IMPORT3 = "@abloatai/ablo/schema";
281303
281458
  var BASE_FIELD_NAMES2 = /* @__PURE__ */ new Set(["id", "organizationId", "createdBy", "createdAt", "updatedAt"]);
@@ -281423,7 +281578,7 @@ function parseDrizzlePullArgs(argv) {
281423
281578
  async function loadModule(path) {
281424
281579
  const { createJiti } = await import("jiti");
281425
281580
  const jiti = createJiti(process.cwd());
281426
- const mod = await jiti.import((0, import_path5.resolve)(path));
281581
+ const mod = await jiti.import((0, import_path6.resolve)(path));
281427
281582
  return mod;
281428
281583
  }
281429
281584
  async function drizzlePull(argv) {
@@ -281731,13 +281886,13 @@ async function init(args = []) {
281731
281886
  }
281732
281887
  }
281733
281888
  }
281734
- (0, import_fs11.writeFileSync)((0, import_path6.join)(abloDir, "schema.ts"), schemaSource);
281889
+ (0, import_fs11.writeFileSync)((0, import_path7.join)(abloDir, "schema.ts"), schemaSource);
281735
281890
  created.push(`${abloDir}/schema.ts${schemaNote}`);
281736
- (0, import_fs11.writeFileSync)((0, import_path6.join)(abloDir, "index.ts"), generateSyncConfig(auth, storage));
281891
+ (0, import_fs11.writeFileSync)((0, import_path7.join)(abloDir, "index.ts"), generateSyncConfig(auth, storage));
281737
281892
  created.push(`${abloDir}/index.ts`);
281738
281893
  const orm = detectOrm(opts.orm);
281739
281894
  if (storage === "endpoint") {
281740
- (0, import_fs11.writeFileSync)((0, import_path6.join)(abloDir, "data-source.ts"), generateDataSource(orm));
281895
+ (0, import_fs11.writeFileSync)((0, import_path7.join)(abloDir, "data-source.ts"), generateDataSource(orm));
281741
281896
  created.push(`${abloDir}/data-source.ts${orm === "drizzle" ? " (Drizzle)" : " (Prisma)"}`);
281742
281897
  }
281743
281898
  const envFile = framework === "nextjs" ? ".env.local" : ".env";
@@ -281754,25 +281909,25 @@ async function init(args = []) {
281754
281909
  }
281755
281910
  }
281756
281911
  if (agent) {
281757
- (0, import_fs11.writeFileSync)((0, import_path6.join)(abloDir, "agent.ts"), generateAgent());
281912
+ (0, import_fs11.writeFileSync)((0, import_path7.join)(abloDir, "agent.ts"), generateAgent());
281758
281913
  created.push(`${abloDir}/agent.ts`);
281759
281914
  }
281760
281915
  if (framework === "nextjs") {
281761
281916
  if (storage === "endpoint") {
281762
- const webhookDir = (0, import_path6.join)("app", "api", "ablo", "webhooks");
281917
+ const webhookDir = (0, import_path7.join)("app", "api", "ablo", "webhooks");
281763
281918
  (0, import_fs11.mkdirSync)(webhookDir, { recursive: true });
281764
- (0, import_fs11.writeFileSync)((0, import_path6.join)(webhookDir, "route.ts"), generateWebhookRoute(orm));
281919
+ (0, import_fs11.writeFileSync)((0, import_path7.join)(webhookDir, "route.ts"), generateWebhookRoute(orm));
281765
281920
  created.push(`${webhookDir}/route.ts${orm === "prisma" ? " (Prisma mirror)" : " (add your database write)"}`);
281766
281921
  }
281767
- (0, import_fs11.writeFileSync)((0, import_path6.join)("app", "providers.tsx"), generateProviders());
281922
+ (0, import_fs11.writeFileSync)((0, import_path7.join)("app", "providers.tsx"), generateProviders());
281768
281923
  created.push(`app/providers.tsx ${import_picocolors16.default.dim("(wrap app/layout.tsx in <Providers>)")}`);
281769
- const sessionDir = (0, import_path6.join)("app", "api", "ablo-session");
281924
+ const sessionDir = (0, import_path7.join)("app", "api", "ablo-session");
281770
281925
  (0, import_fs11.mkdirSync)(sessionDir, { recursive: true });
281771
- (0, import_fs11.writeFileSync)((0, import_path6.join)(sessionDir, "route.ts"), generateSessionRoute());
281926
+ (0, import_fs11.writeFileSync)((0, import_path7.join)(sessionDir, "route.ts"), generateSessionRoute());
281772
281927
  created.push(`app/api/ablo-session/route.ts ${import_picocolors16.default.dim("(wire your auth)")}`);
281773
281928
  }
281774
281929
  if (framework !== "vanilla") {
281775
- (0, import_fs11.writeFileSync)((0, import_path6.join)(abloDir, "TaskList.tsx"), generateComponent());
281930
+ (0, import_fs11.writeFileSync)((0, import_path7.join)(abloDir, "TaskList.tsx"), generateComponent());
281776
281931
  created.push(`${abloDir}/TaskList.tsx`);
281777
281932
  }
281778
281933
  Me(created.map((f) => `${import_picocolors16.default.green("\u2713")} ${f}`).join("\n"), "Created");
@@ -139,6 +139,7 @@ export declare const ERROR_CODES: {
139
139
  readonly issuer_register_forbidden: ErrorCodeSpec;
140
140
  readonly capability_invalid: ErrorCodeSpec;
141
141
  readonly test_database_not_registered: ErrorCodeSpec;
142
+ readonly tenant_routing_failed: ErrorCodeSpec;
142
143
  readonly database_role_cannot_enforce_rls: ErrorCodeSpec;
143
144
  readonly database_role_unreadable: ErrorCodeSpec;
144
145
  readonly database_tables_unforced_rls: ErrorCodeSpec;
@@ -130,6 +130,7 @@ export const ERROR_CODES = {
130
130
  issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (sk_) API key.'),
131
131
  capability_invalid: wire('capability', 403, false, 'The capability is unknown, revoked, or expired.'),
132
132
  test_database_not_registered: wire('permission', 403, false, 'Test mode requires a registered dev database for this org — run `npx ablo init`, or construct the client with `databaseUrl` using your test key.'),
133
+ tenant_routing_failed: wire('server', 500, true, "The org's registered database could not be resolved or dialed. Ablo never falls back to shared storage for a dedicated tenant — retry, and check the datasource status if it persists."),
133
134
  database_role_cannot_enforce_rls: wire('permission', 403, false, 'The connected database role cannot enforce row-level security (superuser or BYPASSRLS).'),
134
135
  database_role_unreadable: wire('permission', 403, false, 'The connected database role could not be introspected.'),
135
136
  database_tables_unforced_rls: wire('permission', 403, false, 'Synced tables in the connected database do not have FORCE ROW LEVEL SECURITY applied.'),
@@ -74,7 +74,26 @@ export const ablo = Ablo({
74
74
 
75
75
  Use a dedicated **non-superuser role** for the connection — Ablo enforces
76
76
  tenant isolation with row-level security, so the server rejects superuser or
77
- `BYPASSRLS` roles outright.
77
+ `BYPASSRLS` roles outright (`database_role_cannot_enforce_rls`).
78
+
79
+ > **Neon / Supabase note:** the connection string those dashboards hand you
80
+ > uses the database OWNER role (e.g. `neondb_owner`), which is `BYPASSRLS` —
81
+ > Ablo will reject it. You don't have to fix that by hand: `npx ablo migrate`
82
+ > detects the unsafe role and offers to create the scoped one for you — from
83
+ > your machine, so the owner credential never reaches Ablo. It writes the new
84
+ > `DATABASE_URL` into your env file (the generated password is never printed).
85
+ >
86
+ > Prefer to do it manually? The equivalent SQL:
87
+ >
88
+ > ```sql
89
+ > CREATE ROLE ablo_app LOGIN PASSWORD '<strong password>'
90
+ > NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
91
+ > GRANT CREATE, CONNECT ON DATABASE <your_db> TO ablo_app;
92
+ > GRANT CREATE, USAGE ON SCHEMA public TO ablo_app;
93
+ > ```
94
+ >
95
+ > Then swap the user/password in the dashboard's string:
96
+ > `postgres://ablo_app:<password>@<same-host>/<same-db>?sslmode=require`.
78
97
 
79
98
  Don't want a connection string to leave your infrastructure? Keep
80
99
  `DATABASE_URL` in your app only and expose one signed **Data Source endpoint**
package/llms-full.txt CHANGED
@@ -142,6 +142,15 @@ This applies to any API-backed app: Python, Rails, Go, or Node. The backend keep
142
142
  its service layer and DB credentials. Ablo gets a Data Source endpoint and uses
143
143
  `ABLO_API_KEY`, not a database URL.
144
144
 
145
+ When the user DOES use the connection-string path (`Ablo({ databaseUrl })` /
146
+ `DATABASE_URL` at init): the role must be NON-superuser and NON-BYPASSRLS —
147
+ Ablo enforces row-level security and rejects owner roles with
148
+ `database_role_cannot_enforce_rls`. Neon's and Supabase's default dashboard
149
+ strings use the database OWNER (e.g. `neondb_owner`) and ARE rejected. EASIEST: have the user run `npx ablo migrate` — it detects the unsafe role and creates the scoped one automatically from their machine (owner credential never reaches Ablo; new DATABASE_URL written to the env file). Manual alternative — create a scoped role first (`CREATE ROLE ablo_app LOGIN PASSWORD '...'
150
+ NOSUPERUSER NOBYPASSRLS; GRANT CREATE, CONNECT ON DATABASE <db> TO ablo_app;
151
+ GRANT CREATE, USAGE ON SCHEMA public TO ablo_app;`), then swap user/password
152
+ into the same host/db string.
153
+
145
154
  ## Client Behavior
146
155
 
147
156
  The options that matter: `schema` and `apiKey`. Everything else
package/llms.txt CHANGED
@@ -120,6 +120,8 @@ Do not add `databaseURL` to `Ablo(...)`. Application and agent code use `ABLO_AP
120
120
 
121
121
  Every schema model is backed by a database, and the default is YOUR OWN. Keep your rows in your Postgres and expose a Data Source endpoint that hands Ablo an ORM `adapter` (Drizzle is the default; Prisma and Kysely are also supported) — it owns the transaction, exactly-once idempotency, and outbox in ONE pass (no hand-written `commit`/`events`). Your `DATABASE_URL` lives in your app, never in `Ablo(...)`; run `npx ablo migrate` to provision the synced-model tables in your DB.
122
122
 
123
+ GOTCHA the user WILL hit: `DATABASE_URL` must use a NON-superuser, NON-BYPASSRLS role (Ablo enforces row-level security; owner roles are rejected with `database_role_cannot_enforce_rls`). Neon's and Supabase's default dashboard connection strings use the database OWNER (e.g. `neondb_owner`) and are rejected. EASIEST: `npx ablo migrate` detects the unsafe role and creates the scoped one automatically from the user's machine (owner credential never reaches Ablo; new DATABASE_URL written to the env file). Manual alternative — create a scoped role first: `CREATE ROLE ablo_app LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS; GRANT CREATE, CONNECT ON DATABASE <db> TO ablo_app; GRANT CREATE, USAGE ON SCHEMA public TO ablo_app;` — then swap user/password into the same host/db string.
124
+
123
125
  ```ts
124
126
  // app/api/ablo/source/route.ts
125
127
  import { dataSourceNext } from '@abloatai/ablo/source/next';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "State control API for AI agents and collaborative apps.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",