latticesql 2.1.1 → 2.2.0

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;
@@ -5473,6 +5479,131 @@ var Lattice = class _Lattice {
5473
5479
  const rows = await allAsyncOrSync(this._adapter, sql, params);
5474
5480
  return this._decryptRows(table, rows);
5475
5481
  }
5482
+ /**
5483
+ * Row-level-security list read for Lattice Teams (2.2). Returns only the
5484
+ * rows of `table` that `userId` may see in team `teamId`, evaluated
5485
+ * entirely in SQL (indexed, bounded — never "load every row then filter
5486
+ * in JS"). A row is visible iff it has a `__lattice_row_acl` entry owned by
5487
+ * the user or marked 'everyone', or a 'custom' entry with a matching
5488
+ * `__lattice_row_grants` row, OR it has no ACL entry at all and the caller
5489
+ * passes `noAclVisible` (the table default is 'everyone', or the user owns
5490
+ * the table — the pre-2.2 / never-narrowed case). Soft-deleted rows are
5491
+ * excluded by default; results reuse the same decrypt path as `query()`.
5492
+ *
5493
+ * The ACL predicate joins on the table's primary-key column cast to TEXT
5494
+ * (ACL pks are stored as TEXT), so it is correct regardless of the user
5495
+ * table's pk type and works on both SQLite and Postgres. The teams layer's
5496
+ * `listVisibleRows` (src/teams/row-access.ts) is the intended caller.
5497
+ */
5498
+ async queryVisible(table, opts) {
5499
+ const notInit = this._notInitError();
5500
+ if (notInit) return notInit;
5501
+ this._assertIdent(table);
5502
+ if (opts.orderBy) this._assertIdent(table, opts.orderBy);
5503
+ const cols = this._ensureColumnCache(table);
5504
+ const pkCol = this._schema.getPrimaryKey(table)[0] ?? "id";
5505
+ let softDelete = "";
5506
+ if (cols.has("deleted_at") && opts.deleted !== "any") {
5507
+ softDelete = opts.deleted === "only" ? `t."deleted_at" IS NOT NULL AND ` : `t."deleted_at" IS NULL AND `;
5508
+ }
5509
+ const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5510
+ let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${rowAclVisibleExists("?", "?", pkExpr)}`;
5511
+ const params = [opts.teamId, table, opts.userId, opts.userId];
5512
+ if (opts.noAclVisible) {
5513
+ sql += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5514
+ params.push(opts.teamId, table);
5515
+ }
5516
+ sql += `)`;
5517
+ if (opts.orderBy && cols.has(opts.orderBy)) {
5518
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
5519
+ sql += ` ORDER BY t."${opts.orderBy}" ${dir}`;
5520
+ }
5521
+ if (opts.limit !== void 0 && Number.isFinite(opts.limit)) {
5522
+ sql += ` LIMIT ${Math.trunc(opts.limit).toString()}`;
5523
+ }
5524
+ if (opts.offset !== void 0 && Number.isFinite(opts.offset)) {
5525
+ if (opts.limit === void 0 && this.getDialect() === "sqlite") sql += " LIMIT -1";
5526
+ sql += ` OFFSET ${Math.trunc(opts.offset).toString()}`;
5527
+ }
5528
+ const rows = await allAsyncOrSync(this._adapter, sql, params);
5529
+ return this._decryptRows(table, rows);
5530
+ }
5531
+ /**
5532
+ * Visible-row counts for MANY tables in a single round-trip, using the same
5533
+ * ACL predicate as {@link queryVisible} — so dashboard tiles agree with what
5534
+ * the rows view lists and a physical count never reveals the existence or
5535
+ * volume of rows the user can't see. One aggregated
5536
+ * `SELECT (SELECT COUNT(*) …) AS c0, …` statement (no per-table fan-out, so
5537
+ * a session pooler with few slots survives concurrent refreshes), capped at
5538
+ * 50 tables per pass; overflow is logged and skipped (no silent truncation)
5539
+ * and those tables count as absent — the caller renders "—". Soft-deleted
5540
+ * rows are excluded wherever the table carries `deleted_at`, matching the
5541
+ * default rows view.
5542
+ */
5543
+ async countVisibleMany(specs, opts) {
5544
+ const out = /* @__PURE__ */ new Map();
5545
+ const notInit = this._notInitError();
5546
+ if (notInit) return notInit;
5547
+ if (specs.length === 0) return out;
5548
+ const VISIBLE_COUNT_CAP = 50;
5549
+ let bounded = specs;
5550
+ if (bounded.length > VISIBLE_COUNT_CAP) {
5551
+ const dropped = bounded.length - VISIBLE_COUNT_CAP;
5552
+ console.warn(
5553
+ `[lattice] visible-count pass capped at ${String(VISIBLE_COUNT_CAP)} tables; ${String(dropped)} table(s) report no count this pass`
5554
+ );
5555
+ bounded = bounded.slice(0, VISIBLE_COUNT_CAP);
5556
+ }
5557
+ const selects = [];
5558
+ const params = [];
5559
+ for (const [i, spec] of bounded.entries()) {
5560
+ this._assertIdent(spec.table);
5561
+ const cols = this._ensureColumnCache(spec.table);
5562
+ const pkCol = this._schema.getPrimaryKey(spec.table)[0] ?? "id";
5563
+ const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
5564
+ const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5565
+ let predicate = rowAclVisibleExists("?", "?", pkExpr);
5566
+ params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5567
+ if (spec.noAclVisible) {
5568
+ predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5569
+ params.push(opts.teamId, spec.table);
5570
+ }
5571
+ selects.push(
5572
+ `(SELECT COUNT(*) FROM "${spec.table}" t WHERE ${softDelete}(${predicate})) AS c${String(i)}`
5573
+ );
5574
+ }
5575
+ const row = await getAsyncOrSync(this._adapter, `SELECT ${selects.join(", ")}`, params);
5576
+ if (!row) return out;
5577
+ for (const [i, spec] of bounded.entries()) {
5578
+ const raw = row[`c${String(i)}`];
5579
+ const n = typeof raw === "bigint" ? Number(raw) : Number(raw);
5580
+ if (Number.isFinite(n) && n >= 0) out.set(spec.table, n);
5581
+ }
5582
+ return out;
5583
+ }
5584
+ /**
5585
+ * Hosted-sync change-log pull, filtered per recipient for 2.2 row-level
5586
+ * security (the hosted server's sole enforcement mechanism). Returns
5587
+ * `__lattice_change_log` rows with seq > `since` for team `teamId` that
5588
+ * `userId` is permitted to receive:
5589
+ * - targeted envelopes (`recipient_user_id = userId`), plus
5590
+ * - broadcast envelopes (`recipient_user_id IS NULL`) that are either
5591
+ * table-level (`pk IS NULL` — schema / unshare, delivered to all) or
5592
+ * whose row is currently visible to the user via `__lattice_row_acl` /
5593
+ * `__lattice_row_grants` (or has no ACL entry and the table defaults to
5594
+ * 'everyone').
5595
+ * Ordered by seq, capped at `limit`. Raw SQL because the predicate needs
5596
+ * OR / EXISTS that the `query()` API can't express; bounded by the seq
5597
+ * window and indexed ACL point-lookups. Mirrors {@link queryVisible}'s
5598
+ * visibility logic so a member never pulls the bytes of a row they can't see.
5599
+ */
5600
+ async listChangesForRecipient(teamId, since, userId, limit) {
5601
+ const notInit = this._notInitError();
5602
+ if (notInit) return notInit;
5603
+ 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()}`;
5604
+ const params = [teamId, since, userId, userId, userId];
5605
+ return allAsyncOrSync(this._adapter, sql, params);
5606
+ }
5476
5607
  async count(table, opts = {}) {
5477
5608
  const notInit = this._notInitError();
5478
5609
  if (notInit) return notInit;
@@ -7347,7 +7478,7 @@ var css = `
7347
7478
  .view-header .actions { margin-left: auto; display: flex; gap: 8px; }
7348
7479
 
7349
7480
  /* Row delete / restore controls */
7350
- .row-actions { width: 64px; text-align: center; white-space: nowrap; }
7481
+ .row-actions { width: 88px; text-align: center; white-space: nowrap; }
7351
7482
  .row-delete, .row-restore {
7352
7483
  background: transparent; border: none; color: var(--text-muted);
7353
7484
  font-size: 16px; cursor: pointer; padding: 4px 6px;
@@ -7358,6 +7489,43 @@ var css = `
7358
7489
  .row-restore:hover { background: var(--accent-soft); color: var(--accent); }
7359
7490
  tr.row-deleted td { background: rgba(251, 146, 60, 0.08); color: var(--text-muted); }
7360
7491
  tr.row-deleted:hover td { background: #fcf5e3; }
7492
+ /* Per-row visibility indicator (2.2). Reuses the team share colour
7493
+ language \u2014 yellow (#eab308) = visible to everyone, red (#ef4444) =
7494
+ private \u2014 matching the .sw-shared / .sw-private swatches. Owner =
7495
+ interactive toggle; non-owner = faded + inert (status only). */
7496
+ .row-vis {
7497
+ background: transparent; border: none; padding: 4px 6px; border-radius: 4px;
7498
+ font-size: 14px; line-height: 1; cursor: pointer; text-decoration: none;
7499
+ color: #eab308;
7500
+ }
7501
+ .row-vis:hover { filter: brightness(1.18); }
7502
+ .row-vis-private { color: #ef4444; }
7503
+ .row-vis-disabled { cursor: default; pointer-events: none; opacity: 0.45; }
7504
+ /* Grants checklist (detail view, owner-only): who can see a
7505
+ shared-with-specific-people row. Checkboxes post straight to the
7506
+ row-grant endpoints. */
7507
+ .grants-panel {
7508
+ margin: 4px 0 10px; padding: 10px 12px; max-width: 420px;
7509
+ border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2);
7510
+ font-size: 13px;
7511
+ }
7512
+ .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
7513
+ .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
7514
+ .grants-panel .grants-row input { accent-color: var(--accent); }
7515
+ /* Deprecation banner: shown when the workspace holds a grandfathered
7516
+ direct database cloud connection (no row-level security). Amber so
7517
+ it reads as a warning, not an error. */
7518
+ .deprecation-banner {
7519
+ display: flex; align-items: center; gap: 12px;
7520
+ padding: 8px 16px; font-size: 13px;
7521
+ background: rgba(234, 179, 8, 0.12); color: var(--text);
7522
+ border-bottom: 1px solid rgba(234, 179, 8, 0.45);
7523
+ }
7524
+ .deprecation-banner button {
7525
+ margin-left: auto; background: transparent; border: none; cursor: pointer;
7526
+ color: var(--text-muted); font-size: 13px; padding: 2px 6px; border-radius: 4px;
7527
+ }
7528
+ .deprecation-banner button:hover { background: rgba(234, 179, 8, 0.18); }
7361
7529
 
7362
7530
  /* Inline create-row at the bottom of every table */
7363
7531
  tr.create-row td { background: var(--surface-2); }
@@ -8848,6 +9016,27 @@ var appJs = `
8848
9016
 
8849
9017
  window.addEventListener('hashchange', renderRoute);
8850
9018
 
9019
+ // Deprecation banner: a grandfathered direct database cloud connection
9020
+ // bypasses the hosted server's row security entirely \u2014 say so up front.
9021
+ // Dismiss hides it for this browser session only.
9022
+ function initDeprecationBanner() {
9023
+ if (sessionStorage.getItem('lattice-direct-banner-dismissed')) return;
9024
+ fetchJson('/api/dbconfig').then(function (d) {
9025
+ if (!d || !d.directCloud) return;
9026
+ var banner = document.getElementById('deprecation-banner');
9027
+ var text = document.getElementById('deprecation-banner-text');
9028
+ if (!banner || !text) return;
9029
+ text.textContent = "Direct database cloud connections are deprecated and don't support row-level security. Migrate to a hosted workspace.";
9030
+ banner.hidden = false;
9031
+ var dismiss = document.getElementById('deprecation-banner-dismiss');
9032
+ if (dismiss) dismiss.addEventListener('click', function () {
9033
+ banner.hidden = true;
9034
+ sessionStorage.setItem('lattice-direct-banner-dismissed', '1');
9035
+ });
9036
+ }).catch(function () { /* dbconfig unavailable (e.g. team-cloud server mode) \u2014 no banner */ });
9037
+ }
9038
+ initDeprecationBanner();
9039
+
8851
9040
  // \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
9041
  // Sidebar
8853
9042
  // \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 +9392,31 @@ var appJs = `
9203
9392
  .map(function (h) { return '<th>' + escapeHtml(h) + '</th>'; }).join('');
9204
9393
  headers += '<th class="row-actions"></th>';
9205
9394
 
9395
+ // Per-row visibility indicator (2.2 row-level permissions). Reads the
9396
+ // server-attached _access summary (team clouds only); absent yields ''.
9397
+ // U+25C9 = everyone (yellow) / private (red, by colour); U+25CE =
9398
+ // custom (shared with specific people). Owner = interactive toggle;
9399
+ // non-owner = faded + inert status.
9400
+ function rowVisMarkup(tbl, r) {
9401
+ var a = r._access;
9402
+ if (!a) return '';
9403
+ var vis = a.visibility;
9404
+ var glyph = vis === 'custom' ? '\u25CE' : '\u25C9';
9405
+ if (!a.ownedByMe) {
9406
+ var seen = vis === 'custom' ? 'Shared with you' : 'Visible to everyone';
9407
+ return '<span class="row-vis row-vis-disabled" title="' + escapeHtml(seen) + '">' + glyph + '</span>';
9408
+ }
9409
+ if (vis === 'custom') {
9410
+ return '<a class="row-vis" href="#/objects/' + encodeURIComponent(tbl) + '/' + encodeURIComponent(r.id) +
9411
+ '" title="Shared with specific people \u2014 open to manage">' + glyph + '</a>';
9412
+ }
9413
+ var cls = vis === 'private' ? 'row-vis row-vis-private' : 'row-vis';
9414
+ var title = vis === 'everyone'
9415
+ ? 'Visible to everyone \u2014 click to make private'
9416
+ : 'Private to you \u2014 click to share with everyone';
9417
+ return '<button class="' + cls + '" data-vis-toggle="' + escapeHtml(r.id) +
9418
+ '" data-vis-cur="' + vis + '" title="' + escapeHtml(title) + '">' + glyph + '</button>';
9419
+ }
9206
9420
  var bodyRows;
9207
9421
  if (rows.length === 0) {
9208
9422
  bodyRows = '';
@@ -9233,7 +9447,8 @@ var appJs = `
9233
9447
  '<button class="row-delete" title="Delete permanently" data-hard-del="' + escapeHtml(r.id) + '">\u2715</button>' +
9234
9448
  '</td>');
9235
9449
  } else {
9236
- tds.push('<td class="row-actions"><button class="row-delete" title="Delete" data-del="' + escapeHtml(r.id) + '">\u2715</button></td>');
9450
+ tds.push('<td class="row-actions">' + rowVisMarkup(tableName, r) +
9451
+ '<button class="row-delete" title="Delete" data-del="' + escapeHtml(r.id) + '">\u2715</button></td>');
9237
9452
  }
9238
9453
  return '<tr data-id="' + escapeHtml(r.id) + '"' + (viewMode === 'trash' ? ' class="row-deleted"' : '') + '>' + tds.join('') + '</tr>';
9239
9454
  }).join('');
@@ -9342,6 +9557,30 @@ var appJs = `
9342
9557
  });
9343
9558
  });
9344
9559
 
9560
+ content.querySelectorAll('button[data-vis-toggle]').forEach(function (btn) {
9561
+ btn.addEventListener('click', function (e) {
9562
+ e.stopPropagation();
9563
+ var id = btn.getAttribute('data-vis-toggle');
9564
+ var cur = btn.getAttribute('data-vis-cur');
9565
+ var next = cur === 'everyone' ? 'private' : 'everyone';
9566
+ withBusy(btn, function () {
9567
+ return fetchJson('/api/tables/' + encodeURIComponent(tableName) + '/rows/' + encodeURIComponent(id) + '/visibility', {
9568
+ method: 'POST',
9569
+ headers: { 'content-type': 'application/json' },
9570
+ body: JSON.stringify({ visibility: next }),
9571
+ }).then(function () {
9572
+ invalidate(tableName);
9573
+ return refreshEntities();
9574
+ }).then(function () {
9575
+ renderTable(content, tableName);
9576
+ showToast(next === 'everyone' ? 'Row shared with everyone' : 'Row made private', {});
9577
+ }).catch(function (err) {
9578
+ showToast('Visibility update failed: ' + err.message, {});
9579
+ });
9580
+ });
9581
+ });
9582
+ });
9583
+
9345
9584
  content.querySelectorAll('tr[data-id]').forEach(function (tr) {
9346
9585
  tr.addEventListener('click', function (e) {
9347
9586
  // Let chip-link anchors and the delete button handle their own click.
@@ -9468,6 +9707,39 @@ var appJs = `
9468
9707
  });
9469
9708
  }
9470
9709
 
9710
+ // Detail-view row visibility line (2.2). Owner: status + everyone/private
9711
+ // toggle + a "Specific people\u2026" / "Manage access" control that opens the
9712
+ // grants checklist (the table view's "open to manage" affordance lands
9713
+ // here). Non-owner: read-only status.
9714
+ function detailVisLineEl(row) {
9715
+ var a = row._access;
9716
+ if (!a) return '';
9717
+ var vis = a.visibility;
9718
+ var labelMap = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
9719
+ if (!a.ownedByMe) {
9720
+ var seen = vis === 'custom' ? 'Shared with you' : (labelMap[vis] || '');
9721
+ return '<div class="detail-vis muted" style="margin:6px 0;font-size:13px">' + escapeHtml(seen) + '</div>';
9722
+ }
9723
+ var info = labelMap[vis] || '';
9724
+ if (vis === 'custom' && a.grantees) info += ' (' + a.grantees.length + ')';
9725
+ var buttons;
9726
+ if (vis === 'custom') {
9727
+ // Leaving custom stops the grant list from applying \u2014 the toggle
9728
+ // handler asks for confirmation. The grants themselves are kept
9729
+ // server-side, so reopening "Manage access" restores the list.
9730
+ buttons = '<button class="btn" id="detail-vis-manage">Manage access</button>' +
9731
+ '<button class="btn" id="detail-vis-toggle" data-vis-cur="custom" data-vis-next="everyone">Share with everyone</button>';
9732
+ } else {
9733
+ var btnLabel = vis === 'everyone' ? 'Make private' : 'Share with everyone';
9734
+ var next = vis === 'everyone' ? 'private' : 'everyone';
9735
+ buttons = '<button class="btn" id="detail-vis-toggle" data-vis-cur="' + vis + '" data-vis-next="' + next + '">' + btnLabel + '</button>' +
9736
+ '<button class="btn" id="detail-vis-manage">Specific people\u2026</button>';
9737
+ }
9738
+ return '<div class="detail-vis" style="display:flex;align-items:center;gap:8px;margin:6px 0;font-size:13px;flex-wrap:wrap">' +
9739
+ '<span class="muted" id="detail-vis-info">' + escapeHtml(info) + '</span>' + buttons +
9740
+ '</div>' +
9741
+ '<div class="grants-panel" id="grants-panel" hidden></div>';
9742
+ }
9471
9743
  function renderDetail(content, tableName, id) {
9472
9744
  var t = tableByName(tableName);
9473
9745
  if (!t) {
@@ -9566,6 +9838,7 @@ var appJs = `
9566
9838
  '<h1>' + escapeHtml(displayNameFor(row) || d.label) + '</h1>' +
9567
9839
  '<div class="actions">' + actions + '</div>' +
9568
9840
  '</div>' +
9841
+ detailVisLineEl(row) +
9569
9842
  lastEditedLineEl(tableName, id) +
9570
9843
  (tableName === 'files' ? '<div class="file-preview" id="file-preview"></div>' : '') +
9571
9844
  '<div class="detail"><dl class="' + (editing ? 'editing' : '') + '">' + rows.join('') + '</dl></div>' +
@@ -9578,6 +9851,93 @@ var appJs = `
9578
9851
  if (!editing) loadRowContext(tableName, id);
9579
9852
  if (!editing && tableName === 'files') renderFilePreview(row);
9580
9853
 
9854
+ function postVisibility(next) {
9855
+ return fetchJson('/api/tables/' + encodeURIComponent(tableName) + '/rows/' + encodeURIComponent(id) + '/visibility', {
9856
+ method: 'POST',
9857
+ headers: { 'content-type': 'application/json' },
9858
+ body: JSON.stringify({ visibility: next }),
9859
+ });
9860
+ }
9861
+ var detailVisBtn = content.querySelector('#detail-vis-toggle');
9862
+ if (detailVisBtn) detailVisBtn.addEventListener('click', function () {
9863
+ var cur = detailVisBtn.getAttribute('data-vis-cur');
9864
+ var next = detailVisBtn.getAttribute('data-vis-next') || (cur === 'everyone' ? 'private' : 'everyone');
9865
+ if (cur === 'custom') {
9866
+ // Non-destructive guard: the grant rows survive server-side, but
9867
+ // the custom list stops applying the moment visibility changes.
9868
+ var cnt = (row._access && row._access.grantees ? row._access.grantees.length : 0);
9869
+ var who = cnt === 1 ? '1 specific person' : cnt + ' specific people';
9870
+ 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;
9871
+ }
9872
+ withBusy(detailVisBtn, function () {
9873
+ return postVisibility(next).then(function () {
9874
+ invalidate(tableName);
9875
+ renderDetail(content, tableName, id);
9876
+ showToast(next === 'everyone' ? 'Shared with everyone' : 'Made private', {});
9877
+ }).catch(function (e) { showToast('Visibility update failed: ' + e.message, {}); });
9878
+ });
9879
+ });
9880
+
9881
+ // Grants checklist ("Specific people\u2026" / "Manage access"): member
9882
+ // checkboxes wired to the row-grant endpoints. Opening it on a
9883
+ // non-custom row first narrows visibility to custom so an empty
9884
+ // checklist is a coherent state (owner-only until people are added).
9885
+ var detailVisManage = content.querySelector('#detail-vis-manage');
9886
+ if (detailVisManage) detailVisManage.addEventListener('click', function () {
9887
+ var panel = content.querySelector('#grants-panel');
9888
+ if (!panel) return;
9889
+ if (!panel.hidden) { panel.hidden = true; return; }
9890
+ var access = row._access || {};
9891
+ var ensure = access.visibility === 'custom'
9892
+ ? Promise.resolve()
9893
+ : postVisibility('custom').then(function () { access.visibility = 'custom'; });
9894
+ withBusy(detailVisManage, function () {
9895
+ return ensure.then(function () {
9896
+ return fetchJson('/api/team/users');
9897
+ }).then(function (d) {
9898
+ var users = ((d && d.users) || []).filter(function (u) { return u.id !== access.owner_user_id; });
9899
+ var granted = {};
9900
+ (access.grantees || []).forEach(function (g) { granted[g] = true; });
9901
+ if (users.length === 0) {
9902
+ panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
9903
+ } else {
9904
+ panel.innerHTML = '<div class="grants-title">Who can see this</div>' + users.map(function (u) {
9905
+ var label = u.name || u.email || u.id;
9906
+ return '<label class="grants-row"><input type="checkbox" data-grant-user="' + escapeHtml(u.id) + '"' +
9907
+ (granted[u.id] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
9908
+ }).join('');
9909
+ }
9910
+ panel.hidden = false;
9911
+ panel.querySelectorAll('[data-grant-user]').forEach(function (cb) {
9912
+ cb.addEventListener('change', function () {
9913
+ var uid = cb.getAttribute('data-grant-user');
9914
+ var base = '/api/tables/' + encodeURIComponent(tableName) + '/rows/' + encodeURIComponent(id) + '/grants';
9915
+ var req = cb.checked
9916
+ ? fetchJson(base, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ user_id: uid }) })
9917
+ : fetchJson(base + '/' + encodeURIComponent(uid), { method: 'DELETE' });
9918
+ cb.disabled = true;
9919
+ req.then(function () {
9920
+ var list = access.grantees || (access.grantees = []);
9921
+ var at = list.indexOf(uid);
9922
+ if (cb.checked && at === -1) list.push(uid);
9923
+ if (!cb.checked && at !== -1) list.splice(at, 1);
9924
+ var infoEl = content.querySelector('#detail-vis-info');
9925
+ if (infoEl) infoEl.textContent = 'Shared with specific people (' + list.length + ')';
9926
+ invalidate(tableName);
9927
+ }).catch(function (e) {
9928
+ cb.checked = !cb.checked; // revert the failed change
9929
+ showToast('Access update failed: ' + e.message, {});
9930
+ }).then(function () { cb.disabled = false; });
9931
+ });
9932
+ });
9933
+ if (access.visibility === 'custom') {
9934
+ var infoEl = content.querySelector('#detail-vis-info');
9935
+ if (infoEl) infoEl.textContent = 'Shared with specific people (' + (access.grantees || []).length + ')';
9936
+ }
9937
+ }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
9938
+ });
9939
+ });
9940
+
9581
9941
  // Junction link/unlink handlers (active in both read and edit modes).
9582
9942
  content.querySelectorAll('.remove-link').forEach(function (btn) {
9583
9943
  btn.addEventListener('click', function (e) {
@@ -11007,6 +11367,18 @@ var appJs = `
11007
11367
  '</span>' +
11008
11368
  '</div>'
11009
11369
  : '';
11370
+ // Owner-only "new rows default to" control, shown for a shared table.
11371
+ var defaultVis = (t && t.defaultRowVisibility) || 'private';
11372
+ var defaultVisRow = canShare && isShared
11373
+ ? '<label>New rows default to</label>' +
11374
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
11375
+ '<select id="dm-rowvis-select">' +
11376
+ '<option value="private"' + (defaultVis === 'private' ? ' selected' : '') + '>Private (owner only)</option>' +
11377
+ '<option value="everyone"' + (defaultVis === 'everyone' ? ' selected' : '') + '>Everyone on the workspace</option>' +
11378
+ '</select>' +
11379
+ '<span style="font-size:12px;color:var(--text-muted)">Visibility new rows in this table are created with.</span>' +
11380
+ '</div>'
11381
+ : '';
11010
11382
  panel.innerHTML =
11011
11383
  '<h3>' + d.icon + ' ' + escapeHtml(d.label) + '</h3>' +
11012
11384
  '<div class="dm-edit-grid">' +
@@ -11021,6 +11393,7 @@ var appJs = `
11021
11393
  '<button class="btn" id="dm-icon-btn" style="margin-top:6px;">Save</button>' +
11022
11394
  '</div>' +
11023
11395
  shareRow +
11396
+ defaultVisRow +
11024
11397
  '<label>Columns</label>' +
11025
11398
  '<div>' +
11026
11399
  '<div class="dm-cols">' + (columnsHtml || '<span class="muted">No columns</span>') + '</div>' +
@@ -11072,6 +11445,22 @@ var appJs = `
11072
11445
  }).catch(function (e) { showToast('Share update failed: ' + e.message, {}); });
11073
11446
  });
11074
11447
  });
11448
+
11449
+ var rowvisSelect = panel.querySelector('#dm-rowvis-select');
11450
+ if (rowvisSelect) rowvisSelect.addEventListener('change', function () {
11451
+ var next = rowvisSelect.value;
11452
+ withBusy(rowvisSelect, function () {
11453
+ return fetchJson('/api/schema/entities/' + encodeURIComponent(tableName) + '/default-row-visibility', {
11454
+ method: 'POST',
11455
+ headers: { 'content-type': 'application/json' },
11456
+ body: JSON.stringify({ visibility: next }),
11457
+ }).then(function () {
11458
+ return dmRefreshPanel(tableName, false);
11459
+ }).then(function () {
11460
+ showToast(next === 'everyone' ? 'New rows now default to everyone' : 'New rows now default to private', {});
11461
+ }).catch(function (e) { showToast('Default visibility update failed: ' + e.message, {}); });
11462
+ });
11463
+ });
11075
11464
  }
11076
11465
 
11077
11466
  /**
@@ -11759,6 +12148,7 @@ var appJs = `
11759
12148
  // enters per-DB things (cloud URL + DB name) in this modal.
11760
12149
  fetchJson('/api/userconfig/identity').then(function (id) {
11761
12150
  var bodyHtml =
12151
+ '<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
12152
  '<div class="field"><label>Cloud URL</label>' +
11763
12153
  '<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
12154
  '</div>' +
@@ -11786,6 +12176,7 @@ var appJs = `
11786
12176
  function showJoinTeamModal(kind) {
11787
12177
  fetchJson('/api/userconfig/identity').then(function (id) {
11788
12178
  var bodyHtml =
12179
+ '<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
12180
  '<div class="field"><label>Cloud URL</label>' +
11790
12181
  '<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
12182
  '</div>' +
@@ -13122,7 +13513,7 @@ var appJs = `
13122
13513
  // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
13123
13514
  // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
13124
13515
  // it shares that debounce with the realtime 'change' handler (no double
13125
- // fetch). See Rule 28: /api/entities uses batched counts, not N queries.
13516
+ // fetch). /api/entities batches its row counts into one query, not N.
13126
13517
  if (data && (data.table || data.op === 'schema')) {
13127
13518
  scheduleRealtimeRefresh();
13128
13519
  }
@@ -13768,6 +14159,10 @@ var guiAppHtml = `<!doctype html>
13768
14159
  </svg>
13769
14160
  </button>
13770
14161
  </header>
14162
+ <div class="deprecation-banner" id="deprecation-banner" hidden>
14163
+ <span id="deprecation-banner-text"></span>
14164
+ <button id="deprecation-banner-dismiss" title="Dismiss for this session" aria-label="Dismiss">\u2715</button>
14165
+ </div>
13771
14166
  <div class="layout">
13772
14167
  <nav class="sidebar">
13773
14168
  <label class="sidebar-advanced toggle" title="Advanced mode \u2014 row/table editor instead of the file workspace">
@@ -13962,7 +14357,13 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
13962
14357
  // Client-generated idempotency key for offline replay: a queued edit
13963
14358
  // carries a stable edit_id, so re-sending it after a reconnect is a
13964
14359
  // no-op rather than a duplicate write. Nullable + additive.
13965
- edit_id: "TEXT"
14360
+ edit_id: "TEXT",
14361
+ // Per-recipient targeting for 2.2 hard row-level sync. NULL =
14362
+ // broadcast (delivered to every member, then filtered at pull time
14363
+ // against __lattice_row_acl); non-null = targeted to exactly this
14364
+ // user (the grant / revoke / delete fan-out). Nullable + additive,
14365
+ // same precedent as client_ts / edit_id.
14366
+ recipient_user_id: "TEXT"
13966
14367
  },
13967
14368
  render: () => "",
13968
14369
  outputFile: ".lattice-teams/change-log.md"
@@ -13978,6 +14379,44 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
13978
14379
  primaryKey: ["team_id", "table_name", "pk"],
13979
14380
  render: () => "",
13980
14381
  outputFile: ".lattice-teams/row-links.md"
14382
+ },
14383
+ // Per-row access control for a team cloud (2.2 row-level permissions).
14384
+ // Mirrors __lattice_object_owners at row granularity: each shared row
14385
+ // has an owner (its creator) and a visibility. Enforcement is at the
14386
+ // application layer (see src/teams/row-access.ts) — every member shares
14387
+ // the same physical DB, so a row a user can't see must be filtered out
14388
+ // before its bytes reach them. Kept out-of-band (never injected into
14389
+ // user tables) so the user's own schema stays untouched.
14390
+ __lattice_row_acl: {
14391
+ columns: {
14392
+ team_id: "TEXT NOT NULL",
14393
+ table_name: "TEXT NOT NULL",
14394
+ pk: "TEXT NOT NULL",
14395
+ owner_user_id: "TEXT NOT NULL",
14396
+ // 'private' = owner only · 'everyone' = all team members ·
14397
+ // 'custom' = the explicit grant list in __lattice_row_grants.
14398
+ visibility: "TEXT NOT NULL CHECK (visibility IN ('private', 'everyone', 'custom'))",
14399
+ created_at: "TEXT NOT NULL",
14400
+ updated_at: "TEXT NOT NULL"
14401
+ },
14402
+ primaryKey: ["team_id", "table_name", "pk"],
14403
+ render: () => "",
14404
+ outputFile: ".lattice-teams/row-acl.md"
14405
+ },
14406
+ // Explicit per-row grant list, consulted only when the owning row's
14407
+ // __lattice_row_acl.visibility = 'custom'. One row per (row, grantee).
14408
+ __lattice_row_grants: {
14409
+ columns: {
14410
+ team_id: "TEXT NOT NULL",
14411
+ table_name: "TEXT NOT NULL",
14412
+ pk: "TEXT NOT NULL",
14413
+ grantee_user_id: "TEXT NOT NULL",
14414
+ granted_by_user_id: "TEXT NOT NULL",
14415
+ granted_at: "TEXT NOT NULL"
14416
+ },
14417
+ primaryKey: ["team_id", "table_name", "pk", "grantee_user_id"],
14418
+ render: () => "",
14419
+ outputFile: ".lattice-teams/row-grants.md"
13981
14420
  }
13982
14421
  };
13983
14422
  var LOCAL_INTERNAL_TABLE_DEFS = {
@@ -14078,6 +14517,341 @@ async function installCloudInternalTriggers(db) {
14078
14517
  };
14079
14518
  await db.migrate([migration]);
14080
14519
  }
14520
+ async function installRowPermsSchema(db) {
14521
+ const migrations = [
14522
+ {
14523
+ // 01 — per-table default visibility for newly-created rows. Born
14524
+ // 'private' unless the table owner opts the table into 'everyone';
14525
+ // the backfill (06) flips already-shared tables to preserve pre-2.2
14526
+ // visibility. No IF NOT EXISTS: the version guard runs this exactly
14527
+ // once, which is what SQLite's ADD COLUMN needs (it has no
14528
+ // IF NOT EXISTS form).
14529
+ version: "internal:row-perms:01-default-row-visibility:v1",
14530
+ sql: `ALTER TABLE "__lattice_shared_objects" ADD COLUMN "default_row_visibility" TEXT NOT NULL DEFAULT 'private' CHECK ("default_row_visibility" IN ('private', 'everyone'))`
14531
+ },
14532
+ {
14533
+ // 02-05 — indexes. One CREATE INDEX per migration (single-statement
14534
+ // for the SQLite path). IF NOT EXISTS is valid in both dialects.
14535
+ version: "internal:row-perms:02-idx-change-log-team-seq:v1",
14536
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_seq" ON "__lattice_change_log" ("team_id", "seq")`
14537
+ },
14538
+ {
14539
+ version: "internal:row-perms:03-idx-change-log-team-recipient-seq:v1",
14540
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_recipient_seq" ON "__lattice_change_log" ("team_id", "recipient_user_id", "seq")`
14541
+ },
14542
+ {
14543
+ version: "internal:row-perms:04-idx-change-log-team-table-pk:v1",
14544
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_table_pk" ON "__lattice_change_log" ("team_id", "table_name", "pk")`
14545
+ },
14546
+ {
14547
+ version: "internal:row-perms:05-idx-row-grants-grantee:v1",
14548
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee" ON "__lattice_row_grants" ("team_id", "grantee_user_id", "table_name", "pk")`
14549
+ },
14550
+ {
14551
+ // 06 — upgrade backfill. Every already-shared table defaults to
14552
+ // 'everyone' so pre-2.2 rows stay visible to all members (nothing
14553
+ // disappears on upgrade). Pre-2.2 rows have no __lattice_row_acl
14554
+ // entry; resolveRowAcl() folds in this table default, so they read
14555
+ // as 'everyone' until an owner narrows an individual row. Runs once.
14556
+ version: "internal:row-perms:06-backfill-shared-defaults:v1",
14557
+ sql: `UPDATE "__lattice_shared_objects" SET "default_row_visibility" = 'everyone' WHERE "deleted_at" IS NULL`
14558
+ }
14559
+ ];
14560
+ await db.migrate(migrations);
14561
+ }
14562
+
14563
+ // src/teams/row-access.ts
14564
+ var RowAccessError = class extends Error {
14565
+ code = "row_access_denied";
14566
+ constructor(message = "Row not accessible") {
14567
+ super(message);
14568
+ this.name = "RowAccessError";
14569
+ }
14570
+ };
14571
+ var RowOwnerOnlyError = class extends Error {
14572
+ code = "row_owner_only";
14573
+ constructor(message = "Only the row owner may change its sharing") {
14574
+ super(message);
14575
+ this.name = "RowOwnerOnlyError";
14576
+ }
14577
+ };
14578
+ function isRowVisibility(v) {
14579
+ return v === "private" || v === "everyone" || v === "custom";
14580
+ }
14581
+ async function tableDefaultVisibility(db, teamId, table) {
14582
+ const rows = await db.query("__lattice_shared_objects", {
14583
+ filters: [
14584
+ { col: "team_id", op: "eq", val: teamId },
14585
+ { col: "table_name", op: "eq", val: table },
14586
+ { col: "deleted_at", op: "isNull" }
14587
+ ],
14588
+ limit: 1
14589
+ });
14590
+ return rows[0]?.default_row_visibility === "everyone" ? "everyone" : "private";
14591
+ }
14592
+ async function tableOwner(db, teamId, table) {
14593
+ const owners = await db.query("__lattice_object_owners", {
14594
+ filters: [
14595
+ { col: "team_id", op: "eq", val: teamId },
14596
+ { col: "table_name", op: "eq", val: table }
14597
+ ],
14598
+ limit: 1
14599
+ });
14600
+ const ownerId = owners[0]?.owner_user_id;
14601
+ if (typeof ownerId === "string" && ownerId) return ownerId;
14602
+ const shared = await db.query("__lattice_shared_objects", {
14603
+ filters: [
14604
+ { col: "team_id", op: "eq", val: teamId },
14605
+ { col: "table_name", op: "eq", val: table },
14606
+ { col: "deleted_at", op: "isNull" }
14607
+ ],
14608
+ limit: 1
14609
+ });
14610
+ const createdBy = shared[0]?.created_by_user_id;
14611
+ return typeof createdBy === "string" ? createdBy : "";
14612
+ }
14613
+ async function rawAclRow(db, teamId, table, pk) {
14614
+ const rows = await db.query("__lattice_row_acl", {
14615
+ filters: [
14616
+ { col: "team_id", op: "eq", val: teamId },
14617
+ { col: "table_name", op: "eq", val: table },
14618
+ { col: "pk", op: "eq", val: pk }
14619
+ ],
14620
+ limit: 1
14621
+ });
14622
+ return rows[0];
14623
+ }
14624
+ async function resolveRowAcl(db, teamId, table, pk) {
14625
+ const acl = await rawAclRow(db, teamId, table, pk);
14626
+ if (acl && isRowVisibility(acl.visibility)) {
14627
+ return { ownerUserId: String(acl.owner_user_id), visibility: acl.visibility };
14628
+ }
14629
+ const [visibility, owner] = await Promise.all([
14630
+ tableDefaultVisibility(db, teamId, table),
14631
+ tableOwner(db, teamId, table)
14632
+ ]);
14633
+ return { ownerUserId: owner, visibility };
14634
+ }
14635
+ async function hasRowGrant(db, teamId, table, pk, userId) {
14636
+ if (!userId) return false;
14637
+ const grants = await db.query("__lattice_row_grants", {
14638
+ filters: [
14639
+ { col: "team_id", op: "eq", val: teamId },
14640
+ { col: "table_name", op: "eq", val: table },
14641
+ { col: "pk", op: "eq", val: pk },
14642
+ { col: "grantee_user_id", op: "eq", val: userId }
14643
+ ],
14644
+ limit: 1
14645
+ });
14646
+ return grants.length > 0;
14647
+ }
14648
+ async function canAccessRow(db, teamId, table, pk, userId) {
14649
+ const acl = await resolveRowAcl(db, teamId, table, pk);
14650
+ if (userId && acl.ownerUserId === userId) return true;
14651
+ if (acl.visibility === "everyone") return true;
14652
+ if (acl.visibility === "custom") return hasRowGrant(db, teamId, table, pk, userId);
14653
+ return false;
14654
+ }
14655
+ async function listVisibleRows(db, teamId, table, userId, opts = {}) {
14656
+ const [owner, def] = await Promise.all([
14657
+ tableOwner(db, teamId, table),
14658
+ tableDefaultVisibility(db, teamId, table)
14659
+ ]);
14660
+ const noAclVisible = def === "everyone" || userId !== "" && owner === userId;
14661
+ return db.queryVisible(table, { teamId, userId, noAclVisible, ...opts });
14662
+ }
14663
+ async function recordRowAcl(db, teamId, table, pk, ownerUserId, visibility) {
14664
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14665
+ const existing = await rawAclRow(db, teamId, table, pk);
14666
+ await db.upsert("__lattice_row_acl", {
14667
+ team_id: teamId,
14668
+ table_name: table,
14669
+ pk,
14670
+ owner_user_id: ownerUserId,
14671
+ visibility,
14672
+ created_at: existing?.created_at ?? now,
14673
+ updated_at: now
14674
+ });
14675
+ }
14676
+ async function requireOwner(db, teamId, table, pk, actorUserId) {
14677
+ const existing = await rawAclRow(db, teamId, table, pk);
14678
+ const owner = existing ? String(existing.owner_user_id) : await tableOwner(db, teamId, table);
14679
+ if (!actorUserId || owner !== actorUserId) throw new RowOwnerOnlyError();
14680
+ return { existing, owner };
14681
+ }
14682
+ async function setRowVisibility(db, teamId, table, pk, actorUserId, visibility) {
14683
+ const { existing, owner } = await requireOwner(db, teamId, table, pk, actorUserId);
14684
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14685
+ await db.upsert("__lattice_row_acl", {
14686
+ team_id: teamId,
14687
+ table_name: table,
14688
+ pk,
14689
+ owner_user_id: owner,
14690
+ visibility,
14691
+ created_at: existing?.created_at ?? now,
14692
+ updated_at: now
14693
+ });
14694
+ }
14695
+ async function addRowGrant(db, teamId, table, pk, granteeUserId, grantedByUserId) {
14696
+ const { existing, owner } = await requireOwner(db, teamId, table, pk, grantedByUserId);
14697
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14698
+ const currentVis = existing && isRowVisibility(existing.visibility) ? existing.visibility : await tableDefaultVisibility(db, teamId, table);
14699
+ const nextVis = currentVis === "everyone" ? "everyone" : "custom";
14700
+ await db.upsert("__lattice_row_acl", {
14701
+ team_id: teamId,
14702
+ table_name: table,
14703
+ pk,
14704
+ owner_user_id: owner,
14705
+ visibility: nextVis,
14706
+ created_at: existing?.created_at ?? now,
14707
+ updated_at: now
14708
+ });
14709
+ await db.upsert("__lattice_row_grants", {
14710
+ team_id: teamId,
14711
+ table_name: table,
14712
+ pk,
14713
+ grantee_user_id: granteeUserId,
14714
+ granted_by_user_id: grantedByUserId,
14715
+ granted_at: now
14716
+ });
14717
+ }
14718
+ async function removeRowGrant(db, teamId, table, pk, granteeUserId, actorUserId) {
14719
+ await requireOwner(db, teamId, table, pk, actorUserId);
14720
+ await db.delete("__lattice_row_grants", {
14721
+ team_id: teamId,
14722
+ table_name: table,
14723
+ pk,
14724
+ grantee_user_id: granteeUserId
14725
+ });
14726
+ }
14727
+ async function rowAccessSummaries(db, teamId, table, userId, pks) {
14728
+ const out = /* @__PURE__ */ new Map();
14729
+ if (pks.length === 0) return out;
14730
+ const acls = await db.query("__lattice_row_acl", {
14731
+ filters: [
14732
+ { col: "team_id", op: "eq", val: teamId },
14733
+ { col: "table_name", op: "eq", val: table }
14734
+ ]
14735
+ });
14736
+ const aclByPk = new Map(acls.map((a) => [String(a.pk), a]));
14737
+ const [owner, def] = await Promise.all([
14738
+ tableOwner(db, teamId, table),
14739
+ tableDefaultVisibility(db, teamId, table)
14740
+ ]);
14741
+ for (const pk of pks) {
14742
+ const a = aclByPk.get(pk);
14743
+ const ownerUserId = a ? String(a.owner_user_id) : owner;
14744
+ const visibility = a && isRowVisibility(a.visibility) ? a.visibility : def;
14745
+ out.set(pk, { owner_user_id: ownerUserId, visibility, ownedByMe: ownerUserId === userId });
14746
+ }
14747
+ return out;
14748
+ }
14749
+ async function filterVisiblePks(db, teamId, table, userId, pks) {
14750
+ const out = /* @__PURE__ */ new Set();
14751
+ if (pks.length === 0) return out;
14752
+ const acls = await db.query("__lattice_row_acl", {
14753
+ filters: [
14754
+ { col: "team_id", op: "eq", val: teamId },
14755
+ { col: "table_name", op: "eq", val: table },
14756
+ { col: "pk", op: "in", val: pks }
14757
+ ]
14758
+ });
14759
+ const aclByPk = new Map(acls.map((a) => [String(a.pk), a]));
14760
+ const [owner, def] = await Promise.all([
14761
+ tableOwner(db, teamId, table),
14762
+ tableDefaultVisibility(db, teamId, table)
14763
+ ]);
14764
+ const customPks = [];
14765
+ for (const pk of pks) {
14766
+ const a = aclByPk.get(pk);
14767
+ const ownerUserId = a ? String(a.owner_user_id) : owner;
14768
+ const visibility = a && isRowVisibility(a.visibility) ? a.visibility : def;
14769
+ if (userId && ownerUserId === userId) {
14770
+ out.add(pk);
14771
+ } else if (visibility === "everyone") {
14772
+ out.add(pk);
14773
+ } else if (visibility === "custom" && userId) {
14774
+ customPks.push(pk);
14775
+ }
14776
+ }
14777
+ if (customPks.length > 0) {
14778
+ const grants = await db.query("__lattice_row_grants", {
14779
+ filters: [
14780
+ { col: "team_id", op: "eq", val: teamId },
14781
+ { col: "table_name", op: "eq", val: table },
14782
+ { col: "grantee_user_id", op: "eq", val: userId },
14783
+ { col: "pk", op: "in", val: customPks }
14784
+ ]
14785
+ });
14786
+ for (const g of grants) out.add(String(g.pk));
14787
+ }
14788
+ return out;
14789
+ }
14790
+ async function usersWithRowAccess(db, teamId, table, pk, candidateUserIds) {
14791
+ const out = [];
14792
+ for (const uid of candidateUserIds) {
14793
+ if (await canAccessRow(db, teamId, table, pk, uid)) out.push(uid);
14794
+ }
14795
+ return out;
14796
+ }
14797
+ async function deleteRowAcl(db, teamId, table, pk) {
14798
+ await db.delete("__lattice_row_acl", { team_id: teamId, table_name: table, pk });
14799
+ const grants = await db.query("__lattice_row_grants", {
14800
+ filters: [
14801
+ { col: "team_id", op: "eq", val: teamId },
14802
+ { col: "table_name", op: "eq", val: table },
14803
+ { col: "pk", op: "eq", val: pk }
14804
+ ]
14805
+ });
14806
+ for (const g of grants) {
14807
+ await db.delete("__lattice_row_grants", {
14808
+ team_id: teamId,
14809
+ table_name: table,
14810
+ pk,
14811
+ grantee_user_id: g.grantee_user_id
14812
+ });
14813
+ }
14814
+ }
14815
+ async function rowGrantees(db, teamId, table, pk) {
14816
+ const grants = await db.query("__lattice_row_grants", {
14817
+ filters: [
14818
+ { col: "team_id", op: "eq", val: teamId },
14819
+ { col: "table_name", op: "eq", val: table },
14820
+ { col: "pk", op: "eq", val: pk }
14821
+ ]
14822
+ });
14823
+ return grants.map((g) => String(g.grantee_user_id));
14824
+ }
14825
+ async function visibleRowEdits(db, teamId, table, userId, scanLimit = 2e3) {
14826
+ const scan = await db.query("__lattice_change_log", {
14827
+ filters: [
14828
+ { col: "team_id", op: "eq", val: teamId },
14829
+ { col: "table_name", op: "eq", val: table }
14830
+ ],
14831
+ orderBy: "seq",
14832
+ orderDir: "desc",
14833
+ limit: scanLimit
14834
+ });
14835
+ const visible = await listVisibleRows(db, teamId, table, userId, { limit: 5e3, deleted: "any" });
14836
+ const visiblePks = new Set(visible.map((r) => String(r.id)));
14837
+ const edits = {};
14838
+ for (const r of scan) {
14839
+ if (!r.pk || edits[r.pk] || !visiblePks.has(r.pk)) continue;
14840
+ edits[r.pk] = { ownerUserId: r.owner_user_id, at: r.client_ts ?? r.created_at };
14841
+ }
14842
+ return edits;
14843
+ }
14844
+ async function setTableDefaultVisibility(db, teamId, table, actorUserId, visibility) {
14845
+ const owner = await tableOwner(db, teamId, table);
14846
+ if (!actorUserId || owner !== actorUserId) {
14847
+ throw new RowOwnerOnlyError("Only the table owner may change the default row visibility");
14848
+ }
14849
+ await db.update(
14850
+ "__lattice_shared_objects",
14851
+ { team_id: teamId, table_name: table },
14852
+ { default_row_visibility: visibility, updated_at: (/* @__PURE__ */ new Date()).toISOString() }
14853
+ );
14854
+ }
14081
14855
 
14082
14856
  // src/teams/server/auth.ts
14083
14857
  import { createHash as createHash2, randomBytes as randomBytes4, timingSafeEqual } from "crypto";
@@ -14095,123 +14869,48 @@ function extractBearer(req) {
14095
14869
  return token;
14096
14870
  }
14097
14871
  function hashToken(rawToken) {
14098
- return createHash2("sha256").update(rawToken).digest("hex");
14099
- }
14100
- function generateToken() {
14101
- const raw = `${TOKEN_PREFIX}${randomBytes4(TOKEN_BYTES).toString("hex")}`;
14102
- return { raw, hash: hashToken(raw) };
14103
- }
14104
- function generateInviteToken() {
14105
- const raw = `${INVITE_PREFIX}${randomBytes4(INVITE_BYTES).toString("hex")}`;
14106
- return { raw, hash: hashToken(raw) };
14107
- }
14108
- async function authenticate(req, db) {
14109
- const raw = extractBearer(req);
14110
- if (!raw) return null;
14111
- const incomingHash = hashToken(raw);
14112
- const rows = await db.query("__lattice_api_tokens", {
14113
- filters: [
14114
- { 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
- }
14872
+ return createHash2("sha256").update(rawToken).digest("hex");
14873
+ }
14874
+ function generateToken() {
14875
+ const raw = `${TOKEN_PREFIX}${randomBytes4(TOKEN_BYTES).toString("hex")}`;
14876
+ return { raw, hash: hashToken(raw) };
14877
+ }
14878
+ function generateInviteToken() {
14879
+ const raw = `${INVITE_PREFIX}${randomBytes4(INVITE_BYTES).toString("hex")}`;
14880
+ return { raw, hash: hashToken(raw) };
14881
+ }
14882
+ async function authenticate(req, db) {
14883
+ const raw = extractBearer(req);
14884
+ if (!raw) return null;
14885
+ const incomingHash = hashToken(raw);
14886
+ const rows = await db.query("__lattice_api_tokens", {
14887
+ filters: [
14888
+ { col: "token_hash", op: "eq", val: incomingHash },
14889
+ { col: "revoked_at", op: "isNull" }
14890
+ ],
14891
+ limit: 1
14892
+ });
14893
+ const tokenRow = rows[0];
14894
+ if (!tokenRow) return null;
14895
+ const storedBuf = Buffer.from(tokenRow.token_hash, "hex");
14896
+ const incomingBuf = Buffer.from(incomingHash, "hex");
14897
+ if (storedBuf.length !== incomingBuf.length || !timingSafeEqual(storedBuf, incomingBuf)) {
14898
+ return null;
14214
14899
  }
14900
+ const userRow = await db.get("__lattice_users", tokenRow.user_id);
14901
+ if (!userRow || userRow.deleted_at) return null;
14902
+ db.update("__lattice_api_tokens", tokenRow.id, {
14903
+ last_used_at: (/* @__PURE__ */ new Date()).toISOString()
14904
+ }).catch(() => void 0);
14905
+ return {
14906
+ user: { id: userRow.id, email: userRow.email, name: userRow.name },
14907
+ tokenId: tokenRow.id
14908
+ };
14909
+ }
14910
+
14911
+ // src/teams/register-direct.ts
14912
+ function isPostgresUrl(url) {
14913
+ return /^postgres(ql)?:\/\//i.test(url);
14215
14914
  }
14216
14915
 
14217
14916
  // src/teams/schema-spec.ts
@@ -14483,7 +15182,8 @@ async function appendChangeEnvelope(db, entry) {
14483
15182
  owner_user_id: entry.owner_user_id ?? null,
14484
15183
  created_at: now,
14485
15184
  client_ts: entry.client_ts ?? now,
14486
- edit_id: entry.edit_id ?? null
15185
+ edit_id: entry.edit_id ?? null,
15186
+ recipient_user_id: entry.recipient_user_id ?? null
14487
15187
  });
14488
15188
  return seq;
14489
15189
  }
@@ -14533,7 +15233,14 @@ async function shareObject(db, teamId, createdByUserId, table, spec) {
14533
15233
  created_by_user_id: createdByUserId,
14534
15234
  created_at: prior?.created_at ?? now,
14535
15235
  updated_at: now,
14536
- deleted_at: null
15236
+ deleted_at: null,
15237
+ // 2.2: a freshly-shared table defaults to 'everyone' so the existing
15238
+ // "share a table → every member sees its rows" contract is preserved.
15239
+ // The owner can narrow the table default (or individual rows) afterward.
15240
+ // Re-share (the branch above) intentionally omits this so an owner's
15241
+ // earlier choice survives a schema bump (the upsert only sets the
15242
+ // columns it lists).
15243
+ default_row_visibility: "everyone"
14537
15244
  });
14538
15245
  }
14539
15246
  await applySchemaSpec(db, table, outSpec);
@@ -14627,88 +15334,26 @@ async function destroyTeamDirect(db) {
14627
15334
  }
14628
15335
  await db.delete("__lattice_team_identity", "singleton");
14629
15336
  }
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
- }
15337
+ var directDeprecationWarned = false;
14700
15338
  async function openCloud(cloudUrl) {
14701
15339
  if (!isPostgresUrl(cloudUrl)) {
14702
15340
  throw new Error(
14703
15341
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
14704
15342
  );
14705
15343
  }
15344
+ if (!directDeprecationWarned) {
15345
+ directDeprecationWarned = true;
15346
+ console.warn(
15347
+ "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
15348
+ );
15349
+ }
14706
15350
  const db = new Lattice(cloudUrl);
14707
15351
  await db.init();
14708
15352
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
14709
15353
  await db.defineLate(table, def);
14710
15354
  }
14711
15355
  await installCloudInternalTriggers(db);
15356
+ await installRowPermsSchema(db);
14712
15357
  return db;
14713
15358
  }
14714
15359
  function closeQuiet(db) {
@@ -15189,6 +15834,23 @@ var FeedBus = class {
15189
15834
  }
15190
15835
  };
15191
15836
 
15837
+ // src/gui/search-acl.ts
15838
+ async function filterSearchGroupsByAcl(db, teamId, userId, result) {
15839
+ const groups = [];
15840
+ for (const group of result.groups) {
15841
+ const visible = await filterVisiblePks(
15842
+ db,
15843
+ teamId,
15844
+ group.table,
15845
+ userId,
15846
+ group.hits.map((h) => h.id)
15847
+ );
15848
+ const hits = group.hits.filter((h) => visible.has(h.id));
15849
+ if (hits.length > 0) groups.push({ ...group, hits, count: hits.length });
15850
+ }
15851
+ return { ...result, groups };
15852
+ }
15853
+
15192
15854
  // src/gui/mutations.ts
15193
15855
  function rowLabel2(row) {
15194
15856
  if (!row || typeof row !== "object") return null;
@@ -15318,6 +15980,10 @@ async function emitTeamEnvelope(ctx, table, pk, op, after) {
15318
15980
  async function createRow(ctx, table, values) {
15319
15981
  const id = await ctx.db.insert(table, values);
15320
15982
  const row = await ctx.db.get(table, id);
15983
+ if (ctx.team) {
15984
+ const vis = await tableDefaultVisibility(ctx.db, ctx.team.teamId, table);
15985
+ await recordRowAcl(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId, vis);
15986
+ }
15321
15987
  await appendAudit(ctx.db, ctx.feed, table, id, "insert", null, row, ctx.source, ctx.sessionId);
15322
15988
  await emitTeamEnvelope(ctx, table, id, "upsert", row);
15323
15989
  return { id, row };
@@ -15341,6 +16007,9 @@ async function updateRow(ctx, table, id, values) {
15341
16007
  if (before === null) {
15342
16008
  throw new Error(`Cannot update "${table}": no row with id "${id}"`);
15343
16009
  }
16010
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
16011
+ throw new RowAccessError();
16012
+ }
15344
16013
  await ctx.db.update(table, id, values);
15345
16014
  const after = await ctx.db.get(table, id);
15346
16015
  if (after != null) {
@@ -15370,6 +16039,9 @@ async function deleteRow(ctx, table, id, hard) {
15370
16039
  if (before === null) {
15371
16040
  throw new Error(`Cannot delete from "${table}": no row with id "${id}"`);
15372
16041
  }
16042
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
16043
+ throw new RowAccessError();
16044
+ }
15373
16045
  if (!hard && ctx.softDeletable.has(table)) {
15374
16046
  await ctx.db.update(table, id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
15375
16047
  const after = await ctx.db.get(table, id);
@@ -16426,25 +17098,30 @@ async function autoUnlinkUserRows(db, teamId, userId) {
16426
17098
  ]
16427
17099
  });
16428
17100
  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
- }
17101
+ await tearDownSharedRow(db, link.team_id, link.table_name, link.pk, link.owner_user_id);
17102
+ }
17103
+ return links.length;
17104
+ }
17105
+ async function tearDownSharedRow(db, teamId, tableName, pk, ownerUserId) {
17106
+ const members = (await listTeamMembers(db, teamId)).map((m) => m.user_id);
17107
+ const permitted = await usersWithRowAccess(db, teamId, tableName, pk, members);
17108
+ await db.delete("__lattice_row_links", { team_id: teamId, table_name: tableName, pk });
17109
+ try {
17110
+ await db.delete(tableName, pk);
17111
+ } catch {
17112
+ }
17113
+ for (const uid of permitted) {
16438
17114
  await appendChangeEnvelope(db, {
16439
- team_id: link.team_id,
16440
- table_name: link.table_name,
16441
- pk: link.pk,
17115
+ team_id: teamId,
17116
+ table_name: tableName,
17117
+ pk,
16442
17118
  op: "unlink",
16443
17119
  payload_json: null,
16444
- owner_user_id: link.owner_user_id
17120
+ owner_user_id: ownerUserId,
17121
+ recipient_user_id: uid
16445
17122
  });
16446
17123
  }
16447
- return links.length;
17124
+ await deleteRowAcl(db, teamId, tableName, pk);
16448
17125
  }
16449
17126
  async function isObjectShared(db, teamId, tableName) {
16450
17127
  const rows = await db.query("__lattice_shared_objects", {
@@ -16560,15 +17237,12 @@ async function handleListChanges(res, ctx, teamId, params) {
16560
17237
  const since = sinceRaw !== null && /^\d+$/.test(sinceRaw) ? Number(sinceRaw) : 0;
16561
17238
  const limitParsed = limitRaw !== null && /^\d+$/.test(limitRaw) ? Number(limitRaw) : 500;
16562
17239
  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",
17240
+ const rows = await ctx.db.listChangesForRecipient(
17241
+ teamId,
17242
+ since,
17243
+ ctx.authContext.user.id,
16570
17244
  limit
16571
- });
17245
+ );
16572
17246
  const envelopes = rows.map((r) => ({
16573
17247
  seq: r.seq,
16574
17248
  table_name: r.table_name,
@@ -16630,6 +17304,10 @@ async function handleLinkRow(req, res, ctx, teamId, tableName) {
16630
17304
  });
16631
17305
  }
16632
17306
  await ctx.db.upsert(tableName, snapshot);
17307
+ if (!existing) {
17308
+ const vis = await tableDefaultVisibility(ctx.db, teamId, tableName);
17309
+ await recordRowAcl(ctx.db, teamId, tableName, pk, ctx.authContext.user.id, vis);
17310
+ }
16633
17311
  await appendChangeEnvelope(ctx.db, {
16634
17312
  team_id: teamId,
16635
17313
  table_name: tableName,
@@ -16675,23 +17353,7 @@ async function handleUnlinkRow(res, ctx, teamId, tableName, pk) {
16675
17353
  sendJson2(res, { error: "Only the row owner or team creator can unlink" }, 403);
16676
17354
  return;
16677
17355
  }
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
- });
17356
+ await tearDownSharedRow(ctx.db, teamId, tableName, pk, link.owner_user_id);
16695
17357
  sendJson2(res, { ok: true });
16696
17358
  }
16697
17359
  async function handlePushRow(req, res, ctx, teamId, tableName) {
@@ -16762,23 +17424,7 @@ async function handleDeleteRow(res, ctx, teamId, tableName, pk) {
16762
17424
  sendJson2(res, { error: "Only the row owner can delete the row" }, 403);
16763
17425
  return;
16764
17426
  }
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
- });
17427
+ await tearDownSharedRow(ctx.db, teamId, tableName, pk, link.owner_user_id);
16782
17428
  sendJson2(res, { ok: true });
16783
17429
  }
16784
17430
 
@@ -16828,6 +17474,7 @@ async function probeCloud(targetUrl) {
16828
17474
  }
16829
17475
 
16830
17476
  // src/teams/client.ts
17477
+ 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
17478
  var TeamsClient = class {
16832
17479
  constructor(local) {
16833
17480
  this.local = local;
@@ -16860,6 +17507,9 @@ var TeamsClient = class {
16860
17507
  * members join via `redeemInvite`). Returns the new user + bearer
16861
17508
  * token + team summary so the caller can immediately save a
16862
17509
  * connection.
17510
+ *
17511
+ * @param teamName The workspace display name (stored as `team_name` for
17512
+ * backward compatibility — a cloud IS a workspace with members).
16863
17513
  */
16864
17514
  async register(cloudUrl, email, name, teamName) {
16865
17515
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/register", {
@@ -16870,7 +17520,7 @@ var TeamsClient = class {
16870
17520
  }
16871
17521
  async redeemInvite(cloudUrl, inviteToken, email, name) {
16872
17522
  if (isPostgresUrl(cloudUrl)) {
16873
- return redeemInviteDirect(cloudUrl, inviteToken, email, name);
17523
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
16874
17524
  }
16875
17525
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
16876
17526
  invite_token: inviteToken,
@@ -16881,9 +17531,11 @@ var TeamsClient = class {
16881
17531
  // ── High-level orchestration (v1.13+) ───────────────────────────────────
16882
17532
  // Wraps the multi-step flows the GUI's Database panel + library
16883
17533
  // 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.
17534
+ // optional team join), and initializing a fresh cloud DB's owner so
17535
+ // its members + per-table sharing surface exists. A cloud workspace IS
17536
+ // a workspace with members — there is no separate "team" to convert to.
17537
+ // The HTTP routes in src/gui/dbconfig-routes.ts are thin shells over
17538
+ // these methods.
16887
17539
  /**
16888
17540
  * Connect a local project to an existing cloud DB by URL. Probes
16889
17541
  * the target for team status first; if it's a teams DB, the caller
@@ -16932,15 +17584,18 @@ var TeamsClient = class {
16932
17584
  return { probe };
16933
17585
  }
16934
17586
  /**
16935
- * Upgrade an already-connected cloud DB to a team DB. Two paths
16936
- * depending on the cloud URL's scheme:
17587
+ * Initialize a fresh cloud DB's owner: register the first member (who
17588
+ * becomes owner) so the cloud's members + per-table sharing surface
17589
+ * exists. This is NOT a "convert a cloud into a team" step — a cloud
17590
+ * workspace IS a workspace with members; this just bootstraps the owner
17591
+ * the first time a cloud is opened. The hosted server path is the only
17592
+ * supported one:
16937
17593
  *
16938
17594
  * - `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.
17595
+ * (a hosted `lattice serve` teams server is fronting the Postgres).
17596
+ * - `postgres(ql)://…` — rejected: direct postgres:// owner bootstrap
17597
+ * is deprecated. Row-level security is enforced by the hosted server,
17598
+ * so it is the only supported connection method for new workspaces.
16944
17599
  *
16945
17600
  * On success writes the bearer token to `~/.lattice/keys/<label>.token`
16946
17601
  * **and** persists the local `__lattice_team_connections` row so the
@@ -16950,8 +17605,11 @@ var TeamsClient = class {
16950
17605
  * the token file, leaving GUI authenticated calls with no
16951
17606
  * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
16952
17607
  */
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);
17608
+ async registerCloudOwner(opts) {
17609
+ if (isPostgresUrl(opts.cloudUrl)) {
17610
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
17611
+ }
17612
+ const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
16955
17613
  writeToken(opts.label, reg.raw_token);
16956
17614
  await this.saveConnection({
16957
17615
  team_id: reg.team.id,
@@ -16984,7 +17642,7 @@ var TeamsClient = class {
16984
17642
  }
16985
17643
  try {
16986
17644
  const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
16987
- await this.upgradeToTeamCloud({
17645
+ await this.registerCloudOwner({
16988
17646
  label: opts.label,
16989
17647
  cloudUrl: opts.cloudUrl,
16990
17648
  teamName: opts.workspaceName,
@@ -18243,7 +18901,11 @@ async function handleRegisterAndCreate(req, res, ctx) {
18243
18901
  sendJson(res, { error: "cloud_url, email, user_name, team_name required" }, 400);
18244
18902
  return;
18245
18903
  }
18246
- const reg = isPostgresUrl(cloudUrl) ? await registerDirectViaPostgres(cloudUrl, email, userName, teamName) : await ctx.client.register(cloudUrl, email, userName, teamName);
18904
+ if (isPostgresUrl(cloudUrl)) {
18905
+ sendJson(res, { error: DIRECT_CLOUD_DEPRECATION_MESSAGE }, 400);
18906
+ return;
18907
+ }
18908
+ const reg = await ctx.client.register(cloudUrl, email, userName, teamName);
18247
18909
  await ctx.client.saveConnection({
18248
18910
  team_id: reg.team.id,
18249
18911
  team_name: reg.team.name,
@@ -18669,7 +19331,10 @@ async function dispatchDbConfigRoute(req, res, ctx) {
18669
19331
  // without a local `__lattice_team_connections` row (which doesn't
18670
19332
  // exist when the team cloud itself is the active database).
18671
19333
  teamId: ctx.teamMembership?.teamId ?? null,
18672
- myUserId: ctx.teamMembership?.myUserId ?? null
19334
+ myUserId: ctx.teamMembership?.myUserId ?? null,
19335
+ // Deprecated direct postgres:// team connection present → the SPA
19336
+ // shows the migrate-to-hosted deprecation banner.
19337
+ directCloud: ctx.directCloud
18673
19338
  });
18674
19339
  });
18675
19340
  return true;
@@ -19872,7 +20537,10 @@ async function executeFunction(ctx, name, args) {
19872
20537
  db: ctx.db,
19873
20538
  feed: ctx.feed,
19874
20539
  softDeletable: ctx.softDeletable,
19875
- source: "ai"
20540
+ source: "ai",
20541
+ // Thread the team through so create/update/delete enforce the row ACL for
20542
+ // the assistant exactly as they do for the HTTP API.
20543
+ team: ctx.team ?? null
19876
20544
  };
19877
20545
  try {
19878
20546
  switch (name) {
@@ -19886,14 +20554,24 @@ async function executeFunction(ctx, name, args) {
19886
20554
  }
19887
20555
  case "list_rows": {
19888
20556
  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
- }
20557
+ const includeDeleted = args.includeDeleted === true;
19893
20558
  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);
20559
+ const orderBy = cols && "created_at" in cols ? "created_at" : ctx.db.getPrimaryKey(table)[0] ?? "id";
20560
+ let rows;
20561
+ if (ctx.team) {
20562
+ rows = await listVisibleRows(ctx.db, ctx.team.teamId, table, ctx.team.myUserId, {
20563
+ limit: 200,
20564
+ orderBy,
20565
+ orderDir: "asc",
20566
+ deleted: ctx.softDeletable.has(table) && includeDeleted ? "any" : "exclude"
20567
+ });
20568
+ } else {
20569
+ const opts = { limit: 200, orderBy, orderDir: "asc" };
20570
+ if (ctx.softDeletable.has(table) && !includeDeleted) {
20571
+ opts.filters = [{ col: "deleted_at", op: "isNull" }];
20572
+ }
20573
+ rows = await ctx.db.query(table, opts);
20574
+ }
19897
20575
  const secretCols = await secretColumnsFor(ctx.db, table);
19898
20576
  return { ok: true, result: rows.map((r) => redactRow(r, secretCols)) };
19899
20577
  }
@@ -19902,6 +20580,9 @@ async function executeFunction(ctx, name, args) {
19902
20580
  const id = requireString3(args.id, "id");
19903
20581
  const row = await ctx.db.get(table, id);
19904
20582
  if (row === null) return { ok: false, error: "Row not found" };
20583
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
20584
+ return { ok: false, error: "Row not found" };
20585
+ }
19905
20586
  return { ok: true, result: redactRow(row, await secretColumnsFor(ctx.db, table)) };
19906
20587
  }
19907
20588
  case "search": {
@@ -19912,10 +20593,18 @@ async function executeFunction(ctx, name, args) {
19912
20593
  tables = tables.filter((t) => want.has(t));
19913
20594
  }
19914
20595
  const limit = typeof args.limit === "number" ? args.limit : 8;
19915
- const result = await fullTextSearch(ctx.db.adapter, tables, {
20596
+ let result = await fullTextSearch(ctx.db.adapter, tables, {
19916
20597
  query,
19917
20598
  limitPerTable: limit
19918
20599
  });
20600
+ if (ctx.team) {
20601
+ result = await filterSearchGroupsByAcl(
20602
+ ctx.db,
20603
+ ctx.team.teamId,
20604
+ ctx.team.myUserId,
20605
+ result
20606
+ );
20607
+ }
19919
20608
  return { ok: true, result };
19920
20609
  }
19921
20610
  case "create_row": {
@@ -20673,6 +21362,7 @@ async function dispatchChatRoute(req, res, ctx) {
20673
21362
  validTables: new Set([...ctx.validTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20674
21363
  junctionTables: new Set([...ctx.junctionTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20675
21364
  softDeletable: ctx.softDeletable,
21365
+ ...ctx.team ? { team: ctx.team } : {},
20676
21366
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {},
20677
21367
  ...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
20678
21368
  ...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
@@ -21795,6 +22485,41 @@ async function countManyPostgres(adapter, tableNames) {
21795
22485
  }
21796
22486
  return out;
21797
22487
  }
22488
+ var EXACT_COUNT_CAP = 50;
22489
+ async function exactCountMany(adapter, tableNames, softDeleteTables) {
22490
+ const out = /* @__PURE__ */ new Map();
22491
+ if (tableNames.length === 0) return out;
22492
+ if (!adapter.getAsync) return out;
22493
+ let names = tableNames;
22494
+ if (names.length > EXACT_COUNT_CAP) {
22495
+ const dropped = names.length - EXACT_COUNT_CAP;
22496
+ console.warn(
22497
+ `[count-many] exact-count subset capped at ${String(EXACT_COUNT_CAP)} tables; ${String(dropped)} suspicious table(s) keep their approximate count this pass`
22498
+ );
22499
+ names = names.slice(0, EXACT_COUNT_CAP);
22500
+ }
22501
+ const selects = names.map((name, i) => {
22502
+ assertSafeIdentifier(name, "table");
22503
+ const where = softDeleteTables.has(name) ? ` WHERE "deleted_at" IS NULL` : "";
22504
+ return `(SELECT count(*) FROM "${name}"${where}) AS c${String(i)}`;
22505
+ });
22506
+ let row;
22507
+ try {
22508
+ row = await adapter.getAsync(`SELECT ${selects.join(", ")}`);
22509
+ } catch (err) {
22510
+ console.warn(
22511
+ `[count-many] exact-count fallback skipped (${err.message}); using approximate counts`
22512
+ );
22513
+ return out;
22514
+ }
22515
+ if (!row) return out;
22516
+ names.forEach((name, i) => {
22517
+ const v = row[`c${String(i)}`];
22518
+ const n = typeof v === "bigint" ? Number(v) : Number(v);
22519
+ if (Number.isFinite(n) && n >= 0) out.set(name, n);
22520
+ });
22521
+ return out;
22522
+ }
21798
22523
 
21799
22524
  // src/gui/server.ts
21800
22525
  function sendText(res, body, status = 200, contentType = "text/plain; charset=utf-8") {
@@ -21853,17 +22578,64 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21853
22578
  if (teamContext) {
21854
22579
  allTables = allTables.filter((t) => isVisibleInTeam(t.name, teamContext));
21855
22580
  }
22581
+ const rowVisDefaults = /* @__PURE__ */ new Map();
22582
+ const sharedCreatedBy = /* @__PURE__ */ new Map();
22583
+ if (teamContext) {
22584
+ const sharedObjs = await db.query("__lattice_shared_objects", {
22585
+ filters: [
22586
+ { col: "team_id", op: "eq", val: teamContext.teamId },
22587
+ { col: "deleted_at", op: "isNull" }
22588
+ ]
22589
+ });
22590
+ for (const r of sharedObjs) {
22591
+ rowVisDefaults.set(
22592
+ String(r.table_name),
22593
+ r.default_row_visibility === "everyone" ? "everyone" : "private"
22594
+ );
22595
+ if (typeof r.created_by_user_id === "string") {
22596
+ sharedCreatedBy.set(String(r.table_name), r.created_by_user_id);
22597
+ }
22598
+ }
22599
+ }
22600
+ let visibleCounts = /* @__PURE__ */ new Map();
22601
+ if (teamContext) {
22602
+ const tc = teamContext;
22603
+ const specs = allTables.map((t) => {
22604
+ const def = rowVisDefaults.get(t.name) ?? "private";
22605
+ const owner = tc.owners.get(t.name) ?? sharedCreatedBy.get(t.name) ?? "";
22606
+ return {
22607
+ table: t.name,
22608
+ noAclVisible: def === "everyone" || tc.myUserId !== "" && owner === tc.myUserId
22609
+ };
22610
+ });
22611
+ visibleCounts = await db.countVisibleMany(specs, {
22612
+ teamId: tc.teamId,
22613
+ userId: tc.myUserId
22614
+ });
22615
+ }
21856
22616
  const adapter = db._adapter;
21857
- const useBatched = adapter.dialect === "postgres" && typeof adapter.allAsync === "function";
22617
+ const useBatched = !teamContext && adapter.dialect === "postgres" && typeof adapter.allAsync === "function";
21858
22618
  const approxCounts = useBatched ? await countManyPostgres(
21859
22619
  adapter,
21860
22620
  allTables.map((t) => t.name)
21861
22621
  ) : /* @__PURE__ */ new Map();
22622
+ let exactCounts = /* @__PURE__ */ new Map();
22623
+ if (useBatched) {
22624
+ const suspicious = allTables.map((t) => t.name).filter((n) => (approxCounts.get(n) ?? 0) === 0);
22625
+ if (suspicious.length > 0) {
22626
+ const softDeleteTables = new Set(
22627
+ allTables.filter((t) => t.columns.includes("deleted_at")).map((t) => t.name)
22628
+ );
22629
+ exactCounts = await exactCountMany(adapter, suspicious, softDeleteTables);
22630
+ }
22631
+ }
21862
22632
  const enrichedTables = await Promise.all(
21863
22633
  allTables.map(async (t) => {
21864
22634
  let rowCount;
21865
- if (useBatched) {
21866
- rowCount = approxCounts.get(t.name) ?? null;
22635
+ if (teamContext) {
22636
+ rowCount = visibleCounts.get(t.name) ?? null;
22637
+ } else if (useBatched) {
22638
+ rowCount = exactCounts.get(t.name) ?? approxCounts.get(t.name) ?? null;
21867
22639
  } else {
21868
22640
  rowCount = t.columns.includes("deleted_at") ? await db.count(t.name, { filters: [{ col: "deleted_at", op: "isNull" }] }) : await db.count(t.name);
21869
22641
  }
@@ -21877,6 +22649,8 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21877
22649
  base.ownedByMe = teamContext.owners.get(t.name) === teamContext.myUserId;
21878
22650
  const ver = teamContext.sharedVersions.get(t.name);
21879
22651
  if (ver !== void 0) base.schemaVersion = ver;
22652
+ const dv = rowVisDefaults.get(t.name);
22653
+ if (dv) base.defaultRowVisibility = dv;
21880
22654
  }
21881
22655
  return base;
21882
22656
  })
@@ -21885,6 +22659,14 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21885
22659
  }
21886
22660
  var FRESHNESS_COLS = ["updated_at", "created_at", "ts"];
21887
22661
  var DASHBOARD_STALE_DAYS = 14;
22662
+ function requireTeamContext(active, res) {
22663
+ const ctx = active.teamContext;
22664
+ if (!ctx) {
22665
+ sendJson(res, { error: "Row permissions require a team cloud" }, 400);
22666
+ return null;
22667
+ }
22668
+ return ctx;
22669
+ }
21888
22670
  function operatorOwnsTable(teamContext, table) {
21889
22671
  if (!teamContext) return true;
21890
22672
  return teamContext.owners.get(table) === teamContext.myUserId;
@@ -22174,6 +22956,13 @@ async function openConfig(configPath, outputDir, autoRender = false) {
22174
22956
  if (!isVisibleInTeam(name, teamContext)) validTables.delete(name);
22175
22957
  }
22176
22958
  }
22959
+ let directTeamConnection = false;
22960
+ try {
22961
+ const conns = await teamsClient.listConnections();
22962
+ directTeamConnection = conns.some((c) => isPostgresUrl(c.cloud_url));
22963
+ } catch (e) {
22964
+ console.warn("[openConfig] could not check for direct team connections:", e.message);
22965
+ }
22177
22966
  let realtime = null;
22178
22967
  if (db.getDialect() === "postgres") {
22179
22968
  try {
@@ -22214,6 +23003,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
22214
23003
  teamsClient,
22215
23004
  validTables,
22216
23005
  teamContext,
23006
+ directTeamConnection,
22217
23007
  junctionTables,
22218
23008
  entityContextByTable,
22219
23009
  manifest,
@@ -22317,6 +23107,18 @@ async function registerTeamCloudTables(db) {
22317
23107
  await db.defineLate(name, def);
22318
23108
  }
22319
23109
  await installCloudInternalTriggers(db);
23110
+ await installRowPermsSchema(db);
23111
+ }
23112
+ async function emitRowChangeSignal(db, team, table, pk) {
23113
+ const row = await db.get(table, pk);
23114
+ await appendChangeEnvelope(db, {
23115
+ team_id: team.teamId,
23116
+ table_name: table,
23117
+ pk,
23118
+ op: "upsert",
23119
+ payload_json: row ? JSON.stringify(row) : null,
23120
+ owner_user_id: team.myUserId
23121
+ });
22320
23122
  }
22321
23123
  async function syncUserIdentityRow(db) {
22322
23124
  const identity = readIdentity();
@@ -22642,10 +23444,19 @@ data: ${JSON.stringify(data)}
22642
23444
  );
22643
23445
  tables = tables.filter((t) => want.has(t));
22644
23446
  }
22645
- sendJson(
22646
- res,
22647
- await fullTextSearch(active.db.adapter, tables, { query: q, limitPerTable: limit })
22648
- );
23447
+ let result = await fullTextSearch(active.db.adapter, tables, {
23448
+ query: q,
23449
+ limitPerTable: limit
23450
+ });
23451
+ if (active.teamContext) {
23452
+ result = await filterSearchGroupsByAcl(
23453
+ active.db,
23454
+ active.teamContext.teamId,
23455
+ active.teamContext.myUserId,
23456
+ result
23457
+ );
23458
+ }
23459
+ sendJson(res, result);
22649
23460
  return;
22650
23461
  }
22651
23462
  if (method === "GET" && pathname === "/api/team/users") {
@@ -22846,6 +23657,78 @@ data: ${JSON.stringify(data)}
22846
23657
  sendJson(res, result.body, result.status);
22847
23658
  return;
22848
23659
  }
23660
+ {
23661
+ const m = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/visibility$/.exec(pathname);
23662
+ if (method === "POST" && m) {
23663
+ const ctx = requireTeamContext(active, res);
23664
+ if (!ctx) return;
23665
+ const table = decodeURIComponent(m[1] ?? "");
23666
+ const rowId = decodeURIComponent(m[2] ?? "");
23667
+ const body = await readJson(req);
23668
+ const vis = body.visibility;
23669
+ if (vis !== "private" && vis !== "everyone" && vis !== "custom") {
23670
+ sendJson(res, { error: "visibility must be private, everyone, or custom" }, 400);
23671
+ return;
23672
+ }
23673
+ await setRowVisibility(active.db, ctx.teamId, table, rowId, ctx.myUserId, vis);
23674
+ await emitRowChangeSignal(active.db, ctx, table, rowId);
23675
+ sendJson(res, { ok: true });
23676
+ return;
23677
+ }
23678
+ }
23679
+ {
23680
+ const m = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/grants$/.exec(pathname);
23681
+ if (method === "POST" && m) {
23682
+ const ctx = requireTeamContext(active, res);
23683
+ if (!ctx) return;
23684
+ const table = decodeURIComponent(m[1] ?? "");
23685
+ const rowId = decodeURIComponent(m[2] ?? "");
23686
+ const body = await readJson(req);
23687
+ let granteeId = typeof body.user_id === "string" ? body.user_id : "";
23688
+ if (!granteeId && typeof body.email === "string") {
23689
+ granteeId = await resolveUserIdByEmail(active.db, body.email) ?? "";
23690
+ }
23691
+ if (!granteeId) {
23692
+ sendJson(res, { error: "user_id or a resolvable email is required" }, 400);
23693
+ return;
23694
+ }
23695
+ await addRowGrant(active.db, ctx.teamId, table, rowId, granteeId, ctx.myUserId);
23696
+ await emitRowChangeSignal(active.db, ctx, table, rowId);
23697
+ sendJson(res, { ok: true });
23698
+ return;
23699
+ }
23700
+ }
23701
+ {
23702
+ const m = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/grants\/([^/]+)$/.exec(pathname);
23703
+ if (method === "DELETE" && m) {
23704
+ const ctx = requireTeamContext(active, res);
23705
+ if (!ctx) return;
23706
+ const table = decodeURIComponent(m[1] ?? "");
23707
+ const rowId = decodeURIComponent(m[2] ?? "");
23708
+ const granteeId = decodeURIComponent(m[3] ?? "");
23709
+ await removeRowGrant(active.db, ctx.teamId, table, rowId, granteeId, ctx.myUserId);
23710
+ await emitRowChangeSignal(active.db, ctx, table, rowId);
23711
+ sendJson(res, { ok: true });
23712
+ return;
23713
+ }
23714
+ }
23715
+ {
23716
+ const m = /^\/api\/schema\/entities\/([^/]+)\/default-row-visibility$/.exec(pathname);
23717
+ if (method === "POST" && m) {
23718
+ const ctx = requireTeamContext(active, res);
23719
+ if (!ctx) return;
23720
+ const table = decodeURIComponent(m[1] ?? "");
23721
+ const body = await readJson(req);
23722
+ const vis = body.visibility;
23723
+ if (vis !== "private" && vis !== "everyone") {
23724
+ sendJson(res, { error: "visibility must be private or everyone" }, 400);
23725
+ return;
23726
+ }
23727
+ await setTableDefaultVisibility(active.db, ctx.teamId, table, ctx.myUserId, vis);
23728
+ sendJson(res, { ok: true });
23729
+ return;
23730
+ }
23731
+ }
22849
23732
  if (method === "GET" && pathname === "/api/gui-meta/columns") {
22850
23733
  const rows = await active.db.query("_lattice_gui_column_meta", {});
22851
23734
  const out = {};
@@ -23940,6 +24823,10 @@ data: ${JSON.stringify(data)}
23940
24823
  sendJson(res, { error: `Unknown table: ${table}` }, 400);
23941
24824
  return;
23942
24825
  }
24826
+ if (!await canAccessRow(active.db, tctx.teamId, table, rowId, tctx.myUserId)) {
24827
+ sendJson(res, { error: "Row not found" }, 404);
24828
+ return;
24829
+ }
23943
24830
  const limit = Math.min(200, Math.max(1, Number(url2.searchParams.get("limit") ?? "50")));
23944
24831
  const rows = await active.db.query("__lattice_change_log", {
23945
24832
  filters: [
@@ -23974,20 +24861,7 @@ data: ${JSON.stringify(data)}
23974
24861
  sendJson(res, { error: `Unknown table: ${table}` }, 400);
23975
24862
  return;
23976
24863
  }
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
- }
24864
+ const edits = await visibleRowEdits(active.db, tctx.teamId, table, tctx.myUserId);
23991
24865
  sendJson(res, { edits });
23992
24866
  return;
23993
24867
  }
@@ -24029,13 +24903,31 @@ data: ${JSON.stringify(data)}
24029
24903
  const limit = Number(url2.searchParams.get("limit") ?? "500");
24030
24904
  const offset = Number(url2.searchParams.get("offset") ?? "0");
24031
24905
  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
- ];
24906
+ let rows;
24907
+ if (active.teamContext) {
24908
+ const tc = active.teamContext;
24909
+ rows = await listVisibleRows(active.db, tc.teamId, table, tc.myUserId, {
24910
+ limit,
24911
+ offset,
24912
+ deleted: deletedMode === "any" ? "any" : deletedMode === "only" ? "only" : "exclude"
24913
+ });
24914
+ const summaries = await rowAccessSummaries(
24915
+ active.db,
24916
+ tc.teamId,
24917
+ table,
24918
+ tc.myUserId,
24919
+ rows.map((r) => String(r.id))
24920
+ );
24921
+ rows = rows.map((r) => ({ ...r, _access: summaries.get(String(r.id)) ?? null }));
24922
+ } else {
24923
+ const queryOpts = { limit, offset };
24924
+ if (active.softDeletable.has(table) && deletedMode !== "any") {
24925
+ queryOpts.filters = [
24926
+ { col: "deleted_at", op: deletedMode === "only" ? "isNotNull" : "isNull" }
24927
+ ];
24928
+ }
24929
+ rows = await active.db.query(table, queryOpts);
24037
24930
  }
24038
- const rows = await active.db.query(table, queryOpts);
24039
24931
  sendJson(res, { rows });
24040
24932
  return;
24041
24933
  }
@@ -24052,6 +24944,19 @@ data: ${JSON.stringify(data)}
24052
24944
  sendJson(res, { error: "Row not found" }, 404);
24053
24945
  return;
24054
24946
  }
24947
+ if (active.teamContext) {
24948
+ const tc = active.teamContext;
24949
+ if (!await canAccessRow(active.db, tc.teamId, table, id, tc.myUserId)) {
24950
+ sendJson(res, { error: "Row not found" }, 404);
24951
+ return;
24952
+ }
24953
+ const summary = (await rowAccessSummaries(active.db, tc.teamId, table, tc.myUserId, [id])).get(
24954
+ id
24955
+ ) ?? null;
24956
+ const access = summary?.ownedByMe ? { ...summary, grantees: await rowGrantees(active.db, tc.teamId, table, id) } : summary;
24957
+ sendJson(res, { ...row, _access: access });
24958
+ return;
24959
+ }
24055
24960
  sendJson(res, row);
24056
24961
  return;
24057
24962
  }
@@ -24144,6 +25049,7 @@ data: ${JSON.stringify(data)}
24144
25049
  validTables: active.validTables,
24145
25050
  junctionTables: active.junctionTables,
24146
25051
  softDeletable: active.softDeletable,
25052
+ team: active.teamContext ? { teamId: active.teamContext.teamId, myUserId: active.teamContext.myUserId } : null,
24147
25053
  // The assistant can create tables + relationships on request — same
24148
25054
  // audited, no-reopen primitives the Context Constructor uses.
24149
25055
  createEntity: (name, columns) => createUserEntity(active, name, columns, sessionId),
@@ -24193,6 +25099,7 @@ data: ${JSON.stringify(data)}
24193
25099
  teamId: active.teamContext.teamId,
24194
25100
  myUserId: active.teamContext.myUserId
24195
25101
  } : null,
25102
+ directCloud: active.directTeamConnection,
24196
25103
  swap: async () => {
24197
25104
  const next = await openConfig(active.configPath, active.outputDir, autoRender);
24198
25105
  await disposeActive(active);
@@ -24204,6 +25111,14 @@ data: ${JSON.stringify(data)}
24204
25111
  sendJson(res, { error: "Not found" }, 404);
24205
25112
  } catch (err) {
24206
25113
  const e = err;
25114
+ if (e.code === "row_access_denied") {
25115
+ sendJson(res, { error: "Row not found" }, 404);
25116
+ return;
25117
+ }
25118
+ if (e.code === "row_owner_only") {
25119
+ sendJson(res, { error: e.message }, 403);
25120
+ return;
25121
+ }
24207
25122
  console.error(
24208
25123
  `[gui] ${req.method ?? "?"} ${req.url ?? "?"} failed: ${e.message}
24209
25124
  ${e.stack ?? ""}`
@@ -24916,7 +25831,7 @@ function printHelp() {
24916
25831
  " status Dry-run reconcile \u2014 show what would change without writing",
24917
25832
  " watch Poll for changes and re-render on each cycle",
24918
25833
  " gui Start a local browser GUI for exploring Lattice context",
24919
- " serve Start a server-mode lattice (use --team-cloud for Lattice Teams)",
25834
+ " serve Start a server-mode lattice (add --team-cloud to host a shared cloud for remote members)",
24920
25835
  " teams Manage Lattice Teams (run `lattice teams help` for subcommands)",
24921
25836
  " update Upgrade latticesql to the latest version",
24922
25837
  "",
@@ -24961,7 +25876,11 @@ function printHelp() {
24961
25876
  " --output <dir> Output directory for rendered context (default: ./context)",
24962
25877
  " --host <addr> Bind address (default: 127.0.0.1; use 0.0.0.0 to expose)",
24963
25878
  " --port <number> Port (default: 4317; auto-increments if busy)",
24964
- " --team-cloud Enable Lattice Teams cloud mode (bearer auth required)",
25879
+ " --team-cloud Host this cloud as a shared server for remote members (bearer auth).",
25880
+ " A cloud already IS a workspace with members; this only adds the",
25881
+ " auth-gated HTTP surface so other people can connect. Omit it to",
25882
+ " open the cloud yourself (you connect directly; eye-icon row",
25883
+ " permissions are active).",
24965
25884
  "",
24966
25885
  "Options (init / workspace):",
24967
25886
  " --root <dir> The .lattice root location (default: discovered or ./.lattice)",
@@ -25205,7 +26124,7 @@ async function runServe(args) {
25205
26124
  openBrowser: false,
25206
26125
  teamCloud: args.teamCloud
25207
26126
  });
25208
- const label = args.teamCloud ? "Lattice team cloud" : "Lattice server";
26127
+ const label = args.teamCloud ? "Lattice shared cloud server" : "Lattice server";
25209
26128
  console.log(`${label} listening on ${args.host}:${String(handle.port)} (${handle.url})`);
25210
26129
  console.log("Press Ctrl+C to stop.");
25211
26130
  const shutdown = () => {