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/README.md +9 -3
- package/dist/cli.js +1600 -610
- package/dist/index.cjs +262 -87
- package/dist/index.d.cts +92 -11
- package/dist/index.d.ts +92 -11
- package/dist/index.js +262 -87
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
7816
|
-
|
|
7817
|
-
|
|
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
|
|
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"
|
|
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.<ref>: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.<ref>: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
|
|
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
|
-
|
|
11878
|
-
(
|
|
11879
|
-
|
|
11880
|
-
|
|
11881
|
-
|
|
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
|
|
11899
|
-
|
|
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">
|
|
11905
|
-
'<option value="openai">OpenAI
|
|
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
|
-
//
|
|
12335
|
-
//
|
|
12336
|
-
//
|
|
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
|
-
//
|
|
12899
|
-
//
|
|
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
|
-
|
|
12902
|
-
|
|
12903
|
-
//
|
|
12904
|
-
//
|
|
12905
|
-
//
|
|
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
|
-
|
|
12908
|
-
|
|
12909
|
-
|
|
12910
|
-
|
|
12911
|
-
|
|
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
|
-
|
|
12921
|
-
|
|
12922
|
-
|
|
12923
|
-
|
|
12924
|
-
|
|
12925
|
-
|
|
12926
|
-
|
|
12927
|
-
|
|
12928
|
-
|
|
12929
|
-
|
|
12930
|
-
|
|
12931
|
-
|
|
12932
|
-
var
|
|
12933
|
-
if (
|
|
12934
|
-
|
|
12935
|
-
|
|
12936
|
-
|
|
12937
|
-
|
|
12938
|
-
|
|
12939
|
-
|
|
12940
|
-
|
|
12941
|
-
|
|
12942
|
-
|
|
12943
|
-
|
|
12944
|
-
|
|
12945
|
-
|
|
12946
|
-
|
|
12947
|
-
|
|
12948
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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).
|
|
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
|
-
//
|
|
13080
|
-
//
|
|
13081
|
-
//
|
|
13082
|
-
//
|
|
13083
|
-
var
|
|
13084
|
-
for (var i = 0; i <
|
|
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 +
|
|
13138
|
-
// matching the live stream. Falls back to
|
|
13139
|
-
// messages saved before turns were persisted.
|
|
13140
|
-
if (Array.isArray(m.turns) && m.turns.length > 0) {
|
|
13141
|
-
|
|
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
|
-
|
|
13172
|
-
msg
|
|
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 =
|
|
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
|
|
13187
|
-
*
|
|
13188
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
13264
|
-
*
|
|
13265
|
-
*/
|
|
13266
|
-
function
|
|
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); //
|
|
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
|
-
|
|
13327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13997
|
-
|
|
13998
|
-
|
|
13999
|
-
|
|
14000
|
-
|
|
14001
|
-
|
|
14002
|
-
|
|
14003
|
-
|
|
14004
|
-
|
|
14005
|
-
|
|
14006
|
-
|
|
14007
|
-
|
|
14008
|
-
|
|
14009
|
-
|
|
14010
|
-
|
|
14011
|
-
|
|
14012
|
-
|
|
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
|
-
|
|
14015
|
-
|
|
14016
|
-
|
|
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
|
|
14019
|
-
const
|
|
14020
|
-
|
|
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
|
|
14023
|
-
const
|
|
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: "
|
|
14029
|
-
{ col: "
|
|
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
|
|
14034
|
-
if (
|
|
14035
|
-
const
|
|
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
|
-
|
|
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.
|
|
16344
|
-
|
|
16345
|
-
|
|
16346
|
-
|
|
16347
|
-
|
|
16348
|
-
|
|
16349
|
-
|
|
16350
|
-
|
|
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:
|
|
16354
|
-
table_name:
|
|
16355
|
-
pk
|
|
17115
|
+
team_id: teamId,
|
|
17116
|
+
table_name: tableName,
|
|
17117
|
+
pk,
|
|
16356
17118
|
op: "unlink",
|
|
16357
17119
|
payload_json: null,
|
|
16358
|
-
owner_user_id:
|
|
17120
|
+
owner_user_id: ownerUserId,
|
|
17121
|
+
recipient_user_id: uid
|
|
16359
17122
|
});
|
|
16360
17123
|
}
|
|
16361
|
-
|
|
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.
|
|
16478
|
-
|
|
16479
|
-
|
|
16480
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
16799
|
-
//
|
|
16800
|
-
//
|
|
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
|
-
*
|
|
16850
|
-
*
|
|
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
|
|
16854
|
-
* - `postgres(ql)://…` —
|
|
16855
|
-
*
|
|
16856
|
-
*
|
|
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
|
|
16868
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
18473
|
-
|
|
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",
|
|
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",
|
|
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
|
|
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
|
-
|
|
19819
|
-
|
|
19820
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({
|
|
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 (
|
|
21767
|
-
rowCount =
|
|
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
|
-
|
|
22575
|
-
|
|
22576
|
-
|
|
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
|
|
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
|
-
|
|
23962
|
-
if (active.
|
|
23963
|
-
|
|
23964
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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 = () => {
|