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/cli.js 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 sep5 = this.getDialect() === "postgres" ? "chr(9)" : "char(9)";
5884
+ return pkCols.map((c) => `CAST(t."${c}" AS TEXT)`).join(` || ${sep5} || `);
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).
@@ -6228,6 +6420,13 @@ var NATIVE_ENTITY_DEFS = {
6228
6420
  columns: {
6229
6421
  id: "TEXT PRIMARY KEY",
6230
6422
  title: "TEXT",
6423
+ // Cloud user id of the member who started the thread (the operator's
6424
+ // `teamContext.myUserId`). A chat is PRIVATE to its author — on a team
6425
+ // cloud the chat routes only ever return threads whose owner matches the
6426
+ // requesting member. NULL on local single-user databases (no team
6427
+ // context) and on pre-2.2.1 threads, which the routes treat as the local
6428
+ // operator's own (visible only when there is no team context).
6429
+ owner_user_id: "TEXT",
6231
6430
  created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6232
6431
  updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6233
6432
  deleted_at: "TEXT"
@@ -6242,6 +6441,10 @@ var NATIVE_ENTITY_DEFS = {
6242
6441
  // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6243
6442
  // to match the generic, dialect-agnostic native-entity style.
6244
6443
  thread_id: "TEXT",
6444
+ // Cloud user id of the member the message belongs to — mirrors the
6445
+ // owning thread's owner_user_id so a message read can be filtered
6446
+ // independently of the thread join. NULL on local DBs / pre-2.2.1 rows.
6447
+ owner_user_id: "TEXT",
6245
6448
  // user | assistant | tool | feed | system
6246
6449
  role: "TEXT NOT NULL DEFAULT 'user'",
6247
6450
  // JSON payload: text, tool_use / tool_result blocks, attachments, or
@@ -7347,7 +7550,7 @@ var css = `
7347
7550
  .view-header .actions { margin-left: auto; display: flex; gap: 8px; }
7348
7551
 
7349
7552
  /* Row delete / restore controls */
7350
- .row-actions { width: 64px; text-align: center; white-space: nowrap; }
7553
+ .row-actions { width: 88px; text-align: center; white-space: nowrap; }
7351
7554
  .row-delete, .row-restore {
7352
7555
  background: transparent; border: none; color: var(--text-muted);
7353
7556
  font-size: 16px; cursor: pointer; padding: 4px 6px;
@@ -7358,6 +7561,43 @@ var css = `
7358
7561
  .row-restore:hover { background: var(--accent-soft); color: var(--accent); }
7359
7562
  tr.row-deleted td { background: rgba(251, 146, 60, 0.08); color: var(--text-muted); }
7360
7563
  tr.row-deleted:hover td { background: #fcf5e3; }
7564
+ /* Per-row visibility indicator (2.2). Reuses the team share colour
7565
+ language \u2014 yellow (#eab308) = visible to everyone, red (#ef4444) =
7566
+ private \u2014 matching the .sw-shared / .sw-private swatches. Owner =
7567
+ interactive toggle; non-owner = faded + inert (status only). */
7568
+ .row-vis {
7569
+ background: transparent; border: none; padding: 4px 6px; border-radius: 4px;
7570
+ font-size: 14px; line-height: 1; cursor: pointer; text-decoration: none;
7571
+ color: #eab308;
7572
+ }
7573
+ .row-vis:hover { filter: brightness(1.18); }
7574
+ .row-vis-private { color: #ef4444; }
7575
+ .row-vis-disabled { cursor: default; pointer-events: none; opacity: 0.45; }
7576
+ /* Grants checklist (detail view, owner-only): who can see a
7577
+ shared-with-specific-people row. Checkboxes post straight to the
7578
+ row-grant endpoints. */
7579
+ .grants-panel {
7580
+ margin: 4px 0 10px; padding: 10px 12px; max-width: 420px;
7581
+ border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2);
7582
+ font-size: 13px;
7583
+ }
7584
+ .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
7585
+ .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
7586
+ .grants-panel .grants-row input { accent-color: var(--accent); }
7587
+ /* Deprecation banner: shown when the workspace holds a grandfathered
7588
+ direct database cloud connection (no row-level security). Amber so
7589
+ it reads as a warning, not an error. */
7590
+ .deprecation-banner {
7591
+ display: flex; align-items: center; gap: 12px;
7592
+ padding: 8px 16px; font-size: 13px;
7593
+ background: rgba(234, 179, 8, 0.12); color: var(--text);
7594
+ border-bottom: 1px solid rgba(234, 179, 8, 0.45);
7595
+ }
7596
+ .deprecation-banner button {
7597
+ margin-left: auto; background: transparent; border: none; cursor: pointer;
7598
+ color: var(--text-muted); font-size: 13px; padding: 2px 6px; border-radius: 4px;
7599
+ }
7600
+ .deprecation-banner button:hover { background: rgba(234, 179, 8, 0.18); }
7361
7601
 
7362
7602
  /* Inline create-row at the bottom of every table */
7363
7603
  tr.create-row td { background: var(--surface-2); }
@@ -8848,6 +9088,27 @@ var appJs = `
8848
9088
 
8849
9089
  window.addEventListener('hashchange', renderRoute);
8850
9090
 
9091
+ // Deprecation banner: a grandfathered direct database cloud connection
9092
+ // bypasses the hosted server's row security entirely \u2014 say so up front.
9093
+ // Dismiss hides it for this browser session only.
9094
+ function initDeprecationBanner() {
9095
+ if (sessionStorage.getItem('lattice-direct-banner-dismissed')) return;
9096
+ fetchJson('/api/dbconfig').then(function (d) {
9097
+ if (!d || !d.directCloud) return;
9098
+ var banner = document.getElementById('deprecation-banner');
9099
+ var text = document.getElementById('deprecation-banner-text');
9100
+ if (!banner || !text) return;
9101
+ text.textContent = "Direct database cloud connections are deprecated and don't support row-level security. Migrate to a hosted workspace.";
9102
+ banner.hidden = false;
9103
+ var dismiss = document.getElementById('deprecation-banner-dismiss');
9104
+ if (dismiss) dismiss.addEventListener('click', function () {
9105
+ banner.hidden = true;
9106
+ sessionStorage.setItem('lattice-direct-banner-dismissed', '1');
9107
+ });
9108
+ }).catch(function () { /* dbconfig unavailable (e.g. team-cloud server mode) \u2014 no banner */ });
9109
+ }
9110
+ initDeprecationBanner();
9111
+
8851
9112
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
8852
9113
  // Sidebar
8853
9114
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -9203,6 +9464,31 @@ var appJs = `
9203
9464
  .map(function (h) { return '<th>' + escapeHtml(h) + '</th>'; }).join('');
9204
9465
  headers += '<th class="row-actions"></th>';
9205
9466
 
9467
+ // Per-row visibility indicator (2.2 row-level permissions). Reads the
9468
+ // server-attached _access summary (team clouds only); absent yields ''.
9469
+ // U+25C9 = everyone (yellow) / private (red, by colour); U+25CE =
9470
+ // custom (shared with specific people). Owner = interactive toggle;
9471
+ // non-owner = faded + inert status.
9472
+ function rowVisMarkup(tbl, r) {
9473
+ var a = r._access;
9474
+ if (!a) return '';
9475
+ var vis = a.visibility;
9476
+ var glyph = vis === 'custom' ? '\u25CE' : '\u25C9';
9477
+ if (!a.ownedByMe) {
9478
+ var seen = vis === 'custom' ? 'Shared with you' : 'Visible to everyone';
9479
+ return '<span class="row-vis row-vis-disabled" title="' + escapeHtml(seen) + '">' + glyph + '</span>';
9480
+ }
9481
+ if (vis === 'custom') {
9482
+ return '<a class="row-vis" href="#/objects/' + encodeURIComponent(tbl) + '/' + encodeURIComponent(r.id) +
9483
+ '" title="Shared with specific people \u2014 open to manage">' + glyph + '</a>';
9484
+ }
9485
+ var cls = vis === 'private' ? 'row-vis row-vis-private' : 'row-vis';
9486
+ var title = vis === 'everyone'
9487
+ ? 'Visible to everyone \u2014 click to make private'
9488
+ : 'Private to you \u2014 click to share with everyone';
9489
+ return '<button class="' + cls + '" data-vis-toggle="' + escapeHtml(r.id) +
9490
+ '" data-vis-cur="' + vis + '" title="' + escapeHtml(title) + '">' + glyph + '</button>';
9491
+ }
9206
9492
  var bodyRows;
9207
9493
  if (rows.length === 0) {
9208
9494
  bodyRows = '';
@@ -9233,7 +9519,8 @@ var appJs = `
9233
9519
  '<button class="row-delete" title="Delete permanently" data-hard-del="' + escapeHtml(r.id) + '">\u2715</button>' +
9234
9520
  '</td>');
9235
9521
  } else {
9236
- tds.push('<td class="row-actions"><button class="row-delete" title="Delete" data-del="' + escapeHtml(r.id) + '">\u2715</button></td>');
9522
+ tds.push('<td class="row-actions">' + rowVisMarkup(tableName, r) +
9523
+ '<button class="row-delete" title="Delete" data-del="' + escapeHtml(r.id) + '">\u2715</button></td>');
9237
9524
  }
9238
9525
  return '<tr data-id="' + escapeHtml(r.id) + '"' + (viewMode === 'trash' ? ' class="row-deleted"' : '') + '>' + tds.join('') + '</tr>';
9239
9526
  }).join('');
@@ -9342,6 +9629,30 @@ var appJs = `
9342
9629
  });
9343
9630
  });
9344
9631
 
9632
+ content.querySelectorAll('button[data-vis-toggle]').forEach(function (btn) {
9633
+ btn.addEventListener('click', function (e) {
9634
+ e.stopPropagation();
9635
+ var id = btn.getAttribute('data-vis-toggle');
9636
+ var cur = btn.getAttribute('data-vis-cur');
9637
+ var next = cur === 'everyone' ? 'private' : 'everyone';
9638
+ withBusy(btn, function () {
9639
+ return fetchJson('/api/tables/' + encodeURIComponent(tableName) + '/rows/' + encodeURIComponent(id) + '/visibility', {
9640
+ method: 'POST',
9641
+ headers: { 'content-type': 'application/json' },
9642
+ body: JSON.stringify({ visibility: next }),
9643
+ }).then(function () {
9644
+ invalidate(tableName);
9645
+ return refreshEntities();
9646
+ }).then(function () {
9647
+ renderTable(content, tableName);
9648
+ showToast(next === 'everyone' ? 'Row shared with everyone' : 'Row made private', {});
9649
+ }).catch(function (err) {
9650
+ showToast('Visibility update failed: ' + err.message, {});
9651
+ });
9652
+ });
9653
+ });
9654
+ });
9655
+
9345
9656
  content.querySelectorAll('tr[data-id]').forEach(function (tr) {
9346
9657
  tr.addEventListener('click', function (e) {
9347
9658
  // Let chip-link anchors and the delete button handle their own click.
@@ -9468,6 +9779,39 @@ var appJs = `
9468
9779
  });
9469
9780
  }
9470
9781
 
9782
+ // Detail-view row visibility line (2.2). Owner: status + everyone/private
9783
+ // toggle + a "Specific people\u2026" / "Manage access" control that opens the
9784
+ // grants checklist (the table view's "open to manage" affordance lands
9785
+ // here). Non-owner: read-only status.
9786
+ function detailVisLineEl(row) {
9787
+ var a = row._access;
9788
+ if (!a) return '';
9789
+ var vis = a.visibility;
9790
+ var labelMap = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
9791
+ if (!a.ownedByMe) {
9792
+ var seen = vis === 'custom' ? 'Shared with you' : (labelMap[vis] || '');
9793
+ return '<div class="detail-vis muted" style="margin:6px 0;font-size:13px">' + escapeHtml(seen) + '</div>';
9794
+ }
9795
+ var info = labelMap[vis] || '';
9796
+ if (vis === 'custom' && a.grantees) info += ' (' + a.grantees.length + ')';
9797
+ var buttons;
9798
+ if (vis === 'custom') {
9799
+ // Leaving custom stops the grant list from applying \u2014 the toggle
9800
+ // handler asks for confirmation. The grants themselves are kept
9801
+ // server-side, so reopening "Manage access" restores the list.
9802
+ buttons = '<button class="btn" id="detail-vis-manage">Manage access</button>' +
9803
+ '<button class="btn" id="detail-vis-toggle" data-vis-cur="custom" data-vis-next="everyone">Share with everyone</button>';
9804
+ } else {
9805
+ var btnLabel = vis === 'everyone' ? 'Make private' : 'Share with everyone';
9806
+ var next = vis === 'everyone' ? 'private' : 'everyone';
9807
+ buttons = '<button class="btn" id="detail-vis-toggle" data-vis-cur="' + vis + '" data-vis-next="' + next + '">' + btnLabel + '</button>' +
9808
+ '<button class="btn" id="detail-vis-manage">Specific people\u2026</button>';
9809
+ }
9810
+ return '<div class="detail-vis" style="display:flex;align-items:center;gap:8px;margin:6px 0;font-size:13px;flex-wrap:wrap">' +
9811
+ '<span class="muted" id="detail-vis-info">' + escapeHtml(info) + '</span>' + buttons +
9812
+ '</div>' +
9813
+ '<div class="grants-panel" id="grants-panel" hidden></div>';
9814
+ }
9471
9815
  function renderDetail(content, tableName, id) {
9472
9816
  var t = tableByName(tableName);
9473
9817
  if (!t) {
@@ -9566,6 +9910,7 @@ var appJs = `
9566
9910
  '<h1>' + escapeHtml(displayNameFor(row) || d.label) + '</h1>' +
9567
9911
  '<div class="actions">' + actions + '</div>' +
9568
9912
  '</div>' +
9913
+ detailVisLineEl(row) +
9569
9914
  lastEditedLineEl(tableName, id) +
9570
9915
  (tableName === 'files' ? '<div class="file-preview" id="file-preview"></div>' : '') +
9571
9916
  '<div class="detail"><dl class="' + (editing ? 'editing' : '') + '">' + rows.join('') + '</dl></div>' +
@@ -9578,6 +9923,93 @@ var appJs = `
9578
9923
  if (!editing) loadRowContext(tableName, id);
9579
9924
  if (!editing && tableName === 'files') renderFilePreview(row);
9580
9925
 
9926
+ function postVisibility(next) {
9927
+ return fetchJson('/api/tables/' + encodeURIComponent(tableName) + '/rows/' + encodeURIComponent(id) + '/visibility', {
9928
+ method: 'POST',
9929
+ headers: { 'content-type': 'application/json' },
9930
+ body: JSON.stringify({ visibility: next }),
9931
+ });
9932
+ }
9933
+ var detailVisBtn = content.querySelector('#detail-vis-toggle');
9934
+ if (detailVisBtn) detailVisBtn.addEventListener('click', function () {
9935
+ var cur = detailVisBtn.getAttribute('data-vis-cur');
9936
+ var next = detailVisBtn.getAttribute('data-vis-next') || (cur === 'everyone' ? 'private' : 'everyone');
9937
+ if (cur === 'custom') {
9938
+ // Non-destructive guard: the grant rows survive server-side, but
9939
+ // the custom list stops applying the moment visibility changes.
9940
+ var cnt = (row._access && row._access.grantees ? row._access.grantees.length : 0);
9941
+ var who = cnt === 1 ? '1 specific person' : cnt + ' specific people';
9942
+ if (!confirm('This row is shared with ' + who + '. The custom list will stop applying (it is kept and reapplies if you return to specific people). Continue?')) return;
9943
+ }
9944
+ withBusy(detailVisBtn, function () {
9945
+ return postVisibility(next).then(function () {
9946
+ invalidate(tableName);
9947
+ renderDetail(content, tableName, id);
9948
+ showToast(next === 'everyone' ? 'Shared with everyone' : 'Made private', {});
9949
+ }).catch(function (e) { showToast('Visibility update failed: ' + e.message, {}); });
9950
+ });
9951
+ });
9952
+
9953
+ // Grants checklist ("Specific people\u2026" / "Manage access"): member
9954
+ // checkboxes wired to the row-grant endpoints. Opening it on a
9955
+ // non-custom row first narrows visibility to custom so an empty
9956
+ // checklist is a coherent state (owner-only until people are added).
9957
+ var detailVisManage = content.querySelector('#detail-vis-manage');
9958
+ if (detailVisManage) detailVisManage.addEventListener('click', function () {
9959
+ var panel = content.querySelector('#grants-panel');
9960
+ if (!panel) return;
9961
+ if (!panel.hidden) { panel.hidden = true; return; }
9962
+ var access = row._access || {};
9963
+ var ensure = access.visibility === 'custom'
9964
+ ? Promise.resolve()
9965
+ : postVisibility('custom').then(function () { access.visibility = 'custom'; });
9966
+ withBusy(detailVisManage, function () {
9967
+ return ensure.then(function () {
9968
+ return fetchJson('/api/team/users');
9969
+ }).then(function (d) {
9970
+ var users = ((d && d.users) || []).filter(function (u) { return u.id !== access.owner_user_id; });
9971
+ var granted = {};
9972
+ (access.grantees || []).forEach(function (g) { granted[g] = true; });
9973
+ if (users.length === 0) {
9974
+ panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
9975
+ } else {
9976
+ panel.innerHTML = '<div class="grants-title">Who can see this</div>' + users.map(function (u) {
9977
+ var label = u.name || u.email || u.id;
9978
+ return '<label class="grants-row"><input type="checkbox" data-grant-user="' + escapeHtml(u.id) + '"' +
9979
+ (granted[u.id] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
9980
+ }).join('');
9981
+ }
9982
+ panel.hidden = false;
9983
+ panel.querySelectorAll('[data-grant-user]').forEach(function (cb) {
9984
+ cb.addEventListener('change', function () {
9985
+ var uid = cb.getAttribute('data-grant-user');
9986
+ var base = '/api/tables/' + encodeURIComponent(tableName) + '/rows/' + encodeURIComponent(id) + '/grants';
9987
+ var req = cb.checked
9988
+ ? fetchJson(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ user_id: uid }) })
9989
+ : fetchJson(base + '/' + encodeURIComponent(uid), { method: 'DELETE' });
9990
+ cb.disabled = true;
9991
+ req.then(function () {
9992
+ var list = access.grantees || (access.grantees = []);
9993
+ var at = list.indexOf(uid);
9994
+ if (cb.checked && at === -1) list.push(uid);
9995
+ if (!cb.checked && at !== -1) list.splice(at, 1);
9996
+ var infoEl = content.querySelector('#detail-vis-info');
9997
+ if (infoEl) infoEl.textContent = 'Shared with specific people (' + list.length + ')';
9998
+ invalidate(tableName);
9999
+ }).catch(function (e) {
10000
+ cb.checked = !cb.checked; // revert the failed change
10001
+ showToast('Access update failed: ' + e.message, {});
10002
+ }).then(function () { cb.disabled = false; });
10003
+ });
10004
+ });
10005
+ if (access.visibility === 'custom') {
10006
+ var infoEl = content.querySelector('#detail-vis-info');
10007
+ if (infoEl) infoEl.textContent = 'Shared with specific people (' + (access.grantees || []).length + ')';
10008
+ }
10009
+ }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
10010
+ });
10011
+ });
10012
+
9581
10013
  // Junction link/unlink handlers (active in both read and edit modes).
9582
10014
  content.querySelectorAll('.remove-link').forEach(function (btn) {
9583
10015
  btn.addEventListener('click', function (e) {
@@ -11007,6 +11439,18 @@ var appJs = `
11007
11439
  '</span>' +
11008
11440
  '</div>'
11009
11441
  : '';
11442
+ // Owner-only "new rows default to" control, shown for a shared table.
11443
+ var defaultVis = (t && t.defaultRowVisibility) || 'private';
11444
+ var defaultVisRow = canShare && isShared
11445
+ ? '<label>New rows default to</label>' +
11446
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
11447
+ '<select id="dm-rowvis-select">' +
11448
+ '<option value="private"' + (defaultVis === 'private' ? ' selected' : '') + '>Private (owner only)</option>' +
11449
+ '<option value="everyone"' + (defaultVis === 'everyone' ? ' selected' : '') + '>Everyone on the workspace</option>' +
11450
+ '</select>' +
11451
+ '<span style="font-size:12px;color:var(--text-muted)">Visibility new rows in this table are created with.</span>' +
11452
+ '</div>'
11453
+ : '';
11010
11454
  panel.innerHTML =
11011
11455
  '<h3>' + d.icon + ' ' + escapeHtml(d.label) + '</h3>' +
11012
11456
  '<div class="dm-edit-grid">' +
@@ -11021,6 +11465,7 @@ var appJs = `
11021
11465
  '<button class="btn" id="dm-icon-btn" style="margin-top:6px;">Save</button>' +
11022
11466
  '</div>' +
11023
11467
  shareRow +
11468
+ defaultVisRow +
11024
11469
  '<label>Columns</label>' +
11025
11470
  '<div>' +
11026
11471
  '<div class="dm-cols">' + (columnsHtml || '<span class="muted">No columns</span>') + '</div>' +
@@ -11072,6 +11517,22 @@ var appJs = `
11072
11517
  }).catch(function (e) { showToast('Share update failed: ' + e.message, {}); });
11073
11518
  });
11074
11519
  });
11520
+
11521
+ var rowvisSelect = panel.querySelector('#dm-rowvis-select');
11522
+ if (rowvisSelect) rowvisSelect.addEventListener('change', function () {
11523
+ var next = rowvisSelect.value;
11524
+ withBusy(rowvisSelect, function () {
11525
+ return fetchJson('/api/schema/entities/' + encodeURIComponent(tableName) + '/default-row-visibility', {
11526
+ method: 'POST',
11527
+ headers: { 'content-type': 'application/json' },
11528
+ body: JSON.stringify({ visibility: next }),
11529
+ }).then(function () {
11530
+ return dmRefreshPanel(tableName, false);
11531
+ }).then(function () {
11532
+ showToast(next === 'everyone' ? 'New rows now default to everyone' : 'New rows now default to private', {});
11533
+ }).catch(function (e) { showToast('Default visibility update failed: ' + e.message, {}); });
11534
+ });
11535
+ });
11075
11536
  }
11076
11537
 
11077
11538
  /**
@@ -11759,6 +12220,7 @@ var appJs = `
11759
12220
  // enters per-DB things (cloud URL + DB name) in this modal.
11760
12221
  fetchJson('/api/userconfig/identity').then(function (id) {
11761
12222
  var bodyHtml =
12223
+ '<p style="font-size:12px;color:#ef4444;margin:0 0 8px">Direct postgres:// connections are deprecated and do not enforce row-level permissions \u2014 use a hosted Lattice Teams URL (https://\u2026) instead.</p>' +
11762
12224
  '<div class="field"><label>Cloud URL</label>' +
11763
12225
  '<input name="cloud_url" placeholder="postgres://postgres.&lt;ref&gt;:password@aws-x-region.pooler.supabase.com:5432/postgres" autocapitalize="off" autocorrect="off" spellcheck="false" />' +
11764
12226
  '</div>' +
@@ -11786,6 +12248,7 @@ var appJs = `
11786
12248
  function showJoinTeamModal(kind) {
11787
12249
  fetchJson('/api/userconfig/identity').then(function (id) {
11788
12250
  var bodyHtml =
12251
+ '<p style="font-size:12px;color:#ef4444;margin:0 0 8px">Direct postgres:// connections are deprecated and do not enforce row-level permissions \u2014 use a hosted Lattice Teams URL (https://\u2026) instead.</p>' +
11789
12252
  '<div class="field"><label>Cloud URL</label>' +
11790
12253
  '<input name="cloud_url" placeholder="postgres://postgres.&lt;ref&gt;:password@aws-x-region.pooler.supabase.com:5432/postgres" autocapitalize="off" autocorrect="off" spellcheck="false" />' +
11791
12254
  '</div>' +
@@ -13122,7 +13585,7 @@ var appJs = `
13122
13585
  // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
13123
13586
  // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
13124
13587
  // it shares that debounce with the realtime 'change' handler (no double
13125
- // fetch). See Rule 28: /api/entities uses batched counts, not N queries.
13588
+ // fetch). /api/entities batches its row counts into one query, not N.
13126
13589
  if (data && (data.table || data.op === 'schema')) {
13127
13590
  scheduleRealtimeRefresh();
13128
13591
  }
@@ -13768,6 +14231,10 @@ var guiAppHtml = `<!doctype html>
13768
14231
  </svg>
13769
14232
  </button>
13770
14233
  </header>
14234
+ <div class="deprecation-banner" id="deprecation-banner" hidden>
14235
+ <span id="deprecation-banner-text"></span>
14236
+ <button id="deprecation-banner-dismiss" title="Dismiss for this session" aria-label="Dismiss">\u2715</button>
14237
+ </div>
13771
14238
  <div class="layout">
13772
14239
  <nav class="sidebar">
13773
14240
  <label class="sidebar-advanced toggle" title="Advanced mode \u2014 row/table editor instead of the file workspace">
@@ -13962,7 +14429,13 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
13962
14429
  // Client-generated idempotency key for offline replay: a queued edit
13963
14430
  // carries a stable edit_id, so re-sending it after a reconnect is a
13964
14431
  // no-op rather than a duplicate write. Nullable + additive.
13965
- edit_id: "TEXT"
14432
+ edit_id: "TEXT",
14433
+ // Per-recipient targeting for 2.2 hard row-level sync. NULL =
14434
+ // broadcast (delivered to every member, then filtered at pull time
14435
+ // against __lattice_row_acl); non-null = targeted to exactly this
14436
+ // user (the grant / revoke / delete fan-out). Nullable + additive,
14437
+ // same precedent as client_ts / edit_id.
14438
+ recipient_user_id: "TEXT"
13966
14439
  },
13967
14440
  render: () => "",
13968
14441
  outputFile: ".lattice-teams/change-log.md"
@@ -13978,6 +14451,44 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
13978
14451
  primaryKey: ["team_id", "table_name", "pk"],
13979
14452
  render: () => "",
13980
14453
  outputFile: ".lattice-teams/row-links.md"
14454
+ },
14455
+ // Per-row access control for a team cloud (2.2 row-level permissions).
14456
+ // Mirrors __lattice_object_owners at row granularity: each shared row
14457
+ // has an owner (its creator) and a visibility. Enforcement is at the
14458
+ // application layer (see src/teams/row-access.ts) — every member shares
14459
+ // the same physical DB, so a row a user can't see must be filtered out
14460
+ // before its bytes reach them. Kept out-of-band (never injected into
14461
+ // user tables) so the user's own schema stays untouched.
14462
+ __lattice_row_acl: {
14463
+ columns: {
14464
+ team_id: "TEXT NOT NULL",
14465
+ table_name: "TEXT NOT NULL",
14466
+ pk: "TEXT NOT NULL",
14467
+ owner_user_id: "TEXT NOT NULL",
14468
+ // 'private' = owner only · 'everyone' = all team members ·
14469
+ // 'custom' = the explicit grant list in __lattice_row_grants.
14470
+ visibility: "TEXT NOT NULL CHECK (visibility IN ('private', 'everyone', 'custom'))",
14471
+ created_at: "TEXT NOT NULL",
14472
+ updated_at: "TEXT NOT NULL"
14473
+ },
14474
+ primaryKey: ["team_id", "table_name", "pk"],
14475
+ render: () => "",
14476
+ outputFile: ".lattice-teams/row-acl.md"
14477
+ },
14478
+ // Explicit per-row grant list, consulted only when the owning row's
14479
+ // __lattice_row_acl.visibility = 'custom'. One row per (row, grantee).
14480
+ __lattice_row_grants: {
14481
+ columns: {
14482
+ team_id: "TEXT NOT NULL",
14483
+ table_name: "TEXT NOT NULL",
14484
+ pk: "TEXT NOT NULL",
14485
+ grantee_user_id: "TEXT NOT NULL",
14486
+ granted_by_user_id: "TEXT NOT NULL",
14487
+ granted_at: "TEXT NOT NULL"
14488
+ },
14489
+ primaryKey: ["team_id", "table_name", "pk", "grantee_user_id"],
14490
+ render: () => "",
14491
+ outputFile: ".lattice-teams/row-grants.md"
13981
14492
  }
13982
14493
  };
13983
14494
  var LOCAL_INTERNAL_TABLE_DEFS = {
@@ -14078,6 +14589,341 @@ async function installCloudInternalTriggers(db) {
14078
14589
  };
14079
14590
  await db.migrate([migration]);
14080
14591
  }
14592
+ async function installRowPermsSchema(db) {
14593
+ const migrations = [
14594
+ {
14595
+ // 01 — per-table default visibility for newly-created rows. Born
14596
+ // 'private' unless the table owner opts the table into 'everyone';
14597
+ // the backfill (06) flips already-shared tables to preserve pre-2.2
14598
+ // visibility. No IF NOT EXISTS: the version guard runs this exactly
14599
+ // once, which is what SQLite's ADD COLUMN needs (it has no
14600
+ // IF NOT EXISTS form).
14601
+ version: "internal:row-perms:01-default-row-visibility:v1",
14602
+ sql: `ALTER TABLE "__lattice_shared_objects" ADD COLUMN "default_row_visibility" TEXT NOT NULL DEFAULT 'private' CHECK ("default_row_visibility" IN ('private', 'everyone'))`
14603
+ },
14604
+ {
14605
+ // 02-05 — indexes. One CREATE INDEX per migration (single-statement
14606
+ // for the SQLite path). IF NOT EXISTS is valid in both dialects.
14607
+ version: "internal:row-perms:02-idx-change-log-team-seq:v1",
14608
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_seq" ON "__lattice_change_log" ("team_id", "seq")`
14609
+ },
14610
+ {
14611
+ version: "internal:row-perms:03-idx-change-log-team-recipient-seq:v1",
14612
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_recipient_seq" ON "__lattice_change_log" ("team_id", "recipient_user_id", "seq")`
14613
+ },
14614
+ {
14615
+ version: "internal:row-perms:04-idx-change-log-team-table-pk:v1",
14616
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_table_pk" ON "__lattice_change_log" ("team_id", "table_name", "pk")`
14617
+ },
14618
+ {
14619
+ version: "internal:row-perms:05-idx-row-grants-grantee:v1",
14620
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee" ON "__lattice_row_grants" ("team_id", "grantee_user_id", "table_name", "pk")`
14621
+ },
14622
+ {
14623
+ // 06 — upgrade backfill. Every already-shared table defaults to
14624
+ // 'everyone' so pre-2.2 rows stay visible to all members (nothing
14625
+ // disappears on upgrade). Pre-2.2 rows have no __lattice_row_acl
14626
+ // entry; resolveRowAcl() folds in this table default, so they read
14627
+ // as 'everyone' until an owner narrows an individual row. Runs once.
14628
+ version: "internal:row-perms:06-backfill-shared-defaults:v1",
14629
+ sql: `UPDATE "__lattice_shared_objects" SET "default_row_visibility" = 'everyone' WHERE "deleted_at" IS NULL`
14630
+ }
14631
+ ];
14632
+ await db.migrate(migrations);
14633
+ }
14634
+
14635
+ // src/teams/row-access.ts
14636
+ var RowAccessError = class extends Error {
14637
+ code = "row_access_denied";
14638
+ constructor(message = "Row not accessible") {
14639
+ super(message);
14640
+ this.name = "RowAccessError";
14641
+ }
14642
+ };
14643
+ var RowOwnerOnlyError = class extends Error {
14644
+ code = "row_owner_only";
14645
+ constructor(message = "Only the row owner may change its sharing") {
14646
+ super(message);
14647
+ this.name = "RowOwnerOnlyError";
14648
+ }
14649
+ };
14650
+ function isRowVisibility(v) {
14651
+ return v === "private" || v === "everyone" || v === "custom";
14652
+ }
14653
+ async function tableDefaultVisibility(db, teamId, table) {
14654
+ const rows = await db.query("__lattice_shared_objects", {
14655
+ filters: [
14656
+ { col: "team_id", op: "eq", val: teamId },
14657
+ { col: "table_name", op: "eq", val: table },
14658
+ { col: "deleted_at", op: "isNull" }
14659
+ ],
14660
+ limit: 1
14661
+ });
14662
+ return rows[0]?.default_row_visibility === "everyone" ? "everyone" : "private";
14663
+ }
14664
+ async function tableOwner(db, teamId, table) {
14665
+ const owners = await db.query("__lattice_object_owners", {
14666
+ filters: [
14667
+ { col: "team_id", op: "eq", val: teamId },
14668
+ { col: "table_name", op: "eq", val: table }
14669
+ ],
14670
+ limit: 1
14671
+ });
14672
+ const ownerId = owners[0]?.owner_user_id;
14673
+ if (typeof ownerId === "string" && ownerId) return ownerId;
14674
+ const shared = await db.query("__lattice_shared_objects", {
14675
+ filters: [
14676
+ { col: "team_id", op: "eq", val: teamId },
14677
+ { col: "table_name", op: "eq", val: table },
14678
+ { col: "deleted_at", op: "isNull" }
14679
+ ],
14680
+ limit: 1
14681
+ });
14682
+ const createdBy = shared[0]?.created_by_user_id;
14683
+ return typeof createdBy === "string" ? createdBy : "";
14684
+ }
14685
+ async function rawAclRow(db, teamId, table, pk) {
14686
+ const rows = await db.query("__lattice_row_acl", {
14687
+ filters: [
14688
+ { col: "team_id", op: "eq", val: teamId },
14689
+ { col: "table_name", op: "eq", val: table },
14690
+ { col: "pk", op: "eq", val: pk }
14691
+ ],
14692
+ limit: 1
14693
+ });
14694
+ return rows[0];
14695
+ }
14696
+ async function resolveRowAcl(db, teamId, table, pk) {
14697
+ const acl = await rawAclRow(db, teamId, table, pk);
14698
+ if (acl && isRowVisibility(acl.visibility)) {
14699
+ return { ownerUserId: String(acl.owner_user_id), visibility: acl.visibility };
14700
+ }
14701
+ const [visibility, owner] = await Promise.all([
14702
+ tableDefaultVisibility(db, teamId, table),
14703
+ tableOwner(db, teamId, table)
14704
+ ]);
14705
+ return { ownerUserId: owner, visibility };
14706
+ }
14707
+ async function hasRowGrant(db, teamId, table, pk, userId) {
14708
+ if (!userId) return false;
14709
+ const grants = await db.query("__lattice_row_grants", {
14710
+ filters: [
14711
+ { col: "team_id", op: "eq", val: teamId },
14712
+ { col: "table_name", op: "eq", val: table },
14713
+ { col: "pk", op: "eq", val: pk },
14714
+ { col: "grantee_user_id", op: "eq", val: userId }
14715
+ ],
14716
+ limit: 1
14717
+ });
14718
+ return grants.length > 0;
14719
+ }
14720
+ async function canAccessRow(db, teamId, table, pk, userId) {
14721
+ const acl = await resolveRowAcl(db, teamId, table, pk);
14722
+ if (userId && acl.ownerUserId === userId) return true;
14723
+ if (acl.visibility === "everyone") return true;
14724
+ if (acl.visibility === "custom") return hasRowGrant(db, teamId, table, pk, userId);
14725
+ return false;
14726
+ }
14727
+ async function listVisibleRows(db, teamId, table, userId, opts = {}) {
14728
+ const [owner, def] = await Promise.all([
14729
+ tableOwner(db, teamId, table),
14730
+ tableDefaultVisibility(db, teamId, table)
14731
+ ]);
14732
+ const noAclVisible = def === "everyone" || userId !== "" && owner === userId;
14733
+ return db.queryVisible(table, { teamId, userId, noAclVisible, ...opts });
14734
+ }
14735
+ async function recordRowAcl(db, teamId, table, pk, ownerUserId, visibility) {
14736
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14737
+ const existing = await rawAclRow(db, teamId, table, pk);
14738
+ await db.upsert("__lattice_row_acl", {
14739
+ team_id: teamId,
14740
+ table_name: table,
14741
+ pk,
14742
+ owner_user_id: ownerUserId,
14743
+ visibility,
14744
+ created_at: existing?.created_at ?? now,
14745
+ updated_at: now
14746
+ });
14747
+ }
14748
+ async function requireOwner(db, teamId, table, pk, actorUserId) {
14749
+ const existing = await rawAclRow(db, teamId, table, pk);
14750
+ const owner = existing ? String(existing.owner_user_id) : await tableOwner(db, teamId, table);
14751
+ if (!actorUserId || owner !== actorUserId) throw new RowOwnerOnlyError();
14752
+ return { existing, owner };
14753
+ }
14754
+ async function setRowVisibility(db, teamId, table, pk, actorUserId, visibility) {
14755
+ const { existing, owner } = await requireOwner(db, teamId, table, pk, actorUserId);
14756
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14757
+ await db.upsert("__lattice_row_acl", {
14758
+ team_id: teamId,
14759
+ table_name: table,
14760
+ pk,
14761
+ owner_user_id: owner,
14762
+ visibility,
14763
+ created_at: existing?.created_at ?? now,
14764
+ updated_at: now
14765
+ });
14766
+ }
14767
+ async function addRowGrant(db, teamId, table, pk, granteeUserId, grantedByUserId) {
14768
+ const { existing, owner } = await requireOwner(db, teamId, table, pk, grantedByUserId);
14769
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14770
+ const currentVis = existing && isRowVisibility(existing.visibility) ? existing.visibility : await tableDefaultVisibility(db, teamId, table);
14771
+ const nextVis = currentVis === "everyone" ? "everyone" : "custom";
14772
+ await db.upsert("__lattice_row_acl", {
14773
+ team_id: teamId,
14774
+ table_name: table,
14775
+ pk,
14776
+ owner_user_id: owner,
14777
+ visibility: nextVis,
14778
+ created_at: existing?.created_at ?? now,
14779
+ updated_at: now
14780
+ });
14781
+ await db.upsert("__lattice_row_grants", {
14782
+ team_id: teamId,
14783
+ table_name: table,
14784
+ pk,
14785
+ grantee_user_id: granteeUserId,
14786
+ granted_by_user_id: grantedByUserId,
14787
+ granted_at: now
14788
+ });
14789
+ }
14790
+ async function removeRowGrant(db, teamId, table, pk, granteeUserId, actorUserId) {
14791
+ await requireOwner(db, teamId, table, pk, actorUserId);
14792
+ await db.delete("__lattice_row_grants", {
14793
+ team_id: teamId,
14794
+ table_name: table,
14795
+ pk,
14796
+ grantee_user_id: granteeUserId
14797
+ });
14798
+ }
14799
+ async function rowAccessSummaries(db, teamId, table, userId, pks) {
14800
+ const out = /* @__PURE__ */ new Map();
14801
+ if (pks.length === 0) return out;
14802
+ const acls = await db.query("__lattice_row_acl", {
14803
+ filters: [
14804
+ { col: "team_id", op: "eq", val: teamId },
14805
+ { col: "table_name", op: "eq", val: table }
14806
+ ]
14807
+ });
14808
+ const aclByPk = new Map(acls.map((a) => [String(a.pk), a]));
14809
+ const [owner, def] = await Promise.all([
14810
+ tableOwner(db, teamId, table),
14811
+ tableDefaultVisibility(db, teamId, table)
14812
+ ]);
14813
+ for (const pk of pks) {
14814
+ const a = aclByPk.get(pk);
14815
+ const ownerUserId = a ? String(a.owner_user_id) : owner;
14816
+ const visibility = a && isRowVisibility(a.visibility) ? a.visibility : def;
14817
+ out.set(pk, { owner_user_id: ownerUserId, visibility, ownedByMe: ownerUserId === userId });
14818
+ }
14819
+ return out;
14820
+ }
14821
+ async function filterVisiblePks(db, teamId, table, userId, pks) {
14822
+ const out = /* @__PURE__ */ new Set();
14823
+ if (pks.length === 0) return out;
14824
+ const acls = await db.query("__lattice_row_acl", {
14825
+ filters: [
14826
+ { col: "team_id", op: "eq", val: teamId },
14827
+ { col: "table_name", op: "eq", val: table },
14828
+ { col: "pk", op: "in", val: pks }
14829
+ ]
14830
+ });
14831
+ const aclByPk = new Map(acls.map((a) => [String(a.pk), a]));
14832
+ const [owner, def] = await Promise.all([
14833
+ tableOwner(db, teamId, table),
14834
+ tableDefaultVisibility(db, teamId, table)
14835
+ ]);
14836
+ const customPks = [];
14837
+ for (const pk of pks) {
14838
+ const a = aclByPk.get(pk);
14839
+ const ownerUserId = a ? String(a.owner_user_id) : owner;
14840
+ const visibility = a && isRowVisibility(a.visibility) ? a.visibility : def;
14841
+ if (userId && ownerUserId === userId) {
14842
+ out.add(pk);
14843
+ } else if (visibility === "everyone") {
14844
+ out.add(pk);
14845
+ } else if (visibility === "custom" && userId) {
14846
+ customPks.push(pk);
14847
+ }
14848
+ }
14849
+ if (customPks.length > 0) {
14850
+ const grants = await db.query("__lattice_row_grants", {
14851
+ filters: [
14852
+ { col: "team_id", op: "eq", val: teamId },
14853
+ { col: "table_name", op: "eq", val: table },
14854
+ { col: "grantee_user_id", op: "eq", val: userId },
14855
+ { col: "pk", op: "in", val: customPks }
14856
+ ]
14857
+ });
14858
+ for (const g of grants) out.add(String(g.pk));
14859
+ }
14860
+ return out;
14861
+ }
14862
+ async function usersWithRowAccess(db, teamId, table, pk, candidateUserIds) {
14863
+ const out = [];
14864
+ for (const uid of candidateUserIds) {
14865
+ if (await canAccessRow(db, teamId, table, pk, uid)) out.push(uid);
14866
+ }
14867
+ return out;
14868
+ }
14869
+ async function deleteRowAcl(db, teamId, table, pk) {
14870
+ await db.delete("__lattice_row_acl", { team_id: teamId, table_name: table, pk });
14871
+ const grants = await db.query("__lattice_row_grants", {
14872
+ filters: [
14873
+ { col: "team_id", op: "eq", val: teamId },
14874
+ { col: "table_name", op: "eq", val: table },
14875
+ { col: "pk", op: "eq", val: pk }
14876
+ ]
14877
+ });
14878
+ for (const g of grants) {
14879
+ await db.delete("__lattice_row_grants", {
14880
+ team_id: teamId,
14881
+ table_name: table,
14882
+ pk,
14883
+ grantee_user_id: g.grantee_user_id
14884
+ });
14885
+ }
14886
+ }
14887
+ async function rowGrantees(db, teamId, table, pk) {
14888
+ const grants = await db.query("__lattice_row_grants", {
14889
+ filters: [
14890
+ { col: "team_id", op: "eq", val: teamId },
14891
+ { col: "table_name", op: "eq", val: table },
14892
+ { col: "pk", op: "eq", val: pk }
14893
+ ]
14894
+ });
14895
+ return grants.map((g) => String(g.grantee_user_id));
14896
+ }
14897
+ async function visibleRowEdits(db, teamId, table, userId, scanLimit = 2e3) {
14898
+ const scan = await db.query("__lattice_change_log", {
14899
+ filters: [
14900
+ { col: "team_id", op: "eq", val: teamId },
14901
+ { col: "table_name", op: "eq", val: table }
14902
+ ],
14903
+ orderBy: "seq",
14904
+ orderDir: "desc",
14905
+ limit: scanLimit
14906
+ });
14907
+ const visible = await listVisibleRows(db, teamId, table, userId, { limit: 5e3, deleted: "any" });
14908
+ const visiblePks = new Set(visible.map((r) => String(r.id)));
14909
+ const edits = {};
14910
+ for (const r of scan) {
14911
+ if (!r.pk || edits[r.pk] || !visiblePks.has(r.pk)) continue;
14912
+ edits[r.pk] = { ownerUserId: r.owner_user_id, at: r.client_ts ?? r.created_at };
14913
+ }
14914
+ return edits;
14915
+ }
14916
+ async function setTableDefaultVisibility(db, teamId, table, actorUserId, visibility) {
14917
+ const owner = await tableOwner(db, teamId, table);
14918
+ if (!actorUserId || owner !== actorUserId) {
14919
+ throw new RowOwnerOnlyError("Only the table owner may change the default row visibility");
14920
+ }
14921
+ await db.update(
14922
+ "__lattice_shared_objects",
14923
+ { team_id: teamId, table_name: table },
14924
+ { default_row_visibility: visibility, updated_at: (/* @__PURE__ */ new Date()).toISOString() }
14925
+ );
14926
+ }
14081
14927
 
14082
14928
  // src/teams/server/auth.ts
14083
14929
  import { createHash as createHash2, randomBytes as randomBytes4, timingSafeEqual } from "crypto";
@@ -14112,106 +14958,31 @@ async function authenticate(req, db) {
14112
14958
  const rows = await db.query("__lattice_api_tokens", {
14113
14959
  filters: [
14114
14960
  { col: "token_hash", op: "eq", val: incomingHash },
14115
- { col: "revoked_at", op: "isNull" }
14116
- ],
14117
- limit: 1
14118
- });
14119
- const tokenRow = rows[0];
14120
- if (!tokenRow) return null;
14121
- const storedBuf = Buffer.from(tokenRow.token_hash, "hex");
14122
- const incomingBuf = Buffer.from(incomingHash, "hex");
14123
- if (storedBuf.length !== incomingBuf.length || !timingSafeEqual(storedBuf, incomingBuf)) {
14124
- return null;
14125
- }
14126
- const userRow = await db.get("__lattice_users", tokenRow.user_id);
14127
- if (!userRow || userRow.deleted_at) return null;
14128
- db.update("__lattice_api_tokens", tokenRow.id, {
14129
- last_used_at: (/* @__PURE__ */ new Date()).toISOString()
14130
- }).catch(() => void 0);
14131
- return {
14132
- user: { id: userRow.id, email: userRow.email, name: userRow.name },
14133
- tokenId: tokenRow.id
14134
- };
14135
- }
14136
-
14137
- // src/teams/register-direct.ts
14138
- function isPostgresUrl(url) {
14139
- return /^postgres(ql)?:\/\//i.test(url);
14140
- }
14141
- async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
14142
- if (!isPostgresUrl(cloudUrl)) {
14143
- throw new Error(
14144
- `registerDirectViaPostgres: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
14145
- );
14146
- }
14147
- const db = new Lattice(cloudUrl);
14148
- try {
14149
- await db.init();
14150
- for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
14151
- await db.defineLate(table, def);
14152
- }
14153
- const existing = await db.query("__lattice_users", {
14154
- filters: [{ col: "deleted_at", op: "isNull" }],
14155
- limit: 1
14156
- });
14157
- if (existing.length > 0) {
14158
- throw new Error(
14159
- "Registration is disabled. This cloud already has users \u2014 join via invitation."
14160
- );
14161
- }
14162
- let identity = null;
14163
- try {
14164
- identity = await db.get("__lattice_team_identity", "singleton");
14165
- } catch {
14166
- identity = null;
14167
- }
14168
- if (identity) {
14169
- throw new Error("This cloud already has a team. Use Connect to existing cloud instead.");
14170
- }
14171
- const now = (/* @__PURE__ */ new Date()).toISOString();
14172
- const userId = await db.insert("__lattice_users", {
14173
- email,
14174
- name,
14175
- created_at: now,
14176
- updated_at: now
14177
- });
14178
- const { raw, hash } = generateToken();
14179
- await db.insert("__lattice_api_tokens", {
14180
- user_id: userId,
14181
- token_hash: hash,
14182
- name: `creator:${teamName}`,
14183
- created_at: now
14184
- });
14185
- const teamId = await db.insert("__lattice_team", {
14186
- name: teamName,
14187
- created_by_user_id: userId,
14188
- created_at: now,
14189
- updated_at: now
14190
- });
14191
- await db.insert("__lattice_team_members", {
14192
- team_id: teamId,
14193
- user_id: userId,
14194
- role: "creator",
14195
- joined_at: now
14196
- });
14197
- await db.insert("__lattice_team_identity", {
14198
- id: "singleton",
14199
- team_id: teamId,
14200
- team_name: teamName,
14201
- creator_email: email,
14202
- created_at: now
14203
- });
14204
- return {
14205
- user: { id: userId, email, name },
14206
- raw_token: raw,
14207
- team: { id: teamId, name: teamName, role: "creator" }
14208
- };
14209
- } finally {
14210
- try {
14211
- db.close();
14212
- } catch {
14213
- }
14961
+ { col: "revoked_at", op: "isNull" }
14962
+ ],
14963
+ limit: 1
14964
+ });
14965
+ const tokenRow = rows[0];
14966
+ if (!tokenRow) return null;
14967
+ const storedBuf = Buffer.from(tokenRow.token_hash, "hex");
14968
+ const incomingBuf = Buffer.from(incomingHash, "hex");
14969
+ if (storedBuf.length !== incomingBuf.length || !timingSafeEqual(storedBuf, incomingBuf)) {
14970
+ return null;
14214
14971
  }
14972
+ const userRow = await db.get("__lattice_users", tokenRow.user_id);
14973
+ if (!userRow || userRow.deleted_at) return null;
14974
+ db.update("__lattice_api_tokens", tokenRow.id, {
14975
+ last_used_at: (/* @__PURE__ */ new Date()).toISOString()
14976
+ }).catch(() => void 0);
14977
+ return {
14978
+ user: { id: userRow.id, email: userRow.email, name: userRow.name },
14979
+ tokenId: tokenRow.id
14980
+ };
14981
+ }
14982
+
14983
+ // src/teams/register-direct.ts
14984
+ function isPostgresUrl(url) {
14985
+ return /^postgres(ql)?:\/\//i.test(url);
14215
14986
  }
14216
14987
 
14217
14988
  // src/teams/schema-spec.ts
@@ -14483,7 +15254,8 @@ async function appendChangeEnvelope(db, entry) {
14483
15254
  owner_user_id: entry.owner_user_id ?? null,
14484
15255
  created_at: now,
14485
15256
  client_ts: entry.client_ts ?? now,
14486
- edit_id: entry.edit_id ?? null
15257
+ edit_id: entry.edit_id ?? null,
15258
+ recipient_user_id: entry.recipient_user_id ?? null
14487
15259
  });
14488
15260
  return seq;
14489
15261
  }
@@ -14533,7 +15305,14 @@ async function shareObject(db, teamId, createdByUserId, table, spec) {
14533
15305
  created_by_user_id: createdByUserId,
14534
15306
  created_at: prior?.created_at ?? now,
14535
15307
  updated_at: now,
14536
- deleted_at: null
15308
+ deleted_at: null,
15309
+ // 2.2: a freshly-shared table defaults to 'everyone' so the existing
15310
+ // "share a table → every member sees its rows" contract is preserved.
15311
+ // The owner can narrow the table default (or individual rows) afterward.
15312
+ // Re-share (the branch above) intentionally omits this so an owner's
15313
+ // earlier choice survives a schema bump (the upsert only sets the
15314
+ // columns it lists).
15315
+ default_row_visibility: "everyone"
14537
15316
  });
14538
15317
  }
14539
15318
  await applySchemaSpec(db, table, outSpec);
@@ -14627,88 +15406,26 @@ async function destroyTeamDirect(db) {
14627
15406
  }
14628
15407
  await db.delete("__lattice_team_identity", "singleton");
14629
15408
  }
14630
- async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
14631
- if (!isPostgresUrl(cloudUrl)) {
14632
- throw new Error(
14633
- `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
14634
- );
14635
- }
14636
- const db = new Lattice(cloudUrl);
14637
- try {
14638
- await db.init();
14639
- for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
14640
- await db.defineLate(table, def);
14641
- }
14642
- await installCloudInternalTriggers(db);
14643
- const invites = await db.query("__lattice_invitations", {
14644
- filters: [
14645
- { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
14646
- { col: "redeemed_at", op: "isNull" }
14647
- ],
14648
- limit: 1
14649
- });
14650
- const invite = invites[0];
14651
- if (!invite) {
14652
- throw new Error("Invitation invalid or already used");
14653
- }
14654
- if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
14655
- throw new Error("Invitation expired");
14656
- }
14657
- if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
14658
- throw new Error("Invitation is addressed to a different email");
14659
- }
14660
- const team = await db.get("__lattice_team", invite.team_id);
14661
- if (!team || team.deleted_at) {
14662
- throw new Error("Team no longer exists");
14663
- }
14664
- const now = (/* @__PURE__ */ new Date()).toISOString();
14665
- const userId = await db.insert("__lattice_users", {
14666
- email,
14667
- name,
14668
- created_at: now,
14669
- updated_at: now
14670
- });
14671
- await db.insert("__lattice_team_members", {
14672
- team_id: invite.team_id,
14673
- user_id: userId,
14674
- role: "member",
14675
- joined_at: now
14676
- });
14677
- const { raw, hash } = generateToken();
14678
- await db.insert("__lattice_api_tokens", {
14679
- user_id: userId,
14680
- token_hash: hash,
14681
- name: `invited:${team.name}`,
14682
- created_at: now
14683
- });
14684
- await db.update("__lattice_invitations", invite.id, {
14685
- redeemed_at: now,
14686
- redeemed_by_user_id: userId
14687
- });
14688
- return {
14689
- user: { id: userId, email, name },
14690
- raw_token: raw,
14691
- team: { id: team.id, name: team.name }
14692
- };
14693
- } finally {
14694
- try {
14695
- db.close();
14696
- } catch {
14697
- }
14698
- }
14699
- }
15409
+ var directDeprecationWarned = false;
14700
15410
  async function openCloud(cloudUrl) {
14701
15411
  if (!isPostgresUrl(cloudUrl)) {
14702
15412
  throw new Error(
14703
15413
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
14704
15414
  );
14705
15415
  }
15416
+ if (!directDeprecationWarned) {
15417
+ directDeprecationWarned = true;
15418
+ console.warn(
15419
+ "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
15420
+ );
15421
+ }
14706
15422
  const db = new Lattice(cloudUrl);
14707
15423
  await db.init();
14708
15424
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
14709
15425
  await db.defineLate(table, def);
14710
15426
  }
14711
15427
  await installCloudInternalTriggers(db);
15428
+ await installRowPermsSchema(db);
14712
15429
  return db;
14713
15430
  }
14714
15431
  function closeQuiet(db) {
@@ -15189,6 +15906,23 @@ var FeedBus = class {
15189
15906
  }
15190
15907
  };
15191
15908
 
15909
+ // src/gui/search-acl.ts
15910
+ async function filterSearchGroupsByAcl(db, teamId, userId, result) {
15911
+ const groups = [];
15912
+ for (const group of result.groups) {
15913
+ const visible = await filterVisiblePks(
15914
+ db,
15915
+ teamId,
15916
+ group.table,
15917
+ userId,
15918
+ group.hits.map((h) => h.id)
15919
+ );
15920
+ const hits = group.hits.filter((h) => visible.has(h.id));
15921
+ if (hits.length > 0) groups.push({ ...group, hits, count: hits.length });
15922
+ }
15923
+ return { ...result, groups };
15924
+ }
15925
+
15192
15926
  // src/gui/mutations.ts
15193
15927
  function rowLabel2(row) {
15194
15928
  if (!row || typeof row !== "object") return null;
@@ -15318,6 +16052,10 @@ async function emitTeamEnvelope(ctx, table, pk, op, after) {
15318
16052
  async function createRow(ctx, table, values) {
15319
16053
  const id = await ctx.db.insert(table, values);
15320
16054
  const row = await ctx.db.get(table, id);
16055
+ if (ctx.team) {
16056
+ const vis = await tableDefaultVisibility(ctx.db, ctx.team.teamId, table);
16057
+ await recordRowAcl(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId, vis);
16058
+ }
15321
16059
  await appendAudit(ctx.db, ctx.feed, table, id, "insert", null, row, ctx.source, ctx.sessionId);
15322
16060
  await emitTeamEnvelope(ctx, table, id, "upsert", row);
15323
16061
  return { id, row };
@@ -15341,6 +16079,9 @@ async function updateRow(ctx, table, id, values) {
15341
16079
  if (before === null) {
15342
16080
  throw new Error(`Cannot update "${table}": no row with id "${id}"`);
15343
16081
  }
16082
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
16083
+ throw new RowAccessError();
16084
+ }
15344
16085
  await ctx.db.update(table, id, values);
15345
16086
  const after = await ctx.db.get(table, id);
15346
16087
  if (after != null) {
@@ -15370,6 +16111,9 @@ async function deleteRow(ctx, table, id, hard) {
15370
16111
  if (before === null) {
15371
16112
  throw new Error(`Cannot delete from "${table}": no row with id "${id}"`);
15372
16113
  }
16114
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
16115
+ throw new RowAccessError();
16116
+ }
15373
16117
  if (!hard && ctx.softDeletable.has(table)) {
15374
16118
  await ctx.db.update(table, id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
15375
16119
  const after = await ctx.db.get(table, id);
@@ -16426,25 +17170,30 @@ async function autoUnlinkUserRows(db, teamId, userId) {
16426
17170
  ]
16427
17171
  });
16428
17172
  for (const link of links) {
16429
- await db.delete("__lattice_row_links", {
16430
- team_id: link.team_id,
16431
- table_name: link.table_name,
16432
- pk: link.pk
16433
- });
16434
- try {
16435
- await db.delete(link.table_name, link.pk);
16436
- } catch {
16437
- }
17173
+ await tearDownSharedRow(db, link.team_id, link.table_name, link.pk, link.owner_user_id);
17174
+ }
17175
+ return links.length;
17176
+ }
17177
+ async function tearDownSharedRow(db, teamId, tableName, pk, ownerUserId) {
17178
+ const members = (await listTeamMembers(db, teamId)).map((m) => m.user_id);
17179
+ const permitted = await usersWithRowAccess(db, teamId, tableName, pk, members);
17180
+ await db.delete("__lattice_row_links", { team_id: teamId, table_name: tableName, pk });
17181
+ try {
17182
+ await db.delete(tableName, pk);
17183
+ } catch {
17184
+ }
17185
+ for (const uid of permitted) {
16438
17186
  await appendChangeEnvelope(db, {
16439
- team_id: link.team_id,
16440
- table_name: link.table_name,
16441
- pk: link.pk,
17187
+ team_id: teamId,
17188
+ table_name: tableName,
17189
+ pk,
16442
17190
  op: "unlink",
16443
17191
  payload_json: null,
16444
- owner_user_id: link.owner_user_id
17192
+ owner_user_id: ownerUserId,
17193
+ recipient_user_id: uid
16445
17194
  });
16446
17195
  }
16447
- return links.length;
17196
+ await deleteRowAcl(db, teamId, tableName, pk);
16448
17197
  }
16449
17198
  async function isObjectShared(db, teamId, tableName) {
16450
17199
  const rows = await db.query("__lattice_shared_objects", {
@@ -16560,15 +17309,12 @@ async function handleListChanges(res, ctx, teamId, params) {
16560
17309
  const since = sinceRaw !== null && /^\d+$/.test(sinceRaw) ? Number(sinceRaw) : 0;
16561
17310
  const limitParsed = limitRaw !== null && /^\d+$/.test(limitRaw) ? Number(limitRaw) : 500;
16562
17311
  const limit = Math.min(Math.max(limitParsed, 1), 1e3);
16563
- const rows = await ctx.db.query("__lattice_change_log", {
16564
- filters: [
16565
- { col: "team_id", op: "eq", val: teamId },
16566
- { col: "seq", op: "gt", val: since }
16567
- ],
16568
- orderBy: "seq",
16569
- orderDir: "asc",
17312
+ const rows = await ctx.db.listChangesForRecipient(
17313
+ teamId,
17314
+ since,
17315
+ ctx.authContext.user.id,
16570
17316
  limit
16571
- });
17317
+ );
16572
17318
  const envelopes = rows.map((r) => ({
16573
17319
  seq: r.seq,
16574
17320
  table_name: r.table_name,
@@ -16630,6 +17376,10 @@ async function handleLinkRow(req, res, ctx, teamId, tableName) {
16630
17376
  });
16631
17377
  }
16632
17378
  await ctx.db.upsert(tableName, snapshot);
17379
+ if (!existing) {
17380
+ const vis = await tableDefaultVisibility(ctx.db, teamId, tableName);
17381
+ await recordRowAcl(ctx.db, teamId, tableName, pk, ctx.authContext.user.id, vis);
17382
+ }
16633
17383
  await appendChangeEnvelope(ctx.db, {
16634
17384
  team_id: teamId,
16635
17385
  table_name: tableName,
@@ -16675,23 +17425,7 @@ async function handleUnlinkRow(res, ctx, teamId, tableName, pk) {
16675
17425
  sendJson2(res, { error: "Only the row owner or team creator can unlink" }, 403);
16676
17426
  return;
16677
17427
  }
16678
- await ctx.db.delete("__lattice_row_links", {
16679
- team_id: teamId,
16680
- table_name: tableName,
16681
- pk
16682
- });
16683
- try {
16684
- await ctx.db.delete(tableName, pk);
16685
- } catch {
16686
- }
16687
- await appendChangeEnvelope(ctx.db, {
16688
- team_id: teamId,
16689
- table_name: tableName,
16690
- pk,
16691
- op: "unlink",
16692
- payload_json: null,
16693
- owner_user_id: link.owner_user_id
16694
- });
17428
+ await tearDownSharedRow(ctx.db, teamId, tableName, pk, link.owner_user_id);
16695
17429
  sendJson2(res, { ok: true });
16696
17430
  }
16697
17431
  async function handlePushRow(req, res, ctx, teamId, tableName) {
@@ -16762,23 +17496,7 @@ async function handleDeleteRow(res, ctx, teamId, tableName, pk) {
16762
17496
  sendJson2(res, { error: "Only the row owner can delete the row" }, 403);
16763
17497
  return;
16764
17498
  }
16765
- await ctx.db.delete("__lattice_row_links", {
16766
- team_id: teamId,
16767
- table_name: tableName,
16768
- pk
16769
- });
16770
- try {
16771
- await ctx.db.delete(tableName, pk);
16772
- } catch {
16773
- }
16774
- await appendChangeEnvelope(ctx.db, {
16775
- team_id: teamId,
16776
- table_name: tableName,
16777
- pk,
16778
- op: "unlink",
16779
- payload_json: null,
16780
- owner_user_id: link.owner_user_id
16781
- });
17499
+ await tearDownSharedRow(ctx.db, teamId, tableName, pk, link.owner_user_id);
16782
17500
  sendJson2(res, { ok: true });
16783
17501
  }
16784
17502
 
@@ -16828,6 +17546,7 @@ async function probeCloud(targetUrl) {
16828
17546
  }
16829
17547
 
16830
17548
  // src/teams/client.ts
17549
+ 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.";
16831
17550
  var TeamsClient = class {
16832
17551
  constructor(local) {
16833
17552
  this.local = local;
@@ -16860,6 +17579,9 @@ var TeamsClient = class {
16860
17579
  * members join via `redeemInvite`). Returns the new user + bearer
16861
17580
  * token + team summary so the caller can immediately save a
16862
17581
  * connection.
17582
+ *
17583
+ * @param teamName The workspace display name (stored as `team_name` for
17584
+ * backward compatibility — a cloud IS a workspace with members).
16863
17585
  */
16864
17586
  async register(cloudUrl, email, name, teamName) {
16865
17587
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/register", {
@@ -16870,7 +17592,7 @@ var TeamsClient = class {
16870
17592
  }
16871
17593
  async redeemInvite(cloudUrl, inviteToken, email, name) {
16872
17594
  if (isPostgresUrl(cloudUrl)) {
16873
- return redeemInviteDirect(cloudUrl, inviteToken, email, name);
17595
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
16874
17596
  }
16875
17597
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
16876
17598
  invite_token: inviteToken,
@@ -16881,9 +17603,11 @@ var TeamsClient = class {
16881
17603
  // ── High-level orchestration (v1.13+) ───────────────────────────────────
16882
17604
  // Wraps the multi-step flows the GUI's Database panel + library
16883
17605
  // consumers both need: connecting to an existing cloud DB (with
16884
- // optional team join), and upgrading a non-team cloud into a team
16885
- // cloud. The HTTP routes in src/gui/dbconfig-routes.ts are thin
16886
- // shells over these methods.
17606
+ // optional team join), and initializing a fresh cloud DB's owner so
17607
+ // its members + per-table sharing surface exists. A cloud workspace IS
17608
+ // a workspace with members — there is no separate "team" to convert to.
17609
+ // The HTTP routes in src/gui/dbconfig-routes.ts are thin shells over
17610
+ // these methods.
16887
17611
  /**
16888
17612
  * Connect a local project to an existing cloud DB by URL. Probes
16889
17613
  * the target for team status first; if it's a teams DB, the caller
@@ -16932,15 +17656,18 @@ var TeamsClient = class {
16932
17656
  return { probe };
16933
17657
  }
16934
17658
  /**
16935
- * Upgrade an already-connected cloud DB to a team DB. Two paths
16936
- * depending on the cloud URL's scheme:
17659
+ * Initialize a fresh cloud DB's owner: register the first member (who
17660
+ * becomes owner) so the cloud's members + per-table sharing surface
17661
+ * exists. This is NOT a "convert a cloud into a team" step — a cloud
17662
+ * workspace IS a workspace with members; this just bootstraps the owner
17663
+ * the first time a cloud is opened. The hosted server path is the only
17664
+ * supported one:
16937
17665
  *
16938
17666
  * - `http(s)://…` — POST to the cloud's `/api/auth/register` endpoint
16939
- * (`lattice serve --team-cloud` is fronting the Postgres).
16940
- * - `postgres(ql)://…` — drive the same INSERT sequence directly
16941
- * against the cloud Postgres via {@link registerDirectViaPostgres}.
16942
- * The HTTP path can't be used here because the browser's Fetch
16943
- * API refuses URLs with embedded credentials.
17667
+ * (a hosted `lattice serve` teams server is fronting the Postgres).
17668
+ * - `postgres(ql)://…` — rejected: direct postgres:// owner bootstrap
17669
+ * is deprecated. Row-level security is enforced by the hosted server,
17670
+ * so it is the only supported connection method for new workspaces.
16944
17671
  *
16945
17672
  * On success writes the bearer token to `~/.lattice/keys/<label>.token`
16946
17673
  * **and** persists the local `__lattice_team_connections` row so the
@@ -16950,8 +17677,11 @@ var TeamsClient = class {
16950
17677
  * the token file, leaving GUI authenticated calls with no
16951
17678
  * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
16952
17679
  */
16953
- async upgradeToTeamCloud(opts) {
16954
- 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);
17680
+ async registerCloudOwner(opts) {
17681
+ if (isPostgresUrl(opts.cloudUrl)) {
17682
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
17683
+ }
17684
+ const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
16955
17685
  writeToken(opts.label, reg.raw_token);
16956
17686
  await this.saveConnection({
16957
17687
  team_id: reg.team.id,
@@ -16984,7 +17714,7 @@ var TeamsClient = class {
16984
17714
  }
16985
17715
  try {
16986
17716
  const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
16987
- await this.upgradeToTeamCloud({
17717
+ await this.registerCloudOwner({
16988
17718
  label: opts.label,
16989
17719
  cloudUrl: opts.cloudUrl,
16990
17720
  teamName: opts.workspaceName,
@@ -18243,7 +18973,11 @@ async function handleRegisterAndCreate(req, res, ctx) {
18243
18973
  sendJson(res, { error: "cloud_url, email, user_name, team_name required" }, 400);
18244
18974
  return;
18245
18975
  }
18246
- const reg = isPostgresUrl(cloudUrl) ? await registerDirectViaPostgres(cloudUrl, email, userName, teamName) : await ctx.client.register(cloudUrl, email, userName, teamName);
18976
+ if (isPostgresUrl(cloudUrl)) {
18977
+ sendJson(res, { error: DIRECT_CLOUD_DEPRECATION_MESSAGE }, 400);
18978
+ return;
18979
+ }
18980
+ const reg = await ctx.client.register(cloudUrl, email, userName, teamName);
18247
18981
  await ctx.client.saveConnection({
18248
18982
  team_id: reg.team.id,
18249
18983
  team_name: reg.team.name,
@@ -18669,7 +19403,10 @@ async function dispatchDbConfigRoute(req, res, ctx) {
18669
19403
  // without a local `__lattice_team_connections` row (which doesn't
18670
19404
  // exist when the team cloud itself is the active database).
18671
19405
  teamId: ctx.teamMembership?.teamId ?? null,
18672
- myUserId: ctx.teamMembership?.myUserId ?? null
19406
+ myUserId: ctx.teamMembership?.myUserId ?? null,
19407
+ // Deprecated direct postgres:// team connection present → the SPA
19408
+ // shows the migrate-to-hosted deprecation banner.
19409
+ directCloud: ctx.directCloud
18673
19410
  });
18674
19411
  });
18675
19412
  return true;
@@ -19872,7 +20609,10 @@ async function executeFunction(ctx, name, args) {
19872
20609
  db: ctx.db,
19873
20610
  feed: ctx.feed,
19874
20611
  softDeletable: ctx.softDeletable,
19875
- source: "ai"
20612
+ source: "ai",
20613
+ // Thread the team through so create/update/delete enforce the row ACL for
20614
+ // the assistant exactly as they do for the HTTP API.
20615
+ team: ctx.team ?? null
19876
20616
  };
19877
20617
  try {
19878
20618
  switch (name) {
@@ -19886,14 +20626,24 @@ async function executeFunction(ctx, name, args) {
19886
20626
  }
19887
20627
  case "list_rows": {
19888
20628
  const table = requireTable(args.table, ctx.validTables);
19889
- const opts = { limit: 200 };
19890
- if (ctx.softDeletable.has(table) && args.includeDeleted !== true) {
19891
- opts.filters = [{ col: "deleted_at", op: "isNull" }];
19892
- }
20629
+ const includeDeleted = args.includeDeleted === true;
19893
20630
  const cols = ctx.db.getRegisteredColumns(table);
19894
- opts.orderBy = cols && "created_at" in cols ? "created_at" : ctx.db.getPrimaryKey(table)[0] ?? "id";
19895
- opts.orderDir = "asc";
19896
- const rows = await ctx.db.query(table, opts);
20631
+ const orderBy = cols && "created_at" in cols ? "created_at" : ctx.db.getPrimaryKey(table)[0] ?? "id";
20632
+ let rows;
20633
+ if (ctx.team) {
20634
+ rows = await listVisibleRows(ctx.db, ctx.team.teamId, table, ctx.team.myUserId, {
20635
+ limit: 200,
20636
+ orderBy,
20637
+ orderDir: "asc",
20638
+ deleted: ctx.softDeletable.has(table) && includeDeleted ? "any" : "exclude"
20639
+ });
20640
+ } else {
20641
+ const opts = { limit: 200, orderBy, orderDir: "asc" };
20642
+ if (ctx.softDeletable.has(table) && !includeDeleted) {
20643
+ opts.filters = [{ col: "deleted_at", op: "isNull" }];
20644
+ }
20645
+ rows = await ctx.db.query(table, opts);
20646
+ }
19897
20647
  const secretCols = await secretColumnsFor(ctx.db, table);
19898
20648
  return { ok: true, result: rows.map((r) => redactRow(r, secretCols)) };
19899
20649
  }
@@ -19902,6 +20652,9 @@ async function executeFunction(ctx, name, args) {
19902
20652
  const id = requireString3(args.id, "id");
19903
20653
  const row = await ctx.db.get(table, id);
19904
20654
  if (row === null) return { ok: false, error: "Row not found" };
20655
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
20656
+ return { ok: false, error: "Row not found" };
20657
+ }
19905
20658
  return { ok: true, result: redactRow(row, await secretColumnsFor(ctx.db, table)) };
19906
20659
  }
19907
20660
  case "search": {
@@ -19912,10 +20665,18 @@ async function executeFunction(ctx, name, args) {
19912
20665
  tables = tables.filter((t) => want.has(t));
19913
20666
  }
19914
20667
  const limit = typeof args.limit === "number" ? args.limit : 8;
19915
- const result = await fullTextSearch(ctx.db.adapter, tables, {
20668
+ let result = await fullTextSearch(ctx.db.adapter, tables, {
19916
20669
  query,
19917
20670
  limitPerTable: limit
19918
20671
  });
20672
+ if (ctx.team) {
20673
+ result = await filterSearchGroupsByAcl(
20674
+ ctx.db,
20675
+ ctx.team.teamId,
20676
+ ctx.team.myUserId,
20677
+ result
20678
+ );
20679
+ }
19919
20680
  return { ok: true, result };
19920
20681
  }
19921
20682
  case "create_row": {
@@ -20479,15 +21240,19 @@ function collapseSameRole(msgs) {
20479
21240
  }
20480
21241
  return out;
20481
21242
  }
20482
- async function rehydrateHistory(db, threadId, clientHistory) {
21243
+ async function rehydrateHistory(db, threadId, clientHistory, ownerUserId) {
20483
21244
  if (!threadId || !rehydrateEnabled()) return clientHistory;
20484
21245
  let rows;
20485
21246
  try {
21247
+ const filters = [
21248
+ { col: "thread_id", op: "eq", val: threadId },
21249
+ { col: "deleted_at", op: "isNull" }
21250
+ ];
21251
+ if (ownerUserId != null) {
21252
+ filters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
21253
+ }
20486
21254
  rows = await db.query("chat_messages", {
20487
- filters: [
20488
- { col: "thread_id", op: "eq", val: threadId },
20489
- { col: "deleted_at", op: "isNull" }
20490
- ],
21255
+ filters,
20491
21256
  limit: 1e3
20492
21257
  });
20493
21258
  } catch {
@@ -20559,13 +21324,18 @@ async function rehydrateHistory(db, threadId, clientHistory) {
20559
21324
  }
20560
21325
  return merged;
20561
21326
  }
20562
- async function ensureThread(db, threadId, title) {
21327
+ async function ensureThread(db, threadId, title, ownerUserId) {
20563
21328
  if (threadId) {
20564
21329
  const existing = await db.get("chat_threads", threadId);
20565
- if (existing && !existing.deleted_at) return threadId;
21330
+ const ownsIt = ownerUserId == null || (existing?.owner_user_id ?? null) === ownerUserId;
21331
+ if (existing && !existing.deleted_at && ownsIt) return threadId;
20566
21332
  }
20567
21333
  const id = crypto.randomUUID();
20568
- await db.insert("chat_threads", { id, title: title.slice(0, 60) || "Chat" });
21334
+ await db.insert("chat_threads", {
21335
+ id,
21336
+ title: title.slice(0, 60) || "Chat",
21337
+ owner_user_id: ownerUserId
21338
+ });
20569
21339
  return id;
20570
21340
  }
20571
21341
  var REHYDRATE_MAX_TURNS = 6;
@@ -20573,20 +21343,28 @@ var REHYDRATE_MAX_BYTES = 24e3;
20573
21343
  function rehydrateEnabled() {
20574
21344
  return process.env.LATTICE_CHAT_REHYDRATE !== "false";
20575
21345
  }
20576
- async function persistMessage(db, threadId, role, text, turns, startedAt) {
21346
+ async function persistMessage(db, threadId, role, text, ownerUserId, turns, startedAt) {
20577
21347
  const payload = turns && turns.length > 0 ? { text, turns } : { text };
20578
21348
  if (startedAt) payload.startedAt = startedAt;
20579
21349
  await db.insert("chat_messages", {
20580
21350
  id: crypto.randomUUID(),
20581
21351
  thread_id: threadId,
21352
+ // Mirror the owning member onto each message so a message read can be
21353
+ // filtered independently of the thread join. NULL on local DBs.
21354
+ owner_user_id: ownerUserId,
20582
21355
  role,
20583
21356
  content_json: JSON.stringify(payload),
20584
21357
  source: role === "user" ? "gui" : "ai"
20585
21358
  });
20586
21359
  }
20587
21360
  async function dispatchChatRoute(req, res, ctx) {
21361
+ const owner = ctx.team ? ctx.team.myUserId : null;
20588
21362
  if (ctx.method === "GET" && ctx.pathname === "/api/chat/threads") {
20589
- const rows = await ctx.db.query("chat_threads", { limit: 100 });
21363
+ const filters = [
21364
+ { col: "deleted_at", op: "isNull" }
21365
+ ];
21366
+ if (owner != null) filters.push({ col: "owner_user_id", op: "eq", val: owner });
21367
+ const rows = await ctx.db.query("chat_threads", { filters, limit: 100 });
20590
21368
  const threads = rows.filter((r) => !r.deleted_at).map((r) => ({
20591
21369
  id: asStr(r.id),
20592
21370
  title: asStr(r.title, "Chat"),
@@ -20598,7 +21376,19 @@ async function dispatchChatRoute(req, res, ctx) {
20598
21376
  const msgMatch = /^\/api\/chat\/threads\/([^/]+)\/messages$/.exec(ctx.pathname);
20599
21377
  if (ctx.method === "GET" && msgMatch) {
20600
21378
  const threadId2 = decodeURIComponent(msgMatch[1] ?? "");
20601
- const rows = await ctx.db.query("chat_messages", { limit: 1e3 });
21379
+ if (owner != null) {
21380
+ const thread = await ctx.db.get("chat_threads", threadId2);
21381
+ if (!thread || thread.deleted_at || (thread.owner_user_id ?? null) !== owner) {
21382
+ sendJson4(res, { messages: [] });
21383
+ return true;
21384
+ }
21385
+ }
21386
+ const msgFilters = [{ col: "thread_id", op: "eq", val: threadId2 }];
21387
+ if (owner != null) msgFilters.push({ col: "owner_user_id", op: "eq", val: owner });
21388
+ const rows = await ctx.db.query("chat_messages", {
21389
+ filters: msgFilters,
21390
+ limit: 1e3
21391
+ });
20602
21392
  const messages = rows.filter((r) => r.thread_id === threadId2 && !r.deleted_at).map((r) => {
20603
21393
  let text = "";
20604
21394
  let turns2;
@@ -20652,11 +21442,11 @@ async function dispatchChatRoute(req, res, ctx) {
20652
21442
  return true;
20653
21443
  }
20654
21444
  const requestedThread = typeof body.threadId === "string" ? body.threadId : null;
20655
- const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history));
21445
+ const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history), owner);
20656
21446
  let threadId = "";
20657
21447
  try {
20658
- threadId = await ensureThread(ctx.db, requestedThread, message);
20659
- await persistMessage(ctx.db, threadId, "user", message);
21448
+ threadId = await ensureThread(ctx.db, requestedThread, message, owner);
21449
+ await persistMessage(ctx.db, threadId, "user", message, owner);
20660
21450
  } catch (e) {
20661
21451
  console.warn("[chat] persist user message failed:", e.message);
20662
21452
  }
@@ -20673,6 +21463,7 @@ async function dispatchChatRoute(req, res, ctx) {
20673
21463
  validTables: new Set([...ctx.validTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20674
21464
  junctionTables: new Set([...ctx.junctionTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20675
21465
  softDeletable: ctx.softDeletable,
21466
+ ...ctx.team ? { team: ctx.team } : {},
20676
21467
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {},
20677
21468
  ...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
20678
21469
  ...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
@@ -20742,7 +21533,15 @@ async function dispatchChatRoute(req, res, ctx) {
20742
21533
  ...t.toolCalls.length > 0 ? { toolCalls: t.toolCalls } : {}
20743
21534
  })).filter((t) => t.text.length > 0 || t.tools.length > 0 || (t.events?.length ?? 0) > 0);
20744
21535
  try {
20745
- await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns, turnStartedAt);
21536
+ await persistMessage(
21537
+ ctx.db,
21538
+ threadId,
21539
+ "assistant",
21540
+ assistantText,
21541
+ owner,
21542
+ cleanTurns,
21543
+ turnStartedAt
21544
+ );
20746
21545
  } catch (e) {
20747
21546
  console.warn("[chat] persist assistant message failed:", e.message);
20748
21547
  }
@@ -21795,6 +22594,41 @@ async function countManyPostgres(adapter, tableNames) {
21795
22594
  }
21796
22595
  return out;
21797
22596
  }
22597
+ var EXACT_COUNT_CAP = 50;
22598
+ async function exactCountMany(adapter, tableNames, softDeleteTables) {
22599
+ const out = /* @__PURE__ */ new Map();
22600
+ if (tableNames.length === 0) return out;
22601
+ if (!adapter.getAsync) return out;
22602
+ let names = tableNames;
22603
+ if (names.length > EXACT_COUNT_CAP) {
22604
+ const dropped = names.length - EXACT_COUNT_CAP;
22605
+ console.warn(
22606
+ `[count-many] exact-count subset capped at ${String(EXACT_COUNT_CAP)} tables; ${String(dropped)} suspicious table(s) keep their approximate count this pass`
22607
+ );
22608
+ names = names.slice(0, EXACT_COUNT_CAP);
22609
+ }
22610
+ const selects = names.map((name, i) => {
22611
+ assertSafeIdentifier(name, "table");
22612
+ const where = softDeleteTables.has(name) ? ` WHERE "deleted_at" IS NULL` : "";
22613
+ return `(SELECT count(*) FROM "${name}"${where}) AS c${String(i)}`;
22614
+ });
22615
+ let row;
22616
+ try {
22617
+ row = await adapter.getAsync(`SELECT ${selects.join(", ")}`);
22618
+ } catch (err) {
22619
+ console.warn(
22620
+ `[count-many] exact-count fallback skipped (${err.message}); using approximate counts`
22621
+ );
22622
+ return out;
22623
+ }
22624
+ if (!row) return out;
22625
+ names.forEach((name, i) => {
22626
+ const v = row[`c${String(i)}`];
22627
+ const n = typeof v === "bigint" ? Number(v) : Number(v);
22628
+ if (Number.isFinite(n) && n >= 0) out.set(name, n);
22629
+ });
22630
+ return out;
22631
+ }
21798
22632
 
21799
22633
  // src/gui/server.ts
21800
22634
  function sendText(res, body, status = 200, contentType = "text/plain; charset=utf-8") {
@@ -21853,17 +22687,64 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21853
22687
  if (teamContext) {
21854
22688
  allTables = allTables.filter((t) => isVisibleInTeam(t.name, teamContext));
21855
22689
  }
22690
+ const rowVisDefaults = /* @__PURE__ */ new Map();
22691
+ const sharedCreatedBy = /* @__PURE__ */ new Map();
22692
+ if (teamContext) {
22693
+ const sharedObjs = await db.query("__lattice_shared_objects", {
22694
+ filters: [
22695
+ { col: "team_id", op: "eq", val: teamContext.teamId },
22696
+ { col: "deleted_at", op: "isNull" }
22697
+ ]
22698
+ });
22699
+ for (const r of sharedObjs) {
22700
+ rowVisDefaults.set(
22701
+ String(r.table_name),
22702
+ r.default_row_visibility === "everyone" ? "everyone" : "private"
22703
+ );
22704
+ if (typeof r.created_by_user_id === "string") {
22705
+ sharedCreatedBy.set(String(r.table_name), r.created_by_user_id);
22706
+ }
22707
+ }
22708
+ }
22709
+ let visibleCounts = /* @__PURE__ */ new Map();
22710
+ if (teamContext) {
22711
+ const tc = teamContext;
22712
+ const specs = allTables.map((t) => {
22713
+ const def = rowVisDefaults.get(t.name) ?? "private";
22714
+ const owner = tc.owners.get(t.name) ?? sharedCreatedBy.get(t.name) ?? "";
22715
+ return {
22716
+ table: t.name,
22717
+ noAclVisible: def === "everyone" || tc.myUserId !== "" && owner === tc.myUserId
22718
+ };
22719
+ });
22720
+ visibleCounts = await db.countVisibleMany(specs, {
22721
+ teamId: tc.teamId,
22722
+ userId: tc.myUserId
22723
+ });
22724
+ }
21856
22725
  const adapter = db._adapter;
21857
- const useBatched = adapter.dialect === "postgres" && typeof adapter.allAsync === "function";
22726
+ const useBatched = !teamContext && adapter.dialect === "postgres" && typeof adapter.allAsync === "function";
21858
22727
  const approxCounts = useBatched ? await countManyPostgres(
21859
22728
  adapter,
21860
22729
  allTables.map((t) => t.name)
21861
22730
  ) : /* @__PURE__ */ new Map();
22731
+ let exactCounts = /* @__PURE__ */ new Map();
22732
+ if (useBatched) {
22733
+ const suspicious = allTables.map((t) => t.name).filter((n) => (approxCounts.get(n) ?? 0) === 0);
22734
+ if (suspicious.length > 0) {
22735
+ const softDeleteTables = new Set(
22736
+ allTables.filter((t) => t.columns.includes("deleted_at")).map((t) => t.name)
22737
+ );
22738
+ exactCounts = await exactCountMany(adapter, suspicious, softDeleteTables);
22739
+ }
22740
+ }
21862
22741
  const enrichedTables = await Promise.all(
21863
22742
  allTables.map(async (t) => {
21864
22743
  let rowCount;
21865
- if (useBatched) {
21866
- rowCount = approxCounts.get(t.name) ?? null;
22744
+ if (teamContext) {
22745
+ rowCount = visibleCounts.get(t.name) ?? null;
22746
+ } else if (useBatched) {
22747
+ rowCount = exactCounts.get(t.name) ?? approxCounts.get(t.name) ?? null;
21867
22748
  } else {
21868
22749
  rowCount = t.columns.includes("deleted_at") ? await db.count(t.name, { filters: [{ col: "deleted_at", op: "isNull" }] }) : await db.count(t.name);
21869
22750
  }
@@ -21877,6 +22758,8 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21877
22758
  base.ownedByMe = teamContext.owners.get(t.name) === teamContext.myUserId;
21878
22759
  const ver = teamContext.sharedVersions.get(t.name);
21879
22760
  if (ver !== void 0) base.schemaVersion = ver;
22761
+ const dv = rowVisDefaults.get(t.name);
22762
+ if (dv) base.defaultRowVisibility = dv;
21880
22763
  }
21881
22764
  return base;
21882
22765
  })
@@ -21885,6 +22768,14 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21885
22768
  }
21886
22769
  var FRESHNESS_COLS = ["updated_at", "created_at", "ts"];
21887
22770
  var DASHBOARD_STALE_DAYS = 14;
22771
+ function requireTeamContext(active, res) {
22772
+ const ctx = active.teamContext;
22773
+ if (!ctx) {
22774
+ sendJson(res, { error: "Row permissions require a team cloud" }, 400);
22775
+ return null;
22776
+ }
22777
+ return ctx;
22778
+ }
21888
22779
  function operatorOwnsTable(teamContext, table) {
21889
22780
  if (!teamContext) return true;
21890
22781
  return teamContext.owners.get(table) === teamContext.myUserId;
@@ -22174,6 +23065,13 @@ async function openConfig(configPath, outputDir, autoRender = false) {
22174
23065
  if (!isVisibleInTeam(name, teamContext)) validTables.delete(name);
22175
23066
  }
22176
23067
  }
23068
+ let directTeamConnection = false;
23069
+ try {
23070
+ const conns = await teamsClient.listConnections();
23071
+ directTeamConnection = conns.some((c) => isPostgresUrl(c.cloud_url));
23072
+ } catch (e) {
23073
+ console.warn("[openConfig] could not check for direct team connections:", e.message);
23074
+ }
22177
23075
  let realtime = null;
22178
23076
  if (db.getDialect() === "postgres") {
22179
23077
  try {
@@ -22214,6 +23112,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
22214
23112
  teamsClient,
22215
23113
  validTables,
22216
23114
  teamContext,
23115
+ directTeamConnection,
22217
23116
  junctionTables,
22218
23117
  entityContextByTable,
22219
23118
  manifest,
@@ -22317,6 +23216,18 @@ async function registerTeamCloudTables(db) {
22317
23216
  await db.defineLate(name, def);
22318
23217
  }
22319
23218
  await installCloudInternalTriggers(db);
23219
+ await installRowPermsSchema(db);
23220
+ }
23221
+ async function emitRowChangeSignal(db, team, table, pk) {
23222
+ const row = await db.get(table, pk);
23223
+ await appendChangeEnvelope(db, {
23224
+ team_id: team.teamId,
23225
+ table_name: table,
23226
+ pk,
23227
+ op: "upsert",
23228
+ payload_json: row ? JSON.stringify(row) : null,
23229
+ owner_user_id: team.myUserId
23230
+ });
22320
23231
  }
22321
23232
  async function syncUserIdentityRow(db) {
22322
23233
  const identity = readIdentity();
@@ -22642,10 +23553,19 @@ data: ${JSON.stringify(data)}
22642
23553
  );
22643
23554
  tables = tables.filter((t) => want.has(t));
22644
23555
  }
22645
- sendJson(
22646
- res,
22647
- await fullTextSearch(active.db.adapter, tables, { query: q, limitPerTable: limit })
22648
- );
23556
+ let result = await fullTextSearch(active.db.adapter, tables, {
23557
+ query: q,
23558
+ limitPerTable: limit
23559
+ });
23560
+ if (active.teamContext) {
23561
+ result = await filterSearchGroupsByAcl(
23562
+ active.db,
23563
+ active.teamContext.teamId,
23564
+ active.teamContext.myUserId,
23565
+ result
23566
+ );
23567
+ }
23568
+ sendJson(res, result);
22649
23569
  return;
22650
23570
  }
22651
23571
  if (method === "GET" && pathname === "/api/team/users") {
@@ -22846,6 +23766,78 @@ data: ${JSON.stringify(data)}
22846
23766
  sendJson(res, result.body, result.status);
22847
23767
  return;
22848
23768
  }
23769
+ {
23770
+ const m = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/visibility$/.exec(pathname);
23771
+ if (method === "POST" && m) {
23772
+ const ctx = requireTeamContext(active, res);
23773
+ if (!ctx) return;
23774
+ const table = decodeURIComponent(m[1] ?? "");
23775
+ const rowId = decodeURIComponent(m[2] ?? "");
23776
+ const body = await readJson(req);
23777
+ const vis = body.visibility;
23778
+ if (vis !== "private" && vis !== "everyone" && vis !== "custom") {
23779
+ sendJson(res, { error: "visibility must be private, everyone, or custom" }, 400);
23780
+ return;
23781
+ }
23782
+ await setRowVisibility(active.db, ctx.teamId, table, rowId, ctx.myUserId, vis);
23783
+ await emitRowChangeSignal(active.db, ctx, table, rowId);
23784
+ sendJson(res, { ok: true });
23785
+ return;
23786
+ }
23787
+ }
23788
+ {
23789
+ const m = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/grants$/.exec(pathname);
23790
+ if (method === "POST" && m) {
23791
+ const ctx = requireTeamContext(active, res);
23792
+ if (!ctx) return;
23793
+ const table = decodeURIComponent(m[1] ?? "");
23794
+ const rowId = decodeURIComponent(m[2] ?? "");
23795
+ const body = await readJson(req);
23796
+ let granteeId = typeof body.user_id === "string" ? body.user_id : "";
23797
+ if (!granteeId && typeof body.email === "string") {
23798
+ granteeId = await resolveUserIdByEmail(active.db, body.email) ?? "";
23799
+ }
23800
+ if (!granteeId) {
23801
+ sendJson(res, { error: "user_id or a resolvable email is required" }, 400);
23802
+ return;
23803
+ }
23804
+ await addRowGrant(active.db, ctx.teamId, table, rowId, granteeId, ctx.myUserId);
23805
+ await emitRowChangeSignal(active.db, ctx, table, rowId);
23806
+ sendJson(res, { ok: true });
23807
+ return;
23808
+ }
23809
+ }
23810
+ {
23811
+ const m = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/grants\/([^/]+)$/.exec(pathname);
23812
+ if (method === "DELETE" && m) {
23813
+ const ctx = requireTeamContext(active, res);
23814
+ if (!ctx) return;
23815
+ const table = decodeURIComponent(m[1] ?? "");
23816
+ const rowId = decodeURIComponent(m[2] ?? "");
23817
+ const granteeId = decodeURIComponent(m[3] ?? "");
23818
+ await removeRowGrant(active.db, ctx.teamId, table, rowId, granteeId, ctx.myUserId);
23819
+ await emitRowChangeSignal(active.db, ctx, table, rowId);
23820
+ sendJson(res, { ok: true });
23821
+ return;
23822
+ }
23823
+ }
23824
+ {
23825
+ const m = /^\/api\/schema\/entities\/([^/]+)\/default-row-visibility$/.exec(pathname);
23826
+ if (method === "POST" && m) {
23827
+ const ctx = requireTeamContext(active, res);
23828
+ if (!ctx) return;
23829
+ const table = decodeURIComponent(m[1] ?? "");
23830
+ const body = await readJson(req);
23831
+ const vis = body.visibility;
23832
+ if (vis !== "private" && vis !== "everyone") {
23833
+ sendJson(res, { error: "visibility must be private or everyone" }, 400);
23834
+ return;
23835
+ }
23836
+ await setTableDefaultVisibility(active.db, ctx.teamId, table, ctx.myUserId, vis);
23837
+ sendJson(res, { ok: true });
23838
+ return;
23839
+ }
23840
+ }
22849
23841
  if (method === "GET" && pathname === "/api/gui-meta/columns") {
22850
23842
  const rows = await active.db.query("_lattice_gui_column_meta", {});
22851
23843
  const out = {};
@@ -23940,6 +24932,10 @@ data: ${JSON.stringify(data)}
23940
24932
  sendJson(res, { error: `Unknown table: ${table}` }, 400);
23941
24933
  return;
23942
24934
  }
24935
+ if (!await canAccessRow(active.db, tctx.teamId, table, rowId, tctx.myUserId)) {
24936
+ sendJson(res, { error: "Row not found" }, 404);
24937
+ return;
24938
+ }
23943
24939
  const limit = Math.min(200, Math.max(1, Number(url2.searchParams.get("limit") ?? "50")));
23944
24940
  const rows = await active.db.query("__lattice_change_log", {
23945
24941
  filters: [
@@ -23974,20 +24970,7 @@ data: ${JSON.stringify(data)}
23974
24970
  sendJson(res, { error: `Unknown table: ${table}` }, 400);
23975
24971
  return;
23976
24972
  }
23977
- const scan = await active.db.query("__lattice_change_log", {
23978
- filters: [
23979
- { col: "team_id", op: "eq", val: tctx.teamId },
23980
- { col: "table_name", op: "eq", val: table }
23981
- ],
23982
- orderBy: "seq",
23983
- orderDir: "desc",
23984
- limit: 2e3
23985
- });
23986
- const edits = {};
23987
- for (const r of scan) {
23988
- if (!r.pk || edits[r.pk]) continue;
23989
- edits[r.pk] = { ownerUserId: r.owner_user_id, at: r.client_ts ?? r.created_at };
23990
- }
24973
+ const edits = await visibleRowEdits(active.db, tctx.teamId, table, tctx.myUserId);
23991
24974
  sendJson(res, { edits });
23992
24975
  return;
23993
24976
  }
@@ -24029,13 +25012,31 @@ data: ${JSON.stringify(data)}
24029
25012
  const limit = Number(url2.searchParams.get("limit") ?? "500");
24030
25013
  const offset = Number(url2.searchParams.get("offset") ?? "0");
24031
25014
  const deletedMode = url2.searchParams.get("deleted");
24032
- const queryOpts = { limit, offset };
24033
- if (active.softDeletable.has(table) && deletedMode !== "any") {
24034
- queryOpts.filters = [
24035
- { col: "deleted_at", op: deletedMode === "only" ? "isNotNull" : "isNull" }
24036
- ];
25015
+ let rows;
25016
+ if (active.teamContext) {
25017
+ const tc = active.teamContext;
25018
+ rows = await listVisibleRows(active.db, tc.teamId, table, tc.myUserId, {
25019
+ limit,
25020
+ offset,
25021
+ deleted: deletedMode === "any" ? "any" : deletedMode === "only" ? "only" : "exclude"
25022
+ });
25023
+ const summaries = await rowAccessSummaries(
25024
+ active.db,
25025
+ tc.teamId,
25026
+ table,
25027
+ tc.myUserId,
25028
+ rows.map((r) => String(r.id))
25029
+ );
25030
+ rows = rows.map((r) => ({ ...r, _access: summaries.get(String(r.id)) ?? null }));
25031
+ } else {
25032
+ const queryOpts = { limit, offset };
25033
+ if (active.softDeletable.has(table) && deletedMode !== "any") {
25034
+ queryOpts.filters = [
25035
+ { col: "deleted_at", op: deletedMode === "only" ? "isNotNull" : "isNull" }
25036
+ ];
25037
+ }
25038
+ rows = await active.db.query(table, queryOpts);
24037
25039
  }
24038
- const rows = await active.db.query(table, queryOpts);
24039
25040
  sendJson(res, { rows });
24040
25041
  return;
24041
25042
  }
@@ -24052,6 +25053,19 @@ data: ${JSON.stringify(data)}
24052
25053
  sendJson(res, { error: "Row not found" }, 404);
24053
25054
  return;
24054
25055
  }
25056
+ if (active.teamContext) {
25057
+ const tc = active.teamContext;
25058
+ if (!await canAccessRow(active.db, tc.teamId, table, id, tc.myUserId)) {
25059
+ sendJson(res, { error: "Row not found" }, 404);
25060
+ return;
25061
+ }
25062
+ const summary = (await rowAccessSummaries(active.db, tc.teamId, table, tc.myUserId, [id])).get(
25063
+ id
25064
+ ) ?? null;
25065
+ const access = summary?.ownedByMe ? { ...summary, grantees: await rowGrantees(active.db, tc.teamId, table, id) } : summary;
25066
+ sendJson(res, { ...row, _access: access });
25067
+ return;
25068
+ }
24055
25069
  sendJson(res, row);
24056
25070
  return;
24057
25071
  }
@@ -24144,6 +25158,7 @@ data: ${JSON.stringify(data)}
24144
25158
  validTables: active.validTables,
24145
25159
  junctionTables: active.junctionTables,
24146
25160
  softDeletable: active.softDeletable,
25161
+ team: active.teamContext ? { teamId: active.teamContext.teamId, myUserId: active.teamContext.myUserId } : null,
24147
25162
  // The assistant can create tables + relationships on request — same
24148
25163
  // audited, no-reopen primitives the Context Constructor uses.
24149
25164
  createEntity: (name, columns) => createUserEntity(active, name, columns, sessionId),
@@ -24193,6 +25208,7 @@ data: ${JSON.stringify(data)}
24193
25208
  teamId: active.teamContext.teamId,
24194
25209
  myUserId: active.teamContext.myUserId
24195
25210
  } : null,
25211
+ directCloud: active.directTeamConnection,
24196
25212
  swap: async () => {
24197
25213
  const next = await openConfig(active.configPath, active.outputDir, autoRender);
24198
25214
  await disposeActive(active);
@@ -24204,6 +25220,14 @@ data: ${JSON.stringify(data)}
24204
25220
  sendJson(res, { error: "Not found" }, 404);
24205
25221
  } catch (err) {
24206
25222
  const e = err;
25223
+ if (e.code === "row_access_denied") {
25224
+ sendJson(res, { error: "Row not found" }, 404);
25225
+ return;
25226
+ }
25227
+ if (e.code === "row_owner_only") {
25228
+ sendJson(res, { error: e.message }, 403);
25229
+ return;
25230
+ }
24207
25231
  console.error(
24208
25232
  `[gui] ${req.method ?? "?"} ${req.url ?? "?"} failed: ${e.message}
24209
25233
  ${e.stack ?? ""}`
@@ -24916,7 +25940,7 @@ function printHelp() {
24916
25940
  " status Dry-run reconcile \u2014 show what would change without writing",
24917
25941
  " watch Poll for changes and re-render on each cycle",
24918
25942
  " gui Start a local browser GUI for exploring Lattice context",
24919
- " serve Start a server-mode lattice (use --team-cloud for Lattice Teams)",
25943
+ " serve Start a server-mode lattice (add --team-cloud to host a shared cloud for remote members)",
24920
25944
  " teams Manage Lattice Teams (run `lattice teams help` for subcommands)",
24921
25945
  " update Upgrade latticesql to the latest version",
24922
25946
  "",
@@ -24961,7 +25985,11 @@ function printHelp() {
24961
25985
  " --output <dir> Output directory for rendered context (default: ./context)",
24962
25986
  " --host <addr> Bind address (default: 127.0.0.1; use 0.0.0.0 to expose)",
24963
25987
  " --port <number> Port (default: 4317; auto-increments if busy)",
24964
- " --team-cloud Enable Lattice Teams cloud mode (bearer auth required)",
25988
+ " --team-cloud Host this cloud as a shared server for remote members (bearer auth).",
25989
+ " A cloud already IS a workspace with members; this only adds the",
25990
+ " auth-gated HTTP surface so other people can connect. Omit it to",
25991
+ " open the cloud yourself (you connect directly; eye-icon row",
25992
+ " permissions are active).",
24965
25993
  "",
24966
25994
  "Options (init / workspace):",
24967
25995
  " --root <dir> The .lattice root location (default: discovered or ./.lattice)",
@@ -25205,7 +26233,7 @@ async function runServe(args) {
25205
26233
  openBrowser: false,
25206
26234
  teamCloud: args.teamCloud
25207
26235
  });
25208
- const label = args.teamCloud ? "Lattice team cloud" : "Lattice server";
26236
+ const label = args.teamCloud ? "Lattice shared cloud server" : "Lattice server";
25209
26237
  console.log(`${label} listening on ${args.host}:${String(handle.port)} (${handle.url})`);
25210
26238
  console.log("Press Ctrl+C to stop.");
25211
26239
  const shutdown = () => {