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