latticesql 1.13.3 → 1.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5943,6 +5943,30 @@ var guiAppHtml = `<!doctype html>
5943
5943
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
5944
5944
  }
5945
5945
 
5946
+ // Redact the userinfo portion of a connection URL so the password
5947
+ // never reaches the rendered DOM. Used for every place the GUI
5948
+ // displays a cloud_url field (team cards, connection list, etc).
5949
+ // Defensive fallback returns the input as-is when it doesn't parse
5950
+ // as a URL \u2014 better to render a non-credential string verbatim than
5951
+ // to silently swallow the value.
5952
+ function redactUrlCredentials(url) {
5953
+ if (url == null) return '';
5954
+ var s = String(url);
5955
+ try {
5956
+ var u = new URL(s);
5957
+ if (u.password) {
5958
+ // Preserve the username (often useful for identification \u2014
5959
+ // e.g. tenant prefixes like postgres.<ref>) but mask the
5960
+ // password portion. URL.password is the decoded form.
5961
+ u.password = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022';
5962
+ return u.toString();
5963
+ }
5964
+ return s;
5965
+ } catch (_) {
5966
+ return s;
5967
+ }
5968
+ }
5969
+
5946
5970
  function truncate(s, n) {
5947
5971
  if (s == null) return '';
5948
5972
  s = String(s);
@@ -7759,7 +7783,7 @@ var guiAppHtml = `<!doctype html>
7759
7783
  '<button class="btn danger-btn" data-act="signout">Sign out</button>' +
7760
7784
  '</h3>' +
7761
7785
  '<div class="team-meta">' +
7762
- 'Cloud: <code>' + escapeHtml(c.cloud_url) + '</code> \xB7 ' +
7786
+ 'Cloud: <code>' + escapeHtml(redactUrlCredentials(c.cloud_url)) + '</code> \xB7 ' +
7763
7787
  'User id: <code>' + escapeHtml(c.my_user_id) + '</code> \xB7 ' +
7764
7788
  'Joined ' + escapeHtml(c.joined_at) +
7765
7789
  '</div>' +
@@ -8281,37 +8305,76 @@ var guiAppHtml = `<!doctype html>
8281
8305
  }
8282
8306
 
8283
8307
  function renderTeamCard(card, conn) {
8284
- // Fetch status + shared + members in parallel; members may 403 for
8285
- // non-creators (only members can list, but we still try and ignore).
8308
+ // Fetch status + shared + members in parallel. The members fetch
8309
+ // can fail two ways: HTTP 403 for non-creators (only members can
8310
+ // list \u2014 though in practice every active member can), or the
8311
+ // cloud is genuinely unreachable. We track which case we're in so
8312
+ // the role pill says something more useful than "unknown".
8286
8313
  var teamId = conn.team_id;
8314
+ var membersFailed = false;
8287
8315
  Promise.all([
8288
8316
  fetchJson('/api/teams-gui/teams/' + teamId + '/status'),
8289
8317
  fetchJson('/api/teams-gui/teams/' + teamId + '/shared').catch(function () { return { objects: [] }; }),
8290
- fetchJson('/api/teams-gui/teams/' + teamId + '/members').catch(function () { return { members: [] }; }),
8318
+ fetchJson('/api/teams-gui/teams/' + teamId + '/members').catch(function () {
8319
+ membersFailed = true;
8320
+ return { members: [] };
8321
+ }),
8291
8322
  ]).then(function (results) {
8292
8323
  var status = results[0];
8293
8324
  var shared = results[1].objects;
8294
8325
  var members = results[2].members;
8295
8326
  var myMembership = members.find(function (m) { return m.user_id === conn.my_user_id; });
8296
- var isCreator = myMembership && myMembership.role === 'creator';
8297
- var rolePill = '<span class="role-tag' + (isCreator ? '' : ' role-member') + '">' + (myMembership ? myMembership.role : 'unknown') + '</span>';
8298
-
8299
- var lastSeq = status.last_change_seq == null ? '(never)' : status.last_change_seq;
8327
+ // Resolve the role label with a three-step fallback chain that
8328
+ // distinguishes "we know what the role is" from "we couldn't ask
8329
+ // the cloud" from "the cloud answered but didn't recognize us".
8330
+ // The pre-v1.13.4 implementation collapsed all three into
8331
+ // "unknown" \u2014 confusing when (a) the user just created the
8332
+ // team and IS the creator on the cloud side, or (b) the cloud
8333
+ // briefly hiccupped. Use saveConnection's own data as the
8334
+ // authoritative source when we have it on the local row.
8335
+ var roleLabel;
8336
+ var isCreator;
8337
+ if (myMembership) {
8338
+ roleLabel = myMembership.role;
8339
+ isCreator = myMembership.role === 'creator';
8340
+ } else if (membersFailed) {
8341
+ // Cloud listMembers couldn't be reached. We don't know who
8342
+ // we are remotely, but the local connection row knows we
8343
+ // joined this team \u2014 surface that, with a soft warning.
8344
+ roleLabel = '(cloud unreachable)';
8345
+ isCreator = false;
8346
+ } else {
8347
+ // listMembers returned a list, but our user_id wasn't in it.
8348
+ // Either we were kicked or the local my_user_id is stale.
8349
+ roleLabel = '(not in member list)';
8350
+ isCreator = false;
8351
+ }
8352
+ var rolePill = '<span class="role-tag' + (isCreator ? '' : ' role-member') + '">' +
8353
+ escapeHtml(roleLabel) +
8354
+ '</span>';
8355
+
8356
+ // v1.13.4: no manual-sync button and no Last seq / Outbox / DLQ /
8357
+ // Local links stats. Lattice is realtime against its canonical
8358
+ // store \u2014 every read and every write the GUI does hits the
8359
+ // active DB directly. When the project's db: line points at a
8360
+ // Postgres URL, that IS the cloud DB; when it's local SQLite,
8361
+ // it's the canonical local. There's nothing for the user to
8362
+ // "sync" \u2014 operations either succeed live or fail gracefully
8363
+ // when the connection is down. The outbox/change-log machinery
8364
+ // is an HTTP-mode-only internal that the CLI still exposes
8365
+ // (lattice teams sync) for power users; the GUI no longer
8366
+ // pretends it's a user-facing action.
8367
+ // Reference status once so eslint no-unused-vars stays happy
8368
+ // even though we've intentionally dropped its display.
8369
+ void status;
8300
8370
  card.innerHTML =
8301
8371
  '<h3>' + escapeHtml(conn.team_name) + ' ' + rolePill +
8302
- '<span style="font-size:11px;color:var(--text-muted);font-weight:normal">' + escapeHtml(conn.cloud_url) + '</span>' +
8372
+ '<span style="font-size:11px;color:var(--text-muted);font-weight:normal">' + escapeHtml(redactUrlCredentials(conn.cloud_url)) + '</span>' +
8303
8373
  '</h3>' +
8304
8374
  '<div class="team-meta">team-id: <code>' + escapeHtml(teamId) + '</code></div>' +
8305
- '<div class="team-stats">' +
8306
- '<div class="team-stat"><div class="stat-label">Last seq</div><div class="stat-value">' + lastSeq + '</div></div>' +
8307
- '<div class="team-stat"><div class="stat-label">Outbox</div><div class="stat-value">' + status.outbox_depth + '</div></div>' +
8308
- '<div class="team-stat"><div class="stat-label">DLQ</div><div class="stat-value">' + status.dlq_depth + '</div></div>' +
8309
- '<div class="team-stat"><div class="stat-label">Local links</div><div class="stat-value">' + status.local_links + '</div></div>' +
8310
- '</div>' +
8311
8375
  '<div class="team-actions">' +
8312
- '<button class="btn primary" data-act="sync">Sync now</button>' +
8313
8376
  (isCreator
8314
- ? '<button class="btn" data-act="invite">Generate invite token</button>'
8377
+ ? '<button class="btn primary" data-act="invite">Generate invite token</button>'
8315
8378
  : '') +
8316
8379
  '<button class="btn" data-act="leave">' + (isCreator ? 'Destroy team' : 'Leave team') + '</button>' +
8317
8380
  '</div>' +
@@ -8359,11 +8422,9 @@ var guiAppHtml = `<!doctype html>
8359
8422
 
8360
8423
  function wireTeamCardActions(card, conn, isCreator) {
8361
8424
  var teamId = conn.team_id;
8362
- card.querySelector('[data-act="sync"]').addEventListener('click', function () {
8363
- fetchJson('/api/teams-gui/teams/' + teamId + '/sync', { method: 'POST' })
8364
- .then(function () { renderTeamCard(card, conn); })
8365
- .catch(function (err) { alert('Sync failed: ' + err.message); });
8366
- });
8425
+ // v1.13.4: no Sync button anymore. The CLI command lattice teams
8426
+ // sync remains for HTTP-mode operators who need to nudge their
8427
+ // outbox; the GUI is realtime against the canonical store.
8367
8428
  var inviteBtn = card.querySelector('[data-act="invite"]');
8368
8429
  if (inviteBtn) inviteBtn.addEventListener('click', function () {
8369
8430
  showInviteByEmailModal(teamId);
@@ -9989,6 +10050,74 @@ async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
9989
10050
  }
9990
10051
  }
9991
10052
 
10053
+ // src/teams/direct-ops.ts
10054
+ async function listMembersDirect(db, teamId) {
10055
+ const members = await db.query("__lattice_team_members", {
10056
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
10057
+ });
10058
+ if (members.length === 0) return [];
10059
+ const users = await db.query("__lattice_users", {
10060
+ filters: [
10061
+ { col: "id", op: "in", val: members.map((m) => m.user_id) },
10062
+ { col: "deleted_at", op: "isNull" }
10063
+ ]
10064
+ });
10065
+ const userById = new Map(users.map((u) => [u.id, u]));
10066
+ const out = [];
10067
+ for (const m of members) {
10068
+ const u = userById.get(m.user_id);
10069
+ if (!u) continue;
10070
+ out.push({
10071
+ user_id: m.user_id,
10072
+ email: u.email,
10073
+ name: u.name,
10074
+ role: m.role,
10075
+ joined_at: m.joined_at
10076
+ });
10077
+ }
10078
+ return out;
10079
+ }
10080
+ async function inviteDirect(db, teamId, inviterUserId, inviteeEmail, expiresInHours = 7 * 24) {
10081
+ const team = await db.get("__lattice_team", teamId);
10082
+ if (!team || team.deleted_at) {
10083
+ throw new Error(`Team not found: ${teamId}`);
10084
+ }
10085
+ const expiresAt = new Date(Date.now() + expiresInHours * 36e5).toISOString();
10086
+ const { raw, hash } = generateInviteToken();
10087
+ const id = await db.insert("__lattice_invitations", {
10088
+ team_id: teamId,
10089
+ token_hash: hash,
10090
+ invitee_email: inviteeEmail,
10091
+ invited_by_user_id: inviterUserId,
10092
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
10093
+ expires_at: expiresAt
10094
+ });
10095
+ return {
10096
+ id,
10097
+ raw_token: raw,
10098
+ expires_at: expiresAt,
10099
+ team_name: team.name,
10100
+ invitee_email: inviteeEmail
10101
+ };
10102
+ }
10103
+ async function kickMemberDirect(db, teamId, userId) {
10104
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: userId });
10105
+ }
10106
+ async function destroyTeamDirect(db) {
10107
+ const identityRow = await db.get("__lattice_team_identity", "singleton");
10108
+ if (identityRow && typeof identityRow.team_id === "string") {
10109
+ const teamId = identityRow.team_id;
10110
+ const members = await db.query("__lattice_team_members", {
10111
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
10112
+ });
10113
+ for (const m of members) {
10114
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: m.user_id });
10115
+ }
10116
+ await db.update("__lattice_team", teamId, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
10117
+ }
10118
+ await db.delete("__lattice_team_identity", "singleton");
10119
+ }
10120
+
9992
10121
  // src/teams/client.ts
9993
10122
  var TeamsClient = class {
9994
10123
  constructor(local) {
@@ -10075,6 +10204,13 @@ var TeamsClient = class {
10075
10204
  const redeem = await this.redeemInvite(opts.cloudUrl, inviteToken, email, name);
10076
10205
  saveDbCredential(opts.label, opts.cloudUrl);
10077
10206
  writeToken(opts.label, redeem.raw_token);
10207
+ await this.saveConnection({
10208
+ team_id: redeem.team.id,
10209
+ team_name: redeem.team.name,
10210
+ cloud_url: opts.cloudUrl,
10211
+ my_user_id: redeem.user.id,
10212
+ api_token: redeem.raw_token
10213
+ });
10078
10214
  return {
10079
10215
  probe,
10080
10216
  joinedAsMember: { user_id: redeem.user.id, team_id: redeem.team.id }
@@ -10094,21 +10230,51 @@ var TeamsClient = class {
10094
10230
  * The HTTP path can't be used here because the browser's Fetch
10095
10231
  * API refuses URLs with embedded credentials.
10096
10232
  *
10097
- * On success writes the bearer token to `~/.lattice/keys/<label>.token`.
10098
- * Caller is expected to call `saveConnection` if they also want the
10099
- * local `__lattice_team_connections` row populated.
10233
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
10234
+ * **and** persists the local `__lattice_team_connections` row so the
10235
+ * GUI's team-management API calls can authenticate immediately
10236
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
10237
+ * connection-row write — the older v1.13 implementation only wrote
10238
+ * the token file, leaving GUI authenticated calls with no
10239
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
10100
10240
  */
10101
10241
  async upgradeToTeamCloud(opts) {
10102
10242
  const reg = isPostgresUrl(opts.cloudUrl) ? await registerDirectViaPostgres(opts.cloudUrl, opts.email, opts.displayName, opts.teamName) : await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
10103
10243
  writeToken(opts.label, reg.raw_token);
10244
+ await this.saveConnection({
10245
+ team_id: reg.team.id,
10246
+ team_name: reg.team.name,
10247
+ cloud_url: opts.cloudUrl,
10248
+ my_user_id: reg.user.id,
10249
+ api_token: reg.raw_token
10250
+ });
10104
10251
  return reg;
10105
10252
  }
10106
- // ── Cloud HTTP calls (authenticated) ────────────────────────────────────
10253
+ // ── Cloud team operations (dispatch on URL scheme) ──────────────────────
10254
+ // For HTTP cloud URLs (`http://lattice-server:port`), every operation
10255
+ // round-trips through the team server's authenticated REST API. For
10256
+ // direct-Postgres cloud URLs (`postgres://...`), the user's `this.local`
10257
+ // Lattice IS the cloud DB — operations dispatch through `direct-ops.ts`
10258
+ // helpers that run the same INSERT / UPDATE / DELETE / SELECT logic
10259
+ // against `this.local` directly. The Fetch API can't handle
10260
+ // credentials-in-URL anyway, so the dispatch isn't optional.
10261
+ //
10262
+ // Authorization model: for HTTP clouds, the server gates by bearer
10263
+ // token + membership row. For direct-Postgres, possession of the
10264
+ // connection credential is the implicit gate — the operator is
10265
+ // already reading/writing the canonical data.
10107
10266
  /** Destroy the singleton team. Creator-only on the cloud side. */
10108
10267
  async destroyTeam(cloudUrl, token) {
10268
+ if (isPostgresUrl(cloudUrl)) {
10269
+ await destroyTeamDirect(this.local);
10270
+ return;
10271
+ }
10109
10272
  await this.fetchAuthed(cloudUrl, token, "DELETE", "/api/team");
10110
10273
  }
10111
10274
  async listMembers(cloudUrl, token, teamId) {
10275
+ if (isPostgresUrl(cloudUrl)) {
10276
+ return listMembersDirect(this.local, teamId);
10277
+ }
10112
10278
  const r = await this.fetchAuthed(
10113
10279
  cloudUrl,
10114
10280
  token,
@@ -10117,7 +10283,15 @@ var TeamsClient = class {
10117
10283
  );
10118
10284
  return r.members;
10119
10285
  }
10120
- async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours) {
10286
+ async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours, inviterUserId) {
10287
+ if (isPostgresUrl(cloudUrl)) {
10288
+ if (!inviterUserId) {
10289
+ throw new Error(
10290
+ "invite: inviterUserId is required for direct-Postgres cloud URLs (read it from __lattice_team_connections.my_user_id)"
10291
+ );
10292
+ }
10293
+ return inviteDirect(this.local, teamId, inviterUserId, inviteeEmail, expiresInHours);
10294
+ }
10121
10295
  const body = { invitee_email: inviteeEmail };
10122
10296
  if (expiresInHours !== void 0) body.expires_in_hours = expiresInHours;
10123
10297
  return this.fetchAuthed(
@@ -10129,6 +10303,10 @@ var TeamsClient = class {
10129
10303
  );
10130
10304
  }
10131
10305
  async kickMember(cloudUrl, token, teamId, userId) {
10306
+ if (isPostgresUrl(cloudUrl)) {
10307
+ await kickMemberDirect(this.local, teamId, userId);
10308
+ return;
10309
+ }
10132
10310
  await this.fetchAuthed(
10133
10311
  cloudUrl,
10134
10312
  token,
@@ -10182,6 +10360,9 @@ var TeamsClient = class {
10182
10360
  * `has_more` flag tells callers to loop until drained before sleeping.
10183
10361
  */
10184
10362
  async fetchChangeBatch(cloudUrl, token, teamId, since = 0, limit = 500) {
10363
+ if (isPostgresUrl(cloudUrl)) {
10364
+ return { envelopes: [], has_more: false };
10365
+ }
10185
10366
  return this.fetchAuthed(
10186
10367
  cloudUrl,
10187
10368
  token,
@@ -10789,7 +10970,8 @@ async function dispatchTeamSubroute(req, res, ctx, teamId, subpath) {
10789
10970
  conn.api_token,
10790
10971
  teamId,
10791
10972
  inviteeEmail,
10792
- hours
10973
+ hours,
10974
+ conn.my_user_id
10793
10975
  );
10794
10976
  sendJson2(res, invite);
10795
10977
  return;
@@ -12282,23 +12464,23 @@ async function startGuiServer(options) {
12282
12464
  return;
12283
12465
  }
12284
12466
  if (method === "GET" && pathname === "/api/system-tables") {
12285
- const rows = await (async () => {
12286
- const adapter = active.db._adapter;
12287
- if (!adapter.allAsync) return [];
12288
- return adapter.allAsync(
12289
- `SELECT name FROM sqlite_master
12290
- WHERE type='table' AND name LIKE '\\_%' ESCAPE '\\'
12291
- ORDER BY name`
12292
- );
12293
- })();
12467
+ const adapter = active.db._adapter;
12468
+ let rows = [];
12469
+ if (adapter.allAsync) {
12470
+ const listSql = adapter.dialect === "postgres" ? (
12471
+ // pg_tables is the public-schema-only counterpart to
12472
+ // sqlite_master. We filter to public + the same `\_%`
12473
+ // ESCAPE pattern so the result is identical to SQLite.
12474
+ // Underscore is a LIKE wildcard in both engines.
12475
+ `SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE '\\_%' ESCAPE '\\' ORDER BY tablename`
12476
+ ) : `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '\\_%' ESCAPE '\\' ORDER BY name`;
12477
+ rows = await adapter.allAsync(listSql);
12478
+ }
12294
12479
  const tables = [];
12295
12480
  for (const r of rows) {
12296
- const cols = await (async () => {
12297
- const adapter = active.db._adapter;
12298
- return adapter.allAsync?.(`PRAGMA table_info("${r.name}")`) ?? Promise.resolve([]);
12299
- })();
12481
+ const cols = await active.db.introspectColumns(r.name);
12300
12482
  const rowCount = await active.db.count(r.name);
12301
- tables.push({ name: r.name, columns: cols.map((c) => c.name), rowCount });
12483
+ tables.push({ name: r.name, columns: cols, rowCount });
12302
12484
  }
12303
12485
  sendJson5(res, { tables });
12304
12486
  return;
package/dist/index.cjs CHANGED
@@ -5891,7 +5891,9 @@ async function probeCloud(targetUrl) {
5891
5891
  // src/teams/server/auth.ts
5892
5892
  var import_node_crypto8 = require("crypto");
5893
5893
  var TOKEN_PREFIX = "lat_";
5894
+ var INVITE_PREFIX = "latinv_";
5894
5895
  var TOKEN_BYTES = 32;
5896
+ var INVITE_BYTES = 24;
5895
5897
  function hashToken(rawToken) {
5896
5898
  return (0, import_node_crypto8.createHash)("sha256").update(rawToken).digest("hex");
5897
5899
  }
@@ -5899,6 +5901,10 @@ function generateToken() {
5899
5901
  const raw = `${TOKEN_PREFIX}${(0, import_node_crypto8.randomBytes)(TOKEN_BYTES).toString("hex")}`;
5900
5902
  return { raw, hash: hashToken(raw) };
5901
5903
  }
5904
+ function generateInviteToken() {
5905
+ const raw = `${INVITE_PREFIX}${(0, import_node_crypto8.randomBytes)(INVITE_BYTES).toString("hex")}`;
5906
+ return { raw, hash: hashToken(raw) };
5907
+ }
5902
5908
 
5903
5909
  // src/teams/register-direct.ts
5904
5910
  function isPostgresUrl(url) {
@@ -5980,6 +5986,74 @@ async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
5980
5986
  }
5981
5987
  }
5982
5988
 
5989
+ // src/teams/direct-ops.ts
5990
+ async function listMembersDirect(db, teamId) {
5991
+ const members = await db.query("__lattice_team_members", {
5992
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
5993
+ });
5994
+ if (members.length === 0) return [];
5995
+ const users = await db.query("__lattice_users", {
5996
+ filters: [
5997
+ { col: "id", op: "in", val: members.map((m) => m.user_id) },
5998
+ { col: "deleted_at", op: "isNull" }
5999
+ ]
6000
+ });
6001
+ const userById = new Map(users.map((u) => [u.id, u]));
6002
+ const out = [];
6003
+ for (const m of members) {
6004
+ const u = userById.get(m.user_id);
6005
+ if (!u) continue;
6006
+ out.push({
6007
+ user_id: m.user_id,
6008
+ email: u.email,
6009
+ name: u.name,
6010
+ role: m.role,
6011
+ joined_at: m.joined_at
6012
+ });
6013
+ }
6014
+ return out;
6015
+ }
6016
+ async function inviteDirect(db, teamId, inviterUserId, inviteeEmail, expiresInHours = 7 * 24) {
6017
+ const team = await db.get("__lattice_team", teamId);
6018
+ if (!team || team.deleted_at) {
6019
+ throw new Error(`Team not found: ${teamId}`);
6020
+ }
6021
+ const expiresAt = new Date(Date.now() + expiresInHours * 36e5).toISOString();
6022
+ const { raw, hash } = generateInviteToken();
6023
+ const id = await db.insert("__lattice_invitations", {
6024
+ team_id: teamId,
6025
+ token_hash: hash,
6026
+ invitee_email: inviteeEmail,
6027
+ invited_by_user_id: inviterUserId,
6028
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
6029
+ expires_at: expiresAt
6030
+ });
6031
+ return {
6032
+ id,
6033
+ raw_token: raw,
6034
+ expires_at: expiresAt,
6035
+ team_name: team.name,
6036
+ invitee_email: inviteeEmail
6037
+ };
6038
+ }
6039
+ async function kickMemberDirect(db, teamId, userId) {
6040
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: userId });
6041
+ }
6042
+ async function destroyTeamDirect(db) {
6043
+ const identityRow = await db.get("__lattice_team_identity", "singleton");
6044
+ if (identityRow && typeof identityRow.team_id === "string") {
6045
+ const teamId = identityRow.team_id;
6046
+ const members = await db.query("__lattice_team_members", {
6047
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
6048
+ });
6049
+ for (const m of members) {
6050
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: m.user_id });
6051
+ }
6052
+ await db.update("__lattice_team", teamId, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
6053
+ }
6054
+ await db.delete("__lattice_team_identity", "singleton");
6055
+ }
6056
+
5983
6057
  // src/teams/client.ts
5984
6058
  var TeamsClient = class {
5985
6059
  constructor(local) {
@@ -6066,6 +6140,13 @@ var TeamsClient = class {
6066
6140
  const redeem = await this.redeemInvite(opts.cloudUrl, inviteToken, email, name);
6067
6141
  saveDbCredential(opts.label, opts.cloudUrl);
6068
6142
  writeToken(opts.label, redeem.raw_token);
6143
+ await this.saveConnection({
6144
+ team_id: redeem.team.id,
6145
+ team_name: redeem.team.name,
6146
+ cloud_url: opts.cloudUrl,
6147
+ my_user_id: redeem.user.id,
6148
+ api_token: redeem.raw_token
6149
+ });
6069
6150
  return {
6070
6151
  probe,
6071
6152
  joinedAsMember: { user_id: redeem.user.id, team_id: redeem.team.id }
@@ -6085,21 +6166,51 @@ var TeamsClient = class {
6085
6166
  * The HTTP path can't be used here because the browser's Fetch
6086
6167
  * API refuses URLs with embedded credentials.
6087
6168
  *
6088
- * On success writes the bearer token to `~/.lattice/keys/<label>.token`.
6089
- * Caller is expected to call `saveConnection` if they also want the
6090
- * local `__lattice_team_connections` row populated.
6169
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
6170
+ * **and** persists the local `__lattice_team_connections` row so the
6171
+ * GUI's team-management API calls can authenticate immediately
6172
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
6173
+ * connection-row write — the older v1.13 implementation only wrote
6174
+ * the token file, leaving GUI authenticated calls with no
6175
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
6091
6176
  */
6092
6177
  async upgradeToTeamCloud(opts) {
6093
6178
  const reg = isPostgresUrl(opts.cloudUrl) ? await registerDirectViaPostgres(opts.cloudUrl, opts.email, opts.displayName, opts.teamName) : await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
6094
6179
  writeToken(opts.label, reg.raw_token);
6180
+ await this.saveConnection({
6181
+ team_id: reg.team.id,
6182
+ team_name: reg.team.name,
6183
+ cloud_url: opts.cloudUrl,
6184
+ my_user_id: reg.user.id,
6185
+ api_token: reg.raw_token
6186
+ });
6095
6187
  return reg;
6096
6188
  }
6097
- // ── Cloud HTTP calls (authenticated) ────────────────────────────────────
6189
+ // ── Cloud team operations (dispatch on URL scheme) ──────────────────────
6190
+ // For HTTP cloud URLs (`http://lattice-server:port`), every operation
6191
+ // round-trips through the team server's authenticated REST API. For
6192
+ // direct-Postgres cloud URLs (`postgres://...`), the user's `this.local`
6193
+ // Lattice IS the cloud DB — operations dispatch through `direct-ops.ts`
6194
+ // helpers that run the same INSERT / UPDATE / DELETE / SELECT logic
6195
+ // against `this.local` directly. The Fetch API can't handle
6196
+ // credentials-in-URL anyway, so the dispatch isn't optional.
6197
+ //
6198
+ // Authorization model: for HTTP clouds, the server gates by bearer
6199
+ // token + membership row. For direct-Postgres, possession of the
6200
+ // connection credential is the implicit gate — the operator is
6201
+ // already reading/writing the canonical data.
6098
6202
  /** Destroy the singleton team. Creator-only on the cloud side. */
6099
6203
  async destroyTeam(cloudUrl, token) {
6204
+ if (isPostgresUrl(cloudUrl)) {
6205
+ await destroyTeamDirect(this.local);
6206
+ return;
6207
+ }
6100
6208
  await this.fetchAuthed(cloudUrl, token, "DELETE", "/api/team");
6101
6209
  }
6102
6210
  async listMembers(cloudUrl, token, teamId) {
6211
+ if (isPostgresUrl(cloudUrl)) {
6212
+ return listMembersDirect(this.local, teamId);
6213
+ }
6103
6214
  const r = await this.fetchAuthed(
6104
6215
  cloudUrl,
6105
6216
  token,
@@ -6108,7 +6219,15 @@ var TeamsClient = class {
6108
6219
  );
6109
6220
  return r.members;
6110
6221
  }
6111
- async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours) {
6222
+ async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours, inviterUserId) {
6223
+ if (isPostgresUrl(cloudUrl)) {
6224
+ if (!inviterUserId) {
6225
+ throw new Error(
6226
+ "invite: inviterUserId is required for direct-Postgres cloud URLs (read it from __lattice_team_connections.my_user_id)"
6227
+ );
6228
+ }
6229
+ return inviteDirect(this.local, teamId, inviterUserId, inviteeEmail, expiresInHours);
6230
+ }
6112
6231
  const body = { invitee_email: inviteeEmail };
6113
6232
  if (expiresInHours !== void 0) body.expires_in_hours = expiresInHours;
6114
6233
  return this.fetchAuthed(
@@ -6120,6 +6239,10 @@ var TeamsClient = class {
6120
6239
  );
6121
6240
  }
6122
6241
  async kickMember(cloudUrl, token, teamId, userId) {
6242
+ if (isPostgresUrl(cloudUrl)) {
6243
+ await kickMemberDirect(this.local, teamId, userId);
6244
+ return;
6245
+ }
6123
6246
  await this.fetchAuthed(
6124
6247
  cloudUrl,
6125
6248
  token,
@@ -6173,6 +6296,9 @@ var TeamsClient = class {
6173
6296
  * `has_more` flag tells callers to loop until drained before sleeping.
6174
6297
  */
6175
6298
  async fetchChangeBatch(cloudUrl, token, teamId, since = 0, limit = 500) {
6299
+ if (isPostgresUrl(cloudUrl)) {
6300
+ return { envelopes: [], has_more: false };
6301
+ }
6176
6302
  return this.fetchAuthed(
6177
6303
  cloudUrl,
6178
6304
  token,
package/dist/index.d.cts CHANGED
@@ -3027,9 +3027,13 @@ declare class TeamsClient {
3027
3027
  * The HTTP path can't be used here because the browser's Fetch
3028
3028
  * API refuses URLs with embedded credentials.
3029
3029
  *
3030
- * On success writes the bearer token to `~/.lattice/keys/<label>.token`.
3031
- * Caller is expected to call `saveConnection` if they also want the
3032
- * local `__lattice_team_connections` row populated.
3030
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
3031
+ * **and** persists the local `__lattice_team_connections` row so the
3032
+ * GUI's team-management API calls can authenticate immediately
3033
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
3034
+ * connection-row write — the older v1.13 implementation only wrote
3035
+ * the token file, leaving GUI authenticated calls with no
3036
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
3033
3037
  */
3034
3038
  upgradeToTeamCloud(opts: {
3035
3039
  label: string;
@@ -3041,7 +3045,7 @@ declare class TeamsClient {
3041
3045
  /** Destroy the singleton team. Creator-only on the cloud side. */
3042
3046
  destroyTeam(cloudUrl: string, token: string): Promise<void>;
3043
3047
  listMembers(cloudUrl: string, token: string, teamId: string): Promise<MemberSummary[]>;
3044
- invite(cloudUrl: string, token: string, teamId: string, inviteeEmail: string, expiresInHours?: number): Promise<InviteResponse>;
3048
+ invite(cloudUrl: string, token: string, teamId: string, inviteeEmail: string, expiresInHours?: number, inviterUserId?: string): Promise<InviteResponse>;
3045
3049
  kickMember(cloudUrl: string, token: string, teamId: string, userId: string): Promise<void>;
3046
3050
  me(cloudUrl: string, token: string): Promise<{
3047
3051
  user: {
package/dist/index.d.ts CHANGED
@@ -3027,9 +3027,13 @@ declare class TeamsClient {
3027
3027
  * The HTTP path can't be used here because the browser's Fetch
3028
3028
  * API refuses URLs with embedded credentials.
3029
3029
  *
3030
- * On success writes the bearer token to `~/.lattice/keys/<label>.token`.
3031
- * Caller is expected to call `saveConnection` if they also want the
3032
- * local `__lattice_team_connections` row populated.
3030
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
3031
+ * **and** persists the local `__lattice_team_connections` row so the
3032
+ * GUI's team-management API calls can authenticate immediately
3033
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
3034
+ * connection-row write — the older v1.13 implementation only wrote
3035
+ * the token file, leaving GUI authenticated calls with no
3036
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
3033
3037
  */
3034
3038
  upgradeToTeamCloud(opts: {
3035
3039
  label: string;
@@ -3041,7 +3045,7 @@ declare class TeamsClient {
3041
3045
  /** Destroy the singleton team. Creator-only on the cloud side. */
3042
3046
  destroyTeam(cloudUrl: string, token: string): Promise<void>;
3043
3047
  listMembers(cloudUrl: string, token: string, teamId: string): Promise<MemberSummary[]>;
3044
- invite(cloudUrl: string, token: string, teamId: string, inviteeEmail: string, expiresInHours?: number): Promise<InviteResponse>;
3048
+ invite(cloudUrl: string, token: string, teamId: string, inviteeEmail: string, expiresInHours?: number, inviterUserId?: string): Promise<InviteResponse>;
3045
3049
  kickMember(cloudUrl: string, token: string, teamId: string, userId: string): Promise<void>;
3046
3050
  me(cloudUrl: string, token: string): Promise<{
3047
3051
  user: {
package/dist/index.js CHANGED
@@ -5817,7 +5817,9 @@ async function probeCloud(targetUrl) {
5817
5817
  // src/teams/server/auth.ts
5818
5818
  import { createHash as createHash5, randomBytes as randomBytes4, timingSafeEqual } from "crypto";
5819
5819
  var TOKEN_PREFIX = "lat_";
5820
+ var INVITE_PREFIX = "latinv_";
5820
5821
  var TOKEN_BYTES = 32;
5822
+ var INVITE_BYTES = 24;
5821
5823
  function hashToken(rawToken) {
5822
5824
  return createHash5("sha256").update(rawToken).digest("hex");
5823
5825
  }
@@ -5825,6 +5827,10 @@ function generateToken() {
5825
5827
  const raw = `${TOKEN_PREFIX}${randomBytes4(TOKEN_BYTES).toString("hex")}`;
5826
5828
  return { raw, hash: hashToken(raw) };
5827
5829
  }
5830
+ function generateInviteToken() {
5831
+ const raw = `${INVITE_PREFIX}${randomBytes4(INVITE_BYTES).toString("hex")}`;
5832
+ return { raw, hash: hashToken(raw) };
5833
+ }
5828
5834
 
5829
5835
  // src/teams/register-direct.ts
5830
5836
  function isPostgresUrl(url) {
@@ -5906,6 +5912,74 @@ async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
5906
5912
  }
5907
5913
  }
5908
5914
 
5915
+ // src/teams/direct-ops.ts
5916
+ async function listMembersDirect(db, teamId) {
5917
+ const members = await db.query("__lattice_team_members", {
5918
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
5919
+ });
5920
+ if (members.length === 0) return [];
5921
+ const users = await db.query("__lattice_users", {
5922
+ filters: [
5923
+ { col: "id", op: "in", val: members.map((m) => m.user_id) },
5924
+ { col: "deleted_at", op: "isNull" }
5925
+ ]
5926
+ });
5927
+ const userById = new Map(users.map((u) => [u.id, u]));
5928
+ const out = [];
5929
+ for (const m of members) {
5930
+ const u = userById.get(m.user_id);
5931
+ if (!u) continue;
5932
+ out.push({
5933
+ user_id: m.user_id,
5934
+ email: u.email,
5935
+ name: u.name,
5936
+ role: m.role,
5937
+ joined_at: m.joined_at
5938
+ });
5939
+ }
5940
+ return out;
5941
+ }
5942
+ async function inviteDirect(db, teamId, inviterUserId, inviteeEmail, expiresInHours = 7 * 24) {
5943
+ const team = await db.get("__lattice_team", teamId);
5944
+ if (!team || team.deleted_at) {
5945
+ throw new Error(`Team not found: ${teamId}`);
5946
+ }
5947
+ const expiresAt = new Date(Date.now() + expiresInHours * 36e5).toISOString();
5948
+ const { raw, hash } = generateInviteToken();
5949
+ const id = await db.insert("__lattice_invitations", {
5950
+ team_id: teamId,
5951
+ token_hash: hash,
5952
+ invitee_email: inviteeEmail,
5953
+ invited_by_user_id: inviterUserId,
5954
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
5955
+ expires_at: expiresAt
5956
+ });
5957
+ return {
5958
+ id,
5959
+ raw_token: raw,
5960
+ expires_at: expiresAt,
5961
+ team_name: team.name,
5962
+ invitee_email: inviteeEmail
5963
+ };
5964
+ }
5965
+ async function kickMemberDirect(db, teamId, userId) {
5966
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: userId });
5967
+ }
5968
+ async function destroyTeamDirect(db) {
5969
+ const identityRow = await db.get("__lattice_team_identity", "singleton");
5970
+ if (identityRow && typeof identityRow.team_id === "string") {
5971
+ const teamId = identityRow.team_id;
5972
+ const members = await db.query("__lattice_team_members", {
5973
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
5974
+ });
5975
+ for (const m of members) {
5976
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: m.user_id });
5977
+ }
5978
+ await db.update("__lattice_team", teamId, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
5979
+ }
5980
+ await db.delete("__lattice_team_identity", "singleton");
5981
+ }
5982
+
5909
5983
  // src/teams/client.ts
5910
5984
  var TeamsClient = class {
5911
5985
  constructor(local) {
@@ -5992,6 +6066,13 @@ var TeamsClient = class {
5992
6066
  const redeem = await this.redeemInvite(opts.cloudUrl, inviteToken, email, name);
5993
6067
  saveDbCredential(opts.label, opts.cloudUrl);
5994
6068
  writeToken(opts.label, redeem.raw_token);
6069
+ await this.saveConnection({
6070
+ team_id: redeem.team.id,
6071
+ team_name: redeem.team.name,
6072
+ cloud_url: opts.cloudUrl,
6073
+ my_user_id: redeem.user.id,
6074
+ api_token: redeem.raw_token
6075
+ });
5995
6076
  return {
5996
6077
  probe,
5997
6078
  joinedAsMember: { user_id: redeem.user.id, team_id: redeem.team.id }
@@ -6011,21 +6092,51 @@ var TeamsClient = class {
6011
6092
  * The HTTP path can't be used here because the browser's Fetch
6012
6093
  * API refuses URLs with embedded credentials.
6013
6094
  *
6014
- * On success writes the bearer token to `~/.lattice/keys/<label>.token`.
6015
- * Caller is expected to call `saveConnection` if they also want the
6016
- * local `__lattice_team_connections` row populated.
6095
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
6096
+ * **and** persists the local `__lattice_team_connections` row so the
6097
+ * GUI's team-management API calls can authenticate immediately
6098
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
6099
+ * connection-row write — the older v1.13 implementation only wrote
6100
+ * the token file, leaving GUI authenticated calls with no
6101
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
6017
6102
  */
6018
6103
  async upgradeToTeamCloud(opts) {
6019
6104
  const reg = isPostgresUrl(opts.cloudUrl) ? await registerDirectViaPostgres(opts.cloudUrl, opts.email, opts.displayName, opts.teamName) : await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
6020
6105
  writeToken(opts.label, reg.raw_token);
6106
+ await this.saveConnection({
6107
+ team_id: reg.team.id,
6108
+ team_name: reg.team.name,
6109
+ cloud_url: opts.cloudUrl,
6110
+ my_user_id: reg.user.id,
6111
+ api_token: reg.raw_token
6112
+ });
6021
6113
  return reg;
6022
6114
  }
6023
- // ── Cloud HTTP calls (authenticated) ────────────────────────────────────
6115
+ // ── Cloud team operations (dispatch on URL scheme) ──────────────────────
6116
+ // For HTTP cloud URLs (`http://lattice-server:port`), every operation
6117
+ // round-trips through the team server's authenticated REST API. For
6118
+ // direct-Postgres cloud URLs (`postgres://...`), the user's `this.local`
6119
+ // Lattice IS the cloud DB — operations dispatch through `direct-ops.ts`
6120
+ // helpers that run the same INSERT / UPDATE / DELETE / SELECT logic
6121
+ // against `this.local` directly. The Fetch API can't handle
6122
+ // credentials-in-URL anyway, so the dispatch isn't optional.
6123
+ //
6124
+ // Authorization model: for HTTP clouds, the server gates by bearer
6125
+ // token + membership row. For direct-Postgres, possession of the
6126
+ // connection credential is the implicit gate — the operator is
6127
+ // already reading/writing the canonical data.
6024
6128
  /** Destroy the singleton team. Creator-only on the cloud side. */
6025
6129
  async destroyTeam(cloudUrl, token) {
6130
+ if (isPostgresUrl(cloudUrl)) {
6131
+ await destroyTeamDirect(this.local);
6132
+ return;
6133
+ }
6026
6134
  await this.fetchAuthed(cloudUrl, token, "DELETE", "/api/team");
6027
6135
  }
6028
6136
  async listMembers(cloudUrl, token, teamId) {
6137
+ if (isPostgresUrl(cloudUrl)) {
6138
+ return listMembersDirect(this.local, teamId);
6139
+ }
6029
6140
  const r = await this.fetchAuthed(
6030
6141
  cloudUrl,
6031
6142
  token,
@@ -6034,7 +6145,15 @@ var TeamsClient = class {
6034
6145
  );
6035
6146
  return r.members;
6036
6147
  }
6037
- async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours) {
6148
+ async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours, inviterUserId) {
6149
+ if (isPostgresUrl(cloudUrl)) {
6150
+ if (!inviterUserId) {
6151
+ throw new Error(
6152
+ "invite: inviterUserId is required for direct-Postgres cloud URLs (read it from __lattice_team_connections.my_user_id)"
6153
+ );
6154
+ }
6155
+ return inviteDirect(this.local, teamId, inviterUserId, inviteeEmail, expiresInHours);
6156
+ }
6038
6157
  const body = { invitee_email: inviteeEmail };
6039
6158
  if (expiresInHours !== void 0) body.expires_in_hours = expiresInHours;
6040
6159
  return this.fetchAuthed(
@@ -6046,6 +6165,10 @@ var TeamsClient = class {
6046
6165
  );
6047
6166
  }
6048
6167
  async kickMember(cloudUrl, token, teamId, userId) {
6168
+ if (isPostgresUrl(cloudUrl)) {
6169
+ await kickMemberDirect(this.local, teamId, userId);
6170
+ return;
6171
+ }
6049
6172
  await this.fetchAuthed(
6050
6173
  cloudUrl,
6051
6174
  token,
@@ -6099,6 +6222,9 @@ var TeamsClient = class {
6099
6222
  * `has_more` flag tells callers to loop until drained before sleeping.
6100
6223
  */
6101
6224
  async fetchChangeBatch(cloudUrl, token, teamId, since = 0, limit = 500) {
6225
+ if (isPostgresUrl(cloudUrl)) {
6226
+ return { envelopes: [], has_more: false };
6227
+ }
6102
6228
  return this.fetchAuthed(
6103
6229
  cloudUrl,
6104
6230
  token,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "1.13.3",
3
+ "version": "1.13.4",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",