latticesql 1.13.1 → 1.13.3

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/README.md CHANGED
@@ -2082,6 +2082,14 @@ npx lattice gui --config ./lattice.config.yml --output ./context --port 4317
2082
2082
 
2083
2083
  The convergence means you don't need to duplicate entity-context definitions in YAML for the GUI to find rendered files.
2084
2084
 
2085
+ **Database wizard form (v1.13.2+).** The Postgres connection form (used by Migrate to cloud + Connect to existing cloud) disables browser autocapitalize, autocorrect, and spellcheck on every text input, and trims whitespace on every read. This avoids silent failure modes where macOS Safari / iOS turned a Supabase tenant user `postgres.<ref>` into `Postgres.<ref>` on submit, and where pasted credentials carrying a trailing newline produced opaque "zero-length delimiter identifier" or SCRAM-mismatch errors. `probeCloud` also folds SQLSTATE + `routine` into `result.error` so the GUI's "Unreachable: …" surface is actionable.
2086
+
2087
+ **Switch vs. migrate (v1.13.2+ wording).** "Connect to existing cloud" _switches_ the project's `db:` line to point at the cloud; the local SQLite file stays on disk and you can switch back by editing the YAML or via the Databases catalog under User Config. Use "Migrate to cloud" only when you want to _push_ the local data into a fresh empty target.
2088
+
2089
+ **Upgrade to team cloud — works against direct Postgres URLs (v1.13.3+).** The "Upgrade to team cloud" action previously HTTP-POSTed to `/api/auth/register` on the cloud URL, which fails with `Request cannot be constructed from a URL that includes credentials` when the cloud URL is a Postgres connection string. v1.13.3 dispatches on URL scheme: `http(s)://` keeps the HTTP path; `postgres(ql)://` calls the new `registerDirectViaPostgres()` helper that drives the same INSERT sequence directly against the cloud Postgres. Same invariants enforced both ways.
2090
+
2091
+ **Dashboard renders every entity (v1.13.3+).** Previously the dashboard cards filtered through a hardcoded entity list (`meetings`, `people`, `messages`, `projects`, `repositories`, `files`). Installs whose YAML declared different names saw a blank dashboard. Now every first-class entity gets a card; the hardcoded list survives as an ordering preference only.
2092
+
2085
2093
  **Views**
2086
2094
 
2087
2095
  - **Dashboard** (`#/`) — one card per first-class entity with live row counts.
package/dist/cli.js CHANGED
@@ -6247,12 +6247,46 @@ var guiAppHtml = `<!doctype html>
6247
6247
  // Dashboard
6248
6248
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6249
6249
  function renderDashboard(content) {
6250
- var cards = DASHBOARD_ORDER.map(function (name) {
6251
- var t = tableByName(name);
6252
- if (!t) return '';
6253
- var d = displayFor(name);
6250
+ // Show every first-class (non-junction, non-system) entity. The
6251
+ // previous implementation used DASHBOARD_ORDER as the filter \u2014 meaning
6252
+ // installs whose YAML declared tables outside the hardcoded list
6253
+ // (e.g. clients / students / vendors) saw a blank dashboard with no
6254
+ // hint why. DASHBOARD_ORDER is now a preference for ordering only;
6255
+ // tables not in it appear after, in declaration order.
6256
+ var preferenceRank = function (name) {
6257
+ var idx = DASHBOARD_ORDER.indexOf(name);
6258
+ return idx === -1 ? DASHBOARD_ORDER.length : idx;
6259
+ };
6260
+ var firstClass = (state.entities.tables || [])
6261
+ .filter(function (t) {
6262
+ // Junctions belong on the Data Model page, not as dashboard cards.
6263
+ if (isJunction(t)) return false;
6264
+ // System tables (_lattice_gui_*, __lattice_*) are hidden.
6265
+ if (t.name.charAt(0) === '_') return false;
6266
+ return true;
6267
+ })
6268
+ .slice()
6269
+ .sort(function (a, b) {
6270
+ var ra = preferenceRank(a.name);
6271
+ var rb = preferenceRank(b.name);
6272
+ if (ra !== rb) return ra - rb;
6273
+ // Same preference rank \u2014 keep declaration order from the API.
6274
+ return 0;
6275
+ });
6276
+
6277
+ if (firstClass.length === 0) {
6278
+ content.innerHTML =
6279
+ '<div class="placeholder">' +
6280
+ '<h2>No entities yet</h2>' +
6281
+ '<p>Define entities in your <code>lattice.config.yml</code> or register them via <code>db.define()</code>, then reload.</p>' +
6282
+ '</div>';
6283
+ return;
6284
+ }
6285
+
6286
+ var cards = firstClass.map(function (t) {
6287
+ var d = displayFor(t.name);
6254
6288
  var count = (t.rowCount != null) ? t.rowCount : 0;
6255
- return '<a class="card" href="#/objects/' + name + '">' +
6289
+ return '<a class="card" href="#/objects/' + t.name + '">' +
6256
6290
  '<div class="card-icon">' + d.icon + '</div>' +
6257
6291
  '<div class="card-label">' + escapeHtml(d.label) + '</div>' +
6258
6292
  '<div class="card-count">' + count + '</div>' +
@@ -7999,30 +8033,117 @@ var guiAppHtml = `<!doctype html>
7999
8033
 
8000
8034
  function postgresFormHtml(prefill) {
8001
8035
  prefill = prefill || {};
8036
+ // autocapitalize="off" + autocorrect="off" + spellcheck="false" keep
8037
+ // mobile / macOS keyboards from "helpfully" capitalizing the first
8038
+ // letter of usernames + host fragments. Supabase tenant users
8039
+ // (postgres.<ref>) are case-sensitive and silently failed
8040
+ // authentication when iOS Safari turned the leading "p" into "P".
8041
+ var attrs = ' autocapitalize="off" autocorrect="off" spellcheck="false"';
8002
8042
  return (
8003
8043
  '<div class="grid" style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px">' +
8004
- '<div><label class="field-label">Label</label><input type="text" id="w-label" placeholder="atlas" value="' + escapeHtml(prefill.label || '') + '" style="width:100%"></div>' +
8005
- '<div><label class="field-label">Host</label><input type="text" id="w-host" placeholder="db.example.com" value="' + escapeHtml(prefill.host || '') + '" style="width:100%"></div>' +
8044
+ '<div><label class="field-label">Label</label><input type="text" id="w-label" placeholder="atlas" value="' + escapeHtml(prefill.label || '') + '" style="width:100%"' + attrs + '></div>' +
8045
+ '<div><label class="field-label">Host</label><input type="text" id="w-host" placeholder="db.example.com" value="' + escapeHtml(prefill.host || '') + '" style="width:100%"' + attrs + '></div>' +
8006
8046
  '<div><label class="field-label">Port</label><input type="number" id="w-port" placeholder="5432" value="' + escapeHtml(String(prefill.port || 5432)) + '" style="width:100%"></div>' +
8007
- '<div><label class="field-label">Database name</label><input type="text" id="w-dbname" placeholder="app" value="' + escapeHtml(prefill.dbname || '') + '" style="width:100%"></div>' +
8008
- '<div><label class="field-label">User</label><input type="text" id="w-user" placeholder="lattice_user" value="' + escapeHtml(prefill.user || '') + '" style="width:100%"></div>' +
8009
- '<div><label class="field-label">Password</label><input type="password" id="w-password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" style="width:100%"></div>' +
8047
+ '<div><label class="field-label">Database name</label><input type="text" id="w-dbname" placeholder="app" value="' + escapeHtml(prefill.dbname || '') + '" style="width:100%"' + attrs + '></div>' +
8048
+ '<div><label class="field-label">User</label><input type="text" id="w-user" placeholder="lattice_user" value="' + escapeHtml(prefill.user || '') + '" style="width:100%"' + attrs + '></div>' +
8049
+ '<div><label class="field-label">Password</label><input type="password" id="w-password" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" style="width:100%"' + attrs + '></div>' +
8010
8050
  '</div>'
8011
8051
  );
8012
8052
  }
8013
8053
 
8014
8054
  function readPostgresWizardForm() {
8055
+ // Every text field is trimmed \u2014 pasted credentials frequently carry a
8056
+ // trailing newline or leading space that breaks URL construction
8057
+ // (zero-length identifier errors from the Postgres parser) or SCRAM
8058
+ // auth (silent password mismatch). Trim once, here, so every caller
8059
+ // benefits.
8060
+ var get = function (id) { return (document.getElementById(id).value || '').trim(); };
8015
8061
  return {
8016
8062
  type: 'postgres',
8017
- label: (document.getElementById('w-label').value || '').trim(),
8018
- host: (document.getElementById('w-host').value || '').trim(),
8063
+ label: get('w-label'),
8064
+ host: get('w-host'),
8019
8065
  port: Number(document.getElementById('w-port').value || 5432),
8020
- dbname: (document.getElementById('w-dbname').value || '').trim(),
8021
- user: document.getElementById('w-user').value || '',
8022
- password: document.getElementById('w-password').value || '',
8066
+ dbname: get('w-dbname'),
8067
+ user: get('w-user'),
8068
+ password: get('w-password'),
8023
8069
  };
8024
8070
  }
8025
8071
 
8072
+ // Detect common Supabase pooler URL mistakes the form gives no hint
8073
+ // about. Returns an array of human-readable hints, or [] when the
8074
+ // form looks plausible. Conservative \u2014 only flags clear patterns.
8075
+ function detectSupabasePoolerMistakes(body) {
8076
+ var hints = [];
8077
+ var host = (body.host || '').toLowerCase();
8078
+ if (host.indexOf('pooler.supabase') !== -1) {
8079
+ // Pooler requires the tenant-prefixed user form postgres.<ref>.
8080
+ if (body.user && body.user.indexOf('.') === -1) {
8081
+ hints.push(
8082
+ 'Supabase pooler hosts require a tenant-prefixed user like ' +
8083
+ '<code>postgres.&lt;project-ref&gt;</code>. You entered <code>' +
8084
+ escapeHtml(body.user) + '</code> \u2014 Supabase will reject SCRAM ' +
8085
+ 'auth with a misleading "password authentication failed" error.'
8086
+ );
8087
+ }
8088
+ // Session-mode is on 5432; transaction-mode on 6543. latticesql
8089
+ // wants session-mode (transactions span multiple statements).
8090
+ if (Number(body.port) === 6543) {
8091
+ hints.push(
8092
+ 'Supabase pooler port <code>6543</code> is transaction mode. ' +
8093
+ 'Lattice needs session mode \u2014 use port <code>5432</code> on ' +
8094
+ 'the same pooler host.'
8095
+ );
8096
+ }
8097
+ } else if (host.indexOf('.supabase.co') !== -1 && host.indexOf('pooler') === -1) {
8098
+ // Direct host form uses bare postgres user, not the tenant-
8099
+ // prefixed pooler form. Easy to mix up.
8100
+ if (body.user && body.user.indexOf('.') !== -1) {
8101
+ hints.push(
8102
+ 'The direct host <code>db.&lt;project-ref&gt;.supabase.co</code> ' +
8103
+ 'uses a bare <code>postgres</code> user (no tenant prefix). ' +
8104
+ 'You entered <code>' + escapeHtml(body.user) + '</code> \u2014 ' +
8105
+ 'Supabase will reject SCRAM auth with "password authentication ' +
8106
+ 'failed".'
8107
+ );
8108
+ }
8109
+ }
8110
+ return hints;
8111
+ }
8112
+
8113
+ // Probe the cloud and validate Supabase form patterns. Resolves to
8114
+ // the probe result on success; rejects with a human-readable error
8115
+ // when the form has obvious mistakes or the probe is unreachable.
8116
+ // Shared by Migrate + Connect so the credential is never saved
8117
+ // without first proving the form values can actually connect.
8118
+ function probeBeforeCredentialSave(body, msgEl) {
8119
+ var hints = detectSupabasePoolerMistakes(body);
8120
+ if (hints.length > 0) {
8121
+ // Block submit until the form is fixed. Show the hints inline.
8122
+ msgEl.innerHTML =
8123
+ '<strong style="color:var(--warn)">Connection looks wrong:</strong>' +
8124
+ '<ul style="margin:6px 0 0 18px;padding:0;color:var(--warn)">' +
8125
+ hints.map(function (h) { return '<li>' + h + '</li>'; }).join('') +
8126
+ '</ul>';
8127
+ return Promise.reject(new Error('Fix the issues above and try again.'));
8128
+ }
8129
+ msgEl.textContent = 'Testing connection\u2026';
8130
+ return fetch('/api/dbconfig/probe', {
8131
+ method: 'POST',
8132
+ headers: { 'content-type': 'application/json' },
8133
+ body: JSON.stringify(body),
8134
+ })
8135
+ .then(function (r) { return r.json(); })
8136
+ .then(function (probe) {
8137
+ if (!probe.reachable) {
8138
+ throw new Error(
8139
+ 'Cloud unreachable: ' + (probe.error || 'unknown error') +
8140
+ '. Double-check host, port, user, and password.'
8141
+ );
8142
+ }
8143
+ return probe;
8144
+ });
8145
+ }
8146
+
8026
8147
  function showMigrateToCloudModal(onClose) {
8027
8148
  var bodyHtml =
8028
8149
  '<p style="margin:0 0 12px;font-size:13px;color:var(--text-muted)">' +
@@ -8038,15 +8159,30 @@ var guiAppHtml = `<!doctype html>
8038
8159
  onSubmit: function () {
8039
8160
  var body = readPostgresWizardForm();
8040
8161
  var msg = document.getElementById('w-msg');
8041
- msg.textContent = 'Migrating\u2026 (this may take a moment for large DBs)';
8042
- return fetch('/api/dbconfig/migrate-to-cloud', {
8043
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
8044
- })
8045
- .then(function (r) { return r.json().then(function (d) { return { status: r.status, body: d }; }); })
8046
- .then(function (r) {
8047
- if (!r.body.ok) throw new Error(r.body.error || ('HTTP ' + r.status));
8048
- if (onClose) onClose();
8049
- });
8162
+ // Validate Supabase URL pattern + probe the cloud before
8163
+ // persisting a credential that would just blow up on the next
8164
+ // open. Saves users from the "Migrate succeeded, switch back
8165
+ // later fails" trap that strikes when the saved credential
8166
+ // has a wrong host/port/user.
8167
+ return probeBeforeCredentialSave(body, msg).then(function (probe) {
8168
+ if (probe.teamEnabled) {
8169
+ throw new Error(
8170
+ 'Target is already a teams DB' +
8171
+ (probe.teamName ? ' (' + probe.teamName + ')' : '') +
8172
+ '. Migrate-to-cloud only works against fresh empty targets \u2014 ' +
8173
+ 'use Connect to existing cloud instead.'
8174
+ );
8175
+ }
8176
+ msg.textContent = 'Migrating\u2026 (this may take a moment for large DBs)';
8177
+ return fetch('/api/dbconfig/migrate-to-cloud', {
8178
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
8179
+ })
8180
+ .then(function (r) { return r.json().then(function (d) { return { status: r.status, body: d }; }); })
8181
+ .then(function (r) {
8182
+ if (!r.body.ok) throw new Error(r.body.error || ('HTTP ' + r.status));
8183
+ if (onClose) onClose();
8184
+ });
8185
+ });
8050
8186
  },
8051
8187
  });
8052
8188
  }
@@ -8054,10 +8190,14 @@ var guiAppHtml = `<!doctype html>
8054
8190
  function showConnectExistingModal(onClose) {
8055
8191
  var bodyHtml =
8056
8192
  '<p style="margin:0 0 12px;font-size:13px;color:var(--text-muted)">' +
8057
- 'Connect this project to an <strong>existing</strong> cloud Postgres. ' +
8058
- 'Your local SQLite data will be ignored \u2014 use Migrate to cloud instead ' +
8059
- 'if you want to push it. If the target is a teams DB you\\'ll be asked ' +
8060
- 'for an invite token after the probe.' +
8193
+ 'Switch this project to an <strong>existing</strong> cloud Postgres. ' +
8194
+ 'Your local SQLite file is preserved \u2014 only this project\\'s active ' +
8195
+ 'connection changes. Switch back any time by editing ' +
8196
+ '<code>lattice.config.yml</code>\\'s <code>db:</code> line or via the ' +
8197
+ 'Databases catalog under User Config. If you want to <em>push</em> ' +
8198
+ 'your local rows into the target instead, use Migrate to cloud. If ' +
8199
+ 'the target is a teams DB you\\'ll be asked for an invite token ' +
8200
+ 'after the probe.' +
8061
8201
  '</p>' +
8062
8202
  postgresFormHtml({}) +
8063
8203
  '<div id="w-team-zone" style="margin-top:10px"></div>' +
@@ -8068,14 +8208,12 @@ var guiAppHtml = `<!doctype html>
8068
8208
  onSubmit: function () {
8069
8209
  var body = readPostgresWizardForm();
8070
8210
  var msg = document.getElementById('w-msg');
8071
- msg.textContent = 'Probing\u2026';
8072
- // First probe; if team, require token before the actual connect.
8073
- return fetch('/api/dbconfig/probe', {
8074
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
8075
- })
8076
- .then(function (r) { return r.json(); })
8211
+ // probeBeforeCredentialSave validates Supabase form patterns
8212
+ // before sending the probe; surfaces inline warnings (with
8213
+ // hints) when the user clearly has e.g. the wrong port or
8214
+ // missing tenant prefix in the pooler user.
8215
+ return probeBeforeCredentialSave(body, msg)
8077
8216
  .then(function (probe) {
8078
- if (!probe.reachable) throw new Error('Unreachable: ' + (probe.error || 'unknown'));
8079
8217
  if (probe.teamEnabled && !teamZoneShown) {
8080
8218
  var zone = document.getElementById('w-team-zone');
8081
8219
  zone.innerHTML =
@@ -9748,11 +9886,18 @@ async function probeCloud(targetUrl) {
9748
9886
  }
9749
9887
  return teamName !== void 0 ? { reachable: true, dialect, teamEnabled, teamName } : { reachable: true, dialect, teamEnabled };
9750
9888
  } catch (e) {
9889
+ const err = e;
9890
+ const parts = [];
9891
+ if (err.code) parts.push(`[${err.code}]`);
9892
+ if (err.message) parts.push(err.message);
9893
+ if (err.routine && !err.message.includes(err.routine)) {
9894
+ parts.push(`(routine: ${err.routine})`);
9895
+ }
9751
9896
  return {
9752
9897
  reachable: false,
9753
9898
  dialect,
9754
9899
  teamEnabled: false,
9755
- error: e.message
9900
+ error: parts.join(" ") || "unknown"
9756
9901
  };
9757
9902
  } finally {
9758
9903
  if (probe) {
@@ -9764,6 +9909,86 @@ async function probeCloud(targetUrl) {
9764
9909
  }
9765
9910
  }
9766
9911
 
9912
+ // src/teams/register-direct.ts
9913
+ function isPostgresUrl(url) {
9914
+ return /^postgres(ql)?:\/\//i.test(url);
9915
+ }
9916
+ async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
9917
+ if (!isPostgresUrl(cloudUrl)) {
9918
+ throw new Error(
9919
+ `registerDirectViaPostgres: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
9920
+ );
9921
+ }
9922
+ const db = new Lattice(cloudUrl);
9923
+ try {
9924
+ await db.init();
9925
+ for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
9926
+ await db.defineLate(table, def);
9927
+ }
9928
+ const existing = await db.query("__lattice_users", {
9929
+ filters: [{ col: "deleted_at", op: "isNull" }],
9930
+ limit: 1
9931
+ });
9932
+ if (existing.length > 0) {
9933
+ throw new Error(
9934
+ "Registration is disabled. This cloud already has users \u2014 join via invitation."
9935
+ );
9936
+ }
9937
+ let identity = null;
9938
+ try {
9939
+ identity = await db.get("__lattice_team_identity", "singleton");
9940
+ } catch {
9941
+ identity = null;
9942
+ }
9943
+ if (identity) {
9944
+ throw new Error("This cloud already has a team. Use Connect to existing cloud instead.");
9945
+ }
9946
+ const now = (/* @__PURE__ */ new Date()).toISOString();
9947
+ const userId = await db.insert("__lattice_users", {
9948
+ email,
9949
+ name,
9950
+ created_at: now,
9951
+ updated_at: now
9952
+ });
9953
+ const { raw, hash } = generateToken();
9954
+ await db.insert("__lattice_api_tokens", {
9955
+ user_id: userId,
9956
+ token_hash: hash,
9957
+ name: `creator:${teamName}`,
9958
+ created_at: now
9959
+ });
9960
+ const teamId = await db.insert("__lattice_team", {
9961
+ name: teamName,
9962
+ created_by_user_id: userId,
9963
+ created_at: now,
9964
+ updated_at: now
9965
+ });
9966
+ await db.insert("__lattice_team_members", {
9967
+ team_id: teamId,
9968
+ user_id: userId,
9969
+ role: "creator",
9970
+ joined_at: now
9971
+ });
9972
+ await db.insert("__lattice_team_identity", {
9973
+ id: "singleton",
9974
+ team_id: teamId,
9975
+ team_name: teamName,
9976
+ creator_email: email,
9977
+ created_at: now
9978
+ });
9979
+ return {
9980
+ user: { id: userId, email, name },
9981
+ raw_token: raw,
9982
+ team: { id: teamId, name: teamName, role: "creator" }
9983
+ };
9984
+ } finally {
9985
+ try {
9986
+ db.close();
9987
+ } catch {
9988
+ }
9989
+ }
9990
+ }
9991
+
9767
9992
  // src/teams/client.ts
9768
9993
  var TeamsClient = class {
9769
9994
  constructor(local) {
@@ -9859,15 +10084,22 @@ var TeamsClient = class {
9859
10084
  return { probe };
9860
10085
  }
9861
10086
  /**
9862
- * Upgrade an already-connected cloud DB to a team DB. Runs the
9863
- * atomic `POST /api/auth/register` flow against the cloud URL
9864
- * stored under `label`. Writes the resulting bearer to
9865
- * `~/.lattice/keys/<label>.token`. Caller is expected to call
9866
- * `saveConnection` if they also want the local
9867
- * `__lattice_team_connections` row populated.
10087
+ * Upgrade an already-connected cloud DB to a team DB. Two paths
10088
+ * depending on the cloud URL's scheme:
10089
+ *
10090
+ * - `http(s)://…` POST to the cloud's `/api/auth/register` endpoint
10091
+ * (`lattice serve --team-cloud` is fronting the Postgres).
10092
+ * - `postgres(ql)://…` drive the same INSERT sequence directly
10093
+ * against the cloud Postgres via {@link registerDirectViaPostgres}.
10094
+ * The HTTP path can't be used here because the browser's Fetch
10095
+ * API refuses URLs with embedded credentials.
10096
+ *
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.
9868
10100
  */
9869
10101
  async upgradeToTeamCloud(opts) {
9870
- const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
10102
+ 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);
9871
10103
  writeToken(opts.label, reg.raw_token);
9872
10104
  return reg;
9873
10105
  }
@@ -11626,8 +11858,16 @@ async function openConfig(configPath, outputDir) {
11626
11858
  db.define("__lattice_user_identity", {
11627
11859
  columns: {
11628
11860
  id: "TEXT PRIMARY KEY",
11629
- display_name: 'TEXT NOT NULL DEFAULT ""',
11630
- email: 'TEXT NOT NULL DEFAULT ""',
11861
+ // Single-quoted empty-string defaults below — not double-quoted!
11862
+ // SQLite leniently accepts `DEFAULT ""` as an empty string literal,
11863
+ // but PostgreSQL treats `""` as a zero-length delimited identifier
11864
+ // (i.e. an empty column name), which throws `zero-length delimited
11865
+ // identifier at or near """""` from the parser before any rows are
11866
+ // inserted. This is the standard-conformant behavior — single
11867
+ // quotes are for string literals; double quotes are for
11868
+ // identifiers. Use `''` so the CREATE TABLE works on both engines.
11869
+ display_name: "TEXT NOT NULL DEFAULT ''",
11870
+ email: "TEXT NOT NULL DEFAULT ''",
11631
11871
  updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))"
11632
11872
  },
11633
11873
  primaryKey: "id",
@@ -12103,7 +12343,20 @@ async function startGuiServer(options) {
12103
12343
  sendJson5(res, { error: `Config not found: ${newPath}` }, 400);
12104
12344
  return;
12105
12345
  }
12106
- const next = await openConfig(newPath, active.outputDir);
12346
+ let next;
12347
+ try {
12348
+ next = await openConfig(newPath, active.outputDir);
12349
+ } catch (e) {
12350
+ const err = e;
12351
+ console.error(`[dbconfig.switch] openConfig(${newPath}) failed:`, err);
12352
+ const codePrefix = err.code ? `[${err.code}] ` : "";
12353
+ sendJson5(
12354
+ res,
12355
+ { error: `Failed to switch to ${newPath}: ${codePrefix}${err.message}` },
12356
+ 500
12357
+ );
12358
+ return;
12359
+ }
12107
12360
  active.db.close();
12108
12361
  active = next;
12109
12362
  sendJson5(res, { ok: true, path: active.configPath });
package/dist/index.cjs CHANGED
@@ -64,6 +64,7 @@ __export(index_exports, {
64
64
  getOrCreateMasterKey: () => getOrCreateMasterKey,
65
65
  hashFile: () => hashFile,
66
66
  isEncrypted: () => isEncrypted,
67
+ isPostgresUrl: () => isPostgresUrl,
67
68
  isV1EntityFiles: () => isV1EntityFiles,
68
69
  listDbCredentials: () => listDbCredentials,
69
70
  listTokens: () => listTokens,
@@ -81,6 +82,7 @@ __export(index_exports, {
81
82
  readIdentity: () => readIdentity,
82
83
  readManifest: () => readManifest,
83
84
  readToken: () => readToken,
85
+ registerDirectViaPostgres: () => registerDirectViaPostgres,
84
86
  registerNativeEntities: () => registerNativeEntities,
85
87
  saveDbCredential: () => saveDbCredential,
86
88
  slugify: () => slugify,
@@ -5568,9 +5570,142 @@ async function attachBlob(srcPath, latticeRoot) {
5568
5570
  }
5569
5571
 
5570
5572
  // src/teams/client.ts
5571
- var import_node_crypto8 = require("crypto");
5573
+ var import_node_crypto9 = require("crypto");
5572
5574
 
5573
5575
  // src/teams/internal-tables.ts
5576
+ var CLOUD_INTERNAL_TABLE_DEFS = {
5577
+ __lattice_users: {
5578
+ columns: {
5579
+ id: "TEXT PRIMARY KEY",
5580
+ email: "TEXT NOT NULL",
5581
+ name: "TEXT",
5582
+ created_at: "TEXT NOT NULL",
5583
+ updated_at: "TEXT NOT NULL",
5584
+ deleted_at: "TEXT"
5585
+ },
5586
+ // Uniqueness enforced at the route layer (we soft-delete by setting
5587
+ // deleted_at, so a column-level UNIQUE blocks re-registration after
5588
+ // a delete). The register/redeem handlers check for an existing
5589
+ // non-deleted user with the same email before insert.
5590
+ render: () => "",
5591
+ outputFile: ".lattice-teams/users.md"
5592
+ },
5593
+ __lattice_api_tokens: {
5594
+ columns: {
5595
+ id: "TEXT PRIMARY KEY",
5596
+ user_id: "TEXT NOT NULL",
5597
+ token_hash: "TEXT NOT NULL UNIQUE",
5598
+ name: "TEXT",
5599
+ created_at: "TEXT NOT NULL",
5600
+ last_used_at: "TEXT",
5601
+ revoked_at: "TEXT"
5602
+ },
5603
+ render: () => "",
5604
+ outputFile: ".lattice-teams/tokens.md"
5605
+ },
5606
+ __lattice_team: {
5607
+ columns: {
5608
+ id: "TEXT PRIMARY KEY",
5609
+ name: "TEXT NOT NULL",
5610
+ created_by_user_id: "TEXT NOT NULL",
5611
+ created_at: "TEXT NOT NULL",
5612
+ updated_at: "TEXT NOT NULL",
5613
+ deleted_at: "TEXT"
5614
+ },
5615
+ render: () => "",
5616
+ outputFile: ".lattice-teams/teams.md"
5617
+ },
5618
+ // Singleton mirror of __lattice_team — populated by `createTeam` when
5619
+ // the first (and only) team is established on this cloud. One row per
5620
+ // DB, id='singleton'. Lets GET /api/team / DELETE /api/team / POST
5621
+ // /api/team/invitations resolve the active team without scanning.
5622
+ // The multi-team `__lattice_team` table remains the source of truth
5623
+ // for the row/object/changes routes until Step 8 deprecates them.
5624
+ __lattice_team_identity: {
5625
+ columns: {
5626
+ id: "TEXT PRIMARY KEY",
5627
+ team_id: "TEXT NOT NULL",
5628
+ team_name: "TEXT NOT NULL",
5629
+ creator_email: "TEXT NOT NULL",
5630
+ created_at: "TEXT NOT NULL"
5631
+ },
5632
+ primaryKey: "id",
5633
+ render: () => "",
5634
+ outputFile: ".lattice-teams/team-identity.md"
5635
+ },
5636
+ __lattice_team_members: {
5637
+ columns: {
5638
+ team_id: "TEXT NOT NULL",
5639
+ user_id: "TEXT NOT NULL",
5640
+ role: "TEXT NOT NULL CHECK (role IN ('creator', 'member'))",
5641
+ joined_at: "TEXT NOT NULL"
5642
+ },
5643
+ primaryKey: ["team_id", "user_id"],
5644
+ render: () => "",
5645
+ outputFile: ".lattice-teams/members.md"
5646
+ },
5647
+ __lattice_invitations: {
5648
+ columns: {
5649
+ id: "TEXT PRIMARY KEY",
5650
+ team_id: "TEXT NOT NULL",
5651
+ token_hash: "TEXT NOT NULL UNIQUE",
5652
+ // Email the invitation is addressed to — redeem requires the
5653
+ // caller's identity email to match. Email-binding makes invite
5654
+ // codes safe to share over a channel that's not strongly
5655
+ // authenticated; the recipient still has to be the recipient.
5656
+ invitee_email: "TEXT NOT NULL",
5657
+ invited_by_user_id: "TEXT NOT NULL",
5658
+ created_at: "TEXT NOT NULL",
5659
+ expires_at: "TEXT",
5660
+ redeemed_at: "TEXT",
5661
+ redeemed_by_user_id: "TEXT"
5662
+ },
5663
+ render: () => "",
5664
+ outputFile: ".lattice-teams/invitations.md"
5665
+ },
5666
+ __lattice_shared_objects: {
5667
+ columns: {
5668
+ team_id: "TEXT NOT NULL",
5669
+ table_name: "TEXT NOT NULL",
5670
+ schema_spec_json: "TEXT NOT NULL",
5671
+ schema_version: "INTEGER NOT NULL",
5672
+ created_by_user_id: "TEXT NOT NULL",
5673
+ created_at: "TEXT NOT NULL",
5674
+ updated_at: "TEXT NOT NULL",
5675
+ deleted_at: "TEXT"
5676
+ },
5677
+ primaryKey: ["team_id", "table_name"],
5678
+ render: () => "",
5679
+ outputFile: ".lattice-teams/shared-objects.md"
5680
+ },
5681
+ __lattice_change_log: {
5682
+ columns: {
5683
+ id: "TEXT PRIMARY KEY",
5684
+ seq: "INTEGER NOT NULL",
5685
+ team_id: "TEXT NOT NULL",
5686
+ table_name: "TEXT",
5687
+ pk: "TEXT",
5688
+ op: "TEXT NOT NULL",
5689
+ payload_json: "TEXT",
5690
+ owner_user_id: "TEXT",
5691
+ created_at: "TEXT NOT NULL"
5692
+ },
5693
+ render: () => "",
5694
+ outputFile: ".lattice-teams/change-log.md"
5695
+ },
5696
+ __lattice_row_links: {
5697
+ columns: {
5698
+ team_id: "TEXT NOT NULL",
5699
+ table_name: "TEXT NOT NULL",
5700
+ pk: "TEXT NOT NULL",
5701
+ owner_user_id: "TEXT NOT NULL",
5702
+ linked_at: "TEXT NOT NULL"
5703
+ },
5704
+ primaryKey: ["team_id", "table_name", "pk"],
5705
+ render: () => "",
5706
+ outputFile: ".lattice-teams/row-links.md"
5707
+ }
5708
+ };
5574
5709
  var LOCAL_INTERNAL_TABLE_DEFS = {
5575
5710
  __lattice_team_connections: {
5576
5711
  columns: {
@@ -5730,11 +5865,18 @@ async function probeCloud(targetUrl) {
5730
5865
  }
5731
5866
  return teamName !== void 0 ? { reachable: true, dialect, teamEnabled, teamName } : { reachable: true, dialect, teamEnabled };
5732
5867
  } catch (e) {
5868
+ const err = e;
5869
+ const parts = [];
5870
+ if (err.code) parts.push(`[${err.code}]`);
5871
+ if (err.message) parts.push(err.message);
5872
+ if (err.routine && !err.message.includes(err.routine)) {
5873
+ parts.push(`(routine: ${err.routine})`);
5874
+ }
5733
5875
  return {
5734
5876
  reachable: false,
5735
5877
  dialect,
5736
5878
  teamEnabled: false,
5737
- error: e.message
5879
+ error: parts.join(" ") || "unknown"
5738
5880
  };
5739
5881
  } finally {
5740
5882
  if (probe) {
@@ -5746,6 +5888,98 @@ async function probeCloud(targetUrl) {
5746
5888
  }
5747
5889
  }
5748
5890
 
5891
+ // src/teams/server/auth.ts
5892
+ var import_node_crypto8 = require("crypto");
5893
+ var TOKEN_PREFIX = "lat_";
5894
+ var TOKEN_BYTES = 32;
5895
+ function hashToken(rawToken) {
5896
+ return (0, import_node_crypto8.createHash)("sha256").update(rawToken).digest("hex");
5897
+ }
5898
+ function generateToken() {
5899
+ const raw = `${TOKEN_PREFIX}${(0, import_node_crypto8.randomBytes)(TOKEN_BYTES).toString("hex")}`;
5900
+ return { raw, hash: hashToken(raw) };
5901
+ }
5902
+
5903
+ // src/teams/register-direct.ts
5904
+ function isPostgresUrl(url) {
5905
+ return /^postgres(ql)?:\/\//i.test(url);
5906
+ }
5907
+ async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
5908
+ if (!isPostgresUrl(cloudUrl)) {
5909
+ throw new Error(
5910
+ `registerDirectViaPostgres: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
5911
+ );
5912
+ }
5913
+ const db = new Lattice(cloudUrl);
5914
+ try {
5915
+ await db.init();
5916
+ for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
5917
+ await db.defineLate(table, def);
5918
+ }
5919
+ const existing = await db.query("__lattice_users", {
5920
+ filters: [{ col: "deleted_at", op: "isNull" }],
5921
+ limit: 1
5922
+ });
5923
+ if (existing.length > 0) {
5924
+ throw new Error(
5925
+ "Registration is disabled. This cloud already has users \u2014 join via invitation."
5926
+ );
5927
+ }
5928
+ let identity = null;
5929
+ try {
5930
+ identity = await db.get("__lattice_team_identity", "singleton");
5931
+ } catch {
5932
+ identity = null;
5933
+ }
5934
+ if (identity) {
5935
+ throw new Error("This cloud already has a team. Use Connect to existing cloud instead.");
5936
+ }
5937
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5938
+ const userId = await db.insert("__lattice_users", {
5939
+ email,
5940
+ name,
5941
+ created_at: now,
5942
+ updated_at: now
5943
+ });
5944
+ const { raw, hash } = generateToken();
5945
+ await db.insert("__lattice_api_tokens", {
5946
+ user_id: userId,
5947
+ token_hash: hash,
5948
+ name: `creator:${teamName}`,
5949
+ created_at: now
5950
+ });
5951
+ const teamId = await db.insert("__lattice_team", {
5952
+ name: teamName,
5953
+ created_by_user_id: userId,
5954
+ created_at: now,
5955
+ updated_at: now
5956
+ });
5957
+ await db.insert("__lattice_team_members", {
5958
+ team_id: teamId,
5959
+ user_id: userId,
5960
+ role: "creator",
5961
+ joined_at: now
5962
+ });
5963
+ await db.insert("__lattice_team_identity", {
5964
+ id: "singleton",
5965
+ team_id: teamId,
5966
+ team_name: teamName,
5967
+ creator_email: email,
5968
+ created_at: now
5969
+ });
5970
+ return {
5971
+ user: { id: userId, email, name },
5972
+ raw_token: raw,
5973
+ team: { id: teamId, name: teamName, role: "creator" }
5974
+ };
5975
+ } finally {
5976
+ try {
5977
+ db.close();
5978
+ } catch {
5979
+ }
5980
+ }
5981
+ }
5982
+
5749
5983
  // src/teams/client.ts
5750
5984
  var TeamsClient = class {
5751
5985
  constructor(local) {
@@ -5841,15 +6075,22 @@ var TeamsClient = class {
5841
6075
  return { probe };
5842
6076
  }
5843
6077
  /**
5844
- * Upgrade an already-connected cloud DB to a team DB. Runs the
5845
- * atomic `POST /api/auth/register` flow against the cloud URL
5846
- * stored under `label`. Writes the resulting bearer to
5847
- * `~/.lattice/keys/<label>.token`. Caller is expected to call
5848
- * `saveConnection` if they also want the local
5849
- * `__lattice_team_connections` row populated.
6078
+ * Upgrade an already-connected cloud DB to a team DB. Two paths
6079
+ * depending on the cloud URL's scheme:
6080
+ *
6081
+ * - `http(s)://…` POST to the cloud's `/api/auth/register` endpoint
6082
+ * (`lattice serve --team-cloud` is fronting the Postgres).
6083
+ * - `postgres(ql)://…` drive the same INSERT sequence directly
6084
+ * against the cloud Postgres via {@link registerDirectViaPostgres}.
6085
+ * The HTTP path can't be used here because the browser's Fetch
6086
+ * API refuses URLs with embedded credentials.
6087
+ *
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.
5850
6091
  */
5851
6092
  async upgradeToTeamCloud(opts) {
5852
- const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
6093
+ 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);
5853
6094
  writeToken(opts.label, reg.raw_token);
5854
6095
  return reg;
5855
6096
  }
@@ -6154,7 +6395,7 @@ var TeamsClient = class {
6154
6395
  if (conn.my_user_id !== link.owner_user_id) continue;
6155
6396
  const now = (/* @__PURE__ */ new Date()).toISOString();
6156
6397
  await this.local.insert("__lattice_team_outbox", {
6157
- id: (0, import_node_crypto8.randomUUID)(),
6398
+ id: (0, import_node_crypto9.randomUUID)(),
6158
6399
  team_id: link.team_id,
6159
6400
  table_name: ctx.table,
6160
6401
  pk: ctx.pk,
@@ -6263,7 +6504,7 @@ var TeamsClient = class {
6263
6504
  totalApplied++;
6264
6505
  } catch (e) {
6265
6506
  await this.local.insert("__lattice_team_dlq", {
6266
- id: (0, import_node_crypto8.randomUUID)(),
6507
+ id: (0, import_node_crypto9.randomUUID)(),
6267
6508
  team_id: connection.team_id,
6268
6509
  envelope_json: JSON.stringify(env),
6269
6510
  error: e.message,
@@ -6517,6 +6758,7 @@ function archiveLocalSqlite(dbPath) {
6517
6758
  getOrCreateMasterKey,
6518
6759
  hashFile,
6519
6760
  isEncrypted,
6761
+ isPostgresUrl,
6520
6762
  isV1EntityFiles,
6521
6763
  listDbCredentials,
6522
6764
  listTokens,
@@ -6534,6 +6776,7 @@ function archiveLocalSqlite(dbPath) {
6534
6776
  readIdentity,
6535
6777
  readManifest,
6536
6778
  readToken,
6779
+ registerDirectViaPostgres,
6537
6780
  registerNativeEntities,
6538
6781
  saveDbCredential,
6539
6782
  slugify,
package/dist/index.d.cts CHANGED
@@ -3017,12 +3017,19 @@ declare class TeamsClient {
3017
3017
  };
3018
3018
  }>;
3019
3019
  /**
3020
- * Upgrade an already-connected cloud DB to a team DB. Runs the
3021
- * atomic `POST /api/auth/register` flow against the cloud URL
3022
- * stored under `label`. Writes the resulting bearer to
3023
- * `~/.lattice/keys/<label>.token`. Caller is expected to call
3024
- * `saveConnection` if they also want the local
3025
- * `__lattice_team_connections` row populated.
3020
+ * Upgrade an already-connected cloud DB to a team DB. Two paths
3021
+ * depending on the cloud URL's scheme:
3022
+ *
3023
+ * - `http(s)://…` POST to the cloud's `/api/auth/register` endpoint
3024
+ * (`lattice serve --team-cloud` is fronting the Postgres).
3025
+ * - `postgres(ql)://…` drive the same INSERT sequence directly
3026
+ * against the cloud Postgres via {@link registerDirectViaPostgres}.
3027
+ * The HTTP path can't be used here because the browser's Fetch
3028
+ * API refuses URLs with embedded credentials.
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.
3026
3033
  */
3027
3034
  upgradeToTeamCloud(opts: {
3028
3035
  label: string;
@@ -3263,4 +3270,54 @@ declare function openTargetLatticeForMigration(configPath: string, targetUrl: st
3263
3270
  */
3264
3271
  declare function archiveLocalSqlite(dbPath: string): string;
3265
3272
 
3266
- export { type ApplyWriteResult, type AuditEvent, type AutoUpdateResult, type BelongsToRelation, type BelongsToSource, type BlobMetadata, type BuiltinTemplateName, type ChangeEntry, type ChangelogOptions, type CleanupOptions, type CleanupResult, type CloudProbeResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EmbeddingsConfig, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileManifestInfo, type EntityFileSource, type EntityFileSpec, type EntityProfileField, type EntityProfileSection, type EntityProfileTemplate, type EntityRenderSpec, type EntityRenderTemplate, type EntitySectionPerRow, type EntitySectionsTemplate, type EntityTableColumn, type EntityTableTemplate, type Filter, type FilterOp, type HasManyRelation, type HasManySource, InMemoryStateStore, type InitOptions, type InviteResponse, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type LinkOptions, type ManyToManySource, type MarkdownTableColumn, type MemberSummary, type Migration, type MigrationOptions, type MigrationProgress, type MigrationResult, type MultiTableDefinition, NATIVE_ENTITY_DEFS, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, PostgresAdapter, type PostgresAdapterOptions, type PreparedStatement, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type RedeemResponse, type RegisterResponse, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type ReportConfig, type ReportResult, type ReportSection, type ReportSectionResult, type ReverseSeedDetection, type ReverseSeedResult, type ReverseSeedTableResult, type ReverseSyncError, type ReverseSyncResult, type ReverseSyncUpdate, type RewardScores, type Row, SQLiteAdapter, type SearchOptions, type SearchResult, type SecurityOptions, type SeedConfig, type SeedLinkSpec, type SeedResult, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type StorageAdapter, type SyncResult, type TableDefinition, type TeamConnection, type TeamSummary, TeamsClient, TeamsHttpError, type TemplateRenderSpec, type UpsertByNaturalKeyOptions, type UserIdentity, type WatchOptions, type WriteHook, type WriteHookContext, type WritebackDefinition, type WritebackStateStore, type WritebackValidationResult, applyTokenBudget, applyWriteEntry, archiveLocalSqlite, attachBlob, autoUpdate, configDir, contentHash, createReadOnlyHeader, createSQLiteStateStore, decrypt, deleteDbCredential, deleteToken, deriveKey, encrypt, entityFileNames, estimateTokens, fixSchemaConflicts, frontmatter, generateEntryId, generateWriteEntryId, getDbCredential, getOrCreateMasterKey, hashFile, isEncrypted, isV1EntityFiles, listDbCredentials, listTokens, manifestPath, markdownTable, migrateLatticeData, normalizeEntityFiles, openTargetLatticeForMigration, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, probeCloud, readIdentity, readManifest, readToken, registerNativeEntities, saveDbCredential, slugify, truncate, validateEntryId, writeIdentity, writeManifest, writeToken };
3273
+ /**
3274
+ * Shape returned by both the HTTP register path and the Postgres-direct
3275
+ * register path. Kept aligned with `RegisterResponse` in
3276
+ * `src/teams/client.ts`.
3277
+ */
3278
+ interface DirectRegisterResult {
3279
+ user: {
3280
+ id: string;
3281
+ email: string;
3282
+ name: string;
3283
+ };
3284
+ raw_token: string;
3285
+ team: {
3286
+ id: string;
3287
+ name: string;
3288
+ role: 'creator';
3289
+ };
3290
+ }
3291
+ /**
3292
+ * True iff `url` parses as a `postgres://` / `postgresql://` URL — used
3293
+ * by the GUI's upgrade flow to decide between HTTP `register` and the
3294
+ * direct-Postgres path implemented here.
3295
+ */
3296
+ declare function isPostgresUrl(url: string): boolean;
3297
+ /**
3298
+ * Direct-Postgres equivalent of the cloud's `POST /api/auth/register`.
3299
+ *
3300
+ * The HTTP teams-cloud server (`lattice serve --team-cloud`) handles
3301
+ * `register` by running an INSERT sequence inside its own request
3302
+ * handler. This helper does the same sequence locally by opening the
3303
+ * cloud Postgres directly — useful when the GUI's "Migrate to cloud"
3304
+ * or "Connect to existing cloud" flow has saved the **Postgres URL**
3305
+ * as the cloud credential (no HTTP teams server in front).
3306
+ *
3307
+ * Hard browser limitation that motivates this: when `cloudUrl` is a
3308
+ * Postgres URL with embedded credentials, the HTTP path's `fetch(url)`
3309
+ * throws "Request cannot be constructed from a URL that includes
3310
+ * credentials" before any network IO happens. We have to drive the
3311
+ * register flow against the database protocol, not HTTP.
3312
+ *
3313
+ * Mirrors `handleRegister`'s invariants:
3314
+ * - Refuses if any non-deleted user already exists on the cloud.
3315
+ * - Refuses if the `__lattice_team_identity` singleton already exists.
3316
+ *
3317
+ * On success returns the same shape the HTTP route returns so the
3318
+ * caller (`TeamsClient.upgradeToTeamCloud`) can use either path
3319
+ * interchangeably.
3320
+ */
3321
+ declare function registerDirectViaPostgres(cloudUrl: string, email: string, name: string, teamName: string): Promise<DirectRegisterResult>;
3322
+
3323
+ export { type ApplyWriteResult, type AuditEvent, type AutoUpdateResult, type BelongsToRelation, type BelongsToSource, type BlobMetadata, type BuiltinTemplateName, type ChangeEntry, type ChangelogOptions, type CleanupOptions, type CleanupResult, type CloudProbeResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type DirectRegisterResult, type EmbeddingsConfig, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileManifestInfo, type EntityFileSource, type EntityFileSpec, type EntityProfileField, type EntityProfileSection, type EntityProfileTemplate, type EntityRenderSpec, type EntityRenderTemplate, type EntitySectionPerRow, type EntitySectionsTemplate, type EntityTableColumn, type EntityTableTemplate, type Filter, type FilterOp, type HasManyRelation, type HasManySource, InMemoryStateStore, type InitOptions, type InviteResponse, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type LinkOptions, type ManyToManySource, type MarkdownTableColumn, type MemberSummary, type Migration, type MigrationOptions, type MigrationProgress, type MigrationResult, type MultiTableDefinition, NATIVE_ENTITY_DEFS, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, PostgresAdapter, type PostgresAdapterOptions, type PreparedStatement, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type RedeemResponse, type RegisterResponse, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type ReportConfig, type ReportResult, type ReportSection, type ReportSectionResult, type ReverseSeedDetection, type ReverseSeedResult, type ReverseSeedTableResult, type ReverseSyncError, type ReverseSyncResult, type ReverseSyncUpdate, type RewardScores, type Row, SQLiteAdapter, type SearchOptions, type SearchResult, type SecurityOptions, type SeedConfig, type SeedLinkSpec, type SeedResult, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type StorageAdapter, type SyncResult, type TableDefinition, type TeamConnection, type TeamSummary, TeamsClient, TeamsHttpError, type TemplateRenderSpec, type UpsertByNaturalKeyOptions, type UserIdentity, type WatchOptions, type WriteHook, type WriteHookContext, type WritebackDefinition, type WritebackStateStore, type WritebackValidationResult, applyTokenBudget, applyWriteEntry, archiveLocalSqlite, attachBlob, autoUpdate, configDir, contentHash, createReadOnlyHeader, createSQLiteStateStore, decrypt, deleteDbCredential, deleteToken, deriveKey, encrypt, entityFileNames, estimateTokens, fixSchemaConflicts, frontmatter, generateEntryId, generateWriteEntryId, getDbCredential, getOrCreateMasterKey, hashFile, isEncrypted, isPostgresUrl, isV1EntityFiles, listDbCredentials, listTokens, manifestPath, markdownTable, migrateLatticeData, normalizeEntityFiles, openTargetLatticeForMigration, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, probeCloud, readIdentity, readManifest, readToken, registerDirectViaPostgres, registerNativeEntities, saveDbCredential, slugify, truncate, validateEntryId, writeIdentity, writeManifest, writeToken };
package/dist/index.d.ts CHANGED
@@ -3017,12 +3017,19 @@ declare class TeamsClient {
3017
3017
  };
3018
3018
  }>;
3019
3019
  /**
3020
- * Upgrade an already-connected cloud DB to a team DB. Runs the
3021
- * atomic `POST /api/auth/register` flow against the cloud URL
3022
- * stored under `label`. Writes the resulting bearer to
3023
- * `~/.lattice/keys/<label>.token`. Caller is expected to call
3024
- * `saveConnection` if they also want the local
3025
- * `__lattice_team_connections` row populated.
3020
+ * Upgrade an already-connected cloud DB to a team DB. Two paths
3021
+ * depending on the cloud URL's scheme:
3022
+ *
3023
+ * - `http(s)://…` POST to the cloud's `/api/auth/register` endpoint
3024
+ * (`lattice serve --team-cloud` is fronting the Postgres).
3025
+ * - `postgres(ql)://…` drive the same INSERT sequence directly
3026
+ * against the cloud Postgres via {@link registerDirectViaPostgres}.
3027
+ * The HTTP path can't be used here because the browser's Fetch
3028
+ * API refuses URLs with embedded credentials.
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.
3026
3033
  */
3027
3034
  upgradeToTeamCloud(opts: {
3028
3035
  label: string;
@@ -3263,4 +3270,54 @@ declare function openTargetLatticeForMigration(configPath: string, targetUrl: st
3263
3270
  */
3264
3271
  declare function archiveLocalSqlite(dbPath: string): string;
3265
3272
 
3266
- export { type ApplyWriteResult, type AuditEvent, type AutoUpdateResult, type BelongsToRelation, type BelongsToSource, type BlobMetadata, type BuiltinTemplateName, type ChangeEntry, type ChangelogOptions, type CleanupOptions, type CleanupResult, type CloudProbeResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EmbeddingsConfig, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileManifestInfo, type EntityFileSource, type EntityFileSpec, type EntityProfileField, type EntityProfileSection, type EntityProfileTemplate, type EntityRenderSpec, type EntityRenderTemplate, type EntitySectionPerRow, type EntitySectionsTemplate, type EntityTableColumn, type EntityTableTemplate, type Filter, type FilterOp, type HasManyRelation, type HasManySource, InMemoryStateStore, type InitOptions, type InviteResponse, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type LinkOptions, type ManyToManySource, type MarkdownTableColumn, type MemberSummary, type Migration, type MigrationOptions, type MigrationProgress, type MigrationResult, type MultiTableDefinition, NATIVE_ENTITY_DEFS, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, PostgresAdapter, type PostgresAdapterOptions, type PreparedStatement, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type RedeemResponse, type RegisterResponse, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type ReportConfig, type ReportResult, type ReportSection, type ReportSectionResult, type ReverseSeedDetection, type ReverseSeedResult, type ReverseSeedTableResult, type ReverseSyncError, type ReverseSyncResult, type ReverseSyncUpdate, type RewardScores, type Row, SQLiteAdapter, type SearchOptions, type SearchResult, type SecurityOptions, type SeedConfig, type SeedLinkSpec, type SeedResult, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type StorageAdapter, type SyncResult, type TableDefinition, type TeamConnection, type TeamSummary, TeamsClient, TeamsHttpError, type TemplateRenderSpec, type UpsertByNaturalKeyOptions, type UserIdentity, type WatchOptions, type WriteHook, type WriteHookContext, type WritebackDefinition, type WritebackStateStore, type WritebackValidationResult, applyTokenBudget, applyWriteEntry, archiveLocalSqlite, attachBlob, autoUpdate, configDir, contentHash, createReadOnlyHeader, createSQLiteStateStore, decrypt, deleteDbCredential, deleteToken, deriveKey, encrypt, entityFileNames, estimateTokens, fixSchemaConflicts, frontmatter, generateEntryId, generateWriteEntryId, getDbCredential, getOrCreateMasterKey, hashFile, isEncrypted, isV1EntityFiles, listDbCredentials, listTokens, manifestPath, markdownTable, migrateLatticeData, normalizeEntityFiles, openTargetLatticeForMigration, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, probeCloud, readIdentity, readManifest, readToken, registerNativeEntities, saveDbCredential, slugify, truncate, validateEntryId, writeIdentity, writeManifest, writeToken };
3273
+ /**
3274
+ * Shape returned by both the HTTP register path and the Postgres-direct
3275
+ * register path. Kept aligned with `RegisterResponse` in
3276
+ * `src/teams/client.ts`.
3277
+ */
3278
+ interface DirectRegisterResult {
3279
+ user: {
3280
+ id: string;
3281
+ email: string;
3282
+ name: string;
3283
+ };
3284
+ raw_token: string;
3285
+ team: {
3286
+ id: string;
3287
+ name: string;
3288
+ role: 'creator';
3289
+ };
3290
+ }
3291
+ /**
3292
+ * True iff `url` parses as a `postgres://` / `postgresql://` URL — used
3293
+ * by the GUI's upgrade flow to decide between HTTP `register` and the
3294
+ * direct-Postgres path implemented here.
3295
+ */
3296
+ declare function isPostgresUrl(url: string): boolean;
3297
+ /**
3298
+ * Direct-Postgres equivalent of the cloud's `POST /api/auth/register`.
3299
+ *
3300
+ * The HTTP teams-cloud server (`lattice serve --team-cloud`) handles
3301
+ * `register` by running an INSERT sequence inside its own request
3302
+ * handler. This helper does the same sequence locally by opening the
3303
+ * cloud Postgres directly — useful when the GUI's "Migrate to cloud"
3304
+ * or "Connect to existing cloud" flow has saved the **Postgres URL**
3305
+ * as the cloud credential (no HTTP teams server in front).
3306
+ *
3307
+ * Hard browser limitation that motivates this: when `cloudUrl` is a
3308
+ * Postgres URL with embedded credentials, the HTTP path's `fetch(url)`
3309
+ * throws "Request cannot be constructed from a URL that includes
3310
+ * credentials" before any network IO happens. We have to drive the
3311
+ * register flow against the database protocol, not HTTP.
3312
+ *
3313
+ * Mirrors `handleRegister`'s invariants:
3314
+ * - Refuses if any non-deleted user already exists on the cloud.
3315
+ * - Refuses if the `__lattice_team_identity` singleton already exists.
3316
+ *
3317
+ * On success returns the same shape the HTTP route returns so the
3318
+ * caller (`TeamsClient.upgradeToTeamCloud`) can use either path
3319
+ * interchangeably.
3320
+ */
3321
+ declare function registerDirectViaPostgres(cloudUrl: string, email: string, name: string, teamName: string): Promise<DirectRegisterResult>;
3322
+
3323
+ export { type ApplyWriteResult, type AuditEvent, type AutoUpdateResult, type BelongsToRelation, type BelongsToSource, type BlobMetadata, type BuiltinTemplateName, type ChangeEntry, type ChangelogOptions, type CleanupOptions, type CleanupResult, type CloudProbeResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type DirectRegisterResult, type EmbeddingsConfig, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileManifestInfo, type EntityFileSource, type EntityFileSpec, type EntityProfileField, type EntityProfileSection, type EntityProfileTemplate, type EntityRenderSpec, type EntityRenderTemplate, type EntitySectionPerRow, type EntitySectionsTemplate, type EntityTableColumn, type EntityTableTemplate, type Filter, type FilterOp, type HasManyRelation, type HasManySource, InMemoryStateStore, type InitOptions, type InviteResponse, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type LinkOptions, type ManyToManySource, type MarkdownTableColumn, type MemberSummary, type Migration, type MigrationOptions, type MigrationProgress, type MigrationResult, type MultiTableDefinition, NATIVE_ENTITY_DEFS, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, PostgresAdapter, type PostgresAdapterOptions, type PreparedStatement, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type RedeemResponse, type RegisterResponse, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type ReportConfig, type ReportResult, type ReportSection, type ReportSectionResult, type ReverseSeedDetection, type ReverseSeedResult, type ReverseSeedTableResult, type ReverseSyncError, type ReverseSyncResult, type ReverseSyncUpdate, type RewardScores, type Row, SQLiteAdapter, type SearchOptions, type SearchResult, type SecurityOptions, type SeedConfig, type SeedLinkSpec, type SeedResult, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type StorageAdapter, type SyncResult, type TableDefinition, type TeamConnection, type TeamSummary, TeamsClient, TeamsHttpError, type TemplateRenderSpec, type UpsertByNaturalKeyOptions, type UserIdentity, type WatchOptions, type WriteHook, type WriteHookContext, type WritebackDefinition, type WritebackStateStore, type WritebackValidationResult, applyTokenBudget, applyWriteEntry, archiveLocalSqlite, attachBlob, autoUpdate, configDir, contentHash, createReadOnlyHeader, createSQLiteStateStore, decrypt, deleteDbCredential, deleteToken, deriveKey, encrypt, entityFileNames, estimateTokens, fixSchemaConflicts, frontmatter, generateEntryId, generateWriteEntryId, getDbCredential, getOrCreateMasterKey, hashFile, isEncrypted, isPostgresUrl, isV1EntityFiles, listDbCredentials, listTokens, manifestPath, markdownTable, migrateLatticeData, normalizeEntityFiles, openTargetLatticeForMigration, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, probeCloud, readIdentity, readManifest, readToken, registerDirectViaPostgres, registerNativeEntities, saveDbCredential, slugify, truncate, validateEntryId, writeIdentity, writeManifest, writeToken };
package/dist/index.js CHANGED
@@ -5499,6 +5499,139 @@ async function attachBlob(srcPath, latticeRoot) {
5499
5499
  import { randomUUID } from "crypto";
5500
5500
 
5501
5501
  // src/teams/internal-tables.ts
5502
+ var CLOUD_INTERNAL_TABLE_DEFS = {
5503
+ __lattice_users: {
5504
+ columns: {
5505
+ id: "TEXT PRIMARY KEY",
5506
+ email: "TEXT NOT NULL",
5507
+ name: "TEXT",
5508
+ created_at: "TEXT NOT NULL",
5509
+ updated_at: "TEXT NOT NULL",
5510
+ deleted_at: "TEXT"
5511
+ },
5512
+ // Uniqueness enforced at the route layer (we soft-delete by setting
5513
+ // deleted_at, so a column-level UNIQUE blocks re-registration after
5514
+ // a delete). The register/redeem handlers check for an existing
5515
+ // non-deleted user with the same email before insert.
5516
+ render: () => "",
5517
+ outputFile: ".lattice-teams/users.md"
5518
+ },
5519
+ __lattice_api_tokens: {
5520
+ columns: {
5521
+ id: "TEXT PRIMARY KEY",
5522
+ user_id: "TEXT NOT NULL",
5523
+ token_hash: "TEXT NOT NULL UNIQUE",
5524
+ name: "TEXT",
5525
+ created_at: "TEXT NOT NULL",
5526
+ last_used_at: "TEXT",
5527
+ revoked_at: "TEXT"
5528
+ },
5529
+ render: () => "",
5530
+ outputFile: ".lattice-teams/tokens.md"
5531
+ },
5532
+ __lattice_team: {
5533
+ columns: {
5534
+ id: "TEXT PRIMARY KEY",
5535
+ name: "TEXT NOT NULL",
5536
+ created_by_user_id: "TEXT NOT NULL",
5537
+ created_at: "TEXT NOT NULL",
5538
+ updated_at: "TEXT NOT NULL",
5539
+ deleted_at: "TEXT"
5540
+ },
5541
+ render: () => "",
5542
+ outputFile: ".lattice-teams/teams.md"
5543
+ },
5544
+ // Singleton mirror of __lattice_team — populated by `createTeam` when
5545
+ // the first (and only) team is established on this cloud. One row per
5546
+ // DB, id='singleton'. Lets GET /api/team / DELETE /api/team / POST
5547
+ // /api/team/invitations resolve the active team without scanning.
5548
+ // The multi-team `__lattice_team` table remains the source of truth
5549
+ // for the row/object/changes routes until Step 8 deprecates them.
5550
+ __lattice_team_identity: {
5551
+ columns: {
5552
+ id: "TEXT PRIMARY KEY",
5553
+ team_id: "TEXT NOT NULL",
5554
+ team_name: "TEXT NOT NULL",
5555
+ creator_email: "TEXT NOT NULL",
5556
+ created_at: "TEXT NOT NULL"
5557
+ },
5558
+ primaryKey: "id",
5559
+ render: () => "",
5560
+ outputFile: ".lattice-teams/team-identity.md"
5561
+ },
5562
+ __lattice_team_members: {
5563
+ columns: {
5564
+ team_id: "TEXT NOT NULL",
5565
+ user_id: "TEXT NOT NULL",
5566
+ role: "TEXT NOT NULL CHECK (role IN ('creator', 'member'))",
5567
+ joined_at: "TEXT NOT NULL"
5568
+ },
5569
+ primaryKey: ["team_id", "user_id"],
5570
+ render: () => "",
5571
+ outputFile: ".lattice-teams/members.md"
5572
+ },
5573
+ __lattice_invitations: {
5574
+ columns: {
5575
+ id: "TEXT PRIMARY KEY",
5576
+ team_id: "TEXT NOT NULL",
5577
+ token_hash: "TEXT NOT NULL UNIQUE",
5578
+ // Email the invitation is addressed to — redeem requires the
5579
+ // caller's identity email to match. Email-binding makes invite
5580
+ // codes safe to share over a channel that's not strongly
5581
+ // authenticated; the recipient still has to be the recipient.
5582
+ invitee_email: "TEXT NOT NULL",
5583
+ invited_by_user_id: "TEXT NOT NULL",
5584
+ created_at: "TEXT NOT NULL",
5585
+ expires_at: "TEXT",
5586
+ redeemed_at: "TEXT",
5587
+ redeemed_by_user_id: "TEXT"
5588
+ },
5589
+ render: () => "",
5590
+ outputFile: ".lattice-teams/invitations.md"
5591
+ },
5592
+ __lattice_shared_objects: {
5593
+ columns: {
5594
+ team_id: "TEXT NOT NULL",
5595
+ table_name: "TEXT NOT NULL",
5596
+ schema_spec_json: "TEXT NOT NULL",
5597
+ schema_version: "INTEGER NOT NULL",
5598
+ created_by_user_id: "TEXT NOT NULL",
5599
+ created_at: "TEXT NOT NULL",
5600
+ updated_at: "TEXT NOT NULL",
5601
+ deleted_at: "TEXT"
5602
+ },
5603
+ primaryKey: ["team_id", "table_name"],
5604
+ render: () => "",
5605
+ outputFile: ".lattice-teams/shared-objects.md"
5606
+ },
5607
+ __lattice_change_log: {
5608
+ columns: {
5609
+ id: "TEXT PRIMARY KEY",
5610
+ seq: "INTEGER NOT NULL",
5611
+ team_id: "TEXT NOT NULL",
5612
+ table_name: "TEXT",
5613
+ pk: "TEXT",
5614
+ op: "TEXT NOT NULL",
5615
+ payload_json: "TEXT",
5616
+ owner_user_id: "TEXT",
5617
+ created_at: "TEXT NOT NULL"
5618
+ },
5619
+ render: () => "",
5620
+ outputFile: ".lattice-teams/change-log.md"
5621
+ },
5622
+ __lattice_row_links: {
5623
+ columns: {
5624
+ team_id: "TEXT NOT NULL",
5625
+ table_name: "TEXT NOT NULL",
5626
+ pk: "TEXT NOT NULL",
5627
+ owner_user_id: "TEXT NOT NULL",
5628
+ linked_at: "TEXT NOT NULL"
5629
+ },
5630
+ primaryKey: ["team_id", "table_name", "pk"],
5631
+ render: () => "",
5632
+ outputFile: ".lattice-teams/row-links.md"
5633
+ }
5634
+ };
5502
5635
  var LOCAL_INTERNAL_TABLE_DEFS = {
5503
5636
  __lattice_team_connections: {
5504
5637
  columns: {
@@ -5658,11 +5791,18 @@ async function probeCloud(targetUrl) {
5658
5791
  }
5659
5792
  return teamName !== void 0 ? { reachable: true, dialect, teamEnabled, teamName } : { reachable: true, dialect, teamEnabled };
5660
5793
  } catch (e) {
5794
+ const err = e;
5795
+ const parts = [];
5796
+ if (err.code) parts.push(`[${err.code}]`);
5797
+ if (err.message) parts.push(err.message);
5798
+ if (err.routine && !err.message.includes(err.routine)) {
5799
+ parts.push(`(routine: ${err.routine})`);
5800
+ }
5661
5801
  return {
5662
5802
  reachable: false,
5663
5803
  dialect,
5664
5804
  teamEnabled: false,
5665
- error: e.message
5805
+ error: parts.join(" ") || "unknown"
5666
5806
  };
5667
5807
  } finally {
5668
5808
  if (probe) {
@@ -5674,6 +5814,98 @@ async function probeCloud(targetUrl) {
5674
5814
  }
5675
5815
  }
5676
5816
 
5817
+ // src/teams/server/auth.ts
5818
+ import { createHash as createHash5, randomBytes as randomBytes4, timingSafeEqual } from "crypto";
5819
+ var TOKEN_PREFIX = "lat_";
5820
+ var TOKEN_BYTES = 32;
5821
+ function hashToken(rawToken) {
5822
+ return createHash5("sha256").update(rawToken).digest("hex");
5823
+ }
5824
+ function generateToken() {
5825
+ const raw = `${TOKEN_PREFIX}${randomBytes4(TOKEN_BYTES).toString("hex")}`;
5826
+ return { raw, hash: hashToken(raw) };
5827
+ }
5828
+
5829
+ // src/teams/register-direct.ts
5830
+ function isPostgresUrl(url) {
5831
+ return /^postgres(ql)?:\/\//i.test(url);
5832
+ }
5833
+ async function registerDirectViaPostgres(cloudUrl, email, name, teamName) {
5834
+ if (!isPostgresUrl(cloudUrl)) {
5835
+ throw new Error(
5836
+ `registerDirectViaPostgres: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
5837
+ );
5838
+ }
5839
+ const db = new Lattice(cloudUrl);
5840
+ try {
5841
+ await db.init();
5842
+ for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
5843
+ await db.defineLate(table, def);
5844
+ }
5845
+ const existing = await db.query("__lattice_users", {
5846
+ filters: [{ col: "deleted_at", op: "isNull" }],
5847
+ limit: 1
5848
+ });
5849
+ if (existing.length > 0) {
5850
+ throw new Error(
5851
+ "Registration is disabled. This cloud already has users \u2014 join via invitation."
5852
+ );
5853
+ }
5854
+ let identity = null;
5855
+ try {
5856
+ identity = await db.get("__lattice_team_identity", "singleton");
5857
+ } catch {
5858
+ identity = null;
5859
+ }
5860
+ if (identity) {
5861
+ throw new Error("This cloud already has a team. Use Connect to existing cloud instead.");
5862
+ }
5863
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5864
+ const userId = await db.insert("__lattice_users", {
5865
+ email,
5866
+ name,
5867
+ created_at: now,
5868
+ updated_at: now
5869
+ });
5870
+ const { raw, hash } = generateToken();
5871
+ await db.insert("__lattice_api_tokens", {
5872
+ user_id: userId,
5873
+ token_hash: hash,
5874
+ name: `creator:${teamName}`,
5875
+ created_at: now
5876
+ });
5877
+ const teamId = await db.insert("__lattice_team", {
5878
+ name: teamName,
5879
+ created_by_user_id: userId,
5880
+ created_at: now,
5881
+ updated_at: now
5882
+ });
5883
+ await db.insert("__lattice_team_members", {
5884
+ team_id: teamId,
5885
+ user_id: userId,
5886
+ role: "creator",
5887
+ joined_at: now
5888
+ });
5889
+ await db.insert("__lattice_team_identity", {
5890
+ id: "singleton",
5891
+ team_id: teamId,
5892
+ team_name: teamName,
5893
+ creator_email: email,
5894
+ created_at: now
5895
+ });
5896
+ return {
5897
+ user: { id: userId, email, name },
5898
+ raw_token: raw,
5899
+ team: { id: teamId, name: teamName, role: "creator" }
5900
+ };
5901
+ } finally {
5902
+ try {
5903
+ db.close();
5904
+ } catch {
5905
+ }
5906
+ }
5907
+ }
5908
+
5677
5909
  // src/teams/client.ts
5678
5910
  var TeamsClient = class {
5679
5911
  constructor(local) {
@@ -5769,15 +6001,22 @@ var TeamsClient = class {
5769
6001
  return { probe };
5770
6002
  }
5771
6003
  /**
5772
- * Upgrade an already-connected cloud DB to a team DB. Runs the
5773
- * atomic `POST /api/auth/register` flow against the cloud URL
5774
- * stored under `label`. Writes the resulting bearer to
5775
- * `~/.lattice/keys/<label>.token`. Caller is expected to call
5776
- * `saveConnection` if they also want the local
5777
- * `__lattice_team_connections` row populated.
6004
+ * Upgrade an already-connected cloud DB to a team DB. Two paths
6005
+ * depending on the cloud URL's scheme:
6006
+ *
6007
+ * - `http(s)://…` POST to the cloud's `/api/auth/register` endpoint
6008
+ * (`lattice serve --team-cloud` is fronting the Postgres).
6009
+ * - `postgres(ql)://…` drive the same INSERT sequence directly
6010
+ * against the cloud Postgres via {@link registerDirectViaPostgres}.
6011
+ * The HTTP path can't be used here because the browser's Fetch
6012
+ * API refuses URLs with embedded credentials.
6013
+ *
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.
5778
6017
  */
5779
6018
  async upgradeToTeamCloud(opts) {
5780
- const reg = await this.register(opts.cloudUrl, opts.email, opts.displayName, opts.teamName);
6019
+ 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);
5781
6020
  writeToken(opts.label, reg.raw_token);
5782
6021
  return reg;
5783
6022
  }
@@ -6444,6 +6683,7 @@ export {
6444
6683
  getOrCreateMasterKey,
6445
6684
  hashFile,
6446
6685
  isEncrypted,
6686
+ isPostgresUrl,
6447
6687
  isV1EntityFiles,
6448
6688
  listDbCredentials,
6449
6689
  listTokens,
@@ -6461,6 +6701,7 @@ export {
6461
6701
  readIdentity,
6462
6702
  readManifest,
6463
6703
  readToken,
6704
+ registerDirectViaPostgres,
6464
6705
  registerNativeEntities,
6465
6706
  saveDbCredential,
6466
6707
  slugify,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "1.13.1",
3
+ "version": "1.13.3",
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",