friend-assistant 0.1.14 → 0.1.15

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
@@ -85,6 +85,8 @@ Every command starts with `friend`. If you run Friend from the source code, writ
85
85
 
86
86
  | Command | What it does | Use it when |
87
87
  |---|---|---|
88
+ | `friend password` | Sets a new sign-in password (asks twice, nothing is shown while you type). The old one is not needed, because you are at this PC, so it is also the way back in if you forgot it. Every browser and phone is signed out. | You forgot the password, or want a new one. From the screen: Esc → Settings → Security. |
89
+ | `friend pin` | Sets a new PIN, the number Friend asks for before risky actions. | You forgot the PIN, or want a new one. |
88
90
  | `friend daemon start` | Runs Friend in the background, with no terminal window needed. Prints the address, keeps a log in the data folder (`friend.log`). | You want Friend always available, even after you close the terminal. |
89
91
  | `friend daemon stop` | Stops the background Friend and the speech engine with it. | Shutting it down. |
90
92
  | `friend daemon restart` | Stops it and starts it again. | After changing a setting such as `friend lan on` or a different model. |
package/dist/BUILD.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.14",
4
- "builtAt": "2026-10-10T06:29:10.737Z"
3
+ "version": "0.1.15",
4
+ "builtAt": "2026-10-10T11:42:54.414Z"
5
5
  }
package/dist/friend.mjs CHANGED
@@ -3031,7 +3031,7 @@ async function verifySecret(secret, stored) {
3031
3031
  const actual = await scryptAsync(secret, Buffer.from(saltHex, "hex"));
3032
3032
  return expected.length === actual.length && timingSafeEqual(expected, actual);
3033
3033
  }
3034
- var Auth = class {
3034
+ var Auth = class _Auth {
3035
3035
  constructor(db, audit, clock = Date.now) {
3036
3036
  this.db = db;
3037
3037
  this.audit = audit;
@@ -3116,6 +3116,48 @@ var Auth = class {
3116
3116
  else this.audit.log("owner", "pin.fail", { ip });
3117
3117
  return ok;
3118
3118
  }
3119
+ /** Checks a new password and/or PIN the same way setup does. Returns the problem, or null when both are fine. */
3120
+ static checkNewSecrets(input) {
3121
+ if (input.newPassword === void 0 && input.newPin === void 0) return "Give a new password or a new PIN.";
3122
+ if (input.newPin !== void 0 && !/^\d{6,}$/.test(input.newPin)) return "PIN must be at least 6 digits";
3123
+ if (input.newPassword !== void 0 && input.newPassword.length < 8) return "Password must be at least 8 characters";
3124
+ return null;
3125
+ }
3126
+ async store(input) {
3127
+ if (input.newPassword !== void 0) this.db.prepare("update owner set password_hash = ? where id = 1").run(await hashSecret(input.newPassword));
3128
+ if (input.newPin !== void 0) this.db.prepare("update owner set pin_hash = ? where id = 1").run(await hashSecret(input.newPin));
3129
+ }
3130
+ /**
3131
+ * Changes the password and/or PIN of a signed-in owner who proves it with the current password. Wrong guesses count toward
3132
+ * the same sign-in lock. A new password signs every other browser out (the one in `keepToken` stays signed in).
3133
+ */
3134
+ async changeSecrets(input, ip, keepToken) {
3135
+ const problem = _Auth.checkNewSecrets(input);
3136
+ if (problem) throw new Error(problem);
3137
+ const succeeded = this.begin("login", ip);
3138
+ const row = this.db.prepare("select password_hash h from owner where id = 1").get();
3139
+ const ok = row ? await verifySecret(input.current, row.h) : false;
3140
+ if (!ok) {
3141
+ this.audit.log("owner", "secrets.fail", { ip });
3142
+ throw new Error("Invalid credentials");
3143
+ }
3144
+ succeeded();
3145
+ await this.store(input);
3146
+ if (input.newPassword !== void 0) {
3147
+ if (keepToken) this.db.prepare("update sessions set revoked = 1 where token_hash != ?").run(sha256(keepToken));
3148
+ else this.db.prepare("update sessions set revoked = 1").run();
3149
+ }
3150
+ this.audit.log("owner", "secrets.changed", { password: input.newPassword !== void 0, pin: input.newPin !== void 0 });
3151
+ }
3152
+ /** For `friend password` / `friend pin` run on this PC: no current password needed (being on the PC is the proof). Signs everyone out. */
3153
+ async resetSecrets(input) {
3154
+ if (!this.hasOwner()) throw new Error("There is no owner yet. Run: friend setup");
3155
+ const problem = _Auth.checkNewSecrets(input);
3156
+ if (problem) throw new Error(problem);
3157
+ await this.store(input);
3158
+ if (input.newPassword !== void 0) this.db.prepare("update sessions set revoked = 1").run();
3159
+ this.audit.log("system", "secrets.reset", { password: input.newPassword !== void 0, pin: input.newPin !== void 0 });
3160
+ }
3119
3161
  createPairCode() {
3120
3162
  const code = randomBytes(5).toString("hex").toUpperCase();
3121
3163
  this.db.prepare("insert into pair_codes (code_hash, expires_at) values (?, ?)").run(sha256(code), this.clock() + PAIR_TTL_MS);
@@ -4078,6 +4120,30 @@ function createGateway(deps) {
4078
4120
  reply.clearCookie(COOKIE_NAME, { path: "/" });
4079
4121
  return { ok: true };
4080
4122
  });
4123
+ app.post("/api/account/secret", async (req, reply) => {
4124
+ reply.header("cache-control", "no-store");
4125
+ const token = req.cookies[COOKIE_NAME];
4126
+ if (!token || !auth.resolveSession(token)) return reply.code(401).send({ error: "Please sign in again." });
4127
+ const body = z10.object({ current: z10.string().min(1).max(1024), newPassword: z10.string().max(1024).optional(), newPin: z10.string().max(128).optional() }).safeParse(req.body);
4128
+ if (!body.success) return reply.code(400).send({ error: "Invalid input" });
4129
+ const { current, newPassword, newPin } = body.data;
4130
+ try {
4131
+ await auth.changeSecrets({ current, newPassword, newPin }, req.ip, token);
4132
+ return { ok: true, signedOutOthers: newPassword !== void 0 };
4133
+ } catch (e) {
4134
+ if (e instanceof LockoutError) {
4135
+ const seconds = Math.max(1, Math.ceil(e.retryAfterMs / 1e3));
4136
+ return reply.code(429).header("retry-after", seconds).send({ error: `Too many wrong passwords. Try again in ${waitText(seconds)}.`, retryAfterSeconds: seconds });
4137
+ }
4138
+ if (e.message === "Invalid credentials") {
4139
+ const state = auth.loginState(req.ip);
4140
+ const seconds = Math.ceil(state.retryAfterMs / 1e3);
4141
+ if (seconds > 0) return reply.code(401).send({ error: `Wrong password. Locked for ${waitText(seconds)}.`, triesLeft: 0, retryAfterSeconds: seconds });
4142
+ return reply.code(401).send({ error: "The current password is wrong.", triesLeft: state.triesLeft });
4143
+ }
4144
+ return reply.code(400).send({ error: e.message });
4145
+ }
4146
+ });
4081
4147
  const pairLimiter = new RateLimiter(Date.now);
4082
4148
  app.post("/api/pair", async (req, reply) => {
4083
4149
  const body = z10.object({ code: z10.string().min(1).max(64), name: z10.string().min(1).max(64) }).safeParse(req.body);
@@ -9594,7 +9660,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
9594
9660
  import crossSpawn5 from "cross-spawn";
9595
9661
  var PACKAGE = "friend-assistant";
9596
9662
  var real = {
9597
- current: () => true ? "0.1.14" : "development",
9663
+ current: () => true ? "0.1.15" : "development",
9598
9664
  latest: () => new Promise((resolve10) => {
9599
9665
  const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
9600
9666
  let out = "";
@@ -9772,6 +9838,35 @@ async function setupCommand3(io2) {
9772
9838
  }
9773
9839
  }
9774
9840
 
9841
+ // ../agent-cli/src/commands/secret.ts
9842
+ async function secretCommand(kind, io2) {
9843
+ const db = openDb(io2.dataDir);
9844
+ try {
9845
+ const auth = new Auth(db, new Audit(db));
9846
+ if (!auth.hasOwner()) {
9847
+ io2.err("There is no owner yet. Run: friend setup");
9848
+ return 1;
9849
+ }
9850
+ const label2 = kind === "password" ? "password" : "PIN";
9851
+ const first2 = await io2.ask(`New ${label2} (${kind === "password" ? "at least 8 characters" : "at least 6 digits"}): `, { hidden: true });
9852
+ const problem = Auth.checkNewSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
9853
+ if (problem) {
9854
+ io2.err(problem);
9855
+ return 1;
9856
+ }
9857
+ const again = await io2.ask(`Type the new ${label2} again: `, { hidden: true });
9858
+ if (again !== first2) {
9859
+ io2.err(`The two ${label2}s are not the same. Nothing was changed.`);
9860
+ return 1;
9861
+ }
9862
+ await auth.resetSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
9863
+ io2.out(kind === "password" ? "Password changed. Every browser and phone has been signed out; sign in again with the new password." : "PIN changed.");
9864
+ return 0;
9865
+ } finally {
9866
+ db.close();
9867
+ }
9868
+ }
9869
+
9775
9870
  // ../agent-cli/src/commands/status.ts
9776
9871
  async function statusCommand3(io2) {
9777
9872
  const cfg = loadConfig(io2.dataDir);
@@ -9796,6 +9891,8 @@ var USAGE4 = `Usage: friend <command>
9796
9891
  onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
9797
9892
  doctor check what this PC has and what is missing (changes nothing)
9798
9893
  setup create the owner account (password, PIN, assistant name)
9894
+ password change the sign-in password (signs every browser out)
9895
+ pin change the PIN for risky actions
9799
9896
  start run the gateway and a local device node
9800
9897
  daemon start|stop|restart|status run Friend in the background (no terminal needed), stop it, or see if it is running
9801
9898
  upgrade [--force] stop the background Friend, install the newest version, start it again
@@ -9821,6 +9918,10 @@ async function run(argv, io2) {
9821
9918
  switch (cmd) {
9822
9919
  case "setup":
9823
9920
  return await setupCommand3(io2);
9921
+ case "password":
9922
+ return await secretCommand("password", io2);
9923
+ case "pin":
9924
+ return await secretCommand("pin", io2);
9824
9925
  case "start":
9825
9926
  return await startCommand(io2);
9826
9927
  case "daemon":
@@ -9847,7 +9948,7 @@ async function run(argv, io2) {
9847
9948
  case "version":
9848
9949
  case "--version":
9849
9950
  case "-v":
9850
- io2.out(true ? "0.1.14" : "development");
9951
+ io2.out(true ? "0.1.15" : "development");
9851
9952
  return 0;
9852
9953
  case "lan":
9853
9954
  return await lanCommand(rest, io2);