latticesql 2.1.0 → 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
@@ -417,12 +417,6 @@ function assertSafeLabel(label) {
417
417
  throw new Error(`Invalid label "${label}": must match [A-Za-z0-9._-]+ and not start with .`);
418
418
  }
419
419
  }
420
- function readToken(label) {
421
- assertSafeLabel(label);
422
- const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
423
- if (!existsSync2(path2)) return null;
424
- return readFileSync(path2, "utf8").trim();
425
- }
426
420
  function writeToken(label, token) {
427
421
  assertSafeLabel(label);
428
422
  const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
@@ -4380,6 +4374,12 @@ function buildAdapter(dbPath, options) {
4380
4374
  return new SQLiteAdapter(sqlitePath, adapterOpts);
4381
4375
  }
4382
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
+ }
4383
4383
  var Lattice = class _Lattice {
4384
4384
  _adapter;
4385
4385
  _changelogService;
@@ -5479,6 +5479,131 @@ var Lattice = class _Lattice {
5479
5479
  const rows = await allAsyncOrSync(this._adapter, sql, params);
5480
5480
  return this._decryptRows(table, rows);
5481
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
+ }
5482
5607
  async count(table, opts = {}) {
5483
5608
  const notInit = this._notInitError();
5484
5609
  if (notInit) return notInit;
@@ -7086,6 +7211,8 @@ var css = `
7086
7211
  }
7087
7212
  a.chip-link { cursor: pointer; }
7088
7213
  a.chip-link:hover { background: var(--accent); color: white; }
7214
+ /* Inline object-reference pills the assistant emits \u2014 render flush in prose. */
7215
+ a.lattice-ref { text-decoration: none; vertical-align: baseline; }
7089
7216
  .empty-row td {
7090
7217
  color: var(--text-muted); font-style: italic; text-align: center;
7091
7218
  padding: 24px;
@@ -7351,7 +7478,7 @@ var css = `
7351
7478
  .view-header .actions { margin-left: auto; display: flex; gap: 8px; }
7352
7479
 
7353
7480
  /* Row delete / restore controls */
7354
- .row-actions { width: 64px; text-align: center; white-space: nowrap; }
7481
+ .row-actions { width: 88px; text-align: center; white-space: nowrap; }
7355
7482
  .row-delete, .row-restore {
7356
7483
  background: transparent; border: none; color: var(--text-muted);
7357
7484
  font-size: 16px; cursor: pointer; padding: 4px 6px;
@@ -7362,6 +7489,43 @@ var css = `
7362
7489
  .row-restore:hover { background: var(--accent-soft); color: var(--accent); }
7363
7490
  tr.row-deleted td { background: rgba(251, 146, 60, 0.08); color: var(--text-muted); }
7364
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); }
7365
7529
 
7366
7530
  /* Inline create-row at the bottom of every table */
7367
7531
  tr.create-row td { background: var(--surface-2); }
@@ -7812,20 +7976,9 @@ var css = `
7812
7976
  padding: 8px; margin: 0 0 8px; overflow-x: auto;
7813
7977
  }
7814
7978
  .chat-bubble.assistant pre code { background: none; border: none; padding: 0; white-space: pre; }
7815
- .chat-tools { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
7816
- .tool-pill {
7817
- display: inline-flex; align-items: center; gap: 5px;
7818
- border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 500;
7819
- background: var(--accent-soft); color: var(--accent);
7820
- box-shadow: var(--glow-accent-soft);
7821
- }
7822
- .tool-pill.done { background: var(--surface-2); color: var(--text-muted); box-shadow: none; }
7823
- .tool-pill.error { background: rgba(251,146,60,0.14); color: var(--warn); box-shadow: none; }
7824
- .tool-pill .spin { display: inline-block; width: 9px; height: 9px;
7825
- border: 1.5px solid currentColor; border-top-color: transparent; border-radius: 50%;
7826
- animation: pillspin 0.7s linear infinite; }
7827
- @keyframes pillspin { to { transform: rotate(360deg); } }
7828
- /* Typing indicator: three pulsing dots shown in an assistant bubble while
7979
+ /* The assistant's data changes render as activity-feed cards (.feed-item) in
7980
+ the rail \u2014 there is no separate inline pill style. Reads emit no card.
7981
+ Typing indicator: three pulsing dots shown in an assistant bubble while
7829
7982
  the model is generating (before the first text delta of a turn). */
7830
7983
  .chat-typing { display: inline-flex; align-items: center; gap: 4px; padding: 1px 0; }
7831
7984
  .chat-typing i {
@@ -7963,6 +8116,13 @@ var appJs = `
7963
8116
  return !!(t && t[colName] && t[colName].secret);
7964
8117
  }
7965
8118
  var SECRET_MASK = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'; // \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022
8119
+ // An encrypted-at-rest value (native secrets etc.) is stored with an "enc:"
8120
+ // sentinel prefix (see framework/native-entities decrypt). It is never
8121
+ // plaintext, so the GUI must never render the raw ciphertext \u2014 mask it the
8122
+ // same way an operator-flagged secret column is masked.
8123
+ function looksEncrypted(v) {
8124
+ return typeof v === 'string' && v.slice(0, 4) === 'enc:';
8125
+ }
7966
8126
 
7967
8127
  function displayFor(name) {
7968
8128
  var override = state.iconOverrides[name];
@@ -8794,9 +8954,16 @@ var appJs = `
8794
8954
  headers: { 'content-type': 'application/json' },
8795
8955
  body: JSON.stringify({ id: id }),
8796
8956
  }).then(function () {
8797
- menu.hidden = true;
8957
+ // Keep the menu OPEN with the item's spinner through the reload \u2014
8958
+ // for a CLOUD workspace the slow part (connecting + fetching
8959
+ // against the remote DB) happens here in reloadEverything, AFTER
8960
+ // the switch POST. Hiding the menu now (the old behavior) hid the
8961
+ // only progress signal, so a cloud switch looked unresponsive.
8962
+ // renderWsSwitcher (inside reloadEverything) only re-binds the
8963
+ // toggle + updates the label, so the spinning item survives.
8798
8964
  return reloadEverything();
8799
8965
  }).then(function () {
8966
+ menu.hidden = true;
8800
8967
  // Conversations + activity both live in the workspace DB. Drop
8801
8968
  // the old workspace's thread + activity cards, reconnect the feed
8802
8969
  // to THIS workspace, and reload its thread list (+ latest convo).
@@ -8805,7 +8972,7 @@ var appJs = `
8805
8972
  startFeed();
8806
8973
  refreshThreadList(true);
8807
8974
  showToast('Switched workspace', {});
8808
- }).catch(function (err) { showToast('Switch failed: ' + err.message, {}); });
8975
+ }).catch(function (err) { menu.hidden = true; showToast('Switch failed: ' + err.message, {}); });
8809
8976
  });
8810
8977
  });
8811
8978
  });
@@ -8849,6 +9016,27 @@ var appJs = `
8849
9016
 
8850
9017
  window.addEventListener('hashchange', renderRoute);
8851
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
+
8852
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
8853
9041
  // Sidebar
8854
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
@@ -9204,13 +9392,38 @@ var appJs = `
9204
9392
  .map(function (h) { return '<th>' + escapeHtml(h) + '</th>'; }).join('');
9205
9393
  headers += '<th class="row-actions"></th>';
9206
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
+ }
9207
9420
  var bodyRows;
9208
9421
  if (rows.length === 0) {
9209
9422
  bodyRows = '';
9210
9423
  } else {
9211
9424
  bodyRows = rows.map(function (r) {
9212
9425
  var tds = intrinsic.map(function (c) {
9213
- if (isSecretColumn(tableName, c) && r[c] != null && r[c] !== '') {
9426
+ if ((isSecretColumn(tableName, c) || looksEncrypted(r[c])) && r[c] != null && r[c] !== '') {
9214
9427
  return '<td class="muted">' + SECRET_MASK + '</td>';
9215
9428
  }
9216
9429
  return '<td><div class="cell-clip">' + escapeHtml(truncate(r[c], 120)) + '</div></td>';
@@ -9234,7 +9447,8 @@ var appJs = `
9234
9447
  '<button class="row-delete" title="Delete permanently" data-hard-del="' + escapeHtml(r.id) + '">\u2715</button>' +
9235
9448
  '</td>');
9236
9449
  } else {
9237
- 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>');
9238
9452
  }
9239
9453
  return '<tr data-id="' + escapeHtml(r.id) + '"' + (viewMode === 'trash' ? ' class="row-deleted"' : '') + '>' + tds.join('') + '</tr>';
9240
9454
  }).join('');
@@ -9343,6 +9557,30 @@ var appJs = `
9343
9557
  });
9344
9558
  });
9345
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
+
9346
9584
  content.querySelectorAll('tr[data-id]').forEach(function (tr) {
9347
9585
  tr.addEventListener('click', function (e) {
9348
9586
  // Let chip-link anchors and the delete button handle their own click.
@@ -9469,6 +9707,39 @@ var appJs = `
9469
9707
  });
9470
9708
  }
9471
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
+ }
9472
9743
  function renderDetail(content, tableName, id) {
9473
9744
  var t = tableByName(tableName);
9474
9745
  if (!t) {
@@ -9495,7 +9766,7 @@ var appJs = `
9495
9766
  function paint(editing) {
9496
9767
  var rows = [];
9497
9768
  intrinsic.forEach(function (c) {
9498
- var secret = isSecretColumn(tableName, c);
9769
+ var secret = isSecretColumn(tableName, c) || looksEncrypted(row[c]);
9499
9770
  var dd;
9500
9771
  if (editing) {
9501
9772
  dd = fieldFor(c, row[c], t);
@@ -9567,6 +9838,7 @@ var appJs = `
9567
9838
  '<h1>' + escapeHtml(displayNameFor(row) || d.label) + '</h1>' +
9568
9839
  '<div class="actions">' + actions + '</div>' +
9569
9840
  '</div>' +
9841
+ detailVisLineEl(row) +
9570
9842
  lastEditedLineEl(tableName, id) +
9571
9843
  (tableName === 'files' ? '<div class="file-preview" id="file-preview"></div>' : '') +
9572
9844
  '<div class="detail"><dl class="' + (editing ? 'editing' : '') + '">' + rows.join('') + '</dl></div>' +
@@ -9579,6 +9851,93 @@ var appJs = `
9579
9851
  if (!editing) loadRowContext(tableName, id);
9580
9852
  if (!editing && tableName === 'files') renderFilePreview(row);
9581
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
+
9582
9941
  // Junction link/unlink handlers (active in both read and edit modes).
9583
9942
  content.querySelectorAll('.remove-link').forEach(function (btn) {
9584
9943
  btn.addEventListener('click', function (e) {
@@ -9863,7 +10222,7 @@ var appJs = `
9863
10222
  function fsValInner(table, row, col) {
9864
10223
  var raw = row[col];
9865
10224
  if (raw == null || raw === '') return '<span class="fs-empty-val">\u2014</span>';
9866
- if (isSecretColumn(table, col)) return '<span class="muted">' + SECRET_MASK + '</span>';
10225
+ if (isSecretColumn(table, col) || looksEncrypted(raw)) return '<span class="muted">' + SECRET_MASK + '</span>';
9867
10226
  var s = String(raw);
9868
10227
  if (FS_LONGFORM.indexOf(col) >= 0 || s.indexOf('\\n') >= 0) {
9869
10228
  return '<div class="md-body">' + mdToHtml(s.slice(0, 40000)) + '</div>';
@@ -11008,6 +11367,18 @@ var appJs = `
11008
11367
  '</span>' +
11009
11368
  '</div>'
11010
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
+ : '';
11011
11382
  panel.innerHTML =
11012
11383
  '<h3>' + d.icon + ' ' + escapeHtml(d.label) + '</h3>' +
11013
11384
  '<div class="dm-edit-grid">' +
@@ -11022,6 +11393,7 @@ var appJs = `
11022
11393
  '<button class="btn" id="dm-icon-btn" style="margin-top:6px;">Save</button>' +
11023
11394
  '</div>' +
11024
11395
  shareRow +
11396
+ defaultVisRow +
11025
11397
  '<label>Columns</label>' +
11026
11398
  '<div>' +
11027
11399
  '<div class="dm-cols">' + (columnsHtml || '<span class="muted">No columns</span>') + '</div>' +
@@ -11073,6 +11445,22 @@ var appJs = `
11073
11445
  }).catch(function (e) { showToast('Share update failed: ' + e.message, {}); });
11074
11446
  });
11075
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
+ });
11076
11464
  }
11077
11465
 
11078
11466
  /**
@@ -11760,6 +12148,7 @@ var appJs = `
11760
12148
  // enters per-DB things (cloud URL + DB name) in this modal.
11761
12149
  fetchJson('/api/userconfig/identity').then(function (id) {
11762
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>' +
11763
12152
  '<div class="field"><label>Cloud URL</label>' +
11764
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" />' +
11765
12154
  '</div>' +
@@ -11787,6 +12176,7 @@ var appJs = `
11787
12176
  function showJoinTeamModal(kind) {
11788
12177
  fetchJson('/api/userconfig/identity').then(function (id) {
11789
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>' +
11790
12180
  '<div class="field"><label>Cloud URL</label>' +
11791
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" />' +
11792
12182
  '</div>' +
@@ -11865,20 +12255,29 @@ var appJs = `
11865
12255
  '</div>' +
11866
12256
  '</div>';
11867
12257
  }
12258
+ // Only the selected provider's key input is shown (declutter). 'auto'
12259
+ // ("Select provider\u2026") shows no key row until a provider is chosen.
12260
+ function voiceRowHtml(provider) {
12261
+ if (provider === 'openai') {
12262
+ return rowHtml('asst-openai', 'OpenAI Whisper key', !!cfg.hasOpenaiKey, 'sk-\u2026');
12263
+ }
12264
+ if (provider === 'elevenlabs') {
12265
+ return rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026');
12266
+ }
12267
+ return '';
12268
+ }
11868
12269
  host.innerHTML =
11869
12270
  '<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
11870
12271
  '<h3 style="margin:0 0 10px">Assistant</h3>' +
11871
12272
  '<p class="lead" style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
11872
- 'Keys are stored encrypted in the <code>secrets</code> table \u2014 never shown again once ' +
11873
- 'saved. Environment variables (<code>ANTHROPIC_API_KEY</code>, <code>OPENAI_API_KEY</code>, ' +
11874
- '<code>ELEVENLABS_API_KEY</code>) also work.' +
12273
+ 'Keys are stored encrypted in the <code>secrets</code> table.' +
11875
12274
  '</p>' +
11876
12275
  rowHtml('asst-anthropic', 'Claude API token (chat)', !!cfg.hasAnthropicKey, 'sk-ant-\u2026') +
11877
- '<div style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
11878
- (cfg.oauthEnabled
11879
- ? 'Or <a href="/api/assistant/oauth/start" style="color:var(--accent)">connect your Claude subscription</a>.'
11880
- : 'Subscription login: set the <code>ANTHROPIC_OAUTH_*</code> env vars to enable.') +
11881
- '</div>' +
12276
+ (cfg.oauthEnabled
12277
+ ? '<div style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
12278
+ 'Or <a href="/api/assistant/oauth/start" style="color:var(--accent)">connect your Claude subscription</a>.' +
12279
+ '</div>'
12280
+ : '') +
11882
12281
  '<div style="margin:6px 0 12px">' +
11883
12282
  '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
11884
12283
  '<strong style="font-size:13px">Inference aggressiveness</strong>' +
@@ -11895,17 +12294,16 @@ var appJs = `
11895
12294
  'auto-creates link tables) when you drop in files. Higher extrapolates more.' +
11896
12295
  '</p>' +
11897
12296
  '</div>' +
11898
- '<div style="font-size:11px;color:var(--text-muted);margin:10px 0 8px;text-transform:uppercase;letter-spacing:0.05em">Voice \u2014 speech to text (set either)</div>' +
11899
- rowHtml('asst-openai', 'OpenAI Whisper key', !!cfg.hasOpenaiKey, 'sk-\u2026') +
11900
- rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026') +
11901
- '<div style="margin:6px 0 2px;display:flex;align-items:center;gap:8px">' +
12297
+ '<div style="font-size:11px;color:var(--text-muted);margin:10px 0 8px;text-transform:uppercase;letter-spacing:0.05em">Voice \u2014 speech to text</div>' +
12298
+ '<div style="margin:6px 0 8px;display:flex;align-items:center;gap:8px">' +
11902
12299
  '<span style="font-size:12px;color:var(--text-muted)">Use for voice:</span>' +
11903
12300
  '<select id="asst-stt" style="background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px;padding:3px 6px">' +
11904
- '<option value="auto">Auto</option>' +
11905
- '<option value="openai">OpenAI Whisper</option>' +
12301
+ '<option value="auto">Select provider\u2026</option>' +
12302
+ '<option value="openai">OpenAI</option>' +
11906
12303
  '<option value="elevenlabs">ElevenLabs</option>' +
11907
12304
  '</select>' +
11908
12305
  '</div>' +
12306
+ '<div id="asst-voice-key">' + voiceRowHtml(cfg.sttPreference || 'auto') + '</div>' +
11909
12307
  '<div id="assistant-msg" style="margin-top:4px;font-size:12px;color:var(--text-muted)"></div>' +
11910
12308
  '</div>';
11911
12309
  var msg = host.querySelector('#assistant-msg');
@@ -11935,12 +12333,18 @@ var appJs = `
11935
12333
  });
11936
12334
  }
11937
12335
  wire('asst-anthropic', 'anthropic');
11938
- wire('asst-openai', 'openai');
11939
- wire('asst-elevenlabs', 'elevenlabs');
11940
12336
  var sttSel = host.querySelector('#asst-stt');
12337
+ var voiceKeyHost = host.querySelector('#asst-voice-key');
12338
+ function wireVoiceKey(provider) {
12339
+ if (provider === 'openai') wire('asst-openai', 'openai');
12340
+ else if (provider === 'elevenlabs') wire('asst-elevenlabs', 'elevenlabs');
12341
+ }
11941
12342
  if (sttSel) {
11942
12343
  sttSel.value = cfg.sttPreference || 'auto';
12344
+ wireVoiceKey(sttSel.value);
11943
12345
  sttSel.addEventListener('change', function () {
12346
+ if (voiceKeyHost) voiceKeyHost.innerHTML = voiceRowHtml(sttSel.value);
12347
+ wireVoiceKey(sttSel.value);
11944
12348
  msg.textContent = 'Saving\u2026';
11945
12349
  fetch('/api/assistant/stt-provider', {
11946
12350
  method: 'PUT',
@@ -12331,9 +12735,10 @@ var appJs = `
12331
12735
 
12332
12736
  // State-machine Database panel (v1.13+). Renders a different body
12333
12737
  // per info.state: local -> Migrate / Connect-existing wizards;
12334
- // cloud-connected -> Upgrade-to-team; team-cloud-creator/member ->
12335
- // team management UI; team-cloud-needs-invite -> join form.
12336
- // Progression is one-way: local -> cloud -> team-cloud.
12738
+ // team-cloud-creator/member -> connection details + members. A connected
12739
+ // cloud workspace is always a member workspace (created or invited), so
12740
+ // there is no in-settings "join via invite" \u2014 that lives in the Join
12741
+ // Workspace flow only.
12337
12742
  function renderDatabasePanel(host) {
12338
12743
  fetchJson('/api/dbconfig').then(function (info) {
12339
12744
  var badge = renderStateBadge(info);
@@ -12369,10 +12774,6 @@ var appJs = `
12369
12774
  label = 'CLOUD \xB7 MEMBER';
12370
12775
  color = 'var(--accent)';
12371
12776
  break;
12372
- case 'team-cloud-needs-invite':
12373
- label = 'CLOUD \xB7 NEEDS INVITE';
12374
- color = 'var(--warn)';
12375
- break;
12376
12777
  default:
12377
12778
  label = String(info.state || 'UNKNOWN').toUpperCase();
12378
12779
  }
@@ -12408,21 +12809,6 @@ var appJs = `
12408
12809
  '<div id="db-members-host" style="margin-top:12px"><div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div></div>'
12409
12810
  );
12410
12811
  }
12411
- if (info.state === 'team-cloud-needs-invite') {
12412
- return (
12413
- renderConnectionSummary(info) +
12414
- '<p style="margin-top:10px;color:var(--warn);font-size:13px">' +
12415
- 'Not a member of this cloud workspace yet \u2014 paste your invite token to join.' +
12416
- '</p>' +
12417
- '<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px">' +
12418
- '<div><label class="field-label">Invite token</label>' +
12419
- '<textarea id="db-rejoin-token" placeholder="latinv_..." style="width:100%;height:54px;font-family:JetBrains Mono,monospace"></textarea></div>' +
12420
- '</div>' +
12421
- '<div class="team-actions" style="margin-top:10px">' +
12422
- '<button class="btn primary" data-act="rejoin-with-token">Join workspace \u2192</button>' +
12423
- '</div>'
12424
- );
12425
- }
12426
12812
  return '<p style="color:var(--text-muted)">Unknown database state.</p>';
12427
12813
  }
12428
12814
 
@@ -12494,32 +12880,6 @@ var appJs = `
12494
12880
  }).catch(function () { membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Members unavailable.</div>'; });
12495
12881
  }
12496
12882
 
12497
- var rejoinBtn = host.querySelector('[data-act="rejoin-with-token"]');
12498
- if (rejoinBtn) rejoinBtn.addEventListener('click', function () {
12499
- var token = (document.getElementById('db-rejoin-token').value || '').trim();
12500
- if (!token) { setMsg('Invite token required.', false); return; }
12501
- // Without form re-entry the credentials are already saved; we
12502
- // call the connect-existing endpoint with just the invite
12503
- // token. The handler reads credentials from db-credentials.enc
12504
- // via the active configPath's label.
12505
- setMsg('Joining workspace\u2026');
12506
- fetch('/api/dbconfig/connect-existing', {
12507
- method: 'POST', headers: { 'content-type': 'application/json' },
12508
- body: JSON.stringify({
12509
- type: 'postgres',
12510
- label: info.label,
12511
- host: info.host, port: info.port, dbname: info.dbname,
12512
- user: info.user, password: '', // password lives in db-credentials.enc; backend will pull
12513
- invite_token: token,
12514
- }),
12515
- })
12516
- .then(function (r) { return r.json(); })
12517
- .then(function (d) {
12518
- if (d.error) { setMsg('Failed: ' + d.error, false); return; }
12519
- setMsg('Joined.', true); rerender();
12520
- })
12521
- .catch(function (e) { setMsg('Failed: ' + e.message, false); });
12522
- });
12523
12883
  }
12524
12884
 
12525
12885
  // \u2500\u2500 v1.13 wizards \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
@@ -12895,64 +13255,103 @@ var appJs = `
12895
13255
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
12896
13256
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
12897
13257
  };
12898
- // Ops whose consecutive runs collapse into one counted bubble (bulk row work
12899
- // spams N near-identical rows otherwise). Schema/undo/redo stay distinct.
13258
+ // Schema mutations reach the client in two shapes: the LIVE feed publishes the
13259
+ // coarse op:'schema', while the persisted audit log / per-thread replay carry
13260
+ // the fine-grained op:'schema.delete_entity' (etc.). Treat both as schema so
13261
+ // they collapse + pick the \u{1F6E0} icon identically (regression: backfilled schema
13262
+ // ops showed '\u2022' and never grouped).
13263
+ function isSchemaOp(op) { var o = String(op || ''); return o === 'schema' || o.indexOf('schema.') === 0; }
13264
+ function feedIcon(op) { return isSchemaOp(op) ? FEED_ICONS.schema : (FEED_ICONS[op] || '\u2022'); }
13265
+ // Ops whose runs collapse into one counted bubble (bulk row work spams N
13266
+ // near-identical rows otherwise). Undo/redo stay distinct.
12900
13267
  var GROUPABLE_OPS = { insert: 1, update: 1, delete: 1, link: 1, unlink: 1 };
12901
- // Active group bubbles keyed by op|table|source so a burst of identical
12902
- // events coalesces into ONE counted bubble even when other events interleave
12903
- // (a bulk ingest emits create/link/update across several tables at once \u2014
12904
- // consecutive-only grouping let those interleaved runs spam the feed). A
12905
- // group stays "open" for FEED_GROUP_WINDOW_MS after its last hit; later
13268
+ var ROW_VERB = { insert: 'Added', update: 'Updated', delete: 'Removed', link: 'Linked', unlink: 'Unlinked' };
13269
+ var ROW_PREP = { insert: 'to', update: 'in', delete: 'from', link: 'in', unlink: 'in' };
13270
+ // Schema events all arrive as op:'schema'; the specific action lives only in
13271
+ // the summary text. Map that text to a stable sub-action so a bulk run of
13272
+ // "Deleted table X" collapses into one "Deleted 19 tables" pill. Each entry
13273
+ // is [verb, singular, plural].
13274
+ var SCHEMA_GROUP = {
13275
+ 'created-table': ['Created', 'table', 'tables'],
13276
+ 'deleted-table': ['Deleted', 'table', 'tables'],
13277
+ 'renamed-table': ['Renamed', 'table', 'tables'],
13278
+ 'added-column': ['Added', 'column', 'columns'],
13279
+ 'renamed-column': ['Renamed', 'column', 'columns'],
13280
+ 'added-link': ['Added', 'link', 'links'],
13281
+ 'deleted-link': ['Deleted', 'link', 'links'],
13282
+ 'created-link': ['Created', 'link table', 'link tables'],
13283
+ };
13284
+ function schemaAction(summary) {
13285
+ var s = String(summary || '');
13286
+ if (/^Created link table/.test(s)) return 'created-link';
13287
+ if (/^Created table/.test(s)) return 'created-table';
13288
+ if (/^Deleted table/.test(s)) return 'deleted-table';
13289
+ if (/^Renamed table/.test(s)) return 'renamed-table';
13290
+ if (/^Added a column/.test(s)) return 'added-column';
13291
+ if (/^Renamed a column/.test(s)) return 'renamed-column';
13292
+ if (/^Added a link/.test(s)) return 'added-link';
13293
+ if (/^Deleted a link/.test(s)) return 'deleted-link';
13294
+ return null; // unknown schema op: keep it ungrouped (stay honest)
13295
+ }
13296
+ // Group identical-TYPE events into one counted pill regardless of which
13297
+ // object they touched, so a bulk run (delete N tables, remove rows across M
13298
+ // tables) shows a single bubble instead of overflowing the rail. Keyed by
13299
+ // op+source (+schema sub-action); the table is intentionally NOT in the key.
13300
+ // A group stays "open" for FEED_GROUP_WINDOW_MS after its last hit; later
12906
13301
  // activity starts a fresh bubble so unrelated edits aren't merged in.
12907
- var feedGroups = {}; // key -> { count, item, summaryEl, timeEl, last }
12908
- var FEED_GROUP_WINDOW_MS = 15000;
12909
- function groupedSummary(op, table, count) {
12910
- var t = String(table || '');
12911
- switch (op) {
12912
- case 'insert': return 'Added ' + count + ' rows to ' + t;
12913
- case 'update': return 'Updated ' + count + ' rows in ' + t;
12914
- case 'delete': return 'Removed ' + count + ' rows from ' + t;
12915
- case 'link': return 'Linked ' + count + ' rows in ' + t;
12916
- case 'unlink': return 'Unlinked ' + count + ' rows in ' + t;
12917
- default: return String(op || '') + ' ' + t;
13302
+ function feedGroupKey(ev) {
13303
+ var src = String(ev.source || '');
13304
+ if (isSchemaOp(ev.op)) {
13305
+ var a = schemaAction(ev.summary);
13306
+ return a ? 'schema|' + a + '|' + src : null;
12918
13307
  }
13308
+ return GROUPABLE_OPS[ev.op] ? String(ev.op) + '|' + src : null;
12919
13309
  }
12920
- function renderFeedItem(ev) {
12921
- var feedEl = document.getElementById('rail-feed');
12922
- if (!feedEl) return;
12923
- var empty = document.getElementById('rail-empty');
12924
- if (empty) empty.remove();
12925
- // Coalesce identical events (same op + table + source) into one counted
12926
- // bubble. Unlike the old consecutive-only rule, the bubble is found by key
12927
- // within a recency window, so interleaved bursts still merge instead of
12928
- // spamming a pill per event.
12929
- var groupKey = GROUPABLE_OPS[ev.op] && ev.table
12930
- ? String(ev.op) + '|' + String(ev.table) + '|' + String(ev.source || '')
12931
- : null;
12932
- var nowMs = Date.now();
12933
- if (groupKey) {
12934
- var g = feedGroups[groupKey];
12935
- if (g && g.item.parentNode === feedEl && (nowMs - g.last) < FEED_GROUP_WINDOW_MS) {
12936
- g.count += 1;
12937
- g.last = nowMs;
12938
- g.summaryEl.textContent = groupedSummary(ev.op, ev.table, g.count);
12939
- g.timeEl.textContent = relTime(ev.ts);
12940
- // A grouped bubble stands for many rows \u2014 disable the single-row click,
12941
- // and expose it to assistive tech as a live status, not a button.
12942
- g.item._rowClickOff = true;
12943
- g.item.classList.remove('feed-clickable');
12944
- g.item.removeAttribute('tabindex');
12945
- g.item.removeAttribute('title');
12946
- g.item.setAttribute('role', 'status');
12947
- feedEl.scrollTop = feedEl.scrollHeight;
12948
- return;
12949
- }
12950
- }
13310
+ var feedGroups = {}; // key -> { op, count, tables, tableCount, schemaKey, firstSummary, item, summaryEl, timeEl, last, startMs, endMs, turnId }
13311
+ var FEED_GROUP_WINDOW_MS = 15000;
13312
+ // Assistant-turn scope for live activity-card grouping + duration. While a
13313
+ // turn is active, its same-type events all collapse into one card (no window
13314
+ // expiry); the card's timer measures from feedTurnStartMs to the last event.
13315
+ var feedTurnId = 0;
13316
+ var feedTurnActive = false;
13317
+ var feedTurnStartMs = 0;
13318
+ function onlyKey(obj) { for (var k in obj) { if (obj.hasOwnProperty(k)) return k; } return ''; }
13319
+ function groupedRowSummary(op, count, tables, tableCount) {
13320
+ var verb = ROW_VERB[op] || String(op || '');
13321
+ var noun = count === 1 ? 'row' : 'rows';
13322
+ var where = '';
13323
+ if (tableCount > 1) { where = ' across ' + tableCount + ' tables'; }
13324
+ else { var only = onlyKey(tables); if (only) where = ' ' + (ROW_PREP[op] || 'in') + ' ' + only; }
13325
+ return verb + ' ' + count + ' ' + noun + where;
13326
+ }
13327
+ function schemaGroupSummary(schemaKey, count, firstSummary) {
13328
+ var g = SCHEMA_GROUP[schemaKey];
13329
+ if (count <= 1 || !g) return firstSummary || '';
13330
+ return g[0] + ' ' + count + ' ' + g[2];
13331
+ }
13332
+ function groupedSummary(g) {
13333
+ return isSchemaOp(g.op)
13334
+ ? schemaGroupSummary(g.schemaKey, g.count, g.firstSummary)
13335
+ : groupedRowSummary(g.op, g.count, g.tables, g.tableCount);
13336
+ }
13337
+ // While a chat turn is streaming, its typing bubble (the not-yet-arrived next
13338
+ // assistant message) must stay last; tool-driven activity cards belong ABOVE
13339
+ // it, not below \u2014 otherwise the "typing\u2026" dots land mid-conversation. Returns
13340
+ // the .chat-msg to insert before, or null when nothing is streaming.
13341
+ function feedTypingAnchor(feedEl) {
13342
+ var typing = feedEl.querySelector('.chat-bubble[data-typing="1"]');
13343
+ var msg = typing && typing.closest ? typing.closest('.chat-msg') : null;
13344
+ return (msg && msg.parentNode === feedEl) ? msg : null;
13345
+ }
13346
+ // Build one activity card (the shared full-width pill shape). Used by BOTH
13347
+ // the live feed and the per-thread replay so they look identical. Returns the
13348
+ // element plus the summary/time nodes a group mutates in place.
13349
+ function makeFeedCard(ev) {
12951
13350
  var item = document.createElement('div');
12952
13351
  item.className = 'feed-item';
12953
13352
  var icon = document.createElement('div');
12954
13353
  icon.className = 'feed-icon';
12955
- icon.textContent = FEED_ICONS[ev.op] || '\u2022';
13354
+ icon.textContent = feedIcon(ev.op);
12956
13355
  var body = document.createElement('div');
12957
13356
  body.className = 'feed-body';
12958
13357
  var summary = document.createElement('div');
@@ -12968,11 +13367,13 @@ var appJs = `
12968
13367
  body.appendChild(meta);
12969
13368
  var time = document.createElement('div');
12970
13369
  time.className = 'feed-time';
12971
- time.textContent = relTime(ev.ts);
13370
+ // Duration ("4s" / "4m 2s") is filled in by the caller once the group's
13371
+ // start/end span is known \u2014 not a relative "ago".
13372
+ time.textContent = '';
12972
13373
  item.appendChild(icon);
12973
13374
  item.appendChild(body);
12974
13375
  item.appendChild(time);
12975
- // Row events (insert/update/delete) carry a rowId \u2014 make the bubble a
13376
+ // Row events (insert/update/delete) carry a rowId \u2014 make the card a
12976
13377
  // shortcut to that object. Link/unlink and schema events have no single
12977
13378
  // row (rowId is null), so they stay non-clickable.
12978
13379
  if (ev.rowId && ev.table) {
@@ -12980,18 +13381,117 @@ var appJs = `
12980
13381
  item.setAttribute('role', 'button');
12981
13382
  item.setAttribute('tabindex', '0');
12982
13383
  item.title = 'Open this ' + String(ev.table);
12983
- // _rowClickOff is set when the bubble becomes a group \u2014 clicks no-op then.
13384
+ // _rowClickOff is set when the card becomes a group \u2014 clicks no-op then.
12984
13385
  var openRow = function () { if (item._rowClickOff) return; openSearchHit(String(ev.table), String(ev.rowId)); };
12985
13386
  item.addEventListener('click', openRow);
12986
13387
  item.addEventListener('keydown', function (e) {
12987
13388
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(); }
12988
13389
  });
12989
13390
  }
12990
- feedEl.appendChild(item);
13391
+ return { item: item, summaryEl: summary, timeEl: time };
13392
+ }
13393
+ // Fold another event into an existing group card: bump the count, track the
13394
+ // table, refresh the summary, and drop the single-row affordances (a grouped
13395
+ // card stands for many rows, so it's a status, not a clickable button).
13396
+ // The card timer shows the TASK DURATION (start \u2192 finish), not a relative
13397
+ // "ago": for a single op it's the time that op took; for a grouped run it's
13398
+ // from the first task's start to the last task's finish. startMs is anchored
13399
+ // to the assistant turn's start (so a one-event card still shows real time);
13400
+ // endMs tracks the latest event in the group.
13401
+ function setGroupTime(g) {
13402
+ if (g.timeEl) g.timeEl.textContent = formatElapsed(Math.max(0, g.endMs - g.startMs));
13403
+ }
13404
+ function applyGroupHit(g, ev, endMs) {
13405
+ g.count += 1;
13406
+ if (ev.table && !g.tables[ev.table]) { g.tables[ev.table] = 1; g.tableCount += 1; }
13407
+ if (typeof endMs === 'number' && endMs > g.endMs) g.endMs = endMs;
13408
+ g.summaryEl.textContent = groupedSummary(g);
13409
+ setGroupTime(g);
13410
+ g.item._rowClickOff = true;
13411
+ g.item.classList.remove('feed-clickable');
13412
+ g.item.removeAttribute('tabindex');
13413
+ g.item.removeAttribute('title');
13414
+ g.item.setAttribute('role', 'status');
13415
+ }
13416
+ function newGroup(ev, card, startMs, endMs) {
13417
+ var tbls = {}; var tc = 0;
13418
+ if (ev.table) { tbls[ev.table] = 1; tc = 1; }
13419
+ return {
13420
+ op: ev.op, count: 1, tables: tbls, tableCount: tc,
13421
+ schemaKey: isSchemaOp(ev.op) ? schemaAction(ev.summary) : null,
13422
+ firstSummary: ev.summary || '',
13423
+ item: card.item, summaryEl: card.summaryEl, timeEl: card.timeEl,
13424
+ startMs: startMs, endMs: endMs,
13425
+ };
13426
+ }
13427
+ function renderFeedItem(ev) {
13428
+ var feedEl = document.getElementById('rail-feed');
13429
+ if (!feedEl) return;
13430
+ var empty = document.getElementById('rail-empty');
13431
+ if (empty) empty.remove();
13432
+ // Coalesce same-TYPE events into one counted card within a recency window \u2014
13433
+ // even across different objects (op+source key, table excluded), so a bulk
13434
+ // run collapses to one card ("Removed 49 rows across 9 tables") instead of
13435
+ // spamming the rail. Distinct tables touched are tracked so a single-table
13436
+ // run still reads "\u2026 from <table>".
13437
+ var groupKey = feedGroupKey(ev);
13438
+ var nowMs = Date.now();
13439
+ if (groupKey) {
13440
+ var g = feedGroups[groupKey];
13441
+ // A group stays open to merge while: (a) we're inside the SAME assistant
13442
+ // turn that opened it \u2014 no time limit, so a slow bulk run (deleting many
13443
+ // tables against a remote DB) stays one card instead of splitting when a
13444
+ // 15s window lapses mid-run; or (b) outside a turn (manual edits / another
13445
+ // client), within the rolling window. Cross-turn events never merge.
13446
+ var open = g && g.item.parentNode === feedEl && (
13447
+ feedTurnActive ? (g.turnId === feedTurnId) : ((nowMs - g.last) < FEED_GROUP_WINDOW_MS)
13448
+ );
13449
+ if (open) {
13450
+ applyGroupHit(g, ev, nowMs);
13451
+ g.last = nowMs;
13452
+ feedEl.scrollTop = feedEl.scrollHeight;
13453
+ return;
13454
+ }
13455
+ }
13456
+ var card = makeFeedCard(ev);
13457
+ // Keep a streaming chat turn's typing bubble pinned to the bottom: insert
13458
+ // this card above it rather than appending below (the dots are the next
13459
+ // message, not done yet). No active turn \u2192 append as usual.
13460
+ var anchor = feedTypingAnchor(feedEl);
13461
+ if (anchor) feedEl.insertBefore(card.item, anchor); else feedEl.appendChild(card.item);
12991
13462
  feedEl.scrollTop = feedEl.scrollHeight;
12992
- // Register/refresh the group anchored on this bubble (groupable ops only).
13463
+ // Anchor the card's duration to the turn start (so even a single-op card
13464
+ // shows how long the task took); fall back to now for non-turn activity.
13465
+ var startMs = (feedTurnActive && feedTurnStartMs) ? feedTurnStartMs : nowMs;
12993
13466
  if (groupKey) {
12994
- feedGroups[groupKey] = { count: 1, item: item, summaryEl: summary, timeEl: time, last: nowMs };
13467
+ var grp = newGroup(ev, card, startMs, nowMs);
13468
+ grp.turnId = feedTurnId;
13469
+ grp.last = nowMs;
13470
+ feedGroups[groupKey] = grp;
13471
+ setGroupTime(grp);
13472
+ } else {
13473
+ card.timeEl.textContent = formatElapsed(Math.max(0, nowMs - startMs));
13474
+ }
13475
+ }
13476
+ // Replay a persisted assistant turn's data-change events as collapsed activity
13477
+ // cards. Grouping is PER-TURN (self-contained, independent of the live feed's
13478
+ // rolling window) so each turn's bulk run shows one card and stays tied to the
13479
+ // turn that produced it. Reads aren't persisted as events, so only mutations
13480
+ // appear. Appends in order; the caller positions them after the turn's text.
13481
+ function renderTurnEventCards(feedEl, events, startedMs) {
13482
+ if (!feedEl || !events || !events.length) return;
13483
+ var groups = {};
13484
+ for (var i = 0; i < events.length; i++) {
13485
+ var ev = events[i];
13486
+ var evMs = ev.ts ? new Date(ev.ts).getTime() : startedMs;
13487
+ if (typeof evMs !== 'number' || isNaN(evMs)) evMs = startedMs;
13488
+ var startMs = (typeof startedMs === 'number' && !isNaN(startedMs)) ? startedMs : evMs;
13489
+ var key = feedGroupKey(ev);
13490
+ if (key && groups[key]) { applyGroupHit(groups[key], ev, evMs); continue; }
13491
+ var card = makeFeedCard(ev);
13492
+ feedEl.appendChild(card.item);
13493
+ if (key) { var g = newGroup(ev, card, startMs, evMs); groups[key] = g; setGroupTime(g); }
13494
+ else { card.timeEl.textContent = formatElapsed(Math.max(0, evMs - startMs)); }
12995
13495
  }
12996
13496
  }
12997
13497
  function startFeed() {
@@ -13013,7 +13513,7 @@ var appJs = `
13013
13513
  // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
13014
13514
  // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
13015
13515
  // it shares that debounce with the realtime 'change' handler (no double
13016
- // 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.
13017
13517
  if (data && (data.table || data.op === 'schema')) {
13018
13518
  scheduleRealtimeRefresh();
13019
13519
  }
@@ -13076,12 +13576,13 @@ var appJs = `
13076
13576
  chatHistory = [];
13077
13577
  var feedEl = railFeedEl();
13078
13578
  if (!feedEl) return;
13079
- // Remove only the chat bubbles. The activity cards (.feed-item) are
13080
- // workspace-global, not part of any one conversation \u2014 loading, switching,
13081
- // or starting a conversation must NOT wipe them (they're backfilled once
13082
- // on connect). Otherwise auto-loading a thread on refresh erases the feed.
13083
- var msgs = feedEl.querySelectorAll('.chat-msg');
13084
- for (var i = 0; i < msgs.length; i++) msgs[i].remove();
13579
+ // The rail is conversation-scoped: clearing or switching a conversation
13580
+ // drops both its chat bubbles AND its activity cards (each conversation
13581
+ // replays its own data-change cards from the persisted per-turn events).
13582
+ // Reset the grouping anchors so a freshly loaded thread starts clean.
13583
+ var nodes = feedEl.querySelectorAll('.chat-msg, .feed-item');
13584
+ for (var i = 0; i < nodes.length; i++) nodes[i].remove();
13585
+ feedGroups = {};
13085
13586
  // Restore the empty hint only when the rail is now completely empty.
13086
13587
  if (!feedEl.firstElementChild) {
13087
13588
  feedEl.innerHTML = '<div class="rail-empty" id="rail-empty">No activity yet. Changes you make will appear here.</div>';
@@ -13134,11 +13635,12 @@ var appJs = `
13134
13635
  msgs.forEach(function (m) {
13135
13636
  if (m.role === 'user') { appendUserBubble(m.text); chatHistory.push({ role: 'user', text: m.text }); }
13136
13637
  else if (m.role === 'assistant') {
13137
- // Rich replay: the saved per-turn structure (text + tool pills),
13138
- // matching the live stream. Falls back to a plain text bubble for
13139
- // messages saved before turns were persisted.
13140
- if (Array.isArray(m.turns) && m.turns.length > 0) { m.turns.forEach(appendAssistantTurn); }
13141
- else { var c = newAssistantBubble(); setBubbleText(c, m.text); }
13638
+ // Rich replay: the saved per-turn structure (text + the data-change
13639
+ // activity cards it produced), matching the live stream. Falls back to
13640
+ // a plain text bubble for messages saved before turns were persisted.
13641
+ if (Array.isArray(m.turns) && m.turns.length > 0) {
13642
+ m.turns.forEach(function (t) { appendAssistantTurn(t, m.created_at, m.startedAt); });
13643
+ } else { var c = newAssistantBubble(); setBubbleText(c, m.text); }
13142
13644
  chatHistory.push({ role: 'assistant', text: m.text });
13143
13645
  }
13144
13646
  });
@@ -13162,126 +13664,88 @@ var appJs = `
13162
13664
  railEmptyGone();
13163
13665
  var feedEl = railFeedEl();
13164
13666
  var msg = document.createElement('div'); msg.className = 'chat-msg assistant';
13165
- var wrap = document.createElement('div');
13166
- var tools = document.createElement('div'); tools.className = 'chat-tools';
13167
13667
  var b = document.createElement('div'); b.className = 'chat-bubble assistant';
13168
13668
  // Show an animated typing indicator until the first text delta arrives.
13169
13669
  b.innerHTML = '<span class="chat-typing"><i></i><i></i><i></i></span>';
13170
13670
  b.setAttribute('data-typing', '1');
13171
- wrap.appendChild(tools); wrap.appendChild(b);
13172
- msg.appendChild(wrap); feedEl.appendChild(msg); feedEl.scrollTop = feedEl.scrollHeight;
13173
- // lastTool anchors the current run of identical tool calls so consecutive
13174
- // same-name calls coalesce into one counted pill (see addToolPill).
13175
- return { bubble: b, tools: tools, pills: {}, lastTool: null, msg: msg };
13671
+ msg.appendChild(b); feedEl.appendChild(msg); feedEl.scrollTop = feedEl.scrollHeight;
13672
+ return { bubble: b, msg: msg };
13176
13673
  }
13177
13674
  /** Set an assistant bubble's text, clearing the typing indicator. */
13675
+ // Turn [label](lattice://table/id) object references the assistant emits into
13676
+ // clickable pills that open the row (mode-aware, via openSearchHit). The
13677
+ // links are pulled out into placeholders BEFORE markdown rendering and the
13678
+ // pill HTML is swapped back in AFTER \u2014 so it's independent of mdToHtml's own
13679
+ // link handling and survives HTML-escaping. Labels/ids are re-escaped.
13680
+ function renderAssistantHtml(text) {
13681
+ var pills = [];
13682
+ // U+0002 sentinel survives mdToHtml's escape + inline passes untouched.
13683
+ // Use a unicode-escape string literal for insertion and a REGEX LITERAL for
13684
+ // the swap (one escaping level each) \u2014 a new RegExp('(\\d+)') here would be
13685
+ // double-collapsed by the template literal into a literal "d", silently
13686
+ // breaking the swap (the pill rendered as a bare index).
13687
+ var pre = String(text == null ? '' : text).replace(
13688
+ /\\[([^\\]]+)\\]\\(lattice:\\/\\/([a-zA-Z0-9_]+)\\/([^)\\s]+)\\)/g,
13689
+ function (_, label, table, id) {
13690
+ pills.push({ label: label, table: table, id: id });
13691
+ return '\\u0002' + (pills.length - 1) + '\\u0002';
13692
+ }
13693
+ );
13694
+ var html = mdToHtml(pre);
13695
+ return html.replace(/\\u0002([0-9]+)\\u0002/g, function (_, n) {
13696
+ var p = pills[Number(n)];
13697
+ return '<a class="chip chip-link lattice-ref" data-table="' + escapeHtml(p.table) +
13698
+ '" data-id="' + escapeHtml(p.id) + '" title="Open this ' + escapeHtml(p.table) + '">\u{1F517} ' +
13699
+ escapeHtml(p.label) + '</a>';
13700
+ });
13701
+ }
13702
+ // One delegated click handler on the rail feed: a lattice-ref pill opens its
13703
+ // object through the same mode-aware navigator the activity feed uses.
13704
+ var _latticeRefWired = false;
13705
+ function ensureLatticeRefHandler() {
13706
+ if (_latticeRefWired) return;
13707
+ var feedEl = document.getElementById('rail-feed');
13708
+ if (!feedEl) return;
13709
+ feedEl.addEventListener('click', function (e) {
13710
+ var a = e.target && e.target.closest ? e.target.closest('.lattice-ref') : null;
13711
+ if (!a) return;
13712
+ e.preventDefault();
13713
+ openSearchHit(a.getAttribute('data-table'), a.getAttribute('data-id'));
13714
+ });
13715
+ _latticeRefWired = true;
13716
+ }
13178
13717
  function setBubbleText(ctx, text) {
13179
13718
  if (!ctx || !ctx.bubble) return; // bubble may have been finalized/removed
13180
13719
  ctx.bubble.removeAttribute('data-typing');
13181
13720
  // Assistant turns are Markdown; render (input is HTML-escaped inside
13182
- // mdToHtml first, so this is injection-safe).
13183
- ctx.bubble.innerHTML = mdToHtml(text);
13721
+ // mdToHtml first, so this is injection-safe) + linkify object references.
13722
+ ctx.bubble.innerHTML = renderAssistantHtml(text);
13723
+ ensureLatticeRefHandler();
13184
13724
  }
13185
13725
  /**
13186
- * A turn ended. If its bubble never got text (still showing the typing
13187
- * indicator), drop the empty bubble \u2014 keeping any tool pills it fired, or
13188
- * removing the whole message when there were none. Stops a dangling
13189
- * "typing\u2026" bubble after the stream completes.
13726
+ * A turn ended still showing the typing indicator (no text streamed) \u2014 drop
13727
+ * the empty bubble. The turn's data-change activity cards live in the rail
13728
+ * feed independently (not inside the message), so they remain.
13190
13729
  */
13191
13730
  function finalizeBubble(ctx) {
13192
13731
  if (!ctx || !ctx.bubble || !ctx.bubble.getAttribute('data-typing')) return;
13193
- if (ctx.tools && ctx.tools.children.length > 0) ctx.bubble.remove();
13194
- else if (ctx.msg) ctx.msg.remove();
13195
- }
13196
- var TOOL_VERBS = {
13197
- create_row: ['Creating row', 'Row created', 'Could not create row'],
13198
- update_row: ['Updating row', 'Row updated', 'Could not update row'],
13199
- delete_row: ['Deleting row', 'Row deleted', 'Could not delete row'],
13200
- list_rows: ['Listing rows', 'Listed rows', 'Could not list rows'],
13201
- get_row: ['Fetching row', 'Fetched row', 'Could not fetch row'],
13202
- list_entities: ['Listing tables', 'Listed tables', 'Could not list tables']
13203
- };
13204
- // Grouped (count > 1) [gerund, past, noun] so a run of identical tool calls
13205
- // collapses into ONE counted pill \u2014 "Listed 5 rows" \u2014 instead of N identical
13206
- // "Listed rows" pills. Mirrors the activity feed's groupedSummary() coalescing.
13207
- var TOOL_GROUP = {
13208
- create_row: ['Creating', 'Created', 'rows'],
13209
- update_row: ['Updating', 'Updated', 'rows'],
13210
- delete_row: ['Deleting', 'Deleted', 'rows'],
13211
- list_rows: ['Listing', 'Listed', 'rows'],
13212
- get_row: ['Fetching', 'Fetched', 'rows'],
13213
- list_entities: ['Listing', 'Listed', 'tables']
13214
- };
13215
- function toolLabel(name, state) {
13216
- var v = TOOL_VERBS[name] || [name, name, name];
13217
- return state === 'pending' ? v[0] + '\u2026' : (state === 'error' ? v[2] : v[1]);
13218
- }
13219
- // Label for a run of "count" identical calls. count <= 1 falls back to the
13220
- // single-call label so a lone pill reads exactly as before.
13221
- function toolGroupLabel(name, count, state) {
13222
- if (count <= 1) return toolLabel(name, state);
13223
- var g = TOOL_GROUP[name];
13224
- if (!g) return toolLabel(name, state) + ' \xD7' + count; // unknown tool: stay honest
13225
- var verb = state === 'pending' ? g[0] : g[1];
13226
- return verb + ' ' + count + ' ' + g[2] + (state === 'pending' ? '\u2026' : '');
13227
- }
13228
- // Paint a (possibly grouped) pill from its live counts: spinner while any call
13229
- // is still running, then \u2713 (or \u26A0 if any errored) once every call resolves.
13230
- function paintToolPill(g) {
13231
- var pending = g.pending > 0;
13232
- var err = !pending && g.error > 0;
13233
- if (pending) {
13234
- g.el.className = 'tool-pill';
13235
- g.el.innerHTML = '<span class="spin"></span>' + escapeHtml(toolGroupLabel(g.name, g.total, 'pending'));
13236
- } else {
13237
- g.el.className = 'tool-pill ' + (err ? 'error' : 'done');
13238
- g.el.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(g.name, g.total, err ? 'error' : 'done');
13239
- }
13240
- }
13241
- function addToolPill(ctx, id, name) {
13242
- // Coalesce a run of the same tool within this turn's pill row into one
13243
- // counted pill (the model emits several list_rows in a single turn).
13244
- var g = ctx.lastTool;
13245
- if (g && g.name === name) {
13246
- g.total += 1; g.pending += 1;
13247
- } else {
13248
- var pill = document.createElement('span'); pill.className = 'tool-pill';
13249
- ctx.tools.appendChild(pill);
13250
- g = { name: name, el: pill, total: 1, pending: 1, error: 0 };
13251
- ctx.lastTool = g;
13252
- }
13253
- ctx.pills[id] = g; // resolveToolPill maps the tool-use id back to its group
13254
- paintToolPill(g);
13255
- }
13256
- function resolveToolPill(ctx, id, isError) {
13257
- var g = ctx.pills[id]; if (!g) return;
13258
- if (g.pending > 0) g.pending -= 1;
13259
- if (isError) g.error += 1;
13260
- paintToolPill(g);
13732
+ if (ctx.msg) ctx.msg.remove();
13261
13733
  }
13262
- /**
13263
- * Append already-resolved pills for a replayed turn, collapsing consecutive
13264
- * identical tools into one counted pill (matching the live grouping above).
13265
- */
13266
- function renderResolvedPills(ctx, tools) {
13267
- var i = 0;
13268
- while (i < tools.length) {
13269
- var name = tools[i].name, j = i, errors = 0;
13270
- while (j < tools.length && tools[j].name === name) { if (tools[j].isError) errors += 1; j++; }
13271
- var count = j - i, err = errors > 0;
13272
- var pill = document.createElement('span');
13273
- pill.className = 'tool-pill ' + (err ? 'error' : 'done');
13274
- pill.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(name, count, err ? 'error' : 'done');
13275
- ctx.tools.appendChild(pill);
13276
- i = j;
13277
- }
13278
- }
13279
- /** Replay one persisted assistant turn: its tool pills + text bubble. */
13280
- function appendAssistantTurn(turn) {
13734
+ /** Replay one persisted assistant turn: its text bubble + the data-change
13735
+ * activity cards it produced (collapsed, per-turn). Reads aren't persisted
13736
+ * as events, so a read-only turn with no text renders nothing. createdAt
13737
+ * stamps the cards' relative time (events carry no ts of their own). */
13738
+ function appendAssistantTurn(turn, createdAt, startedAt) {
13281
13739
  var ctx = newAssistantBubble();
13282
- renderResolvedPills(ctx, turn.tools || []);
13283
13740
  if (turn.text) setBubbleText(ctx, turn.text);
13284
- else finalizeBubble(ctx); // tool-only turn: drop the empty bubble, keep pills
13741
+ else finalizeBubble(ctx); // no text \u2192 drop the empty typing bubble
13742
+ var events = (turn.events || []).map(function (e) {
13743
+ return e.ts ? e : { op: e.op, table: e.table, rowId: e.rowId, summary: e.summary, source: e.source || 'ai', ts: createdAt };
13744
+ });
13745
+ // Task start for the duration timer: the persisted turn-start, else the
13746
+ // message time. Per-event ts (above) gives the run's finish.
13747
+ var startedMs = new Date(startedAt || createdAt || 0).getTime();
13748
+ renderTurnEventCards(railFeedEl(), events, startedMs);
13285
13749
  }
13286
13750
  function parseSse(buffer, onEvent) {
13287
13751
  var sep;
@@ -13297,6 +13761,11 @@ var appJs = `
13297
13761
  function sendChat(text) {
13298
13762
  if (chatBusy || !text) return;
13299
13763
  chatBusy = true;
13764
+ // Open a fresh turn scope: this turn's activity cards group together (no
13765
+ // window expiry) and their timers measure from now.
13766
+ feedTurnId += 1;
13767
+ feedTurnStartMs = Date.now();
13768
+ feedTurnActive = true;
13300
13769
  appendUserBubble(text);
13301
13770
  var historyToSend = chatHistory.slice();
13302
13771
  chatHistory.push({ role: 'user', text: text });
@@ -13323,8 +13792,10 @@ var appJs = `
13323
13792
  buf = parseSse(buf, function (ev) {
13324
13793
  if (ev.type === 'assistant_message_start') { finalizeBubble(actx); actx = newAssistantBubble(); assembled = ''; }
13325
13794
  else if (ev.type === 'text_delta' && actx) { assembled += ev.delta; setBubbleText(actx, assembled); railFeedEl().scrollTop = railFeedEl().scrollHeight; }
13326
- else if (ev.type === 'tool_use' && actx) { addToolPill(actx, ev.id, ev.name); }
13327
- else if (ev.type === 'tool_result' && actx) { resolveToolPill(actx, ev.toolUseId, ev.isError); }
13795
+ // tool_use / tool_result are no longer painted as inline pills \u2014 the
13796
+ // assistant's data changes stream in as activity cards over the feed
13797
+ // SSE (renderFeedItem), which sit above the typing bubble. Reads emit
13798
+ // no card by design (only data changes show).
13328
13799
  else if (ev.type === 'warn') { finalizeBubble(actx); var wb = newAssistantBubble(); setBubbleText(wb, '\u26A0 ' + ev.message); actx = null; }
13329
13800
  else if (ev.type === 'error') { if (!actx) actx = newAssistantBubble(); setBubbleText(actx, (assembled ? assembled + '\\n' : '') + '\u26A0 ' + ev.message); }
13330
13801
  });
@@ -13341,6 +13812,9 @@ var appJs = `
13341
13812
  var c = newAssistantBubble(); setBubbleText(c, '\u26A0 ' + e.message);
13342
13813
  }).finally(function () {
13343
13814
  chatBusy = false;
13815
+ // Close the turn scope: later activity starts fresh cards (the next turn,
13816
+ // or manual edits via the rolling window).
13817
+ feedTurnActive = false;
13344
13818
  var sb = document.getElementById('chat-send'); if (sb) sb.disabled = false;
13345
13819
  var inp = document.getElementById('chat-input'); if (inp) inp.focus();
13346
13820
  });
@@ -13476,7 +13950,10 @@ var appJs = `
13476
13950
  '<div class="feed-icon"><span class="feed-spinner"></span></div>' +
13477
13951
  '<div class="feed-body"><div class="feed-summary">Analyzing ' + escapeHtml(label) + '\u2026</div></div>' +
13478
13952
  '<div class="feed-time">0s</div>';
13479
- feedEl.appendChild(item);
13953
+ // Same bottom-pin rule as renderFeedItem: don't bury a streaming chat
13954
+ // turn's typing bubble beneath this card.
13955
+ var anchor = feedTypingAnchor(feedEl);
13956
+ if (anchor) feedEl.insertBefore(item, anchor); else feedEl.appendChild(item);
13480
13957
  feedEl.scrollTop = feedEl.scrollHeight;
13481
13958
  // Live elapsed-time counter while the upload + server-side extraction run.
13482
13959
  // Previously the time element was left empty (rendered as a stuck "0s")
@@ -13682,6 +14159,10 @@ var guiAppHtml = `<!doctype html>
13682
14159
  </svg>
13683
14160
  </button>
13684
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>
13685
14166
  <div class="layout">
13686
14167
  <nav class="sidebar">
13687
14168
  <label class="sidebar-advanced toggle" title="Advanced mode \u2014 row/table editor instead of the file workspace">
@@ -13876,7 +14357,13 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
13876
14357
  // Client-generated idempotency key for offline replay: a queued edit
13877
14358
  // carries a stable edit_id, so re-sending it after a reconnect is a
13878
14359
  // no-op rather than a duplicate write. Nullable + additive.
13879
- 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"
13880
14367
  },
13881
14368
  render: () => "",
13882
14369
  outputFile: ".lattice-teams/change-log.md"
@@ -13892,6 +14379,44 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
13892
14379
  primaryKey: ["team_id", "table_name", "pk"],
13893
14380
  render: () => "",
13894
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"
13895
14420
  }
13896
14421
  };
13897
14422
  var LOCAL_INTERNAL_TABLE_DEFS = {
@@ -13992,47 +14517,382 @@ async function installCloudInternalTriggers(db) {
13992
14517
  };
13993
14518
  await db.migrate([migration]);
13994
14519
  }
13995
-
13996
- // src/teams/server/auth.ts
13997
- import { createHash as createHash2, randomBytes as randomBytes4, timingSafeEqual } from "crypto";
13998
- var TOKEN_PREFIX = "lat_";
13999
- var INVITE_PREFIX = "latinv_";
14000
- var TOKEN_BYTES = 32;
14001
- var INVITE_BYTES = 24;
14002
- function extractBearer(req) {
14003
- const header = req.headers.authorization;
14004
- if (typeof header !== "string") return null;
14005
- const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
14006
- if (!match) return null;
14007
- const token = match[1];
14008
- if (!token?.startsWith(TOKEN_PREFIX)) return null;
14009
- return token;
14010
- }
14011
- function hashToken(rawToken) {
14012
- return createHash2("sha256").update(rawToken).digest("hex");
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);
14013
14561
  }
14014
- function generateToken() {
14015
- const raw = `${TOKEN_PREFIX}${randomBytes4(TOKEN_BYTES).toString("hex")}`;
14016
- return { raw, hash: hashToken(raw) };
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";
14017
14580
  }
14018
- function generateInviteToken() {
14019
- const raw = `${INVITE_PREFIX}${randomBytes4(INVITE_BYTES).toString("hex")}`;
14020
- return { raw, hash: hashToken(raw) };
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";
14021
14591
  }
14022
- async function authenticate(req, db) {
14023
- const raw = extractBearer(req);
14024
- if (!raw) return null;
14025
- const incomingHash = hashToken(raw);
14026
- const rows = await db.query("__lattice_api_tokens", {
14592
+ async function tableOwner(db, teamId, table) {
14593
+ const owners = await db.query("__lattice_object_owners", {
14027
14594
  filters: [
14028
- { col: "token_hash", op: "eq", val: incomingHash },
14029
- { col: "revoked_at", op: "isNull" }
14595
+ { col: "team_id", op: "eq", val: teamId },
14596
+ { col: "table_name", op: "eq", val: table }
14030
14597
  ],
14031
14598
  limit: 1
14032
14599
  });
14033
- const tokenRow = rows[0];
14034
- if (!tokenRow) return null;
14035
- const storedBuf = Buffer.from(tokenRow.token_hash, "hex");
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
+ }
14855
+
14856
+ // src/teams/server/auth.ts
14857
+ import { createHash as createHash2, randomBytes as randomBytes4, timingSafeEqual } from "crypto";
14858
+ var TOKEN_PREFIX = "lat_";
14859
+ var INVITE_PREFIX = "latinv_";
14860
+ var TOKEN_BYTES = 32;
14861
+ var INVITE_BYTES = 24;
14862
+ function extractBearer(req) {
14863
+ const header = req.headers.authorization;
14864
+ if (typeof header !== "string") return null;
14865
+ const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
14866
+ if (!match) return null;
14867
+ const token = match[1];
14868
+ if (!token?.startsWith(TOKEN_PREFIX)) return null;
14869
+ return token;
14870
+ }
14871
+ function hashToken(rawToken) {
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");
14036
14896
  const incomingBuf = Buffer.from(incomingHash, "hex");
14037
14897
  if (storedBuf.length !== incomingBuf.length || !timingSafeEqual(storedBuf, incomingBuf)) {
14038
14898
  return null;
@@ -14052,81 +14912,6 @@ async function authenticate(req, db) {
14052
14912
  function isPostgresUrl(url) {
14053
14913
  return /^postgres(ql)?:\/\//i.test(url);
14054
14914
  }
14055
- async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
14056
- if (!isPostgresUrl(cloudUrl)) {
14057
- throw new Error(
14058
- `registerDirectViaPostgres: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
14059
- );
14060
- }
14061
- const db = new Lattice(cloudUrl);
14062
- try {
14063
- await db.init();
14064
- for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
14065
- await db.defineLate(table, def);
14066
- }
14067
- const existing = await db.query("__lattice_users", {
14068
- filters: [{ col: "deleted_at", op: "isNull" }],
14069
- limit: 1
14070
- });
14071
- if (existing.length > 0) {
14072
- throw new Error(
14073
- "Registration is disabled. This cloud already has users \u2014 join via invitation."
14074
- );
14075
- }
14076
- let identity = null;
14077
- try {
14078
- identity = await db.get("__lattice_team_identity", "singleton");
14079
- } catch {
14080
- identity = null;
14081
- }
14082
- if (identity) {
14083
- throw new Error("This cloud already has a team. Use Connect to existing cloud instead.");
14084
- }
14085
- const now = (/* @__PURE__ */ new Date()).toISOString();
14086
- const userId = await db.insert("__lattice_users", {
14087
- email,
14088
- name,
14089
- created_at: now,
14090
- updated_at: now
14091
- });
14092
- const { raw, hash } = generateToken();
14093
- await db.insert("__lattice_api_tokens", {
14094
- user_id: userId,
14095
- token_hash: hash,
14096
- name: `creator:${teamName}`,
14097
- created_at: now
14098
- });
14099
- const teamId = await db.insert("__lattice_team", {
14100
- name: teamName,
14101
- created_by_user_id: userId,
14102
- created_at: now,
14103
- updated_at: now
14104
- });
14105
- await db.insert("__lattice_team_members", {
14106
- team_id: teamId,
14107
- user_id: userId,
14108
- role: "creator",
14109
- joined_at: now
14110
- });
14111
- await db.insert("__lattice_team_identity", {
14112
- id: "singleton",
14113
- team_id: teamId,
14114
- team_name: teamName,
14115
- creator_email: email,
14116
- created_at: now
14117
- });
14118
- return {
14119
- user: { id: userId, email, name },
14120
- raw_token: raw,
14121
- team: { id: teamId, name: teamName, role: "creator" }
14122
- };
14123
- } finally {
14124
- try {
14125
- db.close();
14126
- } catch {
14127
- }
14128
- }
14129
- }
14130
14915
 
14131
14916
  // src/teams/schema-spec.ts
14132
14917
  var TYPE_MAP = {
@@ -14397,7 +15182,8 @@ async function appendChangeEnvelope(db, entry) {
14397
15182
  owner_user_id: entry.owner_user_id ?? null,
14398
15183
  created_at: now,
14399
15184
  client_ts: entry.client_ts ?? now,
14400
- edit_id: entry.edit_id ?? null
15185
+ edit_id: entry.edit_id ?? null,
15186
+ recipient_user_id: entry.recipient_user_id ?? null
14401
15187
  });
14402
15188
  return seq;
14403
15189
  }
@@ -14447,7 +15233,14 @@ async function shareObject(db, teamId, createdByUserId, table, spec) {
14447
15233
  created_by_user_id: createdByUserId,
14448
15234
  created_at: prior?.created_at ?? now,
14449
15235
  updated_at: now,
14450
- 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"
14451
15244
  });
14452
15245
  }
14453
15246
  await applySchemaSpec(db, table, outSpec);
@@ -14541,88 +15334,26 @@ async function destroyTeamDirect(db) {
14541
15334
  }
14542
15335
  await db.delete("__lattice_team_identity", "singleton");
14543
15336
  }
14544
- async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
14545
- if (!isPostgresUrl(cloudUrl)) {
14546
- throw new Error(
14547
- `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
14548
- );
14549
- }
14550
- const db = new Lattice(cloudUrl);
14551
- try {
14552
- await db.init();
14553
- for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
14554
- await db.defineLate(table, def);
14555
- }
14556
- await installCloudInternalTriggers(db);
14557
- const invites = await db.query("__lattice_invitations", {
14558
- filters: [
14559
- { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
14560
- { col: "redeemed_at", op: "isNull" }
14561
- ],
14562
- limit: 1
14563
- });
14564
- const invite = invites[0];
14565
- if (!invite) {
14566
- throw new Error("Invitation invalid or already used");
14567
- }
14568
- if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
14569
- throw new Error("Invitation expired");
14570
- }
14571
- if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
14572
- throw new Error("Invitation is addressed to a different email");
14573
- }
14574
- const team = await db.get("__lattice_team", invite.team_id);
14575
- if (!team || team.deleted_at) {
14576
- throw new Error("Team no longer exists");
14577
- }
14578
- const now = (/* @__PURE__ */ new Date()).toISOString();
14579
- const userId = await db.insert("__lattice_users", {
14580
- email,
14581
- name,
14582
- created_at: now,
14583
- updated_at: now
14584
- });
14585
- await db.insert("__lattice_team_members", {
14586
- team_id: invite.team_id,
14587
- user_id: userId,
14588
- role: "member",
14589
- joined_at: now
14590
- });
14591
- const { raw, hash } = generateToken();
14592
- await db.insert("__lattice_api_tokens", {
14593
- user_id: userId,
14594
- token_hash: hash,
14595
- name: `invited:${team.name}`,
14596
- created_at: now
14597
- });
14598
- await db.update("__lattice_invitations", invite.id, {
14599
- redeemed_at: now,
14600
- redeemed_by_user_id: userId
14601
- });
14602
- return {
14603
- user: { id: userId, email, name },
14604
- raw_token: raw,
14605
- team: { id: team.id, name: team.name }
14606
- };
14607
- } finally {
14608
- try {
14609
- db.close();
14610
- } catch {
14611
- }
14612
- }
14613
- }
15337
+ var directDeprecationWarned = false;
14614
15338
  async function openCloud(cloudUrl) {
14615
15339
  if (!isPostgresUrl(cloudUrl)) {
14616
15340
  throw new Error(
14617
15341
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
14618
15342
  );
14619
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
+ }
14620
15350
  const db = new Lattice(cloudUrl);
14621
15351
  await db.init();
14622
15352
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
14623
15353
  await db.defineLate(table, def);
14624
15354
  }
14625
15355
  await installCloudInternalTriggers(db);
15356
+ await installRowPermsSchema(db);
14626
15357
  return db;
14627
15358
  }
14628
15359
  function closeQuiet(db) {
@@ -15103,6 +15834,23 @@ var FeedBus = class {
15103
15834
  }
15104
15835
  };
15105
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
+
15106
15854
  // src/gui/mutations.ts
15107
15855
  function rowLabel2(row) {
15108
15856
  if (!row || typeof row !== "object") return null;
@@ -15232,6 +15980,10 @@ async function emitTeamEnvelope(ctx, table, pk, op, after) {
15232
15980
  async function createRow(ctx, table, values) {
15233
15981
  const id = await ctx.db.insert(table, values);
15234
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
+ }
15235
15987
  await appendAudit(ctx.db, ctx.feed, table, id, "insert", null, row, ctx.source, ctx.sessionId);
15236
15988
  await emitTeamEnvelope(ctx, table, id, "upsert", row);
15237
15989
  return { id, row };
@@ -15255,6 +16007,9 @@ async function updateRow(ctx, table, id, values) {
15255
16007
  if (before === null) {
15256
16008
  throw new Error(`Cannot update "${table}": no row with id "${id}"`);
15257
16009
  }
16010
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
16011
+ throw new RowAccessError();
16012
+ }
15258
16013
  await ctx.db.update(table, id, values);
15259
16014
  const after = await ctx.db.get(table, id);
15260
16015
  if (after != null) {
@@ -15284,6 +16039,9 @@ async function deleteRow(ctx, table, id, hard) {
15284
16039
  if (before === null) {
15285
16040
  throw new Error(`Cannot delete from "${table}": no row with id "${id}"`);
15286
16041
  }
16042
+ if (ctx.team && !await canAccessRow(ctx.db, ctx.team.teamId, table, id, ctx.team.myUserId)) {
16043
+ throw new RowAccessError();
16044
+ }
15287
16045
  if (!hard && ctx.softDeletable.has(table)) {
15288
16046
  await ctx.db.update(table, id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
15289
16047
  const after = await ctx.db.get(table, id);
@@ -16340,25 +17098,30 @@ async function autoUnlinkUserRows(db, teamId, userId) {
16340
17098
  ]
16341
17099
  });
16342
17100
  for (const link of links) {
16343
- await db.delete("__lattice_row_links", {
16344
- team_id: link.team_id,
16345
- table_name: link.table_name,
16346
- pk: link.pk
16347
- });
16348
- try {
16349
- await db.delete(link.table_name, link.pk);
16350
- } catch {
16351
- }
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) {
16352
17114
  await appendChangeEnvelope(db, {
16353
- team_id: link.team_id,
16354
- table_name: link.table_name,
16355
- pk: link.pk,
17115
+ team_id: teamId,
17116
+ table_name: tableName,
17117
+ pk,
16356
17118
  op: "unlink",
16357
17119
  payload_json: null,
16358
- owner_user_id: link.owner_user_id
17120
+ owner_user_id: ownerUserId,
17121
+ recipient_user_id: uid
16359
17122
  });
16360
17123
  }
16361
- return links.length;
17124
+ await deleteRowAcl(db, teamId, tableName, pk);
16362
17125
  }
16363
17126
  async function isObjectShared(db, teamId, tableName) {
16364
17127
  const rows = await db.query("__lattice_shared_objects", {
@@ -16474,15 +17237,12 @@ async function handleListChanges(res, ctx, teamId, params) {
16474
17237
  const since = sinceRaw !== null && /^\d+$/.test(sinceRaw) ? Number(sinceRaw) : 0;
16475
17238
  const limitParsed = limitRaw !== null && /^\d+$/.test(limitRaw) ? Number(limitRaw) : 500;
16476
17239
  const limit = Math.min(Math.max(limitParsed, 1), 1e3);
16477
- const rows = await ctx.db.query("__lattice_change_log", {
16478
- filters: [
16479
- { col: "team_id", op: "eq", val: teamId },
16480
- { col: "seq", op: "gt", val: since }
16481
- ],
16482
- orderBy: "seq",
16483
- orderDir: "asc",
17240
+ const rows = await ctx.db.listChangesForRecipient(
17241
+ teamId,
17242
+ since,
17243
+ ctx.authContext.user.id,
16484
17244
  limit
16485
- });
17245
+ );
16486
17246
  const envelopes = rows.map((r) => ({
16487
17247
  seq: r.seq,
16488
17248
  table_name: r.table_name,
@@ -16544,6 +17304,10 @@ async function handleLinkRow(req, res, ctx, teamId, tableName) {
16544
17304
  });
16545
17305
  }
16546
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
+ }
16547
17311
  await appendChangeEnvelope(ctx.db, {
16548
17312
  team_id: teamId,
16549
17313
  table_name: tableName,
@@ -16589,23 +17353,7 @@ async function handleUnlinkRow(res, ctx, teamId, tableName, pk) {
16589
17353
  sendJson2(res, { error: "Only the row owner or team creator can unlink" }, 403);
16590
17354
  return;
16591
17355
  }
16592
- await ctx.db.delete("__lattice_row_links", {
16593
- team_id: teamId,
16594
- table_name: tableName,
16595
- pk
16596
- });
16597
- try {
16598
- await ctx.db.delete(tableName, pk);
16599
- } catch {
16600
- }
16601
- await appendChangeEnvelope(ctx.db, {
16602
- team_id: teamId,
16603
- table_name: tableName,
16604
- pk,
16605
- op: "unlink",
16606
- payload_json: null,
16607
- owner_user_id: link.owner_user_id
16608
- });
17356
+ await tearDownSharedRow(ctx.db, teamId, tableName, pk, link.owner_user_id);
16609
17357
  sendJson2(res, { ok: true });
16610
17358
  }
16611
17359
  async function handlePushRow(req, res, ctx, teamId, tableName) {
@@ -16676,23 +17424,7 @@ async function handleDeleteRow(res, ctx, teamId, tableName, pk) {
16676
17424
  sendJson2(res, { error: "Only the row owner can delete the row" }, 403);
16677
17425
  return;
16678
17426
  }
16679
- await ctx.db.delete("__lattice_row_links", {
16680
- team_id: teamId,
16681
- table_name: tableName,
16682
- pk
16683
- });
16684
- try {
16685
- await ctx.db.delete(tableName, pk);
16686
- } catch {
16687
- }
16688
- await appendChangeEnvelope(ctx.db, {
16689
- team_id: teamId,
16690
- table_name: tableName,
16691
- pk,
16692
- op: "unlink",
16693
- payload_json: null,
16694
- owner_user_id: link.owner_user_id
16695
- });
17427
+ await tearDownSharedRow(ctx.db, teamId, tableName, pk, link.owner_user_id);
16696
17428
  sendJson2(res, { ok: true });
16697
17429
  }
16698
17430
 
@@ -16742,6 +17474,7 @@ async function probeCloud(targetUrl) {
16742
17474
  }
16743
17475
 
16744
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.";
16745
17478
  var TeamsClient = class {
16746
17479
  constructor(local) {
16747
17480
  this.local = local;
@@ -16774,6 +17507,9 @@ var TeamsClient = class {
16774
17507
  * members join via `redeemInvite`). Returns the new user + bearer
16775
17508
  * token + team summary so the caller can immediately save a
16776
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).
16777
17513
  */
16778
17514
  async register(cloudUrl, email, name, teamName) {
16779
17515
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/register", {
@@ -16784,7 +17520,7 @@ var TeamsClient = class {
16784
17520
  }
16785
17521
  async redeemInvite(cloudUrl, inviteToken, email, name) {
16786
17522
  if (isPostgresUrl(cloudUrl)) {
16787
- return redeemInviteDirect(cloudUrl, inviteToken, email, name);
17523
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
16788
17524
  }
16789
17525
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
16790
17526
  invite_token: inviteToken,
@@ -16795,9 +17531,11 @@ var TeamsClient = class {
16795
17531
  // ── High-level orchestration (v1.13+) ───────────────────────────────────
16796
17532
  // Wraps the multi-step flows the GUI's Database panel + library
16797
17533
  // consumers both need: connecting to an existing cloud DB (with
16798
- // optional team join), and upgrading a non-team cloud into a team
16799
- // cloud. The HTTP routes in src/gui/dbconfig-routes.ts are thin
16800
- // 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.
16801
17539
  /**
16802
17540
  * Connect a local project to an existing cloud DB by URL. Probes
16803
17541
  * the target for team status first; if it's a teams DB, the caller
@@ -16846,15 +17584,18 @@ var TeamsClient = class {
16846
17584
  return { probe };
16847
17585
  }
16848
17586
  /**
16849
- * Upgrade an already-connected cloud DB to a team DB. Two paths
16850
- * 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:
16851
17593
  *
16852
17594
  * - `http(s)://…` — POST to the cloud's `/api/auth/register` endpoint
16853
- * (`lattice serve --team-cloud` is fronting the Postgres).
16854
- * - `postgres(ql)://…` — drive the same INSERT sequence directly
16855
- * against the cloud Postgres via {@link registerDirectViaPostgres}.
16856
- * The HTTP path can't be used here because the browser's Fetch
16857
- * 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.
16858
17599
  *
16859
17600
  * On success writes the bearer token to `~/.lattice/keys/<label>.token`
16860
17601
  * **and** persists the local `__lattice_team_connections` row so the
@@ -16864,8 +17605,11 @@ var TeamsClient = class {
16864
17605
  * the token file, leaving GUI authenticated calls with no
16865
17606
  * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
16866
17607
  */
16867
- async upgradeToTeamCloud(opts) {
16868
- 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);
16869
17613
  writeToken(opts.label, reg.raw_token);
16870
17614
  await this.saveConnection({
16871
17615
  team_id: reg.team.id,
@@ -16898,7 +17642,7 @@ var TeamsClient = class {
16898
17642
  }
16899
17643
  try {
16900
17644
  const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
16901
- await this.upgradeToTeamCloud({
17645
+ await this.registerCloudOwner({
16902
17646
  label: opts.label,
16903
17647
  cloudUrl: opts.cloudUrl,
16904
17648
  teamName: opts.workspaceName,
@@ -18157,7 +18901,11 @@ async function handleRegisterAndCreate(req, res, ctx) {
18157
18901
  sendJson(res, { error: "cloud_url, email, user_name, team_name required" }, 400);
18158
18902
  return;
18159
18903
  }
18160
- 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);
18161
18909
  await ctx.client.saveConnection({
18162
18910
  team_id: reg.team.id,
18163
18911
  team_name: reg.team.name,
@@ -18449,19 +19197,8 @@ async function getCreatorEmail(db) {
18449
19197
  return null;
18450
19198
  }
18451
19199
  }
18452
- function computeState(type, teamEnabled, label, creatorEmail) {
19200
+ function computeState(type, creatorEmail) {
18453
19201
  if (type === "sqlite") return "local";
18454
- if (!teamEnabled) return "team-cloud-needs-invite";
18455
- if (!label) {
18456
- return "team-cloud-needs-invite";
18457
- }
18458
- let token = null;
18459
- try {
18460
- token = readToken(label);
18461
- } catch {
18462
- token = null;
18463
- }
18464
- if (!token) return "team-cloud-needs-invite";
18465
19202
  const identity = readIdentity();
18466
19203
  if (creatorEmail !== null && identity.email.length > 0 && creatorEmail.toLowerCase() === identity.email.toLowerCase()) {
18467
19204
  return "team-cloud-creator";
@@ -18469,8 +19206,9 @@ function computeState(type, teamEnabled, label, creatorEmail) {
18469
19206
  return "team-cloud-member";
18470
19207
  }
18471
19208
  function applyTeamMembershipState(info, membership) {
18472
- if (!membership || info.type !== "postgres" || !info.teamEnabled) return info.state;
18473
- return membership.joined ? membership.isCreator ? "team-cloud-creator" : "team-cloud-member" : "team-cloud-needs-invite";
19209
+ if (info.type !== "postgres") return info.state;
19210
+ if (membership) return membership.isCreator ? "team-cloud-creator" : "team-cloud-member";
19211
+ return info.state;
18474
19212
  }
18475
19213
  async function describeCurrent(configPath, db) {
18476
19214
  const rawYaml = readFileSync14(configPath, "utf8");
@@ -18492,7 +19230,7 @@ async function describeCurrent(configPath, db) {
18492
19230
  if (labelMatch) {
18493
19231
  const label = labelMatch[1] ?? "";
18494
19232
  const url = getDbCredential(label);
18495
- const state = computeState("postgres", teamEnabled, label, creatorEmail);
19233
+ const state = computeState("postgres", creatorEmail);
18496
19234
  if (url) {
18497
19235
  const parsed = parsePostgresUrl(url);
18498
19236
  if (parsed) {
@@ -18519,7 +19257,7 @@ async function describeCurrent(configPath, db) {
18519
19257
  }
18520
19258
  if (/^postgres(ql)?:\/\//i.test(dbLine)) {
18521
19259
  const parsed = parsePostgresUrl(dbLine);
18522
- const state = computeState("postgres", teamEnabled, void 0, creatorEmail);
19260
+ const state = computeState("postgres", creatorEmail);
18523
19261
  return parsed ? {
18524
19262
  type: "postgres",
18525
19263
  state,
@@ -18593,7 +19331,10 @@ async function dispatchDbConfigRoute(req, res, ctx) {
18593
19331
  // without a local `__lattice_team_connections` row (which doesn't
18594
19332
  // exist when the team cloud itself is the active database).
18595
19333
  teamId: ctx.teamMembership?.teamId ?? null,
18596
- 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
18597
19338
  });
18598
19339
  });
18599
19340
  return true;
@@ -19796,7 +20537,10 @@ async function executeFunction(ctx, name, args) {
19796
20537
  db: ctx.db,
19797
20538
  feed: ctx.feed,
19798
20539
  softDeletable: ctx.softDeletable,
19799
- 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
19800
20544
  };
19801
20545
  try {
19802
20546
  switch (name) {
@@ -19810,14 +20554,24 @@ async function executeFunction(ctx, name, args) {
19810
20554
  }
19811
20555
  case "list_rows": {
19812
20556
  const table = requireTable(args.table, ctx.validTables);
19813
- const opts = { limit: 200 };
19814
- if (ctx.softDeletable.has(table) && args.includeDeleted !== true) {
19815
- opts.filters = [{ col: "deleted_at", op: "isNull" }];
19816
- }
20557
+ const includeDeleted = args.includeDeleted === true;
19817
20558
  const cols = ctx.db.getRegisteredColumns(table);
19818
- opts.orderBy = cols && "created_at" in cols ? "created_at" : ctx.db.getPrimaryKey(table)[0] ?? "id";
19819
- opts.orderDir = "asc";
19820
- 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
+ }
19821
20575
  const secretCols = await secretColumnsFor(ctx.db, table);
19822
20576
  return { ok: true, result: rows.map((r) => redactRow(r, secretCols)) };
19823
20577
  }
@@ -19826,6 +20580,9 @@ async function executeFunction(ctx, name, args) {
19826
20580
  const id = requireString3(args.id, "id");
19827
20581
  const row = await ctx.db.get(table, id);
19828
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
+ }
19829
20586
  return { ok: true, result: redactRow(row, await secretColumnsFor(ctx.db, table)) };
19830
20587
  }
19831
20588
  case "search": {
@@ -19836,10 +20593,18 @@ async function executeFunction(ctx, name, args) {
19836
20593
  tables = tables.filter((t) => want.has(t));
19837
20594
  }
19838
20595
  const limit = typeof args.limit === "number" ? args.limit : 8;
19839
- const result = await fullTextSearch(ctx.db.adapter, tables, {
20596
+ let result = await fullTextSearch(ctx.db.adapter, tables, {
19840
20597
  query,
19841
20598
  limitPerTable: limit
19842
20599
  });
20600
+ if (ctx.team) {
20601
+ result = await filterSearchGroupsByAcl(
20602
+ ctx.db,
20603
+ ctx.team.teamId,
20604
+ ctx.team.myUserId,
20605
+ result
20606
+ );
20607
+ }
19843
20608
  return { ok: true, result };
19844
20609
  }
19845
20610
  case "create_row": {
@@ -20001,6 +20766,7 @@ var BASE_SYSTEM_PROMPT = [
20001
20766
  "- To relate two tables (link their rows), call create_relationship(table_a, table_b) to get a junction + its two foreign-key columns, then `link` each pair using those columns. If the junction already exists, just `link`.",
20002
20767
  "- Use the exact table names from the schema (or one you just created) \u2014 never guess a name for a table that should already exist.",
20003
20768
  "- Prefer reading (list_rows, get_row) before writing.",
20769
+ '- When you point the user at a specific row/object \u2014 especially if they ask you to "link", "open", or "show" it \u2014 make it clickable with an INLINE link in this exact form: [short label](lattice://<table>/<id>), using the real table name and the row id from your tool results (e.g. [the offer contract](lattice://contracts/9b7c60f0-fbc2-4f87-a550-c59e3c5d761f)). It renders as a pill that opens that object in the GUI. Only link ids you actually retrieved \u2014 never invent one \u2014 and prefer the user-facing record (the contract/person/etc. row) over an internal `files` id.',
20004
20770
  "- Attached files are rows in the `files` table; a file's full text content (CSV, document, etc.) is in its `extracted_text` column. To work from an attached file, read the relevant `files` row(s) and parse `extracted_text` \u2014 never guess a file's contents.",
20005
20771
  '- A tool result that contains "error" means the call FAILED. Do NOT claim success or proceed as if it returned data \u2014 read the error, correct your arguments, and retry.',
20006
20772
  "- For bulk work, emit several tool calls in one turn instead of one at a time. Every change is recorded in version history and can be undone.",
@@ -20496,15 +21262,14 @@ var REHYDRATE_MAX_BYTES = 24e3;
20496
21262
  function rehydrateEnabled() {
20497
21263
  return process.env.LATTICE_CHAT_REHYDRATE !== "false";
20498
21264
  }
20499
- async function persistMessage(db, threadId, role, text, turns) {
21265
+ async function persistMessage(db, threadId, role, text, turns, startedAt) {
21266
+ const payload = turns && turns.length > 0 ? { text, turns } : { text };
21267
+ if (startedAt) payload.startedAt = startedAt;
20500
21268
  await db.insert("chat_messages", {
20501
21269
  id: crypto.randomUUID(),
20502
21270
  thread_id: threadId,
20503
21271
  role,
20504
- // `text` stays for backward-compat (old clients + the model-history replay);
20505
- // `turns` carries the rich structure so a reloaded conversation shows the
20506
- // same text bubbles + tool pills as the live stream, not one text wall.
20507
- content_json: JSON.stringify(turns && turns.length > 0 ? { text, turns } : { text }),
21272
+ content_json: JSON.stringify(payload),
20508
21273
  source: role === "user" ? "gui" : "ai"
20509
21274
  });
20510
21275
  }
@@ -20526,11 +21291,17 @@ async function dispatchChatRoute(req, res, ctx) {
20526
21291
  const messages = rows.filter((r) => r.thread_id === threadId2 && !r.deleted_at).map((r) => {
20527
21292
  let text = "";
20528
21293
  let turns2;
21294
+ let startedAt;
20529
21295
  try {
20530
21296
  const parsed = JSON.parse(asStr(r.content_json, "{}"));
20531
21297
  text = parsed.text ?? "";
21298
+ if (typeof parsed.startedAt === "string") startedAt = parsed.startedAt;
20532
21299
  if (Array.isArray(parsed.turns)) {
20533
- turns2 = parsed.turns.map((t) => ({ text: t.text, tools: t.tools }));
21300
+ turns2 = parsed.turns.map((t) => ({
21301
+ text: t.text,
21302
+ tools: t.tools,
21303
+ ...t.events ? { events: t.events } : {}
21304
+ }));
20534
21305
  }
20535
21306
  } catch {
20536
21307
  }
@@ -20538,6 +21309,7 @@ async function dispatchChatRoute(req, res, ctx) {
20538
21309
  role: asStr(r.role),
20539
21310
  text,
20540
21311
  ...turns2 ? { turns: turns2 } : {},
21312
+ ...startedAt ? { startedAt } : {},
20541
21313
  created_at: asStr(r.created_at)
20542
21314
  };
20543
21315
  }).sort((a, b) => a.created_at.localeCompare(b.created_at));
@@ -20590,12 +21362,26 @@ async function dispatchChatRoute(req, res, ctx) {
20590
21362
  validTables: new Set([...ctx.validTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20591
21363
  junctionTables: new Set([...ctx.junctionTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
20592
21364
  softDeletable: ctx.softDeletable,
21365
+ ...ctx.team ? { team: ctx.team } : {},
20593
21366
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {},
20594
21367
  ...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
20595
21368
  ...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
20596
21369
  };
21370
+ const turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
20597
21371
  let assistantText = "";
20598
21372
  const turns = [];
21373
+ const unsubscribeFeed = ctx.feed.subscribe((fe) => {
21374
+ if (fe.source !== "ai") return;
21375
+ const cur = turns[turns.length - 1];
21376
+ if (cur)
21377
+ cur.events.push({
21378
+ op: fe.op,
21379
+ table: fe.table,
21380
+ rowId: fe.rowId,
21381
+ summary: fe.summary ?? "",
21382
+ ts: fe.ts
21383
+ });
21384
+ });
20599
21385
  try {
20600
21386
  const client = createAnthropicClient(auth);
20601
21387
  const temperature = aggressivenessToTemperature(getAggressiveness());
@@ -20611,7 +21397,7 @@ async function dispatchChatRoute(req, res, ctx) {
20611
21397
  }
20612
21398
  })) {
20613
21399
  if (ev.type === "assistant_message_start") {
20614
- turns.push({ text: "", tools: [], toolCalls: [] });
21400
+ turns.push({ text: "", tools: [], events: [], toolCalls: [] });
20615
21401
  } else if (ev.type === "text_delta") {
20616
21402
  assistantText += ev.delta;
20617
21403
  const cur = turns[turns.length - 1];
@@ -20634,16 +21420,19 @@ async function dispatchChatRoute(req, res, ctx) {
20634
21420
  res.write(formatSseFrame({ type: "done" }));
20635
21421
  } catch {
20636
21422
  }
21423
+ } finally {
21424
+ unsubscribeFeed();
20637
21425
  }
20638
21426
  res.end();
20639
21427
  if (threadId) {
20640
21428
  const cleanTurns = turns.map((t) => ({
20641
21429
  text: t.text,
20642
21430
  tools: t.tools.map((x) => ({ name: x.name, isError: x.isError })),
21431
+ ...t.events.length > 0 ? { events: t.events } : {},
20643
21432
  ...t.toolCalls.length > 0 ? { toolCalls: t.toolCalls } : {}
20644
- })).filter((t) => t.text.length > 0 || t.tools.length > 0);
21433
+ })).filter((t) => t.text.length > 0 || t.tools.length > 0 || (t.events?.length ?? 0) > 0);
20645
21434
  try {
20646
- await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns);
21435
+ await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns, turnStartedAt);
20647
21436
  } catch (e) {
20648
21437
  console.warn("[chat] persist assistant message failed:", e.message);
20649
21438
  }
@@ -21696,6 +22485,41 @@ async function countManyPostgres(adapter, tableNames) {
21696
22485
  }
21697
22486
  return out;
21698
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
+ }
21699
22523
 
21700
22524
  // src/gui/server.ts
21701
22525
  function sendText(res, body, status = 200, contentType = "text/plain; charset=utf-8") {
@@ -21754,17 +22578,64 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21754
22578
  if (teamContext) {
21755
22579
  allTables = allTables.filter((t) => isVisibleInTeam(t.name, teamContext));
21756
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
+ }
21757
22616
  const adapter = db._adapter;
21758
- const useBatched = adapter.dialect === "postgres" && typeof adapter.allAsync === "function";
22617
+ const useBatched = !teamContext && adapter.dialect === "postgres" && typeof adapter.allAsync === "function";
21759
22618
  const approxCounts = useBatched ? await countManyPostgres(
21760
22619
  adapter,
21761
22620
  allTables.map((t) => t.name)
21762
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
+ }
21763
22632
  const enrichedTables = await Promise.all(
21764
22633
  allTables.map(async (t) => {
21765
22634
  let rowCount;
21766
- if (useBatched) {
21767
- 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;
21768
22639
  } else {
21769
22640
  rowCount = t.columns.includes("deleted_at") ? await db.count(t.name, { filters: [{ col: "deleted_at", op: "isNull" }] }) : await db.count(t.name);
21770
22641
  }
@@ -21778,6 +22649,8 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21778
22649
  base.ownedByMe = teamContext.owners.get(t.name) === teamContext.myUserId;
21779
22650
  const ver = teamContext.sharedVersions.get(t.name);
21780
22651
  if (ver !== void 0) base.schemaVersion = ver;
22652
+ const dv = rowVisDefaults.get(t.name);
22653
+ if (dv) base.defaultRowVisibility = dv;
21781
22654
  }
21782
22655
  return base;
21783
22656
  })
@@ -21786,6 +22659,14 @@ async function entitiesWithCounts(db, configPath, outputDir, teamContext) {
21786
22659
  }
21787
22660
  var FRESHNESS_COLS = ["updated_at", "created_at", "ts"];
21788
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
+ }
21789
22670
  function operatorOwnsTable(teamContext, table) {
21790
22671
  if (!teamContext) return true;
21791
22672
  return teamContext.owners.get(table) === teamContext.myUserId;
@@ -22075,6 +22956,13 @@ async function openConfig(configPath, outputDir, autoRender = false) {
22075
22956
  if (!isVisibleInTeam(name, teamContext)) validTables.delete(name);
22076
22957
  }
22077
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
+ }
22078
22966
  let realtime = null;
22079
22967
  if (db.getDialect() === "postgres") {
22080
22968
  try {
@@ -22115,6 +23003,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
22115
23003
  teamsClient,
22116
23004
  validTables,
22117
23005
  teamContext,
23006
+ directTeamConnection,
22118
23007
  junctionTables,
22119
23008
  entityContextByTable,
22120
23009
  manifest,
@@ -22218,6 +23107,18 @@ async function registerTeamCloudTables(db) {
22218
23107
  await db.defineLate(name, def);
22219
23108
  }
22220
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
+ });
22221
23122
  }
22222
23123
  async function syncUserIdentityRow(db) {
22223
23124
  const identity = readIdentity();
@@ -22462,34 +23363,6 @@ data: ${JSON.stringify(data)}
22462
23363
  } catch {
22463
23364
  }
22464
23365
  };
22465
- try {
22466
- const auditBackfill = await active.db.query("_lattice_gui_audit", {
22467
- orderBy: "ts",
22468
- orderDir: "desc",
22469
- limit: 20
22470
- });
22471
- for (const a of auditBackfill.reverse()) {
22472
- const json = a.operation === "delete" ? a.before_json : a.after_json;
22473
- let labelRow;
22474
- if (json) {
22475
- try {
22476
- labelRow = JSON.parse(json);
22477
- } catch {
22478
- labelRow = void 0;
22479
- }
22480
- }
22481
- writeFeed({
22482
- seq: 0,
22483
- table: a.table_name,
22484
- op: a.operation,
22485
- rowId: a.row_id,
22486
- source: "gui",
22487
- ts: a.ts,
22488
- summary: feedSummary(a.operation, a.table_name, labelRow)
22489
- });
22490
- }
22491
- } catch {
22492
- }
22493
23366
  const keepalive = setInterval(() => {
22494
23367
  try {
22495
23368
  res.write(`: keepalive
@@ -22571,10 +23444,19 @@ data: ${JSON.stringify(data)}
22571
23444
  );
22572
23445
  tables = tables.filter((t) => want.has(t));
22573
23446
  }
22574
- sendJson(
22575
- res,
22576
- await fullTextSearch(active.db.adapter, tables, { query: q, limitPerTable: limit })
22577
- );
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);
22578
23460
  return;
22579
23461
  }
22580
23462
  if (method === "GET" && pathname === "/api/team/users") {
@@ -22775,6 +23657,78 @@ data: ${JSON.stringify(data)}
22775
23657
  sendJson(res, result.body, result.status);
22776
23658
  return;
22777
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
+ }
22778
23732
  if (method === "GET" && pathname === "/api/gui-meta/columns") {
22779
23733
  const rows = await active.db.query("_lattice_gui_column_meta", {});
22780
23734
  const out = {};
@@ -23869,6 +24823,10 @@ data: ${JSON.stringify(data)}
23869
24823
  sendJson(res, { error: `Unknown table: ${table}` }, 400);
23870
24824
  return;
23871
24825
  }
24826
+ if (!await canAccessRow(active.db, tctx.teamId, table, rowId, tctx.myUserId)) {
24827
+ sendJson(res, { error: "Row not found" }, 404);
24828
+ return;
24829
+ }
23872
24830
  const limit = Math.min(200, Math.max(1, Number(url2.searchParams.get("limit") ?? "50")));
23873
24831
  const rows = await active.db.query("__lattice_change_log", {
23874
24832
  filters: [
@@ -23903,20 +24861,7 @@ data: ${JSON.stringify(data)}
23903
24861
  sendJson(res, { error: `Unknown table: ${table}` }, 400);
23904
24862
  return;
23905
24863
  }
23906
- const scan = await active.db.query("__lattice_change_log", {
23907
- filters: [
23908
- { col: "team_id", op: "eq", val: tctx.teamId },
23909
- { col: "table_name", op: "eq", val: table }
23910
- ],
23911
- orderBy: "seq",
23912
- orderDir: "desc",
23913
- limit: 2e3
23914
- });
23915
- const edits = {};
23916
- for (const r of scan) {
23917
- if (!r.pk || edits[r.pk]) continue;
23918
- edits[r.pk] = { ownerUserId: r.owner_user_id, at: r.client_ts ?? r.created_at };
23919
- }
24864
+ const edits = await visibleRowEdits(active.db, tctx.teamId, table, tctx.myUserId);
23920
24865
  sendJson(res, { edits });
23921
24866
  return;
23922
24867
  }
@@ -23958,13 +24903,31 @@ data: ${JSON.stringify(data)}
23958
24903
  const limit = Number(url2.searchParams.get("limit") ?? "500");
23959
24904
  const offset = Number(url2.searchParams.get("offset") ?? "0");
23960
24905
  const deletedMode = url2.searchParams.get("deleted");
23961
- const queryOpts = { limit, offset };
23962
- if (active.softDeletable.has(table) && deletedMode !== "any") {
23963
- queryOpts.filters = [
23964
- { col: "deleted_at", op: deletedMode === "only" ? "isNotNull" : "isNull" }
23965
- ];
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);
23966
24930
  }
23967
- const rows = await active.db.query(table, queryOpts);
23968
24931
  sendJson(res, { rows });
23969
24932
  return;
23970
24933
  }
@@ -23981,6 +24944,19 @@ data: ${JSON.stringify(data)}
23981
24944
  sendJson(res, { error: "Row not found" }, 404);
23982
24945
  return;
23983
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
+ }
23984
24960
  sendJson(res, row);
23985
24961
  return;
23986
24962
  }
@@ -24073,6 +25049,7 @@ data: ${JSON.stringify(data)}
24073
25049
  validTables: active.validTables,
24074
25050
  junctionTables: active.junctionTables,
24075
25051
  softDeletable: active.softDeletable,
25052
+ team: active.teamContext ? { teamId: active.teamContext.teamId, myUserId: active.teamContext.myUserId } : null,
24076
25053
  // The assistant can create tables + relationships on request — same
24077
25054
  // audited, no-reopen primitives the Context Constructor uses.
24078
25055
  createEntity: (name, columns) => createUserEntity(active, name, columns, sessionId),
@@ -24122,6 +25099,7 @@ data: ${JSON.stringify(data)}
24122
25099
  teamId: active.teamContext.teamId,
24123
25100
  myUserId: active.teamContext.myUserId
24124
25101
  } : null,
25102
+ directCloud: active.directTeamConnection,
24125
25103
  swap: async () => {
24126
25104
  const next = await openConfig(active.configPath, active.outputDir, autoRender);
24127
25105
  await disposeActive(active);
@@ -24133,6 +25111,14 @@ data: ${JSON.stringify(data)}
24133
25111
  sendJson(res, { error: "Not found" }, 404);
24134
25112
  } catch (err) {
24135
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
+ }
24136
25122
  console.error(
24137
25123
  `[gui] ${req.method ?? "?"} ${req.url ?? "?"} failed: ${e.message}
24138
25124
  ${e.stack ?? ""}`
@@ -24845,7 +25831,7 @@ function printHelp() {
24845
25831
  " status Dry-run reconcile \u2014 show what would change without writing",
24846
25832
  " watch Poll for changes and re-render on each cycle",
24847
25833
  " gui Start a local browser GUI for exploring Lattice context",
24848
- " 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)",
24849
25835
  " teams Manage Lattice Teams (run `lattice teams help` for subcommands)",
24850
25836
  " update Upgrade latticesql to the latest version",
24851
25837
  "",
@@ -24890,7 +25876,11 @@ function printHelp() {
24890
25876
  " --output <dir> Output directory for rendered context (default: ./context)",
24891
25877
  " --host <addr> Bind address (default: 127.0.0.1; use 0.0.0.0 to expose)",
24892
25878
  " --port <number> Port (default: 4317; auto-increments if busy)",
24893
- " --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).",
24894
25884
  "",
24895
25885
  "Options (init / workspace):",
24896
25886
  " --root <dir> The .lattice root location (default: discovered or ./.lattice)",
@@ -25134,7 +26124,7 @@ async function runServe(args) {
25134
26124
  openBrowser: false,
25135
26125
  teamCloud: args.teamCloud
25136
26126
  });
25137
- const label = args.teamCloud ? "Lattice team cloud" : "Lattice server";
26127
+ const label = args.teamCloud ? "Lattice shared cloud server" : "Lattice server";
25138
26128
  console.log(`${label} listening on ${args.host}:${String(handle.port)} (${handle.url})`);
25139
26129
  console.log("Press Ctrl+C to stop.");
25140
26130
  const shutdown = () => {