Skip to content

Commit 21fb876

Browse files
committed
refactor(release): simplify the favicon lookup budget, share the link-name cap, read sim/ queries as Next parses them
1 parent d64e809 commit 21fb876

18 files changed

Lines changed: 102 additions & 193 deletions

File tree

‎apps/extension/DESIGN.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ other workspace resources or browser-control tools.
6464
or arbitrary remote code execution. The only network interception is the static
6565
`sim/` redirect rule above.
6666
- The only background network request is the `sim` address-bar keyword: after
67-
`sim` and Space, the typed link name (never a page, URL, or search containing a
68-
space) is sent to the configured Sim origin with the signed-in session to suggest
69-
matching Sim links. Unlisted links are never suggested.
67+
`sim` and Space, the text typed before the first `/` or `?` is sent to the
68+
configured Sim origin with the signed-in session to suggest matching Sim links,
69+
unless it contains a space (a search) or is longer than a link name can be. Page
70+
content is never sent, and unlisted links are never suggested.
7071
- No always-running page reader. Extraction is tied to an explicit attachment action.
7172
- Page bytes are not written to extension local/sync storage. Temporary context-menu
7273
handoff uses trusted-context session storage, expiration and navigation cleanup.

‎apps/extension/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app
2727

2828
- Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*`.
2929
- Typing `sim/<name>` in the address bar opens the organization's [Sim link](https://docs.sim.ai/search/sim-links). A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `<Sim origin>/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim.
30-
- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the typed link name, and nothing else (input containing a space is treated as a search and sends nothing), to `<Sim origin>/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested.
30+
- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `<Sim origin>/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested.
3131
- Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites.
3232
- Page capture runs on demand in Chrome's isolated world. It excludes editable fields, inputs, hidden elements, scripts, and embedded documents. It reads only the top frame. PDF viewers and canvas documents may have no accessible text.
3333
- Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation.

‎apps/extension/src/omnibox.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
SIM_LINK_NAME_MAX_LENGTH,
23
SIM_LINK_SUGGESTIONS_PATH,
34
simLinkSuggestionsSchema,
45
} from '@sim/browser-extension-protocol'
@@ -12,9 +13,6 @@ const logger = createLogger('BrowserExtensionOmnibox')
1213
/** Longer than the gap between a typist's keystrokes, so a name is looked up once it settles. */
1314
const SUGGEST_DELAY_MS = 220
1415

15-
/** Link names are at most this long, so nothing longer can match. */
16-
const MAX_NAME_LENGTH = 64
17-
1816
/** A Sim link as suggested: its name, and what it shows beside the name. */
1917
interface SuggestedLink {
2018
name: string
@@ -62,15 +60,17 @@ function encodeSegment(segment: string): string {
6260

6361
/**
6462
* The resolver address for what was typed, as the address bar would send `sim/<typed>`: each
65-
* `/` starts a value, kept in position even when empty, and a trailing `/` adds none.
63+
* `/` starts a value, kept in position even when empty, and a trailing `/` adds none. The query
64+
* is passed on as typed.
6665
*/
6766
function simLinkUrl(text: string): string {
6867
const { name, rest } = readTyped(text)
6968
const queryStart = rest.indexOf('?')
7069
const path = `${name}${queryStart === -1 ? rest : rest.slice(0, queryStart)}`.replace(/\/$/, '')
71-
const query = queryStart === -1 ? '' : new URLSearchParams(rest.slice(queryStart + 1)).toString()
7270
const segments = path ? path.split('/').map(encodeSegment) : []
73-
return `${SIM_ORIGIN}/${['sim', ...segments].join('/')}${query ? `?${query}` : ''}`
71+
const url = new URL(`${SIM_ORIGIN}/${['sim', ...segments].join('/')}`)
72+
if (queryStart !== -1) url.search = rest.slice(queryStart + 1)
73+
return url.toString()
7474
}
7575

7676
/** The links Sim suggests for `name`, or `null` when it couldn't answer (signed out, rate limited). */
@@ -119,7 +119,7 @@ export function registerSimLinkOmnibox() {
119119
description: `Open <match>sim/${escapeXml(`${name}${rest}`)}</match>`,
120120
})
121121
// A name can't hold a space, so `sim card prices` is a search, not a link: ask Sim nothing.
122-
if (/\s/.test(name) || name.length > MAX_NAME_LENGTH) {
122+
if (/\s/.test(name) || name.length > SIM_LINK_NAME_MAX_LENGTH) {
123123
suggest([])
124124
return
125125
}

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createLogger } from '@sim/logger'
2-
import { getErrorMessage } from '@sim/utils/errors'
32
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
43
import { cookies } from 'next/headers'
54
import { redirect } from 'next/navigation'
@@ -66,15 +65,7 @@ export default async function OrganizationLayout({
6665
if (!context) {
6766
return <OrganizationAccessDenied />
6867
}
69-
const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role).catch(
70-
(error) => {
71-
logger.warn('Could not resolve People navigation access', {
72-
organizationId,
73-
error: getErrorMessage(error),
74-
})
75-
return false
76-
}
77-
)
68+
const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role)
7869
const simLinksEnabledLookup = isSimLinksEnabled(organizationId)
7970
if (
8071
!context.mothershipAvailable &&

‎apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useId, useState } from 'react'
4+
import { SIM_LINK_NAME_MAX_LENGTH } from '@sim/browser-extension-protocol'
45
import {
56
Checkbox,
67
ChipModal,
@@ -16,7 +17,6 @@ import type { SimLink, SimLinkBody } from '@/lib/api/contracts/sim-links'
1617
import {
1718
isVariableSimLinkUrl,
1819
SIM_LINK_DESCRIPTION_MAX_LENGTH,
19-
SIM_LINK_NAME_MAX_LENGTH,
2020
SIM_LINK_VARIABLE,
2121
simLinkFieldsSchema,
2222
toSimLinkName,

‎apps/sim/app/sim/[[...path]]/page.tsx‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { ChipLink, StatusPageContent } from '@sim/emcn'
22
import type { Metadata } from 'next'
3-
import { headers } from 'next/headers'
43
import { notFound, redirect } from 'next/navigation'
54
import { getSession } from '@/lib/auth'
65
import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths'
76
import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
87
import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases'
9-
import { SIM_LINK_QUERY_HEADER } from '@/lib/sim-links/typed-query'
108
import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation'
119
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1210
import { LogoShell } from '@/app/(landing)/components/logo-shell'
@@ -19,6 +17,7 @@ export const metadata: Metadata = {
1917

2018
interface SimLinkPageProps {
2119
params: Promise<{ path?: string[] }>
20+
searchParams: Promise<Record<string, string | string[] | undefined>>
2221
}
2322

2423
/**
@@ -32,6 +31,18 @@ function decodeSimLinkPath(path: string[]): string[] {
3231
return segments.length === 1 ? segments[0].split('/').filter(Boolean) : segments
3332
}
3433

34+
/**
35+
* The query, from the params Next has already parsed and normalized (it re-serializes every
36+
* request's query before app code sees it, so the typed spelling isn't recoverable here).
37+
*/
38+
function toQueryString(searchParams: Record<string, string | string[] | undefined>): string {
39+
const query = new URLSearchParams()
40+
for (const [key, value] of Object.entries(searchParams)) {
41+
for (const item of [value ?? []].flat()) query.append(key, item)
42+
}
43+
return query.toString()
44+
}
45+
3546
/**
3647
* Why a link did not redirect. Only a valid link name is echoed back, so an arbitrary path
3748
* can't put its own text on this page.
@@ -50,9 +61,8 @@ function describeUnresolved(result: SimLinkResolution, validName: boolean, name:
5061
* Where `sim/<name>/<values…>?<query>` lands: the viewer's organization link's destination,
5162
* or a page that offers to create the link when nobody has yet.
5263
*/
53-
export default async function SimLinkPage({ params }: SimLinkPageProps) {
54-
const [{ path = [] }, requestHeaders] = await Promise.all([params, headers()])
55-
const query = requestHeaders.get(SIM_LINK_QUERY_HEADER) ?? ''
64+
export default async function SimLinkPage({ params, searchParams }: SimLinkPageProps) {
65+
const [{ path = [] }, query] = await Promise.all([params, searchParams.then(toQueryString)])
5666
const segments = decodeSimLinkPath(path)
5767
const session = await getSession()
5868
if (!session?.user)

‎apps/sim/lib/api/server/routes/definition.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function requireJsonRouteDefinition(
2424
}
2525

2626
/** Statuses a response body can't accompany, so never a binary route's default. */
27-
const BODYLESS_STATUSES = new Set([204, 205, 304])
27+
const BODYLESS_STATUSES = new Set([204, 205])
2828

2929
export function requireBinaryRouteDefinition(
3030
contract: AnyApiRouteContract,

‎apps/sim/lib/favicon/application.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import type { SessionPrincipal } from '@sim/auth/principal'
22
import { defineOperation } from '@/lib/core/application'
33
import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter'
44
import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case'
5-
import { resolveFavicon } from '@/lib/favicon/resolve.server'
5+
import { cachedFavicon, type FaviconResolution, resolveFavicon } from '@/lib/favicon/resolve.server'
66

77
const rateLimiter = new RateLimiter()
88

99
/**
10-
* Lookups that reach the icon providers, per user; icons already cached are not charged. Each
11-
* lookup can ask the providers about several hosts, and every icon it finds is cached for days.
10+
* Lookups that may reach the icon providers, per user; an icon already cached is served without
11+
* charge. Each lookup can ask the providers about several hosts, and caches what it finds for days.
1212
*/
1313
const PROVIDER_LOOKUP_LIMIT: TokenBucketConfig = {
1414
maxTokens: 120,
@@ -24,12 +24,19 @@ export const getFavicon = defineAuthorizedCredentialUserUseCase({
2424
principalKinds: ['session'],
2525
capability: 'none',
2626
}),
27-
execute: ({ principal, input }: { principal: SessionPrincipal; input: { host: string } }) =>
28-
resolveFavicon(input.host, async () => {
29-
const { allowed } = await rateLimiter.checkRateLimitDirect(
30-
`route:favicon-lookup:user:${principal.userId}`,
31-
PROVIDER_LOOKUP_LIMIT
32-
)
33-
return allowed
34-
}),
27+
async execute({
28+
principal,
29+
input,
30+
}: {
31+
principal: SessionPrincipal
32+
input: { host: string }
33+
}): Promise<FaviconResolution> {
34+
const cached = await cachedFavicon(input.host)
35+
if (cached) return cached
36+
const { allowed } = await rateLimiter.checkRateLimitDirect(
37+
`route:favicon-lookup:user:${principal.userId}`,
38+
PROVIDER_LOOKUP_LIMIT
39+
)
40+
return allowed ? resolveFavicon(input.host) : { kind: 'unavailable' }
41+
},
3542
})

‎apps/sim/lib/favicon/resolve.server.test.ts‎

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createMockRedis } from '@sim/testing/mocks/redis.mock'
66
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88
import { MAX_ICON_BYTES } from '@/lib/favicon/probe'
9-
import { resolveFavicon } from '@/lib/favicon/resolve.server'
9+
import { cachedFavicon, resolveFavicon } from '@/lib/favicon/resolve.server'
1010

1111
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
1212

@@ -73,21 +73,6 @@ describe('resolveFavicon', () => {
7373
expect(ttlOf('acme.example.com')).toBe(30 * DAY)
7474
})
7575

76-
it('asks no provider and caches nothing when a lookup is not admitted', async () => {
77-
answers = { 'acme.example.com': 'icon' }
78-
expect((await resolveFavicon('acme.example.com', async () => false)).kind).toBe('unavailable')
79-
expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled()
80-
expect(ttlOf('acme.example.com')).toBeUndefined()
81-
})
82-
83-
it('serves a cached icon without asking to admit a lookup', async () => {
84-
answers = { 'acme.example.com': 'icon' }
85-
await resolveFavicon('acme.example.com')
86-
const admit = vi.fn(async () => false)
87-
expect((await resolveFavicon('acme.example.com', admit)).kind).toBe('hit')
88-
expect(admit).not.toHaveBeenCalled()
89-
})
90-
9176
it('looks up an icon cached under an earlier, larger cap again instead of serving it', async () => {
9277
answers = { 'acme.example.com': 'icon' }
9378
const oversized = Buffer.concat([Buffer.from('h'), PNG, Buffer.alloc(MAX_ICON_BYTES)])
@@ -97,37 +82,20 @@ describe('resolveFavicon', () => {
9782
expect(inputValidationMockFns.mockSecureFetchWithValidation).toHaveBeenCalled()
9883
})
9984

100-
it("never lets one caller's refused budget answer another caller's lookup", async () => {
85+
it('reports nothing cached until a lookup has stored an answer', async () => {
10186
answers = { 'acme.example.com': 'icon' }
102-
const [refused, admitted] = await Promise.all([
103-
resolveFavicon('acme.example.com', async () => false),
104-
resolveFavicon('acme.example.com', async () => true),
105-
])
106-
expect(refused.kind).toBe('unavailable')
107-
expect(admitted.kind).toBe('hit')
87+
expect(await cachedFavicon('acme.example.com')).toBeNull()
88+
await resolveFavicon('acme.example.com')
89+
expect((await cachedFavicon('acme.example.com'))?.kind).toBe('hit')
10890
})
10991

110-
it('does not charge a caller who joins a lookup already asking the providers', async () => {
92+
it('shares one lookup between concurrent callers of a host', async () => {
11193
answers = { 'acme.example.com': 'icon' }
112-
const fetchProvider = inputValidationMockFns.mockSecureFetchWithValidation
113-
const answerProvider = fetchProvider.getMockImplementation()
114-
let release = () => {}
115-
const released = new Promise<void>((resolve) => {
116-
release = resolve
117-
})
118-
fetchProvider.mockImplementation(
119-
async (...args: Parameters<NonNullable<typeof answerProvider>>) => {
120-
await released
121-
return answerProvider?.(...args)
122-
}
123-
)
124-
const first = resolveFavicon('acme.example.com')
125-
await vi.waitFor(() => expect(fetchProvider).toHaveBeenCalled())
126-
const joinerAdmit = vi.fn(async () => true)
127-
const joined = resolveFavicon('acme.example.com', joinerAdmit)
128-
release()
129-
expect((await joined).kind).toBe('hit')
130-
expect((await first).kind).toBe('hit')
131-
expect(joinerAdmit).not.toHaveBeenCalled()
94+
const results = await Promise.all([
95+
resolveFavicon('acme.example.com'),
96+
resolveFavicon('acme.example.com'),
97+
])
98+
expect(results.map((result) => result.kind)).toEqual(['hit', 'hit'])
99+
expect(inputValidationMockFns.mockSecureFetchWithValidation).toHaveBeenCalledTimes(1)
132100
})
133101
})

‎apps/sim/lib/favicon/resolve.server.ts‎

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { coalesceLocally } from '@/lib/concurrency/singleflight'
23
import { getRedisClient } from '@/lib/core/config/redis'
34
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
45
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
@@ -34,13 +35,11 @@ const PROVIDERS: readonly ((host: string) => string)[] = [
3435
(host) => `https://icons.duckduckgo.com/ip3/${host}.ico`,
3536
]
3637

37-
type FaviconResolution =
38+
export type FaviconResolution =
3839
| { kind: 'hit'; contentType: string; bytes: Buffer; provisional?: true }
3940
| { kind: 'miss' }
4041
| { kind: 'unavailable' }
4142

42-
const inFlight = new Map<string, Promise<FaviconResolution>>()
43-
4443
const cacheKey = (host: string) => `favicon:v3:${host}`
4544

4645
/** A host's key holds the result of the walk that starts at that host. */
@@ -152,39 +151,30 @@ async function walk(candidates: string[], signal: AbortSignal): Promise<FaviconR
152151
return unavailable ? { kind: 'unavailable' } : { kind: 'miss' }
153152
}
154153

154+
/** The cached answer for a host, or `null` when it would take a lookup to know. */
155+
export async function cachedFavicon(host: string): Promise<FaviconResolution | null> {
156+
const requested = faviconHostCandidates(host)[0]
157+
return requested ? readCache(requested) : { kind: 'miss' }
158+
}
159+
155160
/**
156161
* The icon for a host, or its nearest parent domain's, from public icon providers — Sim never
157162
* contacts the destination itself (a provider may, from its own servers), so a host behind a
158163
* login still gets its platform's icon. Results are cached per host (30 days for an icon, a day
159164
* for none or for a parent's icon chosen after a timeout, minutes after an outage), and
160-
* concurrent lookups of one host in this process share a single resolution. `admitLookup` is
161-
* the caller's own budget, asked only when nothing is cached and no lookup of the host is already
162-
* running: refused, the host is reported unavailable to this caller alone and nothing is cached,
163-
* so cached icons and lookups shared with another caller stay free.
165+
* concurrent lookups of one host in this process share a single resolution.
164166
*/
165-
export async function resolveFavicon(
166-
host: string,
167-
admitLookup: () => Promise<boolean> = async () => true
168-
): Promise<FaviconResolution> {
167+
export async function resolveFavicon(host: string): Promise<FaviconResolution> {
169168
const candidates = faviconHostCandidates(host)
170169
const requested = candidates[0]
171170
if (!requested) return { kind: 'miss' }
172-
const joined = inFlight.get(requested)
173-
if (joined) return joined
174-
const cached = await readCache(requested)
175-
if (cached) return cached
176-
if (!(await admitLookup())) return { kind: 'unavailable' }
177-
// Another caller may have started the same lookup while this one read the cache and its budget.
178-
const pending = inFlight.get(requested)
179-
if (pending) return pending
180-
181-
const resolution = (async (): Promise<FaviconResolution> => {
171+
return coalesceLocally(`favicon:${requested}`, async (): Promise<FaviconResolution> => {
172+
const cached = await readCache(requested)
173+
if (cached) return cached
182174
const resolved = await runWithOutboundOrganization(null, () =>
183175
walk(candidates, AbortSignal.timeout(RESOLVE_DEADLINE_MS))
184176
).catch((): FaviconResolution => ({ kind: 'unavailable' }))
185177
await writeCache(requested, resolved)
186178
return resolved
187-
})().finally(() => inFlight.delete(requested))
188-
inFlight.set(requested, resolution)
189-
return resolution
179+
}).catch((): FaviconResolution => ({ kind: 'unavailable' }))
190180
}

0 commit comments

Comments
 (0)