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 +306 -48
- package/dist/index.cjs +203 -5
- package/dist/index.d.cts +8 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.js +203 -5
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5943,6 +5943,30 @@ var guiAppHtml = `<!doctype html>
|
|
|
5943
5943
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
|
7593
|
-
|
|
7616
|
+
'<div class="field"><label>Cloud URL</label>' +
|
|
7617
|
+
'<input name="cloud_url" placeholder="postgres://postgres.<ref>: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
|
|
7618
|
-
|
|
7619
|
-
'
|
|
7643
|
+
'<div class="field"><label>Cloud URL</label>' +
|
|
7644
|
+
'<input name="cloud_url" placeholder="postgres://postgres.<ref>: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
|
|
8285
|
-
// non-creators (only members can
|
|
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 () {
|
|
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
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
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
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
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
|
-
*
|
|
10099
|
-
*
|
|
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
|
|
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
|
|
12286
|
-
|
|
12287
|
-
|
|
12288
|
-
|
|
12289
|
-
|
|
12290
|
-
|
|
12291
|
-
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
6090
|
-
*
|
|
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
|
|
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
|
-
*
|
|
3032
|
-
*
|
|
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
|
-
*
|
|
3032
|
-
*
|
|
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
|
-
*
|
|
6016
|
-
*
|
|
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
|
|
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