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.cjs CHANGED
@@ -4374,6 +4374,12 @@ function buildAdapter(dbPath, options) {
4374
4374
  return new SQLiteAdapter(sqlitePath, adapterOpts);
4375
4375
  }
4376
4376
  var NOT_DELETED2 = "(deleted_at IS NULL OR deleted_at = '')";
4377
+ function rowAclVisibleExists(teamExpr, tableExpr, pkExpr) {
4378
+ 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" = ?))))`;
4379
+ }
4380
+ function rowAclAbsent(teamExpr, tableExpr, pkExpr) {
4381
+ return `NOT EXISTS (SELECT 1 FROM "__lattice_row_acl" la2 WHERE la2."team_id" = ${teamExpr} AND la2."table_name" = ${tableExpr} AND la2."pk" = ${pkExpr})`;
4382
+ }
4377
4383
  var Lattice = class _Lattice {
4378
4384
  _adapter;
4379
4385
  _changelogService;
@@ -4929,9 +4935,7 @@ var Lattice = class _Lattice {
4929
4935
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
4930
4936
  values
4931
4937
  );
4932
- const pkCol = pkCols[0] ?? "id";
4933
- const rawPk = rowWithPk[pkCol];
4934
- const pkValue = rawPk != null ? String(rawPk) : "";
4938
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4935
4939
  await this._appendChangelog(table, pkValue, "insert", rowWithPk, null);
4936
4940
  this._sanitizer.emitAudit(table, "insert", pkValue);
4937
4941
  await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
@@ -4974,9 +4978,7 @@ var Lattice = class _Lattice {
4974
4978
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) ON CONFLICT(${conflictCols}) DO UPDATE SET ${updateCols}`,
4975
4979
  values
4976
4980
  );
4977
- const pkCol = pkCols[0] ?? "id";
4978
- const rawPk = rowWithPk[pkCol];
4979
- const pkValue = rawPk != null ? String(rawPk) : "";
4981
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4980
4982
  this._sanitizer.emitAudit(table, "update", pkValue);
4981
4983
  this._scheduleAutoRender();
4982
4984
  return pkValue;
@@ -5022,7 +5024,7 @@ var Lattice = class _Lattice {
5022
5024
  }
5023
5025
  const values = [...Object.values(encrypted), ...pkParams];
5024
5026
  await runAsyncOrSync(this._adapter, `UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
5025
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
5027
+ const auditId = this._serializePkLookup(table, id);
5026
5028
  await this._appendChangelog(table, auditId, "update", sanitized, previousValues);
5027
5029
  this._sanitizer.emitAudit(table, "update", auditId);
5028
5030
  await this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
@@ -5057,7 +5059,7 @@ var Lattice = class _Lattice {
5057
5059
  previousRow = await getAsyncOrSync(this._adapter, `SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null;
5058
5060
  }
5059
5061
  await runAsyncOrSync(this._adapter, `DELETE FROM "${table}" WHERE ${clause}`, params);
5060
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
5062
+ const auditId = this._serializePkLookup(table, id);
5061
5063
  await this._appendChangelog(
5062
5064
  table,
5063
5065
  auditId,
@@ -5473,6 +5475,140 @@ var Lattice = class _Lattice {
5473
5475
  const rows = await allAsyncOrSync(this._adapter, sql, params);
5474
5476
  return this._decryptRows(table, rows);
5475
5477
  }
5478
+ /**
5479
+ * Row-level-security list read for Lattice Teams (2.2). Returns only the
5480
+ * rows of `table` that `userId` may see in team `teamId`, evaluated
5481
+ * entirely in SQL (indexed, bounded — never "load every row then filter
5482
+ * in JS"). A row is visible iff it has a `__lattice_row_acl` entry owned by
5483
+ * the user or marked 'everyone', or a 'custom' entry with a matching
5484
+ * `__lattice_row_grants` row, OR it has no ACL entry at all and the caller
5485
+ * passes `noAclVisible` (the table default is 'everyone', or the user owns
5486
+ * the table — the pre-2.2 / never-narrowed case). Soft-deleted rows are
5487
+ * excluded by default; results reuse the same decrypt path as `query()`.
5488
+ *
5489
+ * The ACL predicate joins on the table's primary-key column cast to TEXT
5490
+ * (ACL pks are stored as TEXT), so it is correct regardless of the user
5491
+ * table's pk type and works on both SQLite and Postgres. The teams layer's
5492
+ * `listVisibleRows` (src/teams/row-access.ts) is the intended caller.
5493
+ */
5494
+ async queryVisible(table, opts) {
5495
+ const notInit = this._notInitError();
5496
+ if (notInit) return notInit;
5497
+ this._assertIdent(table);
5498
+ if (opts.orderBy) this._assertIdent(table, opts.orderBy);
5499
+ const cols = this._ensureColumnCache(table);
5500
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(table));
5501
+ let softDelete = "";
5502
+ if (cols.has("deleted_at") && opts.deleted !== "any") {
5503
+ softDelete = opts.deleted === "only" ? `t."deleted_at" IS NOT NULL AND ` : `t."deleted_at" IS NULL AND `;
5504
+ }
5505
+ const params = [];
5506
+ let visClause;
5507
+ if (pkExpr) {
5508
+ visClause = rowAclVisibleExists("?", "?", pkExpr);
5509
+ params.push(opts.teamId, table, opts.userId, opts.userId);
5510
+ if (opts.noAclVisible) {
5511
+ visClause += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5512
+ params.push(opts.teamId, table);
5513
+ }
5514
+ } else {
5515
+ visClause = opts.noAclVisible ? "1=1" : "1=0";
5516
+ }
5517
+ let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${visClause})`;
5518
+ if (opts.orderBy && cols.has(opts.orderBy)) {
5519
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
5520
+ sql += ` ORDER BY t."${opts.orderBy}" ${dir}`;
5521
+ }
5522
+ if (opts.limit !== void 0 && Number.isFinite(opts.limit)) {
5523
+ sql += ` LIMIT ${Math.trunc(opts.limit).toString()}`;
5524
+ }
5525
+ if (opts.offset !== void 0 && Number.isFinite(opts.offset)) {
5526
+ if (opts.limit === void 0 && this.getDialect() === "sqlite") sql += " LIMIT -1";
5527
+ sql += ` OFFSET ${Math.trunc(opts.offset).toString()}`;
5528
+ }
5529
+ const rows = await allAsyncOrSync(this._adapter, sql, params);
5530
+ return this._decryptRows(table, rows);
5531
+ }
5532
+ /**
5533
+ * Visible-row counts for MANY tables in a single round-trip, using the same
5534
+ * ACL predicate as {@link queryVisible} — so dashboard tiles agree with what
5535
+ * the rows view lists and a physical count never reveals the existence or
5536
+ * volume of rows the user can't see. One aggregated
5537
+ * `SELECT (SELECT COUNT(*) …) AS c0, …` statement (no per-table fan-out, so
5538
+ * a session pooler with few slots survives concurrent refreshes), capped at
5539
+ * 50 tables per pass; overflow is logged and skipped (no silent truncation)
5540
+ * and those tables count as absent — the caller renders "—". Soft-deleted
5541
+ * rows are excluded wherever the table carries `deleted_at`, matching the
5542
+ * default rows view.
5543
+ */
5544
+ async countVisibleMany(specs, opts) {
5545
+ const out = /* @__PURE__ */ new Map();
5546
+ const notInit = this._notInitError();
5547
+ if (notInit) return notInit;
5548
+ if (specs.length === 0) return out;
5549
+ const VISIBLE_COUNT_CAP = 50;
5550
+ let bounded = specs;
5551
+ if (bounded.length > VISIBLE_COUNT_CAP) {
5552
+ const dropped = bounded.length - VISIBLE_COUNT_CAP;
5553
+ console.warn(
5554
+ `[lattice] visible-count pass capped at ${String(VISIBLE_COUNT_CAP)} tables; ${String(dropped)} table(s) report no count this pass`
5555
+ );
5556
+ bounded = bounded.slice(0, VISIBLE_COUNT_CAP);
5557
+ }
5558
+ const selects = [];
5559
+ const params = [];
5560
+ for (const [i, spec] of bounded.entries()) {
5561
+ this._assertIdent(spec.table);
5562
+ const cols = this._ensureColumnCache(spec.table);
5563
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
5564
+ const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
5565
+ let predicate;
5566
+ if (pkExpr) {
5567
+ predicate = rowAclVisibleExists("?", "?", pkExpr);
5568
+ params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5569
+ if (spec.noAclVisible) {
5570
+ predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5571
+ params.push(opts.teamId, spec.table);
5572
+ }
5573
+ } else {
5574
+ predicate = spec.noAclVisible ? "1=1" : "1=0";
5575
+ }
5576
+ selects.push(
5577
+ `(SELECT COUNT(*) FROM "${spec.table}" t WHERE ${softDelete}(${predicate})) AS c${String(i)}`
5578
+ );
5579
+ }
5580
+ const row = await getAsyncOrSync(this._adapter, `SELECT ${selects.join(", ")}`, params);
5581
+ if (!row) return out;
5582
+ for (const [i, spec] of bounded.entries()) {
5583
+ const raw = row[`c${String(i)}`];
5584
+ const n = typeof raw === "bigint" ? Number(raw) : Number(raw);
5585
+ if (Number.isFinite(n) && n >= 0) out.set(spec.table, n);
5586
+ }
5587
+ return out;
5588
+ }
5589
+ /**
5590
+ * Hosted-sync change-log pull, filtered per recipient for 2.2 row-level
5591
+ * security (the hosted server's sole enforcement mechanism). Returns
5592
+ * `__lattice_change_log` rows with seq > `since` for team `teamId` that
5593
+ * `userId` is permitted to receive:
5594
+ * - targeted envelopes (`recipient_user_id = userId`), plus
5595
+ * - broadcast envelopes (`recipient_user_id IS NULL`) that are either
5596
+ * table-level (`pk IS NULL` — schema / unshare, delivered to all) or
5597
+ * whose row is currently visible to the user via `__lattice_row_acl` /
5598
+ * `__lattice_row_grants` (or has no ACL entry and the table defaults to
5599
+ * 'everyone').
5600
+ * Ordered by seq, capped at `limit`. Raw SQL because the predicate needs
5601
+ * OR / EXISTS that the `query()` API can't express; bounded by the seq
5602
+ * window and indexed ACL point-lookups. Mirrors {@link queryVisible}'s
5603
+ * visibility logic so a member never pulls the bytes of a row they can't see.
5604
+ */
5605
+ async listChangesForRecipient(teamId, since, userId, limit) {
5606
+ const notInit = this._notInitError();
5607
+ if (notInit) return notInit;
5608
+ 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()}`;
5609
+ const params = [teamId, since, userId, userId, userId];
5610
+ return allAsyncOrSync(this._adapter, sql, params);
5611
+ }
5476
5612
  async count(table, opts = {}) {
5477
5613
  const notInit = this._notInitError();
5478
5614
  if (notInit) return notInit;
@@ -5691,6 +5827,62 @@ var Lattice = class _Lattice {
5691
5827
  const params = pkCols.map((col) => id[col]);
5692
5828
  return { clause: clauses.join(" AND "), params };
5693
5829
  }
5830
+ // ── Composite-key serialization for the row-level-permission layer ───────
5831
+ // The row ACL (`__lattice_row_acl`/`__lattice_row_grants`) and the
5832
+ // change-log key each row by a single TEXT `pk`. For a table whose primary
5833
+ // key spans several columns (e.g. a junction table `(project_id,
5834
+ // meeting_id)` with no `id`), that key must encode EVERY pk column, and the
5835
+ // write side (what we store) must match the read side (the SQL that
5836
+ // reconstructs it from row columns). These three helpers are the single
5837
+ // source of truth for both. A single-column key serializes to the bare
5838
+ // value (so all pre-2.2.1 single-`id` ACL data stays valid).
5839
+ static _PK_SEP = " ";
5840
+ /**
5841
+ * The primary-key columns of `table` that PHYSICALLY exist, in declared
5842
+ * order. Empty when the table has no Lattice-addressable key — e.g. a table
5843
+ * reached via raw SQL whose PK metadata defaulted to `['id']` but that has
5844
+ * no `id` column. Callers treat an empty result as "unkeyable" (no per-row
5845
+ * ACL is possible, so the row-perm SQL must not reference a pk column).
5846
+ */
5847
+ _resolvedPkCols(table) {
5848
+ const cols = this._ensureColumnCache(table);
5849
+ return this._schema.getPrimaryKey(table).filter((c) => cols.has(c));
5850
+ }
5851
+ /** Canonical ACL / change-log `pk` string for a row. Matches {@link _pkSqlExpr}. */
5852
+ _serializeRowPk(table, row) {
5853
+ const pkCols = this._resolvedPkCols(table);
5854
+ const cols = pkCols.length > 0 ? pkCols : ["id"];
5855
+ return cols.map((c) => {
5856
+ const v = row[c];
5857
+ return v != null ? String(v) : "";
5858
+ }).join(_Lattice._PK_SEP);
5859
+ }
5860
+ /**
5861
+ * Canonical `pk` string for a {@link PkLookup} used by update/delete, so a
5862
+ * row addressed by lookup keys its change-log entry identically to the way
5863
+ * {@link _serializeRowPk} keyed it at insert time.
5864
+ */
5865
+ _serializePkLookup(table, id) {
5866
+ if (typeof id === "string") return id;
5867
+ const pkCols = this._resolvedPkCols(table);
5868
+ if (pkCols.length === 0) return JSON.stringify(id);
5869
+ return pkCols.map((c) => {
5870
+ const v = id[c];
5871
+ return v != null ? String(v) : "";
5872
+ }).join(_Lattice._PK_SEP);
5873
+ }
5874
+ /**
5875
+ * SQL expression reconstructing {@link _serializeRowPk} from a row aliased
5876
+ * `t`. Returns null when the table is unkeyable (no pk columns present) — the
5877
+ * caller must then avoid referencing a pk column at all. Dialect-aware tab
5878
+ * separator: SQLite `char(9)`, Postgres `chr(9)` (both = U+0009), matching
5879
+ * {@link _PK_SEP}.
5880
+ */
5881
+ _pkSqlExpr(pkCols) {
5882
+ if (pkCols.length === 0) return null;
5883
+ const sep2 = this.getDialect() === "postgres" ? "chr(9)" : "char(9)";
5884
+ return pkCols.map((c) => `CAST(t."${c}" AS TEXT)`).join(` || ${sep2} || `);
5885
+ }
5694
5886
  /**
5695
5887
  * Convert Filter objects into SQL clause strings and bound params.
5696
5888
  * An `in` filter with an empty array is silently ignored (produces no clause).
@@ -6631,6 +6823,13 @@ var NATIVE_ENTITY_DEFS = {
6631
6823
  columns: {
6632
6824
  id: "TEXT PRIMARY KEY",
6633
6825
  title: "TEXT",
6826
+ // Cloud user id of the member who started the thread (the operator's
6827
+ // `teamContext.myUserId`). A chat is PRIVATE to its author — on a team
6828
+ // cloud the chat routes only ever return threads whose owner matches the
6829
+ // requesting member. NULL on local single-user databases (no team
6830
+ // context) and on pre-2.2.1 threads, which the routes treat as the local
6831
+ // operator's own (visible only when there is no team context).
6832
+ owner_user_id: "TEXT",
6634
6833
  created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6635
6834
  updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6636
6835
  deleted_at: "TEXT"
@@ -6645,6 +6844,10 @@ var NATIVE_ENTITY_DEFS = {
6645
6844
  // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6646
6845
  // to match the generic, dialect-agnostic native-entity style.
6647
6846
  thread_id: "TEXT",
6847
+ // Cloud user id of the member the message belongs to — mirrors the
6848
+ // owning thread's owner_user_id so a message read can be filtered
6849
+ // independently of the thread join. NULL on local DBs / pre-2.2.1 rows.
6850
+ owner_user_id: "TEXT",
6648
6851
  // user | assistant | tool | feed | system
6649
6852
  role: "TEXT NOT NULL DEFAULT 'user'",
6650
6853
  // JSON payload: text, tool_use / tool_result blocks, attachments, or
@@ -7194,7 +7397,13 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
7194
7397
  // Client-generated idempotency key for offline replay: a queued edit
7195
7398
  // carries a stable edit_id, so re-sending it after a reconnect is a
7196
7399
  // no-op rather than a duplicate write. Nullable + additive.
7197
- edit_id: "TEXT"
7400
+ edit_id: "TEXT",
7401
+ // Per-recipient targeting for 2.2 hard row-level sync. NULL =
7402
+ // broadcast (delivered to every member, then filtered at pull time
7403
+ // against __lattice_row_acl); non-null = targeted to exactly this
7404
+ // user (the grant / revoke / delete fan-out). Nullable + additive,
7405
+ // same precedent as client_ts / edit_id.
7406
+ recipient_user_id: "TEXT"
7198
7407
  },
7199
7408
  render: () => "",
7200
7409
  outputFile: ".lattice-teams/change-log.md"
@@ -7210,6 +7419,44 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
7210
7419
  primaryKey: ["team_id", "table_name", "pk"],
7211
7420
  render: () => "",
7212
7421
  outputFile: ".lattice-teams/row-links.md"
7422
+ },
7423
+ // Per-row access control for a team cloud (2.2 row-level permissions).
7424
+ // Mirrors __lattice_object_owners at row granularity: each shared row
7425
+ // has an owner (its creator) and a visibility. Enforcement is at the
7426
+ // application layer (see src/teams/row-access.ts) — every member shares
7427
+ // the same physical DB, so a row a user can't see must be filtered out
7428
+ // before its bytes reach them. Kept out-of-band (never injected into
7429
+ // user tables) so the user's own schema stays untouched.
7430
+ __lattice_row_acl: {
7431
+ columns: {
7432
+ team_id: "TEXT NOT NULL",
7433
+ table_name: "TEXT NOT NULL",
7434
+ pk: "TEXT NOT NULL",
7435
+ owner_user_id: "TEXT NOT NULL",
7436
+ // 'private' = owner only · 'everyone' = all team members ·
7437
+ // 'custom' = the explicit grant list in __lattice_row_grants.
7438
+ visibility: "TEXT NOT NULL CHECK (visibility IN ('private', 'everyone', 'custom'))",
7439
+ created_at: "TEXT NOT NULL",
7440
+ updated_at: "TEXT NOT NULL"
7441
+ },
7442
+ primaryKey: ["team_id", "table_name", "pk"],
7443
+ render: () => "",
7444
+ outputFile: ".lattice-teams/row-acl.md"
7445
+ },
7446
+ // Explicit per-row grant list, consulted only when the owning row's
7447
+ // __lattice_row_acl.visibility = 'custom'. One row per (row, grantee).
7448
+ __lattice_row_grants: {
7449
+ columns: {
7450
+ team_id: "TEXT NOT NULL",
7451
+ table_name: "TEXT NOT NULL",
7452
+ pk: "TEXT NOT NULL",
7453
+ grantee_user_id: "TEXT NOT NULL",
7454
+ granted_by_user_id: "TEXT NOT NULL",
7455
+ granted_at: "TEXT NOT NULL"
7456
+ },
7457
+ primaryKey: ["team_id", "table_name", "pk", "grantee_user_id"],
7458
+ render: () => "",
7459
+ outputFile: ".lattice-teams/row-grants.md"
7213
7460
  }
7214
7461
  };
7215
7462
  var LOCAL_INTERNAL_TABLE_DEFS = {
@@ -7310,6 +7557,48 @@ async function installCloudInternalTriggers(db) {
7310
7557
  };
7311
7558
  await db.migrate([migration]);
7312
7559
  }
7560
+ async function installRowPermsSchema(db) {
7561
+ const migrations = [
7562
+ {
7563
+ // 01 — per-table default visibility for newly-created rows. Born
7564
+ // 'private' unless the table owner opts the table into 'everyone';
7565
+ // the backfill (06) flips already-shared tables to preserve pre-2.2
7566
+ // visibility. No IF NOT EXISTS: the version guard runs this exactly
7567
+ // once, which is what SQLite's ADD COLUMN needs (it has no
7568
+ // IF NOT EXISTS form).
7569
+ version: "internal:row-perms:01-default-row-visibility:v1",
7570
+ sql: `ALTER TABLE "__lattice_shared_objects" ADD COLUMN "default_row_visibility" TEXT NOT NULL DEFAULT 'private' CHECK ("default_row_visibility" IN ('private', 'everyone'))`
7571
+ },
7572
+ {
7573
+ // 02-05 — indexes. One CREATE INDEX per migration (single-statement
7574
+ // for the SQLite path). IF NOT EXISTS is valid in both dialects.
7575
+ version: "internal:row-perms:02-idx-change-log-team-seq:v1",
7576
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_seq" ON "__lattice_change_log" ("team_id", "seq")`
7577
+ },
7578
+ {
7579
+ version: "internal:row-perms:03-idx-change-log-team-recipient-seq:v1",
7580
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_recipient_seq" ON "__lattice_change_log" ("team_id", "recipient_user_id", "seq")`
7581
+ },
7582
+ {
7583
+ version: "internal:row-perms:04-idx-change-log-team-table-pk:v1",
7584
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_table_pk" ON "__lattice_change_log" ("team_id", "table_name", "pk")`
7585
+ },
7586
+ {
7587
+ version: "internal:row-perms:05-idx-row-grants-grantee:v1",
7588
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee" ON "__lattice_row_grants" ("team_id", "grantee_user_id", "table_name", "pk")`
7589
+ },
7590
+ {
7591
+ // 06 — upgrade backfill. Every already-shared table defaults to
7592
+ // 'everyone' so pre-2.2 rows stay visible to all members (nothing
7593
+ // disappears on upgrade). Pre-2.2 rows have no __lattice_row_acl
7594
+ // entry; resolveRowAcl() folds in this table default, so they read
7595
+ // as 'everyone' until an owner narrows an individual row. Runs once.
7596
+ version: "internal:row-perms:06-backfill-shared-defaults:v1",
7597
+ sql: `UPDATE "__lattice_shared_objects" SET "default_row_visibility" = 'everyone' WHERE "deleted_at" IS NULL`
7598
+ }
7599
+ ];
7600
+ await db.migrate(migrations);
7601
+ }
7313
7602
 
7314
7603
  // src/teams/schema-spec.ts
7315
7604
  function renderColumnType(spec, dialect) {
@@ -7665,7 +7954,8 @@ async function appendChangeEnvelope(db, entry) {
7665
7954
  owner_user_id: entry.owner_user_id ?? null,
7666
7955
  created_at: now,
7667
7956
  client_ts: entry.client_ts ?? now,
7668
- edit_id: entry.edit_id ?? null
7957
+ edit_id: entry.edit_id ?? null,
7958
+ recipient_user_id: entry.recipient_user_id ?? null
7669
7959
  });
7670
7960
  return seq;
7671
7961
  }
@@ -7705,7 +7995,14 @@ async function shareObject(db, teamId, createdByUserId, table, spec) {
7705
7995
  created_by_user_id: createdByUserId,
7706
7996
  created_at: prior?.created_at ?? now,
7707
7997
  updated_at: now,
7708
- deleted_at: null
7998
+ deleted_at: null,
7999
+ // 2.2: a freshly-shared table defaults to 'everyone' so the existing
8000
+ // "share a table → every member sees its rows" contract is preserved.
8001
+ // The owner can narrow the table default (or individual rows) afterward.
8002
+ // Re-share (the branch above) intentionally omits this so an owner's
8003
+ // earlier choice survives a schema bump (the upsert only sets the
8004
+ // columns it lists).
8005
+ default_row_visibility: "everyone"
7709
8006
  });
7710
8007
  }
7711
8008
  await applySchemaSpec(db, table, outSpec);
@@ -7799,88 +8096,26 @@ async function destroyTeamDirect(db) {
7799
8096
  }
7800
8097
  await db.delete("__lattice_team_identity", "singleton");
7801
8098
  }
7802
- async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
7803
- if (!isPostgresUrl(cloudUrl)) {
7804
- throw new Error(
7805
- `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
7806
- );
7807
- }
7808
- const db = new Lattice(cloudUrl);
7809
- try {
7810
- await db.init();
7811
- for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
7812
- await db.defineLate(table, def);
7813
- }
7814
- await installCloudInternalTriggers(db);
7815
- const invites = await db.query("__lattice_invitations", {
7816
- filters: [
7817
- { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
7818
- { col: "redeemed_at", op: "isNull" }
7819
- ],
7820
- limit: 1
7821
- });
7822
- const invite = invites[0];
7823
- if (!invite) {
7824
- throw new Error("Invitation invalid or already used");
7825
- }
7826
- if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
7827
- throw new Error("Invitation expired");
7828
- }
7829
- if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
7830
- throw new Error("Invitation is addressed to a different email");
7831
- }
7832
- const team = await db.get("__lattice_team", invite.team_id);
7833
- if (!team || team.deleted_at) {
7834
- throw new Error("Team no longer exists");
7835
- }
7836
- const now = (/* @__PURE__ */ new Date()).toISOString();
7837
- const userId = await db.insert("__lattice_users", {
7838
- email,
7839
- name,
7840
- created_at: now,
7841
- updated_at: now
7842
- });
7843
- await db.insert("__lattice_team_members", {
7844
- team_id: invite.team_id,
7845
- user_id: userId,
7846
- role: "member",
7847
- joined_at: now
7848
- });
7849
- const { raw, hash } = generateToken();
7850
- await db.insert("__lattice_api_tokens", {
7851
- user_id: userId,
7852
- token_hash: hash,
7853
- name: `invited:${team.name}`,
7854
- created_at: now
7855
- });
7856
- await db.update("__lattice_invitations", invite.id, {
7857
- redeemed_at: now,
7858
- redeemed_by_user_id: userId
7859
- });
7860
- return {
7861
- user: { id: userId, email, name },
7862
- raw_token: raw,
7863
- team: { id: team.id, name: team.name }
7864
- };
7865
- } finally {
7866
- try {
7867
- db.close();
7868
- } catch {
7869
- }
7870
- }
7871
- }
8099
+ var directDeprecationWarned = false;
7872
8100
  async function openCloud(cloudUrl) {
7873
8101
  if (!isPostgresUrl(cloudUrl)) {
7874
8102
  throw new Error(
7875
8103
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
7876
8104
  );
7877
8105
  }
8106
+ if (!directDeprecationWarned) {
8107
+ directDeprecationWarned = true;
8108
+ console.warn(
8109
+ "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
8110
+ );
8111
+ }
7878
8112
  const db = new Lattice(cloudUrl);
7879
8113
  await db.init();
7880
8114
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
7881
8115
  await db.defineLate(table, def);
7882
8116
  }
7883
8117
  await installCloudInternalTriggers(db);
8118
+ await installRowPermsSchema(db);
7884
8119
  return db;
7885
8120
  }
7886
8121
  function closeQuiet(db) {
@@ -7991,6 +8226,7 @@ async function unlinkRowDirect(local, cloudUrl, teamId, table, pk) {
7991
8226
  }
7992
8227
 
7993
8228
  // src/teams/client.ts
8229
+ 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.";
7994
8230
  var TeamsClient = class {
7995
8231
  constructor(local) {
7996
8232
  this.local = local;
@@ -8023,6 +8259,9 @@ var TeamsClient = class {
8023
8259
  * members join via `redeemInvite`). Returns the new user + bearer
8024
8260
  * token + team summary so the caller can immediately save a
8025
8261
  * connection.
8262
+ *
8263
+ * @param teamName The workspace display name (stored as `team_name` for
8264
+ * backward compatibility — a cloud IS a workspace with members).
8026
8265
  */
8027
8266
  async register(cloudUrl, email, name, teamName) {
8028
8267
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/register", {
@@ -8033,7 +8272,7 @@ var TeamsClient = class {
8033
8272
  }
8034
8273
  async redeemInvite(cloudUrl, inviteToken, email, name) {
8035
8274
  if (isPostgresUrl(cloudUrl)) {
8036
- return redeemInviteDirect(cloudUrl, inviteToken, email, name);
8275
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
8037
8276
  }
8038
8277
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
8039
8278
  invite_token: inviteToken,
@@ -8044,9 +8283,11 @@ var TeamsClient = class {
8044
8283
  // ── High-level orchestration (v1.13+) ───────────────────────────────────
8045
8284
  // Wraps the multi-step flows the GUI's Database panel + library
8046
8285
  // consumers both need: connecting to an existing cloud DB (with
8047
- // optional team join), and upgrading a non-team cloud into a team
8048
- // cloud. The HTTP routes in src/gui/dbconfig-routes.ts are thin
8049
- // shells over these methods.
8286
+ // optional team join), and initializing a fresh cloud DB's owner so
8287
+ // its members + per-table sharing surface exists. A cloud workspace IS
8288
+ // a workspace with members — there is no separate "team" to convert to.
8289
+ // The HTTP routes in src/gui/dbconfig-routes.ts are thin shells over
8290
+ // these methods.
8050
8291
  /**
8051
8292
  * Connect a local project to an existing cloud DB by URL. Probes
8052
8293
  * the target for team status first; if it's a teams DB, the caller
@@ -8095,15 +8336,18 @@ var TeamsClient = class {
8095
8336
  return { probe };
8096
8337
  }
8097
8338
  /**
8098
- * Upgrade an already-connected cloud DB to a team DB. Two paths
8099
- * depending on the cloud URL's scheme:
8339
+ * Initialize a fresh cloud DB's owner: register the first member (who
8340
+ * becomes owner) so the cloud's members + per-table sharing surface
8341
+ * exists. This is NOT a "convert a cloud into a team" step — a cloud
8342
+ * workspace IS a workspace with members; this just bootstraps the owner
8343
+ * the first time a cloud is opened. The hosted server path is the only
8344
+ * supported one:
8100
8345
  *
8101
8346
  * - `http(s)://…` — POST to the cloud's `/api/auth/register` endpoint
8102
- * (`lattice serve --team-cloud` is fronting the Postgres).
8103
- * - `postgres(ql)://…` — drive the same INSERT sequence directly
8104
- * against the cloud Postgres via {@link registerDirectViaPostgres}.
8105
- * The HTTP path can't be used here because the browser's Fetch
8106
- * API refuses URLs with embedded credentials.
8347
+ * (a hosted `lattice serve` teams server is fronting the Postgres).
8348
+ * - `postgres(ql)://…` — rejected: direct postgres:// owner bootstrap
8349
+ * is deprecated. Row-level security is enforced by the hosted server,
8350
+ * so it is the only supported connection method for new workspaces.
8107
8351
  *
8108
8352
  * On success writes the bearer token to `~/.lattice/keys/<label>.token`
8109
8353
  * **and** persists the local `__lattice_team_connections` row so the
@@ -8113,8 +8357,11 @@ var TeamsClient = class {
8113
8357
  * the token file, leaving GUI authenticated calls with no
8114
8358
  * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
8115
8359
  */
8116
- async upgradeToTeamCloud(opts) {
8117
- 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);
8360
+ async registerCloudOwner(opts) {
8361
+ if (isPostgresUrl(opts.cloudUrl)) {
8362
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
8363
+ }
8364
+ const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
8118
8365
  writeToken(opts.label, reg.raw_token);
8119
8366
  await this.saveConnection({
8120
8367
  team_id: reg.team.id,
@@ -8147,7 +8394,7 @@ var TeamsClient = class {
8147
8394
  }
8148
8395
  try {
8149
8396
  const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
8150
- await this.upgradeToTeamCloud({
8397
+ await this.registerCloudOwner({
8151
8398
  label: opts.label,
8152
8399
  cloudUrl: opts.cloudUrl,
8153
8400
  teamName: opts.workspaceName,