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/index.cjs
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;
|
|
@@ -7194,7 +7325,13 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
|
|
|
7194
7325
|
// Client-generated idempotency key for offline replay: a queued edit
|
|
7195
7326
|
// carries a stable edit_id, so re-sending it after a reconnect is a
|
|
7196
7327
|
// no-op rather than a duplicate write. Nullable + additive.
|
|
7197
|
-
edit_id: "TEXT"
|
|
7328
|
+
edit_id: "TEXT",
|
|
7329
|
+
// Per-recipient targeting for 2.2 hard row-level sync. NULL =
|
|
7330
|
+
// broadcast (delivered to every member, then filtered at pull time
|
|
7331
|
+
// against __lattice_row_acl); non-null = targeted to exactly this
|
|
7332
|
+
// user (the grant / revoke / delete fan-out). Nullable + additive,
|
|
7333
|
+
// same precedent as client_ts / edit_id.
|
|
7334
|
+
recipient_user_id: "TEXT"
|
|
7198
7335
|
},
|
|
7199
7336
|
render: () => "",
|
|
7200
7337
|
outputFile: ".lattice-teams/change-log.md"
|
|
@@ -7210,6 +7347,44 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
|
|
|
7210
7347
|
primaryKey: ["team_id", "table_name", "pk"],
|
|
7211
7348
|
render: () => "",
|
|
7212
7349
|
outputFile: ".lattice-teams/row-links.md"
|
|
7350
|
+
},
|
|
7351
|
+
// Per-row access control for a team cloud (2.2 row-level permissions).
|
|
7352
|
+
// Mirrors __lattice_object_owners at row granularity: each shared row
|
|
7353
|
+
// has an owner (its creator) and a visibility. Enforcement is at the
|
|
7354
|
+
// application layer (see src/teams/row-access.ts) — every member shares
|
|
7355
|
+
// the same physical DB, so a row a user can't see must be filtered out
|
|
7356
|
+
// before its bytes reach them. Kept out-of-band (never injected into
|
|
7357
|
+
// user tables) so the user's own schema stays untouched.
|
|
7358
|
+
__lattice_row_acl: {
|
|
7359
|
+
columns: {
|
|
7360
|
+
team_id: "TEXT NOT NULL",
|
|
7361
|
+
table_name: "TEXT NOT NULL",
|
|
7362
|
+
pk: "TEXT NOT NULL",
|
|
7363
|
+
owner_user_id: "TEXT NOT NULL",
|
|
7364
|
+
// 'private' = owner only · 'everyone' = all team members ·
|
|
7365
|
+
// 'custom' = the explicit grant list in __lattice_row_grants.
|
|
7366
|
+
visibility: "TEXT NOT NULL CHECK (visibility IN ('private', 'everyone', 'custom'))",
|
|
7367
|
+
created_at: "TEXT NOT NULL",
|
|
7368
|
+
updated_at: "TEXT NOT NULL"
|
|
7369
|
+
},
|
|
7370
|
+
primaryKey: ["team_id", "table_name", "pk"],
|
|
7371
|
+
render: () => "",
|
|
7372
|
+
outputFile: ".lattice-teams/row-acl.md"
|
|
7373
|
+
},
|
|
7374
|
+
// Explicit per-row grant list, consulted only when the owning row's
|
|
7375
|
+
// __lattice_row_acl.visibility = 'custom'. One row per (row, grantee).
|
|
7376
|
+
__lattice_row_grants: {
|
|
7377
|
+
columns: {
|
|
7378
|
+
team_id: "TEXT NOT NULL",
|
|
7379
|
+
table_name: "TEXT NOT NULL",
|
|
7380
|
+
pk: "TEXT NOT NULL",
|
|
7381
|
+
grantee_user_id: "TEXT NOT NULL",
|
|
7382
|
+
granted_by_user_id: "TEXT NOT NULL",
|
|
7383
|
+
granted_at: "TEXT NOT NULL"
|
|
7384
|
+
},
|
|
7385
|
+
primaryKey: ["team_id", "table_name", "pk", "grantee_user_id"],
|
|
7386
|
+
render: () => "",
|
|
7387
|
+
outputFile: ".lattice-teams/row-grants.md"
|
|
7213
7388
|
}
|
|
7214
7389
|
};
|
|
7215
7390
|
var LOCAL_INTERNAL_TABLE_DEFS = {
|
|
@@ -7310,6 +7485,48 @@ async function installCloudInternalTriggers(db) {
|
|
|
7310
7485
|
};
|
|
7311
7486
|
await db.migrate([migration]);
|
|
7312
7487
|
}
|
|
7488
|
+
async function installRowPermsSchema(db) {
|
|
7489
|
+
const migrations = [
|
|
7490
|
+
{
|
|
7491
|
+
// 01 — per-table default visibility for newly-created rows. Born
|
|
7492
|
+
// 'private' unless the table owner opts the table into 'everyone';
|
|
7493
|
+
// the backfill (06) flips already-shared tables to preserve pre-2.2
|
|
7494
|
+
// visibility. No IF NOT EXISTS: the version guard runs this exactly
|
|
7495
|
+
// once, which is what SQLite's ADD COLUMN needs (it has no
|
|
7496
|
+
// IF NOT EXISTS form).
|
|
7497
|
+
version: "internal:row-perms:01-default-row-visibility:v1",
|
|
7498
|
+
sql: `ALTER TABLE "__lattice_shared_objects" ADD COLUMN "default_row_visibility" TEXT NOT NULL DEFAULT 'private' CHECK ("default_row_visibility" IN ('private', 'everyone'))`
|
|
7499
|
+
},
|
|
7500
|
+
{
|
|
7501
|
+
// 02-05 — indexes. One CREATE INDEX per migration (single-statement
|
|
7502
|
+
// for the SQLite path). IF NOT EXISTS is valid in both dialects.
|
|
7503
|
+
version: "internal:row-perms:02-idx-change-log-team-seq:v1",
|
|
7504
|
+
sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_seq" ON "__lattice_change_log" ("team_id", "seq")`
|
|
7505
|
+
},
|
|
7506
|
+
{
|
|
7507
|
+
version: "internal:row-perms:03-idx-change-log-team-recipient-seq:v1",
|
|
7508
|
+
sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_recipient_seq" ON "__lattice_change_log" ("team_id", "recipient_user_id", "seq")`
|
|
7509
|
+
},
|
|
7510
|
+
{
|
|
7511
|
+
version: "internal:row-perms:04-idx-change-log-team-table-pk:v1",
|
|
7512
|
+
sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_table_pk" ON "__lattice_change_log" ("team_id", "table_name", "pk")`
|
|
7513
|
+
},
|
|
7514
|
+
{
|
|
7515
|
+
version: "internal:row-perms:05-idx-row-grants-grantee:v1",
|
|
7516
|
+
sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee" ON "__lattice_row_grants" ("team_id", "grantee_user_id", "table_name", "pk")`
|
|
7517
|
+
},
|
|
7518
|
+
{
|
|
7519
|
+
// 06 — upgrade backfill. Every already-shared table defaults to
|
|
7520
|
+
// 'everyone' so pre-2.2 rows stay visible to all members (nothing
|
|
7521
|
+
// disappears on upgrade). Pre-2.2 rows have no __lattice_row_acl
|
|
7522
|
+
// entry; resolveRowAcl() folds in this table default, so they read
|
|
7523
|
+
// as 'everyone' until an owner narrows an individual row. Runs once.
|
|
7524
|
+
version: "internal:row-perms:06-backfill-shared-defaults:v1",
|
|
7525
|
+
sql: `UPDATE "__lattice_shared_objects" SET "default_row_visibility" = 'everyone' WHERE "deleted_at" IS NULL`
|
|
7526
|
+
}
|
|
7527
|
+
];
|
|
7528
|
+
await db.migrate(migrations);
|
|
7529
|
+
}
|
|
7313
7530
|
|
|
7314
7531
|
// src/teams/schema-spec.ts
|
|
7315
7532
|
function renderColumnType(spec, dialect) {
|
|
@@ -7665,7 +7882,8 @@ async function appendChangeEnvelope(db, entry) {
|
|
|
7665
7882
|
owner_user_id: entry.owner_user_id ?? null,
|
|
7666
7883
|
created_at: now,
|
|
7667
7884
|
client_ts: entry.client_ts ?? now,
|
|
7668
|
-
edit_id: entry.edit_id ?? null
|
|
7885
|
+
edit_id: entry.edit_id ?? null,
|
|
7886
|
+
recipient_user_id: entry.recipient_user_id ?? null
|
|
7669
7887
|
});
|
|
7670
7888
|
return seq;
|
|
7671
7889
|
}
|
|
@@ -7705,7 +7923,14 @@ async function shareObject(db, teamId, createdByUserId, table, spec) {
|
|
|
7705
7923
|
created_by_user_id: createdByUserId,
|
|
7706
7924
|
created_at: prior?.created_at ?? now,
|
|
7707
7925
|
updated_at: now,
|
|
7708
|
-
deleted_at: null
|
|
7926
|
+
deleted_at: null,
|
|
7927
|
+
// 2.2: a freshly-shared table defaults to 'everyone' so the existing
|
|
7928
|
+
// "share a table → every member sees its rows" contract is preserved.
|
|
7929
|
+
// The owner can narrow the table default (or individual rows) afterward.
|
|
7930
|
+
// Re-share (the branch above) intentionally omits this so an owner's
|
|
7931
|
+
// earlier choice survives a schema bump (the upsert only sets the
|
|
7932
|
+
// columns it lists).
|
|
7933
|
+
default_row_visibility: "everyone"
|
|
7709
7934
|
});
|
|
7710
7935
|
}
|
|
7711
7936
|
await applySchemaSpec(db, table, outSpec);
|
|
@@ -7799,88 +8024,26 @@ async function destroyTeamDirect(db) {
|
|
|
7799
8024
|
}
|
|
7800
8025
|
await db.delete("__lattice_team_identity", "singleton");
|
|
7801
8026
|
}
|
|
7802
|
-
|
|
7803
|
-
if (!isPostgresUrl(cloudUrl)) {
|
|
7804
|
-
throw new Error(
|
|
7805
|
-
`redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
|
|
7806
|
-
);
|
|
7807
|
-
}
|
|
7808
|
-
const db = new Lattice(cloudUrl);
|
|
7809
|
-
try {
|
|
7810
|
-
await db.init();
|
|
7811
|
-
for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
|
|
7812
|
-
await db.defineLate(table, def);
|
|
7813
|
-
}
|
|
7814
|
-
await installCloudInternalTriggers(db);
|
|
7815
|
-
const invites = await db.query("__lattice_invitations", {
|
|
7816
|
-
filters: [
|
|
7817
|
-
{ col: "token_hash", op: "eq", val: hashToken(inviteToken) },
|
|
7818
|
-
{ col: "redeemed_at", op: "isNull" }
|
|
7819
|
-
],
|
|
7820
|
-
limit: 1
|
|
7821
|
-
});
|
|
7822
|
-
const invite = invites[0];
|
|
7823
|
-
if (!invite) {
|
|
7824
|
-
throw new Error("Invitation invalid or already used");
|
|
7825
|
-
}
|
|
7826
|
-
if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
|
|
7827
|
-
throw new Error("Invitation expired");
|
|
7828
|
-
}
|
|
7829
|
-
if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
|
|
7830
|
-
throw new Error("Invitation is addressed to a different email");
|
|
7831
|
-
}
|
|
7832
|
-
const team = await db.get("__lattice_team", invite.team_id);
|
|
7833
|
-
if (!team || team.deleted_at) {
|
|
7834
|
-
throw new Error("Team no longer exists");
|
|
7835
|
-
}
|
|
7836
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7837
|
-
const userId = await db.insert("__lattice_users", {
|
|
7838
|
-
email,
|
|
7839
|
-
name,
|
|
7840
|
-
created_at: now,
|
|
7841
|
-
updated_at: now
|
|
7842
|
-
});
|
|
7843
|
-
await db.insert("__lattice_team_members", {
|
|
7844
|
-
team_id: invite.team_id,
|
|
7845
|
-
user_id: userId,
|
|
7846
|
-
role: "member",
|
|
7847
|
-
joined_at: now
|
|
7848
|
-
});
|
|
7849
|
-
const { raw, hash } = generateToken();
|
|
7850
|
-
await db.insert("__lattice_api_tokens", {
|
|
7851
|
-
user_id: userId,
|
|
7852
|
-
token_hash: hash,
|
|
7853
|
-
name: `invited:${team.name}`,
|
|
7854
|
-
created_at: now
|
|
7855
|
-
});
|
|
7856
|
-
await db.update("__lattice_invitations", invite.id, {
|
|
7857
|
-
redeemed_at: now,
|
|
7858
|
-
redeemed_by_user_id: userId
|
|
7859
|
-
});
|
|
7860
|
-
return {
|
|
7861
|
-
user: { id: userId, email, name },
|
|
7862
|
-
raw_token: raw,
|
|
7863
|
-
team: { id: team.id, name: team.name }
|
|
7864
|
-
};
|
|
7865
|
-
} finally {
|
|
7866
|
-
try {
|
|
7867
|
-
db.close();
|
|
7868
|
-
} catch {
|
|
7869
|
-
}
|
|
7870
|
-
}
|
|
7871
|
-
}
|
|
8027
|
+
var directDeprecationWarned = false;
|
|
7872
8028
|
async function openCloud(cloudUrl) {
|
|
7873
8029
|
if (!isPostgresUrl(cloudUrl)) {
|
|
7874
8030
|
throw new Error(
|
|
7875
8031
|
`direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
|
|
7876
8032
|
);
|
|
7877
8033
|
}
|
|
8034
|
+
if (!directDeprecationWarned) {
|
|
8035
|
+
directDeprecationWarned = true;
|
|
8036
|
+
console.warn(
|
|
8037
|
+
"[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
|
|
8038
|
+
);
|
|
8039
|
+
}
|
|
7878
8040
|
const db = new Lattice(cloudUrl);
|
|
7879
8041
|
await db.init();
|
|
7880
8042
|
for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
|
|
7881
8043
|
await db.defineLate(table, def);
|
|
7882
8044
|
}
|
|
7883
8045
|
await installCloudInternalTriggers(db);
|
|
8046
|
+
await installRowPermsSchema(db);
|
|
7884
8047
|
return db;
|
|
7885
8048
|
}
|
|
7886
8049
|
function closeQuiet(db) {
|
|
@@ -7991,6 +8154,7 @@ async function unlinkRowDirect(local, cloudUrl, teamId, table, pk) {
|
|
|
7991
8154
|
}
|
|
7992
8155
|
|
|
7993
8156
|
// src/teams/client.ts
|
|
8157
|
+
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.";
|
|
7994
8158
|
var TeamsClient = class {
|
|
7995
8159
|
constructor(local) {
|
|
7996
8160
|
this.local = local;
|
|
@@ -8023,6 +8187,9 @@ var TeamsClient = class {
|
|
|
8023
8187
|
* members join via `redeemInvite`). Returns the new user + bearer
|
|
8024
8188
|
* token + team summary so the caller can immediately save a
|
|
8025
8189
|
* connection.
|
|
8190
|
+
*
|
|
8191
|
+
* @param teamName The workspace display name (stored as `team_name` for
|
|
8192
|
+
* backward compatibility — a cloud IS a workspace with members).
|
|
8026
8193
|
*/
|
|
8027
8194
|
async register(cloudUrl, email, name, teamName) {
|
|
8028
8195
|
return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/register", {
|
|
@@ -8033,7 +8200,7 @@ var TeamsClient = class {
|
|
|
8033
8200
|
}
|
|
8034
8201
|
async redeemInvite(cloudUrl, inviteToken, email, name) {
|
|
8035
8202
|
if (isPostgresUrl(cloudUrl)) {
|
|
8036
|
-
|
|
8203
|
+
throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
|
|
8037
8204
|
}
|
|
8038
8205
|
return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
|
|
8039
8206
|
invite_token: inviteToken,
|
|
@@ -8044,9 +8211,11 @@ var TeamsClient = class {
|
|
|
8044
8211
|
// ── High-level orchestration (v1.13+) ───────────────────────────────────
|
|
8045
8212
|
// Wraps the multi-step flows the GUI's Database panel + library
|
|
8046
8213
|
// consumers both need: connecting to an existing cloud DB (with
|
|
8047
|
-
// optional team join), and
|
|
8048
|
-
//
|
|
8049
|
-
//
|
|
8214
|
+
// optional team join), and initializing a fresh cloud DB's owner so
|
|
8215
|
+
// its members + per-table sharing surface exists. A cloud workspace IS
|
|
8216
|
+
// a workspace with members — there is no separate "team" to convert to.
|
|
8217
|
+
// The HTTP routes in src/gui/dbconfig-routes.ts are thin shells over
|
|
8218
|
+
// these methods.
|
|
8050
8219
|
/**
|
|
8051
8220
|
* Connect a local project to an existing cloud DB by URL. Probes
|
|
8052
8221
|
* the target for team status first; if it's a teams DB, the caller
|
|
@@ -8095,15 +8264,18 @@ var TeamsClient = class {
|
|
|
8095
8264
|
return { probe };
|
|
8096
8265
|
}
|
|
8097
8266
|
/**
|
|
8098
|
-
*
|
|
8099
|
-
*
|
|
8267
|
+
* Initialize a fresh cloud DB's owner: register the first member (who
|
|
8268
|
+
* becomes owner) so the cloud's members + per-table sharing surface
|
|
8269
|
+
* exists. This is NOT a "convert a cloud into a team" step — a cloud
|
|
8270
|
+
* workspace IS a workspace with members; this just bootstraps the owner
|
|
8271
|
+
* the first time a cloud is opened. The hosted server path is the only
|
|
8272
|
+
* supported one:
|
|
8100
8273
|
*
|
|
8101
8274
|
* - `http(s)://…` — POST to the cloud's `/api/auth/register` endpoint
|
|
8102
|
-
* (`lattice serve
|
|
8103
|
-
* - `postgres(ql)://…` —
|
|
8104
|
-
*
|
|
8105
|
-
*
|
|
8106
|
-
* API refuses URLs with embedded credentials.
|
|
8275
|
+
* (a hosted `lattice serve` teams server is fronting the Postgres).
|
|
8276
|
+
* - `postgres(ql)://…` — rejected: direct postgres:// owner bootstrap
|
|
8277
|
+
* is deprecated. Row-level security is enforced by the hosted server,
|
|
8278
|
+
* so it is the only supported connection method for new workspaces.
|
|
8107
8279
|
*
|
|
8108
8280
|
* On success writes the bearer token to `~/.lattice/keys/<label>.token`
|
|
8109
8281
|
* **and** persists the local `__lattice_team_connections` row so the
|
|
@@ -8113,8 +8285,11 @@ var TeamsClient = class {
|
|
|
8113
8285
|
* the token file, leaving GUI authenticated calls with no
|
|
8114
8286
|
* `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
|
|
8115
8287
|
*/
|
|
8116
|
-
async
|
|
8117
|
-
|
|
8288
|
+
async registerCloudOwner(opts) {
|
|
8289
|
+
if (isPostgresUrl(opts.cloudUrl)) {
|
|
8290
|
+
throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
|
|
8291
|
+
}
|
|
8292
|
+
const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
|
|
8118
8293
|
writeToken(opts.label, reg.raw_token);
|
|
8119
8294
|
await this.saveConnection({
|
|
8120
8295
|
team_id: reg.team.id,
|
|
@@ -8147,7 +8322,7 @@ var TeamsClient = class {
|
|
|
8147
8322
|
}
|
|
8148
8323
|
try {
|
|
8149
8324
|
const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
|
|
8150
|
-
await this.
|
|
8325
|
+
await this.registerCloudOwner({
|
|
8151
8326
|
label: opts.label,
|
|
8152
8327
|
cloudUrl: opts.cloudUrl,
|
|
8153
8328
|
teamName: opts.workspaceName,
|
package/dist/index.d.cts
CHANGED
|
@@ -1969,6 +1969,76 @@ declare class Lattice {
|
|
|
1969
1969
|
*/
|
|
1970
1970
|
search(table: string, query: string, opts?: SearchOptions): Promise<SearchResult[]>;
|
|
1971
1971
|
query(table: string, opts?: QueryOptions): Promise<Row[]>;
|
|
1972
|
+
/**
|
|
1973
|
+
* Row-level-security list read for Lattice Teams (2.2). Returns only the
|
|
1974
|
+
* rows of `table` that `userId` may see in team `teamId`, evaluated
|
|
1975
|
+
* entirely in SQL (indexed, bounded — never "load every row then filter
|
|
1976
|
+
* in JS"). A row is visible iff it has a `__lattice_row_acl` entry owned by
|
|
1977
|
+
* the user or marked 'everyone', or a 'custom' entry with a matching
|
|
1978
|
+
* `__lattice_row_grants` row, OR it has no ACL entry at all and the caller
|
|
1979
|
+
* passes `noAclVisible` (the table default is 'everyone', or the user owns
|
|
1980
|
+
* the table — the pre-2.2 / never-narrowed case). Soft-deleted rows are
|
|
1981
|
+
* excluded by default; results reuse the same decrypt path as `query()`.
|
|
1982
|
+
*
|
|
1983
|
+
* The ACL predicate joins on the table's primary-key column cast to TEXT
|
|
1984
|
+
* (ACL pks are stored as TEXT), so it is correct regardless of the user
|
|
1985
|
+
* table's pk type and works on both SQLite and Postgres. The teams layer's
|
|
1986
|
+
* `listVisibleRows` (src/teams/row-access.ts) is the intended caller.
|
|
1987
|
+
*/
|
|
1988
|
+
queryVisible(table: string, opts: {
|
|
1989
|
+
teamId: string;
|
|
1990
|
+
userId: string;
|
|
1991
|
+
/**
|
|
1992
|
+
* Whether rows with NO `__lattice_row_acl` entry are visible to this
|
|
1993
|
+
* user — true when the table default is 'everyone' OR the user owns the
|
|
1994
|
+
* table (the pre-2.2 / never-narrowed case). Resolved by the teams layer
|
|
1995
|
+
* (`listVisibleRows`); defaults to false, i.e. only rows with an explicit
|
|
1996
|
+
* ACL entry granting access are returned.
|
|
1997
|
+
*/
|
|
1998
|
+
noAclVisible?: boolean;
|
|
1999
|
+
/** Soft-delete handling: 'exclude' (default), 'only' (trash), 'any'. */
|
|
2000
|
+
deleted?: 'exclude' | 'only' | 'any';
|
|
2001
|
+
limit?: number;
|
|
2002
|
+
offset?: number;
|
|
2003
|
+
orderBy?: string;
|
|
2004
|
+
orderDir?: 'asc' | 'desc';
|
|
2005
|
+
}): Promise<Row[]>;
|
|
2006
|
+
/**
|
|
2007
|
+
* Visible-row counts for MANY tables in a single round-trip, using the same
|
|
2008
|
+
* ACL predicate as {@link queryVisible} — so dashboard tiles agree with what
|
|
2009
|
+
* the rows view lists and a physical count never reveals the existence or
|
|
2010
|
+
* volume of rows the user can't see. One aggregated
|
|
2011
|
+
* `SELECT (SELECT COUNT(*) …) AS c0, …` statement (no per-table fan-out, so
|
|
2012
|
+
* a session pooler with few slots survives concurrent refreshes), capped at
|
|
2013
|
+
* 50 tables per pass; overflow is logged and skipped (no silent truncation)
|
|
2014
|
+
* and those tables count as absent — the caller renders "—". Soft-deleted
|
|
2015
|
+
* rows are excluded wherever the table carries `deleted_at`, matching the
|
|
2016
|
+
* default rows view.
|
|
2017
|
+
*/
|
|
2018
|
+
countVisibleMany(specs: {
|
|
2019
|
+
table: string;
|
|
2020
|
+
noAclVisible: boolean;
|
|
2021
|
+
}[], opts: {
|
|
2022
|
+
teamId: string;
|
|
2023
|
+
userId: string;
|
|
2024
|
+
}): Promise<Map<string, number>>;
|
|
2025
|
+
/**
|
|
2026
|
+
* Hosted-sync change-log pull, filtered per recipient for 2.2 row-level
|
|
2027
|
+
* security (the hosted server's sole enforcement mechanism). Returns
|
|
2028
|
+
* `__lattice_change_log` rows with seq > `since` for team `teamId` that
|
|
2029
|
+
* `userId` is permitted to receive:
|
|
2030
|
+
* - targeted envelopes (`recipient_user_id = userId`), plus
|
|
2031
|
+
* - broadcast envelopes (`recipient_user_id IS NULL`) that are either
|
|
2032
|
+
* table-level (`pk IS NULL` — schema / unshare, delivered to all) or
|
|
2033
|
+
* whose row is currently visible to the user via `__lattice_row_acl` /
|
|
2034
|
+
* `__lattice_row_grants` (or has no ACL entry and the table defaults to
|
|
2035
|
+
* 'everyone').
|
|
2036
|
+
* Ordered by seq, capped at `limit`. Raw SQL because the predicate needs
|
|
2037
|
+
* OR / EXISTS that the `query()` API can't express; bounded by the seq
|
|
2038
|
+
* window and indexed ACL point-lookups. Mirrors {@link queryVisible}'s
|
|
2039
|
+
* visibility logic so a member never pulls the bytes of a row they can't see.
|
|
2040
|
+
*/
|
|
2041
|
+
listChangesForRecipient(teamId: string, since: number, userId: string, limit: number): Promise<Row[]>;
|
|
1972
2042
|
count(table: string, opts?: CountOptions): Promise<number>;
|
|
1973
2043
|
render(outputDir: string): Promise<RenderResult>;
|
|
1974
2044
|
sync(outputDir: string): Promise<SyncResult>;
|
|
@@ -3722,6 +3792,9 @@ declare class TeamsClient {
|
|
|
3722
3792
|
* members join via `redeemInvite`). Returns the new user + bearer
|
|
3723
3793
|
* token + team summary so the caller can immediately save a
|
|
3724
3794
|
* connection.
|
|
3795
|
+
*
|
|
3796
|
+
* @param teamName The workspace display name (stored as `team_name` for
|
|
3797
|
+
* backward compatibility — a cloud IS a workspace with members).
|
|
3725
3798
|
*/
|
|
3726
3799
|
register(cloudUrl: string, email: string, name: string, teamName: string): Promise<RegisterResponse>;
|
|
3727
3800
|
redeemInvite(cloudUrl: string, inviteToken: string, email: string, name: string): Promise<RedeemResponse>;
|
|
@@ -3753,15 +3826,18 @@ declare class TeamsClient {
|
|
|
3753
3826
|
};
|
|
3754
3827
|
}>;
|
|
3755
3828
|
/**
|
|
3756
|
-
*
|
|
3757
|
-
*
|
|
3829
|
+
* Initialize a fresh cloud DB's owner: register the first member (who
|
|
3830
|
+
* becomes owner) so the cloud's members + per-table sharing surface
|
|
3831
|
+
* exists. This is NOT a "convert a cloud into a team" step — a cloud
|
|
3832
|
+
* workspace IS a workspace with members; this just bootstraps the owner
|
|
3833
|
+
* the first time a cloud is opened. The hosted server path is the only
|
|
3834
|
+
* supported one:
|
|
3758
3835
|
*
|
|
3759
3836
|
* - `http(s)://…` — POST to the cloud's `/api/auth/register` endpoint
|
|
3760
|
-
* (`lattice serve
|
|
3761
|
-
* - `postgres(ql)://…` —
|
|
3762
|
-
*
|
|
3763
|
-
*
|
|
3764
|
-
* API refuses URLs with embedded credentials.
|
|
3837
|
+
* (a hosted `lattice serve` teams server is fronting the Postgres).
|
|
3838
|
+
* - `postgres(ql)://…` — rejected: direct postgres:// owner bootstrap
|
|
3839
|
+
* is deprecated. Row-level security is enforced by the hosted server,
|
|
3840
|
+
* so it is the only supported connection method for new workspaces.
|
|
3765
3841
|
*
|
|
3766
3842
|
* On success writes the bearer token to `~/.lattice/keys/<label>.token`
|
|
3767
3843
|
* **and** persists the local `__lattice_team_connections` row so the
|
|
@@ -3771,9 +3847,10 @@ declare class TeamsClient {
|
|
|
3771
3847
|
* the token file, leaving GUI authenticated calls with no
|
|
3772
3848
|
* `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
|
|
3773
3849
|
*/
|
|
3774
|
-
|
|
3850
|
+
registerCloudOwner(opts: {
|
|
3775
3851
|
label: string;
|
|
3776
3852
|
cloudUrl: string;
|
|
3853
|
+
/** Workspace display name (stored as `team_name` for backward compatibility). */
|
|
3777
3854
|
teamName: string;
|
|
3778
3855
|
email: string;
|
|
3779
3856
|
displayName: string;
|
|
@@ -4113,9 +4190,13 @@ declare function isPostgresUrl(url: string): boolean;
|
|
|
4113
4190
|
* - Refuses if any non-deleted user already exists on the cloud.
|
|
4114
4191
|
* - Refuses if the `__lattice_team_identity` singleton already exists.
|
|
4115
4192
|
*
|
|
4116
|
-
* On success returns the same shape the HTTP route returns so
|
|
4117
|
-
*
|
|
4118
|
-
*
|
|
4193
|
+
* On success returns the same shape the HTTP route returns so a
|
|
4194
|
+
* direct-postgres register can mirror the hosted register path.
|
|
4195
|
+
*
|
|
4196
|
+
* @deprecated Since 2.2 — new direct registrations are rejected (a direct
|
|
4197
|
+
* connection bypasses row-level security). Retained only for the
|
|
4198
|
+
* grandfathered existing-connection path; will be removed in 3.0. Create or
|
|
4199
|
+
* join new workspaces through a hosted Teams server (an `http(s)://` URL).
|
|
4119
4200
|
*/
|
|
4120
4201
|
declare function registerDirectViaPostgres(cloudUrl: string, email: string, name: string, teamName: string): Promise<DirectRegisterResult>;
|
|
4121
4202
|
|