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 +2044 -695
- package/dist/index.cjs +561 -30
- package/dist/index.d.cts +220 -13
- package/dist/index.d.ts +220 -13
- package/dist/index.js +553 -30
- package/package.json +1 -1
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
|
|
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 =
|
|
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
|
|
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)) :
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
51727
|
-
//
|
|
51728
|
-
// the
|
|
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
|
-
'
|
|
51732
|
-
'
|
|
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
|
-
|
|
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
|
|
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/
|
|
52269
|
+
return fetch('/api/cloud/redeem-invite', {
|
|
51745
52270
|
method: 'POST',
|
|
51746
52271
|
headers: { 'content-type': 'application/json' },
|
|
51747
|
-
body: JSON.stringify(
|
|
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
|
-
// ("
|
|
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">
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
//
|
|
52376
|
-
//
|
|
52377
|
-
|
|
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
|
|
52386
|
-
|
|
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
|
-
|
|
52547
|
-
|
|
52548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52556
|
-
'<p style="
|
|
52557
|
-
|
|
52558
|
-
'
|
|
52559
|
-
|
|
52560
|
-
|
|
52561
|
-
'
|
|
52562
|
-
|
|
52563
|
-
|
|
52564
|
-
|
|
52565
|
-
|
|
52566
|
-
|
|
52567
|
-
|
|
52568
|
-
|
|
52569
|
-
|
|
52570
|
-
|
|
52571
|
-
|
|
52572
|
-
|
|
52573
|
-
|
|
52574
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
52585
|
-
//
|
|
52586
|
-
//
|
|
52587
|
-
//
|
|
52588
|
-
//
|
|
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>
|
|
52592
|
-
'<input name="
|
|
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
|
-
'
|
|
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.
|
|
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({
|
|
53189
|
+
body: JSON.stringify({ email: data.email }),
|
|
52607
53190
|
}).then(function (res) {
|
|
52608
|
-
|
|
53191
|
+
showInviteTokenModal(res || {});
|
|
52609
53192
|
});
|
|
52610
53193
|
},
|
|
52611
53194
|
});
|
|
52612
53195
|
}
|
|
52613
53196
|
|
|
52614
|
-
function
|
|
52615
|
-
|
|
52616
|
-
|
|
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
|
|
52627
|
-
'
|
|
52628
|
-
'<
|
|
52629
|
-
|
|
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(
|
|
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
|
-
|
|
54014
|
-
|
|
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
|
-
|
|
55160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
55319
|
-
|
|
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
|
-
|
|
55328
|
-
|
|
55329
|
-
|
|
55330
|
-
|
|
55331
|
-
|
|
55332
|
-
|
|
55333
|
-
|
|
55334
|
-
|
|
55335
|
-
|
|
55336
|
-
|
|
55337
|
-
|
|
55338
|
-
|
|
55339
|
-
|
|
55340
|
-
|
|
55341
|
-
|
|
55342
|
-
|
|
55343
|
-
|
|
55344
|
-
|
|
55345
|
-
|
|
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
|
-
|
|
55349
|
-
|
|
55350
|
-
|
|
55351
|
-
|
|
55352
|
-
|
|
55353
|
-
|
|
55354
|
-
|
|
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
|
-
|
|
55848
|
-
|
|
55849
|
-
|
|
55850
|
-
|
|
55851
|
-
|
|
55852
|
-
|
|
55853
|
-
|
|
55854
|
-
|
|
55855
|
-
|
|
55856
|
-
|
|
55857
|
-
|
|
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
|
|
55887
|
-
|
|
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
|
-
|
|
55891
|
-
|
|
55892
|
-
|
|
55893
|
-
|
|
55894
|
-
|
|
55895
|
-
|
|
55896
|
-
|
|
55897
|
-
|
|
55898
|
-
|
|
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
|
|
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
|
|
57413
|
+
return randomBytes7(48).toString("base64url");
|
|
56318
57414
|
}
|
|
56319
57415
|
function pkceChallengeFor(verifier) {
|
|
56320
|
-
return
|
|
57416
|
+
return createHash6("sha256").update(verifier).digest("base64url");
|
|
56321
57417
|
}
|
|
56322
57418
|
function generateState() {
|
|
56323
|
-
return
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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);
|