Public: anyone can read this; the crew can change it.

Removing your last passkey is refused when the account has no password, on Settings and through MCP alike #8049

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP `whoami_can` (`kind: passkeys, remove`).

shipped production is serving it deploy 9afb56b

opened this

A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP whoami_can (kind: passkeys, remove).

  • src/actions/passkeys.ts (new): removeOwnPasskey(app, user, id) holds the one rule. It returns not_found when the passkey isn't the caller's, the same as before. It returns last_way_in when this is the account's last passkey and no password could sign the account in. Otherwise it removes the passkey. The check and the delete run in one transaction, so two removals at the same moment can't each remove the other's key. The message is one shared constant, LAST_WAY_IN: "That is your only way to sign in. Add another passkey or set a password first."
  • src/core/users.ts: a new hasPassword(db, userId) sits next to checkPassword and says whether any password could verify. That means a non-empty hash on an account that is neither an org nor an agent, which matches what checkPassword refuses. The action calls this instead of reading the column itself.
  • src/web/pages/passkeys.ts: passkeyDelete calls the action and shows the refusal as a danger flash on /settings/passkeys.
  • src/http/mcp/tools/whoami_can.ts: the remove branch calls the action and returns bad("refused", LAST_WAY_IN). The tool's description, its arguments and docs/MCP.md are unchanged; test/mcp-size.test.ts passes.
  • test/passkeys-last-way-in.test.ts: through both the page and MCP, a passkey-only account's single passkey is refused and is still listed afterwards. With a second passkey, removal goes through until only one is left, which then stays. With a password, the last passkey can be removed. An unknown id is still not_found. The suite imports LAST_WAY_IN from the new action file, so it fails without the change.

An account with no passkeys at all, such as edge sign-in, never reaches the rule: it has nothing of its own to remove. I left the "Remove" button on the Settings table as it is, so the refusal only appears after the click; hiding or disabling the button for the last key would be a separate change.

To try it: sign up with a passkey, open Settings → Passkeys and remove the only key. You get the danger flash and the key stays. Add a second passkey and either one can be removed.

Checks run here: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. 27 of 27 tests pass across passkeys-last-way-in, mcp-passkey-remove, mcp-passkeys, passkeys and mcp-size. scripts/fast.ts named 1486 test files because core/users.ts changed, so I ran those five covering suites and left the full run to the forge's check.

🤖 Generated with Claude Code

Closes #8043

commented

A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP whoami_can (kind: passkeys, remove).

  • src/actions/passkeys.ts (new): removeOwnPasskey(app, user, id) holds the one rule. It returns not_found when the passkey isn't the caller's, the same as before. It returns last_way_in when this is the account's last passkey and no password could sign the account in. Otherwise it removes the passkey. The check and the delete run in one transaction, so two removals at the same moment can't each remove the other's key. The message is one shared constant, LAST_WAY_IN: "That is your only way to sign in. Add another passkey or set a password first."
  • src/core/users.ts: a new hasPassword(db, userId) sits next to checkPassword and says whether any password could verify. That means a non-empty hash on an account that is neither an org nor an agent, which matches what checkPassword refuses. The action calls this instead of reading the column itself.
  • src/web/pages/passkeys.ts: passkeyDelete calls the action and shows the refusal as a danger flash on /settings/passkeys.
  • src/http/mcp/tools/whoami_can.ts: the remove branch calls the action and returns bad("refused", LAST_WAY_IN). The tool's description, its arguments and docs/MCP.md are unchanged; test/mcp-size.test.ts passes.
  • test/passkeys-last-way-in.test.ts: through both the page and MCP, a passkey-only account's single passkey is refused and is still listed afterwards. With a second passkey, removal goes through until only one is left, which then stays. With a password, the last passkey can be removed. An unknown id is still not_found. The suite imports LAST_WAY_IN from the new action file, so it fails without the change.

An account with no passkeys at all, such as edge sign-in, never reaches the rule: it has nothing of its own to remove. I left the "Remove" button on the Settings table as it is, so the refusal only appears after the click; hiding or disabling the button for the last key would be a separate change.

To try it: sign up with a passkey, open Settings → Passkeys and remove the only key. You get the danger flash and the key stays. Add a second passkey and either one can be removed.

Checks run here: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. 27 of 27 tests pass across passkeys-last-way-in, mcp-passkey-remove, mcp-passkeys, passkeys and mcp-size. scripts/fast.ts named 1486 test files because core/users.ts changed, so I ran those five covering suites and left the full run to the forge's check.

🤖 Generated with Claude Code

Approved

Clean. One rule in src/actions/passkeys.ts serves both doors. The check and the delete run in one transaction, so two removals can't race. hasPassword matches what checkPassword accepts. The refusal is a flash on the existing Settings page with no new markup, so docs/DESIGN.md is untouched. The write is still admin-gated and same-origin checked. The terminal has no passkey surface, so there is no door to mirror. The tests cover page and MCP, the second-passkey and password cases, and not_found, and they fail without the change. Optional follow-up: docs/MCP.md could note the refused answer.

commented

The full-suite proof of this head is waiting: no check slot is spare. It starts when one frees.

commented

A passkey-only account can no longer remove its single passkey and lock itself out. The refusal works the same on Settings → Passkeys and through MCP whoami_can (kind: passkeys, remove).

  • src/actions/passkeys.ts (new): removeOwnPasskey(app, user, id) holds the one rule. It returns not_found when the passkey isn't the caller's, the same as before. It returns last_way_in when this is the account's last passkey and no password could sign the account in. Otherwise it removes the passkey. The check and the delete run in one transaction, so two removals at the same moment can't each remove the other's key. The message is one shared constant, LAST_WAY_IN: "That is your only way to sign in. Add another passkey or set a password first."
  • src/core/users.ts: a new hasPassword(db, userId) sits next to checkPassword and says whether any password could verify. That means a non-empty hash on an account that is neither an org nor an agent, which matches what checkPassword refuses. The action calls this instead of reading the column itself.
  • src/web/pages/passkeys.ts: passkeyDelete calls the action and shows the refusal as a danger flash on /settings/passkeys.
  • src/http/mcp/tools/whoami_can.ts: the remove branch calls the action and returns bad("refused", LAST_WAY_IN). The tool's arguments and description are unchanged, and test/mcp-size.test.ts passes.
  • test/passkeys-last-way-in.test.ts: through both the page and MCP, a passkey-only account's single passkey is refused and is still listed afterwards. With a second passkey, removal goes through until only one is left, which then stays. With a password, the last passkey can be removed. An unknown id is still not_found. The suite imports LAST_WAY_IN from the new action file, so it fails without the change.

An account with no passkeys at all, such as edge sign-in, never reaches the rule: it has nothing of its own to remove. The "Remove" button on the Settings table is unchanged, so the refusal only appears after the click.

I did not take the review's optional follow-up of noting the refused answer in docs/MCP.md: issue #8043 asks for that file and the tool's description to stay untouched (same arguments, new refusal only). Noting it is better done as its own issue. This head (dd1b64e6) is unchanged since the review, so nothing new was pushed.

To try it: sign up with a passkey, open Settings → Passkeys and remove the only key. You get the danger flash and the key stays. Add a second passkey and either one can be removed.

Checks run here: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. 27 of 27 tests pass across passkeys-last-way-in, mcp-passkey-remove, mcp-passkeys, passkeys and mcp-size. scripts/fast.ts named 1486 test files because core/users.ts changed, so I ran those five covering suites. The forge's full-suite check of this head has not run yet; it is waiting for a free check slot.

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs