latticesql 1.13.3 → 1.13.5

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);
@@ -7589,8 +7613,10 @@ var guiAppHtml = `<!doctype html>
7589
7613
  // enters per-team things (cloud URL + team name) in this modal.
7590
7614
  fetchJson('/api/userconfig/identity').then(function (id) {
7591
7615
  var bodyHtml =
7592
- '<div class="field"><label>Cloud URL</label><input name="cloud_url" placeholder="http://localhost:4317" /></div>' +
7593
- '<div class="field"><label>Your email</label><input name="email" value="' + escapeHtml(id.email || '') + '" /></div>' +
7616
+ '<div class="field"><label>Cloud URL</label>' +
7617
+ '<input name="cloud_url" placeholder="postgres://postgres.&lt;ref&gt;:password@aws-x-region.pooler.supabase.com:5432/postgres" autocapitalize="off" autocorrect="off" spellcheck="false" />' +
7618
+ '</div>' +
7619
+ '<div class="field"><label>Your email</label><input name="email" value="' + escapeHtml(id.email || '') + '" autocapitalize="off" /></div>' +
7594
7620
  '<div class="field"><label>Your display name</label><input name="user_name" value="' + escapeHtml(id.display_name || '') + '" /></div>' +
7595
7621
  '<div class="field"><label>Team name</label><input name="team_name" /></div>' +
7596
7622
  '<p style="font-size:12px;color:var(--text-muted);margin:0">' +
@@ -7614,12 +7640,14 @@ var guiAppHtml = `<!doctype html>
7614
7640
  function showJoinTeamModal(kind) {
7615
7641
  fetchJson('/api/userconfig/identity').then(function (id) {
7616
7642
  var bodyHtml =
7617
- '<div class="field"><label>Cloud URL</label><input name="cloud_url" placeholder="http://localhost:4317" /></div>' +
7618
- '<div class="field"><label>Invite token</label><textarea name="invite_token" placeholder="latinv_..."></textarea></div>' +
7619
- '<div class="field"><label>Your email</label><input name="email" value="' + escapeHtml(id.email || '') + '" /></div>' +
7643
+ '<div class="field"><label>Cloud URL</label>' +
7644
+ '<input name="cloud_url" placeholder="postgres://postgres.&lt;ref&gt;:password@aws-x-region.pooler.supabase.com:5432/postgres" autocapitalize="off" autocorrect="off" spellcheck="false" />' +
7645
+ '</div>' +
7646
+ '<div class="field"><label>Invite token</label><textarea name="invite_token" placeholder="latinv_..." autocapitalize="off" autocorrect="off" spellcheck="false"></textarea></div>' +
7647
+ '<div class="field"><label>Your email</label><input name="email" value="' + escapeHtml(id.email || '') + '" autocapitalize="off" /></div>' +
7620
7648
  '<div class="field"><label>Your display name</label><input name="name" value="' + escapeHtml(id.display_name || '') + '" /></div>' +
7621
7649
  '<p style="font-size:12px;color:var(--text-muted);margin:0">' +
7622
- 'Email must match the address the invitation was addressed to.' +
7650
+ 'Use the same Postgres URL the inviter used (postgres://\u2026). Email must match the address the invitation was addressed to.' +
7623
7651
  '</p>';
7624
7652
  showModal('Join team', bodyHtml, {
7625
7653
  primaryLabel: 'Join',
@@ -7759,7 +7787,7 @@ var guiAppHtml = `<!doctype html>
7759
7787
  '<button class="btn danger-btn" data-act="signout">Sign out</button>' +
7760
7788
  '</h3>' +
7761
7789
  '<div class="team-meta">' +
7762
- 'Cloud: <code>' + escapeHtml(c.cloud_url) + '</code> \xB7 ' +
7790
+ 'Cloud: <code>' + escapeHtml(redactUrlCredentials(c.cloud_url)) + '</code> \xB7 ' +
7763
7791
  'User id: <code>' + escapeHtml(c.my_user_id) + '</code> \xB7 ' +
7764
7792
  'Joined ' + escapeHtml(c.joined_at) +
7765
7793
  '</div>' +
@@ -8281,37 +8309,76 @@ var guiAppHtml = `<!doctype html>
8281
8309
  }
8282
8310
 
8283
8311
  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).
8312
+ // Fetch status + shared + members in parallel. The members fetch
8313
+ // can fail two ways: HTTP 403 for non-creators (only members can
8314
+ // list \u2014 though in practice every active member can), or the
8315
+ // cloud is genuinely unreachable. We track which case we're in so
8316
+ // the role pill says something more useful than "unknown".
8286
8317
  var teamId = conn.team_id;
8318
+ var membersFailed = false;
8287
8319
  Promise.all([
8288
8320
  fetchJson('/api/teams-gui/teams/' + teamId + '/status'),
8289
8321
  fetchJson('/api/teams-gui/teams/' + teamId + '/shared').catch(function () { return { objects: [] }; }),
8290
- fetchJson('/api/teams-gui/teams/' + teamId + '/members').catch(function () { return { members: [] }; }),
8322
+ fetchJson('/api/teams-gui/teams/' + teamId + '/members').catch(function () {
8323
+ membersFailed = true;
8324
+ return { members: [] };
8325
+ }),
8291
8326
  ]).then(function (results) {
8292
8327
  var status = results[0];
8293
8328
  var shared = results[1].objects;
8294
8329
  var members = results[2].members;
8295
8330
  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;
8331
+ // Resolve the role label with a three-step fallback chain that
8332
+ // distinguishes "we know what the role is" from "we couldn't ask
8333
+ // the cloud" from "the cloud answered but didn't recognize us".
8334
+ // The pre-v1.13.4 implementation collapsed all three into
8335
+ // "unknown" \u2014 confusing when (a) the user just created the
8336
+ // team and IS the creator on the cloud side, or (b) the cloud
8337
+ // briefly hiccupped. Use saveConnection's own data as the
8338
+ // authoritative source when we have it on the local row.
8339
+ var roleLabel;
8340
+ var isCreator;
8341
+ if (myMembership) {
8342
+ roleLabel = myMembership.role;
8343
+ isCreator = myMembership.role === 'creator';
8344
+ } else if (membersFailed) {
8345
+ // Cloud listMembers couldn't be reached. We don't know who
8346
+ // we are remotely, but the local connection row knows we
8347
+ // joined this team \u2014 surface that, with a soft warning.
8348
+ roleLabel = '(cloud unreachable)';
8349
+ isCreator = false;
8350
+ } else {
8351
+ // listMembers returned a list, but our user_id wasn't in it.
8352
+ // Either we were kicked or the local my_user_id is stale.
8353
+ roleLabel = '(not in member list)';
8354
+ isCreator = false;
8355
+ }
8356
+ var rolePill = '<span class="role-tag' + (isCreator ? '' : ' role-member') + '">' +
8357
+ escapeHtml(roleLabel) +
8358
+ '</span>';
8359
+
8360
+ // v1.13.4: no manual-sync button and no Last seq / Outbox / DLQ /
8361
+ // Local links stats. Lattice is realtime against its canonical
8362
+ // store \u2014 every read and every write the GUI does hits the
8363
+ // active DB directly. When the project's db: line points at a
8364
+ // Postgres URL, that IS the cloud DB; when it's local SQLite,
8365
+ // it's the canonical local. There's nothing for the user to
8366
+ // "sync" \u2014 operations either succeed live or fail gracefully
8367
+ // when the connection is down. The outbox/change-log machinery
8368
+ // is an HTTP-mode-only internal that the CLI still exposes
8369
+ // (lattice teams sync) for power users; the GUI no longer
8370
+ // pretends it's a user-facing action.
8371
+ // Reference status once so eslint no-unused-vars stays happy
8372
+ // even though we've intentionally dropped its display.
8373
+ void status;
8300
8374
  card.innerHTML =
8301
8375
  '<h3>' + escapeHtml(conn.team_name) + ' ' + rolePill +
8302
- '<span style="font-size:11px;color:var(--text-muted);font-weight:normal">' + escapeHtml(conn.cloud_url) + '</span>' +
8376
+ '<span style="font-size:11px;color:var(--text-muted);font-weight:normal">' + escapeHtml(redactUrlCredentials(conn.cloud_url)) + '</span>' +
8303
8377
  '</h3>' +
8304
8378
  '<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
8379
  '<div class="team-actions">' +
8312
- '<button class="btn primary" data-act="sync">Sync now</button>' +
8313
8380
  (isCreator
8314
- ? '<button class="btn" data-act="invite">Generate invite token</button>'
8381
+ ? '<button class="btn primary" data-act="invite">Generate invite token</button>'
8315
8382
  : '') +
8316
8383
  '<button class="btn" data-act="leave">' + (isCreator ? 'Destroy team' : 'Leave team') + '</button>' +
8317
8384
  '</div>' +
@@ -8359,11 +8426,9 @@ var guiAppHtml = `<!doctype html>
8359
8426
 
8360
8427
  function wireTeamCardActions(card, conn, isCreator) {
8361
8428
  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
- });
8429
+ // v1.13.4: no Sync button anymore. The CLI command lattice teams
8430
+ // sync remains for HTTP-mode operators who need to nudge their
8431
+ // outbox; the GUI is realtime against the canonical store.
8367
8432
  var inviteBtn = card.querySelector('[data-act="invite"]');
8368
8433
  if (inviteBtn) inviteBtn.addEventListener('click', function () {
8369
8434
  showInviteByEmailModal(teamId);
@@ -9989,6 +10054,143 @@ async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
9989
10054
  }
9990
10055
  }
9991
10056
 
10057
+ // src/teams/direct-ops.ts
10058
+ async function listMembersDirect(db, teamId) {
10059
+ const members = await db.query("__lattice_team_members", {
10060
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
10061
+ });
10062
+ if (members.length === 0) return [];
10063
+ const users = await db.query("__lattice_users", {
10064
+ filters: [
10065
+ { col: "id", op: "in", val: members.map((m) => m.user_id) },
10066
+ { col: "deleted_at", op: "isNull" }
10067
+ ]
10068
+ });
10069
+ const userById = new Map(users.map((u) => [u.id, u]));
10070
+ const out = [];
10071
+ for (const m of members) {
10072
+ const u = userById.get(m.user_id);
10073
+ if (!u) continue;
10074
+ out.push({
10075
+ user_id: m.user_id,
10076
+ email: u.email,
10077
+ name: u.name,
10078
+ role: m.role,
10079
+ joined_at: m.joined_at
10080
+ });
10081
+ }
10082
+ return out;
10083
+ }
10084
+ async function inviteDirect(db, teamId, inviterUserId, inviteeEmail, expiresInHours = 7 * 24) {
10085
+ const team = await db.get("__lattice_team", teamId);
10086
+ if (!team || team.deleted_at) {
10087
+ throw new Error(`Team not found: ${teamId}`);
10088
+ }
10089
+ const expiresAt = new Date(Date.now() + expiresInHours * 36e5).toISOString();
10090
+ const { raw, hash } = generateInviteToken();
10091
+ const id = await db.insert("__lattice_invitations", {
10092
+ team_id: teamId,
10093
+ token_hash: hash,
10094
+ invitee_email: inviteeEmail,
10095
+ invited_by_user_id: inviterUserId,
10096
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
10097
+ expires_at: expiresAt
10098
+ });
10099
+ return {
10100
+ id,
10101
+ raw_token: raw,
10102
+ expires_at: expiresAt,
10103
+ team_name: team.name,
10104
+ invitee_email: inviteeEmail
10105
+ };
10106
+ }
10107
+ async function kickMemberDirect(db, teamId, userId) {
10108
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: userId });
10109
+ }
10110
+ async function destroyTeamDirect(db) {
10111
+ const identityRow = await db.get("__lattice_team_identity", "singleton");
10112
+ if (identityRow && typeof identityRow.team_id === "string") {
10113
+ const teamId = identityRow.team_id;
10114
+ const members = await db.query("__lattice_team_members", {
10115
+ filters: [{ col: "team_id", op: "eq", val: teamId }]
10116
+ });
10117
+ for (const m of members) {
10118
+ await db.delete("__lattice_team_members", { team_id: teamId, user_id: m.user_id });
10119
+ }
10120
+ await db.update("__lattice_team", teamId, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
10121
+ }
10122
+ await db.delete("__lattice_team_identity", "singleton");
10123
+ }
10124
+ async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
10125
+ if (!isPostgresUrl(cloudUrl)) {
10126
+ throw new Error(
10127
+ `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
10128
+ );
10129
+ }
10130
+ const db = new Lattice(cloudUrl);
10131
+ try {
10132
+ await db.init();
10133
+ for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
10134
+ await db.defineLate(table, def);
10135
+ }
10136
+ const invites = await db.query("__lattice_invitations", {
10137
+ filters: [
10138
+ { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
10139
+ { col: "redeemed_at", op: "isNull" }
10140
+ ],
10141
+ limit: 1
10142
+ });
10143
+ const invite = invites[0];
10144
+ if (!invite) {
10145
+ throw new Error("Invitation invalid or already used");
10146
+ }
10147
+ if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
10148
+ throw new Error("Invitation expired");
10149
+ }
10150
+ if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
10151
+ throw new Error("Invitation is addressed to a different email");
10152
+ }
10153
+ const team = await db.get("__lattice_team", invite.team_id);
10154
+ if (!team || team.deleted_at) {
10155
+ throw new Error("Team no longer exists");
10156
+ }
10157
+ const now = (/* @__PURE__ */ new Date()).toISOString();
10158
+ const userId = await db.insert("__lattice_users", {
10159
+ email,
10160
+ name,
10161
+ created_at: now,
10162
+ updated_at: now
10163
+ });
10164
+ await db.insert("__lattice_team_members", {
10165
+ team_id: invite.team_id,
10166
+ user_id: userId,
10167
+ role: "member",
10168
+ joined_at: now
10169
+ });
10170
+ const { raw, hash } = generateToken();
10171
+ await db.insert("__lattice_api_tokens", {
10172
+ user_id: userId,
10173
+ token_hash: hash,
10174
+ name: `invited:${team.name}`,
10175
+ created_at: now
10176
+ });
10177
+ await db.update("__lattice_invitations", invite.id, {
10178
+ redeemed_at: now,
10179
+ redeemed_by_user_id: userId
10180
+ });
10181
+ return {
10182
+ user: { id: userId, email, name },
10183
+ raw_token: raw,
10184
+ team: { id: team.id, name: team.name }
10185
+ };
10186
+ } finally {
10187
+ try {
10188
+ db.close();
10189
+ } catch {
10190
+ }
10191
+ }
10192
+ }
10193
+
9992
10194
  // src/teams/client.ts
9993
10195
  var TeamsClient = class {
9994
10196
  constructor(local) {
@@ -10031,6 +10233,9 @@ var TeamsClient = class {
10031
10233
  });
10032
10234
  }
10033
10235
  async redeemInvite(cloudUrl, inviteToken, email, name) {
10236
+ if (isPostgresUrl(cloudUrl)) {
10237
+ return redeemInviteDirect(cloudUrl, inviteToken, email, name);
10238
+ }
10034
10239
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
10035
10240
  invite_token: inviteToken,
10036
10241
  email,
@@ -10075,6 +10280,13 @@ var TeamsClient = class {
10075
10280
  const redeem = await this.redeemInvite(opts.cloudUrl, inviteToken, email, name);
10076
10281
  saveDbCredential(opts.label, opts.cloudUrl);
10077
10282
  writeToken(opts.label, redeem.raw_token);
10283
+ await this.saveConnection({
10284
+ team_id: redeem.team.id,
10285
+ team_name: redeem.team.name,
10286
+ cloud_url: opts.cloudUrl,
10287
+ my_user_id: redeem.user.id,
10288
+ api_token: redeem.raw_token
10289
+ });
10078
10290
  return {
10079
10291
  probe,
10080
10292
  joinedAsMember: { user_id: redeem.user.id, team_id: redeem.team.id }
@@ -10094,21 +10306,51 @@ var TeamsClient = class {
10094
10306
  * The HTTP path can't be used here because the browser's Fetch
10095
10307
  * API refuses URLs with embedded credentials.
10096
10308
  *
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.
10309
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
10310
+ * **and** persists the local `__lattice_team_connections` row so the
10311
+ * GUI's team-management API calls can authenticate immediately
10312
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
10313
+ * connection-row write — the older v1.13 implementation only wrote
10314
+ * the token file, leaving GUI authenticated calls with no
10315
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
10100
10316
  */
10101
10317
  async upgradeToTeamCloud(opts) {
10102
10318
  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
10319
  writeToken(opts.label, reg.raw_token);
10320
+ await this.saveConnection({
10321
+ team_id: reg.team.id,
10322
+ team_name: reg.team.name,
10323
+ cloud_url: opts.cloudUrl,
10324
+ my_user_id: reg.user.id,
10325
+ api_token: reg.raw_token
10326
+ });
10104
10327
  return reg;
10105
10328
  }
10106
- // ── Cloud HTTP calls (authenticated) ────────────────────────────────────
10329
+ // ── Cloud team operations (dispatch on URL scheme) ──────────────────────
10330
+ // For HTTP cloud URLs (`http://lattice-server:port`), every operation
10331
+ // round-trips through the team server's authenticated REST API. For
10332
+ // direct-Postgres cloud URLs (`postgres://...`), the user's `this.local`
10333
+ // Lattice IS the cloud DB — operations dispatch through `direct-ops.ts`
10334
+ // helpers that run the same INSERT / UPDATE / DELETE / SELECT logic
10335
+ // against `this.local` directly. The Fetch API can't handle
10336
+ // credentials-in-URL anyway, so the dispatch isn't optional.
10337
+ //
10338
+ // Authorization model: for HTTP clouds, the server gates by bearer
10339
+ // token + membership row. For direct-Postgres, possession of the
10340
+ // connection credential is the implicit gate — the operator is
10341
+ // already reading/writing the canonical data.
10107
10342
  /** Destroy the singleton team. Creator-only on the cloud side. */
10108
10343
  async destroyTeam(cloudUrl, token) {
10344
+ if (isPostgresUrl(cloudUrl)) {
10345
+ await destroyTeamDirect(this.local);
10346
+ return;
10347
+ }
10109
10348
  await this.fetchAuthed(cloudUrl, token, "DELETE", "/api/team");
10110
10349
  }
10111
10350
  async listMembers(cloudUrl, token, teamId) {
10351
+ if (isPostgresUrl(cloudUrl)) {
10352
+ return listMembersDirect(this.local, teamId);
10353
+ }
10112
10354
  const r = await this.fetchAuthed(
10113
10355
  cloudUrl,
10114
10356
  token,
@@ -10117,7 +10359,15 @@ var TeamsClient = class {
10117
10359
  );
10118
10360
  return r.members;
10119
10361
  }
10120
- async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours) {
10362
+ async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours, inviterUserId) {
10363
+ if (isPostgresUrl(cloudUrl)) {
10364
+ if (!inviterUserId) {
10365
+ throw new Error(
10366
+ "invite: inviterUserId is required for direct-Postgres cloud URLs (read it from __lattice_team_connections.my_user_id)"
10367
+ );
10368
+ }
10369
+ return inviteDirect(this.local, teamId, inviterUserId, inviteeEmail, expiresInHours);
10370
+ }
10121
10371
  const body = { invitee_email: inviteeEmail };
10122
10372
  if (expiresInHours !== void 0) body.expires_in_hours = expiresInHours;
10123
10373
  return this.fetchAuthed(
@@ -10129,6 +10379,10 @@ var TeamsClient = class {
10129
10379
  );
10130
10380
  }
10131
10381
  async kickMember(cloudUrl, token, teamId, userId) {
10382
+ if (isPostgresUrl(cloudUrl)) {
10383
+ await kickMemberDirect(this.local, teamId, userId);
10384
+ return;
10385
+ }
10132
10386
  await this.fetchAuthed(
10133
10387
  cloudUrl,
10134
10388
  token,
@@ -10182,6 +10436,9 @@ var TeamsClient = class {
10182
10436
  * `has_more` flag tells callers to loop until drained before sleeping.
10183
10437
  */
10184
10438
  async fetchChangeBatch(cloudUrl, token, teamId, since = 0, limit = 500) {
10439
+ if (isPostgresUrl(cloudUrl)) {
10440
+ return { envelopes: [], has_more: false };
10441
+ }
10185
10442
  return this.fetchAuthed(
10186
10443
  cloudUrl,
10187
10444
  token,
@@ -10789,7 +11046,8 @@ async function dispatchTeamSubroute(req, res, ctx, teamId, subpath) {
10789
11046
  conn.api_token,
10790
11047
  teamId,
10791
11048
  inviteeEmail,
10792
- hours
11049
+ hours,
11050
+ conn.my_user_id
10793
11051
  );
10794
11052
  sendJson2(res, invite);
10795
11053
  return;
@@ -12282,23 +12540,23 @@ async function startGuiServer(options) {
12282
12540
  return;
12283
12541
  }
12284
12542
  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
- })();
12543
+ const adapter = active.db._adapter;
12544
+ let rows = [];
12545
+ if (adapter.allAsync) {
12546
+ const listSql = adapter.dialect === "postgres" ? (
12547
+ // pg_tables is the public-schema-only counterpart to
12548
+ // sqlite_master. We filter to public + the same `\_%`
12549
+ // ESCAPE pattern so the result is identical to SQLite.
12550
+ // Underscore is a LIKE wildcard in both engines.
12551
+ `SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE '\\_%' ESCAPE '\\' ORDER BY tablename`
12552
+ ) : `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '\\_%' ESCAPE '\\' ORDER BY name`;
12553
+ rows = await adapter.allAsync(listSql);
12554
+ }
12294
12555
  const tables = [];
12295
12556
  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
- })();
12557
+ const cols = await active.db.introspectColumns(r.name);
12300
12558
  const rowCount = await active.db.count(r.name);
12301
- tables.push({ name: r.name, columns: cols.map((c) => c.name), rowCount });
12559
+ tables.push({ name: r.name, columns: cols, rowCount });
12302
12560
  }
12303
12561
  sendJson5(res, { tables });
12304
12562
  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,143 @@ 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
+ async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
6057
+ if (!isPostgresUrl(cloudUrl)) {
6058
+ throw new Error(
6059
+ `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
6060
+ );
6061
+ }
6062
+ const db = new Lattice(cloudUrl);
6063
+ try {
6064
+ await db.init();
6065
+ for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
6066
+ await db.defineLate(table, def);
6067
+ }
6068
+ const invites = await db.query("__lattice_invitations", {
6069
+ filters: [
6070
+ { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
6071
+ { col: "redeemed_at", op: "isNull" }
6072
+ ],
6073
+ limit: 1
6074
+ });
6075
+ const invite = invites[0];
6076
+ if (!invite) {
6077
+ throw new Error("Invitation invalid or already used");
6078
+ }
6079
+ if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
6080
+ throw new Error("Invitation expired");
6081
+ }
6082
+ if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
6083
+ throw new Error("Invitation is addressed to a different email");
6084
+ }
6085
+ const team = await db.get("__lattice_team", invite.team_id);
6086
+ if (!team || team.deleted_at) {
6087
+ throw new Error("Team no longer exists");
6088
+ }
6089
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6090
+ const userId = await db.insert("__lattice_users", {
6091
+ email,
6092
+ name,
6093
+ created_at: now,
6094
+ updated_at: now
6095
+ });
6096
+ await db.insert("__lattice_team_members", {
6097
+ team_id: invite.team_id,
6098
+ user_id: userId,
6099
+ role: "member",
6100
+ joined_at: now
6101
+ });
6102
+ const { raw, hash } = generateToken();
6103
+ await db.insert("__lattice_api_tokens", {
6104
+ user_id: userId,
6105
+ token_hash: hash,
6106
+ name: `invited:${team.name}`,
6107
+ created_at: now
6108
+ });
6109
+ await db.update("__lattice_invitations", invite.id, {
6110
+ redeemed_at: now,
6111
+ redeemed_by_user_id: userId
6112
+ });
6113
+ return {
6114
+ user: { id: userId, email, name },
6115
+ raw_token: raw,
6116
+ team: { id: team.id, name: team.name }
6117
+ };
6118
+ } finally {
6119
+ try {
6120
+ db.close();
6121
+ } catch {
6122
+ }
6123
+ }
6124
+ }
6125
+
5983
6126
  // src/teams/client.ts
5984
6127
  var TeamsClient = class {
5985
6128
  constructor(local) {
@@ -6022,6 +6165,9 @@ var TeamsClient = class {
6022
6165
  });
6023
6166
  }
6024
6167
  async redeemInvite(cloudUrl, inviteToken, email, name) {
6168
+ if (isPostgresUrl(cloudUrl)) {
6169
+ return redeemInviteDirect(cloudUrl, inviteToken, email, name);
6170
+ }
6025
6171
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
6026
6172
  invite_token: inviteToken,
6027
6173
  email,
@@ -6066,6 +6212,13 @@ var TeamsClient = class {
6066
6212
  const redeem = await this.redeemInvite(opts.cloudUrl, inviteToken, email, name);
6067
6213
  saveDbCredential(opts.label, opts.cloudUrl);
6068
6214
  writeToken(opts.label, redeem.raw_token);
6215
+ await this.saveConnection({
6216
+ team_id: redeem.team.id,
6217
+ team_name: redeem.team.name,
6218
+ cloud_url: opts.cloudUrl,
6219
+ my_user_id: redeem.user.id,
6220
+ api_token: redeem.raw_token
6221
+ });
6069
6222
  return {
6070
6223
  probe,
6071
6224
  joinedAsMember: { user_id: redeem.user.id, team_id: redeem.team.id }
@@ -6085,21 +6238,51 @@ var TeamsClient = class {
6085
6238
  * The HTTP path can't be used here because the browser's Fetch
6086
6239
  * API refuses URLs with embedded credentials.
6087
6240
  *
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.
6241
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
6242
+ * **and** persists the local `__lattice_team_connections` row so the
6243
+ * GUI's team-management API calls can authenticate immediately
6244
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
6245
+ * connection-row write — the older v1.13 implementation only wrote
6246
+ * the token file, leaving GUI authenticated calls with no
6247
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
6091
6248
  */
6092
6249
  async upgradeToTeamCloud(opts) {
6093
6250
  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
6251
  writeToken(opts.label, reg.raw_token);
6252
+ await this.saveConnection({
6253
+ team_id: reg.team.id,
6254
+ team_name: reg.team.name,
6255
+ cloud_url: opts.cloudUrl,
6256
+ my_user_id: reg.user.id,
6257
+ api_token: reg.raw_token
6258
+ });
6095
6259
  return reg;
6096
6260
  }
6097
- // ── Cloud HTTP calls (authenticated) ────────────────────────────────────
6261
+ // ── Cloud team operations (dispatch on URL scheme) ──────────────────────
6262
+ // For HTTP cloud URLs (`http://lattice-server:port`), every operation
6263
+ // round-trips through the team server's authenticated REST API. For
6264
+ // direct-Postgres cloud URLs (`postgres://...`), the user's `this.local`
6265
+ // Lattice IS the cloud DB — operations dispatch through `direct-ops.ts`
6266
+ // helpers that run the same INSERT / UPDATE / DELETE / SELECT logic
6267
+ // against `this.local` directly. The Fetch API can't handle
6268
+ // credentials-in-URL anyway, so the dispatch isn't optional.
6269
+ //
6270
+ // Authorization model: for HTTP clouds, the server gates by bearer
6271
+ // token + membership row. For direct-Postgres, possession of the
6272
+ // connection credential is the implicit gate — the operator is
6273
+ // already reading/writing the canonical data.
6098
6274
  /** Destroy the singleton team. Creator-only on the cloud side. */
6099
6275
  async destroyTeam(cloudUrl, token) {
6276
+ if (isPostgresUrl(cloudUrl)) {
6277
+ await destroyTeamDirect(this.local);
6278
+ return;
6279
+ }
6100
6280
  await this.fetchAuthed(cloudUrl, token, "DELETE", "/api/team");
6101
6281
  }
6102
6282
  async listMembers(cloudUrl, token, teamId) {
6283
+ if (isPostgresUrl(cloudUrl)) {
6284
+ return listMembersDirect(this.local, teamId);
6285
+ }
6103
6286
  const r = await this.fetchAuthed(
6104
6287
  cloudUrl,
6105
6288
  token,
@@ -6108,7 +6291,15 @@ var TeamsClient = class {
6108
6291
  );
6109
6292
  return r.members;
6110
6293
  }
6111
- async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours) {
6294
+ async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours, inviterUserId) {
6295
+ if (isPostgresUrl(cloudUrl)) {
6296
+ if (!inviterUserId) {
6297
+ throw new Error(
6298
+ "invite: inviterUserId is required for direct-Postgres cloud URLs (read it from __lattice_team_connections.my_user_id)"
6299
+ );
6300
+ }
6301
+ return inviteDirect(this.local, teamId, inviterUserId, inviteeEmail, expiresInHours);
6302
+ }
6112
6303
  const body = { invitee_email: inviteeEmail };
6113
6304
  if (expiresInHours !== void 0) body.expires_in_hours = expiresInHours;
6114
6305
  return this.fetchAuthed(
@@ -6120,6 +6311,10 @@ var TeamsClient = class {
6120
6311
  );
6121
6312
  }
6122
6313
  async kickMember(cloudUrl, token, teamId, userId) {
6314
+ if (isPostgresUrl(cloudUrl)) {
6315
+ await kickMemberDirect(this.local, teamId, userId);
6316
+ return;
6317
+ }
6123
6318
  await this.fetchAuthed(
6124
6319
  cloudUrl,
6125
6320
  token,
@@ -6173,6 +6368,9 @@ var TeamsClient = class {
6173
6368
  * `has_more` flag tells callers to loop until drained before sleeping.
6174
6369
  */
6175
6370
  async fetchChangeBatch(cloudUrl, token, teamId, since = 0, limit = 500) {
6371
+ if (isPostgresUrl(cloudUrl)) {
6372
+ return { envelopes: [], has_more: false };
6373
+ }
6176
6374
  return this.fetchAuthed(
6177
6375
  cloudUrl,
6178
6376
  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,143 @@ 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
+ async function redeemInviteDirect(cloudUrl, inviteToken, email, name) {
5983
+ if (!isPostgresUrl(cloudUrl)) {
5984
+ throw new Error(
5985
+ `redeemInviteDirect: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
5986
+ );
5987
+ }
5988
+ const db = new Lattice(cloudUrl);
5989
+ try {
5990
+ await db.init();
5991
+ for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
5992
+ await db.defineLate(table, def);
5993
+ }
5994
+ const invites = await db.query("__lattice_invitations", {
5995
+ filters: [
5996
+ { col: "token_hash", op: "eq", val: hashToken(inviteToken) },
5997
+ { col: "redeemed_at", op: "isNull" }
5998
+ ],
5999
+ limit: 1
6000
+ });
6001
+ const invite = invites[0];
6002
+ if (!invite) {
6003
+ throw new Error("Invitation invalid or already used");
6004
+ }
6005
+ if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
6006
+ throw new Error("Invitation expired");
6007
+ }
6008
+ if (invite.invitee_email && invite.invitee_email.toLowerCase() !== email.toLowerCase()) {
6009
+ throw new Error("Invitation is addressed to a different email");
6010
+ }
6011
+ const team = await db.get("__lattice_team", invite.team_id);
6012
+ if (!team || team.deleted_at) {
6013
+ throw new Error("Team no longer exists");
6014
+ }
6015
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6016
+ const userId = await db.insert("__lattice_users", {
6017
+ email,
6018
+ name,
6019
+ created_at: now,
6020
+ updated_at: now
6021
+ });
6022
+ await db.insert("__lattice_team_members", {
6023
+ team_id: invite.team_id,
6024
+ user_id: userId,
6025
+ role: "member",
6026
+ joined_at: now
6027
+ });
6028
+ const { raw, hash } = generateToken();
6029
+ await db.insert("__lattice_api_tokens", {
6030
+ user_id: userId,
6031
+ token_hash: hash,
6032
+ name: `invited:${team.name}`,
6033
+ created_at: now
6034
+ });
6035
+ await db.update("__lattice_invitations", invite.id, {
6036
+ redeemed_at: now,
6037
+ redeemed_by_user_id: userId
6038
+ });
6039
+ return {
6040
+ user: { id: userId, email, name },
6041
+ raw_token: raw,
6042
+ team: { id: team.id, name: team.name }
6043
+ };
6044
+ } finally {
6045
+ try {
6046
+ db.close();
6047
+ } catch {
6048
+ }
6049
+ }
6050
+ }
6051
+
5909
6052
  // src/teams/client.ts
5910
6053
  var TeamsClient = class {
5911
6054
  constructor(local) {
@@ -5948,6 +6091,9 @@ var TeamsClient = class {
5948
6091
  });
5949
6092
  }
5950
6093
  async redeemInvite(cloudUrl, inviteToken, email, name) {
6094
+ if (isPostgresUrl(cloudUrl)) {
6095
+ return redeemInviteDirect(cloudUrl, inviteToken, email, name);
6096
+ }
5951
6097
  return this.fetchUnauthed(cloudUrl, "POST", "/api/auth/redeem-invite", {
5952
6098
  invite_token: inviteToken,
5953
6099
  email,
@@ -5992,6 +6138,13 @@ var TeamsClient = class {
5992
6138
  const redeem = await this.redeemInvite(opts.cloudUrl, inviteToken, email, name);
5993
6139
  saveDbCredential(opts.label, opts.cloudUrl);
5994
6140
  writeToken(opts.label, redeem.raw_token);
6141
+ await this.saveConnection({
6142
+ team_id: redeem.team.id,
6143
+ team_name: redeem.team.name,
6144
+ cloud_url: opts.cloudUrl,
6145
+ my_user_id: redeem.user.id,
6146
+ api_token: redeem.raw_token
6147
+ });
5995
6148
  return {
5996
6149
  probe,
5997
6150
  joinedAsMember: { user_id: redeem.user.id, team_id: redeem.team.id }
@@ -6011,21 +6164,51 @@ var TeamsClient = class {
6011
6164
  * The HTTP path can't be used here because the browser's Fetch
6012
6165
  * API refuses URLs with embedded credentials.
6013
6166
  *
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.
6167
+ * On success writes the bearer token to `~/.lattice/keys/<label>.token`
6168
+ * **and** persists the local `__lattice_team_connections` row so the
6169
+ * GUI's team-management API calls can authenticate immediately
6170
+ * afterward (members, invites, kick, destroy). v1.13.4 added the
6171
+ * connection-row write — the older v1.13 implementation only wrote
6172
+ * the token file, leaving GUI authenticated calls with no
6173
+ * `cloud_url` + `my_user_id` + `api_token_encrypted` row to read.
6017
6174
  */
6018
6175
  async upgradeToTeamCloud(opts) {
6019
6176
  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
6177
  writeToken(opts.label, reg.raw_token);
6178
+ await this.saveConnection({
6179
+ team_id: reg.team.id,
6180
+ team_name: reg.team.name,
6181
+ cloud_url: opts.cloudUrl,
6182
+ my_user_id: reg.user.id,
6183
+ api_token: reg.raw_token
6184
+ });
6021
6185
  return reg;
6022
6186
  }
6023
- // ── Cloud HTTP calls (authenticated) ────────────────────────────────────
6187
+ // ── Cloud team operations (dispatch on URL scheme) ──────────────────────
6188
+ // For HTTP cloud URLs (`http://lattice-server:port`), every operation
6189
+ // round-trips through the team server's authenticated REST API. For
6190
+ // direct-Postgres cloud URLs (`postgres://...`), the user's `this.local`
6191
+ // Lattice IS the cloud DB — operations dispatch through `direct-ops.ts`
6192
+ // helpers that run the same INSERT / UPDATE / DELETE / SELECT logic
6193
+ // against `this.local` directly. The Fetch API can't handle
6194
+ // credentials-in-URL anyway, so the dispatch isn't optional.
6195
+ //
6196
+ // Authorization model: for HTTP clouds, the server gates by bearer
6197
+ // token + membership row. For direct-Postgres, possession of the
6198
+ // connection credential is the implicit gate — the operator is
6199
+ // already reading/writing the canonical data.
6024
6200
  /** Destroy the singleton team. Creator-only on the cloud side. */
6025
6201
  async destroyTeam(cloudUrl, token) {
6202
+ if (isPostgresUrl(cloudUrl)) {
6203
+ await destroyTeamDirect(this.local);
6204
+ return;
6205
+ }
6026
6206
  await this.fetchAuthed(cloudUrl, token, "DELETE", "/api/team");
6027
6207
  }
6028
6208
  async listMembers(cloudUrl, token, teamId) {
6209
+ if (isPostgresUrl(cloudUrl)) {
6210
+ return listMembersDirect(this.local, teamId);
6211
+ }
6029
6212
  const r = await this.fetchAuthed(
6030
6213
  cloudUrl,
6031
6214
  token,
@@ -6034,7 +6217,15 @@ var TeamsClient = class {
6034
6217
  );
6035
6218
  return r.members;
6036
6219
  }
6037
- async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours) {
6220
+ async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours, inviterUserId) {
6221
+ if (isPostgresUrl(cloudUrl)) {
6222
+ if (!inviterUserId) {
6223
+ throw new Error(
6224
+ "invite: inviterUserId is required for direct-Postgres cloud URLs (read it from __lattice_team_connections.my_user_id)"
6225
+ );
6226
+ }
6227
+ return inviteDirect(this.local, teamId, inviterUserId, inviteeEmail, expiresInHours);
6228
+ }
6038
6229
  const body = { invitee_email: inviteeEmail };
6039
6230
  if (expiresInHours !== void 0) body.expires_in_hours = expiresInHours;
6040
6231
  return this.fetchAuthed(
@@ -6046,6 +6237,10 @@ var TeamsClient = class {
6046
6237
  );
6047
6238
  }
6048
6239
  async kickMember(cloudUrl, token, teamId, userId) {
6240
+ if (isPostgresUrl(cloudUrl)) {
6241
+ await kickMemberDirect(this.local, teamId, userId);
6242
+ return;
6243
+ }
6049
6244
  await this.fetchAuthed(
6050
6245
  cloudUrl,
6051
6246
  token,
@@ -6099,6 +6294,9 @@ var TeamsClient = class {
6099
6294
  * `has_more` flag tells callers to loop until drained before sleeping.
6100
6295
  */
6101
6296
  async fetchChangeBatch(cloudUrl, token, teamId, since = 0, limit = 500) {
6297
+ if (isPostgresUrl(cloudUrl)) {
6298
+ return { envelopes: [], has_more: false };
6299
+ }
6102
6300
  return this.fetchAuthed(
6103
6301
  cloudUrl,
6104
6302
  token,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "1.13.3",
3
+ "version": "1.13.5",
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",