Skip to content

Commit 6af75ae

Browse files
committed
refactor(security): tighten the token-encryption surface after the staging merge
Swarm-verification round on the merged tree. The end-to-end trace found no decrypt/encrypt gap and the privacy-mode threading from the selector unification survives every hop; these are the items the sweep did surface. - The backfill's SQL pre-filter (`LIKE 'simenc:v%'`) disagreed with the app's envelope classifier for a value such as `simenc:vX:…` — excluded from selection yet counted as plaintext by the app, so it would never be enveloped while the run reported the table as done. The predicate is now the regex twin of `ENVELOPE_HEADER_RE`. Also: dry runs walk the whole table so the summary is the real total, batch size is clamped, `--sleep=0` works, and a run of 25 consecutive row failures aborts instead of erroring 92k times. - `/account-info` joins the blocked Better Auth endpoints: it shares `getValidAccessToken` with the two POST endpoints but is GET, so it gets the same treatment on the GET handler. Nothing in the monorepo calls it. - `resolveAccessTokenForAccount` now accepts `CredentialTokenResolutionOptions` and gates its identifier logs, so a future selector routed through it keeps the privacy guarantees instead of silently losing them. - The refresh path logs its decision reason again — the consumer of `RefreshDecision.reason` had been collapsed away in the merge. - `safeAccountInsert` folds into `upsertProviderAccountTokens` (its only caller), `findAccountIdByProviderAccount` goes module-private, the token field list is declared once, and the audit allowlist shrinks to the two modules that genuinely touch the table. - Dead weight removed: an unused Shopify logger, a redundant dynamic import in the backfill, a stale `safeAccountInsert` mock for a module that no longer exists, and three change-log-style comments rewritten to describe the code.
1 parent fff7769 commit 6af75ae

13 files changed

Lines changed: 609 additions & 100 deletions

File tree

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,27 @@ function isBlockedSsoMutationPath(path: string): boolean {
7373

7474
/**
7575
* Better Auth's own account-token endpoints read `account` through the adapter with no
76-
* `databaseHooks` pass, so they would return the stored column verbatim — ciphertext.
77-
* Nothing here calls them; token reads go through `@/lib/oauth/credential-service`.
76+
* `databaseHooks` pass, so they would forward the stored column verbatim — ciphertext.
77+
* `get-access-token` and `refresh-token` are POST; `account-info` shares the same token
78+
* read on GET. Nothing here calls any of them; token reads go through
79+
* `@/lib/oauth/credential-service`.
7880
*/
79-
const BLOCKED_ACCOUNT_TOKEN_POST_PATHS = new Set(['get-access-token', 'refresh-token'])
81+
const BLOCKED_ACCOUNT_TOKEN_PATHS = new Set(['get-access-token', 'refresh-token', 'account-info'])
8082

8183
function isBlockedAccountTokenPath(path: string): boolean {
82-
return BLOCKED_ACCOUNT_TOKEN_POST_PATHS.has(path)
84+
return BLOCKED_ACCOUNT_TOKEN_PATHS.has(path)
8385
}
8486

8587
export const GET = withRouteHandler(async (request: NextRequest) => {
8688
const path = getAuthPath(request)
89+
90+
if (isBlockedAccountTokenPath(path)) {
91+
return NextResponse.json(
92+
{ error: 'Account token access is handled by application API routes.' },
93+
{ status: 404 }
94+
)
95+
}
96+
8797
const credentialGroupProviderId = getCredentialGroupCallbackProviderId(request, path)
8898

8999
if (credentialGroupProviderId) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Find-in-transcript highlights, painted through the CSS Custom Highlight API
3+
* rather than by wrapping matches in elements. The transcript is virtualized
4+
* markdown: threading a highlight term into `MessageContent` would re-parse
5+
* every mounted message — and re-run its syntax highlighting — on each
6+
* keystroke. Highlight ranges paint over the existing text instead, so typing
7+
* costs no React render at all.
8+
*
9+
* Registry names are global by definition, so these selectors cannot be scoped
10+
* to a module; `sim-chat-find` keeps them namespaced instead.
11+
*/
12+
13+
::highlight(sim-chat-find) {
14+
background-color: var(--highlight-match-bg);
15+
color: var(--highlight-match-text);
16+
}
17+
18+
/**
19+
* The message the user is currently stepping through. `::highlight()` accepts
20+
* only colour-ish properties — no outline or border — so the active state
21+
* reads as a filled swatch rather than the selection ring the table and file
22+
* grids use. `--brand-secondary` is the same value in both themes, so one rule
23+
* covers light and dark.
24+
*/
25+
::highlight(sim-chat-find-active) {
26+
background-color: var(--brand-secondary);
27+
color: var(--white);
28+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Chat find's contract: the tally counts MESSAGES (not occurrences), stepping
5+
* wraps and reveals, and the highlights painted into the global registry only
6+
* ever cover mounted rows — with the message being stepped on separated out.
7+
*/
8+
import { act, useRef } from 'react'
9+
import { createRoot, type Root } from 'react-dom/client'
10+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11+
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
12+
import { collectHighlightRanges, useChatFind } from './use-chat-find'
13+
14+
/**
15+
* jsdom ships neither `CSS.highlights` nor `Highlight`. The stub keeps the real
16+
* contract — a named registry of range sets — so the assertions below read the
17+
* same ranges the browser would paint.
18+
*/
19+
class HighlightStub extends Set<Range> {}
20+
21+
let registry: Map<string, HighlightStub>
22+
23+
function installHighlightRegistry() {
24+
registry = new Map()
25+
vi.stubGlobal('Highlight', HighlightStub)
26+
vi.stubGlobal('CSS', { highlights: registry })
27+
}
28+
29+
function rangeTexts(name: string): string[] {
30+
return [...(registry.get(name) ?? [])].map((range) => range.toString())
31+
}
32+
33+
function message(id: string, content: string, role: 'user' | 'assistant' = 'user'): ChatMessage {
34+
return { id, role, content, timestamp: '2026-01-01T00:00:00.000Z' } as ChatMessage
35+
}
36+
37+
const reveal = vi.fn()
38+
39+
interface HarnessProps {
40+
messages: ChatMessage[]
41+
/** Indexes the virtualizer has mounted; the rows rendered below. */
42+
rendered: number[]
43+
onFind: (find: ReturnType<typeof useChatFind>) => void
44+
}
45+
46+
function Harness({ messages, rendered, onFind }: HarnessProps) {
47+
const rowsRef = useRef<HTMLDivElement>(null)
48+
const find = useChatFind({
49+
messages,
50+
rowsRef,
51+
renderedItems: rendered.map((index) => ({ index })),
52+
revealMessage: reveal,
53+
})
54+
onFind(find)
55+
return (
56+
<div ref={rowsRef}>
57+
{rendered.map((index) => (
58+
<div key={index} data-index={index}>
59+
{messages[index].content}
60+
</div>
61+
))}
62+
</div>
63+
)
64+
}
65+
66+
let container: HTMLDivElement
67+
let root: Root
68+
let find: ReturnType<typeof useChatFind>
69+
70+
function render(messages: ChatMessage[], rendered: number[]) {
71+
act(() => {
72+
root.render(
73+
<Harness
74+
messages={messages}
75+
rendered={rendered}
76+
onFind={(next) => {
77+
find = next
78+
}}
79+
/>
80+
)
81+
})
82+
}
83+
84+
function pressFind(options: { defaultPrevented?: boolean } = {}) {
85+
act(() => {
86+
const event = new KeyboardEvent('keydown', {
87+
key: 'f',
88+
metaKey: true,
89+
bubbles: true,
90+
cancelable: true,
91+
})
92+
if (options.defaultPrevented) event.preventDefault()
93+
document.dispatchEvent(event)
94+
})
95+
}
96+
97+
function type(query: string) {
98+
act(() => find.setQuery(query))
99+
}
100+
101+
beforeEach(() => {
102+
installHighlightRegistry()
103+
reveal.mockClear()
104+
container = document.createElement('div')
105+
document.body.appendChild(container)
106+
root = createRoot(container)
107+
})
108+
109+
afterEach(() => {
110+
act(() => root.unmount())
111+
container.remove()
112+
vi.unstubAllGlobals()
113+
})
114+
115+
describe('collectHighlightRanges', () => {
116+
it('finds every occurrence in a node, case-insensitively', () => {
117+
const root = document.createElement('div')
118+
root.textContent = 'Postgres, postgres and POSTGRES'
119+
const ranges = collectHighlightRanges(root, 'postgres')
120+
expect(ranges.map((range) => range.toString())).toEqual(['Postgres', 'postgres', 'POSTGRES'])
121+
})
122+
123+
it('walks nested nodes and skips a term split across them', () => {
124+
const root = document.createElement('div')
125+
root.innerHTML = '<p>a <strong>post</strong>gres <em>postgres</em></p>'
126+
expect(collectHighlightRanges(root, 'postgres').map((r) => r.toString())).toEqual(['postgres'])
127+
})
128+
129+
it('returns nothing for an empty term', () => {
130+
const root = document.createElement('div')
131+
root.textContent = 'postgres'
132+
expect(collectHighlightRanges(root, '')).toEqual([])
133+
})
134+
})
135+
136+
describe('useChatFind', () => {
137+
const messages = [
138+
message('a', 'postgres postgres postgres'),
139+
message('b', 'nothing here', 'assistant'),
140+
message('c', 'one postgres'),
141+
]
142+
143+
it('opens on Cmd+F and ignores a press another surface already took', () => {
144+
render(messages, [0, 1, 2])
145+
expect(find.isOpen).toBe(false)
146+
147+
pressFind({ defaultPrevented: true })
148+
expect(find.isOpen).toBe(false)
149+
150+
pressFind()
151+
expect(find.isOpen).toBe(true)
152+
})
153+
154+
it('counts matching messages, not occurrences, and reveals the first', () => {
155+
render(messages, [0, 1, 2])
156+
pressFind()
157+
type('postgres')
158+
159+
// Message 'a' holds three occurrences; the tally still reads two matches.
160+
expect(find.matchCount).toBe(2)
161+
expect(find.activeIndex).toBe(0)
162+
expect(reveal).toHaveBeenLastCalledWith(0)
163+
})
164+
165+
it('steps forward and backward with wrapping', () => {
166+
render(messages, [0, 1, 2])
167+
pressFind()
168+
type('postgres')
169+
170+
act(() => find.goToNext())
171+
expect(find.activeIndex).toBe(1)
172+
expect(reveal).toHaveBeenLastCalledWith(2)
173+
174+
act(() => find.goToNext())
175+
expect(find.activeIndex).toBe(0)
176+
expect(reveal).toHaveBeenLastCalledWith(0)
177+
178+
act(() => find.goToPrev())
179+
expect(find.activeIndex).toBe(1)
180+
expect(reveal).toHaveBeenLastCalledWith(2)
181+
})
182+
183+
it('paints every occurrence, holding the stepped-on message apart', () => {
184+
render(messages, [0, 1, 2])
185+
pressFind()
186+
type('postgres')
187+
188+
// Active message is 'a' — its three occurrences carry the active highlight,
189+
// message 'c' the base one.
190+
expect(rangeTexts('sim-chat-find-active')).toHaveLength(3)
191+
expect(rangeTexts('sim-chat-find')).toEqual(['postgres'])
192+
193+
act(() => find.goToNext())
194+
expect(rangeTexts('sim-chat-find-active')).toEqual(['postgres'])
195+
expect(rangeTexts('sim-chat-find')).toHaveLength(3)
196+
})
197+
198+
it('paints only mounted rows', () => {
199+
render(messages, [2])
200+
pressFind()
201+
type('postgres')
202+
203+
// Both messages match, but only row 2 is mounted, so only it is painted —
204+
// and it is not the active one, which the virtualizer has yet to reveal.
205+
expect(find.matchCount).toBe(2)
206+
expect(rangeTexts('sim-chat-find')).toEqual(['postgres'])
207+
expect(rangeTexts('sim-chat-find-active')).toEqual([])
208+
})
209+
210+
it('clears the term and every highlight on close', () => {
211+
render(messages, [0, 1, 2])
212+
pressFind()
213+
type('postgres')
214+
expect(registry.get('sim-chat-find-active')?.size).toBeGreaterThan(0)
215+
216+
act(() => find.close())
217+
expect(find.isOpen).toBe(false)
218+
expect(find.query).toBe('')
219+
expect(find.matchCount).toBe(0)
220+
expect(registry.has('sim-chat-find')).toBe(false)
221+
expect(registry.has('sim-chat-find-active')).toBe(false)
222+
})
223+
224+
it('drops highlights when the surface unmounts', () => {
225+
render(messages, [0, 1, 2])
226+
pressFind()
227+
type('postgres')
228+
expect(registry.size).toBeGreaterThan(0)
229+
230+
act(() => root.unmount())
231+
expect(registry.has('sim-chat-find')).toBe(false)
232+
expect(registry.has('sim-chat-find-active')).toBe(false)
233+
234+
root = createRoot(container)
235+
})
236+
})

0 commit comments

Comments
 (0)