backthread 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "backthread",
3
3
  "displayName": "Backthread",
4
4
  "description": "Backthread helps you understand your codebase while AI ships features. It captures the why behind every Claude Code session so you can ask \"how does X work?\" without digging through PRs.",
5
- "version": "0.6.0",
5
+ "version": "0.7.0",
6
6
  "author": {
7
7
  "name": "Backthread"
8
8
  },
@@ -6892,109 +6892,6 @@ import { realpathSync } from "node:fs";
6892
6892
  // src/login.ts
6893
6893
  import { hostname } from "node:os";
6894
6894
 
6895
- // src/loopback.ts
6896
- import { createServer } from "node:http";
6897
- import { randomBytes } from "node:crypto";
6898
- function generateState() {
6899
- return base64url(randomBytes(32));
6900
- }
6901
- function base64url(bytes) {
6902
- return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6903
- }
6904
- function validateCallback(method, rawUrl, expectedState) {
6905
- if ((method ?? "").toUpperCase() !== "GET") return { ok: false, reason: "bad_method" };
6906
- let url2;
6907
- try {
6908
- url2 = new URL(rawUrl ?? "", "http://127.0.0.1");
6909
- } catch {
6910
- return { ok: false, reason: "wrong_path" };
6911
- }
6912
- if (url2.pathname !== "/callback") return { ok: false, reason: "wrong_path" };
6913
- const errorParam = url2.searchParams.get("error");
6914
- if (errorParam) return { ok: false, reason: "error_param", error: errorParam };
6915
- const state = url2.searchParams.get("state");
6916
- if (state === null || state !== expectedState) return { ok: false, reason: "state_mismatch" };
6917
- const token = url2.searchParams.get("token");
6918
- if (!token || !/^backthread_pat_[A-Za-z0-9_-]+$/.test(token)) {
6919
- return { ok: false, reason: "missing_token" };
6920
- }
6921
- return { ok: true, token };
6922
- }
6923
- function resultPage(ok) {
6924
- const title = ok ? "You\u2019re connected" : "Something went wrong";
6925
- const body = ok ? "Backthread is now authorized on this device. You can close this tab and return to your terminal." : "Backthread couldn\u2019t finish authorizing this device. Close this tab and run <code>backthread login</code> again.";
6926
- return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
6927
- <style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:18vh auto;padding:0 1.5rem;color:#18181b}
6928
- h1{font-size:1.25rem}code{background:#f4f4f5;padding:.1em .3em;border-radius:4px}</style></head>
6929
- <body><h1>${title}</h1><p>${body}</p></body></html>`;
6930
- }
6931
- function startLoopbackServer() {
6932
- return new Promise((resolveStart, rejectStart) => {
6933
- const state = generateState();
6934
- let outcome = null;
6935
- let onOutcome = null;
6936
- const deliver = (o) => {
6937
- if (outcome) return;
6938
- outcome = o;
6939
- onOutcome?.();
6940
- };
6941
- const server = createServer((req, res) => {
6942
- const result = validateCallback(req.method, req.url, state);
6943
- if (!result.ok && result.reason === "wrong_path") {
6944
- res.writeHead(404, { "Content-Type": "text/plain" });
6945
- res.end("not found");
6946
- return;
6947
- }
6948
- res.writeHead(result.ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
6949
- res.end(resultPage(result.ok));
6950
- if (result.ok && result.token) {
6951
- deliver({ token: result.token });
6952
- } else {
6953
- deliver({
6954
- error: new Error(
6955
- result.reason === "error_param" ? `web app reported: ${result.error}` : `invalid callback (${result.reason})`
6956
- )
6957
- });
6958
- }
6959
- });
6960
- server.on("error", (err) => {
6961
- if (outcome === null && onOutcome === null) rejectStart(err);
6962
- else deliver({ error: err });
6963
- });
6964
- server.listen(0, "127.0.0.1", () => {
6965
- const addr = server.address();
6966
- const port = addr.port;
6967
- const close = () => {
6968
- try {
6969
- server.close();
6970
- } catch {
6971
- }
6972
- };
6973
- const waitForToken = (timeoutMs = 5 * 6e4) => new Promise((resolve, reject) => {
6974
- const finish = () => {
6975
- if (!outcome) return;
6976
- close();
6977
- if ("token" in outcome) resolve(outcome.token);
6978
- else reject(outcome.error);
6979
- };
6980
- const timer = setTimeout(() => {
6981
- close();
6982
- reject(new Error("timed out waiting for the browser to authorize this device"));
6983
- }, timeoutMs);
6984
- onOutcome = () => {
6985
- clearTimeout(timer);
6986
- finish();
6987
- };
6988
- if (outcome) {
6989
- clearTimeout(timer);
6990
- finish();
6991
- }
6992
- });
6993
- resolveStart({ port, state, waitForToken, close });
6994
- });
6995
- });
6996
- }
6997
-
6998
6895
  // src/urls.ts
6999
6896
  var DEFAULT_APP_URL = "https://app.backthread.dev";
7000
6897
  function appBaseUrl(env = process.env) {
@@ -7002,10 +6899,11 @@ function appBaseUrl(env = process.env) {
7002
6899
  if (override && override.trim().length > 0) return override.replace(/\/+$/, "");
7003
6900
  return DEFAULT_APP_URL;
7004
6901
  }
7005
- function buildCliAuthUrl(port, state, env = process.env) {
6902
+ function buildCliAuthUrl(session, clientPubKey, env = process.env, label) {
7006
6903
  const u = new URL("/cli-auth", appBaseUrl(env));
7007
- u.searchParams.set("port", String(port));
7008
- u.searchParams.set("state", state);
6904
+ u.searchParams.set("session", session);
6905
+ u.searchParams.set("k", clientPubKey);
6906
+ if (label && label.trim().length > 0) u.searchParams.set("label", label.trim());
7009
6907
  return u.toString();
7010
6908
  }
7011
6909
  var DEFAULT_WORKER_URL = "https://clew-ingest-worker.arpy-183.workers.dev";
@@ -7032,6 +6930,9 @@ function buildIngestDecisionsUrl(env = process.env) {
7032
6930
  function buildOnboardingStateUrl(env = process.env) {
7033
6931
  return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/onboarding-state`).toString();
7034
6932
  }
6933
+ function buildCliAuthPollUrl(env = process.env) {
6934
+ return new URL(`${functionsBaseUrl(env).replace(/\/+$/, "")}/cli-auth-poll`).toString();
6935
+ }
7035
6936
  function buildRepoDeepLink(owner, name, env = process.env) {
7036
6937
  const base = appBaseUrl(env);
7037
6938
  return `${base}/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
@@ -7278,22 +7179,117 @@ function configLocationHint(env) {
7278
7179
  return env.BACKTHREAD_CONFIG_DIR ? `${env.BACKTHREAD_CONFIG_DIR}/config.json` : "~/.backthread/config.json";
7279
7180
  }
7280
7181
 
7182
+ // src/cliAuthCrypto.ts
7183
+ import { createECDH, hkdfSync, createDecipheriv, randomBytes } from "node:crypto";
7184
+ var HKDF_SALT = Buffer.from("backthread-cli-auth");
7185
+ var HKDF_INFO = Buffer.from("device-token-v1");
7186
+ function generateSessionId() {
7187
+ return toB64url(randomBytes(32));
7188
+ }
7189
+ function generateEphemeralKeypair() {
7190
+ const ecdh = createECDH("prime256v1");
7191
+ ecdh.generateKeys();
7192
+ return { ecdh, publicKeyB64url: toB64url(ecdh.getPublicKey()) };
7193
+ }
7194
+ function decryptToken(enc, ecdh) {
7195
+ const pagePub = fromB64url(enc.page_ephemeral_pubkey);
7196
+ const shared = ecdh.computeSecret(pagePub);
7197
+ const aesKey = Buffer.from(hkdfSync("sha256", shared, HKDF_SALT, HKDF_INFO, 32));
7198
+ const ctFull = fromB64url(enc.ciphertext);
7199
+ const tag = ctFull.subarray(ctFull.length - 16);
7200
+ const ct = ctFull.subarray(0, ctFull.length - 16);
7201
+ const iv = fromB64url(enc.iv);
7202
+ const decipher = createDecipheriv("aes-256-gcm", aesKey, iv);
7203
+ decipher.setAuthTag(tag);
7204
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
7205
+ }
7206
+ function toB64url(buf) {
7207
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
7208
+ }
7209
+ function fromB64url(s) {
7210
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
7211
+ }
7212
+
7213
+ // src/cliAuthPoll.ts
7214
+ var TOKEN_RE = /^backthread_pat_[A-Za-z0-9_-]+$/;
7215
+ async function pollForToken(sessionId, keypair, opts = {}) {
7216
+ const env = opts.env ?? process.env;
7217
+ const doFetch = opts.fetchImpl ?? fetch;
7218
+ const interval = opts.intervalMs ?? 1500;
7219
+ const timeout = opts.timeoutMs ?? 5 * 6e4;
7220
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
7221
+ const now = opts.now ?? (() => Date.now());
7222
+ const url2 = buildCliAuthPollUrl(env);
7223
+ const deadline = now() + timeout;
7224
+ while (now() < deadline) {
7225
+ let res;
7226
+ try {
7227
+ res = await doFetch(url2, {
7228
+ method: "POST",
7229
+ headers: { "Content-Type": "application/json", ...versionHeaders() },
7230
+ // The CLI is the CONSUMING poller (default mode) — the browser peeks separately.
7231
+ body: JSON.stringify({ session_id: sessionId })
7232
+ });
7233
+ } catch {
7234
+ await sleep(interval);
7235
+ continue;
7236
+ }
7237
+ if (res.status === 429 || res.status >= 500) {
7238
+ await sleep(interval);
7239
+ continue;
7240
+ }
7241
+ const body = await res.json().catch(() => null);
7242
+ const status = typeof body?.status === "string" ? body.status : null;
7243
+ if (status === "ready") {
7244
+ const enc = extractPayload(body);
7245
+ if (!enc) return { ok: false, reason: "error", message: "incomplete token payload from the server" };
7246
+ let token;
7247
+ try {
7248
+ token = decryptToken(enc, keypair.ecdh);
7249
+ } catch {
7250
+ return { ok: false, reason: "error", message: "could not decrypt the token (key mismatch)" };
7251
+ }
7252
+ if (!TOKEN_RE.test(token)) {
7253
+ return { ok: false, reason: "error", message: "the decrypted token was malformed" };
7254
+ }
7255
+ return { ok: true, token };
7256
+ }
7257
+ if (status === "expired") {
7258
+ return { ok: false, reason: "expired", message: "the login session expired before you authorized" };
7259
+ }
7260
+ if (status === "consumed") {
7261
+ return { ok: false, reason: "error", message: "this login was already used \u2014 start a fresh `bt login`" };
7262
+ }
7263
+ await sleep(interval);
7264
+ }
7265
+ return { ok: false, reason: "timeout", message: "timed out waiting for the browser to authorize this device" };
7266
+ }
7267
+ function extractPayload(body) {
7268
+ if (!body) return null;
7269
+ const { page_ephemeral_pubkey, iv, ciphertext } = body;
7270
+ if (typeof page_ephemeral_pubkey === "string" && typeof iv === "string" && typeof ciphertext === "string") {
7271
+ return { page_ephemeral_pubkey, iv, ciphertext };
7272
+ }
7273
+ return null;
7274
+ }
7275
+
7281
7276
  // src/login.ts
7282
7277
  async function login(opts = {}) {
7283
7278
  const env = opts.env ?? process.env;
7284
7279
  const log = opts.log ?? ((m) => console.error(m));
7285
7280
  if (opts.claim) {
7286
- const result = await exchangeClaim(opts.claim, { env, label: deviceLabel() });
7287
- log(result.message);
7288
- return { ok: result.ok, message: result.message };
7281
+ const result2 = await exchangeClaim(opts.claim, { env, label: deviceLabel() });
7282
+ log(result2.message);
7283
+ return { ok: result2.ok, message: result2.message };
7289
7284
  }
7290
7285
  if (opts.device) {
7291
7286
  return deviceLogin(log);
7292
7287
  }
7293
- const handle = await startLoopbackServer();
7294
- const authUrl = buildCliAuthUrl(handle.port, handle.state, env);
7288
+ const sessionId = generateSessionId();
7289
+ const keypair = generateEphemeralKeypair();
7290
+ const authUrl = buildCliAuthUrl(sessionId, keypair.publicKeyB64url, env, deviceLabel());
7295
7291
  log("Opening your browser to authorize this device\u2026");
7296
- log(`If it doesn't open, visit:
7292
+ log(`If it doesn't open \u2014 or you're on a remote/SSH box \u2014 open this on any device:
7297
7293
 
7298
7294
  ${authUrl}
7299
7295
  `);
@@ -7303,17 +7299,21 @@ async function login(opts = {}) {
7303
7299
  log("(Could not open a browser automatically \u2014 use the URL above.)");
7304
7300
  }
7305
7301
  }
7306
- let token;
7307
- try {
7308
- token = await handle.waitForToken();
7309
- } catch (err) {
7310
- handle.close();
7311
- return { ok: false, message: `Login failed: ${err.message}` };
7302
+ const poll = opts.pollImpl ?? pollForToken;
7303
+ const result = await poll(sessionId, keypair, { env });
7304
+ if (!result.ok) {
7305
+ return { ok: false, message: pollFailureMessage(result) };
7312
7306
  }
7313
- await updateConfig({ device_token: token }, env);
7307
+ await updateConfig({ device_token: result.token }, env);
7314
7308
  log(`Authorized. Token stored in ${configLocationHint2(env)} (chmod 0600).`);
7315
7309
  return { ok: true, message: "Device authorized and token stored." };
7316
7310
  }
7311
+ function pollFailureMessage(result) {
7312
+ if (result.reason === "expired" || result.reason === "timeout") {
7313
+ return `Login ${result.reason === "expired" ? "expired" : "timed out"}: ${result.message}. Re-run \`backthread login\` to try again.`;
7314
+ }
7315
+ return `Login failed: ${result.message}`;
7316
+ }
7317
7317
  function deviceLabel() {
7318
7318
  try {
7319
7319
  const h = hostname();
@@ -7330,10 +7330,11 @@ function deviceLogin(log) {
7330
7330
  [
7331
7331
  "Headless (--device) login is not available yet.",
7332
7332
  "",
7333
- "The device-code fallback needs a server-side device-authorization endpoint",
7334
- "that ships in a later task. For now, run `backthread login` on a machine with a",
7335
- "browser, or mint a token from the web app (Account \u2192 Connected devices) and",
7336
- 'place it in ~/.backthread/config.json under "device_token".'
7333
+ "You usually don\u2019t need it: `backthread login` prints a URL you can open on ANY",
7334
+ "device (phone, laptop) \u2014 the token is delivered by polling, so the browser doesn\u2019t",
7335
+ "have to be on this machine. For a fully browserless box, mint a token from the web",
7336
+ "app (Account \u2192 Connected devices) and place it in ~/.backthread/config.json under",
7337
+ '"device_token", or use `--claim <code>` from the web app.'
7337
7338
  ].join("\n")
7338
7339
  );
7339
7340
  return { ok: false, message: "--device fallback not implemented yet." };
@@ -14533,8 +14534,8 @@ function uint8ArrayToBase64(bytes) {
14533
14534
  }
14534
14535
  return btoa(binaryString);
14535
14536
  }
14536
- function base64urlToUint8Array(base64url4) {
14537
- const base643 = base64url4.replace(/-/g, "+").replace(/_/g, "/");
14537
+ function base64urlToUint8Array(base64url3) {
14538
+ const base643 = base64url3.replace(/-/g, "+").replace(/_/g, "/");
14538
14539
  const padding = "=".repeat((4 - base643.length % 4) % 4);
14539
14540
  return base64ToUint8Array(base643 + padding);
14540
14541
  }
@@ -14791,7 +14792,7 @@ var safeDecodeAsync = /* @__PURE__ */ _safeDecodeAsync($ZodRealError);
14791
14792
  var regexes_exports = {};
14792
14793
  __export(regexes_exports, {
14793
14794
  base64: () => base64,
14794
- base64url: () => base64url2,
14795
+ base64url: () => base64url,
14795
14796
  bigint: () => bigint,
14796
14797
  boolean: () => boolean,
14797
14798
  browserEmail: () => browserEmail,
@@ -14886,7 +14887,7 @@ var mac = (delimiter) => {
14886
14887
  var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
14887
14888
  var cidrv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/;
14888
14889
  var base64 = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/;
14889
- var base64url2 = /^[A-Za-z0-9_-]*$/;
14890
+ var base64url = /^[A-Za-z0-9_-]*$/;
14890
14891
  var hostname2 = /^(?=.{1,253}\.?$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$/;
14891
14892
  var domain = /^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
14892
14893
  var httpProtocol = /^https?$/;
@@ -15900,14 +15901,14 @@ var $ZodBase64 = /* @__PURE__ */ $constructor("$ZodBase64", (inst, def) => {
15900
15901
  };
15901
15902
  });
15902
15903
  function isValidBase64URL(data) {
15903
- if (!base64url2.test(data))
15904
+ if (!base64url.test(data))
15904
15905
  return false;
15905
15906
  const base643 = data.replace(/[-_]/g, (c) => c === "-" ? "+" : "/");
15906
15907
  const padded = base643.padEnd(Math.ceil(base643.length / 4) * 4, "=");
15907
15908
  return isValidBase64(padded);
15908
15909
  }
15909
15910
  var $ZodBase64URL = /* @__PURE__ */ $constructor("$ZodBase64URL", (inst, def) => {
15910
- def.pattern ?? (def.pattern = base64url2);
15911
+ def.pattern ?? (def.pattern = base64url);
15911
15912
  $ZodStringFormat.init(inst, def);
15912
15913
  inst._zod.bag.contentEncoding = "base64url";
15913
15914
  inst._zod.check = (payload) => {
@@ -25936,7 +25937,7 @@ __export(external_exports, {
25936
25937
  any: () => any,
25937
25938
  array: () => array,
25938
25939
  base64: () => base642,
25939
- base64url: () => base64url3,
25940
+ base64url: () => base64url2,
25940
25941
  bigint: () => bigint2,
25941
25942
  boolean: () => boolean2,
25942
25943
  catch: () => _catch2,
@@ -26166,7 +26167,7 @@ __export(schemas_exports2, {
26166
26167
  any: () => any,
26167
26168
  array: () => array,
26168
26169
  base64: () => base642,
26169
- base64url: () => base64url3,
26170
+ base64url: () => base64url2,
26170
26171
  bigint: () => bigint2,
26171
26172
  boolean: () => boolean2,
26172
26173
  catch: () => _catch2,
@@ -26788,7 +26789,7 @@ var ZodBase64URL = /* @__PURE__ */ $constructor("ZodBase64URL", (inst, def) => {
26788
26789
  $ZodBase64URL.init(inst, def);
26789
26790
  ZodStringFormat.init(inst, def);
26790
26791
  });
26791
- function base64url3(params) {
26792
+ function base64url2(params) {
26792
26793
  return _base64url(ZodBase64URL, params);
26793
26794
  }
26794
26795
  var ZodE164 = /* @__PURE__ */ $constructor("ZodE164", (inst, def) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backthread",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Backthread helps you understand your codebase while AI ships features. The CLI captures the why behind every AI session and lets you ask how your codebase works, right from the terminal.",
5
5
  "license": "MIT",
6
6
  "author": "Backthread",