Feat/read marker#182
Conversation
Implements the client side of the draft/account-recovery WIP spec we authored at extensions/account-recovery.md, plus opt-in SASL EXTERNAL for cert-based login. * IRCClient.recoverRequest / recoverConfirm / setpass send the new commands. SETPASS uses the IRC trailing-parameter form so passwords may contain spaces (passphrases). No base64. * New typed events RECOVER_NOTE / RECOVER_FAIL / SETPASS_NOTE / SETPASS_FAIL projected from the generic NOTE/FAIL stream so components don't have to filter by command on every render. * PasswordRecoveryModal: three-stage flow (account -> code -> new password). Surfaces the spec's account-existence-ambiguity to the user instead of silently swallowing typos. * ChangePasswordModal: one-shot SETPASS for in-session rotation; only shown while the connection is authenticated. * EditServerModal exposes both modals when the server advertises draft/account-recovery. * SASL EXTERNAL added to the SaslMech union and the AUTHENTICATE state machine; an EXTERNAL-configured server replies '+' to '+' and the TLS cert provides identity. EXTERNAL is only chosen when the user explicitly picks it -- never under "auto", since it requires a device-side client cert.
When the server advertises draft/persistence, the user's account
settings tab now gets a tri-state preference:
- Stay in channels (ON) -- ghost survives disconnect
- Leave on disconnect (OFF) -- clean exit
- Use server default (DEFAULT) -- inherit operator setting
Plumbing:
- IRCClient.persistenceGet / persistenceSet send PERSISTENCE GET
and PERSISTENCE SET ON|OFF|DEFAULT.
- handlePersistence parses the server's PERSISTENCE STATUS reply
and emits a typed PERSISTENCE_STATUS event; the FAIL handler
projects PERSISTENCE_FAIL alongside the generic FAIL.
- registerAuthHandlers caches both preference + effective state on
the Server record, and fires an automatic PERSISTENCE GET 1.5s
after CAP ACK so the panel has fresh state when the user opens
settings (the spec gates the command on IsLoggedIn, hence the
small post-SASL delay).
- PersistenceSettingsPanel renders a radio-card list with helper
text and an "Currently ON/OFF" badge so the user can reconcile
their preference with what the server is actually doing (e.g.
preference DEFAULT but the operator default flipped).
- The panel is mounted in UserSettings -> Account, gated on the
server's CAP set, so non-supporting networks see nothing change.
Cross-session read state: when one of your devices reads up to a
particular message, the other devices clear their unread / mention
state for that buffer too.
- draft/read-marker added to ourCaps so the cap is auto-requested
when the server advertises it.
- IRCClient.markreadGet / markreadSet send the GET and SET wire
forms. SET uses the "timestamp=YYYY-MM-DDThh:mm:ss.sssZ" format
the spec requires.
- handleMarkread parses ":server MARKREAD <target> {timestamp=ts|*}"
into a typed MARKREAD event with timestamp: string | null.
- registerReadMarkerHandlers caches the marker on the matching
Channel (case-insensitive name match) or PrivateChat (username
match). Adds readMarker / readMarkerFetched to the type defs.
- selectChannel: when the user opens a channel, send MARKREAD
SET with the latest message timestamp in that channel's history.
- selectPrivateChat: PMs aren't auto-pushed by the server, so we
issue a one-shot MARKREAD GET on first open to pick up any
marker another session set earlier. Subsequent re-selections
just push the latest timestamp via SET.
- MARKREAD_FAIL projection on the FAIL stream so future UI can
surface server-side rejections without filtering generic FAIL.
|
Automated deployment preview for the PR in the Cloudflare Pages. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
No. Not for now. |
# Conflicts: # src/components/ui/EditServerModal.tsx # src/lib/irc/IRCClient.ts # src/lib/irc/handlers/auth.ts # src/lib/irc/handlers/index.ts # src/store/handlers/auth.ts # src/types/index.ts
* feat: SCRAM-SHA-256, TOTP 2FA + biometric (WebAuthn) login
Adds three new SASL paths plus an enrolment UI for second-factor
credentials, all behind a single feature branch.
* `src/lib/sasl/scram.ts` - SCRAM-SHA-256 (RFC 7677) using Web Crypto
for PBKDF2/HMAC/SHA-256. Picked automatically when the server
advertises it; falls back to PLAIN.
* `src/lib/sasl/webauthn.ts` - thin `navigator.credentials` wrapper for
the DRAFT-WEBAUTHN-BIO mechanism and `2FA ADD webauthn` enrolment.
* `src/store/handlers/auth.ts` - per-server SASL session state machine
that dispatches AUTHENTICATE messages by mechanism and routes
`AUTHENTICATE 2FA-REQUIRED` to the step-up modal.
* `2FA` IRC command + `TWOFA` / `TWOFA_NOTE` events for status, listing,
enrolment, removal, enable/disable replies from the server.
* `TotpStepUpModal` - prompts for the 6-digit code mid-SASL.
* `TwoFactorSettingsModal` - status, credential list, TOTP enrol with
QR (qrcode dep), WebAuthn biometric enrol via `navigator.credentials.
create`, removal, and password-free disable via TOTP proof.
* Wired into `EditServerModal` (button visible when the server
advertises `draft/account-2fa`).
* feat: tic-tac-toe (compatible with KiwiIRC plugin)
Implements the same TAGMSG protocol used by
ItsOnlyBinary/kiwiirc-plugin-tictactoe so KiwiIRC and ObsidianIRC
users can play each other.
* `src/lib/games/tictactoe.ts` -- pure game logic: board, turn
bookkeeping, win/draw detection, marker assignment.
* `src/lib/games/tictactoeProtocol.ts` -- IRC message-tag escape and
unescape per IRCv3 message-tags.
* `src/store/handlers/tictactoeActions.ts` -- store-side actions
(invite/accept/decline/move/terminate) that mutate state and emit
TAGMSGs over `+kiwiirc.com/ttt`.
* `src/store/handlers/tictactoe.ts` -- inbound TAGMSG dispatch.
Auto-opens the modal on incoming invites, replays moves into the
local board, detects out-of-sync turns, handles forfeit / decline.
* `src/components/ui/TicTacToeModal.tsx` -- minimal 3x3 board UI with
invite accept/decline, in-game forfeit, and game-over close.
* "Play Tic-Tac-Toe" entry in the chat-header overflow menu when a
private chat is selected.
* feat: surface tic-tac-toe button in the PM header action row
The overflow menu is only rendered in the channel header (and only on
mobile), so the previous "Play Tic-Tac-Toe" entry was unreachable in
private chats. Add a dedicated 🎮 button next to the existing Media /
Search controls in the PM action cluster instead.
* 2FA: confirm-before-remove, block removing last credential while enabled
The remove button used to fire an immediate `2FA REMOVE`. That had two
problems:
1. No undo path -- a stray click silently dropped a TOTP credential.
2. With 2FA enforcement on, the server's REMOVE_LAST_CREDENTIAL guard
would reject the request, but we'd still surface the failure as a
plain error toast with no recovery hint.
Now the button just stages a confirm prompt inline. When 2FA is enabled
and the credential being removed is the only one left, the confirm
prompt is replaced with a "can't remove your last credential" notice
that points the user at adding another credential or disabling 2FA
first. Otherwise the user gets a normal "remove '<name>'?" confirm.
* Request draft/account-2fa CAP so the client merges it into server.capabilities
Without this CAP REQ, the server's CAP LS advertisement of draft/account-2fa
never lands on server.capabilities (the merger only writes from CAP ACK), so
EditServerModal's gate
server?.capabilities?.some(c => c.startsWith("draft/account-2fa"))
stays false and the 'Manage two-factor authentication' button never renders.
* Move 2FA management into UserSettings → Account; raise 2FA modal z-index above LoadingOverlay
The button in EditServerModal was buried behind the 'Login to an account'
checkbox, so users wouldn't find it. Surface it in the always-reachable
Account category of UserSettings instead, gated on the currently selected
server's draft/account-2fa capability.
LoadingOverlay sits at z-[100001]; the 2FA / TOTP step-up modals were at
z-50 and got hidden behind it whenever the server reconnected. Bump both
modals to z-[100002].
* Don't send spurious AUTHENTICATE + after SCRAM server-final
Per IRCv3 SASL, the server completes the exchange itself after server-final
by emitting 900/903 (or AUTHENTICATE 2FA-REQUIRED for step-up). UnrealIRCd's
saslserv reads our extra 'AUTHENTICATE +' as an empty/abort payload and
fires 904 SASL authentication failed, which dropped the SASL session before
the user could supply their TOTP code.
* feat(auth): SASL IRCV3BEARER + per-server OAuth2/OIDC sign-in
Lets ObsidianIRC connect to obbyircd via OAuth2 instead of (or in
addition to) PLAIN. Each server entry now carries its own provider
config, so admins can point one server at Logto, another at Auth0,
Keycloak, Okta, or any custom OIDC issuer.
Flow
1. In Edit Server -> "OAuth2 / OIDC sign-in", admin picks a preset
(Logto, Auth0, Keycloak, Custom), pastes the issuer URL + client
ID, optionally tweaks scopes/redirect URI, and clicks "Sign in".
2. We discover the issuer's metadata, build an authorize URL with
PKCE (S256), and open the IdP in a popup.
3. The popup redirects to /oauth/callback (a new same-origin
SPA route) which postMessages the auth code back to the opener
and closes itself.
4. The opener exchanges code+verifier at the token endpoint and
stores the resulting access/id/refresh tokens on the server.
5. On every connect, if the server has an access token, the SASL
step picks IRCV3BEARER, sends "[authzid]\0jwt\0<token>" in 400-
char AUTHENTICATE chunks, and the server validates the JWT
against its configured oauth-provider/JWKS.
Notes
* Tokens live in localStorage with the rest of the server config;
expired tokens just yield a 904 and the user re-authenticates.
* IRCClient gained an oauthBearerEnabled flag on connect() so CAP
requests SASL and we don't race CAP END against AUTHENTICATE.
* saslFrames.ts also exports buildOauthBearerPayload for the
RFC 7628 mech, even though the wired-up flow uses IRCV3BEARER.
Tests cover OIDC discovery, PKCE verifier/challenge (incl. the
RFC 7636 vector), authorize-URL construction, and both SASL frame
builders + chunker behavior. Full suite passes (722 tests).
* feat(auth): bake OAuth config into the build for lock-mode deployments
When the client is built with VITE_HIDE_SERVER_LIST=true (single-server
deploy where users can't add their own servers), admins can now bake
the OAuth provider settings into the build via VITE_DEFAULT_OAUTH_*
env vars. Users see a "Sign in with <ProviderLabel>" button instead of
an editable issuer/client_id form -- they don't need to know what
Logto, Auth0 or Keycloak even is.
VITE_DEFAULT_OAUTH_PROVIDER_LABEL e.g. "Logto"
VITE_DEFAULT_OAUTH_ISSUER e.g. https://my-tenant.logto.app/oidc
VITE_DEFAULT_OAUTH_CLIENT_ID
VITE_DEFAULT_OAUTH_SCOPES optional, defaults "openid"
VITE_DEFAULT_OAUTH_REDIRECT_URI optional, defaults <origin>/oauth/callback
Both the welcome AddServerModal and EditServerModal pull
getBuiltinOAuthConfig() and pass it as `locked` to OAuthSection,
which in locked mode renders only the Sign-in / Sign-out actions
plus a token status line. The provider fields are no longer editable.
In multi-server builds (VITE_HIDE_SERVER_LIST unset), behavior is
unchanged: the editable per-server OAuth panel still drives.
Tests cover getBuiltinOAuthConfig() across all four
present/missing-field combinations. Dockerfile + BUILD.md updated.
* refactor(EditServerModal): tabbed left-sidebar layout
The Edit Server modal had grown to seven sections stacked in one
column (server info, account login toggles, server password, SASL,
OAuth, operator, register-account dead code). Migrate it to the same
left-sidebar tabbed shell as ChannelSettingsModal:
General - network name, host, port, nickname
Authentication - SASL PLAIN account/password + server password
OAuth2 - the OAuthSection (locked or editable)
IRC Operator - oper credentials + on-connect toggle + Oper Up
Desktop renders sidebar + main content in a single 80vh dialog;
mobile mirrors ChannelSettingsModal's two-step categories ->
content navigation with back chevron, identical safe-area
insets, and an always-visible Cancel/Update footer.
Validation errors now jump back to the General tab (mobile: into
the content view) so the user sees what's missing instead of
silently failing on submit.
Drops the dead "Register for an account" section from this
modal: the registerAccount/email/password state was collected but
never sent on submit -- registration only happens at server-add
time via AddServerModal -> store.connect().
Behavior preserved:
* SASL PLAIN account/password (was: state initialized empty,
btoa'd into config on submit). Now also exposes "(stored --
leave blank to keep)" placeholder when a SASL/oper/server
password already exists, fixing the prior surprise where the
field appeared blank but was actually preserved on save.
* Operator credentials, on-connect toggle, "Forget Credentials"
+ "Oper Up" buttons.
* OAuth: locked-mode (HIDE_SERVER_LIST + builtin config) still
surfaces only Sign in / Sign out; editable mode still gets the
full preset/issuer/clientId/scopes form.
Implementation also picks up modal-behavior parity with
ChannelSettingsModal: createPortal rendering, useModalBehavior for
Esc + click-outside, animated fade-in on mobile.
No tests changed -- this is a UI-shell refactor over the same
state shape; existing 726-test suite continues to pass.
* feat(auth): opaque-token (GitHub) + id_token (Gmail) OAuth paths
Adds the client side of the obbyircd opaque-token feature. Users can
now sign in with any provider obbyircd is configured for, including
GitHub-style OAuth2 (opaque tokens, server hits userinfo endpoint)
and Google-style OIDC (id_token JWT, server validates locally).
Two new fields on ServerOAuthConfig:
tokenKind: "jwt" (default) or "opaque"
serverProvider: name of the matching oauth-provider {} on the IRC
server, sent as IRCV3BEARER authzid (or OAUTHBEARER
`provider=` k/v) so obbyircd picks the right
userinfo URL
For non-OIDC providers (GitHub) we add explicit endpoint overrides
since there's no /.well-known/openid-configuration to discover:
authorizeEndpoint, tokenEndpoint
Built-in presets gain Google (jwt, OIDC) and GitHub (opaque, manual
endpoints) so the dropdown auto-fills issuer/scopes/endpoints/
tokenKind/serverProvider in one click.
JWT path now prefers id_token over access_token (matters for Google,
whose access_token is opaque even when an id_token is also issued).
Lock-mode build vars (single-server deploy) gain matching siblings:
VITE_DEFAULT_OAUTH_TOKEN_KIND
VITE_DEFAULT_OAUTH_SERVER_PROVIDER
VITE_DEFAULT_OAUTH_AUTHORIZE_URL
VITE_DEFAULT_OAUTH_TOKEN_URL
so admins can ship a "Sign in with GitHub" or "Sign in with Google"
build with no per-user configuration.
BUILD.md + Dockerfile updated; SASL frame builder gets a new test
covering the opaque format. 727 tests pass.
For each preset the corresponding obbyircd oauth-provider {} block
is documented inline (Google: jwks-file, GitHub: userinfo-url).
* feat(auth): static /oauth/callback page
The SPA bundle uses base="./" so its asset paths are relative; loading
the React OAuthCallback at /oauth/callback would resolve assets to
/oauth/assets/... and 404. Replace the in-SPA route with a self-contained
public/oauth/callback/index.html that just postMessages the auth code to
its opener and closes -- no SPA bundle dependency, works under any base
config and any deep-link routing setup.
* feat(auth): smart 2FA step-up — IRCV3BEARER step-up + reject same-factor replay
Wires the client to the new server-side step-up policy:
- When SASL primary is PLAIN/SCRAM and an OAuth bearer is also
configured, on AUTHENTICATE 2FA-REQUIRED the client now
automatically replies with AUTHENTICATE IRCV3BEARER + the chunked
bearer payload, satisfying 2FA in one round trip with no UI prompt.
- When SASL primary is IRCV3BEARER (the bearer is already spent as
primary), the auto-path is skipped on 2FA-REQUIRED so the
TotpStepUpModal pops -- replaying the same bearer would just be
rejected by the server's same-factor guard.
- Drops the made-up "AUTHENTICATE 2FA-OAUTH" mech name. Step-up uses
the real IRCV3BEARER mech with identical wire format; the server
routes to the step-up handler based on TwoFAStepup state.
- Two new store actions: twofaToken(serverId, chunk) and
linkOauthCredential(serverId, provider, bearer, name) which run
/2FA CHALLENGE oauth + chunked /2FA TOKEN + /2FA ADD oauth.
twofaAdd's data param became optional (oauth ADD takes no inline
data -- token was already streamed).
- TwoFactorSettingsModal grows a "Link an OAuth identity" card that
runs the popup OAuth flow against the per-server (or
deployer-baked, in lock-mode builds) provider config and binds the
resulting (provider, subject) credential to the account via the
actions above.
* Feat/account recovery (#181)
* feat: draft/account-recovery (RECOVER + SETPASS) and SASL EXTERNAL
Implements the client side of the draft/account-recovery WIP spec we
authored at extensions/account-recovery.md, plus opt-in SASL
EXTERNAL for cert-based login.
* IRCClient.recoverRequest / recoverConfirm / setpass send the new
commands. SETPASS uses the IRC trailing-parameter form so passwords
may contain spaces (passphrases). No base64.
* New typed events RECOVER_NOTE / RECOVER_FAIL / SETPASS_NOTE /
SETPASS_FAIL projected from the generic NOTE/FAIL stream so
components don't have to filter by command on every render.
* PasswordRecoveryModal: three-stage flow (account -> code -> new
password). Surfaces the spec's account-existence-ambiguity to the
user instead of silently swallowing typos.
* ChangePasswordModal: one-shot SETPASS for in-session rotation; only
shown while the connection is authenticated.
* EditServerModal exposes both modals when the server advertises
draft/account-recovery.
* SASL EXTERNAL added to the SaslMech union and the AUTHENTICATE
state machine; an EXTERNAL-configured server replies '+' to '+' and
the TLS cert provides identity. EXTERNAL is only chosen when the
user explicitly picks it -- never under "auto", since it requires a
device-side client cert.
* feat: draft/persistence settings panel
When the server advertises draft/persistence, the user's account
settings tab now gets a tri-state preference:
- Stay in channels (ON) -- ghost survives disconnect
- Leave on disconnect (OFF) -- clean exit
- Use server default (DEFAULT) -- inherit operator setting
Plumbing:
- IRCClient.persistenceGet / persistenceSet send PERSISTENCE GET
and PERSISTENCE SET ON|OFF|DEFAULT.
- handlePersistence parses the server's PERSISTENCE STATUS reply
and emits a typed PERSISTENCE_STATUS event; the FAIL handler
projects PERSISTENCE_FAIL alongside the generic FAIL.
- registerAuthHandlers caches both preference + effective state on
the Server record, and fires an automatic PERSISTENCE GET 1.5s
after CAP ACK so the panel has fresh state when the user opens
settings (the spec gates the command on IsLoggedIn, hence the
small post-SASL delay).
- PersistenceSettingsPanel renders a radio-card list with helper
text and an "Currently ON/OFF" badge so the user can reconcile
their preference with what the server is actually doing (e.g.
preference DEFAULT but the operator default flipped).
- The panel is mounted in UserSettings -> Account, gated on the
server's CAP set, so non-supporting networks see nothing change.
* Feat/read marker (#182)
* feat: draft/account-recovery (RECOVER + SETPASS) and SASL EXTERNAL
Implements the client side of the draft/account-recovery WIP spec we
authored at extensions/account-recovery.md, plus opt-in SASL
EXTERNAL for cert-based login.
* IRCClient.recoverRequest / recoverConfirm / setpass send the new
commands. SETPASS uses the IRC trailing-parameter form so passwords
may contain spaces (passphrases). No base64.
* New typed events RECOVER_NOTE / RECOVER_FAIL / SETPASS_NOTE /
SETPASS_FAIL projected from the generic NOTE/FAIL stream so
components don't have to filter by command on every render.
* PasswordRecoveryModal: three-stage flow (account -> code -> new
password). Surfaces the spec's account-existence-ambiguity to the
user instead of silently swallowing typos.
* ChangePasswordModal: one-shot SETPASS for in-session rotation; only
shown while the connection is authenticated.
* EditServerModal exposes both modals when the server advertises
draft/account-recovery.
* SASL EXTERNAL added to the SaslMech union and the AUTHENTICATE
state machine; an EXTERNAL-configured server replies '+' to '+' and
the TLS cert provides identity. EXTERNAL is only chosen when the
user explicitly picks it -- never under "auto", since it requires a
device-side client cert.
* feat: draft/persistence settings panel
When the server advertises draft/persistence, the user's account
settings tab now gets a tri-state preference:
- Stay in channels (ON) -- ghost survives disconnect
- Leave on disconnect (OFF) -- clean exit
- Use server default (DEFAULT) -- inherit operator setting
Plumbing:
- IRCClient.persistenceGet / persistenceSet send PERSISTENCE GET
and PERSISTENCE SET ON|OFF|DEFAULT.
- handlePersistence parses the server's PERSISTENCE STATUS reply
and emits a typed PERSISTENCE_STATUS event; the FAIL handler
projects PERSISTENCE_FAIL alongside the generic FAIL.
- registerAuthHandlers caches both preference + effective state on
the Server record, and fires an automatic PERSISTENCE GET 1.5s
after CAP ACK so the panel has fresh state when the user opens
settings (the spec gates the command on IsLoggedIn, hence the
small post-SASL delay).
- PersistenceSettingsPanel renders a radio-card list with helper
text and an "Currently ON/OFF" badge so the user can reconcile
their preference with what the server is actually doing (e.g.
preference DEFAULT but the operator default flipped).
- The panel is mounted in UserSettings -> Account, gated on the
server's CAP set, so non-supporting networks see nothing change.
* feat: draft/read-marker (MARKREAD)
Cross-session read state: when one of your devices reads up to a
particular message, the other devices clear their unread / mention
state for that buffer too.
- draft/read-marker added to ourCaps so the cap is auto-requested
when the server advertises it.
- IRCClient.markreadGet / markreadSet send the GET and SET wire
forms. SET uses the "timestamp=YYYY-MM-DDThh:mm:ss.sssZ" format
the spec requires.
- handleMarkread parses ":server MARKREAD <target> {timestamp=ts|*}"
into a typed MARKREAD event with timestamp: string | null.
- registerReadMarkerHandlers caches the marker on the matching
Channel (case-insensitive name match) or PrivateChat (username
match). Adds readMarker / readMarkerFetched to the type defs.
- selectChannel: when the user opens a channel, send MARKREAD
SET with the latest message timestamp in that channel's history.
- selectPrivateChat: PMs aren't auto-pushed by the server, so we
issue a one-shot MARKREAD GET on first open to pick up any
marker another session set earlier. Subsequent re-selections
just push the latest timestamp via SET.
- MARKREAD_FAIL projection on the FAIL stream so future UI can
surface server-side rejections without filtering generic FAIL.
* Fix cmd→command after standard-reply merge
Sorry this required the other code being in place first