Skip to content

feat(upstream): back-merge batch A — 2FA lockout columns, sign-in devices, widget deep-links, attachment tray - #31

Merged
theRealBithive merged 14 commits into
mainfrom
feat/upstream-batch-a
Sep 14, 2026
Merged

theRealBithive merged 14 commits into
mainfrom
feat/upstream-batch-a

Conversation

@theRealBithive

Copy link
Copy Markdown
Owner

What changes in the running service

Nine upstream commits, cherry-picked with -x in upstream order, plus two fork fixes their tests found.

Skipped on purpose: upstream ce6951a (migration 0277_widget_chat_to_messenger, only relevant to a database with pre-messenger chat settings); skipping it keeps 02780280 in step with upstream. Upstream QuackbackIO#537 is done by hand because our replay-gate span lists our own migrations.

Docs

UPSTREAM.md is new: every upstream commit since the fork point with its status here, and the command that recomputes it from the cherry-pick trailers. Linked from CLAUDE.md.

Gates

  • Diff coverage: PASS, 717 of 717 executable added lines executed (started at 307 uncovered; contract W1–W11, A1–A4, C1–C8, E1–E2, I1–I4, T1, S1–S3 confirmed before the tests were written).
  • Mutation: PASS: this change touched no file the manifest declares (touched files are listed by name).
  • i18n gate PASS; typecheck 0 errors; lint 0 errors (three max-lines warnings on upstream files that were already over the limit); db:check-drift no drift.
  • Two known load flakes needed a control run (settings.test.ts, help-center-article.service.test.ts); both green alone. SELF-IMPROVE updated.

🤖 Generated with Claude Code

mortondev and others added 14 commits September 14, 2026 13:47
* feat(tags): restyle the create/edit tag dialog

The New tag dialog was a vertical stack with a disconnected preview,
a 32-swatch palette, and a switch squeezed against a paragraph.

Name now sits next to a live chip (no "PostTag" placeholder). Color is
eight presets plus hex, with the rest behind More colors. Visibility is
two cards — Portal / Internal — matching Create board. Description is
last and optional. Create stays disabled until a name is entered.

* fix(tags): stack the name preview under the field on small screens

The chip sat beside the input, so the placeholder truncated at phone
width. Stack below sm, keep the side-by-side row on desktop.

* feat(tags): pick tag color from a single swatch

Drop the inline 8-dot grid, hex row, and More colors disclosure. Color
is a circle next to the name that opens the same popover the list uses.

* feat(tags): render the settings list as chips with visibility

Each row is the tag as it appears on posts, not a color dot plus
plain text. Portal and Internal sit in an aligned column so public
vs team-only is scannable without opening the editor. The chip still
opens the color popover.

* test(tags): scope Portal/Internal assertions to the list row

closest('div') stopped at the chip wrapper after the list became a
colored badge, so the visibility label was outside the queried node.

* fix(tags): use a real radio group for visibility

Codex review: the Portal/Internal cards advertised radio semantics
without arrow-key or roving tabindex behaviour. Switch them to the
shared RadioGroup primitive. Also restore the dialog description
association that DialogContent otherwise strips.

* test(tags): cast list-row closest() to HTMLElement

within() wants an HTMLElement; closest('.group') is typed as Element.

* fix(tags): keep tag rows usable on narrow settings viewports

The chip column was a fixed 10rem and did not shrink, so combined with
the visibility label and action buttons it overflowed the
overflow-hidden settings card. Let the chip shrink, hide the
description below sm, and keep edit/delete visible on touch.

(cherry picked from commit 4ef5beb)
…IO#525)

* fix(auth): identify new-sign-in devices by browser, not IP

Rotating client IPs were hashed into the device fingerprint, so the
same browser looked new on every network. Hash the normalised browser
and OS instead; keep IP (and country when known) in the mail only.
Seed the first recorded device silently so a hash-format change does
not re-alert every account.

* fix(auth): count the claimed device and isolate the new fingerprint set

Postgres data-modifying CTEs share the outer snapshot, so liveCount
missed the row just inserted and suppressed the second-device alert.
UNION the RETURNING member into the count. Key devices as
user:devices:v2 so leftover UA+/24 hashes cannot look like an extra
browser. Point the password link at /settings/profile, which is where
the personal password form actually lives.

* fix(auth): serialize first-device claims on a set

Two concurrent inserts into an empty device set could both see
liveCount 1 and both skip the alert. Take an advisory xact lock on
(workspace, set) before the insert so the second statement's snapshot
includes the first member.

* fix(auth): take the device-set lock in a prior statement

READ COMMITTED snapshots at statement start, so an advisory lock in
the same SQL as the counted insert does not help the waiter — it
resumes with the empty snapshot it already took. Lock first in the
transaction, then insert and count, so the second claim sees the
first member.

* fix(auth): revoke other sessions and use SSO-aware sign-in alerts

changePassword now sets revokeOtherSessions: true so recovery from a
new-sign-in alert signs out other sessions. Enforced SSO recipients
are directed to their identity provider instead of a password CTA
the profile page hides.

* fix(auth): type the new-sign-in SSO mocks so web typecheck passes

The empty-array mock inferred never[], and spreading unknown[] into
getRegisteredOidcProviderIds failed TS2556. Both blocked the check job.

* fix(auth): revoke other sessions when setting a first password

OAuth and magic-link users recover via setPassword, which did not
revoke sessions. The profile form now asks setPasswordFn to drop
every session except the current one. complete-signup leaves the
flag off so it does not kick the just-minted session.

* fix(auth): revoke other sessions through Better Auth after setPassword

Profile recovery now calls auth.api.revokeOtherSessions after a first
password so the attacker session is dropped. complete-signup still
leaves the flag off. The new-sign-in mail covers set-or-change.

* fix(auth): slide known-device TTL and point SSO recovery at admins

A daily sign-in on a known browser never called markDeviceSeen, so
the member expired after 90 days and the next browser was a silent
seed. Known claims now refresh the set TTL. SSO alert copy tells
the recipient to ask a workspace admin to sign out other sessions,
because IdP logout does not delete the local session row.

* fix(auth): do not revive expired device fingerprints on TTL slide

kvSetTouch updated every row in the set, including expired members
the sweeper had not yet deleted. A known-device sign-in then
resurrected old browsers and suppressed later alerts. Touch now
only slides live rows.

(cherry picked from commit fb3c1a9)
…kIO#529)

* feat(auth): identify new-sign-in devices by a signed cookie

Bowser/UA is too coarse for "is this the same browser?" — the cookie is the claim key, and the email still shows browser, OS, and IP as context.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auth): type the device-set TTL query for tsc

The member-touch test cast postgres.js rows after the fact; tsc rejects that overlap. Use the same generic on testSql() as the rest of the harness.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 7aac548)
* fix(editor): let Enter insert a newline in comments

Restore enterAsHardBreak on the comment preset and stop parent forms from treating Enter as submit, so a line break no longer requires Shift.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(editor): type the Enter DOM handler

CI typecheck failed because the handleDOMEvents keydown params were implicit any.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 1c8d144)
* fix(widget): honour documented open() deep-links

The SDK already sent view/title/board/body and post/article/changelog targets; the iframe dropped new-post after the composer merge and never applied the other fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): resolve boards after identify and article TypeIDs

Identify now refetches the visitor-visible board list so open({ board })
can select members-only slugs, and articleId accepts a kb_article TypeID.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): load articles like posts and auth identity feeds

open({ articleId }) now stores the TypeID or slug and lets help-detail
fetch with Bearer + sessionVersion, accepting article_ and kb_article_.
Popular board feeds pass the widget identity so members-only pills are
not empty after identify.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): isolate open() feeds from identity and board filter drift

Similar-post hits are cached per session so a later identify cannot
keep showing another visitor's private titles. Popular Ideas keeps the
SDK ?board= filter across session re-keys, and article detail no longer
reuses another actor's content as placeholder data.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): honour identity and locale on open() detail views

Changelog entryId fetches with the widget Bearer and sessionVersion so
an authenticated-audience entry is not treated as anonymous. Article
detail passes the active widget locale through the same query key as
help search, so a translated article is not swapped for the default.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): isolate open() cache, auth changelog list, and article locale fallback

Keep similar-search hits session-scoped and bounded, send Bearer on the
changelog feed, fall back to the default-locale article, and stop Home
from advertising a board filter or overwriting a visitor board pick.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(widget): share query-key and similar-search helpers

Deduplicate session-cache reset and match placeholders by key prefix
instead of queryKey slot indexes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): clear identity placeholders and attach help/changelog OTT

Logout no longer keeps the previous visitor's boards or search hits, help
collections refetch with Bearer + sessionVersion, and View on portal
transfers an identified OTT for articles and changelog entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): auth help search, localize portal article URLs, drop lost board filters

Help search sends the widget Bearer and is session-scoped; article "View on
portal" uses hcArticlePath with the resolved locale; Popular Ideas clears a
filter the current session can no longer see.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): abort stale help searches and reset compose after logout

In-flight KB search is aborted when sessionVersion changes, messenger
suggestions re-key with identity, and a compose board the live list no
longer contains falls back to the default.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): auth Ask AI and leave gated help collections after logout

SDK-only visitors can retrieve the same gated articles they can browse,
and a replacement visitor is not left on a members-only category.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): type the Ask AI fetch stub so header assertions typecheck

The mock was inferred as taking no arguments, so reading the request
init from mock.calls failed CI typecheck.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): keep manual compose boards and drop stale changelog filters

A replacement visitor who picked another board after open() is no longer
overwritten when a restricted slug reappears, and a gated changelog
category clears once the new session feed does not contain it.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 43df5c9)
* feat(ids): serialize help-center articles as article_

Generate and emit article_ TypeIDs from the same UUID; accept retired
kb_article_ ids as an inbound alias. No database migration — prefixes
are applied at the ORM boundary.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ids): match legacy kb_article_ text in citations and redirects

Copilot citation joins and redirect-rule cleanup now treat kb_article_
and article_ as the same UUID, so pre-prefix-flip JSON and text targets
are not dropped after emit changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ids): fold kb_article_ citations before the top-cited limit

The Copilot report now rewrites retired prefixes in SQL so one article
cited as both article_ and kb_article_ is ranked and capped as a single
source.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ids): canonicalize article TypeIDs and batch redirect-rule labels

typeIdSchema now rewrites kb_article_ to article_ so writers cannot
persist the retired prefix, and redirect-rule lists load labels in two
queries instead of one per row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ids): keep isTypeId exact so alias narrowing stays sound

isValidTypeId still accepts kb_article_ as an inbound article id;
the TypeId<'article'> guard only matches the canonical prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(ids): reuse core TypeID helpers in schema and isTypeId

typeIdSchema now validates and rewrites through isValidTypeId/ensureTypeId,
and isTypeId composes those checks instead of re-parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(help-center): canonicalize legacy admin article route IDs

Bookmarked kb_article_ editor URLs now redirect to article_, and the
detail query key is the same either way so publish/update cache the
active editor instead of a stale alias key.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit a720add)
better-auth 1.6.30 writes failed_verification_count and locked_until
on every TOTP verify. The two_factor table never declared them, so
Drizzle emitted `update "two_factor" set  where …` and enrolment /
sign-in 500'd. Add the columns and pin them in a schema test.

Fixes QuackbackIO#432

(cherry picked from commit bcd4e6b)
…O#538)

* feat(widget): two-step install with a lazy signing secret

Cloud and self-host users were stuck looking for QUACKBACK_WIDGET_SECRET. Mint the secret on first admin fetch, put the launcher first, and keep regenerate on the install page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): keep install from sending users hunting for an env var

Critic pass: launcher prompt forbids QUACKBACK_WIDGET_SECRET, Show on your website lives on the install page, regenerate writes the new secret into cache, and async confirm stays open until the rotate finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(widget): harden install confirm and visibility feedback

Keep async ConfirmDialog submits single-shot, toast when the site toggle fails, and stop sending detected installs to another settings page.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 1f795b0)
…t tray (QuackbackIO#539)

Route conversation paste/drop/paperclip through the attachment tray so landscape screenshots stay in aspect. Old inline images lift onto attachments on read; posts and changelog still inline.

(cherry picked from commit 19c3bd1)
The two_factor lockout migration picked from upstream (QuackbackIO#536) turns two
gates red on purpose until a human confirms what it writes: the post-0248
replay span in the migrator gate and the contract ledger's scan count.
0278 is expand-only (two ADD COLUMN IF NOT EXISTS), so destructive DDL
stays at 33 and the span gains one entry. Upstream did the same in QuackbackIO#537,
which does not apply here because our span lists our own migrations.

MATRIX.md gains one ungated entry point, resolvePublicArticleRefFn,
which QuackbackIO#531 added so a widget deep-link can resolve a public article.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
resolvePrefix and prefixMatches looked a prefix up in ID_PREFIX_ALIASES with a
bare bracket access. The table is a plain object, so a prefix that happens to
be the name of an inherited Object.prototype member was answered with that
member: resolvePrefix('__proto__') returned Object.prototype itself and
resolvePrefix('valueOf') a function, where the contract says an unknown
prefix resolves to nothing. The prefix comes off the wire in every id the API
accepts, so only the table's own keys may count.

Found by the A2 property test written for the upstream article_ pick
(a720add); the test fails without this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A confirm action that threw synchronously closed the dialog as if it had
succeeded: the catch swallowed the error and returned without preventing the
click, and Radix dismisses the dialog on any click it was allowed to handle.
A rejected async action already left the dialog open. To the person clicking,
a failed confirm is the same as one still running, so the dialog now stays in
both cases.

Found by the I4 test written for the upstream widget-install pick (QuackbackIO#538).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The nine upstream commits picked in this batch left 307 added lines that no
test executed, almost all in the widget open() deep-links (QuackbackIO#531) and the
conversation attachment tray (QuackbackIO#539). These suites pin them to a contract
written in domain language before the tests: W1-W11 widget deep-links and
identity-scoped feeds, A1-A4 article ids, C1-C8 attachment tray, E1-E2 editor,
I1-I4 widget install, T1 tag dialog, S1-S3 sign-in devices. Every test names
its number; the group text sits verbatim in each module's header.

Two of the tests found production bugs, fixed in the two commits before this
one. The pre-existing upstream tests in the three extended widget suites were
numbered as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
UPSTREAM.md lists every upstream commit since the fork point with what we did
with it: picked (fork PR and deploy tag), skipped (with the reason), or planned
(with its batch). It carries the command that recomputes the picked/open
split from the cherry-pick trailers, so a row that disagrees with the history
is visible. CLAUDE.md points at it and asks a pick to update its row in the
same pull request.

SELF-IMPROVE gains what this batch cost: the load-flake entry is at 9x, with
split coverage directories as the working pattern; the vitest 4 entry records
that -u swallows the next positional argument and that an empty filter list
runs the whole suite; and four findings from the test-writing pass are new or
bumped entries (route mounting via a mocked createFileRoute, parallel coverage
runs deleting each other's .tmp, Image never loading under the test DOM, and a
file-wide vi.mock blocking a suite from testing the hook it mocked).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@theRealBithive
theRealBithive merged commit d48a396 into main Sep 14, 2026
11 checks passed
@theRealBithive
theRealBithive deleted the feat/upstream-batch-a branch September 16, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants