latticesql 2.1.1 → 2.2.1

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.js CHANGED
@@ -4240,6 +4240,12 @@ function buildAdapter(dbPath, options) {
4240
4240
  return new SQLiteAdapter(sqlitePath, adapterOpts);
4241
4241
  }
4242
4242
  var NOT_DELETED2 = "(deleted_at IS NULL OR deleted_at = '')";
4243
+ function rowAclVisibleExists(teamExpr, tableExpr, pkExpr) {
4244
+ return `EXISTS (SELECT 1 FROM "__lattice_row_acl" la WHERE la."team_id" = ${teamExpr} AND la."table_name" = ${tableExpr} AND la."pk" = ${pkExpr} AND (la."owner_user_id" = ? OR la."visibility" = 'everyone' OR (la."visibility" = 'custom' AND EXISTS (SELECT 1 FROM "__lattice_row_grants" lg WHERE lg."team_id" = la."team_id" AND lg."table_name" = la."table_name" AND lg."pk" = la."pk" AND lg."grantee_user_id" = ?))))`;
4245
+ }
4246
+ function rowAclAbsent(teamExpr, tableExpr, pkExpr) {
4247
+ return `NOT EXISTS (SELECT 1 FROM "__lattice_row_acl" la2 WHERE la2."team_id" = ${teamExpr} AND la2."table_name" = ${tableExpr} AND la2."pk" = ${pkExpr})`;
4248
+ }
4243
4249
  var Lattice = class _Lattice {
4244
4250
  _adapter;
4245
4251
  _changelogService;
@@ -4795,9 +4801,7 @@ var Lattice = class _Lattice {
4795
4801
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
4796
4802
  values
4797
4803
  );
4798
- const pkCol = pkCols[0] ?? "id";
4799
- const rawPk = rowWithPk[pkCol];
4800
- const pkValue = rawPk != null ? String(rawPk) : "";
4804
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4801
4805
  await this._appendChangelog(table, pkValue, "insert", rowWithPk, null);
4802
4806
  this._sanitizer.emitAudit(table, "insert", pkValue);
4803
4807
  await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
@@ -4840,9 +4844,7 @@ var Lattice = class _Lattice {
4840
4844
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) ON CONFLICT(${conflictCols}) DO UPDATE SET ${updateCols}`,
4841
4845
  values
4842
4846
  );
4843
- const pkCol = pkCols[0] ?? "id";
4844
- const rawPk = rowWithPk[pkCol];
4845
- const pkValue = rawPk != null ? String(rawPk) : "";
4847
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4846
4848
  this._sanitizer.emitAudit(table, "update", pkValue);
4847
4849
  this._scheduleAutoRender();
4848
4850
  return pkValue;
@@ -4888,7 +4890,7 @@ var Lattice = class _Lattice {
4888
4890
  }
4889
4891
  const values = [...Object.values(encrypted), ...pkParams];
4890
4892
  await runAsyncOrSync(this._adapter, `UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
4891
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
4893
+ const auditId = this._serializePkLookup(table, id);
4892
4894
  await this._appendChangelog(table, auditId, "update", sanitized, previousValues);
4893
4895
  this._sanitizer.emitAudit(table, "update", auditId);
4894
4896
  await this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
@@ -4923,7 +4925,7 @@ var Lattice = class _Lattice {
4923
4925
  previousRow = await getAsyncOrSync(this._adapter, `SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null;
4924
4926
  }
4925
4927
  await runAsyncOrSync(this._adapter, `DELETE FROM "${table}" WHERE ${clause}`, params);
4926
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
4928
+ const auditId = this._serializePkLookup(table, id);
4927
4929
  await this._appendChangelog(
4928
4930
  table,
4929
4931
  auditId,
@@ -5339,6 +5341,140 @@ var Lattice = class _Lattice {
5339
5341
  const rows = await allAsyncOrSync(this._adapter, sql, params);
5340
5342
  return this._decryptRows(table, rows);
5341
5343
  }
5344
+ /**
5345
+ * Row-level-security list read for Lattice Teams (2.2). Returns only the
5346
+ * rows of `table` that `userId` may see in team `teamId`, evaluated
5347
+ * entirely in SQL (indexed, bounded — never "load every row then filter
5348
+ * in JS"). A row is visible iff it has a `__lattice_row_acl` entry owned by
5349
+ * the user or marked 'everyone', or a 'custom' entry with a matching
5350
+ * `__lattice_row_grants` row, OR it has no ACL entry at all and the caller
5351
+ * passes `noAclVisible` (the table default is 'everyone', or the user owns
5352
+ * the table — the pre-2.2 / never-narrowed case). Soft-deleted rows are
5353
+ * excluded by default; results reuse the same decrypt path as `query()`.
5354
+ *
5355
+ * The ACL predicate joins on the table's primary-key column cast to TEXT
5356
+ * (ACL pks are stored as TEXT), so it is correct regardless of the user
5357
+ * table's pk type and works on both SQLite and Postgres. The teams layer's
5358
+ * `listVisibleRows` (src/teams/row-access.ts) is the intended caller.
5359
+ */
5360
+ async queryVisible(table, opts) {
5361
+ const notInit = this._notInitError();
5362
+ if (notInit) return notInit;
5363
+ this._assertIdent(table);
5364
+ if (opts.orderBy) this._assertIdent(table, opts.orderBy);
5365
+ const cols = this._ensureColumnCache(table);
5366
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(table));
5367
+ let softDelete = "";
5368
+ if (cols.has("deleted_at") && opts.deleted !== "any") {
5369
+ softDelete = opts.deleted === "only" ? `t."deleted_at" IS NOT NULL AND ` : `t."deleted_at" IS NULL AND `;
5370
+ }
5371
+ const params = [];
5372
+ let visClause;
5373
+ if (pkExpr) {
5374
+ visClause = rowAclVisibleExists("?", "?", pkExpr);
5375
+ params.push(opts.teamId, table, opts.userId, opts.userId);
5376
+ if (opts.noAclVisible) {
5377
+ visClause += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5378
+ params.push(opts.teamId, table);
5379
+ }
5380
+ } else {
5381
+ visClause = opts.noAclVisible ? "1=1" : "1=0";
5382
+ }
5383
+ let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${visClause})`;
5384
+ if (opts.orderBy && cols.has(opts.orderBy)) {
5385
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
5386
+ sql += ` ORDER BY t."${opts.orderBy}" ${dir}`;
5387
+ }
5388
+ if (opts.limit !== void 0 && Number.isFinite(opts.limit)) {
5389
+ sql += ` LIMIT ${Math.trunc(opts.limit).toString()}`;
5390
+ }
5391
+ if (opts.offset !== void 0 && Number.isFinite(opts.offset)) {
5392
+ if (opts.limit === void 0 && this.getDialect() === "sqlite") sql += " LIMIT -1";
5393
+ sql += ` OFFSET ${Math.trunc(opts.offset).toString()}`;
5394
+ }
5395
+ const rows = await allAsyncOrSync(this._adapter, sql, params);
5396
+ return this._decryptRows(table, rows);
5397
+ }
5398
+ /**
5399
+ * Visible-row counts for MANY tables in a single round-trip, using the same
5400
+ * ACL predicate as {@link queryVisible} — so dashboard tiles agree with what
5401
+ * the rows view lists and a physical count never reveals the existence or
5402
+ * volume of rows the user can't see. One aggregated
5403
+ * `SELECT (SELECT COUNT(*) …) AS c0, …` statement (no per-table fan-out, so
5404
+ * a session pooler with few slots survives concurrent refreshes), capped at
5405
+ * 50 tables per pass; overflow is logged and skipped (no silent truncation)
5406
+ * and those tables count as absent — the caller renders "—". Soft-deleted
5407
+ * rows are excluded wherever the table carries `deleted_at`, matching the
5408
+ * default rows view.
5409
+ */
5410
+ async countVisibleMany(specs, opts) {
5411
+ const out = /* @__PURE__ */ new Map();
5412
+ const notInit = this._notInitError();
5413
+ if (notInit) return notInit;
5414
+ if (specs.length === 0) return out;
5415
+ const VISIBLE_COUNT_CAP = 50;
5416
+ let bounded = specs;
5417
+ if (bounded.length > VISIBLE_COUNT_CAP) {
5418
+ const dropped = bounded.length - VISIBLE_COUNT_CAP;
5419
+ console.warn(
5420
+ `[lattice] visible-count pass capped at ${String(VISIBLE_COUNT_CAP)} tables; ${String(dropped)} table(s) report no count this pass`
5421
+ );
5422
+ bounded = bounded.slice(0, VISIBLE_COUNT_CAP);
5423
+ }
5424
+ const selects = [];
5425
+ const params = [];
5426
+ for (const [i, spec] of bounded.entries()) {
5427
+ this._assertIdent(spec.table);
5428
+ const cols = this._ensureColumnCache(spec.table);
5429
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
5430
+ const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
5431
+ let predicate;
5432
+ if (pkExpr) {
5433
+ predicate = rowAclVisibleExists("?", "?", pkExpr);
5434
+ params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5435
+ if (spec.noAclVisible) {
5436
+ predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5437
+ params.push(opts.teamId, spec.table);
5438
+ }
5439
+ } else {
5440
+ predicate = spec.noAclVisible ? "1=1" : "1=0";
5441
+ }
5442
+ selects.push(
5443
+ `(SELECT COUNT(*) FROM "${spec.table}" t WHERE ${softDelete}(${predicate})) AS c${String(i)}`
5444
+ );
5445
+ }
5446
+ const row = await getAsyncOrSync(this._adapter, `SELECT ${selects.join(", ")}`, params);
5447
+ if (!row) return out;
5448
+ for (const [i, spec] of bounded.entries()) {
5449
+ const raw = row[`c${String(i)}`];
5450
+ const n = typeof raw === "bigint" ? Number(raw) : Number(raw);
5451
+ if (Number.isFinite(n) && n >= 0) out.set(spec.table, n);
5452
+ }
5453
+ return out;
5454
+ }
5455
+ /**
5456
+ * Hosted-sync change-log pull, filtered per recipient for 2.2 row-level
5457
+ * security (the hosted server's sole enforcement mechanism). Returns
5458
+ * `__lattice_change_log` rows with seq > `since` for team `teamId` that
5459
+ * `userId` is permitted to receive:
5460
+ * - targeted envelopes (`recipient_user_id = userId`), plus
5461
+ * - broadcast envelopes (`recipient_user_id IS NULL`) that are either
5462
+ * table-level (`pk IS NULL` — schema / unshare, delivered to all) or
5463
+ * whose row is currently visible to the user via `__lattice_row_acl` /
5464
+ * `__lattice_row_grants` (or has no ACL entry and the table defaults to
5465
+ * 'everyone').
5466
+ * Ordered by seq, capped at `limit`. Raw SQL because the predicate needs
5467
+ * OR / EXISTS that the `query()` API can't express; bounded by the seq
5468
+ * window and indexed ACL point-lookups. Mirrors {@link queryVisible}'s
5469
+ * visibility logic so a member never pulls the bytes of a row they can't see.
5470
+ */
5471
+ async listChangesForRecipient(teamId, since, userId, limit) {
5472
+ const notInit = this._notInitError();
5473
+ if (notInit) return notInit;
5474
+ const sql = `SELECT cl.* FROM "__lattice_change_log" cl WHERE cl."team_id" = ? AND cl."seq" > ? AND (cl."recipient_user_id" = ? OR (cl."recipient_user_id" IS NULL AND (cl."pk" IS NULL OR ${rowAclVisibleExists('cl."team_id"', 'cl."table_name"', 'cl."pk"')} OR (${rowAclAbsent('cl."team_id"', 'cl."table_name"', 'cl."pk"')} AND EXISTS (SELECT 1 FROM "__lattice_shared_objects" so WHERE so."team_id" = cl."team_id" AND so."table_name" = cl."table_name" AND so."deleted_at" IS NULL AND so."default_row_visibility" = 'everyone'))))) ORDER BY cl."seq" ASC LIMIT ${Math.trunc(limit).toString()}`;
5475
+ const params = [teamId, since, userId, userId, userId];
5476
+ return allAsyncOrSync(this._adapter, sql, params);
5477
+ }
5342
5478
  async count(table, opts = {}) {
5343
5479
  const notInit = this._notInitError();
5344
5480
  if (notInit) return notInit;
@@ -5557,6 +5693,62 @@ var Lattice = class _Lattice {
5557
5693
  const params = pkCols.map((col) => id[col]);
5558
5694
  return { clause: clauses.join(" AND "), params };
5559
5695
  }
5696
+ // ── Composite-key serialization for the row-level-permission layer ───────
5697
+ // The row ACL (`__lattice_row_acl`/`__lattice_row_grants`) and the
5698
+ // change-log key each row by a single TEXT `pk`. For a table whose primary
5699
+ // key spans several columns (e.g. a junction table `(project_id,
5700
+ // meeting_id)` with no `id`), that key must encode EVERY pk column, and the
5701
+ // write side (what we store) must match the read side (the SQL that
5702
+ // reconstructs it from row columns). These three helpers are the single
5703
+ // source of truth for both. A single-column key serializes to the bare
5704
+ // value (so all pre-2.2.1 single-`id` ACL data stays valid).
5705
+ static _PK_SEP = " ";
5706
+ /**
5707
+ * The primary-key columns of `table` that PHYSICALLY exist, in declared
5708
+ * order. Empty when the table has no Lattice-addressable key — e.g. a table
5709
+ * reached via raw SQL whose PK metadata defaulted to `['id']` but that has
5710
+ * no `id` column. Callers treat an empty result as "unkeyable" (no per-row
5711
+ * ACL is possible, so the row-perm SQL must not reference a pk column).
5712
+ */
5713
+ _resolvedPkCols(table) {
5714
+ const cols = this._ensureColumnCache(table);
5715
+ return this._schema.getPrimaryKey(table).filter((c) => cols.has(c));
5716
+ }
5717
+ /** Canonical ACL / change-log `pk` string for a row. Matches {@link _pkSqlExpr}. */
5718
+ _serializeRowPk(table, row) {
5719
+ const pkCols = this._resolvedPkCols(table);
5720
+ const cols = pkCols.length > 0 ? pkCols : ["id"];
5721
+ return cols.map((c) => {
5722
+ const v = row[c];
5723
+ return v != null ? String(v) : "";
5724
+ }).join(_Lattice._PK_SEP);
5725
+ }
5726
+ /**
5727
+ * Canonical `pk` string for a {@link PkLookup} used by update/delete, so a
5728
+ * row addressed by lookup keys its change-log entry identically to the way
5729
+ * {@link _serializeRowPk} keyed it at insert time.
5730
+ */
5731
+ _serializePkLookup(table, id) {
5732
+ if (typeof id === "string") return id;
5733
+ const pkCols = this._resolvedPkCols(table);
5734
+ if (pkCols.length === 0) return JSON.stringify(id);
5735
+ return pkCols.map((c) => {
5736
+ const v = id[c];
5737
+ return v != null ? String(v) : "";
5738
+ }).join(_Lattice._PK_SEP);
5739
+ }
5740
+ /**
5741
+ * SQL expression reconstructing {@link _serializeRowPk} from a row aliased
5742
+ * `t`. Returns null when the table is unkeyable (no pk columns present) — the
5743
+ * caller must then avoid referencing a pk column at all. Dialect-aware tab
5744
+ * separator: SQLite `char(9)`, Postgres `chr(9)` (both = U+0009), matching
5745
+ * {@link _PK_SEP}.
5746
+ */
5747
+ _pkSqlExpr(pkCols) {
5748
+ if (pkCols.length === 0) return null;
5749
+ const sep2 = this.getDialect() === "postgres" ? "chr(9)" : "char(9)";
5750
+ return pkCols.map((c) => `CAST(t."${c}" AS TEXT)`).join(` || ${sep2} || `);
5751
+ }
5560
5752
  /**
5561
5753
  * Convert Filter objects into SQL clause strings and bound params.
5562
5754
  * An `in` filter with an empty array is silently ignored (produces no clause).
@@ -6497,6 +6689,13 @@ var NATIVE_ENTITY_DEFS = {
6497
6689
  columns: {
6498
6690
  id: "TEXT PRIMARY KEY",
6499
6691
  title: "TEXT",
6692
+ // Cloud user id of the member who started the thread (the operator's
6693
+ // `teamContext.myUserId`). A chat is PRIVATE to its author — on a team
6694
+ // cloud the chat routes only ever return threads whose owner matches the
6695
+ // requesting member. NULL on local single-user databases (no team
6696
+ // context) and on pre-2.2.1 threads, which the routes treat as the local
6697
+ // operator's own (visible only when there is no team context).
6698
+ owner_user_id: "TEXT",
6500
6699
  created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6501
6700
  updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6502
6701
  deleted_at: "TEXT"
@@ -6511,6 +6710,10 @@ var NATIVE_ENTITY_DEFS = {
6511
6710
  // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6512
6711
  // to match the generic, dialect-agnostic native-entity style.
6513
6712
  thread_id: "TEXT",
6713
+ // Cloud user id of the member the message belongs to — mirrors the
6714
+ // owning thread's owner_user_id so a message read can be filtered
6715
+ // independently of the thread join. NULL on local DBs / pre-2.2.1 rows.
6716
+ owner_user_id: "TEXT",
6514
6717
  // user | assistant | tool | feed | system
6515
6718
  role: "TEXT NOT NULL DEFAULT 'user'",
6516
6719
  // JSON payload: text, tool_use / tool_result blocks, attachments, or
@@ -7060,7 +7263,13 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
7060
7263
  // Client-generated idempotency key for offline replay: a queued edit
7061
7264
  // carries a stable edit_id, so re-sending it after a reconnect is a
7062
7265
  // no-op rather than a duplicate write. Nullable + additive.
7063
- edit_id: "TEXT"
7266
+ edit_id: "TEXT",
7267
+ // Per-recipient targeting for 2.2 hard row-level sync. NULL =
7268
+ // broadcast (delivered to every member, then filtered at pull time
7269
+ // against __lattice_row_acl); non-null = targeted to exactly this
7270
+ // user (the grant / revoke / delete fan-out). Nullable + additive,
7271
+ // same precedent as client_ts / edit_id.
7272
+ recipient_user_id: "TEXT"
7064
7273
  },
7065
7274
  render: () => "",
7066
7275
  outputFile: ".lattice-teams/change-log.md"
@@ -7076,6 +7285,44 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
7076
7285
  primaryKey: ["team_id", "table_name", "pk"],
7077
7286
  render: () => "",
7078
7287
  outputFile: ".lattice-teams/row-links.md"
7288
+ },
7289
+ // Per-row access control for a team cloud (2.2 row-level permissions).
7290
+ // Mirrors __lattice_object_owners at row granularity: each shared row
7291
+ // has an owner (its creator) and a visibility. Enforcement is at the
7292
+ // application layer (see src/teams/row-access.ts) — every member shares
7293
+ // the same physical DB, so a row a user can't see must be filtered out
7294
+ // before its bytes reach them. Kept out-of-band (never injected into
7295
+ // user tables) so the user's own schema stays untouched.
7296
+ __lattice_row_acl: {
7297
+ columns: {
7298
+ team_id: "TEXT NOT NULL",
7299
+ table_name: "TEXT NOT NULL",
7300
+ pk: "TEXT NOT NULL",
7301
+ owner_user_id: "TEXT NOT NULL",
7302
+ // 'private' = owner only · 'everyone' = all team members ·
7303
+ // 'custom' = the explicit grant list in __lattice_row_grants.
7304
+ visibility: "TEXT NOT NULL CHECK (visibility IN ('private', 'everyone', 'custom'))",
7305
+ created_at: "TEXT NOT NULL",
7306
+ updated_at: "TEXT NOT NULL"
7307
+ },
7308
+ primaryKey: ["team_id", "table_name", "pk"],
7309
+ render: () => "",
7310
+ outputFile: ".lattice-teams/row-acl.md"
7311
+ },
7312
+ // Explicit per-row grant list, consulted only when the owning row's
7313
+ // __lattice_row_acl.visibility = 'custom'. One row per (row, grantee).
7314
+ __lattice_row_grants: {
7315
+ columns: {
7316
+ team_id: "TEXT NOT NULL",
7317
+ table_name: "TEXT NOT NULL",
7318
+ pk: "TEXT NOT NULL",
7319
+ grantee_user_id: "TEXT NOT NULL",
7320
+ granted_by_user_id: "TEXT NOT NULL",
7321
+ granted_at: "TEXT NOT NULL"
7322
+ },
7323
+ primaryKey: ["team_id", "table_name", "pk", "grantee_user_id"],
7324
+ render: () => "",
7325
+ outputFile: ".lattice-teams/row-grants.md"
7079
7326
  }
7080
7327
  };
7081
7328
  var LOCAL_INTERNAL_TABLE_DEFS = {
@@ -7176,6 +7423,48 @@ async function installCloudInternalTriggers(db) {
7176
7423
  };
7177
7424
  await db.migrate([migration]);
7178
7425
  }
7426
+ async function installRowPermsSchema(db) {
7427
+ const migrations = [
7428
+ {
7429
+ // 01 — per-table default visibility for newly-created rows. Born
7430
+ // 'private' unless the table owner opts the table into 'everyone';
7431
+ // the backfill (06) flips already-shared tables to preserve pre-2.2
7432
+ // visibility. No IF NOT EXISTS: the version guard runs this exactly
7433
+ // once, which is what SQLite's ADD COLUMN needs (it has no
7434
+ // IF NOT EXISTS form).
7435
+ version: "internal:row-perms:01-default-row-visibility:v1",
7436
+ sql: `ALTER TABLE "__lattice_shared_objects" ADD COLUMN "default_row_visibility" TEXT NOT NULL DEFAULT 'private' CHECK ("default_row_visibility" IN ('private', 'everyone'))`
7437
+ },
7438
+ {
7439
+ // 02-05 — indexes. One CREATE INDEX per migration (single-statement
7440
+ // for the SQLite path). IF NOT EXISTS is valid in both dialects.
7441
+ version: "internal:row-perms:02-idx-change-log-team-seq:v1",
7442
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_seq" ON "__lattice_change_log" ("team_id", "seq")`
7443
+ },
7444
+ {
7445
+ version: "internal:row-perms:03-idx-change-log-team-recipient-seq:v1",
7446
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_recipient_seq" ON "__lattice_change_log" ("team_id", "recipient_user_id", "seq")`
7447
+ },
7448
+ {
7449
+ version: "internal:row-perms:04-idx-change-log-team-table-pk:v1",
7450
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_table_pk" ON "__lattice_change_log" ("team_id", "table_name", "pk")`
7451
+ },
7452
+ {
7453
+ version: "internal:row-perms:05-idx-row-grants-grantee:v1",
7454
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee" ON "__lattice_row_grants" ("team_id", "grantee_user_id", "table_name", "pk")`
7455
+ },
7456
+ {
7457
+ // 06 — upgrade backfill. Every already-shared table defaults to
7458
+ // 'everyone' so pre-2.2 rows stay visible to all members (nothing
7459
+ // disappears on upgrade). Pre-2.2 rows have no __lattice_row_acl
7460
+ // entry; resolveRowAcl() folds in this table default, so they read
7461
+ // as 'everyone' until an owner narrows an individual row. Runs once.
7462
+ version: "internal:row-perms:06-backfill-shared-defaults:v1",
7463
+ sql: `UPDATE "__lattice_shared_objects" SET "default_row_visibility" = 'everyone' WHERE "deleted_at" IS NULL`
7464
+ }
7465
+ ];
7466
+ await db.migrate(migrations);
7467
+ }
7179
7468
 
7180
7469
  // src/teams/schema-spec.ts
7181
7470
  function renderColumnType(spec, dialect) {
@@ -7531,7 +7820,8 @@ async function appendChangeEnvelope(db, entry) {
7531
7820
  owner_user_id: entry.owner_user_id ?? null,
7532
7821
  created_at: now,
7533
7822
  client_ts: entry.client_ts ?? now,
7534
- edit_id: entry.edit_id ?? null
7823
+ edit_id: entry.edit_id ?? null,
7824
+ recipient_user_id: entry.recipient_user_id ?? null
7535
7825
  });
7536
7826
  return seq;
7537
7827
  }
@@ -7571,7 +7861,14 @@ async function shareObject(db, teamId, createdByUserId, table, spec) {
7571
7861
  created_by_user_id: createdByUserId,
7572
7862
  created_at: prior?.created_at ?? now,
7573
7863
  updated_at: now,
7574
- deleted_at: null
7864
+ deleted_at: null,
7865
+ // 2.2: a freshly-shared table defaults to 'everyone' so the existing
7866
+ // "share a table → every member sees its rows" contract is preserved.
7867
+ // The owner can narrow the table default (or individual rows) afterward.
7868
+ // Re-share (the branch above) intentionally omits this so an owner's
7869
+ // earlier choice survives a schema bump (the upsert only sets the
7870
+ // columns it lists).
7871
+ default_row_visibility: "everyone"
7575
7872
  });
7576
7873
  }
7577
7874
  await applySchemaSpec(db, table, outSpec);
@@ -7665,88 +7962,26 @@ async function destroyTeamDirect(db) {
7665
7962
  }
7666
7963
  await db.delete("__lattice_team_identity", "singleton");
7667
7964
  }
7668
- async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
7669
- if (!isPostgresUrl(cloudUrl)) {
7670
- throw new Error(
7671
- `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
7672
- );
7673
- }
7674
- const db = new Lattice(cloudUrl);
7675
- try {
7676
- await db.init();
7677
- for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
7678
- await db.defineLate(table, def);
7679
- }
7680
- await installCloudInternalTriggers(db);
7681
- const invites = await db.query("__lattice_invitations", {
7682
- filters: [
7683
- { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
7684
- { col: "redeemed_at", op: "isNull" }
7685
- ],
7686
- limit: 1
7687
- });
7688
- const invite = invites[0];
7689
- if (!invite) {
7690
- throw new Error("Invitation invalid or already used");
7691
- }
7692
- if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
7693
- throw new Error("Invitation expired");
7694
- }
7695
- if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
7696
- throw new Error("Invitation is addressed to a different email");
7697
- }
7698
- const team = await db.get("__lattice_team", invite.team_id);
7699
- if (!team || team.deleted_at) {
7700
- throw new Error("Team no longer exists");
7701
- }
7702
- const now = (/* @__PURE__ */ new Date()).toISOString();
7703
- const userId = await db.insert("__lattice_users", {
7704
- email,
7705
- name,
7706
- created_at: now,
7707
- updated_at: now
7708
- });
7709
- await db.insert("__lattice_team_members", {
7710
- team_id: invite.team_id,
7711
- user_id: userId,
7712
- role: "member",
7713
- joined_at: now
7714
- });
7715
- const { raw, hash } = generateToken();
7716
- await db.insert("__lattice_api_tokens", {
7717
- user_id: userId,
7718
- token_hash: hash,
7719
- name: `invited:${team.name}`,
7720
- created_at: now
7721
- });
7722
- await db.update("__lattice_invitations", invite.id, {
7723
- redeemed_at: now,
7724
- redeemed_by_user_id: userId
7725
- });
7726
- return {
7727
- user: { id: userId, email, name },
7728
- raw_token: raw,
7729
- team: { id: team.id, name: team.name }
7730
- };
7731
- } finally {
7732
- try {
7733
- db.close();
7734
- } catch {
7735
- }
7736
- }
7737
- }
7965
+ var directDeprecationWarned = false;
7738
7966
  async function openCloud(cloudUrl) {
7739
7967
  if (!isPostgresUrl(cloudUrl)) {
7740
7968
  throw new Error(
7741
7969
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
7742
7970
  );
7743
7971
  }
7972
+ if (!directDeprecationWarned) {
7973
+ directDeprecationWarned = true;
7974
+ console.warn(
7975
+ "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
7976
+ );
7977
+ }
7744
7978
  const db = new Lattice(cloudUrl);
7745
7979
  await db.init();
7746
7980
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
7747
7981
  await db.defineLate(table, def);
7748
7982
  }
7749
7983
  await installCloudInternalTriggers(db);
7984
+ await installRowPermsSchema(db);
7750
7985
  return db;
7751
7986
  }
7752
7987
  function closeQuiet(db) {
@@ -7857,6 +8092,7 @@ async function unlinkRowDirect(local, cloudUrl, teamId, table, pk) {
7857
8092
  }
7858
8093
 
7859
8094
  // src/teams/client.ts
8095
+ var DIRECT_CLOUD_DEPRECATION_MESSAGE = "Direct postgres:// team-cloud connections are deprecated and do not support row-level security. Create or join a workspace through a hosted Lattice Teams server (an http(s):// URL) instead. Existing direct connections continue to work but should be migrated.";
7860
8096
  var TeamsClient = class {
7861
8097
  constructor(local) {
7862
8098
  this.local = local;
@@ -7889,6 +8125,9 @@ var TeamsClient = class {
7889
8125
  * members join via `redeemInvite`). Returns the new user + bearer
7890
8126
  * token + team summary so the caller can immediately save a
7891
8127
  * connection.
8128
+ *
8129
+ * @param teamName The workspace display name (stored as `team_name` for
8130
+ * backward compatibility — a cloud IS a workspace with members).
7892
8131
  */
7893
8132
  async register(cloudUrl, email, name, teamName) {
7894
8133
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/register", {
@@ -7899,7 +8138,7 @@ var TeamsClient = class {
7899
8138
  }
7900
8139
  async redeemInvite(cloudUrl, inviteToken, email, name) {
7901
8140
  if (isPostgresUrl(cloudUrl)) {
7902
- return redeemInviteDirect(cloudUrl, inviteToken, email, name);
8141
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
7903
8142
  }
7904
8143
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
7905
8144
  invite_token: inviteToken,
@@ -7910,9 +8149,11 @@ var TeamsClient = class {
7910
8149
  // ── High-level orchestration (v1.13+) ───────────────────────────────────
7911
8150
  // Wraps the multi-step flows the GUI's Database panel + library
7912
8151
  // consumers both need: connecting to an existing cloud DB (with
7913
- // optional team join), and upgrading a non-team cloud into a team
7914
- // cloud. The HTTP routes in src/gui/dbconfig-routes.ts are thin
7915
- // shells over these methods.
8152
+ // optional team join), and initializing a fresh cloud DB's owner so
8153
+ // its members + per-table sharing surface exists. A cloud workspace IS
8154
+ // a workspace with members — there is no separate "team" to convert to.
8155
+ // The HTTP routes in src/gui/dbconfig-routes.ts are thin shells over
8156
+ // these methods.
7916
8157
  /**
7917
8158
  * Connect a local project to an existing cloud DB by URL. Probes
7918
8159
  * the target for team status first; if it's a teams DB, the caller
@@ -7961,15 +8202,18 @@ var TeamsClient = class {
7961
8202
  return { probe };
7962
8203
  }
7963
8204
  /**
7964
- * Upgrade an already-connected cloud DB to a team DB. Two paths
7965
- * depending on the cloud URL's scheme:
8205
+ * Initialize a fresh cloud DB's owner: register the first member (who
8206
+ * becomes owner) so the cloud's members + per-table sharing surface
8207
+ * exists. This is NOT a "convert a cloud into a team" step — a cloud
8208
+ * workspace IS a workspace with members; this just bootstraps the owner
8209
+ * the first time a cloud is opened. The hosted server path is the only
8210
+ * supported one:
7966
8211
  *
7967
8212
  * - `http(s)://…` — POST to the cloud's `/api/auth/register` endpoint
7968
- * (`lattice serve --team-cloud` is fronting the Postgres).
7969
- * - `postgres(ql)://…` — drive the same INSERT sequence directly
7970
- * against the cloud Postgres via {@link registerDirectViaPostgres}.
7971
- * The HTTP path can't be used here because the browser's Fetch
7972
- * API refuses URLs with embedded credentials.
8213
+ * (a hosted `lattice serve` teams server is fronting the Postgres).
8214
+ * - `postgres(ql)://…` — rejected: direct postgres:// owner bootstrap
8215
+ * is deprecated. Row-level security is enforced by the hosted server,
8216
+ * so it is the only supported connection method for new workspaces.
7973
8217
  *
7974
8218
  * On success writes the bearer token to `~/.lattice/keys/<label>.token`
7975
8219
  * **and** persists the local `__lattice_team_connections` row so the
@@ -7979,8 +8223,11 @@ var TeamsClient = class {
7979
8223
  * the token file, leaving GUI authenticated calls with no
7980
8224
  * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
7981
8225
  */
7982
- async upgradeToTeamCloud(opts) {
7983
- const reg = isPostgresUrl(opts.cloudUrl) ? await registerDirectViaPostgres(opts.cloudUrl, opts.email, opts.displayName, opts.teamName) : await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
8226
+ async registerCloudOwner(opts) {
8227
+ if (isPostgresUrl(opts.cloudUrl)) {
8228
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
8229
+ }
8230
+ const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
7984
8231
  writeToken(opts.label, reg.raw_token);
7985
8232
  await this.saveConnection({
7986
8233
  team_id: reg.team.id,
@@ -8013,7 +8260,7 @@ var TeamsClient = class {
8013
8260
  }
8014
8261
  try {
8015
8262
  const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
8016
- await this.upgradeToTeamCloud({
8263
+ await this.registerCloudOwner({
8017
8264
  label: opts.label,
8018
8265
  cloudUrl: opts.cloudUrl,
8019
8266
  teamName: opts.workspaceName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",