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/dist/index.d.ts 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
- * Upgrade an already-connected cloud DB to a team DB. Two paths
3757
- * depending on the cloud URL's scheme:
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 --team-cloud` is fronting the Postgres).
3761
- * - `postgres(ql)://…` — drive the same INSERT sequence directly
3762
- * against the cloud Postgres via {@link registerDirectViaPostgres}.
3763
- * The HTTP path can't be used here because the browser's Fetch
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
- upgradeToTeamCloud(opts: {
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 the
4117
- * caller (`TeamsClient.upgradeToTeamCloud`) can use either path
4118
- * interchangeably.
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
 
package/dist/index.js CHANGED
@@ -4240,6 +4240,12 @@ function buildAdapter(dbPath, options) {
4240
4240
  return new SQLiteAdapter(sqlitePath, adapterOpts);
4241
4241
  }
4242
4242
  var NOT_DELETED2 = "(deleted_at IS NULL OR deleted_at = '')";
4243
+ function rowAclVisibleExists(teamExpr, tableExpr, pkExpr) {
4244
+ 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" = ?))))`;
4245
+ }
4246
+ function rowAclAbsent(teamExpr, tableExpr, pkExpr) {
4247
+ return `NOT EXISTS (SELECT 1 FROM "__lattice_row_acl" la2 WHERE la2."team_id" = ${teamExpr} AND la2."table_name" = ${tableExpr} AND la2."pk" = ${pkExpr})`;
4248
+ }
4243
4249
  var Lattice = class _Lattice {
4244
4250
  _adapter;
4245
4251
  _changelogService;
@@ -5339,6 +5345,131 @@ var Lattice = class _Lattice {
5339
5345
  const rows = await allAsyncOrSync(this._adapter, sql, params);
5340
5346
  return this._decryptRows(table, rows);
5341
5347
  }
5348
+ /**
5349
+ * Row-level-security list read for Lattice Teams (2.2). Returns only the
5350
+ * rows of `table` that `userId` may see in team `teamId`, evaluated
5351
+ * entirely in SQL (indexed, bounded — never "load every row then filter
5352
+ * in JS"). A row is visible iff it has a `__lattice_row_acl` entry owned by
5353
+ * the user or marked 'everyone', or a 'custom' entry with a matching
5354
+ * `__lattice_row_grants` row, OR it has no ACL entry at all and the caller
5355
+ * passes `noAclVisible` (the table default is 'everyone', or the user owns
5356
+ * the table — the pre-2.2 / never-narrowed case). Soft-deleted rows are
5357
+ * excluded by default; results reuse the same decrypt path as `query()`.
5358
+ *
5359
+ * The ACL predicate joins on the table's primary-key column cast to TEXT
5360
+ * (ACL pks are stored as TEXT), so it is correct regardless of the user
5361
+ * table's pk type and works on both SQLite and Postgres. The teams layer's
5362
+ * `listVisibleRows` (src/teams/row-access.ts) is the intended caller.
5363
+ */
5364
+ async queryVisible(table, opts) {
5365
+ const notInit = this._notInitError();
5366
+ if (notInit) return notInit;
5367
+ this._assertIdent(table);
5368
+ if (opts.orderBy) this._assertIdent(table, opts.orderBy);
5369
+ const cols = this._ensureColumnCache(table);
5370
+ const pkCol = this._schema.getPrimaryKey(table)[0] ?? "id";
5371
+ let softDelete = "";
5372
+ if (cols.has("deleted_at") && opts.deleted !== "any") {
5373
+ softDelete = opts.deleted === "only" ? `t."deleted_at" IS NOT NULL AND ` : `t."deleted_at" IS NULL AND `;
5374
+ }
5375
+ const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5376
+ let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${rowAclVisibleExists("?", "?", pkExpr)}`;
5377
+ const params = [opts.teamId, table, opts.userId, opts.userId];
5378
+ if (opts.noAclVisible) {
5379
+ sql += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5380
+ params.push(opts.teamId, table);
5381
+ }
5382
+ sql += `)`;
5383
+ if (opts.orderBy && cols.has(opts.orderBy)) {
5384
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
5385
+ sql += ` ORDER BY t."${opts.orderBy}" ${dir}`;
5386
+ }
5387
+ if (opts.limit !== void 0 && Number.isFinite(opts.limit)) {
5388
+ sql += ` LIMIT ${Math.trunc(opts.limit).toString()}`;
5389
+ }
5390
+ if (opts.offset !== void 0 && Number.isFinite(opts.offset)) {
5391
+ if (opts.limit === void 0 && this.getDialect() === "sqlite") sql += " LIMIT -1";
5392
+ sql += ` OFFSET ${Math.trunc(opts.offset).toString()}`;
5393
+ }
5394
+ const rows = await allAsyncOrSync(this._adapter, sql, params);
5395
+ return this._decryptRows(table, rows);
5396
+ }
5397
+ /**
5398
+ * Visible-row counts for MANY tables in a single round-trip, using the same
5399
+ * ACL predicate as {@link queryVisible} — so dashboard tiles agree with what
5400
+ * the rows view lists and a physical count never reveals the existence or
5401
+ * volume of rows the user can't see. One aggregated
5402
+ * `SELECT (SELECT COUNT(*) …) AS c0, …` statement (no per-table fan-out, so
5403
+ * a session pooler with few slots survives concurrent refreshes), capped at
5404
+ * 50 tables per pass; overflow is logged and skipped (no silent truncation)
5405
+ * and those tables count as absent — the caller renders "—". Soft-deleted
5406
+ * rows are excluded wherever the table carries `deleted_at`, matching the
5407
+ * default rows view.
5408
+ */
5409
+ async countVisibleMany(specs, opts) {
5410
+ const out = /* @__PURE__ */ new Map();
5411
+ const notInit = this._notInitError();
5412
+ if (notInit) return notInit;
5413
+ if (specs.length === 0) return out;
5414
+ const VISIBLE_COUNT_CAP = 50;
5415
+ let bounded = specs;
5416
+ if (bounded.length > VISIBLE_COUNT_CAP) {
5417
+ const dropped = bounded.length - VISIBLE_COUNT_CAP;
5418
+ console.warn(
5419
+ `[lattice] visible-count pass capped at ${String(VISIBLE_COUNT_CAP)} tables; ${String(dropped)} table(s) report no count this pass`
5420
+ );
5421
+ bounded = bounded.slice(0, VISIBLE_COUNT_CAP);
5422
+ }
5423
+ const selects = [];
5424
+ const params = [];
5425
+ for (const [i, spec] of bounded.entries()) {
5426
+ this._assertIdent(spec.table);
5427
+ const cols = this._ensureColumnCache(spec.table);
5428
+ const pkCol = this._schema.getPrimaryKey(spec.table)[0] ?? "id";
5429
+ const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
5430
+ const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5431
+ let predicate = rowAclVisibleExists("?", "?", pkExpr);
5432
+ params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5433
+ if (spec.noAclVisible) {
5434
+ predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5435
+ params.push(opts.teamId, spec.table);
5436
+ }
5437
+ selects.push(
5438
+ `(SELECT COUNT(*) FROM "${spec.table}" t WHERE ${softDelete}(${predicate})) AS c${String(i)}`
5439
+ );
5440
+ }
5441
+ const row = await getAsyncOrSync(this._adapter, `SELECT ${selects.join(", ")}`, params);
5442
+ if (!row) return out;
5443
+ for (const [i, spec] of bounded.entries()) {
5444
+ const raw = row[`c${String(i)}`];
5445
+ const n = typeof raw === "bigint" ? Number(raw) : Number(raw);
5446
+ if (Number.isFinite(n) && n >= 0) out.set(spec.table, n);
5447
+ }
5448
+ return out;
5449
+ }
5450
+ /**
5451
+ * Hosted-sync change-log pull, filtered per recipient for 2.2 row-level
5452
+ * security (the hosted server's sole enforcement mechanism). Returns
5453
+ * `__lattice_change_log` rows with seq > `since` for team `teamId` that
5454
+ * `userId` is permitted to receive:
5455
+ * - targeted envelopes (`recipient_user_id = userId`), plus
5456
+ * - broadcast envelopes (`recipient_user_id IS NULL`) that are either
5457
+ * table-level (`pk IS NULL` — schema / unshare, delivered to all) or
5458
+ * whose row is currently visible to the user via `__lattice_row_acl` /
5459
+ * `__lattice_row_grants` (or has no ACL entry and the table defaults to
5460
+ * 'everyone').
5461
+ * Ordered by seq, capped at `limit`. Raw SQL because the predicate needs
5462
+ * OR / EXISTS that the `query()` API can't express; bounded by the seq
5463
+ * window and indexed ACL point-lookups. Mirrors {@link queryVisible}'s
5464
+ * visibility logic so a member never pulls the bytes of a row they can't see.
5465
+ */
5466
+ async listChangesForRecipient(teamId, since, userId, limit) {
5467
+ const notInit = this._notInitError();
5468
+ if (notInit) return notInit;
5469
+ 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()}`;
5470
+ const params = [teamId, since, userId, userId, userId];
5471
+ return allAsyncOrSync(this._adapter, sql, params);
5472
+ }
5342
5473
  async count(table, opts = {}) {
5343
5474
  const notInit = this._notInitError();
5344
5475
  if (notInit) return notInit;
@@ -7060,7 +7191,13 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
7060
7191
  // Client-generated idempotency key for offline replay: a queued edit
7061
7192
  // carries a stable edit_id, so re-sending it after a reconnect is a
7062
7193
  // no-op rather than a duplicate write. Nullable + additive.
7063
- edit_id: "TEXT"
7194
+ edit_id: "TEXT",
7195
+ // Per-recipient targeting for 2.2 hard row-level sync. NULL =
7196
+ // broadcast (delivered to every member, then filtered at pull time
7197
+ // against __lattice_row_acl); non-null = targeted to exactly this
7198
+ // user (the grant / revoke / delete fan-out). Nullable + additive,
7199
+ // same precedent as client_ts / edit_id.
7200
+ recipient_user_id: "TEXT"
7064
7201
  },
7065
7202
  render: () => "",
7066
7203
  outputFile: ".lattice-teams/change-log.md"
@@ -7076,6 +7213,44 @@ var CLOUD_INTERNAL_TABLE_DEFS = {
7076
7213
  primaryKey: ["team_id", "table_name", "pk"],
7077
7214
  render: () => "",
7078
7215
  outputFile: ".lattice-teams/row-links.md"
7216
+ },
7217
+ // Per-row access control for a team cloud (2.2 row-level permissions).
7218
+ // Mirrors __lattice_object_owners at row granularity: each shared row
7219
+ // has an owner (its creator) and a visibility. Enforcement is at the
7220
+ // application layer (see src/teams/row-access.ts) — every member shares
7221
+ // the same physical DB, so a row a user can't see must be filtered out
7222
+ // before its bytes reach them. Kept out-of-band (never injected into
7223
+ // user tables) so the user's own schema stays untouched.
7224
+ __lattice_row_acl: {
7225
+ columns: {
7226
+ team_id: "TEXT NOT NULL",
7227
+ table_name: "TEXT NOT NULL",
7228
+ pk: "TEXT NOT NULL",
7229
+ owner_user_id: "TEXT NOT NULL",
7230
+ // 'private' = owner only · 'everyone' = all team members ·
7231
+ // 'custom' = the explicit grant list in __lattice_row_grants.
7232
+ visibility: "TEXT NOT NULL CHECK (visibility IN ('private', 'everyone', 'custom'))",
7233
+ created_at: "TEXT NOT NULL",
7234
+ updated_at: "TEXT NOT NULL"
7235
+ },
7236
+ primaryKey: ["team_id", "table_name", "pk"],
7237
+ render: () => "",
7238
+ outputFile: ".lattice-teams/row-acl.md"
7239
+ },
7240
+ // Explicit per-row grant list, consulted only when the owning row's
7241
+ // __lattice_row_acl.visibility = 'custom'. One row per (row, grantee).
7242
+ __lattice_row_grants: {
7243
+ columns: {
7244
+ team_id: "TEXT NOT NULL",
7245
+ table_name: "TEXT NOT NULL",
7246
+ pk: "TEXT NOT NULL",
7247
+ grantee_user_id: "TEXT NOT NULL",
7248
+ granted_by_user_id: "TEXT NOT NULL",
7249
+ granted_at: "TEXT NOT NULL"
7250
+ },
7251
+ primaryKey: ["team_id", "table_name", "pk", "grantee_user_id"],
7252
+ render: () => "",
7253
+ outputFile: ".lattice-teams/row-grants.md"
7079
7254
  }
7080
7255
  };
7081
7256
  var LOCAL_INTERNAL_TABLE_DEFS = {
@@ -7176,6 +7351,48 @@ async function installCloudInternalTriggers(db) {
7176
7351
  };
7177
7352
  await db.migrate([migration]);
7178
7353
  }
7354
+ async function installRowPermsSchema(db) {
7355
+ const migrations = [
7356
+ {
7357
+ // 01 — per-table default visibility for newly-created rows. Born
7358
+ // 'private' unless the table owner opts the table into 'everyone';
7359
+ // the backfill (06) flips already-shared tables to preserve pre-2.2
7360
+ // visibility. No IF NOT EXISTS: the version guard runs this exactly
7361
+ // once, which is what SQLite's ADD COLUMN needs (it has no
7362
+ // IF NOT EXISTS form).
7363
+ version: "internal:row-perms:01-default-row-visibility:v1",
7364
+ sql: `ALTER TABLE "__lattice_shared_objects" ADD COLUMN "default_row_visibility" TEXT NOT NULL DEFAULT 'private' CHECK ("default_row_visibility" IN ('private', 'everyone'))`
7365
+ },
7366
+ {
7367
+ // 02-05 — indexes. One CREATE INDEX per migration (single-statement
7368
+ // for the SQLite path). IF NOT EXISTS is valid in both dialects.
7369
+ version: "internal:row-perms:02-idx-change-log-team-seq:v1",
7370
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_seq" ON "__lattice_change_log" ("team_id", "seq")`
7371
+ },
7372
+ {
7373
+ version: "internal:row-perms:03-idx-change-log-team-recipient-seq:v1",
7374
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_recipient_seq" ON "__lattice_change_log" ("team_id", "recipient_user_id", "seq")`
7375
+ },
7376
+ {
7377
+ version: "internal:row-perms:04-idx-change-log-team-table-pk:v1",
7378
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_change_log_team_table_pk" ON "__lattice_change_log" ("team_id", "table_name", "pk")`
7379
+ },
7380
+ {
7381
+ version: "internal:row-perms:05-idx-row-grants-grantee:v1",
7382
+ sql: `CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee" ON "__lattice_row_grants" ("team_id", "grantee_user_id", "table_name", "pk")`
7383
+ },
7384
+ {
7385
+ // 06 — upgrade backfill. Every already-shared table defaults to
7386
+ // 'everyone' so pre-2.2 rows stay visible to all members (nothing
7387
+ // disappears on upgrade). Pre-2.2 rows have no __lattice_row_acl
7388
+ // entry; resolveRowAcl() folds in this table default, so they read
7389
+ // as 'everyone' until an owner narrows an individual row. Runs once.
7390
+ version: "internal:row-perms:06-backfill-shared-defaults:v1",
7391
+ sql: `UPDATE "__lattice_shared_objects" SET "default_row_visibility" = 'everyone' WHERE "deleted_at" IS NULL`
7392
+ }
7393
+ ];
7394
+ await db.migrate(migrations);
7395
+ }
7179
7396
 
7180
7397
  // src/teams/schema-spec.ts
7181
7398
  function renderColumnType(spec, dialect) {
@@ -7531,7 +7748,8 @@ async function appendChangeEnvelope(db, entry) {
7531
7748
  owner_user_id: entry.owner_user_id ?? null,
7532
7749
  created_at: now,
7533
7750
  client_ts: entry.client_ts ?? now,
7534
- edit_id: entry.edit_id ?? null
7751
+ edit_id: entry.edit_id ?? null,
7752
+ recipient_user_id: entry.recipient_user_id ?? null
7535
7753
  });
7536
7754
  return seq;
7537
7755
  }
@@ -7571,7 +7789,14 @@ async function shareObject(db, teamId, createdByUserId, table, spec) {
7571
7789
  created_by_user_id: createdByUserId,
7572
7790
  created_at: prior?.created_at ?? now,
7573
7791
  updated_at: now,
7574
- deleted_at: null
7792
+ deleted_at: null,
7793
+ // 2.2: a freshly-shared table defaults to 'everyone' so the existing
7794
+ // "share a table → every member sees its rows" contract is preserved.
7795
+ // The owner can narrow the table default (or individual rows) afterward.
7796
+ // Re-share (the branch above) intentionally omits this so an owner's
7797
+ // earlier choice survives a schema bump (the upsert only sets the
7798
+ // columns it lists).
7799
+ default_row_visibility: "everyone"
7575
7800
  });
7576
7801
  }
7577
7802
  await applySchemaSpec(db, table, outSpec);
@@ -7665,88 +7890,26 @@ async function destroyTeamDirect(db) {
7665
7890
  }
7666
7891
  await db.delete("__lattice_team_identity", "singleton");
7667
7892
  }
7668
- async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
7669
- if (!isPostgresUrl(cloudUrl)) {
7670
- throw new Error(
7671
- `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
7672
- );
7673
- }
7674
- const db = new Lattice(cloudUrl);
7675
- try {
7676
- await db.init();
7677
- for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
7678
- await db.defineLate(table, def);
7679
- }
7680
- await installCloudInternalTriggers(db);
7681
- const invites = await db.query("__lattice_invitations", {
7682
- filters: [
7683
- { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
7684
- { col: "redeemed_at", op: "isNull" }
7685
- ],
7686
- limit: 1
7687
- });
7688
- const invite = invites[0];
7689
- if (!invite) {
7690
- throw new Error("Invitation invalid or already used");
7691
- }
7692
- if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
7693
- throw new Error("Invitation expired");
7694
- }
7695
- if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
7696
- throw new Error("Invitation is addressed to a different email");
7697
- }
7698
- const team = await db.get("__lattice_team", invite.team_id);
7699
- if (!team || team.deleted_at) {
7700
- throw new Error("Team no longer exists");
7701
- }
7702
- const now = (/* @__PURE__ */ new Date()).toISOString();
7703
- const userId = await db.insert("__lattice_users", {
7704
- email,
7705
- name,
7706
- created_at: now,
7707
- updated_at: now
7708
- });
7709
- await db.insert("__lattice_team_members", {
7710
- team_id: invite.team_id,
7711
- user_id: userId,
7712
- role: "member",
7713
- joined_at: now
7714
- });
7715
- const { raw, hash } = generateToken();
7716
- await db.insert("__lattice_api_tokens", {
7717
- user_id: userId,
7718
- token_hash: hash,
7719
- name: `invited:${team.name}`,
7720
- created_at: now
7721
- });
7722
- await db.update("__lattice_invitations", invite.id, {
7723
- redeemed_at: now,
7724
- redeemed_by_user_id: userId
7725
- });
7726
- return {
7727
- user: { id: userId, email, name },
7728
- raw_token: raw,
7729
- team: { id: team.id, name: team.name }
7730
- };
7731
- } finally {
7732
- try {
7733
- db.close();
7734
- } catch {
7735
- }
7736
- }
7737
- }
7893
+ var directDeprecationWarned = false;
7738
7894
  async function openCloud(cloudUrl) {
7739
7895
  if (!isPostgresUrl(cloudUrl)) {
7740
7896
  throw new Error(
7741
7897
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
7742
7898
  );
7743
7899
  }
7900
+ if (!directDeprecationWarned) {
7901
+ directDeprecationWarned = true;
7902
+ console.warn(
7903
+ "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
7904
+ );
7905
+ }
7744
7906
  const db = new Lattice(cloudUrl);
7745
7907
  await db.init();
7746
7908
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
7747
7909
  await db.defineLate(table, def);
7748
7910
  }
7749
7911
  await installCloudInternalTriggers(db);
7912
+ await installRowPermsSchema(db);
7750
7913
  return db;
7751
7914
  }
7752
7915
  function closeQuiet(db) {
@@ -7857,6 +8020,7 @@ async function unlinkRowDirect(local, cloudUrl, teamId, table, pk) {
7857
8020
  }
7858
8021
 
7859
8022
  // src/teams/client.ts
8023
+ 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.";
7860
8024
  var TeamsClient = class {
7861
8025
  constructor(local) {
7862
8026
  this.local = local;
@@ -7889,6 +8053,9 @@ var TeamsClient = class {
7889
8053
  * members join via `redeemInvite`). Returns the new user + bearer
7890
8054
  * token + team summary so the caller can immediately save a
7891
8055
  * connection.
8056
+ *
8057
+ * @param teamName The workspace display name (stored as `team_name` for
8058
+ * backward compatibility — a cloud IS a workspace with members).
7892
8059
  */
7893
8060
  async register(cloudUrl, email, name, teamName) {
7894
8061
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/register", {
@@ -7899,7 +8066,7 @@ var TeamsClient = class {
7899
8066
  }
7900
8067
  async redeemInvite(cloudUrl, inviteToken, email, name) {
7901
8068
  if (isPostgresUrl(cloudUrl)) {
7902
- return redeemInviteDirect(cloudUrl, inviteToken, email, name);
8069
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
7903
8070
  }
7904
8071
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
7905
8072
  invite_token: inviteToken,
@@ -7910,9 +8077,11 @@ var TeamsClient = class {
7910
8077
  // ── High-level orchestration (v1.13+) ───────────────────────────────────
7911
8078
  // Wraps the multi-step flows the GUI's Database panel + library
7912
8079
  // consumers both need: connecting to an existing cloud DB (with
7913
- // optional team join), and upgrading a non-team cloud into a team
7914
- // cloud. The HTTP routes in src/gui/dbconfig-routes.ts are thin
7915
- // shells over these methods.
8080
+ // optional team join), and initializing a fresh cloud DB's owner so
8081
+ // its members + per-table sharing surface exists. A cloud workspace IS
8082
+ // a workspace with members — there is no separate "team" to convert to.
8083
+ // The HTTP routes in src/gui/dbconfig-routes.ts are thin shells over
8084
+ // these methods.
7916
8085
  /**
7917
8086
  * Connect a local project to an existing cloud DB by URL. Probes
7918
8087
  * the target for team status first; if it's a teams DB, the caller
@@ -7961,15 +8130,18 @@ var TeamsClient = class {
7961
8130
  return { probe };
7962
8131
  }
7963
8132
  /**
7964
- * Upgrade an already-connected cloud DB to a team DB. Two paths
7965
- * depending on the cloud URL's scheme:
8133
+ * Initialize a fresh cloud DB's owner: register the first member (who
8134
+ * becomes owner) so the cloud's members + per-table sharing surface
8135
+ * exists. This is NOT a "convert a cloud into a team" step — a cloud
8136
+ * workspace IS a workspace with members; this just bootstraps the owner
8137
+ * the first time a cloud is opened. The hosted server path is the only
8138
+ * supported one:
7966
8139
  *
7967
8140
  * - `http(s)://…` — POST to the cloud's `/api/auth/register` endpoint
7968
- * (`lattice serve --team-cloud` is fronting the Postgres).
7969
- * - `postgres(ql)://…` — drive the same INSERT sequence directly
7970
- * against the cloud Postgres via {@link registerDirectViaPostgres}.
7971
- * The HTTP path can't be used here because the browser's Fetch
7972
- * API refuses URLs with embedded credentials.
8141
+ * (a hosted `lattice serve` teams server is fronting the Postgres).
8142
+ * - `postgres(ql)://…` — rejected: direct postgres:// owner bootstrap
8143
+ * is deprecated. Row-level security is enforced by the hosted server,
8144
+ * so it is the only supported connection method for new workspaces.
7973
8145
  *
7974
8146
  * On success writes the bearer token to `~/.lattice/keys/<label>.token`
7975
8147
  * **and** persists the local `__lattice_team_connections` row so the
@@ -7979,8 +8151,11 @@ var TeamsClient = class {
7979
8151
  * the token file, leaving GUI authenticated calls with no
7980
8152
  * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
7981
8153
  */
7982
- async upgradeToTeamCloud(opts) {
7983
- const reg = isPostgresUrl(opts.cloudUrl) ? await registerDirectViaPostgres(opts.cloudUrl, opts.email, opts.displayName, opts.teamName) : await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
8154
+ async registerCloudOwner(opts) {
8155
+ if (isPostgresUrl(opts.cloudUrl)) {
8156
+ throw new Error(DIRECT_CLOUD_DEPRECATION_MESSAGE);
8157
+ }
8158
+ const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
7984
8159
  writeToken(opts.label, reg.raw_token);
7985
8160
  await this.saveConnection({
7986
8161
  team_id: reg.team.id,
@@ -8013,7 +8188,7 @@ var TeamsClient = class {
8013
8188
  }
8014
8189
  try {
8015
8190
  const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
8016
- await this.upgradeToTeamCloud({
8191
+ await this.registerCloudOwner({
8017
8192
  label: opts.label,
8018
8193
  cloudUrl: opts.cloudUrl,
8019
8194
  teamName: opts.workspaceName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",