latticesql 3.0.0 → 3.1.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/cli.js CHANGED
@@ -3590,7 +3590,7 @@ var init_getProfileName = __esm({
3590
3590
  });
3591
3591
 
3592
3592
  // node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getSSOTokenFilepath.js
3593
- import { createHash as createHash2 } from "crypto";
3593
+ import { createHash as createHash3 } from "crypto";
3594
3594
  import { join as join17 } from "path";
3595
3595
  var getSSOTokenFilepath;
3596
3596
  var init_getSSOTokenFilepath = __esm({
@@ -3598,7 +3598,7 @@ var init_getSSOTokenFilepath = __esm({
3598
3598
  "use strict";
3599
3599
  init_getHomeDir();
3600
3600
  getSSOTokenFilepath = (id) => {
3601
- const hasher = createHash2("sha1");
3601
+ const hasher = createHash3("sha1");
3602
3602
  const cacheName = hasher.update(id).digest("hex");
3603
3603
  return join17(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);
3604
3604
  };
@@ -5374,7 +5374,7 @@ var init_endpoints2 = __esm({
5374
5374
  });
5375
5375
 
5376
5376
  // node_modules/@smithy/core/dist-es/submodules/serde/hash-node/hash-node.js
5377
- import { createHash as createHash3, createHmac } from "crypto";
5377
+ import { createHash as createHash4, createHmac } from "crypto";
5378
5378
  function castSourceData(toCast, encoding) {
5379
5379
  if (Buffer.isBuffer(toCast)) {
5380
5380
  return toCast;
@@ -5409,7 +5409,7 @@ var init_hash_node = __esm({
5409
5409
  return Promise.resolve(this.hash.digest());
5410
5410
  }
5411
5411
  reset() {
5412
- this.hash = this.secret ? createHmac(this.algorithmIdentifier, castSourceData(this.secret)) : createHash3(this.algorithmIdentifier);
5412
+ this.hash = this.secret ? createHmac(this.algorithmIdentifier, castSourceData(this.secret)) : createHash4(this.algorithmIdentifier);
5413
5413
  }
5414
5414
  };
5415
5415
  }
@@ -34053,7 +34053,7 @@ var init_signin = __esm({
34053
34053
  });
34054
34054
 
34055
34055
  // node_modules/@aws-sdk/credential-provider-login/dist-es/LoginCredentialsFetcher.js
34056
- import { createHash as createHash4, createPrivateKey, createPublicKey, sign } from "crypto";
34056
+ import { createHash as createHash5, createPrivateKey, createPublicKey, sign } from "crypto";
34057
34057
  import { promises as fs2 } from "fs";
34058
34058
  import { homedir as homedir5 } from "os";
34059
34059
  import { dirname as dirname10, join as join22 } from "path";
@@ -34220,7 +34220,7 @@ var init_LoginCredentialsFetcher = __esm({
34220
34220
  getTokenFilePath() {
34221
34221
  const directory = process.env.AWS_LOGIN_CACHE_DIRECTORY ?? join22(homedir5(), ".aws", "login", "cache");
34222
34222
  const loginSessionBytes = Buffer.from(this.loginSession, "utf8");
34223
- const loginSessionSha256 = createHash4("sha256").update(loginSessionBytes).digest("hex");
34223
+ const loginSessionSha256 = createHash5("sha256").update(loginSessionBytes).digest("hex");
34224
34224
  return join22(directory, `${loginSessionSha256}.json`);
34225
34225
  }
34226
34226
  derToRawSignature(derSignature) {
@@ -42123,7 +42123,42 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
42123
42123
  return result;
42124
42124
  }
42125
42125
 
42126
+ // src/render/progress.ts
42127
+ var THROTTLE_WINDOW_MS = 200;
42128
+ var ProgressThrottle = class {
42129
+ cb;
42130
+ windowMs;
42131
+ lastEmit = 0;
42132
+ constructor(cb, windowMs = THROTTLE_WINDOW_MS) {
42133
+ this.cb = cb;
42134
+ this.windowMs = windowMs;
42135
+ }
42136
+ /**
42137
+ * Emit a `table-progress` event, but only if the window since the last
42138
+ * passthrough has elapsed. Dropped events are simply not delivered — the next
42139
+ * one that survives carries the latest running count.
42140
+ */
42141
+ tick(event) {
42142
+ if (!this.cb) return;
42143
+ const now = Date.now();
42144
+ if (now - this.lastEmit < this.windowMs) return;
42145
+ this.lastEmit = now;
42146
+ this.cb(event);
42147
+ }
42148
+ /**
42149
+ * Emit a lifecycle event immediately and reset the throttle window. Use for
42150
+ * `table-start`, `table-done`, `done`, and `error` — none of which should
42151
+ * ever be dropped. Resetting on `table-start` gives each table a clean budget.
42152
+ */
42153
+ force(event) {
42154
+ if (!this.cb) return;
42155
+ this.lastEmit = Date.now();
42156
+ this.cb(event);
42157
+ }
42158
+ };
42159
+
42126
42160
  // src/render/engine.ts
42161
+ var YIELD_EVERY_ENTITIES = 200;
42127
42162
  var NOOP_RENDER = () => "";
42128
42163
  var RenderEngine = class {
42129
42164
  _schema;
@@ -42137,11 +42172,14 @@ var RenderEngine = class {
42137
42172
  this._getTaskContext = getTaskContext ?? (() => "");
42138
42173
  this._skipEmpty = options?.skipEmpty ?? false;
42139
42174
  }
42140
- async render(outputDir) {
42175
+ async render(outputDir, opts = {}) {
42141
42176
  const start = Date.now();
42142
42177
  const filesWritten = [];
42143
42178
  const counters = { skipped: 0 };
42179
+ const signal = opts.signal;
42180
+ const throttle = new ProgressThrottle(opts.onProgress);
42144
42181
  for (const [name, def] of this._schema.getTables()) {
42182
+ if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
42145
42183
  if (this._skipEmpty && def.render === NOOP_RENDER) continue;
42146
42184
  let rows = await this._schema.queryTable(this._adapter, name);
42147
42185
  if (def.relevanceFilter) {
@@ -42183,8 +42221,18 @@ var RenderEngine = class {
42183
42221
  } else {
42184
42222
  counters.skipped++;
42185
42223
  }
42224
+ throttle.force({
42225
+ kind: "table-done",
42226
+ table: name,
42227
+ entitiesRendered: rows.length,
42228
+ entitiesTotal: rows.length,
42229
+ tableIndex: 0,
42230
+ tableCount: 0,
42231
+ pct: 100
42232
+ });
42186
42233
  }
42187
- for (const [, def] of this._schema.getMultis()) {
42234
+ for (const [name, def] of this._schema.getMultis()) {
42235
+ if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
42188
42236
  const keys = await def.keys();
42189
42237
  const tables = {};
42190
42238
  if (def.tables) {
@@ -42201,12 +42249,26 @@ var RenderEngine = class {
42201
42249
  counters.skipped++;
42202
42250
  }
42203
42251
  }
42252
+ throttle.force({
42253
+ kind: "table-done",
42254
+ table: name,
42255
+ entitiesRendered: keys.length,
42256
+ entitiesTotal: keys.length,
42257
+ tableIndex: 0,
42258
+ tableCount: 0,
42259
+ pct: 100
42260
+ });
42204
42261
  }
42205
42262
  const entityContextManifest = await this._renderEntityContexts(
42206
42263
  outputDir,
42207
42264
  filesWritten,
42208
- counters
42265
+ counters,
42266
+ throttle,
42267
+ signal
42209
42268
  );
42269
+ if (entityContextManifest === null) {
42270
+ return this._abortedResult(filesWritten, counters, start);
42271
+ }
42210
42272
  if (this._schema.getEntityContexts().size > 0) {
42211
42273
  writeManifest(outputDir, {
42212
42274
  version: 2,
@@ -42214,6 +42276,29 @@ var RenderEngine = class {
42214
42276
  entityContexts: entityContextManifest
42215
42277
  });
42216
42278
  }
42279
+ const result = {
42280
+ filesWritten,
42281
+ filesSkipped: counters.skipped,
42282
+ durationMs: Date.now() - start
42283
+ };
42284
+ throttle.force({
42285
+ kind: "done",
42286
+ table: null,
42287
+ entitiesRendered: 0,
42288
+ entitiesTotal: 0,
42289
+ tableIndex: 0,
42290
+ tableCount: 0,
42291
+ pct: 100,
42292
+ durationMs: result.durationMs
42293
+ });
42294
+ return result;
42295
+ }
42296
+ /**
42297
+ * Build the partial RenderResult to return when a render is aborted. No
42298
+ * `done` event is emitted — the caller treats abort as "discard the partial
42299
+ * tree", not as a successful completion.
42300
+ */
42301
+ _abortedResult(filesWritten, counters, start) {
42217
42302
  return {
42218
42303
  filesWritten,
42219
42304
  filesSkipped: counters.skipped,
@@ -42249,18 +42334,35 @@ var RenderEngine = class {
42249
42334
  /**
42250
42335
  * Render all entity context definitions.
42251
42336
  * Mutates `filesWritten` and `counters` in place.
42252
- * Returns manifest data for the entity contexts rendered this cycle.
42337
+ * Returns manifest data for the entity contexts rendered this cycle, or
42338
+ * `null` if the render was aborted mid-flight (the caller discards the
42339
+ * partial tree). Progress is reported through `throttle`; abort is observed
42340
+ * via `signal`.
42253
42341
  */
42254
- async _renderEntityContexts(outputDir, filesWritten, counters) {
42342
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
42255
42343
  const manifestData = {};
42256
42344
  const protectedTables = /* @__PURE__ */ new Set();
42257
42345
  for (const [t8, d6] of this._schema.getEntityContexts()) {
42258
42346
  if (d6.protected) protectedTables.add(t8);
42259
42347
  }
42260
- for (const [table, def] of this._schema.getEntityContexts()) {
42348
+ const entityTables = [...this._schema.getEntityContexts()];
42349
+ const tableCount = entityTables.length;
42350
+ for (let tableIndex = 0; tableIndex < tableCount; tableIndex++) {
42351
+ if (signal?.aborted) return null;
42352
+ const [table, def] = entityTables[tableIndex];
42261
42353
  const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
42262
42354
  const allRows = await this._schema.queryTable(this._adapter, table);
42263
42355
  const directoryRoot = def.directoryRoot ?? table;
42356
+ const entitiesTotal = allRows.length;
42357
+ throttle.force({
42358
+ kind: "table-start",
42359
+ table,
42360
+ entitiesRendered: 0,
42361
+ entitiesTotal,
42362
+ tableIndex,
42363
+ tableCount,
42364
+ pct: 0
42365
+ });
42264
42366
  const manifestEntry = {
42265
42367
  directoryRoot,
42266
42368
  ...def.index ? { indexFile: def.index.outputFile } : {},
@@ -42276,7 +42378,12 @@ var RenderEngine = class {
42276
42378
  counters.skipped++;
42277
42379
  }
42278
42380
  }
42279
- for (const entityRow of allRows) {
42381
+ for (let i6 = 0; i6 < allRows.length; i6++) {
42382
+ const entityRow = allRows[i6];
42383
+ if (signal?.aborted) return null;
42384
+ if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
42385
+ await new Promise((r6) => setImmediate(r6));
42386
+ }
42280
42387
  const rawSlug = def.slug(entityRow);
42281
42388
  const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
42282
42389
  if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
@@ -42321,6 +42428,7 @@ var RenderEngine = class {
42321
42428
  const entityFileHashes = {};
42322
42429
  const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
42323
42430
  for (const [filename, spec] of Object.entries(def.files)) {
42431
+ if (signal?.aborted) return null;
42324
42432
  const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
42325
42433
  const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
42326
42434
  const rows = await resolveEntitySource(
@@ -42368,8 +42476,27 @@ var RenderEngine = class {
42368
42476
  }
42369
42477
  }
42370
42478
  manifestEntry.entities[slug] = entityFileHashes;
42479
+ const entitiesRendered = i6 + 1;
42480
+ throttle.tick({
42481
+ kind: "table-progress",
42482
+ table,
42483
+ entitiesRendered,
42484
+ entitiesTotal,
42485
+ tableIndex,
42486
+ tableCount,
42487
+ pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
42488
+ });
42371
42489
  }
42372
42490
  manifestData[table] = manifestEntry;
42491
+ throttle.force({
42492
+ kind: "table-done",
42493
+ table,
42494
+ entitiesRendered: entitiesTotal,
42495
+ entitiesTotal,
42496
+ tableIndex,
42497
+ tableCount,
42498
+ pct: 100
42499
+ });
42373
42500
  }
42374
42501
  return manifestData;
42375
42502
  }
@@ -44369,6 +44496,54 @@ var Lattice = class _Lattice {
44369
44496
  async insert(table, row, provenance) {
44370
44497
  const notInit = this._notInitError();
44371
44498
  if (notInit) return notInit;
44499
+ const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
44500
+ await runAsyncOrSync(this._adapter, sql, values);
44501
+ await this._afterInsert(table, pkValue, rowWithPk, provenance);
44502
+ return pkValue;
44503
+ }
44504
+ /**
44505
+ * Insert a row while atomically forcing its cloud row-visibility, regardless of
44506
+ * the table's `default_row_visibility`. The per-table insert trigger reads a
44507
+ * transaction-local GUC (`lattice.force_row_visibility`); we set it and run the
44508
+ * INSERT inside a single transaction, so the row is stamped at `visibility` the
44509
+ * instant it exists — it is never momentarily visible at the table default, and
44510
+ * the change-feed `NOTIFY` (delivered only at COMMIT) fires when the row already
44511
+ * carries this visibility. This closes the create-then-demote window that a
44512
+ * plain `insert()` + `setRowVisibility()` would leave open.
44513
+ *
44514
+ * Postgres-only: SQLite is single-user (no cross-viewer leak) and has no trigger
44515
+ * to read the GUC, so it degrades to a plain {@link insert}. A `never_share`
44516
+ * table still wins — its rows are forced private even if `visibility` is
44517
+ * `'everyone'` (the trigger enforces that precedence).
44518
+ *
44519
+ * @since 3.1.0
44520
+ */
44521
+ async insertForcingVisibility(table, row, visibility, provenance) {
44522
+ const notInit = this._notInitError();
44523
+ if (notInit) return notInit;
44524
+ const vis = visibility;
44525
+ if (vis !== "private" && vis !== "everyone") {
44526
+ throw new Error(`lattice: invalid forced visibility "${vis}"`);
44527
+ }
44528
+ const withClient = this._adapter.withClient?.bind(this._adapter);
44529
+ if (this.getDialect() !== "postgres" || !withClient) {
44530
+ return this.insert(table, row, provenance);
44531
+ }
44532
+ const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
44533
+ await withClient(async (tx) => {
44534
+ await tx.run(`SELECT set_config('lattice.force_row_visibility', ?, true)`, [visibility]);
44535
+ await tx.run(sql, values);
44536
+ });
44537
+ await this._afterInsert(table, pkValue, rowWithPk, provenance);
44538
+ return pkValue;
44539
+ }
44540
+ /**
44541
+ * Build the INSERT statement + canonical pk for a row (sanitize → schema-filter →
44542
+ * auto-pk → encrypt). Shared by {@link insert} and {@link insertForcingVisibility}
44543
+ * so both produce byte-identical writes; the latter only differs in running it
44544
+ * inside a GUC-scoped transaction.
44545
+ */
44546
+ _prepareInsert(table, row) {
44372
44547
  this._assertIdent(table);
44373
44548
  const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
44374
44549
  const pkCols = this._schema.getPrimaryKey(table);
@@ -44384,12 +44559,17 @@ var Lattice = class _Lattice {
44384
44559
  const cols = Object.keys(encrypted).map((c6) => `"${c6}"`).join(", ");
44385
44560
  const placeholders = Object.keys(encrypted).map(() => "?").join(", ");
44386
44561
  const values = Object.values(encrypted);
44387
- await runAsyncOrSync(
44388
- this._adapter,
44389
- `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
44390
- values
44391
- );
44392
44562
  const pkValue = this._serializeRowPk(table, rowWithPk);
44563
+ return {
44564
+ sql: `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
44565
+ values,
44566
+ pkValue,
44567
+ rowWithPk
44568
+ };
44569
+ }
44570
+ /** Post-insert side effects (changelog, audit, write hooks, embedding sync),
44571
+ * identical for the plain and force-visibility insert paths. */
44572
+ async _afterInsert(table, pkValue, rowWithPk, provenance) {
44393
44573
  await this._appendChangelog(
44394
44574
  table,
44395
44575
  pkValue,
@@ -44403,7 +44583,6 @@ var Lattice = class _Lattice {
44403
44583
  this._sanitizer.emitAudit(table, "insert", pkValue);
44404
44584
  await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
44405
44585
  this._syncEmbedding(table, "insert", rowWithPk, pkValue);
44406
- return pkValue;
44407
44586
  }
44408
44587
  /**
44409
44588
  * Insert a row and return the full inserted row (including auto-generated
@@ -44470,6 +44649,7 @@ var Lattice = class _Lattice {
44470
44649
  const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
44471
44650
  const encrypted = this._encryptRow(table, sanitized);
44472
44651
  const setCols = Object.keys(encrypted).map((c6) => `"${c6}" = ?`).join(", ");
44652
+ if (setCols === "") return;
44473
44653
  const { clause, params: pkParams } = this._pkWhere(table, id);
44474
44654
  let previousValues = null;
44475
44655
  if (this._changelogTables.has(table)) {
@@ -45086,13 +45266,32 @@ var Lattice = class _Lattice {
45086
45266
  // -------------------------------------------------------------------------
45087
45267
  // Sync
45088
45268
  // -------------------------------------------------------------------------
45089
- async render(outputDir) {
45269
+ async render(outputDir, opts = {}) {
45090
45270
  const notInit = this._notInitError();
45091
45271
  if (notInit) return notInit;
45092
- const result = await this._render.render(outputDir);
45272
+ const result = await this._render.render(outputDir, opts);
45093
45273
  for (const h6 of this._renderHandlers) h6(result);
45094
45274
  return result;
45095
45275
  }
45276
+ /**
45277
+ * Render into `outputDir` through the shared single-flight guard, intended to
45278
+ * be called fire-and-forget (e.g. the GUI's instant-open background render).
45279
+ *
45280
+ * The guard ({@link _renderGuarded}) holds {@link _autoRenderInFlight} for the
45281
+ * render's duration, so a data mutation that lands while this render is in
45282
+ * flight is deferred by {@link _runAutoRender} and coalesced — when this
45283
+ * render settles, `finally` clears the flag and re-arms exactly one follow-up
45284
+ * render via {@link _rearmAutoRenderIfPending}. Net invariant: at most one
45285
+ * render to a given dir at a time.
45286
+ *
45287
+ * Errors propagate to the caller (the GUI surfaces them, never silently swallowed); they are
45288
+ * not swallowed here.
45289
+ */
45290
+ async renderInBackground(outputDir, opts = {}) {
45291
+ const notInit = this._notInitError();
45292
+ if (notInit) return notInit;
45293
+ return this._renderGuarded(outputDir, opts);
45294
+ }
45096
45295
  async sync(outputDir) {
45097
45296
  const notInit = this._notInitError();
45098
45297
  if (notInit) return notInit;
@@ -45434,6 +45633,30 @@ var Lattice = class _Lattice {
45434
45633
  }, this._autoRenderDebounceMs);
45435
45634
  this._autoRenderTimer.unref();
45436
45635
  }
45636
+ /**
45637
+ * Shared single-flight render path used by {@link renderInBackground}.
45638
+ *
45639
+ * Holds {@link _autoRenderInFlight} for the render's duration so the
45640
+ * mutation-driven {@link _runAutoRender} defers while this render runs (it
45641
+ * sees the flag and marks itself pending instead of starting a second,
45642
+ * overlapping render). On settle, `finally` clears the flag and re-arms a
45643
+ * single coalesced follow-up render if any mutation arrived mid-flight.
45644
+ * Errors propagate to the caller; the flag is always cleared.
45645
+ */
45646
+ async _renderGuarded(outputDir, opts) {
45647
+ while (this._autoRenderInFlight) {
45648
+ await new Promise((r6) => setImmediate(r6));
45649
+ }
45650
+ this._autoRenderInFlight = true;
45651
+ try {
45652
+ const result = await this._render.render(outputDir, opts);
45653
+ for (const h6 of this._renderHandlers) h6(result);
45654
+ return result;
45655
+ } finally {
45656
+ this._autoRenderInFlight = false;
45657
+ this._rearmAutoRenderIfPending();
45658
+ }
45659
+ }
45437
45660
  async _runAutoRender() {
45438
45661
  const dir = this._autoRenderDir;
45439
45662
  if (!dir || !this._initialized) return;
@@ -46580,6 +46803,12 @@ var css = `
46580
46803
  .db-button .db-status.is-cloud-connecting { background: var(--warn); }
46581
46804
  .db-button:hover { background: rgba(255, 255, 255, 0.08); }
46582
46805
  .db-button .db-caret { color: #9aa1ad; font-size: 10px; }
46806
+ /* While a workspace switch is in flight, the stable header button shows a
46807
+ spinner (swapped for the \u{1F4C2} icon) so the switch is visible for its whole
46808
+ duration \u2014 POST + reloadEverything \u2014 not just while the dropdown is open. */
46809
+ .db-button.is-switching { opacity: 0.85; cursor: progress; }
46810
+ .db-button.is-switching .db-icon .spinner { margin-right: 0; }
46811
+ .db-button.is-switching.is-switch-error .db-name { color: #ef4444; }
46583
46812
  .db-menu {
46584
46813
  position: absolute; top: 38px; left: 0;
46585
46814
  min-width: 260px; background: var(--glass-strong);
@@ -46744,6 +46973,7 @@ var css = `
46744
46973
  max-width: 1100px;
46745
46974
  }
46746
46975
  .card {
46976
+ position: relative; overflow: hidden;
46747
46977
  background: var(--sheen), var(--surface); border: 1px solid var(--border);
46748
46978
  border-radius: 12px; padding: 22px;
46749
46979
  min-height: 160px;
@@ -46756,6 +46986,33 @@ var css = `
46756
46986
  .card-label { font-size: 15px; font-weight: 600; }
46757
46987
  .card-count { font-size: 28px; font-weight: 700; color: var(--text-muted); margin-top: auto; }
46758
46988
  .card-fresh { font-size: 11px; color: var(--text-muted); }
46989
+ /* \u2500\u2500 Per-card background-render progress overlay \u2500\u2500\u2500\u2500\u2500
46990
+ Hidden by default; .card.is-rendering reveals the bottom-edge bar + the
46991
+ corner pill while the context tree is rendered in the background. The row
46992
+ count dims so the live value reads as not-yet-final until completion. */
46993
+ .card-render { display: none; }
46994
+ .card.is-rendering .card-render { display: block; }
46995
+ .card.is-rendering .card-count { opacity: 0.45; transition: opacity 0.2s ease; }
46996
+ .card-render-fill {
46997
+ position: absolute; left: 0; bottom: 0; height: 3px; width: 0%;
46998
+ background: linear-gradient(90deg, var(--accent-deep), var(--accent));
46999
+ transition: width 0.2s ease; pointer-events: none;
47000
+ }
47001
+ .card-render-pill {
47002
+ position: absolute; top: 10px; right: 10px;
47003
+ display: inline-flex; align-items: center; gap: 4px;
47004
+ padding: 2px 8px; border-radius: 10px;
47005
+ background: var(--accent-soft); color: var(--accent);
47006
+ font-size: 11px; font-weight: 600; line-height: 1.4;
47007
+ pointer-events: none;
47008
+ }
47009
+ /* The render pill reuses the shared .spinner + @keyframes lattice-spin. */
47010
+ .card-render-pill .spinner { margin-right: 0; }
47011
+ /* A render that errors out paints a red card state instead of a stuck
47012
+ spinner (surface the failure, don't hide it). */
47013
+ .card.is-render-error { border-color: #ef4444; }
47014
+ .card.is-render-error .card-render-fill { background: #ef4444; }
47015
+ .card.is-render-error .card-render-pill { background: rgba(239, 68, 68, 0.14); color: #ef4444; }
46759
47016
 
46760
47017
  /* \u2500\u2500 Table view \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 */
46761
47018
  .view-header {
@@ -47022,7 +47279,9 @@ var css = `
47022
47279
  padding: 10px 18px; border-radius: 999px;
47023
47280
  display: flex; align-items: center; gap: 14px;
47024
47281
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
47025
- z-index: 200; font-size: 13.5px;
47282
+ /* Above every overlay (.modal-backdrop is z-index 1000, drawers 120-130) so
47283
+ an error thrown by an overlay screen is always visible on top. */
47284
+ z-index: 2000; font-size: 13.5px;
47026
47285
  animation: toast-in 0.18s ease;
47027
47286
  }
47028
47287
  @keyframes toast-in {
@@ -47157,6 +47416,11 @@ var css = `
47157
47416
  .teams-page { padding: 24px 28px; max-width: 1000px; }
47158
47417
  .teams-page h2 { margin: 0 0 4px 0; font-size: 22px; }
47159
47418
  .teams-page .lead { color: var(--text-muted); margin-bottom: 24px; font-size: 13.5px; }
47419
+ /* Workspace list (Lattice Settings): active row highlighted, others click-to-switch. */
47420
+ .teams-page tr.ws-row { cursor: pointer; }
47421
+ .teams-page tr.ws-row:hover td { background: var(--surface-2); }
47422
+ .teams-page tr.ws-active td { background: var(--accent-soft); }
47423
+ .teams-page tr.ws-active td:first-child { font-weight: 600; color: var(--accent); }
47160
47424
  .teams-actions { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
47161
47425
  .team-card {
47162
47426
  background: var(--sheen), var(--surface); border: 1px solid var(--border);
@@ -47601,6 +47865,13 @@ var css = `
47601
47865
  .rail-composer .composer-send:disabled { opacity: 0.4; cursor: default; box-shadow: none; }
47602
47866
  .rail-composer .composer-setup { font-size: 12.5px; color: var(--text-muted); text-align: center; }
47603
47867
  .rail-composer .composer-setup a { color: var(--accent); }
47868
+ /* Private-mode toggle under the composer row. */
47869
+ .rail-composer .composer-private {
47870
+ display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
47871
+ margin-top: 6px; font-size: 12px; color: var(--text-muted); cursor: pointer;
47872
+ }
47873
+ .rail-composer .composer-private input { cursor: pointer; }
47874
+ .rail-composer .composer-private-hint { color: var(--text-muted); opacity: 0.8; font-size: 11px; }
47604
47875
  .rail-composer .composer-mic {
47605
47876
  flex: 0 0 auto; height: 38px; width: 38px; font-size: 15px;
47606
47877
  border: 1px solid var(--border-strong); border-radius: 8px;
@@ -47905,8 +48176,6 @@ var appJs = `
47905
48176
  state.systemTables = (results[3] && results[3].tables) || [];
47906
48177
  state.preferences = results[4] || { show_system_tables: false, analytics: true };
47907
48178
  document.body.classList.toggle('advanced-mode', advancedMode());
47908
- var advToggle = document.getElementById('advanced-toggle');
47909
- if (advToggle) advToggle.checked = advancedMode();
47910
48179
  wireSettingsDrawer();
47911
48180
  renderWsSwitcher(results[5]);
47912
48181
  renderSidebar();
@@ -47914,6 +48183,7 @@ var appJs = `
47914
48183
  refreshHistoryState();
47915
48184
  renderRoute();
47916
48185
  startRealtime();
48186
+ startRenderProgress();
47917
48187
  initSearch();
47918
48188
  initLastEdited();
47919
48189
  initOffline();
@@ -48274,6 +48544,153 @@ var appJs = `
48274
48544
  };
48275
48545
  }
48276
48546
 
48547
+ // \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
48548
+ // Background-render progress \u2014 Server-Sent Events from
48549
+ // /api/render/progress. A workspace opens/switches instantly and renders its
48550
+ // context tree in the background; this paints a per-table % overlay on the
48551
+ // dashboard cards (bottom-edge bar + \u27F3 pill) and dims the row count until
48552
+ // each table completes. Row COUNTS come only from /api/entities \u2014 the render
48553
+ // stream drives only the transient overlay and one reconciling refetch on
48554
+ // completion. Mirrors the realtime EventSource pattern above.
48555
+ // \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
48556
+ var renderSource = null;
48557
+ // { [table]: { pct, rendered, total, done, error } } \u2014 the live render state,
48558
+ // re-applied to cards after every dashboard rebuild (drawDashboard wipes the
48559
+ // DOM overlays but not this map).
48560
+ var renderProgress = {};
48561
+ // Apply one table's render % to its matching card only (no full rebuild).
48562
+ function applyCardProgress(table, pct) {
48563
+ if (!table) return;
48564
+ var sel = '.card[data-table="' + (window.CSS && CSS.escape ? CSS.escape(table) : table) + '"]';
48565
+ var card = document.querySelector(sel);
48566
+ if (!card) return;
48567
+ var st = renderProgress[table];
48568
+ if (st && st.error) {
48569
+ card.classList.remove('is-rendering');
48570
+ card.classList.add('is-render-error');
48571
+ var perr = card.querySelector('.card-render-pct');
48572
+ if (perr) perr.textContent = 'error';
48573
+ return;
48574
+ }
48575
+ card.classList.remove('is-render-error');
48576
+ var clamped = Math.max(0, Math.min(100, Math.round(pct || 0)));
48577
+ card.classList.add('is-rendering');
48578
+ var fill = card.querySelector('.card-render-fill');
48579
+ if (fill) fill.style.width = clamped + '%';
48580
+ var pctEl = card.querySelector('.card-render-pct');
48581
+ if (pctEl) pctEl.textContent = clamped + '%';
48582
+ }
48583
+ // Clear the overlay for a finished/aborted table.
48584
+ function clearCardProgress(table) {
48585
+ if (!table) return;
48586
+ var sel = '.card[data-table="' + (window.CSS && CSS.escape ? CSS.escape(table) : table) + '"]';
48587
+ var card = document.querySelector(sel);
48588
+ if (!card) return;
48589
+ card.classList.remove('is-rendering', 'is-render-error');
48590
+ }
48591
+ // Repaint every still-in-flight card from the renderProgress map. Called at
48592
+ // the end of drawDashboard so overlays survive a feed-triggered rebuild.
48593
+ function reapplyRenderOverlays() {
48594
+ Object.keys(renderProgress).forEach(function (table) {
48595
+ var st = renderProgress[table];
48596
+ if (!st) return;
48597
+ if (st.done && !st.error) { clearCardProgress(table); return; }
48598
+ applyCardProgress(table, st.pct);
48599
+ });
48600
+ }
48601
+ // Fold one render event into the renderProgress map + paint the card.
48602
+ function onRenderEvent(e) {
48603
+ if (!e) return;
48604
+ if (e.kind === 'error') {
48605
+ var t = e.table;
48606
+ if (t) {
48607
+ renderProgress[t] = { pct: e.pct || 0, rendered: 0, total: 0, done: false, error: true };
48608
+ applyCardProgress(t, e.pct || 0);
48609
+ }
48610
+ return;
48611
+ }
48612
+ if (e.kind === 'done') {
48613
+ // Whole-render completion: clear every overlay and let the debounced
48614
+ // refetch snap the counts to their real values.
48615
+ Object.keys(renderProgress).forEach(function (table) {
48616
+ var s = renderProgress[table];
48617
+ if (s) s.done = true;
48618
+ clearCardProgress(table);
48619
+ });
48620
+ scheduleRealtimeRefresh();
48621
+ return;
48622
+ }
48623
+ if (!e.table) return;
48624
+ var done = e.kind === 'table-done';
48625
+ renderProgress[e.table] = {
48626
+ pct: e.pct,
48627
+ rendered: e.entitiesRendered,
48628
+ total: e.entitiesTotal,
48629
+ done: done,
48630
+ error: false,
48631
+ };
48632
+ if (done) {
48633
+ clearCardProgress(e.table);
48634
+ // The count for this table is now final on the server; nudge one
48635
+ // reconciling refetch from /api/entities (debounced, coalesced).
48636
+ scheduleRealtimeRefresh();
48637
+ } else {
48638
+ applyCardProgress(e.table, e.pct);
48639
+ }
48640
+ }
48641
+ // Paint from a full snapshot (initial connect / status fetch): the snapshot
48642
+ // carries { phase, tables: { [t]: { pct, entitiesRendered, entitiesTotal,
48643
+ // done } } }. Fold each table in and paint.
48644
+ function applyRenderSnapshot(snap) {
48645
+ if (!snap || !snap.tables) return;
48646
+ Object.keys(snap.tables).forEach(function (table) {
48647
+ var s = snap.tables[table];
48648
+ if (!s) return;
48649
+ renderProgress[table] = {
48650
+ pct: s.pct,
48651
+ rendered: s.entitiesRendered,
48652
+ total: s.entitiesTotal,
48653
+ done: !!s.done,
48654
+ error: false,
48655
+ };
48656
+ if (s.done) clearCardProgress(table);
48657
+ else applyCardProgress(table, s.pct);
48658
+ });
48659
+ if (snap.phase === 'error') {
48660
+ // A whole-render failure with no table attribution still surfaces on the
48661
+ // currently-rendering card if we know one.
48662
+ if (snap.currentTable) {
48663
+ renderProgress[snap.currentTable] = renderProgress[snap.currentTable] || { pct: 0 };
48664
+ renderProgress[snap.currentTable].error = true;
48665
+ applyCardProgress(snap.currentTable, renderProgress[snap.currentTable].pct);
48666
+ }
48667
+ }
48668
+ }
48669
+ function startRenderProgress() {
48670
+ if (renderSource) {
48671
+ try { renderSource.close(); } catch (_) { /* ignore */ }
48672
+ renderSource = null;
48673
+ }
48674
+ if (typeof EventSource === 'undefined') return;
48675
+ // On (re)connect, fetch the single-shot snapshot so a tab that connects
48676
+ // mid- or post-render paints correctly even before the next event. The SSE
48677
+ // endpoint ALSO replays a 'snapshot' event on connect, so both paths agree.
48678
+ fetchJson('/api/render/status').then(applyRenderSnapshot).catch(function () { /* ignore */ });
48679
+ renderSource = new EventSource('/api/render/progress');
48680
+ renderSource.addEventListener('snapshot', function (ev) {
48681
+ var snap = null;
48682
+ try { snap = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
48683
+ if (snap) applyRenderSnapshot(snap);
48684
+ });
48685
+ renderSource.addEventListener('progress', function (ev) {
48686
+ var e = null;
48687
+ try { e = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
48688
+ if (e) onRenderEvent(e);
48689
+ });
48690
+ // EventSource auto-reconnects on error; the status refetch on the next
48691
+ // open repaints from the authoritative snapshot.
48692
+ }
48693
+
48277
48694
  // \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
48278
48695
  // Shared activity helpers \u2014 the operation-icon map and relative-time
48279
48696
  // formatter, used by Version History and the dashboard activity list. The
@@ -48503,9 +48920,58 @@ var appJs = `
48503
48920
  else renderRoute();
48504
48921
  loadedTables = {};
48505
48922
  startRealtime();
48923
+ // A switch swaps the server-side render bus to the new workspace; drop the
48924
+ // old workspace's overlay state and re-subscribe so the new render streams
48925
+ // onto this workspace's cards.
48926
+ renderProgress = {};
48927
+ startRenderProgress();
48506
48928
  });
48507
48929
  }
48508
48930
 
48931
+ // \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
48932
+ // Workspace-switch progress on the STABLE header button.
48933
+ // The old menu-item spinner (withBusy on the open-menu db-item) is lost the
48934
+ // moment the menu closes or rebuilds mid-switch. We additionally surface
48935
+ // "switching" on the always-visible #ws-button for the ENTIRE switch (POST +
48936
+ // reloadEverything), so the user always sees a live signal. The menu-item
48937
+ // withBusy spinner is kept too.
48938
+ // \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
48939
+ var wsSwitching = false;
48940
+ function beginWsSwitching() {
48941
+ wsSwitching = true;
48942
+ var btn = document.getElementById('ws-button');
48943
+ var nameEl = document.getElementById('ws-name');
48944
+ var iconEl = btn && btn.querySelector('.db-icon');
48945
+ if (btn) { btn.classList.add('is-switching'); btn.classList.remove('is-switch-error'); }
48946
+ if (iconEl) iconEl.innerHTML = '<span class="spinner" aria-hidden="true"></span>';
48947
+ if (nameEl) nameEl.textContent = 'Switching\u2026';
48948
+ }
48949
+ function endWsSwitching(failed) {
48950
+ wsSwitching = false;
48951
+ var btn = document.getElementById('ws-button');
48952
+ var iconEl = btn && btn.querySelector('.db-icon');
48953
+ if (iconEl) iconEl.textContent = '\u{1F4C2}';
48954
+ if (btn) {
48955
+ btn.classList.remove('is-switching');
48956
+ if (failed) btn.classList.add('is-switch-error');
48957
+ else btn.classList.remove('is-switch-error');
48958
+ }
48959
+ // The label writes inside reloadEverything ran while wsSwitching was still
48960
+ // true (guarded out to preserve "Switching\u2026"), and they already completed
48961
+ // BEFORE this call \u2014 so nothing else will apply the NEW workspace name. Now
48962
+ // that the switch resolved, re-render the switcher so the real name lands
48963
+ // (otherwise #ws-name stays stuck on "Switching\u2026").
48964
+ if (!failed) {
48965
+ fetchJson('/api/workspaces')
48966
+ .then(function (d) {
48967
+ if (d) renderWsSwitcher(d);
48968
+ })
48969
+ .catch(function () {
48970
+ /* best-effort: the next reload re-renders the switcher anyway */
48971
+ });
48972
+ }
48973
+ }
48974
+
48509
48975
  var wsOutsideClickBound = false;
48510
48976
  function renderWsSwitcher(data) {
48511
48977
  var wrap = document.getElementById('ws-switcher');
@@ -48519,7 +48985,10 @@ var appJs = `
48519
48985
  wrap.hidden = false;
48520
48986
  var list = (data && data.workspaces) || [];
48521
48987
  var current = list.filter(function (w) { return w.id === (data && data.current); })[0];
48522
- nameEl.textContent = (current && current.label) || 'workspace';
48988
+ // Don't clobber the "Switching\u2026" label/icon while a switch is in flight \u2014
48989
+ // renderWsSwitcher runs mid-reload, and the new workspace label should only
48990
+ // land once the switch resolves (endWsSwitching \u2192 next render).
48991
+ if (!wsSwitching) nameEl.textContent = (current && current.label) || 'workspace';
48523
48992
  var curKind = (current && current.kind) || 'local';
48524
48993
  setStatusPill(curKind, curKind === 'cloud' ? 'connecting' : 'local');
48525
48994
 
@@ -48550,6 +49019,10 @@ var appJs = `
48550
49019
  b.addEventListener('click', function () {
48551
49020
  var id = b.getAttribute('data-id');
48552
49021
  if (id === currentId) { menu.hidden = true; return; }
49022
+ // Surface "switching" on the stable header button for the WHOLE
49023
+ // switch (POST + reloadEverything), independent of the ephemeral
49024
+ // menu-item withBusy spinner \u2014 the menu can close/rebuild mid-switch.
49025
+ beginWsSwitching();
48553
49026
  withBusy(b, function () {
48554
49027
  return fetchJson('/api/workspaces/switch', {
48555
49028
  method: 'POST',
@@ -48566,6 +49039,7 @@ var appJs = `
48566
49039
  return reloadEverything();
48567
49040
  }).then(function () {
48568
49041
  menu.hidden = true;
49042
+ endWsSwitching(false);
48569
49043
  // Conversations + activity both live in the workspace DB. Drop
48570
49044
  // the old workspace's thread + activity cards, reconnect the feed
48571
49045
  // to THIS workspace, and reload its thread list (+ latest convo).
@@ -48574,7 +49048,7 @@ var appJs = `
48574
49048
  startFeed();
48575
49049
  refreshThreadList(true);
48576
49050
  showToast('Switched workspace', {});
48577
- }).catch(function (err) { menu.hidden = true; showToast('Switch failed: ' + err.message, {}); });
49051
+ }).catch(function (err) { menu.hidden = true; endWsSwitching(true); showToast('Switch failed: ' + err.message, {}); });
48578
49052
  });
48579
49053
  });
48580
49054
  });
@@ -48756,14 +49230,24 @@ var appJs = `
48756
49230
  ? '<div class="card-fresh" title="Last updated ' +
48757
49231
  escapeHtml(String(e.lastUpdatedAt)) + '">' + relTime(e.lastUpdatedAt) + '</div>'
48758
49232
  : '';
48759
- return '<a class="card" href="' + cardPrefix + e.name + '">' +
49233
+ return '<a class="card" data-table="' + escapeHtml(e.name) + '" href="' + cardPrefix + e.name + '">' +
48760
49234
  '<div class="card-icon">' + disp.icon + '</div>' +
48761
49235
  '<div class="card-label">' + escapeHtml(disp.label) + '</div>' +
48762
49236
  '<div class="card-count">' + count + '</div>' +
48763
49237
  fresh +
49238
+ // Hidden until a background render touches this table; revealed by the
49239
+ // .is-rendering class applied in applyCardProgress(). The fill is the
49240
+ // bottom-edge bar (width = %); the pill is the \u27F3 <pct>% corner badge.
49241
+ '<div class="card-render" aria-hidden="true">' +
49242
+ '<div class="card-render-fill"></div>' +
49243
+ '<span class="card-render-pill"><span class="spinner" aria-hidden="true"></span><span class="card-render-pct">0%</span></span>' +
49244
+ '</div>' +
48764
49245
  '</a>';
48765
49246
  }).join('');
48766
49247
  content.innerHTML = '<div class="dashboard">' + cards + '</div>';
49248
+ // drawDashboard wiped the previous overlays; repaint any still-in-flight
49249
+ // render state from the renderProgress map onto the freshly-built cards.
49250
+ reapplyRenderOverlays();
48767
49251
  }
48768
49252
  function renderDashboard(content) {
48769
49253
  // Workspace overview: counts + freshness + recent activity from
@@ -50100,8 +50584,6 @@ var appJs = `
50100
50584
  if (!drawer || !backdrop) return;
50101
50585
  backdrop.hidden = false;
50102
50586
  drawer.hidden = false;
50103
- var toggle = document.getElementById('advanced-toggle');
50104
- if (toggle) toggle.checked = advancedMode();
50105
50587
  // Allow the elements to lay out before transitioning in.
50106
50588
  window.requestAnimationFrame(function () {
50107
50589
  drawer.classList.add('open');
@@ -50125,6 +50607,7 @@ var appJs = `
50125
50607
  var body = document.getElementById('drawer-body');
50126
50608
  if (!body) return;
50127
50609
  if (tab === 'database') renderDatabaseSettings(body);
50610
+ else if (tab === 'chat') renderChatSettings(body);
50128
50611
  else if (tab === 'lattice') renderLatticeSettings(body);
50129
50612
  else renderUserConfig(body);
50130
50613
  }
@@ -50138,8 +50621,6 @@ var appJs = `
50138
50621
  document.querySelectorAll('.drawer-tab').forEach(function (b) {
50139
50622
  b.addEventListener('click', function () { selectDrawerTab(b.getAttribute('data-tab')); });
50140
50623
  });
50141
- var toggle = document.getElementById('advanced-toggle');
50142
- if (toggle) toggle.addEventListener('change', function () { setAdvancedMode(toggle.checked); });
50143
50624
  document.addEventListener('keydown', function (e) {
50144
50625
  if (e.key !== 'Escape') return;
50145
50626
  var drawer = document.getElementById('settings-drawer');
@@ -50955,17 +51436,32 @@ var appJs = `
50955
51436
  '</div>'
50956
51437
  : '';
50957
51438
  // Owner-only "new rows default to" control, shown for a shared table.
51439
+ // A never-share table's rows are always private, so the default-visibility
51440
+ // select is disabled while never-share is on.
50958
51441
  var defaultVis = (t && t.defaultRowVisibility) || 'private';
51442
+ var neverShare = !!(t && t.neverShare);
50959
51443
  var defaultVisRow = canShare && isShared
50960
51444
  ? '<label>New rows default to</label>' +
50961
51445
  '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
50962
- '<select id="dm-rowvis-select">' +
51446
+ '<select id="dm-rowvis-select"' + (neverShare ? ' disabled' : '') + '>' +
50963
51447
  '<option value="private"' + (defaultVis === 'private' ? ' selected' : '') + '>Private (owner only)</option>' +
50964
51448
  '<option value="everyone"' + (defaultVis === 'everyone' ? ' selected' : '') + '>Everyone on the workspace</option>' +
50965
51449
  '</select>' +
50966
51450
  '<span style="font-size:12px;color:var(--text-muted)">Visibility new rows in this table are created with.</span>' +
50967
51451
  '</div>'
50968
51452
  : '';
51453
+ // Owner-only "Never share" control, shown for a shared table. When on, the
51454
+ // table's rows are always private to their owner regardless of the default
51455
+ // visibility above \u2014 a hard floor the owner can set per shared table.
51456
+ var neverShareRow = canShare && isShared
51457
+ ? '<label>Never share</label>' +
51458
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
51459
+ '<label class="dm-secret-toggle">' +
51460
+ '<input type="checkbox" id="dm-nevershare-check"' + (neverShare ? ' checked' : '') + ' /> Keep all rows private' +
51461
+ '</label>' +
51462
+ '<span style="font-size:12px;color:var(--text-muted)">When on, rows in this table are always private to their owner, ignoring the default above.</span>' +
51463
+ '</div>'
51464
+ : '';
50969
51465
  panel.innerHTML =
50970
51466
  '<h3>' + d.icon + ' ' + escapeHtml(d.label) + '</h3>' +
50971
51467
  '<div class="dm-edit-grid">' +
@@ -50981,6 +51477,7 @@ var appJs = `
50981
51477
  '</div>' +
50982
51478
  shareRow +
50983
51479
  defaultVisRow +
51480
+ neverShareRow +
50984
51481
  '<label>Columns</label>' +
50985
51482
  '<div>' +
50986
51483
  '<div class="dm-cols">' + (columnsHtml || '<span class="muted">No columns</span>') + '</div>' +
@@ -51050,6 +51547,27 @@ var appJs = `
51050
51547
  }).catch(function (e) { showToast('Default visibility update failed: ' + e.message, {}); });
51051
51548
  });
51052
51549
  });
51550
+
51551
+ var neverShareCheck = panel.querySelector('#dm-nevershare-check');
51552
+ if (neverShareCheck) neverShareCheck.addEventListener('change', function () {
51553
+ var on = neverShareCheck.checked;
51554
+ // Disable while the round-trip is in flight; dmRefreshPanel rebuilds the
51555
+ // panel (and the default-visibility select's disabled state) on success.
51556
+ neverShareCheck.disabled = true;
51557
+ fetchJson('/api/schema/entities/' + encodeURIComponent(tableName) + '/never-share', {
51558
+ method: 'POST',
51559
+ headers: { 'content-type': 'application/json' },
51560
+ body: JSON.stringify({ on: on }),
51561
+ }).then(function () {
51562
+ return dmRefreshPanel(tableName, false);
51563
+ }).then(function () {
51564
+ showToast(on ? 'Rows in "' + tableName + '" are now always private' : 'Rows in "' + tableName + '" follow the default visibility', {});
51565
+ }).catch(function (e) {
51566
+ neverShareCheck.disabled = false;
51567
+ neverShareCheck.checked = !on;
51568
+ showToast('Never-share update failed: ' + e.message, {});
51569
+ });
51570
+ });
51053
51571
  }
51054
51572
 
51055
51573
  /**
@@ -51723,28 +52241,35 @@ var appJs = `
51723
52241
 
51724
52242
  function showJoinTeamModal(kind) {
51725
52243
  void kind;
51726
- // v3: join a cloud by connecting DIRECTLY with the scoped credentials the
51727
- // owner gave you (the invite blob). No invite token, no email binding \u2014
51728
- // the database (row-level security) enforces what your role can see.
52244
+ // Join a cloud with the email-bound invite token the owner sent you. The
52245
+ // token decrypts LOCALLY with your email to the same scoped credential \u2014
52246
+ // the member UI never handles a postgres:// string. You then connect
52247
+ // directly with your own scoped role; the database (RLS) enforces access.
51729
52248
  var bodyHtml =
51730
52249
  '<p style="margin:0 0 12px;font-size:13px;color:var(--text-muted)">' +
51731
- 'Paste the connection credentials the cloud owner sent you. You connect ' +
51732
- 'directly with your own scoped Postgres role \u2014 access is enforced by the ' +
51733
- 'database, not a server.' +
52250
+ 'Enter the email this invite was sent to and the invite token the cloud ' +
52251
+ 'owner gave you.' +
51734
52252
  '</p>' +
51735
- postgresFormHtml({}) +
52253
+ '<div class="field"><label>Email</label>' +
52254
+ '<input id="join-email" type="email" placeholder="you@example.com" autocapitalize="off" autocorrect="off" spellcheck="false" style="width:100%"></div>' +
52255
+ '<div class="field" style="margin-top:8px"><label>Invite token</label>' +
52256
+ '<textarea id="join-token" rows="4" placeholder="paste the invite token" autocapitalize="off" autocorrect="off" spellcheck="false" style="width:100%;resize:vertical;font-family:JetBrains Mono,monospace;font-size:12px"></textarea></div>' +
51736
52257
  '<div id="join-msg" style="margin-top:10px;font-size:12px;color:var(--text-muted)"></div>';
51737
52258
  showModal('Join a cloud', bodyHtml, {
51738
52259
  primaryLabel: 'Join',
51739
52260
  onSubmit: function (scope) {
51740
52261
  void scope;
51741
- var body = readPostgresWizardForm();
52262
+ var emailEl = document.getElementById('join-email');
52263
+ var tokenEl = document.getElementById('join-token');
52264
+ var email = (emailEl && emailEl.value ? emailEl.value : '').trim();
52265
+ var token = (tokenEl && tokenEl.value ? tokenEl.value : '').trim();
52266
+ if (!email || !token) throw new Error('Enter your email and the invite token');
51742
52267
  var msg = document.getElementById('join-msg');
51743
52268
  if (msg) msg.textContent = 'Connecting\u2026';
51744
- return fetch('/api/dbconfig/connect-existing', {
52269
+ return fetch('/api/cloud/redeem-invite', {
51745
52270
  method: 'POST',
51746
52271
  headers: { 'content-type': 'application/json' },
51747
- body: JSON.stringify(body),
52272
+ body: JSON.stringify({ email: email, token: token }),
51748
52273
  })
51749
52274
  .then(function (r) { return r.json().then(function (d) { return { status: r.status, body: d }; }); })
51750
52275
  .then(function (r) {
@@ -51796,7 +52321,7 @@ var appJs = `
51796
52321
  '</div>';
51797
52322
  }
51798
52323
  // Only the selected provider's key input is shown (declutter). 'auto'
51799
- // ("Select provider\u2026") shows no key row until a provider is chosen.
52324
+ // ("No Voice") shows no key row and disables voice \u2014 no STT provider.
51800
52325
  function voiceRowHtml(provider) {
51801
52326
  if (provider === 'openai') {
51802
52327
  return rowHtml('asst-openai', 'OpenAI Whisper key', !!cfg.hasOpenaiKey, 'sk-\u2026');
@@ -51838,7 +52363,7 @@ var appJs = `
51838
52363
  '<div style="margin:6px 0 8px;display:flex;align-items:center;gap:8px">' +
51839
52364
  '<span style="font-size:12px;color:var(--text-muted)">Use for voice:</span>' +
51840
52365
  '<select id="asst-stt" style="background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px;padding:3px 6px">' +
51841
- '<option value="auto">Select provider\u2026</option>' +
52366
+ '<option value="auto">No Voice</option>' +
51842
52367
  '<option value="openai">OpenAI</option>' +
51843
52368
  '<option value="elevenlabs">ElevenLabs</option>' +
51844
52369
  '</select>' +
@@ -51892,7 +52417,9 @@ var appJs = `
51892
52417
  body: JSON.stringify({ provider: sttSel.value }),
51893
52418
  })
51894
52419
  .then(function (r) { if (!r.ok) throw new Error('save failed (' + r.status + ')'); return r.json(); })
51895
- .then(function () { msg.textContent = 'Saved.'; })
52420
+ // Refresh the composer so the mic affordance disappears immediately
52421
+ // when "No Voice" is selected (and reappears for a real provider).
52422
+ .then(function () { msg.textContent = 'Saved.'; renderComposer(); })
51896
52423
  .catch(function (e) { msg.textContent = 'Failed: ' + e.message; });
51897
52424
  });
51898
52425
  }
@@ -52213,9 +52740,24 @@ var appJs = `
52213
52740
  content.innerHTML =
52214
52741
  '<div class="teams-page">' +
52215
52742
  '<h2>Lattice Settings</h2>' +
52743
+ '<div class="dbconfig-panel" style="margin-bottom:14px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
52744
+ '<label class="toggle" title="Advanced mode \u2014 row/table editor instead of the file workspace" style="display:inline-flex;align-items:center;gap:10px;cursor:pointer">' +
52745
+ '<input type="checkbox" id="advanced-toggle">' +
52746
+ '<span class="toggle-track"><span class="toggle-thumb"></span></span>' +
52747
+ '<span class="toggle-label">Advanced View</span>' +
52748
+ '</label>' +
52749
+ '<p class="lead" style="margin:8px 0 0;font-size:12px;color:var(--text-muted)">Row/table editor instead of the file workspace.</p>' +
52750
+ '</div>' +
52216
52751
  '<p class="lead">Every workspace this lattice can switch to. This is the same list as the header dropdown.</p>' +
52217
52752
  '<div id="lattice-dbs-host"><div class="placeholder" style="padding:18px">Loading workspaces\u2026</div></div>' +
52218
52753
  '</div>';
52754
+ // Advanced View toggle lives here now (moved out of the sidebar). Wired on
52755
+ // each render since renderLatticeSettings rebuilds the drawer body.
52756
+ var advToggle = content.querySelector('#advanced-toggle');
52757
+ if (advToggle) {
52758
+ advToggle.checked = advancedMode();
52759
+ advToggle.addEventListener('change', function () { setAdvancedMode(advToggle.checked); });
52760
+ }
52219
52761
  var host = document.getElementById('lattice-dbs-host');
52220
52762
  // Single source of truth: the workspace registry (same as the header switcher).
52221
52763
  fetchJson('/api/workspaces').then(function (data) {
@@ -52225,7 +52767,8 @@ var appJs = `
52225
52767
  var isActive = w.id === currentId;
52226
52768
  var kind = w.kind === 'cloud' ? 'Cloud (Postgres)' : 'Local (SQLite)';
52227
52769
  // Rows are click-to-switch; deletion lives in Workspace Settings \u2192 Danger Zone.
52228
- return '<tr' + (isActive ? '' : ' class="ws-row" data-switch-id="' + escapeHtml(w.id) + '"') + '>' +
52770
+ // The active row is highlighted (.ws-active) and not click-to-switch.
52771
+ return '<tr class="' + (isActive ? 'ws-active' : 'ws-row') + '"' + (isActive ? '' : ' data-switch-id="' + escapeHtml(w.id) + '"') + '>' +
52229
52772
  '<td>' + escapeHtml(w.label) + (isActive ? ' <span class="role-tag">active</span>' : '') + '</td>' +
52230
52773
  '<td>' + kind + '</td>' +
52231
52774
  '<td><code>' + escapeHtml(w.dir || '') + '</code></td>' +
@@ -52245,10 +52788,13 @@ var appJs = `
52245
52788
  host.querySelectorAll('tr.ws-row[data-switch-id]').forEach(function (row) {
52246
52789
  row.addEventListener('click', function () {
52247
52790
  var id = row.getAttribute('data-switch-id');
52791
+ // Switch the workspace AND close the settings drawer at the same time \u2014
52792
+ // close immediately (concurrent with the switch) so it isn't left open.
52793
+ closeSettingsDrawer();
52248
52794
  fetch('/api/workspaces/switch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: id }) })
52249
52795
  .then(function (r) { return r.json(); })
52250
52796
  .then(function () { return reloadEverything(); })
52251
- .then(function () { renderLatticeSettings(document.getElementById('content')); });
52797
+ .catch(function (err) { showToast('Switch failed: ' + err.message, {}); });
52252
52798
  });
52253
52799
  });
52254
52800
  host.querySelector('#action-add-db').addEventListener('click', showCreateDatabaseWizard);
@@ -52327,7 +52873,6 @@ var appJs = `
52327
52873
  '</div>' +
52328
52874
  '<div class="team-actions" style="margin-top:10px">' +
52329
52875
  (isOwner ? '<button class="btn primary" data-act="open-invite">Invite a member</button>' : '') +
52330
- (isOwner ? '<button class="btn" data-act="open-system-prompt">Edit chat system prompt</button>' : '') +
52331
52876
  '</div>' +
52332
52877
  // Owner: invite affordance below. Member: a short note. Row-level
52333
52878
  // security is enforced by the database, not this panel \u2014 there is
@@ -52372,22 +52917,44 @@ var appJs = `
52372
52917
  showInviteMemberModal(info);
52373
52918
  });
52374
52919
 
52375
- // Owner-only: edit the cloud's chat system prompt (bundled into every
52376
- // member's chat; members can neither see nor edit it).
52377
- var promptBtn = host.querySelector('[data-act="open-system-prompt"]');
52378
- if (promptBtn) promptBtn.addEventListener('click', function () {
52379
- showSystemPromptModal();
52380
- });
52381
-
52382
- // No members list to fetch. The owner sees the invite affordance (the
52383
- // button above); a member sees a short note that they're connected.
52920
+ // Members list: the owner sees the owner + every member role; a member
52921
+ // sees a short note. Backed by /api/cloud/members (the lattice_members
52922
+ // group). The inline list itself is recovered from latticesql 1.14.0.
52384
52923
  var membersHost = host.querySelector('#db-members-host');
52385
- if (membersHost && info.state === 'cloud-member') {
52386
- membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">You are a member of this cloud.</div>';
52924
+ if (membersHost) {
52925
+ if (info.state === 'cloud-member') {
52926
+ membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">You are a member of this cloud.</div>';
52927
+ } else {
52928
+ membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div>';
52929
+ fetchJson('/api/cloud/members').then(function (data) {
52930
+ membersHost.innerHTML = renderMembersList((data && data.members) || []);
52931
+ }).catch(function (e) {
52932
+ membersHost.innerHTML = '<div style="font-size:12px;color:var(--warn)">Could not load members: ' + escapeHtml(e.message) + '</div>';
52933
+ });
52934
+ }
52387
52935
  }
52388
52936
  void isOwner;
52389
52937
  }
52390
52938
 
52939
+ /** Members list (owner + member roles), recovered from latticesql 1.14.0
52940
+ * (commit 2862959), adapted to the RLS-cloud member model. */
52941
+ function renderMembersList(members) {
52942
+ if (!members.length) {
52943
+ return '<div class="members-list"><h4>Members</h4>' +
52944
+ '<div style="font-size:12px;color:var(--text-muted)">Just you.</div></div>';
52945
+ }
52946
+ var rows = members.map(function (m) {
52947
+ var pill = m.isOwner ? 'Owner' : 'Member';
52948
+ return '<div class="member-row" data-role="' + escapeHtml(m.role) + '">' +
52949
+ '<span><code>' + escapeHtml(m.role) + '</code>' +
52950
+ (m.isYou ? ' <span style="color:var(--accent);font-size:11px">(you)</span>' : '') +
52951
+ ' <span class="role-tag' + (m.isOwner ? '' : ' role-member') + '">' + pill + '</span>' +
52952
+ '</span>' +
52953
+ '</div>';
52954
+ }).join('');
52955
+ return '<div class="members-list"><h4>Members</h4>' + rows + '</div>';
52956
+ }
52957
+
52391
52958
  // \u2500\u2500 v1.13 wizards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
52392
52959
 
52393
52960
  function postgresFormHtml(prefill) {
@@ -52543,94 +53110,104 @@ var appJs = `
52543
53110
  });
52544
53111
  }
52545
53112
 
52546
- function showSystemPromptModal() {
52547
- // Owner-only editor for the cloud chat system prompt. Load the current value
52548
- // first (the GET returns the text ONLY to an owner), then open the editor.
53113
+ // Chat settings (drawer tab): the cloud chat system prompt, edited INLINE
53114
+ // with a Save button \u2014 no overlay. Owner-only (the GET returns the text only
53115
+ // to an owner); members / local workspaces see a short note instead.
53116
+ function renderChatSettings(content) {
53117
+ content.innerHTML =
53118
+ '<div class="teams-page">' +
53119
+ '<h2>Chat</h2>' +
53120
+ '<div id="chat-settings-host"><div class="placeholder" style="padding:18px">Loading\u2026</div></div>' +
53121
+ '</div>';
53122
+ var host = document.getElementById('chat-settings-host');
52549
53123
  fetchJson('/api/cloud/system-prompt').then(function (cfg) {
53124
+ var panelOpen =
53125
+ '<div class="dbconfig-panel" style="padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
53126
+ '<h3 style="margin:0 0 8px">Chat system prompt</h3>';
52550
53127
  if (!cfg || cfg.canEdit !== true) {
52551
- showToast('Only a cloud owner can edit the chat system prompt');
53128
+ host.innerHTML = panelOpen +
53129
+ '<p style="font-size:12px;color:var(--text-muted);margin:0">' +
53130
+ 'The chat system prompt is owner-only and applies to a cloud workspace. ' +
53131
+ 'Nothing to edit here for this workspace.</p></div>';
52552
53132
  return;
52553
53133
  }
52554
53134
  var current = typeof cfg.prompt === 'string' ? cfg.prompt : '';
52555
- var bodyHtml =
52556
- '<p style="margin-top:0;font-size:13px;color:var(--text-muted)">' +
52557
- 'Added to every member chat in this cloud. Members cannot see or edit it \u2014 only you, the owner, can.' +
52558
- '</p>' +
52559
- '<div class="field"><label>Chat system prompt</label>' +
52560
- '<textarea name="system-prompt" rows="10" style="width:100%;font-family:inherit;resize:vertical" ' +
52561
- 'placeholder="e.g. Always answer in a formal tone. Our fiscal year starts in July.">' +
52562
- escapeHtml(current) +
52563
- '</textarea></div>';
52564
- showModal('Chat system prompt', bodyHtml, {
52565
- primaryLabel: 'Save',
52566
- onSubmit: function (scope) {
52567
- var ta = scope.querySelector('textarea[name="system-prompt"]');
52568
- var value = ta ? ta.value : '';
52569
- return fetchJson('/api/cloud/system-prompt', {
52570
- method: 'POST',
52571
- headers: { 'content-type': 'application/json' },
52572
- body: JSON.stringify({ prompt: value }),
52573
- }).then(function () {
52574
- showToast('Chat system prompt saved');
52575
- });
52576
- },
53135
+ host.innerHTML = panelOpen +
53136
+ '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
53137
+ 'Added to every member chat in this cloud. Members cannot see or edit it \u2014 only you, the owner, can.</p>' +
53138
+ '<textarea id="chat-system-prompt" rows="10" style="width:100%;font-family:inherit;resize:vertical" ' +
53139
+ 'placeholder="e.g. Always answer in a formal tone. Our fiscal year starts in July.">' +
53140
+ escapeHtml(current) + '</textarea>' +
53141
+ '<div style="margin-top:10px;display:flex;align-items:center;gap:10px">' +
53142
+ '<button class="btn primary" id="chat-prompt-save">Save</button>' +
53143
+ '<span id="chat-prompt-msg" style="font-size:12px;color:var(--text-muted)"></span>' +
53144
+ '</div>' +
53145
+ '</div>';
53146
+ var saveBtn = document.getElementById('chat-prompt-save');
53147
+ var msg = document.getElementById('chat-prompt-msg');
53148
+ if (saveBtn) saveBtn.addEventListener('click', function () {
53149
+ var ta = document.getElementById('chat-system-prompt');
53150
+ var value = ta ? ta.value : '';
53151
+ if (msg) msg.textContent = 'Saving\u2026';
53152
+ fetchJson('/api/cloud/system-prompt', {
53153
+ method: 'POST',
53154
+ headers: { 'content-type': 'application/json' },
53155
+ body: JSON.stringify({ prompt: value }),
53156
+ }).then(function () {
53157
+ if (msg) msg.textContent = 'Saved.';
53158
+ }).catch(function (e) {
53159
+ if (msg) msg.textContent = 'Failed: ' + (e && e.message ? e.message : String(e));
53160
+ });
52577
53161
  });
52578
53162
  }).catch(function (err) {
52579
- showToast('Failed to load: ' + (err && err.message ? err.message : String(err)));
53163
+ host.innerHTML = '<div class="placeholder">Could not load: ' +
53164
+ escapeHtml(err && err.message ? err.message : String(err)) + '</div>';
52580
53165
  });
52581
53166
  }
52582
53167
 
52583
53168
  function showInviteMemberModal(info) {
52584
- // v3 owner-only invite: the server provisions a scoped member role and
52585
- // returns a connection blob. That blob IS the invite \u2014 copy it and hand
52586
- // it to the new member, who pastes the fields into "Join a cloud". There
52587
- // is no per-table sharing here (rows are private-by-default, shared per
52588
- // row via the eye toggle).
53169
+ // Owner-only invite: collect the invitee's email; the server provisions a
53170
+ // scoped role and returns ONE email-bound token carrying its credential.
53171
+ // The invitee redeems it with the same email in "Join a cloud" \u2014 no
53172
+ // postgres:// fields ever change hands. (Recovered from 1.14.0's email
53173
+ // invite flow, adapted to the RLS-cloud token.)
52589
53174
  info = info || {};
52590
53175
  var bodyHtml =
52591
- '<div class="field"><label>Member label</label>' +
52592
- '<input name="label" type="text" placeholder="bob" autocapitalize="off" autocorrect="off" spellcheck="false" /></div>' +
53176
+ '<div class="field"><label>Invitee email</label>' +
53177
+ '<input name="email" type="email" placeholder="bob@example.com" autocapitalize="off" autocorrect="off" spellcheck="false" /></div>' +
52593
53178
  '<p style="font-size:12px;color:var(--text-muted);margin:0">' +
52594
- 'A short name for the scoped role you are provisioning. Lattice returns ' +
52595
- 'connection credentials below \u2014 send them to the new member, who pastes ' +
52596
- 'them into \u201CJoin a cloud\u201D.' +
53179
+ 'The invite is bound to this email \u2014 only the recipient can redeem it.' +
52597
53180
  '</p>';
52598
53181
  showModal('Invite a member', bodyHtml, {
52599
53182
  primaryLabel: 'Generate invite',
52600
53183
  onSubmit: function (scope) {
52601
53184
  var data = collectFormValues(scope);
52602
- if (!data.label) throw new Error('label is required');
53185
+ if (!data.email) throw new Error('an invitee email is required');
52603
53186
  return fetchJson('/api/cloud/invite', {
52604
53187
  method: 'POST',
52605
53188
  headers: { 'content-type': 'application/json' },
52606
- body: JSON.stringify({ label: data.label }),
53189
+ body: JSON.stringify({ email: data.email }),
52607
53190
  }).then(function (res) {
52608
- showInviteCredentialsModal((res && res.invite) || {});
53191
+ showInviteTokenModal(res || {});
52609
53192
  });
52610
53193
  },
52611
53194
  });
52612
53195
  }
52613
53196
 
52614
- function showInviteCredentialsModal(invite) {
52615
- invite = invite || {};
52616
- // Render the returned connection blob in a single copyable block. The
52617
- // member pastes these fields into "Join a cloud".
52618
- var lines = [
52619
- 'host=' + (invite.host || ''),
52620
- 'port=' + (invite.port || 5432),
52621
- 'dbname=' + (invite.dbname || ''),
52622
- 'user=' + (invite.user || ''),
52623
- 'password=' + (invite.password || ''),
52624
- ].join('\\n');
53197
+ function showInviteTokenModal(res) {
53198
+ res = res || {};
53199
+ var token = res.token || '';
52625
53200
  var bodyHtml =
52626
- '<p style="margin-top:0">Send these credentials to your new member \u2014 they paste them into \u201CJoin a cloud\u201D.</p>' +
52627
- '<div class="copy-token" id="copy-invite" style="white-space:pre">' + escapeHtml(lines) + '</div>' +
52628
- '<p style="font-size:12px;color:var(--text-muted);margin-bottom:0">Click the block to copy.</p>';
52629
- var handle = showModal('Invite credentials', bodyHtml, { primaryLabel: 'Done', onSubmit: function () {} });
53201
+ '<p style="margin-top:0">Send this invite token to <code>' + escapeHtml(res.email || '') +
53202
+ '</code> (privately). They enter their email + this token in \u201CJoin a cloud\u201D. It expires in ~7 days.</p>' +
53203
+ '<div class="copy-token" id="copy-invite" style="white-space:pre-wrap;word-break:break-all">' +
53204
+ escapeHtml(token) + '</div>' +
53205
+ '<p style="font-size:12px;color:var(--text-muted);margin-bottom:0">Click the token to copy.</p>';
53206
+ var handle = showModal('Invite token', bodyHtml, { primaryLabel: 'Done', onSubmit: function () {} });
52630
53207
  var blockEl = document.getElementById('copy-invite');
52631
53208
  if (blockEl) {
52632
53209
  blockEl.addEventListener('click', function () {
52633
- navigator.clipboard.writeText(lines).then(function () {
53210
+ navigator.clipboard.writeText(token).then(function () {
52634
53211
  var prev = blockEl.textContent;
52635
53212
  blockEl.textContent = 'Copied!';
52636
53213
  setTimeout(function () { blockEl.textContent = prev; }, 1200);
@@ -53168,9 +53745,13 @@ var appJs = `
53168
53745
  if (input) { input.value = ''; if (input._autoGrow) input._autoGrow(); else input.style.height = 'auto'; }
53169
53746
  if (sendBtn) sendBtn.disabled = true;
53170
53747
  var actx = null; var assembled = '';
53748
+ // Private mode: when the composer checkbox is checked, items the assistant
53749
+ // adds on this turn stay private to the current user.
53750
+ var privEl = document.getElementById('chat-private');
53751
+ var privateMode = !!(privEl && privEl.checked);
53171
53752
  fetch('/api/chat', {
53172
53753
  method: 'POST', headers: { 'content-type': 'application/json' },
53173
- body: JSON.stringify({ message: text, history: historyToSend, threadId: currentThreadId })
53754
+ body: JSON.stringify({ message: text, history: historyToSend, threadId: currentThreadId, privateMode: privateMode })
53174
53755
  }).then(function (r) {
53175
53756
  if (!r.ok || !r.body) {
53176
53757
  return r.json().then(function (j) { throw new Error(j.error || ('HTTP ' + r.status)); });
@@ -53436,6 +54017,12 @@ var appJs = `
53436
54017
  '<textarea id="chat-input" rows="1" placeholder="Ask or instruct\u2026 (Enter to send)"></textarea>' +
53437
54018
  '<button class="composer-send" id="chat-send">Send</button>' +
53438
54019
  '</div>' +
54020
+ // Private mode \u2014 when checked, items the assistant adds on this send
54021
+ // stay private to me (passed as privateMode in the /api/chat body).
54022
+ '<label class="composer-private">' +
54023
+ '<input type="checkbox" id="chat-private" /> Private mode ' +
54024
+ '<span class="composer-private-hint">New items I add stay private to you</span>' +
54025
+ '</label>' +
53439
54026
  '<input type="file" id="chat-file" multiple style="display:none">';
53440
54027
  var input = document.getElementById('chat-input');
53441
54028
  var sendBtn = document.getElementById('chat-send');
@@ -53553,11 +54140,6 @@ var guiAppHtml = `<!doctype html>
53553
54140
  </header>
53554
54141
  <div class="layout">
53555
54142
  <nav class="sidebar">
53556
- <label class="sidebar-advanced toggle" title="Advanced mode \u2014 row/table editor instead of the file workspace">
53557
- <input type="checkbox" id="advanced-toggle">
53558
- <span class="toggle-track"><span class="toggle-thumb"></span></span>
53559
- <span class="toggle-label">Advanced View</span>
53560
- </label>
53561
54143
  <div class="section-label">Objects</div>
53562
54144
  <ul id="object-nav"></ul>
53563
54145
  <div id="system-section" hidden>
@@ -53589,6 +54171,7 @@ var guiAppHtml = `<!doctype html>
53589
54171
  </div>
53590
54172
  <div class="drawer-tabs" id="drawer-tabs">
53591
54173
  <button class="drawer-tab" data-tab="database">Workspace</button>
54174
+ <button class="drawer-tab" data-tab="chat">Chat</button>
53592
54175
  <button class="drawer-tab" data-tab="lattice">Lattice</button>
53593
54176
  <button class="drawer-tab" data-tab="user">User</button>
53594
54177
  </div>
@@ -53846,6 +54429,681 @@ async function discoverCloudTables(db) {
53846
54429
  return out;
53847
54430
  }
53848
54431
 
54432
+ // src/cloud/members.ts
54433
+ import { randomBytes as randomBytes5 } from "crypto";
54434
+
54435
+ // src/cloud/rls.ts
54436
+ function isPg(db) {
54437
+ return db.getDialect() === "postgres";
54438
+ }
54439
+ function pkSqlExpr(pkCols, prefix) {
54440
+ if (pkCols.length === 0) {
54441
+ throw new Error("cloud RLS: cannot key a table with no primary key column");
54442
+ }
54443
+ return pkCols.map((c6) => `CAST(${prefix}"${c6}" AS TEXT)`).join(` || chr(9) || `);
54444
+ }
54445
+ var MEMBER_GROUP = "lattice_members";
54446
+ function pinDefinerSearchPath(sql, schema) {
54447
+ const safe = schema.replace(/"/g, '""');
54448
+ return sql.replace(
54449
+ /SECURITY DEFINER AS/g,
54450
+ `SECURITY DEFINER SET search_path = "${safe}", pg_temp AS`
54451
+ );
54452
+ }
54453
+ async function cloudSchema(db) {
54454
+ const row = await getAsyncOrSync(db.adapter, `SELECT current_schema() AS schema`);
54455
+ const s2 = row?.schema;
54456
+ if (typeof s2 !== "string" || s2.length === 0) {
54457
+ throw new Error("cloud RLS: could not resolve current_schema() for search_path pinning");
54458
+ }
54459
+ return s2;
54460
+ }
54461
+ function revokeSchemaCreateSql(schema) {
54462
+ const lit = `'${schema.replace(/'/g, "''")}'`;
54463
+ return `
54464
+ DO $LATTICE_REVOKE$ BEGIN
54465
+ EXECUTE format('REVOKE CREATE ON SCHEMA %I FROM PUBLIC', ${lit});
54466
+ EXCEPTION WHEN OTHERS THEN
54467
+ NULL; -- not the schema owner, or already revoked
54468
+ END $LATTICE_REVOKE$;
54469
+ `;
54470
+ }
54471
+ var CLOUD_RLS_BOOTSTRAP_SQL = `
54472
+ -- Member group (NOLOGIN). Members inherit schema/connect/table privileges from it;
54473
+ -- RLS filters per the individual member's login role, so the group never widens
54474
+ -- what a member can see. Idempotent.
54475
+ DO $LATTICE$ BEGIN
54476
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${MEMBER_GROUP}') THEN
54477
+ CREATE ROLE ${MEMBER_GROUP} NOLOGIN;
54478
+ END IF;
54479
+ EXECUTE format('GRANT USAGE ON SCHEMA %I TO ${MEMBER_GROUP}', current_schema());
54480
+ EXECUTE format('GRANT CONNECT ON DATABASE %I TO ${MEMBER_GROUP}', current_database());
54481
+ END $LATTICE$;
54482
+
54483
+ CREATE TABLE IF NOT EXISTS "__lattice_owners" (
54484
+ "table_name" text NOT NULL,
54485
+ "pk" text NOT NULL,
54486
+ "owner_role" text NOT NULL,
54487
+ "visibility" text NOT NULL DEFAULT 'private' CHECK ("visibility" IN ('private','everyone','custom')),
54488
+ "created_at" timestamptz NOT NULL DEFAULT now(),
54489
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
54490
+ PRIMARY KEY ("table_name", "pk")
54491
+ );
54492
+
54493
+ CREATE TABLE IF NOT EXISTS "__lattice_row_grants" (
54494
+ "table_name" text NOT NULL,
54495
+ "pk" text NOT NULL,
54496
+ "grantee_role" text NOT NULL,
54497
+ "granted_by" text NOT NULL,
54498
+ "granted_at" timestamptz NOT NULL DEFAULT now(),
54499
+ PRIMARY KEY ("table_name", "pk", "grantee_role")
54500
+ );
54501
+
54502
+ CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee"
54503
+ ON "__lattice_row_grants" ("grantee_role", "table_name", "pk");
54504
+
54505
+ -- App-role assignments for the audience layer: maps a member's login role to the
54506
+ -- named app roles (e.g. 'hr') a fixed-policy column may require. Owner-managed;
54507
+ -- members cannot read or write it (no grant), so a member can't self-promote.
54508
+ CREATE TABLE IF NOT EXISTS "__lattice_member_roles" (
54509
+ "member_role" text NOT NULL,
54510
+ "app_role" text NOT NULL,
54511
+ "granted_by" text NOT NULL DEFAULT session_user,
54512
+ "granted_at" timestamptz NOT NULL DEFAULT now(),
54513
+ PRIMARY KEY ("member_role", "app_role")
54514
+ );
54515
+
54516
+ -- Per-card audience overrides: a row owner can grant a SPECIFIC member access to
54517
+ -- a SPECIFIC masked cell (table + pk + column), without changing the column's
54518
+ -- schema-level audience. The generated mask view ORs this in. Owner-managed;
54519
+ -- members can't read or write it.
54520
+ CREATE TABLE IF NOT EXISTS "__lattice_cell_grants" (
54521
+ "table_name" text NOT NULL,
54522
+ "pk" text NOT NULL,
54523
+ "column_name" text NOT NULL,
54524
+ "grantee_role" text NOT NULL,
54525
+ "granted_by" text NOT NULL DEFAULT session_user,
54526
+ "granted_at" timestamptz NOT NULL DEFAULT now(),
54527
+ PRIMARY KEY ("table_name", "pk", "column_name", "grantee_role")
54528
+ );
54529
+
54530
+ -- Per-table policy: the owner-controlled defaults that govern a whole table.
54531
+ -- default_row_visibility is the visibility NEW rows are stamped with (the insert
54532
+ -- trigger reads it); never_share is a hard exclusion \u2014 the share/grant functions
54533
+ -- refuse to elevate such a table and the trigger forces its rows private. Owner-
54534
+ -- managed; members have no grant (it never appears in their data API).
54535
+ CREATE TABLE IF NOT EXISTS "__lattice_table_policy" (
54536
+ "table_name" text PRIMARY KEY,
54537
+ "default_row_visibility" text NOT NULL DEFAULT 'private'
54538
+ CHECK ("default_row_visibility" IN ('private','everyone')),
54539
+ "never_share" boolean NOT NULL DEFAULT false,
54540
+ "updated_by" text NOT NULL DEFAULT session_user,
54541
+ "updated_at" timestamptz NOT NULL DEFAULT now()
54542
+ );
54543
+
54544
+ -- Per-column audience policy: the CANONICAL store of which column carries which
54545
+ -- audience spec (role: / subject: / source: / owner / everyone). Previously the
54546
+ -- spec lived only in the owner's on-disk YAML and was compiled into the mask view
54547
+ -- once at init; storing it here makes it cloud-canonical and member-consistent.
54548
+ -- The generated <table>_v mask view is regenerated from THIS table on change.
54549
+ -- Owner-managed; members have no grant.
54550
+ CREATE TABLE IF NOT EXISTS "__lattice_column_policy" (
54551
+ "table_name" text NOT NULL,
54552
+ "column_name" text NOT NULL,
54553
+ "audience" text NOT NULL,
54554
+ "updated_by" text NOT NULL DEFAULT session_user,
54555
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
54556
+ PRIMARY KEY ("table_name", "column_name")
54557
+ );
54558
+
54559
+ -- Owner-only audit of issued member invites: which scoped role was minted for
54560
+ -- which email (HASHED \u2014 the plaintext email is never stored), when it expires,
54561
+ -- and whether it was redeemed/revoked. No plaintext password is ever stored
54562
+ -- (the credential lives only inside the email-bound token the owner delivers).
54563
+ -- Owner-managed; members have no grant. Named distinctly from any legacy
54564
+ -- team-model invitations table so a pre-existing cloud never collides.
54565
+ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
54566
+ "id" text PRIMARY KEY,
54567
+ "role" text NOT NULL,
54568
+ "email_hash" text NOT NULL,
54569
+ "created_by" text NOT NULL DEFAULT session_user,
54570
+ "created_at" timestamptz NOT NULL DEFAULT now(),
54571
+ "expires_at" timestamptz NOT NULL,
54572
+ "redeemed_at" timestamptz,
54573
+ "revoked_at" timestamptz
54574
+ );
54575
+
54576
+ -- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
54577
+ -- keyed on session_user (the member's login role). A row with no ownership record
54578
+ -- is visible to nobody.
54579
+ CREATE OR REPLACE FUNCTION lattice_row_visible(p_table text, p_pk text)
54580
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54581
+ SELECT EXISTS (
54582
+ SELECT 1 FROM "__lattice_owners" o
54583
+ WHERE o."table_name" = p_table AND o."pk" = p_pk
54584
+ AND ( o."owner_role" = session_user
54585
+ OR o."visibility" = 'everyone'
54586
+ OR ( o."visibility" = 'custom' AND EXISTS (
54587
+ SELECT 1 FROM "__lattice_row_grants" g
54588
+ WHERE g."table_name" = o."table_name" AND g."pk" = o."pk"
54589
+ AND g."grantee_role" = session_user)))
54590
+ );
54591
+ $fn$;
54592
+
54593
+ -- Owner-only: change a row's visibility. Raises if the caller is not the owner.
54594
+ CREATE OR REPLACE FUNCTION lattice_set_row_visibility(p_table text, p_pk text, p_visibility text)
54595
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54596
+ DECLARE v_owner text;
54597
+ BEGIN
54598
+ IF p_visibility NOT IN ('private','everyone','custom') THEN
54599
+ RAISE EXCEPTION 'lattice: invalid visibility %', p_visibility;
54600
+ END IF;
54601
+ IF p_visibility <> 'private'
54602
+ AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
54603
+ RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
54604
+ END IF;
54605
+ SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54606
+ WHERE o."table_name" = p_table AND o."pk" = p_pk;
54607
+ IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
54608
+ IF v_owner <> session_user THEN RAISE EXCEPTION 'lattice: only the row owner may change its sharing'; END IF;
54609
+ UPDATE "__lattice_owners" SET "visibility" = p_visibility, "updated_at" = now()
54610
+ WHERE "table_name" = p_table AND "pk" = p_pk;
54611
+ END $fn$;
54612
+
54613
+ -- Owner-only: grant a specific member access to a row (sets visibility = 'custom').
54614
+ CREATE OR REPLACE FUNCTION lattice_grant_row(p_table text, p_pk text, p_grantee text)
54615
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54616
+ DECLARE v_owner text;
54617
+ BEGIN
54618
+ IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
54619
+ RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
54620
+ END IF;
54621
+ SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54622
+ WHERE o."table_name" = p_table AND o."pk" = p_pk;
54623
+ IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
54624
+ IF v_owner <> session_user THEN RAISE EXCEPTION 'lattice: only the row owner may grant access'; END IF;
54625
+ UPDATE "__lattice_owners" SET "visibility" = 'custom', "updated_at" = now()
54626
+ WHERE "table_name" = p_table AND "pk" = p_pk;
54627
+ INSERT INTO "__lattice_row_grants" ("table_name","pk","grantee_role","granted_by")
54628
+ VALUES (p_table, p_pk, p_grantee, session_user)
54629
+ ON CONFLICT ("table_name","pk","grantee_role") DO NOTHING;
54630
+ END $fn$;
54631
+
54632
+ -- Owner-only: revoke a member's access to a row.
54633
+ CREATE OR REPLACE FUNCTION lattice_revoke_row(p_table text, p_pk text, p_grantee text)
54634
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54635
+ DECLARE v_owner text;
54636
+ BEGIN
54637
+ SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54638
+ WHERE o."table_name" = p_table AND o."pk" = p_pk;
54639
+ IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
54640
+ IF v_owner <> session_user THEN RAISE EXCEPTION 'lattice: only the row owner may revoke access'; END IF;
54641
+ DELETE FROM "__lattice_row_grants"
54642
+ WHERE "table_name" = p_table AND "pk" = p_pk AND "grantee_role" = p_grantee;
54643
+ END $fn$;
54644
+
54645
+ -- \u2500\u2500 Per-viewer audience helpers (Stage-0 scaffolding) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54646
+ -- The predicates a generated per-column cell-masking view will call. ALL are
54647
+ -- SECURITY DEFINER and keyed on session_user (NEVER current_user / SECURITY
54648
+ -- INVOKER) so they bind to the real member even when an owner-rights view
54649
+ -- executes them \u2014 the identity invariant the whole cloud model depends on. They
54650
+ -- are not referenced by any policy or view yet, so they change NO behavior in
54651
+ -- Stage-0; a later stage wires them into generated views.
54652
+
54653
+ -- Is the connected member the subject of this row (e.g. their own person row)?
54654
+ CREATE OR REPLACE FUNCTION lattice_is_subject(p_subject text)
54655
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54656
+ SELECT p_subject = session_user
54657
+ $fn$;
54658
+
54659
+ -- Does the connected member hold a named app role? Reads the owner-managed
54660
+ -- member-roles table (which members can't see) keyed on session_user, so a
54661
+ -- member cannot grant themselves a role to unmask a column.
54662
+ CREATE OR REPLACE FUNCTION lattice_has_role(p_role text)
54663
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54664
+ SELECT EXISTS (
54665
+ SELECT 1 FROM "__lattice_member_roles"
54666
+ WHERE "member_role" = session_user AND "app_role" = p_role
54667
+ )
54668
+ $fn$;
54669
+
54670
+ -- Owner-only: assign an app role to a member (so a fixed-policy masked column
54671
+ -- becomes visible to them). Raises unless the caller can create roles (a cloud
54672
+ -- owner / DBA).
54673
+ CREATE OR REPLACE FUNCTION lattice_assign_role(p_member text, p_role text)
54674
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54675
+ BEGIN
54676
+ IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
54677
+ RAISE EXCEPTION 'lattice: only a cloud owner may assign app roles';
54678
+ END IF;
54679
+ INSERT INTO "__lattice_member_roles" ("member_role", "app_role")
54680
+ VALUES (p_member, p_role) ON CONFLICT DO NOTHING;
54681
+ END $fn$;
54682
+
54683
+ -- Owner-only: revoke an app role from a member.
54684
+ CREATE OR REPLACE FUNCTION lattice_revoke_role(p_member text, p_role text)
54685
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54686
+ BEGIN
54687
+ IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
54688
+ RAISE EXCEPTION 'lattice: only a cloud owner may revoke app roles';
54689
+ END IF;
54690
+ DELETE FROM "__lattice_member_roles"
54691
+ WHERE "member_role" = p_member AND "app_role" = p_role;
54692
+ END $fn$;
54693
+
54694
+ -- Per-card override check: does the connected member hold a specific-cell grant?
54695
+ -- The generated mask view ORs this into a masked column's predicate.
54696
+ CREATE OR REPLACE FUNCTION lattice_cell_visible(p_table text, p_pk text, p_column text)
54697
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54698
+ SELECT EXISTS (
54699
+ SELECT 1 FROM "__lattice_cell_grants"
54700
+ WHERE "table_name" = p_table AND "pk" = p_pk
54701
+ AND "column_name" = p_column AND "grantee_role" = session_user
54702
+ )
54703
+ $fn$;
54704
+
54705
+ -- Owner-only: grant a member access to one masked cell (a per-card override).
54706
+ CREATE OR REPLACE FUNCTION lattice_grant_cell(p_table text, p_pk text, p_column text, p_grantee text)
54707
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54708
+ DECLARE v_owner text;
54709
+ BEGIN
54710
+ IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
54711
+ RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
54712
+ END IF;
54713
+ SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54714
+ WHERE o."table_name" = p_table AND o."pk" = p_pk;
54715
+ IF v_owner IS NULL OR v_owner <> session_user THEN
54716
+ RAISE EXCEPTION 'lattice: only the row owner may set a per-cell audience';
54717
+ END IF;
54718
+ INSERT INTO "__lattice_cell_grants" ("table_name","pk","column_name","grantee_role")
54719
+ VALUES (p_table, p_pk, p_column, p_grantee) ON CONFLICT DO NOTHING;
54720
+ END $fn$;
54721
+
54722
+ -- Owner-only: revoke a per-cell override.
54723
+ CREATE OR REPLACE FUNCTION lattice_revoke_cell(p_table text, p_pk text, p_column text, p_grantee text)
54724
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54725
+ DECLARE v_owner text;
54726
+ BEGIN
54727
+ SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54728
+ WHERE o."table_name" = p_table AND o."pk" = p_pk;
54729
+ IF v_owner IS NULL OR v_owner <> session_user THEN
54730
+ RAISE EXCEPTION 'lattice: only the row owner may change a per-cell audience';
54731
+ END IF;
54732
+ DELETE FROM "__lattice_cell_grants"
54733
+ WHERE "table_name" = p_table AND "pk" = p_pk
54734
+ AND "column_name" = p_column AND "grantee_role" = p_grantee;
54735
+ END $fn$;
54736
+
54737
+ -- Can the connected member see a source? Reduces to the source row's own RLS, so
54738
+ -- file-sharing drives enrichment visibility for free. p_source_ref is the
54739
+ -- source's primary key in the files table.
54740
+ CREATE OR REPLACE FUNCTION lattice_source_visible(p_source_ref text)
54741
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54742
+ SELECT lattice_row_visible('files', p_source_ref)
54743
+ $fn$;
54744
+
54745
+ -- Is the connected member the OWNER of this row? Used by the "owner" column
54746
+ -- audience (a secret column reveals only to the row owner). SECURITY DEFINER +
54747
+ -- session_user, like the other predicates.
54748
+ CREATE OR REPLACE FUNCTION lattice_is_owner(p_table text, p_pk text)
54749
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54750
+ SELECT EXISTS (
54751
+ SELECT 1 FROM "__lattice_owners" o
54752
+ WHERE o."table_name" = p_table AND o."pk" = p_pk AND o."owner_role" = session_user
54753
+ )
54754
+ $fn$;
54755
+
54756
+ -- Owner-only: set a table's default row visibility for NEW rows. Raises unless the
54757
+ -- caller can create roles (a cloud owner / DBA), like lattice_assign_role. Rejects
54758
+ -- 'everyone' on a never-share table.
54759
+ CREATE OR REPLACE FUNCTION lattice_set_table_default_visibility(p_table text, p_visibility text)
54760
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54761
+ BEGIN
54762
+ IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
54763
+ RAISE EXCEPTION 'lattice: only a cloud owner may set a table''s default visibility';
54764
+ END IF;
54765
+ IF p_visibility NOT IN ('private','everyone') THEN
54766
+ RAISE EXCEPTION 'lattice: invalid default visibility %', p_visibility;
54767
+ END IF;
54768
+ IF p_visibility = 'everyone'
54769
+ AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
54770
+ RAISE EXCEPTION 'lattice: "%" is a private-only table; its rows cannot default to everyone', p_table;
54771
+ END IF;
54772
+ INSERT INTO "__lattice_table_policy" ("table_name","default_row_visibility","updated_by","updated_at")
54773
+ VALUES (p_table, p_visibility, session_user, now())
54774
+ ON CONFLICT ("table_name") DO UPDATE
54775
+ SET "default_row_visibility" = EXCLUDED."default_row_visibility",
54776
+ "updated_by" = session_user, "updated_at" = now();
54777
+ END $fn$;
54778
+
54779
+ -- Owner-only: mark a table never-shareable (Secrets/Messages-class). When true the
54780
+ -- share/grant functions raise and the insert trigger forces new rows private; the
54781
+ -- default visibility is also forced private. Turning it ON also RETROACTIVELY
54782
+ -- privatizes the table: any row currently shared ('everyone'/'custom') is reset to
54783
+ -- 'private' and every existing row/cell grant on the table is dropped \u2014 otherwise
54784
+ -- flagging a table never-share would leave already-leaked rows visible, defeating
54785
+ -- the point. Idempotent: re-running with already-private rows updates nothing.
54786
+ CREATE OR REPLACE FUNCTION lattice_set_table_never_share(p_table text, p_on boolean)
54787
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54788
+ BEGIN
54789
+ IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
54790
+ RAISE EXCEPTION 'lattice: only a cloud owner may change a table''s never-share flag';
54791
+ END IF;
54792
+ INSERT INTO "__lattice_table_policy" ("table_name","never_share","default_row_visibility","updated_by","updated_at")
54793
+ VALUES (p_table, p_on, CASE WHEN p_on THEN 'private' ELSE 'private' END, session_user, now())
54794
+ ON CONFLICT ("table_name") DO UPDATE
54795
+ SET "never_share" = EXCLUDED."never_share",
54796
+ "default_row_visibility" = CASE WHEN EXCLUDED."never_share"
54797
+ THEN 'private' ELSE "__lattice_table_policy"."default_row_visibility" END,
54798
+ "updated_by" = session_user, "updated_at" = now();
54799
+ IF p_on THEN
54800
+ UPDATE "__lattice_owners" SET "visibility" = 'private', "updated_at" = now()
54801
+ WHERE "table_name" = p_table AND "visibility" <> 'private';
54802
+ DELETE FROM "__lattice_row_grants" WHERE "table_name" = p_table;
54803
+ DELETE FROM "__lattice_cell_grants" WHERE "table_name" = p_table;
54804
+ END IF;
54805
+ END $fn$;
54806
+
54807
+ -- Owner-only: set (or clear) a column's audience spec in the canonical DB store.
54808
+ -- An empty/null spec removes the policy row (column becomes unmasked). The GUI/lib
54809
+ -- regenerates the table's mask view from this store after calling this.
54810
+ CREATE OR REPLACE FUNCTION lattice_set_column_audience(p_table text, p_column text, p_audience text)
54811
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54812
+ BEGIN
54813
+ IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
54814
+ RAISE EXCEPTION 'lattice: only a cloud owner may set a column audience';
54815
+ END IF;
54816
+ IF p_audience IS NULL OR btrim(p_audience) = '' THEN
54817
+ DELETE FROM "__lattice_column_policy" WHERE "table_name" = p_table AND "column_name" = p_column;
54818
+ ELSE
54819
+ INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience","updated_by","updated_at")
54820
+ VALUES (p_table, p_column, p_audience, session_user, now())
54821
+ ON CONFLICT ("table_name","column_name") DO UPDATE
54822
+ SET "audience" = EXCLUDED."audience", "updated_by" = session_user, "updated_at" = now();
54823
+ END IF;
54824
+ END $fn$;
54825
+
54826
+ -- Append-only change feed. The per-table ownership trigger records one row per
54827
+ -- INSERT/UPDATE/DELETE; the AFTER INSERT trigger here fires pg_notify so a
54828
+ -- connected member's realtime broker refreshes. Members get no direct access \u2014
54829
+ -- the NOTIFY carries only (table, pk, op) metadata, and the SPA refetches the row
54830
+ -- itself through RLS, so another member's content is never broadcast.
54831
+ CREATE TABLE IF NOT EXISTS "__lattice_changes" (
54832
+ "seq" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
54833
+ "table_name" text NOT NULL,
54834
+ "pk" text NOT NULL,
54835
+ "op" text NOT NULL CHECK ("op" IN ('upsert','delete')),
54836
+ "owner_role" text NOT NULL,
54837
+ "created_at" timestamptz NOT NULL DEFAULT now()
54838
+ );
54839
+
54840
+ CREATE OR REPLACE FUNCTION lattice_notify_change() RETURNS trigger
54841
+ LANGUAGE plpgsql AS $fn$
54842
+ BEGIN
54843
+ PERFORM pg_notify('lattice_changes', json_build_object(
54844
+ 'seq', NEW."seq",
54845
+ 'table_name', NEW."table_name",
54846
+ 'pk', NEW."pk",
54847
+ 'op', NEW."op",
54848
+ 'owner_role', NEW."owner_role",
54849
+ 'created_at', NEW."created_at"
54850
+ )::text);
54851
+ RETURN NEW;
54852
+ END $fn$;
54853
+
54854
+ DROP TRIGGER IF EXISTS "lattice_notify_change_trg" ON "__lattice_changes";
54855
+ CREATE TRIGGER "lattice_notify_change_trg" AFTER INSERT ON "__lattice_changes"
54856
+ FOR EACH ROW EXECUTE FUNCTION lattice_notify_change();
54857
+ `;
54858
+ function tableRlsSql(table, pkCols) {
54859
+ const q3 = `"${table.replace(/"/g, '""')}"`;
54860
+ const lit = `'${table.replace(/'/g, "''")}'`;
54861
+ const pkNew = pkSqlExpr(pkCols, "NEW.");
54862
+ const pkOld = pkSqlExpr(pkCols, "OLD.");
54863
+ const pkRow = pkSqlExpr(pkCols, "");
54864
+ const trg = `lattice_track_${table.replace(/[^A-Za-z0-9_]/g, "_")}`;
54865
+ return `
54866
+ CREATE OR REPLACE FUNCTION "${trg}"() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54867
+ BEGIN
54868
+ IF TG_OP = 'INSERT' THEN
54869
+ INSERT INTO "__lattice_owners" ("table_name","pk","owner_role","visibility")
54870
+ VALUES (${lit}, ${pkNew}, session_user,
54871
+ CASE
54872
+ -- never-share always wins: such a table's rows are private, full stop.
54873
+ WHEN COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), false)
54874
+ THEN 'private'
54875
+ -- per-INSERT override: a caller forcing visibility for THIS write (e.g.
54876
+ -- chat "private mode") sets the transaction-local lattice.force_row_visibility
54877
+ -- GUC, so the row is stamped atomically at insert \u2014 never momentarily at
54878
+ -- the table default, and the change-feed NOTIFY (deferred to COMMIT) only
54879
+ -- fires once the row already carries this visibility.
54880
+ WHEN NULLIF(current_setting('lattice.force_row_visibility', true), '') IN ('private','everyone')
54881
+ THEN current_setting('lattice.force_row_visibility', true)
54882
+ ELSE COALESCE((SELECT "default_row_visibility" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), 'private')
54883
+ END)
54884
+ ON CONFLICT ("table_name","pk") DO NOTHING;
54885
+ INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
54886
+ VALUES (${lit}, ${pkNew}, 'upsert', session_user);
54887
+ RETURN NEW;
54888
+ ELSIF TG_OP = 'UPDATE' THEN
54889
+ INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
54890
+ VALUES (${lit}, ${pkNew}, 'upsert', session_user);
54891
+ RETURN NEW;
54892
+ ELSIF TG_OP = 'DELETE' THEN
54893
+ DELETE FROM "__lattice_owners" WHERE "table_name" = ${lit} AND "pk" = ${pkOld};
54894
+ DELETE FROM "__lattice_row_grants" WHERE "table_name" = ${lit} AND "pk" = ${pkOld};
54895
+ INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
54896
+ VALUES (${lit}, ${pkOld}, 'delete', session_user);
54897
+ RETURN OLD;
54898
+ END IF;
54899
+ RETURN NEW;
54900
+ END $fn$;
54901
+
54902
+ ALTER TABLE ${q3} ENABLE ROW LEVEL SECURITY;
54903
+ ALTER TABLE ${q3} FORCE ROW LEVEL SECURITY;
54904
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ${q3} TO ${MEMBER_GROUP};
54905
+
54906
+ DROP POLICY IF EXISTS "lattice_sel" ON ${q3};
54907
+ CREATE POLICY "lattice_sel" ON ${q3} FOR SELECT USING (lattice_row_visible(${lit}, ${pkRow}));
54908
+ DROP POLICY IF EXISTS "lattice_upd" ON ${q3};
54909
+ CREATE POLICY "lattice_upd" ON ${q3} FOR UPDATE USING (lattice_row_visible(${lit}, ${pkRow}))
54910
+ WITH CHECK (lattice_row_visible(${lit}, ${pkRow}));
54911
+ DROP POLICY IF EXISTS "lattice_del" ON ${q3};
54912
+ CREATE POLICY "lattice_del" ON ${q3} FOR DELETE USING (lattice_row_visible(${lit}, ${pkRow}));
54913
+ DROP POLICY IF EXISTS "lattice_ins" ON ${q3};
54914
+ CREATE POLICY "lattice_ins" ON ${q3} FOR INSERT WITH CHECK (true);
54915
+
54916
+ DROP TRIGGER IF EXISTS "${trg}" ON ${q3};
54917
+ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
54918
+ FOR EACH ROW EXECUTE FUNCTION "${trg}"();
54919
+ `;
54920
+ }
54921
+ async function installCloudRls(db) {
54922
+ if (!isPg(db)) return;
54923
+ const schema = await cloudSchema(db);
54924
+ const migration = {
54925
+ // v3 added the audience helpers; v4 the role model; v5 the per-card override
54926
+ // model (__lattice_cell_grants + lattice_cell_visible / lattice_grant_cell);
54927
+ // v6 added per-table policy (__lattice_table_policy: default_row_visibility +
54928
+ // never_share, enforced in the insert trigger + share/grant guards), the
54929
+ // canonical column-audience store (__lattice_column_policy), lattice_is_owner,
54930
+ // and the owner-only setters; v7 pins search_path on every SECURITY DEFINER
54931
+ // helper (closes the pg_temp-shadow RLS bypass) + revokes schema CREATE from
54932
+ // PUBLIC. The bootstrap is fully idempotent.
54933
+ version: "internal:cloud-rls:bootstrap:v7",
54934
+ sql: pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema)
54935
+ };
54936
+ await db.migrate([migration]);
54937
+ }
54938
+ async function enableChangelogRls(db) {
54939
+ if (!isPg(db)) return;
54940
+ const migration = {
54941
+ // v2: ground-truth/audit entries are owner-only (was lattice_row_visible),
54942
+ // closing the masked-column-via-history leak. Bump re-installs the policy on
54943
+ // existing clouds.
54944
+ version: "internal:cloud-rls:changelog:v2",
54945
+ sql: `
54946
+ ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
54947
+ ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
54948
+ GRANT SELECT, INSERT ON "__lattice_changelog" TO ${MEMBER_GROUP};
54949
+
54950
+ DROP POLICY IF EXISTS "lattice_changelog_sel" ON "__lattice_changelog";
54951
+ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING (
54952
+ CASE
54953
+ WHEN "change_kind" = 'derived' THEN
54954
+ "source_ref" IS NOT NULL
54955
+ AND NOT EXISTS (
54956
+ SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
54957
+ WHERE NOT lattice_source_visible(src.sid)
54958
+ )
54959
+ ELSE lattice_is_owner("table_name", "row_id")
54960
+ END
54961
+ );
54962
+ DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
54963
+ CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH CHECK (true);
54964
+ `
54965
+ };
54966
+ await db.migrate([migration]);
54967
+ }
54968
+ async function enableRlsForTable(db, table, pkCols) {
54969
+ if (!isPg(db)) return;
54970
+ const schema = await cloudSchema(db);
54971
+ const migration = {
54972
+ version: `internal:cloud-rls:table:${table}:v3`,
54973
+ sql: pinDefinerSearchPath(tableRlsSql(table, pkCols), schema)
54974
+ };
54975
+ await db.migrate([migration]);
54976
+ }
54977
+ async function backfillOwnership(db, table, pkCols) {
54978
+ if (!isPg(db) || pkCols.length === 0) return;
54979
+ const q3 = `"${table.replace(/"/g, '""')}"`;
54980
+ const lit = `'${table.replace(/'/g, "''")}'`;
54981
+ const pkRow = pkSqlExpr(pkCols, "");
54982
+ await runAsyncOrSync(
54983
+ db.adapter,
54984
+ `INSERT INTO "__lattice_owners" ("table_name","pk","owner_role","visibility")
54985
+ SELECT ${lit}, ${pkRow}, current_user, 'private' FROM ${q3}
54986
+ ON CONFLICT ("table_name","pk") DO NOTHING`
54987
+ );
54988
+ }
54989
+
54990
+ // src/cloud/members.ts
54991
+ var ROLE_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
54992
+ var HEX_PW_RE = /^[0-9a-f]{16,}$/;
54993
+ function assertPg(db) {
54994
+ if (db.getDialect() !== "postgres") {
54995
+ throw new Error(
54996
+ "lattice: cloud members require a Postgres cloud (SQLite is single-user/local)"
54997
+ );
54998
+ }
54999
+ }
55000
+ function generateMemberPassword() {
55001
+ return randomBytes5(24).toString("hex");
55002
+ }
55003
+ function memberRoleName(label) {
55004
+ const base = label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "member";
55005
+ return `lm_${base}_${randomBytes5(3).toString("hex")}`.slice(0, 63);
55006
+ }
55007
+ async function provisionMemberRole(db, role, password) {
55008
+ assertPg(db);
55009
+ if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
55010
+ if (!HEX_PW_RE.test(password)) {
55011
+ throw new Error("lattice: member password must be hex \u2014 use generateMemberPassword()");
55012
+ }
55013
+ await runAsyncOrSync(
55014
+ db.adapter,
55015
+ `DO $LATTICE$ BEGIN
55016
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
55017
+ CREATE ROLE "${role}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
55018
+ ELSE
55019
+ ALTER ROLE "${role}" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
55020
+ END IF;
55021
+ END $LATTICE$`
55022
+ );
55023
+ await runAsyncOrSync(db.adapter, `GRANT ${MEMBER_GROUP} TO "${role}"`);
55024
+ }
55025
+ var VISIBILITY = /* @__PURE__ */ new Set(["private", "everyone"]);
55026
+ async function setRowVisibility(db, table, pk, visibility) {
55027
+ assertPg(db);
55028
+ if (!VISIBILITY.has(visibility)) {
55029
+ throw new Error(`lattice: invalid visibility "${visibility}" (expected private | everyone)`);
55030
+ }
55031
+ await runAsyncOrSync(db.adapter, `SELECT lattice_set_row_visibility(?, ?, ?)`, [
55032
+ table,
55033
+ pk,
55034
+ visibility
55035
+ ]);
55036
+ }
55037
+ async function rowAccessSummaries(db, table, pks) {
55038
+ const out = /* @__PURE__ */ new Map();
55039
+ if (db.getDialect() !== "postgres" || pks.length === 0) return out;
55040
+ if (!await cloudRlsInstalled(db)) return out;
55041
+ const placeholders = pks.map(() => "?").join(", ");
55042
+ const owners = await allAsyncOrSync(
55043
+ db.adapter,
55044
+ `SELECT "pk", "visibility", ("owner_role" = session_user) AS owned
55045
+ FROM "__lattice_owners"
55046
+ WHERE "table_name" = ? AND "pk" IN (${placeholders})`,
55047
+ [table, ...pks]
55048
+ );
55049
+ for (const o3 of owners) {
55050
+ out.set(o3.pk, {
55051
+ visibility: o3.visibility ?? "private",
55052
+ ownedByMe: o3.owned === true || o3.owned === "t" || o3.owned === 1
55053
+ });
55054
+ }
55055
+ const customPks = owners.filter((o3) => o3.visibility === "custom").map((o3) => o3.pk);
55056
+ if (customPks.length > 0) {
55057
+ const cph = customPks.map(() => "?").join(", ");
55058
+ const grants = await allAsyncOrSync(
55059
+ db.adapter,
55060
+ `SELECT "pk", "grantee_role" FROM "__lattice_row_grants"
55061
+ WHERE "table_name" = ? AND "pk" IN (${cph})`,
55062
+ [table, ...customPks]
55063
+ );
55064
+ for (const g6 of grants) {
55065
+ const a6 = out.get(g6.pk);
55066
+ if (a6) (a6.grantees ??= []).push(g6.grantee_role);
55067
+ }
55068
+ }
55069
+ return out;
55070
+ }
55071
+ async function assertScopedMemberRole(db, role) {
55072
+ assertPg(db);
55073
+ const row = await getAsyncOrSync(
55074
+ db.adapter,
55075
+ `SELECT rolsuper, rolcreaterole, rolbypassrls, (rolname = session_user) AS is_self
55076
+ FROM pg_roles WHERE rolname = ?`,
55077
+ [role]
55078
+ );
55079
+ if (!row) throw new Error(`lattice: role "${role}" does not exist`);
55080
+ const truthy = (v2) => v2 === true || v2 === "t" || v2 === 1;
55081
+ if (truthy(row.rolsuper) || truthy(row.rolcreaterole) || truthy(row.rolbypassrls)) {
55082
+ throw new Error("lattice: refusing to embed a privileged role in an invite token");
55083
+ }
55084
+ if (truthy(row.is_self)) {
55085
+ throw new Error("lattice: refusing to embed the cloud owner role in an invite token");
55086
+ }
55087
+ }
55088
+ async function grantCell(db, table, pk, column, grantee) {
55089
+ assertPg(db);
55090
+ await runAsyncOrSync(db.adapter, `SELECT lattice_grant_cell(?, ?, ?, ?)`, [
55091
+ table,
55092
+ pk,
55093
+ column,
55094
+ grantee
55095
+ ]);
55096
+ }
55097
+ async function revokeCell(db, table, pk, column, grantee) {
55098
+ assertPg(db);
55099
+ await runAsyncOrSync(db.adapter, `SELECT lattice_revoke_cell(?, ?, ?, ?)`, [
55100
+ table,
55101
+ pk,
55102
+ column,
55103
+ grantee
55104
+ ]);
55105
+ }
55106
+
53849
55107
  // src/gui/feed.ts
53850
55108
  import { EventEmitter as EventEmitter2 } from "events";
53851
55109
  var EVENT = "feed";
@@ -53897,6 +55155,132 @@ var FeedBus = class {
53897
55155
  }
53898
55156
  };
53899
55157
 
55158
+ // src/cloud/audience.ts
55159
+ var ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
55160
+ var COL_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
55161
+ function isRowAudience(audience) {
55162
+ const a6 = (audience ?? "").trim();
55163
+ return a6 === "" || a6 === "everyone" || a6 === "row-audience";
55164
+ }
55165
+ function audiencePredicate(audience, ctx) {
55166
+ if (isRowAudience(audience)) return "true";
55167
+ const clauses = audience.split("+").map((c6) => c6.trim()).filter(Boolean);
55168
+ const parts = [];
55169
+ for (const clause of clauses) {
55170
+ if (clause === "everyone" || clause === "row-audience") return "true";
55171
+ if (clause === "owner") {
55172
+ if (!ctx) throw new Error('lattice: the "owner" audience needs a row context');
55173
+ parts.push(`lattice_is_owner(${ctx.tableLit}, ${ctx.pkExpr})`);
55174
+ continue;
55175
+ }
55176
+ const idx = clause.indexOf(":");
55177
+ const kind = idx === -1 ? clause : clause.slice(0, idx);
55178
+ const arg = idx === -1 ? "" : clause.slice(idx + 1).trim();
55179
+ if (kind === "role") {
55180
+ if (!ROLE_NAME_RE.test(arg)) throw new Error(`lattice: invalid role in audience "${clause}"`);
55181
+ parts.push(`lattice_has_role('${arg}')`);
55182
+ } else if (kind === "subject") {
55183
+ if (!COL_RE.test(arg))
55184
+ throw new Error(`lattice: invalid subject column in audience "${clause}"`);
55185
+ parts.push(`lattice_is_subject("${arg}")`);
55186
+ } else if (kind === "source") {
55187
+ if (!COL_RE.test(arg))
55188
+ throw new Error(`lattice: invalid source column in audience "${clause}"`);
55189
+ parts.push(`lattice_source_visible("${arg}")`);
55190
+ } else {
55191
+ throw new Error(`lattice: unknown audience clause "${clause}"`);
55192
+ }
55193
+ }
55194
+ return parts.length > 0 ? parts.map((p3) => `(${p3})`).join(" OR ") : "true";
55195
+ }
55196
+ function tableNeedsAudienceView(columnAudience) {
55197
+ return Object.values(columnAudience).some((a6) => !isRowAudience(a6));
55198
+ }
55199
+ function quoteIdent(s2) {
55200
+ return `"${s2.replace(/"/g, '""')}"`;
55201
+ }
55202
+ function audienceViewSql(table, columns, pkCols, columnAudience) {
55203
+ const view = quoteIdent(`${table}_v`);
55204
+ const base = quoteIdent(table);
55205
+ const lit = `'${table.replace(/'/g, "''")}'`;
55206
+ const pkExpr = pkSqlExpr(pkCols, "");
55207
+ const selectCols = columns.map((col) => {
55208
+ const aud = columnAudience[col] ?? "";
55209
+ if (isRowAudience(aud)) return quoteIdent(col);
55210
+ const pred = audiencePredicate(aud, { tableLit: lit, pkExpr });
55211
+ if (pred === "true") return quoteIdent(col);
55212
+ const colLit = `'${col.replace(/'/g, "''")}'`;
55213
+ const full = `(${pred}) OR lattice_cell_visible(${lit}, ${pkExpr}, ${colLit})`;
55214
+ return `CASE WHEN ${full} THEN ${quoteIdent(col)} END AS ${quoteIdent(col)}`;
55215
+ });
55216
+ return [
55217
+ `CREATE OR REPLACE VIEW ${view} AS SELECT ${selectCols.join(", ")} FROM ${base} WHERE lattice_row_visible(${lit}, ${pkSqlExpr(pkCols, "")});`,
55218
+ `GRANT SELECT ON ${view} TO ${MEMBER_GROUP};`,
55219
+ `REVOKE SELECT ON ${base} FROM ${MEMBER_GROUP};`
55220
+ ].join("\n");
55221
+ }
55222
+ async function loadColumnPolicy(db, table) {
55223
+ if (db.getDialect() !== "postgres") return {};
55224
+ const rows = await allAsyncOrSync(
55225
+ db.adapter,
55226
+ `SELECT "column_name", "audience" FROM "__lattice_column_policy" WHERE "table_name" = ?`,
55227
+ [table]
55228
+ );
55229
+ const out = {};
55230
+ for (const r6 of rows) out[r6.column_name] = r6.audience;
55231
+ return out;
55232
+ }
55233
+ async function seedColumnPolicyFromYaml(db, table, yamlAudience) {
55234
+ if (db.getDialect() !== "postgres") return;
55235
+ const marker = `internal:cloud-column-seed:${table}:v1`;
55236
+ const already = await getAsyncOrSync(
55237
+ db.adapter,
55238
+ `SELECT 1 AS one FROM "__lattice_migrations" WHERE "version" = ?`,
55239
+ [marker]
55240
+ );
55241
+ if (already) return;
55242
+ for (const [col, aud] of Object.entries(yamlAudience)) {
55243
+ if (isRowAudience(aud)) continue;
55244
+ await runAsyncOrSync(
55245
+ db.adapter,
55246
+ `INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience")
55247
+ VALUES (?, ?, ?) ON CONFLICT ("table_name","column_name") DO NOTHING`,
55248
+ [table, col, aud]
55249
+ );
55250
+ }
55251
+ await runAsyncOrSync(
55252
+ db.adapter,
55253
+ `INSERT INTO "__lattice_migrations" ("version","applied_at") VALUES (?, ?)
55254
+ ON CONFLICT ("version") DO NOTHING`,
55255
+ [marker, (/* @__PURE__ */ new Date()).toISOString()]
55256
+ );
55257
+ }
55258
+ async function regenerateAudienceViewFromDb(db, table, columns, pkCols) {
55259
+ if (db.getDialect() !== "postgres") return;
55260
+ if (pkCols.length === 0) return;
55261
+ const spec = await loadColumnPolicy(db, table);
55262
+ const view = quoteIdent(`${table}_v`);
55263
+ const base = quoteIdent(table);
55264
+ if (!tableNeedsAudienceView(spec)) {
55265
+ await runAsyncOrSync(
55266
+ db.adapter,
55267
+ `DROP VIEW IF EXISTS ${view};
55268
+ GRANT SELECT ON ${base} TO ${MEMBER_GROUP};`
55269
+ );
55270
+ return;
55271
+ }
55272
+ await runAsyncOrSync(db.adapter, audienceViewSql(table, columns, pkCols, spec));
55273
+ }
55274
+ async function setColumnAudience(db, table, column, audience, columns, pkCols) {
55275
+ if (db.getDialect() !== "postgres") return;
55276
+ await runAsyncOrSync(db.adapter, `SELECT lattice_set_column_audience(?, ?, ?)`, [
55277
+ table,
55278
+ column,
55279
+ audience
55280
+ ]);
55281
+ await regenerateAudienceViewFromDb(db, table, columns, pkCols);
55282
+ }
55283
+
53900
55284
  // src/gui/mutations.ts
53901
55285
  function rowLabel2(row) {
53902
55286
  if (!row || typeof row !== "object") return null;
@@ -54010,8 +55394,44 @@ async function recordSchemaAudit(db, feed, table, operation2, before, after, sum
54010
55394
  });
54011
55395
  feed.publish({ table, op: "schema", rowId: null, source, summary });
54012
55396
  }
54013
- async function createRow(ctx, table, values) {
54014
- const id = await ctx.db.insert(table, values);
55397
+ function inferColumnType(v2) {
55398
+ if (typeof v2 === "number") return Number.isInteger(v2) ? "INTEGER" : "REAL";
55399
+ if (typeof v2 === "boolean") return "INTEGER";
55400
+ return "TEXT";
55401
+ }
55402
+ async function ensureColumns(db, table, values) {
55403
+ if (table.startsWith("_lattice_") || table.startsWith("__lattice_")) return [];
55404
+ const existing = db.getRegisteredColumns(table);
55405
+ if (!existing) return [];
55406
+ const added = Object.keys(values).filter((k6) => !(k6 in existing));
55407
+ if (added.length === 0) return [];
55408
+ for (const col of added) await db.addColumn(table, col, inferColumnType(values[col]));
55409
+ if (db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
55410
+ const cols = db.getRegisteredColumns(table);
55411
+ const pk = db.getPrimaryKey(table);
55412
+ if (cols && pk.length > 0) await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
55413
+ }
55414
+ return added;
55415
+ }
55416
+ async function announceAddedColumns(ctx, table, added) {
55417
+ if (added.length === 0) return;
55418
+ const summary = `Added column${added.length > 1 ? "s" : ""} ${added.join(", ")} to ${table}`;
55419
+ await recordSchemaAudit(
55420
+ ctx.db,
55421
+ ctx.feed,
55422
+ table,
55423
+ "schema.add_column",
55424
+ null,
55425
+ { columns: added },
55426
+ summary,
55427
+ ctx.source,
55428
+ ctx.sessionId
55429
+ );
55430
+ }
55431
+ async function createRow(ctx, table, values, forceVisibility) {
55432
+ const addedCols = await ensureColumns(ctx.db, table, values);
55433
+ await announceAddedColumns(ctx, table, addedCols);
55434
+ const id = forceVisibility !== void 0 ? await ctx.db.insertForcingVisibility(table, values, forceVisibility) : await ctx.db.insert(table, values);
54015
55435
  const row = await ctx.db.get(table, id);
54016
55436
  await appendAudit(ctx.db, ctx.feed, table, id, "insert", null, row, ctx.source, ctx.sessionId);
54017
55437
  return { id, row };
@@ -54035,6 +55455,8 @@ async function updateRow(ctx, table, id, values) {
54035
55455
  if (before === null) {
54036
55456
  throw new Error(`Cannot update "${table}": no row with id "${id}"`);
54037
55457
  }
55458
+ const addedCols = await ensureColumns(ctx.db, table, values);
55459
+ await announceAddedColumns(ctx, table, addedCols);
54038
55460
  await ctx.db.update(table, id, values);
54039
55461
  const after = await ctx.db.get(table, id);
54040
55462
  if (after != null) {
@@ -54746,376 +56168,6 @@ function resolveActiveS3Config(configPath) {
54746
56168
  return fromEnv();
54747
56169
  }
54748
56170
 
54749
- // src/cloud/rls.ts
54750
- function isPg(db) {
54751
- return db.getDialect() === "postgres";
54752
- }
54753
- function pkSqlExpr(pkCols, prefix) {
54754
- if (pkCols.length === 0) {
54755
- throw new Error("cloud RLS: cannot key a table with no primary key column");
54756
- }
54757
- return pkCols.map((c6) => `CAST(${prefix}"${c6}" AS TEXT)`).join(` || chr(9) || `);
54758
- }
54759
- var MEMBER_GROUP = "lattice_members";
54760
- var CLOUD_RLS_BOOTSTRAP_SQL = `
54761
- -- Member group (NOLOGIN). Members inherit schema/connect/table privileges from it;
54762
- -- RLS filters per the individual member's login role, so the group never widens
54763
- -- what a member can see. Idempotent.
54764
- DO $LATTICE$ BEGIN
54765
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${MEMBER_GROUP}') THEN
54766
- CREATE ROLE ${MEMBER_GROUP} NOLOGIN;
54767
- END IF;
54768
- EXECUTE format('GRANT USAGE ON SCHEMA %I TO ${MEMBER_GROUP}', current_schema());
54769
- EXECUTE format('GRANT CONNECT ON DATABASE %I TO ${MEMBER_GROUP}', current_database());
54770
- END $LATTICE$;
54771
-
54772
- CREATE TABLE IF NOT EXISTS "__lattice_owners" (
54773
- "table_name" text NOT NULL,
54774
- "pk" text NOT NULL,
54775
- "owner_role" text NOT NULL,
54776
- "visibility" text NOT NULL DEFAULT 'private' CHECK ("visibility" IN ('private','everyone','custom')),
54777
- "created_at" timestamptz NOT NULL DEFAULT now(),
54778
- "updated_at" timestamptz NOT NULL DEFAULT now(),
54779
- PRIMARY KEY ("table_name", "pk")
54780
- );
54781
-
54782
- CREATE TABLE IF NOT EXISTS "__lattice_row_grants" (
54783
- "table_name" text NOT NULL,
54784
- "pk" text NOT NULL,
54785
- "grantee_role" text NOT NULL,
54786
- "granted_by" text NOT NULL,
54787
- "granted_at" timestamptz NOT NULL DEFAULT now(),
54788
- PRIMARY KEY ("table_name", "pk", "grantee_role")
54789
- );
54790
-
54791
- CREATE INDEX IF NOT EXISTS "idx_lattice_row_grants_grantee"
54792
- ON "__lattice_row_grants" ("grantee_role", "table_name", "pk");
54793
-
54794
- -- App-role assignments for the audience layer: maps a member's login role to the
54795
- -- named app roles (e.g. 'hr') a fixed-policy column may require. Owner-managed;
54796
- -- members cannot read or write it (no grant), so a member can't self-promote.
54797
- CREATE TABLE IF NOT EXISTS "__lattice_member_roles" (
54798
- "member_role" text NOT NULL,
54799
- "app_role" text NOT NULL,
54800
- "granted_by" text NOT NULL DEFAULT session_user,
54801
- "granted_at" timestamptz NOT NULL DEFAULT now(),
54802
- PRIMARY KEY ("member_role", "app_role")
54803
- );
54804
-
54805
- -- Per-card audience overrides: a row owner can grant a SPECIFIC member access to
54806
- -- a SPECIFIC masked cell (table + pk + column), without changing the column's
54807
- -- schema-level audience. The generated mask view ORs this in. Owner-managed;
54808
- -- members can't read or write it.
54809
- CREATE TABLE IF NOT EXISTS "__lattice_cell_grants" (
54810
- "table_name" text NOT NULL,
54811
- "pk" text NOT NULL,
54812
- "column_name" text NOT NULL,
54813
- "grantee_role" text NOT NULL,
54814
- "granted_by" text NOT NULL DEFAULT session_user,
54815
- "granted_at" timestamptz NOT NULL DEFAULT now(),
54816
- PRIMARY KEY ("table_name", "pk", "column_name", "grantee_role")
54817
- );
54818
-
54819
- -- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
54820
- -- keyed on session_user (the member's login role). A row with no ownership record
54821
- -- is visible to nobody.
54822
- CREATE OR REPLACE FUNCTION lattice_row_visible(p_table text, p_pk text)
54823
- RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54824
- SELECT EXISTS (
54825
- SELECT 1 FROM "__lattice_owners" o
54826
- WHERE o."table_name" = p_table AND o."pk" = p_pk
54827
- AND ( o."owner_role" = session_user
54828
- OR o."visibility" = 'everyone'
54829
- OR ( o."visibility" = 'custom' AND EXISTS (
54830
- SELECT 1 FROM "__lattice_row_grants" g
54831
- WHERE g."table_name" = o."table_name" AND g."pk" = o."pk"
54832
- AND g."grantee_role" = session_user)))
54833
- );
54834
- $fn$;
54835
-
54836
- -- Owner-only: change a row's visibility. Raises if the caller is not the owner.
54837
- CREATE OR REPLACE FUNCTION lattice_set_row_visibility(p_table text, p_pk text, p_visibility text)
54838
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54839
- DECLARE v_owner text;
54840
- BEGIN
54841
- IF p_visibility NOT IN ('private','everyone','custom') THEN
54842
- RAISE EXCEPTION 'lattice: invalid visibility %', p_visibility;
54843
- END IF;
54844
- SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54845
- WHERE o."table_name" = p_table AND o."pk" = p_pk;
54846
- IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
54847
- IF v_owner <> session_user THEN RAISE EXCEPTION 'lattice: only the row owner may change its sharing'; END IF;
54848
- UPDATE "__lattice_owners" SET "visibility" = p_visibility, "updated_at" = now()
54849
- WHERE "table_name" = p_table AND "pk" = p_pk;
54850
- END $fn$;
54851
-
54852
- -- Owner-only: grant a specific member access to a row (sets visibility = 'custom').
54853
- CREATE OR REPLACE FUNCTION lattice_grant_row(p_table text, p_pk text, p_grantee text)
54854
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54855
- DECLARE v_owner text;
54856
- BEGIN
54857
- SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54858
- WHERE o."table_name" = p_table AND o."pk" = p_pk;
54859
- IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
54860
- IF v_owner <> session_user THEN RAISE EXCEPTION 'lattice: only the row owner may grant access'; END IF;
54861
- UPDATE "__lattice_owners" SET "visibility" = 'custom', "updated_at" = now()
54862
- WHERE "table_name" = p_table AND "pk" = p_pk;
54863
- INSERT INTO "__lattice_row_grants" ("table_name","pk","grantee_role","granted_by")
54864
- VALUES (p_table, p_pk, p_grantee, session_user)
54865
- ON CONFLICT ("table_name","pk","grantee_role") DO NOTHING;
54866
- END $fn$;
54867
-
54868
- -- Owner-only: revoke a member's access to a row.
54869
- CREATE OR REPLACE FUNCTION lattice_revoke_row(p_table text, p_pk text, p_grantee text)
54870
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54871
- DECLARE v_owner text;
54872
- BEGIN
54873
- SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54874
- WHERE o."table_name" = p_table AND o."pk" = p_pk;
54875
- IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
54876
- IF v_owner <> session_user THEN RAISE EXCEPTION 'lattice: only the row owner may revoke access'; END IF;
54877
- DELETE FROM "__lattice_row_grants"
54878
- WHERE "table_name" = p_table AND "pk" = p_pk AND "grantee_role" = p_grantee;
54879
- END $fn$;
54880
-
54881
- -- \u2500\u2500 Per-viewer audience helpers (Stage-0 scaffolding) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54882
- -- The predicates a generated per-column cell-masking view will call. ALL are
54883
- -- SECURITY DEFINER and keyed on session_user (NEVER current_user / SECURITY
54884
- -- INVOKER) so they bind to the real member even when an owner-rights view
54885
- -- executes them \u2014 the identity invariant the whole cloud model depends on. They
54886
- -- are not referenced by any policy or view yet, so they change NO behavior in
54887
- -- Stage-0; a later stage wires them into generated views.
54888
-
54889
- -- Is the connected member the subject of this row (e.g. their own person row)?
54890
- CREATE OR REPLACE FUNCTION lattice_is_subject(p_subject text)
54891
- RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54892
- SELECT p_subject = session_user
54893
- $fn$;
54894
-
54895
- -- Does the connected member hold a named app role? Reads the owner-managed
54896
- -- member-roles table (which members can't see) keyed on session_user, so a
54897
- -- member cannot grant themselves a role to unmask a column.
54898
- CREATE OR REPLACE FUNCTION lattice_has_role(p_role text)
54899
- RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54900
- SELECT EXISTS (
54901
- SELECT 1 FROM "__lattice_member_roles"
54902
- WHERE "member_role" = session_user AND "app_role" = p_role
54903
- )
54904
- $fn$;
54905
-
54906
- -- Owner-only: assign an app role to a member (so a fixed-policy masked column
54907
- -- becomes visible to them). Raises unless the caller can create roles (a cloud
54908
- -- owner / DBA).
54909
- CREATE OR REPLACE FUNCTION lattice_assign_role(p_member text, p_role text)
54910
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54911
- BEGIN
54912
- IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
54913
- RAISE EXCEPTION 'lattice: only a cloud owner may assign app roles';
54914
- END IF;
54915
- INSERT INTO "__lattice_member_roles" ("member_role", "app_role")
54916
- VALUES (p_member, p_role) ON CONFLICT DO NOTHING;
54917
- END $fn$;
54918
-
54919
- -- Owner-only: revoke an app role from a member.
54920
- CREATE OR REPLACE FUNCTION lattice_revoke_role(p_member text, p_role text)
54921
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54922
- BEGIN
54923
- IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
54924
- RAISE EXCEPTION 'lattice: only a cloud owner may revoke app roles';
54925
- END IF;
54926
- DELETE FROM "__lattice_member_roles"
54927
- WHERE "member_role" = p_member AND "app_role" = p_role;
54928
- END $fn$;
54929
-
54930
- -- Per-card override check: does the connected member hold a specific-cell grant?
54931
- -- The generated mask view ORs this into a masked column's predicate.
54932
- CREATE OR REPLACE FUNCTION lattice_cell_visible(p_table text, p_pk text, p_column text)
54933
- RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54934
- SELECT EXISTS (
54935
- SELECT 1 FROM "__lattice_cell_grants"
54936
- WHERE "table_name" = p_table AND "pk" = p_pk
54937
- AND "column_name" = p_column AND "grantee_role" = session_user
54938
- )
54939
- $fn$;
54940
-
54941
- -- Owner-only: grant a member access to one masked cell (a per-card override).
54942
- CREATE OR REPLACE FUNCTION lattice_grant_cell(p_table text, p_pk text, p_column text, p_grantee text)
54943
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54944
- DECLARE v_owner text;
54945
- BEGIN
54946
- SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54947
- WHERE o."table_name" = p_table AND o."pk" = p_pk;
54948
- IF v_owner IS NULL OR v_owner <> session_user THEN
54949
- RAISE EXCEPTION 'lattice: only the row owner may set a per-cell audience';
54950
- END IF;
54951
- INSERT INTO "__lattice_cell_grants" ("table_name","pk","column_name","grantee_role")
54952
- VALUES (p_table, p_pk, p_column, p_grantee) ON CONFLICT DO NOTHING;
54953
- END $fn$;
54954
-
54955
- -- Owner-only: revoke a per-cell override.
54956
- CREATE OR REPLACE FUNCTION lattice_revoke_cell(p_table text, p_pk text, p_column text, p_grantee text)
54957
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
54958
- DECLARE v_owner text;
54959
- BEGIN
54960
- SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
54961
- WHERE o."table_name" = p_table AND o."pk" = p_pk;
54962
- IF v_owner IS NULL OR v_owner <> session_user THEN
54963
- RAISE EXCEPTION 'lattice: only the row owner may change a per-cell audience';
54964
- END IF;
54965
- DELETE FROM "__lattice_cell_grants"
54966
- WHERE "table_name" = p_table AND "pk" = p_pk
54967
- AND "column_name" = p_column AND "grantee_role" = p_grantee;
54968
- END $fn$;
54969
-
54970
- -- Can the connected member see a source? Reduces to the source row's own RLS, so
54971
- -- file-sharing drives enrichment visibility for free. p_source_ref is the
54972
- -- source's primary key in the files table.
54973
- CREATE OR REPLACE FUNCTION lattice_source_visible(p_source_ref text)
54974
- RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
54975
- SELECT lattice_row_visible('files', p_source_ref)
54976
- $fn$;
54977
-
54978
- -- Append-only change feed. The per-table ownership trigger records one row per
54979
- -- INSERT/UPDATE/DELETE; the AFTER INSERT trigger here fires pg_notify so a
54980
- -- connected member's realtime broker refreshes. Members get no direct access \u2014
54981
- -- the NOTIFY carries only (table, pk, op) metadata, and the SPA refetches the row
54982
- -- itself through RLS, so another member's content is never broadcast.
54983
- CREATE TABLE IF NOT EXISTS "__lattice_changes" (
54984
- "seq" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
54985
- "table_name" text NOT NULL,
54986
- "pk" text NOT NULL,
54987
- "op" text NOT NULL CHECK ("op" IN ('upsert','delete')),
54988
- "owner_role" text NOT NULL,
54989
- "created_at" timestamptz NOT NULL DEFAULT now()
54990
- );
54991
-
54992
- CREATE OR REPLACE FUNCTION lattice_notify_change() RETURNS trigger
54993
- LANGUAGE plpgsql AS $fn$
54994
- BEGIN
54995
- PERFORM pg_notify('lattice_changes', json_build_object(
54996
- 'seq', NEW."seq",
54997
- 'table_name', NEW."table_name",
54998
- 'pk', NEW."pk",
54999
- 'op', NEW."op",
55000
- 'owner_role', NEW."owner_role",
55001
- 'created_at', NEW."created_at"
55002
- )::text);
55003
- RETURN NEW;
55004
- END $fn$;
55005
-
55006
- DROP TRIGGER IF EXISTS "lattice_notify_change_trg" ON "__lattice_changes";
55007
- CREATE TRIGGER "lattice_notify_change_trg" AFTER INSERT ON "__lattice_changes"
55008
- FOR EACH ROW EXECUTE FUNCTION lattice_notify_change();
55009
- `;
55010
- function tableRlsSql(table, pkCols) {
55011
- const q3 = `"${table.replace(/"/g, '""')}"`;
55012
- const lit = `'${table.replace(/'/g, "''")}'`;
55013
- const pkNew = pkSqlExpr(pkCols, "NEW.");
55014
- const pkOld = pkSqlExpr(pkCols, "OLD.");
55015
- const pkRow = pkSqlExpr(pkCols, "");
55016
- const trg = `lattice_track_${table.replace(/[^A-Za-z0-9_]/g, "_")}`;
55017
- return `
55018
- CREATE OR REPLACE FUNCTION "${trg}"() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $fn$
55019
- BEGIN
55020
- IF TG_OP = 'INSERT' THEN
55021
- INSERT INTO "__lattice_owners" ("table_name","pk","owner_role","visibility")
55022
- VALUES (${lit}, ${pkNew}, session_user, 'private')
55023
- ON CONFLICT ("table_name","pk") DO NOTHING;
55024
- INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
55025
- VALUES (${lit}, ${pkNew}, 'upsert', session_user);
55026
- RETURN NEW;
55027
- ELSIF TG_OP = 'UPDATE' THEN
55028
- INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
55029
- VALUES (${lit}, ${pkNew}, 'upsert', session_user);
55030
- RETURN NEW;
55031
- ELSIF TG_OP = 'DELETE' THEN
55032
- DELETE FROM "__lattice_owners" WHERE "table_name" = ${lit} AND "pk" = ${pkOld};
55033
- DELETE FROM "__lattice_row_grants" WHERE "table_name" = ${lit} AND "pk" = ${pkOld};
55034
- INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
55035
- VALUES (${lit}, ${pkOld}, 'delete', session_user);
55036
- RETURN OLD;
55037
- END IF;
55038
- RETURN NEW;
55039
- END $fn$;
55040
-
55041
- ALTER TABLE ${q3} ENABLE ROW LEVEL SECURITY;
55042
- ALTER TABLE ${q3} FORCE ROW LEVEL SECURITY;
55043
- GRANT SELECT, INSERT, UPDATE, DELETE ON ${q3} TO ${MEMBER_GROUP};
55044
-
55045
- DROP POLICY IF EXISTS "lattice_sel" ON ${q3};
55046
- CREATE POLICY "lattice_sel" ON ${q3} FOR SELECT USING (lattice_row_visible(${lit}, ${pkRow}));
55047
- DROP POLICY IF EXISTS "lattice_upd" ON ${q3};
55048
- CREATE POLICY "lattice_upd" ON ${q3} FOR UPDATE USING (lattice_row_visible(${lit}, ${pkRow}))
55049
- WITH CHECK (lattice_row_visible(${lit}, ${pkRow}));
55050
- DROP POLICY IF EXISTS "lattice_del" ON ${q3};
55051
- CREATE POLICY "lattice_del" ON ${q3} FOR DELETE USING (lattice_row_visible(${lit}, ${pkRow}));
55052
- DROP POLICY IF EXISTS "lattice_ins" ON ${q3};
55053
- CREATE POLICY "lattice_ins" ON ${q3} FOR INSERT WITH CHECK (true);
55054
-
55055
- DROP TRIGGER IF EXISTS "${trg}" ON ${q3};
55056
- CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
55057
- FOR EACH ROW EXECUTE FUNCTION "${trg}"();
55058
- `;
55059
- }
55060
- async function installCloudRls(db) {
55061
- if (!isPg(db)) return;
55062
- const migration = {
55063
- // v3 added the audience helpers; v4 the role model; v5 the per-card override
55064
- // model (__lattice_cell_grants + lattice_cell_visible / lattice_grant_cell).
55065
- // The bootstrap is fully idempotent (CREATE OR REPLACE / IF NOT EXISTS).
55066
- version: "internal:cloud-rls:bootstrap:v5",
55067
- sql: CLOUD_RLS_BOOTSTRAP_SQL
55068
- };
55069
- await db.migrate([migration]);
55070
- }
55071
- async function enableChangelogRls(db) {
55072
- if (!isPg(db)) return;
55073
- const migration = {
55074
- version: "internal:cloud-rls:changelog:v1",
55075
- sql: `
55076
- ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
55077
- ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
55078
- GRANT SELECT, INSERT ON "__lattice_changelog" TO ${MEMBER_GROUP};
55079
-
55080
- DROP POLICY IF EXISTS "lattice_changelog_sel" ON "__lattice_changelog";
55081
- CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING (
55082
- CASE
55083
- WHEN "change_kind" = 'derived' THEN
55084
- "source_ref" IS NOT NULL
55085
- AND NOT EXISTS (
55086
- SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
55087
- WHERE NOT lattice_source_visible(src.sid)
55088
- )
55089
- ELSE lattice_row_visible("table_name", "row_id")
55090
- END
55091
- );
55092
- DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
55093
- CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH CHECK (true);
55094
- `
55095
- };
55096
- await db.migrate([migration]);
55097
- }
55098
- async function enableRlsForTable(db, table, pkCols) {
55099
- if (!isPg(db)) return;
55100
- const migration = {
55101
- version: `internal:cloud-rls:table:${table}:v2`,
55102
- sql: tableRlsSql(table, pkCols)
55103
- };
55104
- await db.migrate([migration]);
55105
- }
55106
- async function backfillOwnership(db, table, pkCols) {
55107
- if (!isPg(db) || pkCols.length === 0) return;
55108
- const q3 = `"${table.replace(/"/g, '""')}"`;
55109
- const lit = `'${table.replace(/'/g, "''")}'`;
55110
- const pkRow = pkSqlExpr(pkCols, "");
55111
- await runAsyncOrSync(
55112
- db.adapter,
55113
- `INSERT INTO "__lattice_owners" ("table_name","pk","owner_role","visibility")
55114
- SELECT ${lit}, ${pkRow}, current_user, 'private' FROM ${q3}
55115
- ON CONFLICT ("table_name","pk") DO NOTHING`
55116
- );
55117
- }
55118
-
55119
56171
  // src/cloud/settings.ts
55120
56172
  var CLOUD_SETTING_SYSTEM_PROMPT = "chat_system_prompt";
55121
56173
  var CLOUD_SETTINGS_BOOTSTRAP_SQL = `
@@ -55155,9 +56207,12 @@ END $fn$;
55155
56207
  `;
55156
56208
  async function installCloudSettings(db) {
55157
56209
  if (db.getDialect() !== "postgres") return;
56210
+ const schema = await cloudSchema(db);
55158
56211
  const migration = {
55159
- version: "internal:cloud-settings:v1",
55160
- sql: CLOUD_SETTINGS_BOOTSTRAP_SQL
56212
+ // v2 pins search_path on the two SECURITY DEFINER helpers (closes the
56213
+ // pg_temp-shadow class of bypass on the settings getter/setter).
56214
+ version: "internal:cloud-settings:v2",
56215
+ sql: pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema)
55161
56216
  };
55162
56217
  await db.migrate([migration]);
55163
56218
  }
@@ -55185,89 +56240,6 @@ async function setCloudSetting(db, key, value) {
55185
56240
  await runAsyncOrSync(db.adapter, `SELECT lattice_set_cloud_setting(?, ?)`, [key, value]);
55186
56241
  }
55187
56242
 
55188
- // src/cloud/audience.ts
55189
- var ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
55190
- var COL_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
55191
- function isRowAudience(audience) {
55192
- const a6 = (audience ?? "").trim();
55193
- return a6 === "" || a6 === "everyone" || a6 === "row-audience";
55194
- }
55195
- function audiencePredicate(audience) {
55196
- if (isRowAudience(audience)) return "true";
55197
- const clauses = audience.split("+").map((c6) => c6.trim()).filter(Boolean);
55198
- const parts = [];
55199
- for (const clause of clauses) {
55200
- if (clause === "everyone" || clause === "row-audience") return "true";
55201
- const idx = clause.indexOf(":");
55202
- const kind = idx === -1 ? clause : clause.slice(0, idx);
55203
- const arg = idx === -1 ? "" : clause.slice(idx + 1).trim();
55204
- if (kind === "role") {
55205
- if (!ROLE_NAME_RE.test(arg)) throw new Error(`lattice: invalid role in audience "${clause}"`);
55206
- parts.push(`lattice_has_role('${arg}')`);
55207
- } else if (kind === "subject") {
55208
- if (!COL_RE.test(arg))
55209
- throw new Error(`lattice: invalid subject column in audience "${clause}"`);
55210
- parts.push(`lattice_is_subject("${arg}")`);
55211
- } else if (kind === "source") {
55212
- if (!COL_RE.test(arg))
55213
- throw new Error(`lattice: invalid source column in audience "${clause}"`);
55214
- parts.push(`lattice_source_visible("${arg}")`);
55215
- } else {
55216
- throw new Error(`lattice: unknown audience clause "${clause}"`);
55217
- }
55218
- }
55219
- return parts.length > 0 ? parts.map((p3) => `(${p3})`).join(" OR ") : "true";
55220
- }
55221
- function tableNeedsAudienceView(columnAudience) {
55222
- return Object.values(columnAudience).some((a6) => !isRowAudience(a6));
55223
- }
55224
- function quoteIdent(s2) {
55225
- return `"${s2.replace(/"/g, '""')}"`;
55226
- }
55227
- function audienceViewSql(table, columns, pkCols, columnAudience) {
55228
- const view = quoteIdent(`${table}_v`);
55229
- const base = quoteIdent(table);
55230
- const lit = `'${table.replace(/'/g, "''")}'`;
55231
- const pkExpr = pkSqlExpr(pkCols, "");
55232
- const selectCols = columns.map((col) => {
55233
- const aud = columnAudience[col] ?? "";
55234
- if (isRowAudience(aud)) return quoteIdent(col);
55235
- const pred = audiencePredicate(aud);
55236
- if (pred === "true") return quoteIdent(col);
55237
- const colLit = `'${col.replace(/'/g, "''")}'`;
55238
- const full = `(${pred}) OR lattice_cell_visible(${lit}, ${pkExpr}, ${colLit})`;
55239
- return `CASE WHEN ${full} THEN ${quoteIdent(col)} END AS ${quoteIdent(col)}`;
55240
- });
55241
- return [
55242
- `CREATE OR REPLACE VIEW ${view} AS SELECT ${selectCols.join(", ")} FROM ${base} WHERE lattice_row_visible(${lit}, ${pkSqlExpr(pkCols, "")});`,
55243
- `GRANT SELECT ON ${view} TO ${MEMBER_GROUP};`,
55244
- `REVOKE SELECT ON ${base} FROM ${MEMBER_GROUP};`
55245
- ].join("\n");
55246
- }
55247
- function audienceVersionHash(columns, pkCols, columnAudience) {
55248
- const spec = JSON.stringify([
55249
- [...columns],
55250
- [...pkCols],
55251
- Object.keys(columnAudience).sort().map((k6) => [k6, columnAudience[k6]])
55252
- ]);
55253
- let h6 = 2166136261;
55254
- for (let i6 = 0; i6 < spec.length; i6++) {
55255
- h6 ^= spec.charCodeAt(i6);
55256
- h6 = Math.imul(h6, 16777619) >>> 0;
55257
- }
55258
- return h6.toString(16).padStart(8, "0");
55259
- }
55260
- async function enableAudienceView(db, table, columns, pkCols, columnAudience) {
55261
- if (db.getDialect() !== "postgres") return;
55262
- if (!tableNeedsAudienceView(columnAudience)) return;
55263
- if (pkCols.length === 0) return;
55264
- const migration = {
55265
- version: `internal:audience:table:${table}:v1:${audienceVersionHash(columns, pkCols, columnAudience)}`,
55266
- sql: audienceViewSql(table, columns, pkCols, columnAudience)
55267
- };
55268
- await db.migrate([migration]);
55269
- }
55270
-
55271
56243
  // src/cloud/setup.ts
55272
56244
  async function secureCloud(db) {
55273
56245
  if (db.getDialect() !== "postgres") return;
@@ -55275,7 +56247,8 @@ async function secureCloud(db) {
55275
56247
  await installCloudSettings(db);
55276
56248
  await db.ensureObservationSubstrate();
55277
56249
  await enableChangelogRls(db);
55278
- for (const table of db.getRegisteredTableNames()) {
56250
+ const registered = db.getRegisteredTableNames();
56251
+ for (const table of registered) {
55279
56252
  if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) continue;
55280
56253
  const pk = db.getPrimaryKey(table);
55281
56254
  if (pk.length === 0) continue;
@@ -55283,78 +56256,113 @@ async function secureCloud(db) {
55283
56256
  await enableRlsForTable(db, table, pk);
55284
56257
  const cols = db.getRegisteredColumns(table);
55285
56258
  if (cols) {
55286
- await enableAudienceView(db, table, Object.keys(cols), pk, db.getColumnAudience(table));
56259
+ await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
56260
+ await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
55287
56261
  }
55288
56262
  }
55289
- }
55290
-
55291
- // src/cloud/members.ts
55292
- import { randomBytes as randomBytes5 } from "crypto";
55293
- var ROLE_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
55294
- var HEX_PW_RE = /^[0-9a-f]{16,}$/;
55295
- function assertPg(db) {
55296
- if (db.getDialect() !== "postgres") {
55297
- throw new Error(
55298
- "lattice: cloud members require a Postgres cloud (SQLite is single-user/local)"
55299
- );
55300
- }
55301
- }
55302
- function generateMemberPassword() {
55303
- return randomBytes5(24).toString("hex");
55304
- }
55305
- function memberRoleName(label) {
55306
- const base = label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "member";
55307
- return `lm_${base}_${randomBytes5(3).toString("hex")}`.slice(0, 63);
55308
- }
55309
- async function provisionMemberRole(db, role, password) {
55310
- assertPg(db);
55311
- if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
55312
- if (!HEX_PW_RE.test(password)) {
55313
- throw new Error("lattice: member password must be hex \u2014 use generateMemberPassword()");
56263
+ if (registered.includes("secrets")) {
56264
+ await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);
55314
56265
  }
55315
56266
  await runAsyncOrSync(
55316
56267
  db.adapter,
55317
56268
  `DO $LATTICE$ BEGIN
55318
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
55319
- CREATE ROLE "${role}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
55320
- ELSE
55321
- ALTER ROLE "${role}" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
56269
+ IF to_regclass('__lattice_user_identity') IS NOT NULL THEN
56270
+ EXECUTE 'GRANT SELECT, INSERT, UPDATE ON "__lattice_user_identity" TO ${MEMBER_GROUP}';
55322
56271
  END IF;
55323
56272
  END $LATTICE$`
55324
56273
  );
55325
- await runAsyncOrSync(db.adapter, `GRANT ${MEMBER_GROUP} TO "${role}"`);
55326
56274
  }
55327
- var VISIBILITY = /* @__PURE__ */ new Set(["private", "everyone"]);
55328
- async function setRowVisibility(db, table, pk, visibility) {
55329
- assertPg(db);
55330
- if (!VISIBILITY.has(visibility)) {
55331
- throw new Error(`lattice: invalid visibility "${visibility}" (expected private | everyone)`);
55332
- }
55333
- await runAsyncOrSync(db.adapter, `SELECT lattice_set_row_visibility(?, ?, ?)`, [
55334
- table,
55335
- pk,
55336
- visibility
55337
- ]);
55338
- }
55339
- async function grantCell(db, table, pk, column, grantee) {
55340
- assertPg(db);
55341
- await runAsyncOrSync(db.adapter, `SELECT lattice_grant_cell(?, ?, ?, ?)`, [
55342
- table,
55343
- pk,
55344
- column,
55345
- grantee
55346
- ]);
56275
+
56276
+ // src/cloud/invite.ts
56277
+ import { randomBytes as randomBytes6, scryptSync as scryptSync2, hkdfSync, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2 } from "crypto";
56278
+ var VERSION = 1;
56279
+ var SALT_LEN = 16;
56280
+ var SECRET_LEN = 32;
56281
+ var NONCE_LEN = 12;
56282
+ var TAG_LEN2 = 16;
56283
+ var KEY_LEN = 32;
56284
+ var HKDF_INFO = Buffer.from("lattice-invite-v1", "utf8");
56285
+ function normalizeEmail(email) {
56286
+ return email.trim().toLowerCase();
56287
+ }
56288
+ function poolerAwareUser(host, role, ownerUser) {
56289
+ if (!/\.pooler\.supabase\.com$/i.test(host)) return role;
56290
+ const dot = ownerUser.indexOf(".");
56291
+ const ref = dot >= 0 ? ownerUser.slice(dot + 1).trim() : "";
56292
+ return ref ? `${role}.${ref}` : role;
56293
+ }
56294
+ function deriveKey2(tokenSecret, email, salt) {
56295
+ const emailSalt = Buffer.from(scryptSync2(normalizeEmail(email), salt, KEY_LEN));
56296
+ return Buffer.from(hkdfSync("sha256", tokenSecret, emailSalt, HKDF_INFO, KEY_LEN));
56297
+ }
56298
+ function mintInviteToken(input) {
56299
+ const email = normalizeEmail(input.email);
56300
+ if (!email) throw new Error("lattice: an invite must be bound to an email");
56301
+ const salt = randomBytes6(SALT_LEN);
56302
+ const tokenSecret = randomBytes6(SECRET_LEN);
56303
+ const nonce = randomBytes6(NONCE_LEN);
56304
+ const key = deriveKey2(tokenSecret, email, salt);
56305
+ const payload = {
56306
+ v: 1,
56307
+ host: input.coords.host,
56308
+ port: input.coords.port,
56309
+ dbname: input.coords.dbname,
56310
+ user: input.user,
56311
+ password: input.password,
56312
+ role: input.role,
56313
+ email,
56314
+ expires_at: input.expiresAt.toISOString()
56315
+ };
56316
+ const cipher = createCipheriv2("aes-256-gcm", key, nonce);
56317
+ cipher.setAAD(Buffer.from(email, "utf8"));
56318
+ const ct = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
56319
+ const tag = cipher.getAuthTag();
56320
+ return Buffer.concat([Buffer.from([VERSION]), salt, tokenSecret, nonce, ct, tag]).toString(
56321
+ "base64url"
56322
+ );
55347
56323
  }
55348
- async function revokeCell(db, table, pk, column, grantee) {
55349
- assertPg(db);
55350
- await runAsyncOrSync(db.adapter, `SELECT lattice_revoke_cell(?, ?, ?, ?)`, [
55351
- table,
55352
- pk,
55353
- column,
55354
- grantee
55355
- ]);
56324
+ function redeemInviteToken(email, token) {
56325
+ const normEmail = normalizeEmail(email);
56326
+ if (!normEmail) throw new Error("lattice: enter the email this invite was sent to");
56327
+ const raw = Buffer.from(token.trim(), "base64url");
56328
+ const minLen = 1 + SALT_LEN + SECRET_LEN + NONCE_LEN + TAG_LEN2;
56329
+ if (raw.length < minLen || raw[0] !== VERSION) {
56330
+ throw new Error("lattice: invite token is malformed or from an unsupported version");
56331
+ }
56332
+ let off = 1;
56333
+ const salt = raw.subarray(off, off += SALT_LEN);
56334
+ const tokenSecret = raw.subarray(off, off += SECRET_LEN);
56335
+ const nonce = raw.subarray(off, off += NONCE_LEN);
56336
+ const tag = raw.subarray(raw.length - TAG_LEN2);
56337
+ const ct = raw.subarray(off, raw.length - TAG_LEN2);
56338
+ const key = deriveKey2(tokenSecret, normEmail, salt);
56339
+ const decipher = createDecipheriv2("aes-256-gcm", key, nonce);
56340
+ decipher.setAAD(Buffer.from(normEmail, "utf8"));
56341
+ decipher.setAuthTag(tag);
56342
+ let plaintext;
56343
+ try {
56344
+ plaintext = Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
56345
+ } catch {
56346
+ throw new Error("lattice: invite token does not match this email (or is corrupt)");
56347
+ }
56348
+ let payload;
56349
+ try {
56350
+ payload = JSON.parse(plaintext);
56351
+ } catch {
56352
+ throw new Error("lattice: invite token payload is unreadable");
56353
+ }
56354
+ if (normalizeEmail(payload.email) !== normEmail) {
56355
+ throw new Error("lattice: invite token does not match this email");
56356
+ }
56357
+ if (Number.isNaN(Date.parse(payload.expires_at)) || Date.parse(payload.expires_at) < Date.now()) {
56358
+ throw new Error("lattice: this invite has expired \u2014 ask the owner for a new one");
56359
+ }
56360
+ return payload;
55356
56361
  }
55357
56362
 
56363
+ // src/gui/dbconfig-routes.ts
56364
+ import { createHash as createHash2, randomUUID } from "crypto";
56365
+
55358
56366
  // src/framework/cloud-migration.ts
55359
56367
  import { existsSync as existsSync17, renameSync as renameSync3, unlinkSync as unlinkSync4 } from "fs";
55360
56368
  function isMigratable(tableName) {
@@ -55665,6 +56673,30 @@ function parseSaveBody(body) {
55665
56673
  function resolveRelativeToConfig(configPath, candidate) {
55666
56674
  return isAbsolute2(candidate) ? candidate : resolve7(configPath, "..", candidate);
55667
56675
  }
56676
+ async function joinCloudAsMember(ctx, res, fields, label) {
56677
+ const url = buildPostgresUrl(fields);
56678
+ const probe = await probeCloud(url);
56679
+ if (!probe.reachable) {
56680
+ sendJson(res, { ok: false, error: probe.error ?? "Cloud DB unreachable" }, 502);
56681
+ return;
56682
+ }
56683
+ if (!probe.isCloud) {
56684
+ sendJson(
56685
+ res,
56686
+ {
56687
+ ok: false,
56688
+ error: "That Postgres database is not a Lattice cloud yet. The owner must migrate a local Lattice into it first."
56689
+ },
56690
+ 409
56691
+ );
56692
+ return;
56693
+ }
56694
+ saveDbCredential(label, url);
56695
+ rewriteDbLine(ctx.configPath, "${LATTICE_DB:" + label + "}");
56696
+ updateActiveWorkspaceToCloud(ctx.configPath, label);
56697
+ await ctx.swap();
56698
+ sendJson(res, { ok: true, label, isCloud: true });
56699
+ }
55668
56700
  async function dispatchDbConfigRoute(req, res, ctx) {
55669
56701
  const { pathname, method } = ctx;
55670
56702
  if (pathname === "/api/dbconfig" && method === "GET") {
@@ -55836,35 +56868,19 @@ async function dispatchDbConfigRoute(req, res, ctx) {
55836
56868
  sendJson(res, { error: "Invalid Postgres credentials" }, 400);
55837
56869
  return;
55838
56870
  }
55839
- const url = buildPostgresUrl({
55840
- host: parsed.host,
55841
- port: Number(parsed.port),
55842
- dbname: parsed.dbname,
55843
- user: parsed.user,
55844
- password: parsed.password
55845
- });
55846
56871
  try {
55847
- const probe = await probeCloud(url);
55848
- if (!probe.reachable) {
55849
- sendJson(res, { ok: false, error: probe.error ?? "Cloud DB unreachable" }, 502);
55850
- return;
55851
- }
55852
- if (!probe.isCloud) {
55853
- sendJson(
55854
- res,
55855
- {
55856
- ok: false,
55857
- error: "That Postgres database is not a Lattice cloud yet. The owner must migrate a local Lattice into it first."
55858
- },
55859
- 409
55860
- );
55861
- return;
55862
- }
55863
- saveDbCredential(parsed.label, url);
55864
- rewriteDbLine(ctx.configPath, "${LATTICE_DB:" + parsed.label + "}");
55865
- updateActiveWorkspaceToCloud(ctx.configPath, parsed.label);
55866
- await ctx.swap();
55867
- sendJson(res, { ok: true, label: parsed.label, isCloud: true });
56872
+ await joinCloudAsMember(
56873
+ ctx,
56874
+ res,
56875
+ {
56876
+ host: parsed.host,
56877
+ port: Number(parsed.port),
56878
+ dbname: parsed.dbname,
56879
+ user: parsed.user,
56880
+ password: parsed.password
56881
+ },
56882
+ parsed.label
56883
+ );
55868
56884
  } catch (e6) {
55869
56885
  const status = e6.status ?? 500;
55870
56886
  sendJson(res, { ok: false, error: e6.message }, status);
@@ -55872,6 +56888,35 @@ async function dispatchDbConfigRoute(req, res, ctx) {
55872
56888
  });
55873
56889
  return true;
55874
56890
  }
56891
+ if (pathname === "/api/cloud/members" && method === "GET") {
56892
+ await tryHandler(res, async () => {
56893
+ if (ctx.db.getDialect() !== "postgres" || !await cloudRlsInstalled(ctx.db)) {
56894
+ sendJson(res, { members: [] });
56895
+ return;
56896
+ }
56897
+ const me = await getAsyncOrSync(ctx.db.adapter, `SELECT session_user AS u`);
56898
+ const owner = me?.u ?? "";
56899
+ if (!await canManageRoles(ctx.db)) {
56900
+ sendJson(res, { members: owner ? [{ role: owner, isOwner: false, isYou: true }] : [] });
56901
+ return;
56902
+ }
56903
+ const rows = await allAsyncOrSync(
56904
+ ctx.db.adapter,
56905
+ `SELECT m.rolname AS role
56906
+ FROM pg_auth_members am
56907
+ JOIN pg_roles g ON g.oid = am.roleid AND g.rolname = ?
56908
+ JOIN pg_roles m ON m.oid = am.member
56909
+ ORDER BY m.rolname`,
56910
+ [MEMBER_GROUP]
56911
+ );
56912
+ const members = [
56913
+ ...owner ? [{ role: owner, isOwner: true, isYou: true }] : [],
56914
+ ...rows.map((r6) => ({ role: r6.role, isOwner: false, isYou: r6.role === owner }))
56915
+ ];
56916
+ sendJson(res, { members });
56917
+ });
56918
+ return true;
56919
+ }
55875
56920
  if (pathname === "/api/cloud/invite" && method === "POST") {
55876
56921
  await tryHandler(res, async () => {
55877
56922
  if (ctx.db.getDialect() !== "postgres" || !await cloudRlsInstalled(ctx.db)) {
@@ -55883,21 +56928,72 @@ async function dispatchDbConfigRoute(req, res, ctx) {
55883
56928
  return;
55884
56929
  }
55885
56930
  const body = await readJson(req);
55886
- const label = typeof body.label === "string" && body.label.trim() ? body.label.trim() : "member";
55887
- const role = memberRoleName(label);
56931
+ const email = typeof body.email === "string" ? body.email.trim() : "";
56932
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
56933
+ sendJson(res, { error: "A valid invitee email is required" }, 400);
56934
+ return;
56935
+ }
56936
+ const coords = activeCloudCoords(ctx.configPath);
56937
+ if (!coords) {
56938
+ sendJson(res, { error: "Could not resolve the cloud connection coordinates" }, 500);
56939
+ return;
56940
+ }
56941
+ const role = memberRoleName(email);
55888
56942
  const password = generateMemberPassword();
55889
56943
  await provisionMemberRole(ctx.db, role, password);
55890
- const coords = activeCloudCoords(ctx.configPath);
55891
- sendJson(res, {
55892
- ok: true,
55893
- invite: {
55894
- host: coords?.host ?? null,
55895
- port: coords?.port ?? null,
55896
- dbname: coords?.dbname ?? null,
55897
- user: role,
55898
- password
55899
- }
55900
- });
56944
+ await assertScopedMemberRole(ctx.db, role);
56945
+ const me = await getAsyncOrSync(ctx.db.adapter, `SELECT session_user AS u`);
56946
+ const user = poolerAwareUser(coords.host, role, me?.u ?? "");
56947
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3);
56948
+ const token = mintInviteToken({ coords, user, password, role, email, expiresAt });
56949
+ await runAsyncOrSync(
56950
+ ctx.db.adapter,
56951
+ `INSERT INTO "__lattice_member_invites" ("id","role","email_hash","expires_at")
56952
+ VALUES (?, ?, ?, ?)`,
56953
+ [
56954
+ randomUUID(),
56955
+ role,
56956
+ createHash2("sha256").update(email.trim().toLowerCase()).digest("hex"),
56957
+ expiresAt.toISOString()
56958
+ ]
56959
+ );
56960
+ sendJson(res, { ok: true, token, role, email });
56961
+ });
56962
+ return true;
56963
+ }
56964
+ if (pathname === "/api/cloud/redeem-invite" && method === "POST") {
56965
+ await tryHandler(res, async () => {
56966
+ const body = await readJson(req);
56967
+ const email = typeof body.email === "string" ? body.email.trim() : "";
56968
+ const token = typeof body.token === "string" ? body.token.trim() : "";
56969
+ if (!email || !token) {
56970
+ sendJson(
56971
+ res,
56972
+ { ok: false, error: "Enter the email this invite was sent to and the invite token." },
56973
+ 400
56974
+ );
56975
+ return;
56976
+ }
56977
+ let payload;
56978
+ try {
56979
+ payload = redeemInviteToken(email, token);
56980
+ } catch (e6) {
56981
+ sendJson(res, { ok: false, error: e6.message }, 400);
56982
+ return;
56983
+ }
56984
+ const label = typeof body.label === "string" && body.label.trim() ? body.label.trim() : "Cloud workspace";
56985
+ await joinCloudAsMember(
56986
+ ctx,
56987
+ res,
56988
+ {
56989
+ host: payload.host,
56990
+ port: payload.port,
56991
+ dbname: payload.dbname,
56992
+ user: payload.user,
56993
+ password: payload.password
56994
+ },
56995
+ label
56996
+ );
55901
56997
  });
55902
56998
  return true;
55903
56999
  }
@@ -56300,7 +57396,7 @@ async function transcribe(opts) {
56300
57396
  }
56301
57397
 
56302
57398
  // src/gui/ai/oauth.ts
56303
- import { createHash as createHash5, randomBytes as randomBytes6 } from "crypto";
57399
+ import { createHash as createHash6, randomBytes as randomBytes7 } from "crypto";
56304
57400
  function readOAuthConfig(env2 = process.env) {
56305
57401
  const authorizeUrl = env2.ANTHROPIC_OAUTH_AUTHORIZE_URL;
56306
57402
  const tokenUrl = env2.ANTHROPIC_OAUTH_TOKEN_URL;
@@ -56314,13 +57410,13 @@ function oauthConfigured(env2 = process.env) {
56314
57410
  return readOAuthConfig(env2) !== null;
56315
57411
  }
56316
57412
  function generatePkceVerifier() {
56317
- return randomBytes6(48).toString("base64url");
57413
+ return randomBytes7(48).toString("base64url");
56318
57414
  }
56319
57415
  function pkceChallengeFor(verifier) {
56320
- return createHash5("sha256").update(verifier).digest("base64url");
57416
+ return createHash6("sha256").update(verifier).digest("base64url");
56321
57417
  }
56322
57418
  function generateState() {
56323
- return randomBytes6(24).toString("base64url");
57419
+ return randomBytes7(24).toString("base64url");
56324
57420
  }
56325
57421
  function buildAuthorizeUrl(cfg, state2, codeChallenge) {
56326
57422
  const params = new URLSearchParams({
@@ -57153,7 +58249,12 @@ async function executeFunction(ctx, name, args) {
57153
58249
  if (!args.values || typeof args.values !== "object") {
57154
58250
  throw new Error("values object is required");
57155
58251
  }
57156
- const { id } = await createRow(mctx, table, args.values);
58252
+ const { id } = await createRow(
58253
+ mctx,
58254
+ table,
58255
+ args.values,
58256
+ ctx.privateMode ? "private" : void 0
58257
+ );
57157
58258
  return { ok: true, result: { id } };
57158
58259
  }
57159
58260
  case "update_row": {
@@ -57931,6 +59032,9 @@ async function dispatchChatRoute(req, res, ctx) {
57931
59032
  const dispatch = {
57932
59033
  db: ctx.db,
57933
59034
  feed: ctx.feed,
59035
+ // "Private mode" chat toggle: force rows the assistant creates this turn private
59036
+ // regardless of the table default (a transient per-request choice).
59037
+ privateMode: body.privateMode === true,
57934
59038
  validTables: new Set([...ctx.validTables].filter((t8) => !ASSISTANT_HIDDEN_TABLES.has(t8))),
57935
59039
  junctionTables: new Set([...ctx.junctionTables].filter((t8) => !ASSISTANT_HIDDEN_TABLES.has(t8))),
57936
59040
  softDeletable: ctx.softDeletable,
@@ -58996,12 +60100,12 @@ async function renderViaPlaywright(url, timeoutMs) {
58996
60100
  }
58997
60101
 
58998
60102
  // src/framework/blob-store.ts
58999
- import { createHash as createHash6 } from "crypto";
60103
+ import { createHash as createHash7 } from "crypto";
59000
60104
  import { createReadStream as createReadStream2, existsSync as existsSync20, mkdirSync as mkdirSync9, statSync as statSync6, copyFileSync as copyFileSync3 } from "fs";
59001
60105
  import { basename as basename9, join as join24 } from "path";
59002
60106
  async function hashFile(srcPath) {
59003
60107
  return new Promise((resolve11, reject) => {
59004
- const hash = createHash6("sha256");
60108
+ const hash = createHash7("sha256");
59005
60109
  const stream = createReadStream2(srcPath);
59006
60110
  stream.on("data", (chunk) => hash.update(chunk));
59007
60111
  stream.on("error", reject);
@@ -59033,7 +60137,7 @@ async function attachBlob(srcPath, latticeRoot) {
59033
60137
  }
59034
60138
 
59035
60139
  // src/gui/ingest-routes.ts
59036
- import { createHash as createHash7 } from "crypto";
60140
+ import { createHash as createHash8 } from "crypto";
59037
60141
  function fileSlug(name, id) {
59038
60142
  const base = slugify(name.replace(/\.[^./\\]+$/, "")) || "file";
59039
60143
  return `${base}-${id.slice(0, 8)}`;
@@ -59453,7 +60557,7 @@ async function dispatchIngestRoute(req, res, ctx) {
59453
60557
  let s3Status = null;
59454
60558
  const s3cfg = resolveActiveS3Config(ctx.configPath);
59455
60559
  if (s3cfg) {
59456
- const sha256 = blob?.sha256 ?? createHash7("sha256").update(buf).digest("hex");
60560
+ const sha256 = blob?.sha256 ?? createHash8("sha256").update(buf).digest("hex");
59457
60561
  const key = s3Key(s3cfg.prefix, sha256);
59458
60562
  try {
59459
60563
  const store = await createS3Store(s3cfg);
@@ -59716,6 +60820,63 @@ async function exactCountMany(adapter, tableNames, softDeleteTables) {
59716
60820
  return out;
59717
60821
  }
59718
60822
 
60823
+ // src/gui/render-progress.ts
60824
+ import { EventEmitter as EventEmitter3 } from "events";
60825
+ var RenderProgressBus = class {
60826
+ emitter = new EventEmitter3();
60827
+ EVENT = "render-progress";
60828
+ _latest = null;
60829
+ constructor() {
60830
+ this.emitter.setMaxListeners(64);
60831
+ }
60832
+ /** Publish a render progress event to all subscribers. */
60833
+ publish(event) {
60834
+ this._latest = event;
60835
+ this.emitter.emit(this.EVENT, event);
60836
+ }
60837
+ /** Subscribe to future events. Returns an unsubscribe function. */
60838
+ subscribe(handler) {
60839
+ this.emitter.on(this.EVENT, handler);
60840
+ return () => this.emitter.off(this.EVENT, handler);
60841
+ }
60842
+ /** The most recently published event, or null if none yet. */
60843
+ latest() {
60844
+ return this._latest;
60845
+ }
60846
+ /** Number of live subscribers (for diagnostics/tests). */
60847
+ listenerCount() {
60848
+ return this.emitter.listenerCount(this.EVENT);
60849
+ }
60850
+ };
60851
+
60852
+ // src/cloud/table-policy.ts
60853
+ async function getAllTablePolicies(db) {
60854
+ if (db.getDialect() !== "postgres") return {};
60855
+ const rows = await allAsyncOrSync(
60856
+ db.adapter,
60857
+ `SELECT "table_name", "default_row_visibility", "never_share" FROM "__lattice_table_policy"`
60858
+ );
60859
+ const out = {};
60860
+ for (const r6 of rows) {
60861
+ out[r6.table_name] = {
60862
+ defaultRowVisibility: r6.default_row_visibility === "everyone" ? "everyone" : "private",
60863
+ neverShare: r6.never_share === true
60864
+ };
60865
+ }
60866
+ return out;
60867
+ }
60868
+ async function setTableDefaultVisibility(db, table, visibility) {
60869
+ if (db.getDialect() !== "postgres") return;
60870
+ await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_default_visibility(?, ?)`, [
60871
+ table,
60872
+ visibility
60873
+ ]);
60874
+ }
60875
+ async function setTableNeverShare(db, table, on) {
60876
+ if (db.getDialect() !== "postgres") return;
60877
+ await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share(?, ?)`, [table, on]);
60878
+ }
60879
+
59719
60880
  // src/gui/server.ts
59720
60881
  function sendText(res, body, status = 200, contentType = "text/plain; charset=utf-8") {
59721
60882
  res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
@@ -59802,6 +60963,14 @@ async function entitiesWithCounts(db, configPath, outputDir) {
59802
60963
  return base;
59803
60964
  })
59804
60965
  );
60966
+ if (db.getDialect() === "postgres" && await cloudRlsInstalled(db) && await canManageRoles(db)) {
60967
+ const policies = await getAllTablePolicies(db);
60968
+ for (const t8 of enrichedTables) {
60969
+ const policy = policies[t8.name];
60970
+ t8.defaultRowVisibility = policy?.defaultRowVisibility ?? "private";
60971
+ t8.neverShare = policy?.neverShare ?? false;
60972
+ }
60973
+ }
59805
60974
  return { ...payload, tables: enrichedTables };
59806
60975
  }
59807
60976
  var FRESHNESS_COLS = ["updated_at", "created_at", "ts"];
@@ -60053,11 +61222,6 @@ async function openConfig(configPath, outputDir, autoRender = false) {
60053
61222
  }
60054
61223
  if (autoRender) {
60055
61224
  db.enableAutoRender(outputDir);
60056
- try {
60057
- await db.render(outputDir);
60058
- } catch (e6) {
60059
- console.warn("[openConfig] initial render failed:", e6.message);
60060
- }
60061
61225
  if (!existsSync21(manifestPath(outputDir))) {
60062
61226
  writeManifest(outputDir, {
60063
61227
  version: 2,
@@ -60078,7 +61242,10 @@ async function openConfig(configPath, outputDir, autoRender = false) {
60078
61242
  realtime,
60079
61243
  feed: new FeedBus(),
60080
61244
  dbPath: parsed.dbPath,
60081
- autoRender
61245
+ autoRender,
61246
+ renderProgress: new RenderProgressBus(),
61247
+ renderAbort: new AbortController(),
61248
+ renderState: { phase: "idle", tables: {} }
60082
61249
  };
60083
61250
  }
60084
61251
  function friendlyConfigName(parsedName, configPath) {
@@ -60150,7 +61317,77 @@ function deleteDatabaseFiles(targetConfigPath) {
60150
61317
  }
60151
61318
  return { deletedConfig: basename11(targetConfigPath), deletedDbFile };
60152
61319
  }
61320
+ function startBackgroundRender(active) {
61321
+ if (!active.autoRender) return;
61322
+ if (active.renderState.phase === "running") return;
61323
+ active.renderState.phase = "running";
61324
+ const db = active.db;
61325
+ const signal = active.renderAbort.signal;
61326
+ const state2 = active.renderState;
61327
+ const bus = active.renderProgress;
61328
+ const startedAt = Date.now();
61329
+ const onProgress = (e6) => {
61330
+ if (signal.aborted) return;
61331
+ if (e6.table) {
61332
+ state2.tables[e6.table] = {
61333
+ pct: e6.pct,
61334
+ entitiesRendered: e6.entitiesRendered,
61335
+ entitiesTotal: e6.entitiesTotal,
61336
+ done: e6.kind === "table-done"
61337
+ };
61338
+ state2.currentTable = e6.table;
61339
+ state2.tableIndex = e6.tableIndex;
61340
+ state2.tableCount = e6.tableCount;
61341
+ }
61342
+ if (e6.kind === "done") {
61343
+ state2.phase = "done";
61344
+ state2.durationMs = e6.durationMs ?? Date.now() - startedAt;
61345
+ } else if (e6.kind === "error") {
61346
+ state2.phase = "error";
61347
+ state2.error = e6.message ?? "render failed";
61348
+ console.error("[render] background render error:", e6.message ?? "(no message)");
61349
+ }
61350
+ bus.publish(e6);
61351
+ };
61352
+ void db.renderInBackground(active.outputDir, { signal, onProgress }).then(
61353
+ () => {
61354
+ },
61355
+ (err) => {
61356
+ if (signal.aborted) return;
61357
+ const message = err instanceof Error ? err.message : String(err);
61358
+ state2.phase = "error";
61359
+ state2.error = message;
61360
+ console.error("[render] background render rejected:", message);
61361
+ bus.publish({
61362
+ kind: "error",
61363
+ table: state2.currentTable ?? null,
61364
+ entitiesRendered: 0,
61365
+ entitiesTotal: 0,
61366
+ tableIndex: state2.tableIndex ?? 0,
61367
+ tableCount: state2.tableCount ?? 0,
61368
+ pct: 0,
61369
+ message
61370
+ });
61371
+ }
61372
+ );
61373
+ }
61374
+ async function attachRowAccess(db, table, rows) {
61375
+ if (rows.length === 0) return;
61376
+ const pkCols = db.getPrimaryKey(table);
61377
+ if (pkCols.length === 0) return;
61378
+ const pkOf = (r6) => pkCols.map((c6) => String(r6[c6])).join(" ");
61379
+ const summaries = await rowAccessSummaries(db, table, rows.map(pkOf));
61380
+ if (summaries.size === 0) return;
61381
+ for (const r6 of rows) {
61382
+ const a6 = summaries.get(pkOf(r6));
61383
+ if (a6) r6._access = a6;
61384
+ }
61385
+ }
60153
61386
  async function disposeActive(active) {
61387
+ try {
61388
+ active.renderAbort.abort();
61389
+ } catch {
61390
+ }
60154
61391
  if (active.realtime) {
60155
61392
  try {
60156
61393
  await active.realtime.stop();
@@ -60167,6 +61404,7 @@ async function reopenSameConfig(active, autoRender) {
60167
61404
  await disposeActive(active);
60168
61405
  const next = await openConfig(active.configPath, active.outputDir, autoRender);
60169
61406
  next.feed = feed;
61407
+ startBackgroundRender(next);
60170
61408
  return next;
60171
61409
  }
60172
61410
  async function syncUserIdentityRow(db) {
@@ -60282,7 +61520,9 @@ async function applySchemaConfig(active, entry, direction, autoRender) {
60282
61520
  for (const sql of ddl) await execSql(active.db, sql);
60283
61521
  saveConfigDoc(active.configPath, doc);
60284
61522
  await disposeActive(active);
60285
- return openConfig(active.configPath, active.outputDir, autoRender);
61523
+ const next = await openConfig(active.configPath, active.outputDir, autoRender);
61524
+ startBackgroundRender(next);
61525
+ return next;
60286
61526
  }
60287
61527
  function schemaReverseSummary(verb, entry) {
60288
61528
  const what = entry.operation.replace("schema.", "").replace(/_/g, " ");
@@ -60426,6 +61666,46 @@ data: ${JSON.stringify(data)}
60426
61666
  req.on("error", cleanup);
60427
61667
  return;
60428
61668
  }
61669
+ if (method === "GET" && pathname === "/api/render/status") {
61670
+ sendJson(res, active.renderState);
61671
+ return;
61672
+ }
61673
+ if (method === "GET" && pathname === "/api/render/progress") {
61674
+ res.writeHead(200, {
61675
+ "content-type": "text/event-stream; charset=utf-8",
61676
+ "cache-control": "no-store, no-transform",
61677
+ connection: "keep-alive",
61678
+ "x-accel-buffering": "no"
61679
+ });
61680
+ const writeEvent = (event, data) => {
61681
+ try {
61682
+ res.write(`event: ${event}
61683
+ data: ${JSON.stringify(data)}
61684
+
61685
+ `);
61686
+ } catch {
61687
+ }
61688
+ };
61689
+ writeEvent("snapshot", active.renderState);
61690
+ const keepalive = setInterval(() => {
61691
+ try {
61692
+ res.write(`: keepalive
61693
+
61694
+ `);
61695
+ } catch {
61696
+ }
61697
+ }, 25e3);
61698
+ const offProgress = active.renderProgress.subscribe((e6) => {
61699
+ writeEvent("progress", e6);
61700
+ });
61701
+ const cleanup = () => {
61702
+ clearInterval(keepalive);
61703
+ offProgress();
61704
+ };
61705
+ req.on("close", cleanup);
61706
+ req.on("error", cleanup);
61707
+ return;
61708
+ }
60429
61709
  if (method === "GET" && pathname === "/api/project") {
60430
61710
  sendJson(res, getGuiProject(active.configPath, active.outputDir));
60431
61711
  return;
@@ -60660,9 +61940,68 @@ data: ${JSON.stringify(data)}
60660
61940
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
60661
61941
  });
60662
61942
  }
61943
+ if (active.db.getDialect() === "postgres") {
61944
+ const columnNames = Object.keys(active.db.getRegisteredColumns(tableName) ?? {});
61945
+ const pkCols = active.db.getPrimaryKey(tableName);
61946
+ await setColumnAudience(
61947
+ active.db,
61948
+ tableName,
61949
+ colName,
61950
+ secret ? "owner" : "",
61951
+ columnNames,
61952
+ pkCols
61953
+ );
61954
+ }
60663
61955
  sendJson(res, { ok: true });
60664
61956
  return;
60665
61957
  }
61958
+ if (method === "POST" && /^\/api\/schema\/entities\/[^/]+\/default-row-visibility$/.test(pathname)) {
61959
+ const table = decodeURIComponent(pathname.split("/")[4] ?? "");
61960
+ if (!active.validTables.has(table)) {
61961
+ sendJson(res, { error: `Unknown table: ${table}` }, 400);
61962
+ return;
61963
+ }
61964
+ if (active.db.getDialect() !== "postgres" || !await cloudRlsInstalled(active.db)) {
61965
+ sendJson(res, { error: "The active database is not a Lattice cloud" }, 400);
61966
+ return;
61967
+ }
61968
+ if (!await canManageRoles(active.db)) {
61969
+ sendJson(res, { error: "Only a cloud owner can change default row visibility" }, 403);
61970
+ return;
61971
+ }
61972
+ const body = await readJson(req);
61973
+ const visibility = body.visibility === "everyone" ? "everyone" : "private";
61974
+ if (body.visibility !== "everyone" && body.visibility !== "private") {
61975
+ sendJson(res, { error: "visibility must be 'private' or 'everyone'" }, 400);
61976
+ return;
61977
+ }
61978
+ await setTableDefaultVisibility(active.db, table, visibility);
61979
+ sendJson(res, { ok: true, table, visibility });
61980
+ return;
61981
+ }
61982
+ if (method === "POST" && /^\/api\/schema\/entities\/[^/]+\/never-share$/.test(pathname)) {
61983
+ const table = decodeURIComponent(pathname.split("/")[4] ?? "");
61984
+ if (!active.validTables.has(table)) {
61985
+ sendJson(res, { error: `Unknown table: ${table}` }, 400);
61986
+ return;
61987
+ }
61988
+ if (active.db.getDialect() !== "postgres" || !await cloudRlsInstalled(active.db)) {
61989
+ sendJson(res, { error: "The active database is not a Lattice cloud" }, 400);
61990
+ return;
61991
+ }
61992
+ if (!await canManageRoles(active.db)) {
61993
+ sendJson(res, { error: "Only a cloud owner can change never-share" }, 403);
61994
+ return;
61995
+ }
61996
+ const body = await readJson(req);
61997
+ if (typeof body.on !== "boolean") {
61998
+ sendJson(res, { error: "on must be a boolean" }, 400);
61999
+ return;
62000
+ }
62001
+ await setTableNeverShare(active.db, table, body.on);
62002
+ sendJson(res, { ok: true, table, on: body.on });
62003
+ return;
62004
+ }
60666
62005
  if (method === "POST" && /^\/api\/schema\/entities\/[^/]+\/rename$/.test(pathname)) {
60667
62006
  const oldName = decodeURIComponent(pathname.split("/")[4] ?? "");
60668
62007
  if (!active.validTables.has(oldName)) {
@@ -61297,6 +62636,7 @@ data: ${JSON.stringify(data)}
61297
62636
  setActiveWorkspace(latticeRoot, ws.id);
61298
62637
  await disposeActive(active);
61299
62638
  active = next;
62639
+ startBackgroundRender(active);
61300
62640
  currentWorkspaceId = ws.id;
61301
62641
  sendJson(res, { ok: true, id: ws.id });
61302
62642
  return;
@@ -61336,6 +62676,7 @@ data: ${JSON.stringify(data)}
61336
62676
  setActiveWorkspace(latticeRoot, created.id);
61337
62677
  await disposeActive(active);
61338
62678
  active = newActive;
62679
+ startBackgroundRender(active);
61339
62680
  currentWorkspaceId = created.id;
61340
62681
  sendJson(res, { ok: true, id: created.id });
61341
62682
  return;
@@ -61389,6 +62730,7 @@ data: ${JSON.stringify(data)}
61389
62730
  setActiveWorkspace(latticeRoot, fallback.id);
61390
62731
  await disposeActive(active);
61391
62732
  active = next;
62733
+ startBackgroundRender(active);
61392
62734
  switchedTo = fallback.id;
61393
62735
  currentWorkspaceId = fallback.id;
61394
62736
  }
@@ -61467,6 +62809,7 @@ data: ${JSON.stringify(data)}
61467
62809
  }
61468
62810
  await disposeActive(active);
61469
62811
  active = next;
62812
+ startBackgroundRender(active);
61470
62813
  sendJson(res, { ok: true, path: active.configPath });
61471
62814
  return;
61472
62815
  }
@@ -61484,6 +62827,7 @@ data: ${JSON.stringify(data)}
61484
62827
  );
61485
62828
  await disposeActive(active);
61486
62829
  active = next;
62830
+ startBackgroundRender(active);
61487
62831
  sendJson(res, { ok: true, path: active.configPath });
61488
62832
  return;
61489
62833
  }
@@ -61534,6 +62878,7 @@ data: ${JSON.stringify(data)}
61534
62878
  }
61535
62879
  await disposeActive(active);
61536
62880
  active = next;
62881
+ startBackgroundRender(active);
61537
62882
  switchedTo = active.configPath;
61538
62883
  }
61539
62884
  let deleted;
@@ -61667,6 +63012,7 @@ data: ${JSON.stringify(data)}
61667
63012
  ];
61668
63013
  }
61669
63014
  const rows = await active.db.query(table, queryOpts);
63015
+ await attachRowAccess(active.db, table, rows);
61670
63016
  sendJson(res, { rows });
61671
63017
  return;
61672
63018
  }
@@ -61683,6 +63029,7 @@ data: ${JSON.stringify(data)}
61683
63029
  sendJson(res, { error: "Row not found" }, 404);
61684
63030
  return;
61685
63031
  }
63032
+ await attachRowAccess(active.db, table, [row]);
61686
63033
  sendJson(res, row);
61687
63034
  return;
61688
63035
  }
@@ -61803,6 +63150,7 @@ data: ${JSON.stringify(data)}
61803
63150
  const next = await openConfig(active.configPath, active.outputDir, autoRender);
61804
63151
  await disposeActive(active);
61805
63152
  active = next;
63153
+ startBackgroundRender(active);
61806
63154
  }
61807
63155
  });
61808
63156
  if (handled) return;
@@ -61827,6 +63175,7 @@ ${e6.stack ?? ""}`
61827
63175
  })();
61828
63176
  });
61829
63177
  const port = await listenWithPortFallback(server, startPort, host);
63178
+ startBackgroundRender(active);
61830
63179
  const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
61831
63180
  const url = `http://${displayHost}:${String(port)}`;
61832
63181
  if (options.openBrowser ?? true) openUrl(url);