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