From 57f5b2947f6c021ab16f4da015eea64eddc12b57 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:03:51 -0800 Subject: [PATCH 1/8] improvement(links): make go/ and sim/ work on install with no setup Chrome treats go/x as a search until it has seen the host. It counts a visit to an unfamiliar single-label host as typed, so the extension now visits both hosts once on install, in background tabs that close themselves. That removes the Browser shortcuts modal and its Enable buttons, and the Links header now just links to the store. Drop the managed bookmarks export with it. The /sim-links// route stays so bookmarks already distributed keep opening. --- .../content/docs/browser-extension/index.mdx | 2 +- apps/docs/content/docs/search/sim-links.mdx | 12 +- apps/extension/README.md | 2 +- apps/extension/package.json | 2 +- apps/extension/scripts/test-e2e.ts | 15 +++ apps/extension/src/background.ts | 2 + apps/extension/src/short-hosts.ts | 49 ++++++++ .../app/(landing)/privacy/privacy-content.tsx | 2 +- .../[id]/sim-links/bookmarks/route.ts | 20 ---- .../links/browser-shortcuts-modal.tsx | 62 ---------- .../app/o/[organizationId]/links/columns.ts | 2 +- .../app/o/[organizationId]/links/loading.tsx | 4 +- .../links/managed-bookmarks.tsx | 108 ------------------ .../o/[organizationId]/links/sim-links.tsx | 17 +-- apps/sim/hooks/queries/sim-links.ts | 9 -- apps/sim/lib/api/contracts/sim-links.ts | 18 +-- .../lib/sim-links/application/operations.ts | 7 -- .../lib/sim-links/application/use-cases.ts | 23 ---- apps/sim/lib/sim-links/repository.ts | 13 --- apps/sim/lib/sim-links/validation.ts | 1 - apps/sim/scripts/test-sim-links-e2e.ts | 68 ++++------- 21 files changed, 108 insertions(+), 330 deletions(-) create mode 100644 apps/extension/src/short-hosts.ts delete mode 100644 apps/sim/app/api/organizations/[id]/sim-links/bookmarks/route.ts delete mode 100644 apps/sim/app/o/[organizationId]/links/browser-shortcuts-modal.tsx delete mode 100644 apps/sim/app/o/[organizationId]/links/managed-bookmarks.tsx diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx index 8832971a08e..fbe557925bf 100644 --- a/apps/docs/content/docs/browser-extension/index.mdx +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -33,7 +33,7 @@ Change the binding in Chrome’s **Extensions → Keyboard shortcuts**. If Chrom ## Open team links -Open **Links → Browser shortcuts** in Sim and select **Enable go/ shortcuts**. Then type a shortcut such as `go/handbook` in Chrome’s address bar. You can enable `sim/handbook` there too. +Type a shortcut such as `go/handbook` or `sim/handbook` in Chrome’s address bar. Both work as soon as the extension is installed. For suggestions, type `sim`, press **Space**, and enter part of a link name. If Chrome searches instead of opening a shortcut, enable your search engine under the extension’s **+ → Browser shortcuts**. Other searches stay unchanged. diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index 11a79f74813..46112573dbd 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -15,15 +15,17 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile. In Sim, open **Links → Browser shortcuts** and select **Enable go/ shortcuts**. Type `go/handbook` in the address bar to open a link. Enable **sim/ shortcuts** there if you prefer `sim/handbook`. +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile, then type `go/handbook` or `sim/handbook` in the address bar. Both work as soon as it's installed. For suggestions, type `sim`, press **Space**, and enter part of a name. Matching links appear with the most opened first. Unlisted links still open by name. ### If a shortcut opens search -Open the extension’s **+ → Browser shortcuts** and turn on your search engine. Approve Chrome’s site-access prompt if shown. Google (`google.com`), Bing, DuckDuckGo, and Yahoo are supported. Other searches and your default search engine stay unchanged. +This can happen after you clear your browsing history. A trailing slash (`go/handbook/`) or `http://go/handbook` tells Chrome to navigate directly, and Chrome remembers it from then on. -This handles whole shortcuts such as `go/handbook` or `go/jira/123`. For spaces, query parameters, unsupported search sites, or blocked site access, use `sim` + **Space** or the full `https://www.sim.ai/sim/` address. A trailing slash (`go/handbook/`) or `http://go/handbook` also tells Chrome to navigate directly. +To catch these searches automatically, open the extension’s **+ → Browser shortcuts** and turn on your search engine. Approve Chrome’s site-access prompt if shown. Google (`google.com`), Bing, DuckDuckGo, and Yahoo are supported. Other searches and your default search engine stay unchanged. + +This handles whole shortcuts such as `go/handbook` or `go/jira/123`. For spaces, query parameters, unsupported search sites, or blocked site access, use `sim` + **Space** or the full `https://www.sim.ai/sim/` address. Turning shortcuts off keeps site access. To remove it, open Chrome’s **Extensions → Manage extensions → Sim → Details**. @@ -45,9 +47,7 @@ Type `sim`, press **Space**, then type a link name and press **Enter**. For mana ### Show favorites in regular Chrome suggestions -In Sim, choose **Copy link** for a favorite. In Chrome’s bookmark manager (`chrome://bookmarks`), add a bookmark with that URL and name it after the shortcut, such as `sim/handbook`. Chrome can suggest that bookmark when you type `sim/handb` or `handbook` in the address bar. Administrators can distribute selected team favorites through Chrome’s [managed bookmarks policy](https://chromeenterprise.google/policies/managed-bookmarks/). - -Organization admins can also open **Browser shortcuts → Team bookmarks**, choose listed links, and select **Export bookmarks**. The downloaded JSON contains a folder to merge into Chrome’s managed bookmarks policy. Its links resolve in the exporting organization, so a bookmark shared outside the team cannot open another team's same-name destination. +In Sim, choose **Copy link** for a favorite. In Chrome’s bookmark manager (`chrome://bookmarks`), add a bookmark with that URL and name it after the shortcut, such as `sim/handbook`. Chrome can suggest that bookmark when you type `sim/handb` or `handbook` in the address bar. ## Create a link diff --git a/apps/extension/README.md b/apps/extension/README.md index f5ae44212ae..8319ee863d0 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -30,7 +30,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app ## Data and permissions - Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`. -- Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome can treat bare `go/` or `sim/` as a search before the redirect rule runs. Typing `http://go/` once lets Chrome learn the short host in that profile. A trailing slash (`go//`) also selects navigation without relying on that history. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. +- Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome treats bare `go/` or `sim/` as a search until its history holds that short host, so on install the extension opens `http://go/` and `http://sim/` once in background tabs that close themselves. Chrome counts a visit to an unfamiliar single-label host as typed, which is what the address bar checks. If that history is cleared, a trailing slash (`go//`) selects navigation and teaches the host again. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. - The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. For these suggestions, 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 `/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. - **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `go/` or `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. - Optional HTTP/HTTPS access is requested together by **Allow across websites**, from a direct user action. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. diff --git a/apps/extension/package.json b/apps/extension/package.json index 655e487affe..2529b2b9e69 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@sim/extension", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 32700032bd3..0f0dc8c981d 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -209,6 +209,21 @@ try { const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker')) context.setDefaultTimeout(15_000) const extensionId = worker.url().split('/')[2] + await check('Installing visits each short host once in tabs that close themselves', async () => { + await expect + .poll( + () => + worker.evaluate( + async () => (await chrome.storage.local.get('shortHostsLearned')).shortHostsLearned + ), + { timeout: 30_000 } + ) + .toBe(true) + const leftBehind = await worker.evaluate( + async () => (await chrome.tabs.query({ url: ['http://go/*', 'http://sim/*'] })).length + ) + assert(leftBehind === 0, 'Short host tabs were left open') + }) const signIn = await context.newPage() await signIn.goto(`${origin}/sign-in`) await signIn.close() diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index b836406d50e..b4524b1cc55 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -11,6 +11,7 @@ import { import { captureIntentSchema } from '@/intent' import { registerSimLinkOmnibox } from '@/omnibox' import { registerSearchRecovery } from '@/search-recovery' +import { registerShortHostLearning } from '@/short-hosts' import { digestUrl } from '@/utils' const logger = createLogger('BrowserExtension') @@ -92,6 +93,7 @@ function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') registerSimLinkOmnibox() registerSearchRecovery() +registerShortHostLearning() chrome.runtime.onInstalled.addListener(() => { void configure().catch(() => logger.warn('Could not initialize extension controls')) }) diff --git a/apps/extension/src/short-hosts.ts b/apps/extension/src/short-hosts.ts new file mode 100644 index 00000000000..7a5de2f6edf --- /dev/null +++ b/apps/extension/src/short-hosts.ts @@ -0,0 +1,49 @@ +import { SIM_LINK_HOSTS } from '@sim/browser-extension-protocol' +import { createLogger } from '@sim/logger' +import { SIM_ORIGIN } from '@/config' + +const logger = createLogger('ShortHosts') +const LEARNED_KEY = 'shortHostsLearned' +const VISIT_TIMEOUT_MS = 15_000 + +/** Closes once Chrome has committed the visit, which is what records the host in its history. */ +async function visitInBackground(host: string) { + const { id: tabId } = await chrome.tabs.create({ url: `http://${host}/`, active: false }) + if (tabId === undefined) return + await new Promise((resolve) => { + const finish = () => { + clearTimeout(timeout) + chrome.tabs.onUpdated.removeListener(updated) + resolve() + } + const updated = (id: number, change: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab) => { + if (id === tabId && (change.status === 'complete' || tab.url?.startsWith(SIM_ORIGIN))) { + finish() + } + } + const timeout = setTimeout(finish, VISIT_TIMEOUT_MS) + chrome.tabs.onUpdated.addListener(updated) + }) + await chrome.tabs.remove(tabId).catch(() => undefined) +} + +/** + * Chrome searches for `go/handbook` until it has seen `go` as a host, so visiting each short host + * once is what makes them work from the address bar. A visit to an unfamiliar single-label host + * counts as typed in Chrome's history, so it teaches the address bar without asking the user to. + */ +async function learnShortHosts() { + const stored = await chrome.storage.local.get(LEARNED_KEY) + if (stored[LEARNED_KEY] === true) return + await Promise.all(SIM_LINK_HOSTS.map(visitInBackground)) + await chrome.storage.local.set({ [LEARNED_KEY]: true }) +} + +export function registerShortHostLearning() { + const learn = () => { + void learnShortHosts().catch(() => logger.warn('Could not visit the short link hosts')) + } + chrome.runtime.onInstalled.addListener(learn) + // A managed install can run before any window exists, so it retries at the next browser start. + chrome.runtime.onStartup.addListener(learn) +} diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index 4be1c4dc5e8..353a9638d74 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -848,7 +848,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'Browser shortcuts can optionally recover an explicit sim/ or go/ shortcut that Chrome sends to a supported search engine. After You enable a provider and approve access to its listed sites, Chrome applies request rules that send only the matching shortcut name and path values to Sim’s normal link resolver. Other search parameters and ordinary search terms are not sent to Sim by this feature. The extension stores Your provider choices locally and does not change Your default search engine. These site permissions can also allow page attachment when You explicitly request it; enabling shortcut recovery does not enable Reference current page or background capture. Turning recovery off stops these redirects. Site access remains until You revoke or restrict it in Chrome settings.' + 'When installed, the extension opens sim/ and go/ once in background tabs that close themselves, so Chrome recognizes these shortcuts in the address bar. Those tabs load Sim’s link page with Your session and send no browsing data. Browser shortcuts can optionally recover an explicit sim/ or go/ shortcut that Chrome sends to a supported search engine. After You enable a provider and approve access to its listed sites, Chrome applies request rules that send only the matching shortcut name and path values to Sim’s normal link resolver. Other search parameters and ordinary search terms are not sent to Sim by this feature. The extension stores Your provider choices locally and does not change Your default search engine. These site permissions can also allow page attachment when You explicitly request it; enabling shortcut recovery does not enable Reference current page or background capture. Turning recovery off stops these redirects. Site access remains until You revoke or restrict it in Chrome settings.' ), }, { diff --git a/apps/sim/app/api/organizations/[id]/sim-links/bookmarks/route.ts b/apps/sim/app/api/organizations/[id]/sim-links/bookmarks/route.ts deleted file mode 100644 index 1d9701a136b..00000000000 --- a/apps/sim/app/api/organizations/[id]/sim-links/bookmarks/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { exportSimBookmarksContract } from '@/lib/api/contracts/sim-links' -import { - defineInternalJsonRoute, - internalOrchestrationErrorPolicy, - internalRateLimits, - internalSessionAuth, -} from '@/lib/api/server/routes' -import { exportSimBookmarks } from '@/lib/sim-links/application/use-cases' - -export const POST = defineInternalJsonRoute({ - contract: exportSimBookmarksContract, - auth: internalSessionAuth, - rateLimit: internalRateLimits.user({ bucketName: 'sim-links' }), - errorPolicy: internalOrchestrationErrorPolicy, - staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, - operation: exportSimBookmarks.operation, - useCase: exportSimBookmarks, - mapInput: ({ params, body }) => ({ organizationId: params.id, links: body.links }), - present: (links) => ({ links }), -}) diff --git a/apps/sim/app/o/[organizationId]/links/browser-shortcuts-modal.tsx b/apps/sim/app/o/[organizationId]/links/browser-shortcuts-modal.tsx deleted file mode 100644 index f7d2be7199d..00000000000 --- a/apps/sim/app/o/[organizationId]/links/browser-shortcuts-modal.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - ChipLink, - ChipModal, - ChipModalBody, - ChipModalDescription, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import { ManagedBookmarks } from '@/app/o/[organizationId]/links/managed-bookmarks' -import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' - -interface BrowserShortcutsModalProps { - onClose: () => void -} - -export function BrowserShortcutsModal({ onClose }: BrowserShortcutsModalProps) { - const { viewer, organization } = useOrganizationContext() - return ( - !open && onClose()} srTitle='Browser shortcuts'> - Browser shortcuts - - - With Sim for Chrome installed, type go/handbook or{' '} - sim/handbook in the address bar. - -
- - Enable go/ shortcuts - - - Enable sim/ shortcuts - -
- - Still opening search results? In the Sim extension, open{' '} - + → Browser shortcuts and enable your search provider. - - {viewer.isAdmin && ( - - )} -
- - Install Sim for Chrome - - ), - }, - ]} - defaultAction='dismiss' - cancelLabel='Done' - onCancel={onClose} - /> -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/links/columns.ts b/apps/sim/app/o/[organizationId]/links/columns.ts index 5ecb9c92ee5..f522b1d3a5d 100644 --- a/apps/sim/app/o/[organizationId]/links/columns.ts +++ b/apps/sim/app/o/[organizationId]/links/columns.ts @@ -13,5 +13,5 @@ export const SIM_LINK_COLUMNS = [ export const SIM_LINKS_SEARCH_PLACEHOLDER = 'Search links...' -export const BROWSER_SHORTCUTS_ACTION = { text: 'Browser shortcuts', icon: Globe } as const +export const INSTALL_EXTENSION_ACTION = { text: 'Install Sim for Chrome', icon: Globe } as const export const NEW_SIM_LINK_ACTION = { text: 'New link', icon: Plus, variant: 'primary' } as const diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx index b5d14c95a2a..f955ff8dd99 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -3,7 +3,7 @@ import { Link } from '@sim/emcn/icons' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { - BROWSER_SHORTCUTS_ACTION, + INSTALL_EXTENSION_ACTION, NEW_SIM_LINK_ACTION, SIM_LINK_COLUMNS, SIM_LINKS_SEARCH_PLACEHOLDER, @@ -18,7 +18,7 @@ export default function SimLinksLoading() { icon={Link} title='Links' columns={SIM_LINK_COLUMNS} - actions={hosted ? [BROWSER_SHORTCUTS_ACTION, NEW_SIM_LINK_ACTION] : [NEW_SIM_LINK_ACTION]} + actions={hosted ? [INSTALL_EXTENSION_ACTION, NEW_SIM_LINK_ACTION] : [NEW_SIM_LINK_ACTION]} searchPlaceholder={SIM_LINKS_SEARCH_PLACEHOLDER} hasSort /> diff --git a/apps/sim/app/o/[organizationId]/links/managed-bookmarks.tsx b/apps/sim/app/o/[organizationId]/links/managed-bookmarks.tsx deleted file mode 100644 index a630594c6e5..00000000000 --- a/apps/sim/app/o/[organizationId]/links/managed-bookmarks.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useState } from 'react' -import { Chip, ChipCombobox, ChipModalField } from '@sim/emcn' -import type { BookmarkLink } from '@/lib/api/contracts/sim-links' -import { organizationRoutes } from '@/lib/navigation/paths' -import { MAX_MANAGED_BOOKMARKS } from '@/lib/sim-links/validation' -import { saveBlob } from '@/lib/uploads/client/download' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { useExportSimBookmarks, useSimLinks } from '@/hooks/queries/sim-links' -import { useSearchFilterValue } from '@/hooks/use-search-filter-value' - -interface ManagedBookmarksProps { - organizationId: string -} - -export function ManagedBookmarks({ organizationId }: ManagedBookmarksProps) { - const [search, setSearch] = useState('') - const [selected, setSelected] = useState([]) - const [notice, setNotice] = useState('') - const exporting = useExportSimBookmarks(organizationId) - const query = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS) - const links = useSimLinks(organizationId, query, 'name', 'asc') - const listed = links.isPlaceholderData - ? [] - : (links.data?.pages.flatMap((page) => page.links.filter((link) => !link.unlisted)) ?? []) - const selectedIds = new Set(selected.map((link) => link.id)) - const candidates = new Map([...selected, ...listed].map((link) => [link.id, link])) - const visible = new Set(listed.map((link) => link.id)) - const options = [...candidates.values()].map((link) => ({ - value: link.id, - label: link.name, - hidden: !visible.has(link.id), - disabled: selected.length >= MAX_MANAGED_BOOKMARKS && !selectedIds.has(link.id), - })) - function choose(ids: string[]) { - setNotice('') - exporting.reset() - setSelected( - ids.flatMap((id) => { - const link = candidates.get(id) - return link ? [{ id: link.id, name: link.name }] : [] - }) - ) - } - function download() { - exporting.mutate(selected, { - onSuccess: ({ links: current }) => { - if (current.length !== selected.length) { - setSelected(current) - setNotice( - 'Some links changed or are no longer listed. Review your selection before exporting.' - ) - return - } - const routes = organizationRoutes(organizationId) - const children = current.map(({ name }) => ({ - name: `sim/${name}`, - url: new URL(routes.openSimLink(name), window.location.origin).href, - })) - saveBlob( - new Blob([JSON.stringify([{ name: 'Links', children }], null, 2)], { - type: 'application/json', - }), - 'sim-managed-bookmarks.json' - ) - }, - }) - } - return ( - - {(aria) => ( -
- void links.fetchNextPage()} - isLoadingMore={links.isFetchingNextPage} - error={links.isError ? 'Could not load links.' : undefined} - emptyMessage='No listed links found' - /> - - Export bookmarks - - {(notice || exporting.isError) && ( - - {exporting.isError ? 'Could not export these bookmarks. Try again.' : notice} - - )} -
- )} -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 6f6fa49cf89..76116fe5d09 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -10,9 +10,8 @@ import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { simLinkPath, simLinkUrl } from '@/lib/navigation/paths' import { toSimLinkName } from '@/lib/sim-links/validation' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { BrowserShortcutsModal } from '@/app/o/[organizationId]/links/browser-shortcuts-modal' import { - BROWSER_SHORTCUTS_ACTION, + INSTALL_EXTENSION_ACTION, NEW_SIM_LINK_ACTION, SIM_LINK_COLUMNS, SIM_LINKS_SEARCH_PLACEHOLDER, @@ -42,12 +41,14 @@ import { import { useDeleteSimLink, useSimLinks } from '@/hooks/queries/sim-links' import { useContextMenu } from '@/hooks/use-context-menu' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' -import { useHydrated } from '@/hooks/use-hydrated' import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('SimLinks') +const SIM_FOR_CHROME_URL = + 'https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg' + const SORT_OPTIONS = [ { id: 'name', label: 'Name' }, { id: 'popular', label: 'Opens' }, @@ -78,8 +79,6 @@ interface SimLinksProps { /** Every Sim link the viewer may browse, in the shared resource table. */ export function SimLinks({ organizationId }: SimLinksProps) { const { hosted } = useDeploymentShape() - const hydrated = useHydrated() - const [showBrowserShortcuts, setShowBrowserShortcuts] = useState(false) const [{ search, create }, setView] = useQueryStates(simLinksParsers, simLinksUrlKeys) const setSearch = useDebouncedSearchSetter((value, options) => setView({ search: value }, options) @@ -168,9 +167,8 @@ export function SimLinks({ organizationId }: SimLinksProps) { ...(hosted ? [ { - ...BROWSER_SHORTCUTS_ACTION, - disabled: !hydrated, - onSelect: () => setShowBrowserShortcuts(true), + ...INSTALL_EXTENSION_ACTION, + onSelect: () => window.open(SIM_FOR_CHROME_URL, '_blank', 'noopener,noreferrer'), }, ] : []), @@ -205,9 +203,6 @@ export function SimLinks({ organizationId }: SimLinksProps) { )) } /> - {hosted && showBrowserShortcuts && ( - setShowBrowserShortcuts(false)} /> - )} {menuLink && ( - requestJson(exportSimBookmarksContract, { params: { id: organizationId }, body: { links } }), - }) -} diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index 9e268ad2e67..dc509a746a2 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -6,12 +6,7 @@ import { import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { organizationIdSchema } from '@/lib/api/contracts/primitives' -import { - MAX_MANAGED_BOOKMARKS, - SIM_LINK_SORTS, - simLinkFieldsSchema, - simLinkNameSchema, -} from '@/lib/sim-links/validation' +import { SIM_LINK_SORTS, simLinkFieldsSchema, simLinkNameSchema } from '@/lib/sim-links/validation' import { SORT_DIRECTIONS } from '@/lib/url-state' const params = z.object({ id: organizationIdSchema }) @@ -66,17 +61,6 @@ export const listSimLinksContract = defineRouteContract({ }, }) -const bookmarkLink = z.object({ id: linkParams.shape.linkId, name: simLinkNameSchema }) -export type BookmarkLink = z.output - -export const exportSimBookmarksContract = defineRouteContract({ - method: 'POST', - path: '/api/organizations/[id]/sim-links/bookmarks', - params, - body: z.object({ links: z.array(bookmarkLink).min(1).max(MAX_MANAGED_BOOKMARKS) }), - response: { mode: 'json', schema: z.object({ links: z.array(bookmarkLink) }) }, -}) - export const createSimLinkContract = defineRouteContract({ method: 'POST', path: '/api/organizations/[id]/sim-links', diff --git a/apps/sim/lib/sim-links/application/operations.ts b/apps/sim/lib/sim-links/application/operations.ts index abf7eb0a56c..81057857d2a 100644 --- a/apps/sim/lib/sim-links/application/operations.ts +++ b/apps/sim/lib/sim-links/application/operations.ts @@ -50,13 +50,6 @@ export const simLinkOperations = { ...policy, capability: 'none', }), - // permission-group-exempt: Managed bookmarks are a Chrome admin export of organization-wide shortcuts. - exportBookmarks: defineOrganizationOperation({ - id: 'organization.sim_links.export_bookmarks', - minimumRole: 'admin', - principalKinds: ['session'], - capability: 'none', - }), /** Not organization-scoped: it serves whichever organization `sim/` opens in for the session. */ // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. suggest: defineOperation({ diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index f2a722a2f73..83346b277ed 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -4,7 +4,6 @@ import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getPostgresErrorCode, toError } from '@sim/utils/errors' -import type { BookmarkLink } from '@/lib/api/contracts/sim-links' import { defineAuthorizedOrganizationUseCase, type OrganizationUseCaseContext, @@ -24,7 +23,6 @@ import { requireSimLinksEnabled } from '@/lib/sim-links/feature-flag' import type { SimLinkAccess, SimLinkRecord, SimLinkViewer } from '@/lib/sim-links/repository' import * as repository from '@/lib/sim-links/repository' import { - MAX_MANAGED_BOOKMARKS, type SimLinkFields, type SimLinkSort, stripSimLinkPrefix, @@ -149,27 +147,6 @@ async function readView( return toView(record, viewer) } -export const exportSimBookmarks = defineAuthorizedOrganizationUseCase({ - operation: simLinkOperations.exportBookmarks, - ...rollout, - async execute({ - input, - context, - }: OrganizationUseCaseContext) { - if (!input.links.length || input.links.length > MAX_MANAGED_BOOKMARKS) - throw new OrchestrationError( - 'validation', - `Choose between 1 and ${MAX_MANAGED_BOOKMARKS} bookmarks.` - ) - const selected = new Map(input.links.map((link) => [link.id, link])) - const listed = await repository.listBookmarkCandidates(context.organizationId, [ - ...selected.keys(), - ]) - const current = new Map(listed.map((link) => [link.id, link.name])) - return [...selected.values()].filter((link) => current.get(link.id) === link.name) - }, -}) - export const listSimLinks = defineAuthorizedOrganizationUseCase({ operation: simLinkOperations.list, ...rollout, diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts index bbbf24e54ec..689fbb233f2 100644 --- a/apps/sim/lib/sim-links/repository.ts +++ b/apps/sim/lib/sim-links/repository.ts @@ -12,19 +12,6 @@ import { } from '@/lib/sim-links/validation' import type { SortDirection } from '@/lib/url-state' -export function listBookmarkCandidates(organizationId: string, ids: string[]) { - return db - .select({ id: simLink.id, name: simLink.name }) - .from(simLink) - .where( - and( - eq(simLink.organizationId, organizationId), - eq(simLink.unlisted, false), - inArray(simLink.id, ids) - ) - ) -} - const PAGE_SIZE = 50 export interface SimLinkRecord { diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index 70a30e6ea02..ffb7c58d664 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -8,7 +8,6 @@ import { z } from 'zod' const NAME_PATTERN = /^[A-Za-z0-9.:@&~_-]+$/ const NAME_PUNCTUATION = /[.:@&~_-]/g const NAME_DISALLOWED = /[^A-Za-z0-9.:@&~_-]/g -export const MAX_MANAGED_BOOKMARKS = 100 export const SIM_LINK_DESCRIPTION_MAX_LENGTH = 500 /** List orders: alphabetical by name, or most visited first. */ diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts index 624b261ee9c..02a364f1c21 100644 --- a/apps/sim/scripts/test-sim-links-e2e.ts +++ b/apps/sim/scripts/test-sim-links-e2e.ts @@ -405,55 +405,31 @@ async function run() { await api({ ...outsider, orgId }, '', { expected: 404 }) }) - await check('team bookmarks preserve encoded names and organization across sign-in', async () => { - const destination = 'https://team.example.com/help' - for (const name of ['managed-help', 'managed:help@team&docs']) { - const path = `/sim-links/${encodeURIComponent(orgId)}/${encodeURIComponent(name)}` - await create(owner, { name, url: destination }) - await create(outsider, { name, url: 'https://another-team.example.com/help' }) - const signedOut = await send(path, null) - assert.equal(signedOut.status, 307, `${name}: expected sign-in redirect`) - const login = new URL(signedOut.headers.get('location') ?? '/', baseUrl) - assert.equal(login.pathname, '/login', name) - assert.equal(login.searchParams.get('callbackUrl'), path, name) - const opened = await send(path, member) - assert.equal(opened.status, 307, `${name}: expected destination redirect`) - assert.equal(opened.headers.get('location'), destination, name) - const denied = await send(path, outsider) - assert.equal(denied.status, 404, name) - assert(!denied.headers.get('location'), name) - } - for (const encodedName of ['%256Danaged-help', 'managed%2Fhelp']) { - const denied = await send(`/sim-links/${encodeURIComponent(orgId)}/${encodedName}`, member) - assert.equal(denied.status, 404, encodedName) - assert(!denied.headers.get('location'), encodedName) - } - }) - await check( - 'bookmark export requires admin access and drops stale or foreign selections', + 'organization links preserve encoded names and organization across sign-in', async () => { - const name = 'managed-directory' - const id = await create(owner, { name, url: 'https://directory.example.com' }) - const otherId = await create(outsider, { name, url: 'https://other.example.com' }) - const request = { - links: [ - { id, name }, - { id: otherId, name }, - ], + const destination = 'https://team.example.com/help' + for (const name of ['managed-help', 'managed:help@team&docs']) { + const path = `/sim-links/${encodeURIComponent(orgId)}/${encodeURIComponent(name)}` + await create(owner, { name, url: destination }) + await create(outsider, { name, url: 'https://another-team.example.com/help' }) + const signedOut = await send(path, null) + assert.equal(signedOut.status, 307, `${name}: expected sign-in redirect`) + const login = new URL(signedOut.headers.get('location') ?? '/', baseUrl) + assert.equal(login.pathname, '/login', name) + assert.equal(login.searchParams.get('callbackUrl'), path, name) + const opened = await send(path, member) + assert.equal(opened.status, 307, `${name}: expected destination redirect`) + assert.equal(opened.headers.get('location'), destination, name) + const denied = await send(path, outsider) + assert.equal(denied.status, 404, name) + assert(!denied.headers.get('location'), name) + } + for (const encodedName of ['%256Danaged-help', 'managed%2Fhelp']) { + const denied = await send(`/sim-links/${encodeURIComponent(orgId)}/${encodedName}`, member) + assert.equal(denied.status, 404, encodedName) + assert(!denied.headers.get('location'), encodedName) } - const exportPolicy = (caller: Member, expected = 200) => - api(caller, '/bookmarks', { method: 'POST', body: request, expected }) - assert.deepEqual((await exportPolicy(owner)).links, [{ id, name }]) - await exportPolicy(member, 403) - await api(owner, `/${id}`, { - method: 'PUT', - body: fields({ name, url: 'https://directory.example.com', unlisted: true }), - }) - assert.deepEqual((await exportPolicy(owner)).links, []) - await api(owner, `/${id}`, { method: 'DELETE' }) - await create(owner, { name, url: 'https://replacement.example.com' }) - assert.deepEqual((await exportPolicy(owner)).links, []) } ) From 6a43dc89c117d6f39d0578ab5f676e8b37ac3eca Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:19:54 -0800 Subject: [PATCH 2/8] improvement(links): only teach Chrome when links are on, and open links in the organization they belong to Teaching Chrome go/ for everyone would turn a typed go/foo into a Sim page for people without links, so the visit now waits for the signed-in session to report links as enabled. History expires after 90 days, so the visit repeats weekly at browser start and retries when it did not complete. Links page Open and Copy, the shortcut chips and the org route now resolve in the organization they name rather than the session's landing organization. A link can't point back into link resolution. Suggestions match inside names, a missing link offers similar names, a typed phrase searches Links, and an organization without links gets an explanation instead of a 404. --- apps/docs/content/docs/search/sim-links.mdx | 6 +- apps/extension/README.md | 2 +- apps/extension/scripts/test-e2e.ts | 13 +++- apps/extension/src/background.ts | 4 +- apps/extension/src/short-hosts.ts | 78 +++++++++++++------ .../app/(landing)/privacy/privacy-content.tsx | 4 +- .../app/api/sim-links/suggestions/route.ts | 2 +- apps/sim/app/o/[organizationId]/layout.tsx | 9 +-- .../o/[organizationId]/links/search-params.ts | 14 ++-- .../links/sim-link-organization-provider.tsx | 4 +- .../links/sim-link-shortcut.tsx | 6 +- .../o/[organizationId]/links/sim-links.tsx | 22 +++--- .../[organizationId]/[name]/page.tsx | 47 +++++------ apps/sim/app/sim/[[...path]]/page.tsx | 33 +++++++- .../message-sources/message-sources.test.tsx | 4 +- .../tools/server/settings-sim-links.ts | 4 +- apps/sim/lib/navigation/paths.ts | 6 +- .../lib/sim-links/application/use-cases.ts | 20 ++++- apps/sim/lib/sim-links/repository.ts | 16 ++-- apps/sim/lib/sim-links/validation.ts | 9 +++ apps/sim/scripts/test-sim-links-e2e.ts | 54 +++++++++++++ .../browser-extension-protocol/src/index.ts | 5 +- 22 files changed, 256 insertions(+), 106 deletions(-) diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index 46112573dbd..876e6e81eff 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -21,7 +21,7 @@ For suggestions, type `sim`, press **Space**, and enter part of a name. Matching ### If a shortcut opens search -This can happen after you clear your browsing history. A trailing slash (`go/handbook/`) or `http://go/handbook` tells Chrome to navigate directly, and Chrome remembers it from then on. +This can happen if you installed the extension while signed out, or just cleared your browsing history. Opening the Sim extension once signed in fixes the first case, and the second fixes itself within a week. Until then, a trailing slash (`go/handbook/`) or `http://go/handbook` tells Chrome to navigate directly. To catch these searches automatically, open the extension’s **+ → Browser shortcuts** and turn on your search engine. Approve Chrome’s site-access prompt if shown. Google (`google.com`), Bing, DuckDuckGo, and Yahoo are supported. Other searches and your default search engine stay unchanged. @@ -29,6 +29,10 @@ This handles whole shortcuts such as `go/handbook` or `go/jira/123`. For spaces, Turning shortcuts off keeps site access. To remove it, open Chrome’s **Extensions → Manage extensions → Sim → Details**. +### Roll out to your team + +Administrators can [force-install Sim for Chrome](https://support.google.com/chrome/a/answer/6306504) for an organizational unit. Shortcuts start working once each person signs in to Sim and Chrome next starts. + ### If another tool uses go/ or sim Sim routes both `go/` and `sim/` to your organization’s links. If Glean or another extension also handles `go/`, Chrome uses the most recently installed extension. Disable the other tool’s go-link handling or remove that extension to use Sim consistently. An existing intranet host named `go` or `sim` conflicts too. diff --git a/apps/extension/README.md b/apps/extension/README.md index 8319ee863d0..6a27e40de78 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -30,7 +30,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app ## Data and permissions - Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`. -- Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome treats bare `go/` or `sim/` as a search until its history holds that short host, so on install the extension opens `http://go/` and `http://sim/` once in background tabs that close themselves. Chrome counts a visit to an unfamiliar single-label host as typed, which is what the address bar checks. If that history is cleared, a trailing slash (`go//`) selects navigation and teaches the host again. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. +- Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome treats bare `go/` or `sim/` as a search until its history holds that short host, so on install, and at browser start once a week, the extension opens `http://go/` and `http://sim/` in background tabs that close themselves. It does this only once Sim says links are on for the signed-in session, so `go/x` stays an ordinary search for everyone else; opening the side panel checks again. Chrome counts a visit to an unfamiliar single-label host as typed, which is what the address bar checks, and repeating covers history that expires after 90 days or is cleared. A trailing slash (`go//`) always selects navigation. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. - The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. For these suggestions, 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 `/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. - **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `go/` or `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. - Optional HTTP/HTTPS access is requested together by **Allow across websites**, from a direct user action. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 0f0dc8c981d..987233a5117 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -12,6 +12,7 @@ import { extensionToAppMessageSchema, pageSnapshotSchema, SIM_LINK_HOSTS, + SIM_LINK_SUGGESTIONS_PATH, } from '@sim/browser-extension-protocol' import { SecretReveal } from '@sim/emcn' import { createLogger } from '@sim/logger' @@ -38,6 +39,8 @@ const fixture = Bun.serve({ return new Response('Fixture session created', { headers: { 'set-cookie': 'fixture-session=present; Path=/; HttpOnly; SameSite=Lax' }, }) + if (url.pathname === SIM_LINK_SUGGESTIONS_PATH) + return Response.json({ enabled: true, links: [] }) if (url.pathname === '/extension') return new Response( ` @@ -209,18 +212,20 @@ try { const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker')) context.setDefaultTimeout(15_000) const extensionId = worker.url().split('/')[2] - await check('Installing visits each short host once in tabs that close themselves', async () => { + await check('Installing visits each short host in tabs that close themselves', async () => { await expect .poll( () => worker.evaluate( - async () => (await chrome.storage.local.get('shortHostsLearned')).shortHostsLearned + async () => + typeof (await chrome.storage.local.get('shortHostsVisitedAt')).shortHostsVisitedAt ), { timeout: 30_000 } ) - .toBe(true) + .toBe('number') const leftBehind = await worker.evaluate( - async () => (await chrome.tabs.query({ url: ['http://go/*', 'http://sim/*'] })).length + async (simLinks) => (await chrome.tabs.query({ url: simLinks })).length, + ['http://go/*', 'http://sim/*', `${origin}/sim*`] ) assert(leftBehind === 0, 'Short host tabs were left open') }) diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index b4524b1cc55..65f6049c4d7 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -11,7 +11,7 @@ import { import { captureIntentSchema } from '@/intent' import { registerSimLinkOmnibox } from '@/omnibox' import { registerSearchRecovery } from '@/search-recovery' -import { registerShortHostLearning } from '@/short-hosts' +import { registerShortHostVisits } from '@/short-hosts' import { digestUrl } from '@/utils' const logger = createLogger('BrowserExtension') @@ -93,7 +93,7 @@ function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') registerSimLinkOmnibox() registerSearchRecovery() -registerShortHostLearning() +registerShortHostVisits() chrome.runtime.onInstalled.addListener(() => { void configure().catch(() => logger.warn('Could not initialize extension controls')) }) diff --git a/apps/extension/src/short-hosts.ts b/apps/extension/src/short-hosts.ts index 7a5de2f6edf..f969aa04ca7 100644 --- a/apps/extension/src/short-hosts.ts +++ b/apps/extension/src/short-hosts.ts @@ -1,49 +1,79 @@ -import { SIM_LINK_HOSTS } from '@sim/browser-extension-protocol' +import { + SIM_LINK_HOSTS, + SIM_LINK_SUGGESTIONS_PATH, + simLinkSuggestionsSchema, +} from '@sim/browser-extension-protocol' import { createLogger } from '@sim/logger' import { SIM_ORIGIN } from '@/config' const logger = createLogger('ShortHosts') -const LEARNED_KEY = 'shortHostsLearned' +const VISITED_AT_KEY = 'shortHostsVisitedAt' +const REFRESH_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000 const VISIT_TIMEOUT_MS = 15_000 -/** Closes once Chrome has committed the visit, which is what records the host in its history. */ -async function visitInBackground(host: string) { +/** False when signed out or links are off here, where `go/x` should stay an ordinary search. */ +async function linksAreEnabled(): Promise { + const response = await fetch(`${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}`, { + credentials: 'include', + }) + if (!response.ok) return false + return simLinkSuggestionsSchema.parse(await response.json()).enabled === true +} + +/** + * Closes once Chrome has committed the visit, which is what records the host in its history. + * False when it never got that far, such as the person closing the tab first. + */ +async function visitInBackground(host: string): Promise { const { id: tabId } = await chrome.tabs.create({ url: `http://${host}/`, active: false }) - if (tabId === undefined) return - await new Promise((resolve) => { - const finish = () => { + if (tabId === undefined) return false + const visited = await new Promise((resolve) => { + const finish = (committed: boolean) => { clearTimeout(timeout) chrome.tabs.onUpdated.removeListener(updated) - resolve() + resolve(committed) } const updated = (id: number, change: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab) => { if (id === tabId && (change.status === 'complete' || tab.url?.startsWith(SIM_ORIGIN))) { - finish() + finish(true) } } - const timeout = setTimeout(finish, VISIT_TIMEOUT_MS) + const timeout = setTimeout(() => finish(false), VISIT_TIMEOUT_MS) chrome.tabs.onUpdated.addListener(updated) }) await chrome.tabs.remove(tabId).catch(() => undefined) + return visited } /** - * Chrome searches for `go/handbook` until it has seen `go` as a host, so visiting each short host - * once is what makes them work from the address bar. A visit to an unfamiliar single-label host - * counts as typed in Chrome's history, so it teaches the address bar without asking the user to. + * Chrome searches for `go/handbook` until its history holds `go` as a typed host, and it counts a + * visit to an unfamiliar single-label host as typed. So a background visit teaches the address bar + * without asking anything of the user. History expires after 90 days and can be cleared, which is + * why this repeats: a later visit only adds a history entry while the host is known, and relearns + * it once it isn't. */ -async function learnShortHosts() { - const stored = await chrome.storage.local.get(LEARNED_KEY) - if (stored[LEARNED_KEY] === true) return - await Promise.all(SIM_LINK_HOSTS.map(visitInBackground)) - await chrome.storage.local.set({ [LEARNED_KEY]: true }) +async function visitShortHosts() { + const stored = await chrome.storage.local.get(VISITED_AT_KEY) + const visitedAt = stored[VISITED_AT_KEY] + if (typeof visitedAt === 'number' && Date.now() - visitedAt < REFRESH_INTERVAL_MS) return + if (!(await linksAreEnabled())) return + const visits = await Promise.all(SIM_LINK_HOSTS.map(visitInBackground)) + if (visits.every(Boolean)) await chrome.storage.local.set({ [VISITED_AT_KEY]: Date.now() }) } -export function registerShortHostLearning() { - const learn = () => { - void learnShortHosts().catch(() => logger.warn('Could not visit the short link hosts')) +export function registerShortHostVisits() { + let visiting = false + const visit = () => { + if (visiting) return + visiting = true + void visitShortHosts() + .catch(() => logger.warn('Could not visit the short link hosts')) + .finally(() => { + visiting = false + }) } - chrome.runtime.onInstalled.addListener(learn) - // A managed install can run before any window exists, so it retries at the next browser start. - chrome.runtime.onStartup.addListener(learn) + chrome.runtime.onInstalled.addListener(visit) + chrome.runtime.onStartup.addListener(visit) + // Installing signed out leaves nothing to learn yet, so opening Sim is the next chance. + chrome.action.onClicked.addListener(visit) } diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index 353a9638d74..50767ef6118 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -812,7 +812,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'When You ask Sim to attach a page or selection, the browser extension reads the requested text from the top frame of that page and transfers it, the page title, and a normalized page address to the hosted Sim composer. URL credentials, query parameters, and fragments are removed. The extractor skips form inputs, editable fields, scripts, embedded frames, and elements it detects as hidden. Page text, including text in collapsed sections, may still contain personal or sensitive information, such as personal communications or health, financial, or authentication information.' + 'When You ask Sim to attach a page or selection, or open Sim from its toolbar button with Reference current page off, the browser extension reads the requested text from the top frame of that page and transfers it, the page title, and a normalized page address to the hosted Sim composer. URL credentials, query parameters, and fragments are removed. The extractor skips form inputs, editable fields, scripts, embedded frames, and elements it detects as hidden. Page text, including text in collapsed sections, may still contain personal or sensitive information, such as personal communications or health, financial, or authentication information.' ), }, { @@ -848,7 +848,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'When installed, the extension opens sim/ and go/ once in background tabs that close themselves, so Chrome recognizes these shortcuts in the address bar. Those tabs load Sim’s link page with Your session and send no browsing data. Browser shortcuts can optionally recover an explicit sim/ or go/ shortcut that Chrome sends to a supported search engine. After You enable a provider and approve access to its listed sites, Chrome applies request rules that send only the matching shortcut name and path values to Sim’s normal link resolver. Other search parameters and ordinary search terms are not sent to Sim by this feature. The extension stores Your provider choices locally and does not change Your default search engine. These site permissions can also allow page attachment when You explicitly request it; enabling shortcut recovery does not enable Reference current page or background capture. Turning recovery off stops these redirects. Site access remains until You revoke or restrict it in Chrome settings.' + 'When Links are available for Your organization, the extension opens sim/ and go/ about once a week in background tabs that close themselves, so Chrome recognizes these shortcuts in the address bar. Those tabs load Sim’s link page with Your session, send no browsing data, and appear in Your browser history. Browser shortcuts can optionally recover an explicit sim/ or go/ shortcut that Chrome sends to a supported search engine. After You enable a provider and approve access to its listed sites, Chrome applies request rules that send only the matching shortcut name and path values to Sim’s normal link resolver. Other search parameters and ordinary search terms are not sent to Sim by this feature. The extension stores Your provider choices locally and does not change Your default search engine. These site permissions can also allow page attachment when You explicitly request it; enabling shortcut recovery does not enable Reference current page or background capture. Turning recovery off stops these redirects. Site access remains until You revoke or restrict it in Chrome settings.' ), }, { diff --git a/apps/sim/app/api/sim-links/suggestions/route.ts b/apps/sim/app/api/sim-links/suggestions/route.ts index d7535a6cc71..539fbc374b1 100644 --- a/apps/sim/app/api/sim-links/suggestions/route.ts +++ b/apps/sim/app/api/sim-links/suggestions/route.ts @@ -20,5 +20,5 @@ export const GET = defineInternalJsonRoute({ errorPolicy: internalOrchestrationErrorPolicy, staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, mapInput: ({ query }) => ({ query: query.q }), - present: (links) => ({ links }), + present: (suggestions) => suggestions, }) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index ddaa31bf583..815c035e47e 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -14,7 +14,6 @@ import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/pa import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { canBrowsePeople } from '@/lib/people/feature-flag' import { isProjectsEnabled } from '@/lib/projects/rollout.server' -import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag' import { getQueryClient } from '@/app/_shell/providers/get-query-client' @@ -88,7 +87,6 @@ export default async function OrganizationLayout({ projectsEnabled, simLinksEnabled, peopleEnabled, - simLinkOrganizationId, ] = await Promise.all([ prefetchOrganizationSidebar( queryClient, @@ -108,11 +106,6 @@ export default async function OrganizationLayout({ isProjectsEnabled(), simLinksEnabledLookup, peopleEnabledLookup, - resolveSimLinkOrganization({ - kind: 'session', - userId: session.user.id, - sessionId: session.session.id, - }), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' @@ -131,7 +124,7 @@ export default async function OrganizationLayout({ }} > - + diff --git a/apps/sim/app/o/[organizationId]/links/search-params.ts b/apps/sim/app/o/[organizationId]/links/search-params.ts index 17b39723673..381e818a28a 100644 --- a/apps/sim/app/o/[organizationId]/links/search-params.ts +++ b/apps/sim/app/o/[organizationId]/links/search-params.ts @@ -2,17 +2,17 @@ import { createSerializer, parseAsString } from 'nuqs/server' import { SIM_LINK_SORTS } from '@/lib/sim-links/validation' import { createSortParams } from '@/lib/url-state' -/** `create` opens the new-link editor, prefilled with its value as the name. */ -const simLinkViewParsers = { create: parseAsString } as const - -export const serializeSimLinkView = createSerializer(simLinkViewParsers) - -/** `search` filters by name, destination, or description. */ +/** + * `create` opens the new-link editor, prefilled with its value as the name. `search` filters by + * name, destination, or description. + */ export const simLinksParsers = { - ...simLinkViewParsers, + create: parseAsString, search: parseAsString.withDefault(''), } as const +export const serializeSimLinkView = createSerializer(simLinksParsers) + /** Shared `sort` + `dir` params for the Sim Links list. */ export const simLinksSortParams = createSortParams(SIM_LINK_SORTS, { column: 'name', diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx index 03d1b30fc83..dacd0da1f4e 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx @@ -21,13 +21,13 @@ const SimLinkScopeContext = createContext({ }) interface SimLinkOrganizationProviderProps { - /** Where `sim/` resolves for this session, or null when it resolves nowhere. */ + /** The organization this surface creates and copies links in, or null when links are off. */ organizationId: string | null children: ReactNode } /** - * Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer, and hosts + * Scopes Sim link shortcuts to one organization, and hosts * the new-link form at the layout root. A form opened from a menu row inside a popover would * otherwise render beneath it, since popovers stack above modals so they can open inside one; at * the root the form takes focus outside the popover, which dismisses it. diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx index 00e14859ec1..e823afd52f0 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx @@ -46,7 +46,7 @@ function useExistingSimLink(organizationId: string, url: string) { const existing = data?.at(0) const copyExisting = async () => { if (!existing) return false - const done = await copy(simLinkUrl(window.location.origin, existing.name)) + const done = await copy(simLinkUrl(window.location.origin, organizationId, existing.name)) if (!done) toast.error('Unable to copy link') return done } @@ -82,7 +82,7 @@ export function SimLinkMenuItem({ organizationId, url, onCreate }: SimLinkShortc return ( - Create Sim link + Create link ) } @@ -102,7 +102,7 @@ function ShortcutChip({ organizationId, url, onCreate }: SimLinkShortcutProps) { } return ( - Create Sim link + Create link ) } diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 76116fe5d09..26f37db7015 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { useQueryStates } from 'nuqs' import type { SimLink } from '@/lib/api/contracts/sim-links' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { simLinkPath, simLinkUrl } from '@/lib/navigation/paths' +import { simLinkUrl } from '@/lib/navigation/paths' import { toSimLinkName } from '@/lib/sim-links/validation' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { @@ -60,13 +60,17 @@ function accessLabel(link: SimLink): string | null { return limits.length ? limits.join(', ') : null } -function openLink(name: string) { - window.open(simLinkPath(name), '_blank', 'noopener,noreferrer') +function openLink(organizationId: string, name: string) { + window.open( + simLinkUrl(window.location.origin, organizationId, name), + '_blank', + 'noopener,noreferrer' + ) } -async function copyLink(name: string) { +async function copyLink(organizationId: string, name: string) { try { - await writeTextToClipboard(simLinkUrl(window.location.origin, name)) + await writeTextToClipboard(simLinkUrl(window.location.origin, organizationId, name)) } catch (error) { logger.error('Failed to copy Sim link', { error }) } @@ -142,9 +146,9 @@ export function SimLinks({ organizationId }: SimLinksProps) { const openRow = useCallback( (id: string) => { const link = linksById.get(id) - if (link) openLink(link.name) + if (link) openLink(organizationId, link.name) }, - [linksById] + [linksById, organizationId] ) const openRowMenu = useCallback( (event: MouseEvent, id: string) => { @@ -208,8 +212,8 @@ export function SimLinks({ organizationId }: SimLinksProps) { isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={contextMenu.closeMenu} - onOpen={() => openLink(menuLink.name)} - onCopyLink={() => void copyLink(menuLink.name)} + onOpen={() => openLink(organizationId, menuLink.name)} + onCopyLink={() => void copyLink(organizationId, menuLink.name)} onEdit={menuLink.canEdit ? () => setEditing(menuLink) : undefined} onDelete={ menuLink.canEdit diff --git a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx b/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx index f1245dda8ed..cdf75a17a13 100644 --- a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx +++ b/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx @@ -1,17 +1,18 @@ import { ChipLink, StatusPageContent } from '@sim/emcn' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { organizationRoutes } from '@/lib/navigation/paths' -import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' import { resolveSimLink } from '@/lib/sim-links/application/use-cases' import { simLinkNameSchema } from '@/lib/sim-links/validation' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { LogoShell } from '@/app/(landing)/components/logo-shell' interface OrganizationLinkPageProps { params: Promise<{ organizationId: string; name: string }> } -/** Bookmarks stay in their exporting organization even after the browser's active team changes. */ +/** A link in one named organization, whichever organization the session is currently active in. */ export default async function OrganizationLinkPage({ params }: OrganizationLinkPageProps) { const { organizationId, name: encodedName } = await params const name = decodeURIComponent(encodedName) @@ -25,27 +26,29 @@ export default async function OrganizationLinkPage({ params }: OrganizationLinkP isInviteFlow: false, }) ) - const principal = { - kind: 'session', - userId: session.user.id, - sessionId: session.session.id, - } as const - if ((await resolveSimLinkOrganization(principal)) !== organizationId) notFound() - const result = await resolveSimLink.execute({ - principal, - input: { organizationId, name, values: [], query: '' }, - }) + const result = await resolveSimLink + .execute({ + principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + input: { organizationId, name, values: [], query: '' }, + }) + .catch((error: unknown) => { + if (error instanceof OrchestrationError) return null + throw error + }) + if (!result) notFound() if (result.kind === 'redirect') redirect(result.url) return ( - - Open Links - + + + Open Links + + ) } diff --git a/apps/sim/app/sim/[[...path]]/page.tsx b/apps/sim/app/sim/[[...path]]/page.tsx index 50e4ea1b0eb..0c2ec380ce1 100644 --- a/apps/sim/app/sim/[[...path]]/page.tsx +++ b/apps/sim/app/sim/[[...path]]/page.tsx @@ -1,10 +1,14 @@ import { ChipLink, StatusPageContent } from '@sim/emcn' import type { Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' +import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths' import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' -import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases' +import { + resolveSimLink, + type SimLinkResolution, + suggestSimLinks, +} from '@/lib/sim-links/application/use-cases' import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { LogoShell } from '@/app/(landing)/components/logo-shell' @@ -78,7 +82,18 @@ export default async function SimLinkPage({ params, searchParams }: SimLinkPageP sessionId: session.session.id, } as const const organizationId = await resolveSimLinkOrganization(principal) - if (!organizationId) notFound() + if (!organizationId) { + return ( + + + Open Sim + + + ) + } const library = organizationRoutes(organizationId).simLinks const { name, values } = parseTypedSimLink(segments) if (!name) redirect(library) @@ -90,7 +105,14 @@ export default async function SimLinkPage({ params, searchParams }: SimLinkPageP if (result.kind === 'redirect') redirect(result.url) const validName = simLinkNameSchema.safeParse(name).success + // A name can't hold a space, so `sim card prices` is someone looking for a link, not naming one. + if (result.kind === 'not_found' && /\s/.test(name)) { + redirect(serializeSimLinkView(library, { search: name })) + } const creatable = result.kind === 'not_found' && validName + const similar = creatable + ? (await suggestSimLinks.execute({ principal, input: { query: name } })).links.slice(0, 3) + : [] return ( )} + {similar.map((link) => ( + + {link.name} + + ))} All links diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx index a17a92443bb..bec91fe068f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx @@ -58,9 +58,9 @@ describe('creating a Sim link from the sources popover', () => { act(() => press(document.querySelector(`[aria-label="Actions for ${source.title}"]`))) await settle() const create = [...document.querySelectorAll('[role="menuitem"]')].find( - (item) => item.textContent === 'Create Sim link' + (item) => item.textContent === 'Create link' ) - if (!(create instanceof HTMLElement)) throw new Error('Expected a Create Sim link item') + if (!(create instanceof HTMLElement)) throw new Error('Expected a Create link item') expect(create.getAttribute('aria-disabled')).not.toBe('true') act(() => create.click()) await settle() diff --git a/apps/sim/lib/mothership/tools/server/settings-sim-links.ts b/apps/sim/lib/mothership/tools/server/settings-sim-links.ts index 90e42df2e25..bfb1ff02b08 100644 --- a/apps/sim/lib/mothership/tools/server/settings-sim-links.ts +++ b/apps/sim/lib/mothership/tools/server/settings-sim-links.ts @@ -51,9 +51,7 @@ export const simLinkSettingsActions = { principal: context.principal, input: { organizationId: settingsOrganizationId(context), - ...('linkId' in input - ? { linkId: input.linkId } - : { name: input.name.replace(/^sim\//i, '') }), + ...('linkId' in input ? { linkId: input.linkId } : { name: input.name }), }, }) ) diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index 3c861a01742..eb1b045e975 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -66,9 +66,9 @@ export function simLinkPath(...segments: string[]): string { return ['/sim', ...segments.map(encodeURIComponent)].join('/') } -/** The full address of `sim/` on `origin`, which opens it with or without the extension. */ -export function simLinkUrl(origin: string, name: string): string { - return new URL(simLinkPath(name), origin).href +/** The full address of a link on `origin`, which opens it in this organization for any member. */ +export function simLinkUrl(origin: string, organizationId: string, name: string): string { + return new URL(organizationRoutes(organizationId).openSimLink(name), origin).href } /** Whether `pathname` is `root` itself or a route beneath it. */ diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index 83346b277ed..48eb9e93e5a 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -14,6 +14,7 @@ import { requireOrganizationSubjectMembership, } from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import type { DbTransaction } from '@/lib/db/types' import { simLinkOperations } from '@/lib/sim-links/application/operations' @@ -23,6 +24,7 @@ import { requireSimLinksEnabled } from '@/lib/sim-links/feature-flag' import type { SimLinkAccess, SimLinkRecord, SimLinkViewer } from '@/lib/sim-links/repository' import * as repository from '@/lib/sim-links/repository' import { + isSimLinkLoop, type SimLinkFields, type SimLinkSort, stripSimLinkPrefix, @@ -200,11 +202,21 @@ export const getSimLinkView = defineAuthorizedOrganizationUseCase({ }, }) +function requireExternalDestination({ url, defaultUrl }: SimLinkFields) { + const appOrigin = new URL(getBaseUrl()).origin + if (isSimLinkLoop(url, appOrigin) || (defaultUrl && isSimLinkLoop(defaultUrl, appOrigin))) + throw new OrchestrationError( + 'validation', + "A link can't point at another link. Use the page that link opens." + ) +} + export const createSimLink = defineAuthorizedOrganizationUseCase({ operation: simLinkOperations.create, ...rollout, async execute({ input, context }: OrganizationUseCaseContext) { const { organizationId, ...fields } = input + requireExternalDestination(fields) return withUniqueName(organizationId, fields.name, () => db.transaction(async (tx) => { /** A removal deletes this row before handing off the member's links, so it waits for this insert. */ @@ -233,6 +245,7 @@ export const updateSimLink = defineAuthorizedOrganizationUseCase({ ...rollout, async execute({ input, context }: OrganizationUseCaseContext) { const { organizationId, linkId, ...fields } = input + requireExternalDestination(fields) const viewer = viewerOf(context) return withUniqueName(organizationId, fields.name, () => db.transaction(async (tx) => { @@ -310,8 +323,11 @@ export const suggestSimLinks = defineAuthorizedCredentialUserUseCase({ operation: simLinkOperations.suggest, async execute({ principal, input }: { principal: SessionPrincipal; input: { query: string } }) { const organizationId = await resolveSimLinkOrganization(principal) + if (!organizationId) return { enabled: false, links: [] } const prefix = typedSimLinkPrefix(input.query) - if (!organizationId || prefix === null) return [] - return repository.suggestSimLinks(organizationId, prefix) + return { + enabled: true, + links: prefix === null ? [] : await repository.suggestSimLinks(organizationId, prefix), + } }, }) diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts index 689fbb233f2..ec9eb6cbdd0 100644 --- a/apps/sim/lib/sim-links/repository.ts +++ b/apps/sim/lib/sim-links/repository.ts @@ -140,13 +140,15 @@ export async function getSimLink( const SUGGESTION_LIMIT = 6 /** - * Listed links whose name starts with `namePrefix` (normalized), most visited first; the most - * visited overall when it is empty. Unlisted links never appear: suggesting is browsing. + * Listed links whose name contains `typedName` (normalized), names starting with it first and then + * most visited; the most visited overall when it is empty. Unlisted links never appear: suggesting + * is browsing. */ export async function suggestSimLinks( organizationId: string, - namePrefix: string + typedName: string ): Promise<{ name: string; description: string | null; url: string }[]> { + const pattern = escapeLikePattern(typedName) return db .select({ name: simLink.name, description: simLink.description, url: simLink.url }) .from(simLink) @@ -154,10 +156,14 @@ export async function suggestSimLinks( and( eq(simLink.organizationId, organizationId), eq(simLink.unlisted, false), - namePrefix ? like(simLink.normalizedName, `${escapeLikePattern(namePrefix)}%`) : undefined + typedName ? like(simLink.normalizedName, `%${pattern}%`) : undefined ) ) - .orderBy(desc(simLink.visitCount), asc(simLink.normalizedName)) + .orderBy( + desc(sql`${simLink.normalizedName} like ${`${pattern}%`}`), + desc(simLink.visitCount), + asc(simLink.normalizedName) + ) .limit(SUGGESTION_LIMIT) } diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index ffb7c58d664..95d49b2ad7d 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -1,5 +1,6 @@ import { SIM_LINK_HOST_PATTERN, + SIM_LINK_HOSTS, SIM_LINK_NAME_MAX_LENGTH, SIM_LINK_URL_MAX_LENGTH, } from '@sim/browser-extension-protocol' @@ -130,6 +131,14 @@ const destinationSchema = z .min(1, 'Enter a destination URL') .max(SIM_LINK_URL_MAX_LENGTH, `Use at most ${SIM_LINK_URL_MAX_LENGTH} characters`) +/** Whether a destination leads back into link resolution, which would redirect forever. */ +export function isSimLinkLoop(url: string, appOrigin: string): boolean { + const parsed = parseHttpUrl(url.replaceAll(SIM_LINK_VARIABLE, 'value')) + if (!parsed) return false + if ((SIM_LINK_HOSTS as readonly string[]).includes(parsed.hostname)) return true + return parsed.origin === appOrigin && /^\/sim(?:-links)?(?:\/|$)/.test(parsed.pathname) +} + const simLinkUrlSchema = destinationSchema.refine( (url) => simLinkOrigin(url) !== null, `Enter a full http:// or https:// URL. ${SIM_LINK_VARIABLE} can't replace the domain.` diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts index 02a364f1c21..b351a0172f5 100644 --- a/apps/sim/scripts/test-sim-links-e2e.ts +++ b/apps/sim/scripts/test-sim-links-e2e.ts @@ -433,6 +433,60 @@ async function run() { } ) + await check( + 'a member of two organizations opens links in the organization they name', + async () => { + const both = await seedMember(orgId, 'member') + await sql`insert into member (id, user_id, organization_id, role) + values (${generateId()}, ${both.userId}, ${outsider.orgId}, 'member')` + for (const [organizationId, destination] of [ + [orgId, 'https://team.example.com/help'], + [outsider.orgId, 'https://another-team.example.com/help'], + ]) { + const opened = await send( + `/sim-links/${encodeURIComponent(organizationId)}/managed-help`, + both + ) + assert.equal(opened.status, 307, organizationId) + assert.equal(opened.headers.get('location'), destination, organizationId) + } + } + ) + + await check('a link cannot point back into link resolution', async () => { + for (const url of [ + 'http://go/managed-help', + 'https://sim/x', + new URL('/sim/managed-help', baseUrl).href, + ]) { + await api(owner, '', { + method: 'POST', + body: fields({ name: 'looping', url }), + expected: 400, + }) + } + await create(owner, { name: 'simulate', url: new URL('/simulate', baseUrl).href }) + }) + + await check('the address bar learns whether links are on and matches inside names', async () => { + const suggest = (caller: Member | null, q: string) => + send(`/api/sim-links/suggestions?q=${encodeURIComponent(q)}`, caller) + assert.equal((await suggest(null, '')).status, 401) + const { enabled, links } = (await (await suggest(member, 'handbook')).json()) as { + enabled: boolean + links: { name: string }[] + } + assert.equal(enabled, true) + assert(links.some((link) => link.name === 'Team-Handbook')) + }) + + await check('a typed phrase searches links and a near miss offers what exists', async () => { + const phrase = await open(member, 'team%20handbook') + assert(phrase.location, 'Expected a redirect to search') + assert.equal(new URL(phrase.location).searchParams.get('search'), 'team handbook') + assert.match((await open(member, 'handbook')).text, /Team-Handbook/) + }) + await check('redirects are counted and order the most-used view', async () => { const { links } = await api(member, '?sort=popular', {}) assert(Array.isArray(links), 'Expected a link list') diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts index 4532a879758..153fa1a5870 100644 --- a/packages/browser-extension-protocol/src/index.ts +++ b/packages/browser-extension-protocol/src/index.ts @@ -134,10 +134,11 @@ export const SIM_LINK_HOST_PATTERN = SIM_LINK_HOSTS.join('|') export const SIM_LINK_SUGGESTIONS_PATH = '/api/sim-links/suggestions' /** - * Listed Sim links whose name starts with what was typed, most visited first. Not strict: an - * installed extension lags the server, so a field added later must not break its parse. + * Listed Sim links matching what was typed, and whether links are on at all for this session. Not + * strict: an installed extension lags the server, so a field added later must not break its parse. */ export const simLinkSuggestionsSchema = z.object({ + enabled: z.boolean().optional(), links: z.array( z.object({ name: z.string(), description: z.string().nullable(), url: z.string() }) ), From 163f98ebecddd3ae0db7b0ad83fb445b2fffc560 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:24:15 -0800 Subject: [PATCH 3/8] improvement(extension): tidy page capture and session handling Use the shared UTF-8 length helper, drop redundant mounted guards, reuse ExtensionSessionExpired and the repeated capture messages, style the page-access error, and guard that the stale-transcript e2e sends before the socket closes. --- apps/extension/src/capture.ts | 14 +++++++------ apps/extension/src/reference.ts | 4 +++- apps/extension/src/sidepanel.tsx | 20 +++++++++---------- apps/extension/src/site-access.tsx | 20 ++++++++----------- .../app/extension/[workspaceId]/workspace.tsx | 7 ++----- .../o/[organizationId]/organization.tsx | 7 ++----- apps/sim/app/extension/workspaces.tsx | 7 ++----- apps/sim/hooks/use-speech-to-text.ts | 10 +++++----- apps/sim/lib/mothership/chat/page-context.ts | 4 ++-- .../lib/sim-links/application/use-cases.ts | 17 ++++++++-------- .../sim/scripts/test-browser-extension-e2e.ts | 1 + .../browser-extension-protocol/src/index.ts | 1 - 12 files changed, 51 insertions(+), 61 deletions(-) diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index da26f4597a9..143dcc1f29e 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -8,6 +8,8 @@ import { PAGE_ACCESS_ORIGINS, SIM_ORIGIN } from '@/config' import { extractPageText } from '@/extract' import { digestUrl } from '@/utils' +export const PAGE_CHANGED_MESSAGE = 'The page changed. Attach it again.' + /** A public capture error whose message is safe to share with the hosted composer. */ export class PageCaptureError extends Error {} @@ -49,7 +51,7 @@ export async function captureActivePage( if (tab?.id === undefined || tab.incognito) throw new PageCaptureError('This page cannot be attached.') if (expected && expected.tabId !== tab.id) { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } if (!tab.url) { const allowed = await chrome.permissions.contains({ origins: PAGE_ACCESS_ORIGINS }) @@ -75,10 +77,10 @@ export async function captureActivePage( signal?.addEventListener('abort', removeListeners, { once: true }) try { const snapshot = await readTab({ tabId: tab.id, url: tab.url }, kind, expected, signal) - if (changed) throw new PageCaptureError('The page changed. Attach it again.') + if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE) return snapshot } catch (error) { - if (changed) throw new PageCaptureError('The page changed. Attach it again.') + if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE) if (expected && !(error instanceof PageCaptureError)) throw new PageCaptureError('The original page is unavailable. Attach the current page.') throw error @@ -95,7 +97,7 @@ async function readTab( ) { const url = new URL(tab.url) if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } let pathname = url.pathname if (url.origin === SIM_ORIGIN) { @@ -132,12 +134,12 @@ async function readTab( world: 'ISOLATED', }) if (identity[0]?.documentId !== result.documentId || identity[0]?.result !== tab.url) { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } const [current] = await chrome.tabs.query({ active: true, currentWindow: true }) signal?.throwIfAborted() if (current?.id !== tab.tabId || current.url !== tab.url || current.status === 'loading') { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } if (result.result.error) { if (kind === 'selection') diff --git a/apps/extension/src/reference.ts b/apps/extension/src/reference.ts index 46e0f717a25..2b3890c9370 100644 --- a/apps/extension/src/reference.ts +++ b/apps/extension/src/reference.ts @@ -11,6 +11,8 @@ import { } from '@/capture' import { REFERENCE_PAGE_KEY } from '@/config' +export const PAGE_ATTACHMENT_PENDING_MESSAGE = 'Wait for the page attachment, then try again.' + interface ReferenceOptions { session: () => string | null post: (message: ExtensionToAppMessage) => void @@ -124,7 +126,7 @@ export function createReferenceCapture({ replyError( sessionId, requestId, - manualError?.message ?? 'Wait for the page attachment, then try again.', + manualError?.message ?? PAGE_ATTACHMENT_PENDING_MESSAGE, manualError?.reason ) return diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index 73beab91033..7e579300244 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -8,17 +8,19 @@ import { createRoot } from 'react-dom/client' import { captureActivePage, captureErrorMessage, + PAGE_CHANGED_MESSAGE, PageCaptureError, SiteAccessRequiredError, UnreadableSelectionError, } from '@/capture' import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config' import { type CaptureIntent, captureIntentSchema } from '@/intent' -import { createReferenceCapture } from '@/reference' +import { createReferenceCapture, PAGE_ATTACHMENT_PENDING_MESSAGE } from '@/reference' import { SearchRecoveryDialog } from '@/search-recovery-dialog' import { PageAccessButton, SiteAccessDialog } from '@/site-access' import '@/shell.css' +const PAGE_ATTACHMENT_EXPIRED_MESSAGE = 'The page attachment expired. Attach it again.' const BLOCKED_SELECTION = 'Select readable text again, attach the page explicitly, or turn off Reference current page.' const KEYBOARD_FOCUS_HINT = navigator.platform.startsWith('Mac') @@ -49,9 +51,7 @@ function SidePanel({ origin }: SidePanelProps) { if (!(status instanceof SiteAccessRequiredError)) return const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { if (tabId === status.tabId && (change.status === 'loading' || change.url !== undefined)) { - setStatus((current) => - current === status ? 'The page changed. Attach it again.' : current - ) + setStatus((current) => (current === status ? PAGE_CHANGED_MESSAGE : current)) } } const activated = (tab: chrome.tabs.OnActivatedInfo) => { @@ -74,7 +74,7 @@ function SidePanel({ origin }: SidePanelProps) { if (Date.now() - Date.parse(snapshot.capturedAt) > CAPTURE_INTENT_TTL_MS) { pendingRef.current = null focusRequested.current = false - setStatus('The page attachment expired. Attach it again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) return } const message: ExtensionToAppMessage = { @@ -130,12 +130,12 @@ function SidePanel({ origin }: SidePanelProps) { } ) { if (referenceRef.current?.busy()) { - setStatus('Wait for the page attachment, then try again.') + setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE) return } if (busyRef.current) return if (pendingRef.current) { - setStatus('Wait for the page attachment, then try again.') + setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE) return } busyRef.current = true @@ -171,7 +171,7 @@ function SidePanel({ origin }: SidePanelProps) { manualCaptureError: () => { if (busyRef.current || pendingRef.current || processingManual || manualIntents.size) return { - message: 'Wait for the page attachment, then try again.', + message: PAGE_ATTACHMENT_PENDING_MESSAGE, } if (unreadableSelection.current) return { message: BLOCKED_SELECTION, reason: 'selection' } }, @@ -249,7 +249,7 @@ function SidePanel({ origin }: SidePanelProps) { } if (disposed || !intent) return if (!currentIntent(intent, window.id)) { - setStatus('The page attachment expired. Attach it again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) return } if (intent.kind === 'focus') { @@ -362,7 +362,7 @@ function SidePanel({ origin }: SidePanelProps) { ) { pendingRef.current = null focusRequested.current = false - setStatus('The page attachment expired. Attach it again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) } void consumeIntent() }, 5_000) diff --git a/apps/extension/src/site-access.tsx b/apps/extension/src/site-access.tsx index ddf6468eaef..0ee685ade23 100644 --- a/apps/extension/src/site-access.tsx +++ b/apps/extension/src/site-access.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn' import { PAGE_ACCESS_ORIGINS } from '@/config' @@ -8,25 +8,17 @@ interface PageAccessButtonProps { } export function PageAccessButton({ allowed, onAccessGranted }: PageAccessButtonProps) { - const mounted = useRef(false) const [status, setStatus] = useState<'idle' | 'requesting' | 'error'>('idle') - useEffect(() => { - mounted.current = true - return () => { - mounted.current = false - } - }, []) - async function enable() { if (status === 'requesting') return setStatus('requesting') try { const granted = await chrome.permissions.request({ origins: PAGE_ACCESS_ORIGINS }) - if (mounted.current) setStatus('idle') + setStatus('idle') if (granted) onAccessGranted() } catch { - if (mounted.current) setStatus('error') + setStatus('error') } } @@ -47,7 +39,11 @@ export function PageAccessButton({ allowed, onAccessGranted }: PageAccessButtonP void enable()}> Allow across websites - {status === 'error' && Could not enable page access. Try again.} + {status === 'error' && ( + + Could not enable page access. Try again. + + )} ) } diff --git a/apps/sim/app/extension/[workspaceId]/workspace.tsx b/apps/sim/app/extension/[workspaceId]/workspace.tsx index 1bd55ea6ed3..6c5f61f72cb 100644 --- a/apps/sim/app/extension/[workspaceId]/workspace.tsx +++ b/apps/sim/app/extension/[workspaceId]/workspace.tsx @@ -4,8 +4,7 @@ import { Chip, ChipLink } from '@sim/emcn' import { isApiClientError } from '@/lib/api/client/errors' import { ExtensionChat } from '@/app/extension/[workspaceId]/chat' import ExtensionLoading from '@/app/extension/loading' -import { useExtensionIdentity } from '@/app/extension/providers' -import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ExtensionSessionExpired } from '@/app/extension/providers' import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal' import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' @@ -26,13 +25,11 @@ interface ExtensionWorkspaceProps { } export function ExtensionWorkspace({ workspaceId }: ExtensionWorkspaceProps) { - const { userId } = useExtensionIdentity() const { data, isPending, error, refetch } = useWorkspaceHostContextQuery(workspaceId, { refetchInterval: WORKSPACE_HOST_CONTEXT_STALE_TIME, refetchOnWindowFocus: true, }) - if (isApiClientError(error) && error.status === 401) - return + if (isApiClientError(error) && error.status === 401) return if (isPending) return const accessDenied = isApiClientError(error) && (error.status === 403 || error.status === 404) if (accessDenied || !data) { diff --git a/apps/sim/app/extension/o/[organizationId]/organization.tsx b/apps/sim/app/extension/o/[organizationId]/organization.tsx index fb5dd1b01c0..eaad05cbccc 100644 --- a/apps/sim/app/extension/o/[organizationId]/organization.tsx +++ b/apps/sim/app/extension/o/[organizationId]/organization.tsx @@ -10,8 +10,7 @@ import { ExtensionHeader } from '@/app/extension/[workspaceId]/header' import { extensionChatParam } from '@/app/extension/[workspaceId]/search-params' import { useExtensionComposer } from '@/app/extension/hooks/use-composer' import ExtensionLoading from '@/app/extension/loading' -import { useExtensionIdentity } from '@/app/extension/providers' -import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ExtensionSessionExpired } from '@/app/extension/providers' import { ExtensionUnavailable } from '@/app/extension/unavailable' import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation' import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' @@ -27,14 +26,12 @@ interface ExtensionOrganizationProps { } export function ExtensionOrganization({ context }: ExtensionOrganizationProps) { - const { userId } = useExtensionIdentity() const access = useOrganizationSummary(context.organization.id) const history = useOrganizationMothershipChats(context.organization.id) const denied = [access.error, history.error].find( (error) => isApiClientError(error) && [401, 403, 404].includes(error.status) ) - if (isApiClientError(denied) && denied.status === 401) - return + if (isApiClientError(denied) && denied.status === 401) return if (access.isPending) return if (denied || !access.data?.chatAccess) return ( diff --git a/apps/sim/app/extension/workspaces.tsx b/apps/sim/app/extension/workspaces.tsx index 74f5d607252..e3ed7a67bf2 100644 --- a/apps/sim/app/extension/workspaces.tsx +++ b/apps/sim/app/extension/workspaces.tsx @@ -11,17 +11,14 @@ import { } from '@sim/emcn' import { isApiClientError } from '@/lib/api/client/errors' import ExtensionLoading from '@/app/extension/loading' -import { useExtensionIdentity } from '@/app/extension/providers' -import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ExtensionSessionExpired } from '@/app/extension/providers' import { useWorkspacesQuery } from '@/hooks/queries/workspace' export function ExtensionWorkspaces() { - const { userId } = useExtensionIdentity() const [scrollElement, setScrollElement] = useState(null) const scrollEdges = useScrollEdges(scrollElement) const { data: workspaces, isPending, error, refetch } = useWorkspacesQuery() - if (isApiClientError(error) && error.status === 401) - return + if (isApiClientError(error) && error.status === 401) return if (isPending) return const transientError = !isApiClientError(error) || error.status === 408 || error.status === 429 || error.status >= 500 diff --git a/apps/sim/hooks/use-speech-to-text.ts b/apps/sim/hooks/use-speech-to-text.ts index 130760281de..5df692a88c1 100644 --- a/apps/sim/hooks/use-speech-to-text.ts +++ b/apps/sim/hooks/use-speech-to-text.ts @@ -234,7 +234,7 @@ export function useSpeechToText({ : { workspaceId: workspaceIdRef.current }, }) } catch (err) { - if (!mountedRef.current || generation !== generationRef.current) return false + if (generation !== generationRef.current) return false if (isApiClientError(err) && err.status === 402) { const isMemberLimit = (err.body as { scope?: string } | null)?.scope === 'member' setIsListening(false) @@ -246,7 +246,7 @@ export function useSpeechToText({ const token = typeof tokenData.token === 'string' ? tokenData.token : undefined if (!token) throw new Error('Failed to get speech token') - if (!mountedRef.current || generation !== generationRef.current) return false + if (generation !== generationRef.current) return false const stream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -258,7 +258,7 @@ export function useSpeechToText({ }, }) - if (!mountedRef.current || generation !== generationRef.current) { + if (generation !== generationRef.current) { stream.getTracks().forEach((track) => track.stop()) return false } @@ -287,7 +287,7 @@ export function useSpeechToText({ ws.onerror = () => reject(new Error('WebSocket connection failed')) ws.onmessage = (event) => { - if (!mountedRef.current || generation !== generationRef.current) return + if (generation !== generationRef.current) return try { const msg = JSON.parse(event.data) @@ -331,7 +331,7 @@ export function useSpeechToText({ } }) - if (!mountedRef.current || generation !== generationRef.current) { + if (generation !== generationRef.current) { ws.close() stream.getTracks().forEach((track) => track.stop()) return false diff --git a/apps/sim/lib/mothership/chat/page-context.ts b/apps/sim/lib/mothership/chat/page-context.ts index be88ee20e6a..78c8b827645 100644 --- a/apps/sim/lib/mothership/chat/page-context.ts +++ b/apps/sim/lib/mothership/chat/page-context.ts @@ -1,9 +1,9 @@ import type { PageSnapshot } from '@sim/browser-extension-protocol' +import { utf8ByteLength } from '@sim/utils/paste' import { MAX_CHAT_CONTEXTS } from '@/lib/mothership/chat/context-limits' const MAX_PAGE_CONTEXT_TEXT_LENGTH = 150_000 const MAX_PAGE_CONTEXT_BYTES = 450_000 -const encoder = new TextEncoder() /** Bounds the combined page budget and refuses ambiguous snapshot identities within a turn. */ export function getPageContextBudgetError( @@ -19,7 +19,7 @@ export function getPageContextBudgetError( if (ids.has(context.snapshot.id)) return 'Page snapshot IDs must be unique within a message' ids.add(context.snapshot.id) textLength += context.snapshot.text.length - bytes += encoder.encode(JSON.stringify(context.snapshot)).byteLength + bytes += utf8ByteLength(JSON.stringify(context.snapshot)) if (textLength > MAX_PAGE_CONTEXT_TEXT_LENGTH || bytes > MAX_PAGE_CONTEXT_BYTES) return 'These pages contain too much text' } diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index 48eb9e93e5a..6eca3094bfe 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -173,14 +173,6 @@ export const listSimLinks = defineAuthorizedOrganizationUseCase({ }, }) -async function findLinkIdByName(organizationId: string, typedName: string) { - const link = await repository.findSimLinkDestination( - organizationId, - stripSimLinkPrefix(typedName) - ) - return link?.id -} - /** * One link by id or by name as typed. Hidden exactly as browsing hides it: an unlisted link reads * as missing to anyone who cannot manage it. @@ -194,7 +186,14 @@ export const getSimLinkView = defineAuthorizedOrganizationUseCase({ }: OrganizationUseCaseContext) { const viewer = viewerOf(context) const linkId = - 'linkId' in input ? input.linkId : await findLinkIdByName(input.organizationId, input.name) + 'linkId' in input + ? input.linkId + : ( + await repository.findSimLinkDestination( + input.organizationId, + stripSimLinkPrefix(input.name) + ) + )?.id const record = linkId ? await repository.getSimLink(db, input.organizationId, linkId) : null if (!record || (record.unlisted && !canManage(record.createdBy?.id ?? null, viewer))) throw new OrchestrationError('not_found', 'Link not found') diff --git a/apps/sim/scripts/test-browser-extension-e2e.ts b/apps/sim/scripts/test-browser-extension-e2e.ts index 5724146db2a..c27aea377ab 100644 --- a/apps/sim/scripts/test-browser-extension-e2e.ts +++ b/apps/sim/scripts/test-browser-extension-e2e.ts @@ -1573,6 +1573,7 @@ try { await mic.click() await expect(stop).toBeVisible() await expect.poll(() => sessions.length).toBe(3) + assert.equal(sessions[1].closed, false, 'Stale-transcript grace must still be open') sessions[1].socket.send( JSON.stringify({ message_type: 'partial_transcript', text: 'stale thought' }) ) diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts index 153fa1a5870..7567552046d 100644 --- a/packages/browser-extension-protocol/src/index.ts +++ b/packages/browser-extension-protocol/src/index.ts @@ -127,7 +127,6 @@ export const SIM_LINK_URL_MAX_LENGTH = 2048 /** Short hosts a link opens from: `sim/` and `go/`. */ export const SIM_LINK_HOSTS = ['sim', 'go'] as const -/** {@link SIM_LINK_HOSTS} as a regular-expression alternation. */ export const SIM_LINK_HOST_PATTERN = SIM_LINK_HOSTS.join('|') /** Where the extension asks for the address-bar suggestions behind its `sim` keyword. */ From 918a433455d1b2eb895d297af538960a78bd146b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:31:45 -0800 Subject: [PATCH 4/8] fix(extension): visit short hosts when a signed-in panel is ready Installing signed out and signing in through the panel left go/ and sim/ untaught until the next browser start. Prove it end to end: nothing is visited signed out, and each host is visited once after sign-in. --- apps/docs/content/docs/search/sim-links.mdx | 2 +- apps/extension/README.md | 2 +- apps/extension/scripts/test-e2e.ts | 46 +++++++++++++-------- apps/extension/src/config.ts | 1 + apps/extension/src/short-hosts.ts | 8 ++-- apps/extension/src/sidepanel.tsx | 9 +++- apps/sim/lib/api/contracts/sim-links.ts | 2 +- 7 files changed, 46 insertions(+), 24 deletions(-) diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index 876e6e81eff..fbdfccfbdac 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -21,7 +21,7 @@ For suggestions, type `sim`, press **Space**, and enter part of a name. Matching ### If a shortcut opens search -This can happen if you installed the extension while signed out, or just cleared your browsing history. Opening the Sim extension once signed in fixes the first case, and the second fixes itself within a week. Until then, a trailing slash (`go/handbook/`) or `http://go/handbook` tells Chrome to navigate directly. +This can happen if you installed the extension while signed out, or just cleared your browsing history. Opening the Sim extension and signing in fixes the first case, and the second fixes itself within a week. Until then, a trailing slash (`go/handbook/`) or `http://go/handbook` tells Chrome to navigate directly. To catch these searches automatically, open the extension’s **+ → Browser shortcuts** and turn on your search engine. Approve Chrome’s site-access prompt if shown. Google (`google.com`), Bing, DuckDuckGo, and Yahoo are supported. Other searches and your default search engine stay unchanged. diff --git a/apps/extension/README.md b/apps/extension/README.md index 6a27e40de78..5315549bf3d 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -30,7 +30,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app ## Data and permissions - Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`. -- Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome treats bare `go/` or `sim/` as a search until its history holds that short host, so on install, and at browser start once a week, the extension opens `http://go/` and `http://sim/` in background tabs that close themselves. It does this only once Sim says links are on for the signed-in session, so `go/x` stays an ordinary search for everyone else; opening the side panel checks again. Chrome counts a visit to an unfamiliar single-label host as typed, which is what the address bar checks, and repeating covers history that expires after 90 days or is cleared. A trailing slash (`go//`) always selects navigation. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. +- Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome treats bare `go/` or `sim/` as a search until its history holds that short host, so on install, and at browser start once a week, the extension opens `http://go/` and `http://sim/` in background tabs that close themselves. It does this only once Sim says links are on for the signed-in session, so `go/x` stays an ordinary search for everyone else; signing in through the side panel checks again. Chrome counts a visit to an unfamiliar single-label host as typed, which is what the address bar checks, and repeating covers history that expires after 90 days or is cleared. A trailing slash (`go//`) always selects navigation. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. - The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. For these suggestions, 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 `/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. - **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `go/` or `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. - Optional HTTP/HTTPS access is requested together by **Allow across websites**, from a direct user action. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 987233a5117..886de7b05b9 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -30,6 +30,8 @@ const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; e const secretReveal = renderToStaticMarkup( createElement(SecretReveal, { value: 'SECRET_REVEAL_SENTINEL' }) ) +let linksChecks = 0 +let shortHostVisits = 0 const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, @@ -39,8 +41,14 @@ const fixture = Bun.serve({ return new Response('Fixture session created', { headers: { 'set-cookie': 'fixture-session=present; Path=/; HttpOnly; SameSite=Lax' }, }) - if (url.pathname === SIM_LINK_SUGGESTIONS_PATH) - return Response.json({ enabled: true, links: [] }) + if (url.pathname === SIM_LINK_SUGGESTIONS_PATH) { + linksChecks += 1 + return Response.json({ + enabled: request.headers.get('cookie')?.includes('fixture-session=present') === true, + links: [], + }) + } + if (url.pathname === '/sim/') shortHostVisits += 1 if (url.pathname === '/extension') return new Response( ` @@ -212,22 +220,14 @@ try { const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker')) context.setDefaultTimeout(15_000) const extensionId = worker.url().split('/')[2] - await check('Installing visits each short host in tabs that close themselves', async () => { - await expect - .poll( - () => - worker.evaluate( - async () => - typeof (await chrome.storage.local.get('shortHostsVisitedAt')).shortHostsVisitedAt - ), - { timeout: 30_000 } - ) - .toBe('number') - const leftBehind = await worker.evaluate( - async (simLinks) => (await chrome.tabs.query({ url: simLinks })).length, - ['http://go/*', 'http://sim/*', `${origin}/sim*`] + const shortHostsVisitedAt = () => + worker.evaluate( + async () => typeof (await chrome.storage.local.get('shortHostsVisitedAt')).shortHostsVisitedAt ) - assert(leftBehind === 0, 'Short host tabs were left open') + await check('Installing signed out leaves go/ and sim/ as ordinary searches', async () => { + await expect.poll(() => linksChecks, { timeout: 30_000 }).toBeGreaterThan(0) + assert((await shortHostsVisitedAt()) === 'undefined', 'Visits were recorded while signed out') + assert(shortHostVisits === 0, 'Short hosts were visited while signed out') }) const signIn = await context.newPage() await signIn.goto(`${origin}/sign-in`) @@ -523,6 +523,18 @@ try { ) await page.reload() }) + await check( + 'A signed-in panel visits each short host in tabs that close themselves', + async () => { + await expect.poll(shortHostsVisitedAt, { timeout: 30_000 }).toBe('number') + assert(shortHostVisits === SIM_LINK_HOSTS.length, `Visited ${shortHostVisits} short hosts`) + const leftBehind = await worker.evaluate( + async (simLinks) => (await chrome.tabs.query({ url: simLinks })).length, + ['http://go/*', 'http://sim/*', `${origin}/sim/`] + ) + assert(leftBehind === 0, 'Short host tabs were left open') + } + ) await check( 'An expired toolbar intent asks for a fresh capture without delivering content', async () => { diff --git a/apps/extension/src/config.ts b/apps/extension/src/config.ts index e2fb17f4f76..ccbe186d507 100644 --- a/apps/extension/src/config.ts +++ b/apps/extension/src/config.ts @@ -3,6 +3,7 @@ export const SIM_ORIGIN = __SIM_ORIGIN__ export const PAGE_ACCESS_ORIGINS = ['https://*/*', 'http://*/*'] export const REFERENCE_PAGE_KEY = 'referenceCurrentPage' export const OPEN_SIM_COMMAND = 'open-sim' +export const VISIT_SHORT_HOSTS_MESSAGE = 'visit-short-hosts' export const CAPTURE_INTENT_KEY = 'captureIntent' export const CAPTURE_INTENT_TTL_MS = 30_000 export const captureIntentKey = (windowId: number, intentId: string) => diff --git a/apps/extension/src/short-hosts.ts b/apps/extension/src/short-hosts.ts index f969aa04ca7..4ae7c4cd009 100644 --- a/apps/extension/src/short-hosts.ts +++ b/apps/extension/src/short-hosts.ts @@ -4,7 +4,7 @@ import { simLinkSuggestionsSchema, } from '@sim/browser-extension-protocol' import { createLogger } from '@sim/logger' -import { SIM_ORIGIN } from '@/config' +import { SIM_ORIGIN, VISIT_SHORT_HOSTS_MESSAGE } from '@/config' const logger = createLogger('ShortHosts') const VISITED_AT_KEY = 'shortHostsVisitedAt' @@ -74,6 +74,8 @@ export function registerShortHostVisits() { } chrome.runtime.onInstalled.addListener(visit) chrome.runtime.onStartup.addListener(visit) - // Installing signed out leaves nothing to learn yet, so opening Sim is the next chance. - chrome.action.onClicked.addListener(visit) + // Installing signed out leaves nothing to learn yet, so a signed-in panel is the next chance. + chrome.runtime.onMessage.addListener((message: unknown, sender) => { + if (sender.id === chrome.runtime.id && message === VISIT_SHORT_HOSTS_MESSAGE) visit() + }) } diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index 7e579300244..7ae1051c9f8 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -13,7 +13,13 @@ import { SiteAccessRequiredError, UnreadableSelectionError, } from '@/capture' -import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config' +import { + CAPTURE_INTENT_KEY, + CAPTURE_INTENT_TTL_MS, + REFERENCE_PAGE_KEY, + SIM_ORIGIN, + VISIT_SHORT_HOSTS_MESSAGE, +} from '@/config' import { type CaptureIntent, captureIntentSchema } from '@/intent' import { createReferenceCapture, PAGE_ATTACHMENT_PENDING_MESSAGE } from '@/reference' import { SearchRecoveryDialog } from '@/search-recovery-dialog' @@ -329,6 +335,7 @@ function SidePanel({ origin }: SidePanelProps) { applyTheme() } else if (parsed.data.type === 'ready') { void connect(parsed.data.sessionId) + void chrome.runtime.sendMessage(VISIT_SHORT_HOSTS_MESSAGE).catch(() => undefined) } else if (parsed.data.sessionId !== sessionRef.current) { return } else if (parsed.data.type === 'unavailable') { diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index dc509a746a2..868ea36e863 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -6,7 +6,7 @@ import { import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { organizationIdSchema } from '@/lib/api/contracts/primitives' -import { SIM_LINK_SORTS, simLinkFieldsSchema, simLinkNameSchema } from '@/lib/sim-links/validation' +import { SIM_LINK_SORTS, simLinkFieldsSchema } from '@/lib/sim-links/validation' import { SORT_DIRECTIONS } from '@/lib/url-state' const params = z.object({ id: organizationIdSchema }) From c8f811fcb11daec5cccd865b7e81170071031ae6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:47:22 -0800 Subject: [PATCH 5/8] improvement(links): keep sim/ URLs, tell the extension when sign-in finishes, and fix what the feature audit found A user belongs to one organization, so org-scoped link URLs fixed nothing and lost values and queries. Restore them, and drop the redirect-loop guard, which refused real aliases and broke editing them. The hosted app now posts signed-in from the extension layout, so members without chat access still get shortcuts. Typed phrases find hyphenated names, Copy link confirms, the omnibox hint matches what Enter does, and the docs match the behaviour. Share the extension dialog shell and remove an unreachable recovery branch. --- .../content/docs/browser-extension/index.mdx | 4 +- .../docs/platform/enterprise/audit-logs.mdx | 2 +- apps/docs/content/docs/search/sim-links.mdx | 4 +- apps/extension/DESIGN.md | 10 ++-- apps/extension/README.md | 8 ++-- apps/extension/scripts/test-e2e.ts | 10 ++++ apps/extension/src/extension-dialog.tsx | 40 ++++++++++++++++ apps/extension/src/omnibox.ts | 6 ++- apps/extension/src/search-recovery-dialog.tsx | 29 ++++------- apps/extension/src/search-recovery.ts | 13 ++--- apps/extension/src/short-hosts.ts | 2 +- apps/extension/src/sidepanel.tsx | 3 +- apps/extension/src/site-access.tsx | 23 ++------- apps/sim/app/extension/providers.tsx | 13 ++++- apps/sim/app/o/[organizationId]/layout.tsx | 9 +++- .../links/sim-link-organization-provider.tsx | 4 +- .../links/sim-link-shortcut.tsx | 2 +- .../o/[organizationId]/links/sim-links.tsx | 26 +++++----- .../[organizationId]/[name]/page.tsx | 47 +++++++++--------- apps/sim/app/sim-links/recover/route.ts | 5 +- apps/sim/app/sim/[[...path]]/page.tsx | 23 +++++---- apps/sim/lib/api/contracts/sim-links.ts | 10 ++-- apps/sim/lib/navigation/paths.ts | 6 +-- .../lib/sim-links/application/use-cases.ts | 13 ----- apps/sim/lib/sim-links/repository.ts | 5 +- apps/sim/lib/sim-links/validation.ts | 14 ++---- .../sim/scripts/test-browser-extension-e2e.ts | 1 - apps/sim/scripts/test-sim-links-e2e.ts | 48 +++++-------------- .../browser-extension-protocol/src/index.ts | 1 + 29 files changed, 192 insertions(+), 189 deletions(-) create mode 100644 apps/extension/src/extension-dialog.tsx diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx index fbe557925bf..87334fcae21 100644 --- a/apps/docs/content/docs/browser-extension/index.mdx +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -33,9 +33,9 @@ Change the binding in Chrome’s **Extensions → Keyboard shortcuts**. If Chrom ## Open team links -Type a shortcut such as `go/handbook` or `sim/handbook` in Chrome’s address bar. Both work as soon as the extension is installed. +Type a shortcut such as `go/handbook` or `sim/handbook` in Chrome’s address bar. Both work once you're signed in to Sim and Links is on for your organization. -For suggestions, type `sim`, press **Space**, and enter part of a link name. If Chrome searches instead of opening a shortcut, enable your search engine under the extension’s **+ → Browser shortcuts**. Other searches stay unchanged. +For suggestions, type `sim`, press **Space**, and enter part of a link name. If Chrome searches instead of opening a shortcut, open the Sim extension and sign in. If it still searches, enable your search engine under the extension’s **+ → Browser shortcuts**. Other searches stay unchanged. See [Links](/search/sim-links#open-links-from-the-address-bar) for supported inputs, Glean conflicts, and team rollout. Administrators can [allow the extension for a Chrome organizational unit](https://support.google.com/chrome/a/answer/6306504). diff --git a/apps/docs/content/docs/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/platform/enterprise/audit-logs.mdx index 5e3e2677af9..f7f58c486bb 100644 --- a/apps/docs/content/docs/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/platform/enterprise/audit-logs.mdx @@ -101,7 +101,7 @@ Events use identifiers such as `resource.action`. These categories are represent | API keys | `api_key.created`, `api_key.revoked` | | Credentials | `credential.created`, `credential.deleted`, `oauth.disconnected` | | Organization | `organization.updated`, `org_member.added`, `org_member.role_changed`, `organization.sso_provider.updated` | -| Sim links | `sim_link.created`, `sim_link.updated`, `sim_link.deleted` | +| Links | `sim_link.created`, `sim_link.updated`, `sim_link.deleted` | | Exports and drains | `audit_logs.exported`, `data_drain.created`, `data_drain.updated`, `data_drain.ran`, `data_drain.tested` | Authentication failures and password-reset requests use an anonymous actor and record the target account separately. Impersonation and administrative session revocations identify the operator and target organization. Table-row mutations record summaries and counts without copying row contents. diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index fbdfccfbdac..18cbd9e2a50 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -15,7 +15,7 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile, then type `go/handbook` or `sim/handbook` in the address bar. Both work as soon as it's installed. +[Install Sim for Chrome](https://chromewebstore.google.com/detail/sim/oanlcahddodnjpdoopcpjlcageejjpmg) in your work profile, then type `go/handbook` or `sim/handbook` in the address bar. Both work once you're signed in to Sim. For suggestions, type `sim`, press **Space**, and enter part of a name. Matching links appear with the most opened first. Unlisted links still open by name. @@ -31,7 +31,7 @@ Turning shortcuts off keeps site access. To remove it, open Chrome’s **Extensi ### Roll out to your team -Administrators can [force-install Sim for Chrome](https://support.google.com/chrome/a/answer/6306504) for an organizational unit. Shortcuts start working once each person signs in to Sim and Chrome next starts. +Administrators can [force-install Sim for Chrome](https://support.google.com/chrome/a/answer/6306504) for an organizational unit. Shortcuts start working once each person signs in through the extension, or the next time Chrome starts after they've signed in. ### If another tool uses go/ or sim diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index 4cfec47d023..69ac2a23bed 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -60,16 +60,20 @@ other workspace resources or browser-control tools. ## Permission and lifetime rules - Required browser capabilities: side panel, scripting, active tab, context menus, - and short-lived session storage for selection handoff. Required host access is + short-lived session storage for selection handoff, and local storage for preferences. Required host access is limited to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`, which exist only so one static redirect rule can send top-level navigations to those hosts to the link resolver. - HTTP(S) page access is one optional browser-wide grant, requested from a direct user gesture. The browser owns revocation and restrictions. Approval enables capture across sites; switching tabs alone does not extract content. -- No history, bookmarks, cookie API, debugger, native messaging, external messaging, +- No history permission, bookmarks, cookie API, debugger, native messaging, external messaging, or arbitrary remote code execution. The only network interception is the static `sim/` and `go/` redirect rule above, plus opt-in search recovery. -- The only background network request is the `sim` address-bar keyword: after +- Background network requests are limited to two. Once a week at browser start, and after + signing in to the panel, the worker asks the Sim origin whether links are on for the session; + if so, it opens `http://go/` and `http://sim/` in background tabs that close themselves, which + teaches Chrome's address bar these hosts and adds entries to browser history. The other is the + `sim` address-bar keyword: after `sim` and Space, the text typed before the first `/` or `?` is sent to the configured Sim origin with the signed-in session to suggest matching Sim links, unless it contains a space (a search) or is longer than a link name can be. Page diff --git a/apps/extension/README.md b/apps/extension/README.md index 5315549bf3d..43570275cc8 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -38,13 +38,13 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app - Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation. - Attached pages share a 150,000-character and 450,000-byte snapshot budget per message, within the general 100-context ceiling. Shorter pages can share the available budget without a separate page-count restriction. - Source URLs omit credentials, query parameters, and fragments. Human-readable text can still contain sensitive information; it is the user-selected material they review and send into their conversation. -- Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. Extension storage contains only a short-lived intent with a tab/document identity and a SHA-256 URL digest, never raw URLs, captured text, or credentials. Closing a tab or navigating invalidates its pending intent. Sim applies its normal conversation retention once a message is sent. -- No browser history, bookmarks, cookie access, debugger access, remote extension JavaScript, arbitrary network proxy, or background content collection is requested. Incognito use is disabled. +- Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. For captures, extension storage contains only a short-lived intent with a tab/document identity and a SHA-256 URL digest, never raw URLs, captured text, or credentials. Closing a tab or navigating invalidates its pending intent. Sim applies its normal conversation retention once a message is sent. +- No browser history permission, bookmarks, cookie access, debugger access, remote extension JavaScript, arbitrary network proxy, or background content collection is requested. Incognito use is disabled. - The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, the native page-access and browser-shortcut dialogs, or the browser's fixed microphone settings page. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned page-access control and browser approval. Shortcut settings are advertised separately so older installed shells keep working without the new action. - Captures and queued toolbar intents are scoped to the receiving conversation. Changing conversation, workspace, or account discards an unfinished handoff. Captured attachments remain explicit snapshots when the source tab changes. - The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing and microphone capture are delegated only to the configured Sim origin; clipboard reading and camera access are not delegated. Voice input starts only after the user clicks the microphone and the browser grants permission. The shared Sim voice service streams microphone audio to ElevenLabs for transcription into the editable draft. Stop ends capture while allowing the final transcript to arrive; Send and conversation changes cancel unfinished transcription. There is no tab-audio capture or background recording. -With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. The extension stores only the preference in local storage. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. **Allow across websites** works even when Chrome hides the active address; no address entry is required. The grant covers HTTP and HTTPS sites across tabs until the user revokes or restricts it in browser settings. Uploaded screenshot files stay in the hosted app’s normal attachment flow. +With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. Local storage holds only this preference, search recovery choices, and when the short hosts were last visited. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. **Allow across websites** works even when Chrome hides the active address; no address entry is required. The grant covers HTTP and HTTPS sites across tabs until the user revokes or restricts it in browser settings. Uploaded screenshot files stay in the hosted app’s normal attachment flow. ## Verification @@ -56,7 +56,7 @@ bun run --cwd apps/extension build The E2E suite uses real Chromium, the packaged extension, and a disposable HTTP app fixture. It pregrants only the fixture page origin in a temporary package to exercise extraction without automating the browser's permission dialog. It checks HttpOnly session-cookie transport, origin/frame spoofing, sensitive-field exclusion, source URL minimization, selected excerpts, UTF-8 limits, stale-document handoffs, denied host access, and absence of page bytes in extension storage. The cookie check uses a fixture, not Better Auth or SSO. JSON results and a screenshot are written beside the caller-supplied report path. The E2E suite builds into a temporary directory and leaves shipping `dist` untouched. -Release verification must additionally exercise the native toolbar and shortcut, Chrome's real grant/revoke prompt, sign-in/SSO and expiry against the running Sim app, side-panel closing/reopening, workspace changes, narrow widths, and the real Mothership streaming response. The fixture suite does not establish those integrations. The design document records launch scope and release criteria. +Release verification must additionally exercise typing `go/` in Chrome's real address bar on a clean profile (the e2e proves the visits, not the address bar), the native toolbar and shortcut, Chrome's real grant/revoke prompt, sign-in/SSO and expiry against the running Sim app, side-panel closing/reopening, workspace changes, narrow widths, and the real Mothership streaming response. The fixture suite does not establish those integrations. The design document records launch scope and release criteria. The authenticated acceptance suite runs against a real local app, Better Auth, and a disposable Postgres database whose name contains `test`. Enable `KNOWLEDGE_MEMBER_ACCESS`, `CREDENTIAL_GROUPS`, `ACCESS_CONTROL_ENABLED`, and `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` on that app to exercise restricted organization members: diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 886de7b05b9..a4b53aa5722 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -17,6 +17,7 @@ import { import { SecretReveal } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { renderToStaticMarkup } from 'react-dom/server' @@ -226,6 +227,7 @@ try { ) await check('Installing signed out leaves go/ and sim/ as ordinary searches', async () => { await expect.poll(() => linksChecks, { timeout: 30_000 }).toBeGreaterThan(0) + await sleep(2_000) assert((await shortHostsVisitedAt()) === 'undefined', 'Visits were recorded while signed out') assert(shortHostVisits === 0, 'Short hosts were visited while signed out') }) @@ -526,6 +528,14 @@ try { await check( 'A signed-in panel visits each short host in tabs that close themselves', async () => { + await app.evaluate( + (targetOrigin) => + parent.postMessage( + { channel: 'sim-extension', version: 1, type: 'signed-in' }, + targetOrigin + ), + `chrome-extension://${extensionId}` + ) await expect.poll(shortHostsVisitedAt, { timeout: 30_000 }).toBe('number') assert(shortHostVisits === SIM_LINK_HOSTS.length, `Visited ${shortHostVisits} short hosts`) const leftBehind = await worker.evaluate( diff --git a/apps/extension/src/extension-dialog.tsx b/apps/extension/src/extension-dialog.tsx new file mode 100644 index 00000000000..b565d510309 --- /dev/null +++ b/apps/extension/src/extension-dialog.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' + +interface ExtensionDialogProps { + labelledBy: string + describedBy?: string + onClose: () => void + children: ReactNode +} + +/** Chrome owns modality so the dialog works without the scroll-lock stylesheet the CSP blocks. */ +function showDialog(dialog: HTMLDialogElement | null) { + if (!dialog) return + dialog.showModal() + return () => dialog.close() +} + +export function ExtensionDialog({ + labelledBy, + describedBy, + onClose, + children, +}: ExtensionDialogProps) { + return ( + { + event.preventDefault() + onClose() + }} + onClick={(event) => { + if (event.target === event.currentTarget) onClose() + }} + > + {children} + + ) +} diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts index 1edfe3698c9..80ba8ff326d 100644 --- a/apps/extension/src/omnibox.ts +++ b/apps/extension/src/omnibox.ts @@ -15,6 +15,7 @@ const SUGGEST_DELAY_MS = 220 const SEARCH_HINT = 'Find a link in Sim' // Chrome persists this template and substitutes the input when rendering the default row. const OPEN_LINK_HINT = 'Open %s in Sim' +const SEARCH_LINKS_HINT = 'Find %s in Sim' /** A Sim link as suggested: its name, and what it shows beside the name. */ interface SuggestedLink { @@ -140,9 +141,10 @@ export function registerSimLinkOmnibox() { chrome.omnibox.onInputChanged.addListener((text, suggest) => { pending?.abort() const { name, rest } = readTyped(text) - showHint(name || rest ? OPEN_LINK_HINT : SEARCH_HINT) // A name can't hold a space, so `sim card prices` is a search, not a link: ask Sim nothing. - if (/\s/.test(name) || name.length > SIM_LINK_NAME_MAX_LENGTH) { + const phrase = /\s/.test(name) + showHint(phrase ? SEARCH_LINKS_HINT : name || rest ? OPEN_LINK_HINT : SEARCH_HINT) + if (phrase || name.length > SIM_LINK_NAME_MAX_LENGTH) { suggest([]) return } diff --git a/apps/extension/src/search-recovery-dialog.tsx b/apps/extension/src/search-recovery-dialog.tsx index 0d5806e9c46..d92dc791b7d 100644 --- a/apps/extension/src/search-recovery-dialog.tsx +++ b/apps/extension/src/search-recovery-dialog.tsx @@ -9,6 +9,7 @@ import { ChipModalSurface, Switch, } from '@sim/emcn' +import { ExtensionDialog } from '@/extension-dialog' import { readSearchRecovery, SEARCH_RECOVERY_KEY, @@ -23,13 +24,6 @@ interface SearchRecoveryDialogProps { type SearchProvider = (typeof SEARCH_RECOVERY_PROVIDERS)[number] type SearchRecovery = Awaited> -/** Chrome owns modality so the dialog works without an injected scroll-lock stylesheet. */ -function mountSearchRecoveryDialog(dialog: HTMLDialogElement | null) { - if (!dialog) return - dialog.showModal() - return () => dialog.close() -} - export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { const generation = useRef(0) const [settings, setSettings] = useState() @@ -93,18 +87,10 @@ export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { } return ( - { - event.preventDefault() - onClose() - }} - onClick={(event) => { - if (event.target === event.currentTarget) onClose() - }} + @@ -112,7 +98,8 @@ export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { - Open go/ and sim/ links from search. Other searches stay unchanged. + If Chrome searches for a go/ or sim/ link instead of opening it, open the link anyway. + Other searches stay unchanged. {SEARCH_RECOVERY_PROVIDERS.map((provider) => ( - + ) } diff --git a/apps/extension/src/search-recovery.ts b/apps/extension/src/search-recovery.ts index 9b095a9759f..1c43d3568c1 100644 --- a/apps/extension/src/search-recovery.ts +++ b/apps/extension/src/search-recovery.ts @@ -40,6 +40,7 @@ export const SEARCH_RECOVERY_PROVIDERS = [ }, ] as const +type SearchRecoveryProvider = (typeof SEARCH_RECOVERY_PROVIDERS)[number] type ProviderId = (typeof SEARCH_RECOVERY_PROVIDERS)[number]['id'] type RecoveryPreferences = Record const logger = createLogger('BrowserShortcutRecovery') @@ -122,20 +123,16 @@ export async function setSearchRecovery(provider: ProviderId, enabled: boolean): /** Reconcile persisted, permission-bound rules at startup, on revocation, and after an explicit choice. */ export function registerSearchRecovery() { let pending = Promise.resolve() - const update = (choice?: { provider: ProviderId; enabled: boolean }) => { + const update = (choice?: { provider: SearchRecoveryProvider; enabled: boolean }) => { const operation = pending.then(async () => { const preferences = await readSearchRecovery() if (choice) { - const provider = SEARCH_RECOVERY_PROVIDERS.find( - (provider) => provider.id === choice.provider - ) - if (!provider) throw new Error('Unknown shortcut provider.') if ( choice.enabled && - !(await chrome.permissions.contains({ origins: [...provider.origins] })) + !(await chrome.permissions.contains({ origins: [...choice.provider.origins] })) ) throw new Error('Search site access has not been granted.') - preferences[choice.provider] = choice.enabled + preferences[choice.provider.id] = choice.enabled } const rules = SEARCH_RECOVERY_PROVIDERS.filter( (provider) => preferences[provider.id] @@ -190,7 +187,7 @@ export function registerSearchRecovery() { return const provider = SEARCH_RECOVERY_PROVIDERS.find((provider) => provider.id === message.provider) if (!provider) return - void update({ provider: provider.id, enabled: message.enabled }).then( + void update({ provider, enabled: message.enabled }).then( () => respond({ ok: true }), () => respond({ ok: false }) ) diff --git a/apps/extension/src/short-hosts.ts b/apps/extension/src/short-hosts.ts index 4ae7c4cd009..6776723b467 100644 --- a/apps/extension/src/short-hosts.ts +++ b/apps/extension/src/short-hosts.ts @@ -74,7 +74,7 @@ export function registerShortHostVisits() { } chrome.runtime.onInstalled.addListener(visit) chrome.runtime.onStartup.addListener(visit) - // Installing signed out leaves nothing to learn yet, so a signed-in panel is the next chance. + // Installing signed out leaves nothing to learn yet, so signing in to the panel is the next chance. chrome.runtime.onMessage.addListener((message: unknown, sender) => { if (sender.id === chrome.runtime.id && message === VISIT_SHORT_HOSTS_MESSAGE) visit() }) diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index 7ae1051c9f8..36d97101d8d 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -333,9 +333,10 @@ function SidePanel({ origin }: SidePanelProps) { if (parsed.data.type === 'theme') { appTheme = parsed.data.theme applyTheme() + } else if (parsed.data.type === 'signed-in') { + void chrome.runtime.sendMessage(VISIT_SHORT_HOSTS_MESSAGE).catch(() => undefined) } else if (parsed.data.type === 'ready') { void connect(parsed.data.sessionId) - void chrome.runtime.sendMessage(VISIT_SHORT_HOSTS_MESSAGE).catch(() => undefined) } else if (parsed.data.sessionId !== sessionRef.current) { return } else if (parsed.data.type === 'unavailable') { diff --git a/apps/extension/src/site-access.tsx b/apps/extension/src/site-access.tsx index 0ee685ade23..73c8a45ba68 100644 --- a/apps/extension/src/site-access.tsx +++ b/apps/extension/src/site-access.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn' import { PAGE_ACCESS_ORIGINS } from '@/config' +import { ExtensionDialog } from '@/extension-dialog' interface PageAccessButtonProps { allowed?: boolean @@ -53,13 +54,6 @@ interface SiteAccessDialogProps { onAccessGranted: () => void } -/** Native modality avoids the inline scroll-lock stylesheet blocked by the extension CSP. */ -function mountAccessDialog(dialog: HTMLDialogElement | null) { - if (!dialog) return - dialog.showModal() - return () => dialog.close() -} - export function SiteAccessDialog({ onClose, onAccessGranted }: SiteAccessDialogProps) { const [allowed, setAllowed] = useState() @@ -87,18 +81,7 @@ export function SiteAccessDialog({ onClose, onAccessGranted }: SiteAccessDialogP }, []) return ( - { - event.preventDefault() - onClose() - }} - onClick={(event) => { - if (event.target === event.currentTarget) onClose() - }} - > + Page access @@ -112,6 +95,6 @@ export function SiteAccessDialog({ onClose, onAccessGranted }: SiteAccessDialogP - + ) } diff --git a/apps/sim/app/extension/providers.tsx b/apps/sim/app/extension/providers.tsx index dc54c6ef4fe..18e4cf02865 100644 --- a/apps/sim/app/extension/providers.tsx +++ b/apps/sim/app/extension/providers.tsx @@ -1,6 +1,7 @@ 'use client' -import { createContext, type ReactNode, useContext, useRef } from 'react' +import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react' +import type { AppToExtensionMessage } from '@sim/browser-extension-protocol' import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider' import { isApiClientError } from '@/lib/api/client/errors' import { useSession } from '@/lib/auth/client' @@ -31,6 +32,16 @@ export function ExtensionProviders({ }: ExtensionProvidersProps) { const focusComposer = useRef(null) const { data: session, isPending, error } = useSession() + const signedIn = session?.user?.id === userId + useEffect(() => { + if (!signedIn) return + const message: AppToExtensionMessage = { + channel: 'sim-extension', + version: 1, + type: 'signed-in', + } + for (const origin of origins) window.parent.postMessage(message, origin) + }, [origins, signedIn]) if (isPending) return const accessDenied = isApiClientError(error) && (error.status === 401 || error.status === 403) if (accessDenied || !session?.user || session.user.id !== userId) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 815c035e47e..ddaa31bf583 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -14,6 +14,7 @@ import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/pa import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { canBrowsePeople } from '@/lib/people/feature-flag' import { isProjectsEnabled } from '@/lib/projects/rollout.server' +import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag' import { getQueryClient } from '@/app/_shell/providers/get-query-client' @@ -87,6 +88,7 @@ export default async function OrganizationLayout({ projectsEnabled, simLinksEnabled, peopleEnabled, + simLinkOrganizationId, ] = await Promise.all([ prefetchOrganizationSidebar( queryClient, @@ -106,6 +108,11 @@ export default async function OrganizationLayout({ isProjectsEnabled(), simLinksEnabledLookup, peopleEnabledLookup, + resolveSimLinkOrganization({ + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' @@ -124,7 +131,7 @@ export default async function OrganizationLayout({ }} > - + diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx index dacd0da1f4e..03d1b30fc83 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx @@ -21,13 +21,13 @@ const SimLinkScopeContext = createContext({ }) interface SimLinkOrganizationProviderProps { - /** The organization this surface creates and copies links in, or null when links are off. */ + /** Where `sim/` resolves for this session, or null when it resolves nowhere. */ organizationId: string | null children: ReactNode } /** - * Scopes Sim link shortcuts to one organization, and hosts + * Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer, and hosts * the new-link form at the layout root. A form opened from a menu row inside a popover would * otherwise render beneath it, since popovers stack above modals so they can open inside one; at * the root the form takes focus outside the popover, which dismisses it. diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx index e823afd52f0..e47e9588ec4 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx @@ -46,7 +46,7 @@ function useExistingSimLink(organizationId: string, url: string) { const existing = data?.at(0) const copyExisting = async () => { if (!existing) return false - const done = await copy(simLinkUrl(window.location.origin, organizationId, existing.name)) + const done = await copy(simLinkUrl(window.location.origin, existing.name)) if (!done) toast.error('Unable to copy link') return done } diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 26f37db7015..ee0f8efa4c2 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -1,13 +1,13 @@ 'use client' import { type MouseEvent, useCallback, useMemo, useState } from 'react' -import { Avatar, ChipConfirmModal, ChipModalError, writeTextToClipboard } from '@sim/emcn' +import { Avatar, ChipConfirmModal, ChipModalError, toast, writeTextToClipboard } from '@sim/emcn' import { Link } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useQueryStates } from 'nuqs' import type { SimLink } from '@/lib/api/contracts/sim-links' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { simLinkUrl } from '@/lib/navigation/paths' +import { simLinkPath, simLinkUrl } from '@/lib/navigation/paths' import { toSimLinkName } from '@/lib/sim-links/validation' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { @@ -60,19 +60,17 @@ function accessLabel(link: SimLink): string | null { return limits.length ? limits.join(', ') : null } -function openLink(organizationId: string, name: string) { - window.open( - simLinkUrl(window.location.origin, organizationId, name), - '_blank', - 'noopener,noreferrer' - ) +function openLink(name: string) { + window.open(simLinkPath(name), '_blank', 'noopener,noreferrer') } -async function copyLink(organizationId: string, name: string) { +async function copyLink(name: string) { try { - await writeTextToClipboard(simLinkUrl(window.location.origin, organizationId, name)) + await writeTextToClipboard(simLinkUrl(window.location.origin, name)) + toast.success('Link copied') } catch (error) { logger.error('Failed to copy Sim link', { error }) + toast.error('Unable to copy link') } } @@ -146,9 +144,9 @@ export function SimLinks({ organizationId }: SimLinksProps) { const openRow = useCallback( (id: string) => { const link = linksById.get(id) - if (link) openLink(organizationId, link.name) + if (link) openLink(link.name) }, - [linksById, organizationId] + [linksById] ) const openRowMenu = useCallback( (event: MouseEvent, id: string) => { @@ -212,8 +210,8 @@ export function SimLinks({ organizationId }: SimLinksProps) { isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={contextMenu.closeMenu} - onOpen={() => openLink(organizationId, menuLink.name)} - onCopyLink={() => void copyLink(organizationId, menuLink.name)} + onOpen={() => openLink(menuLink.name)} + onCopyLink={() => void copyLink(menuLink.name)} onEdit={menuLink.canEdit ? () => setEditing(menuLink) : undefined} onDelete={ menuLink.canEdit diff --git a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx b/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx index cdf75a17a13..f1245dda8ed 100644 --- a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx +++ b/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx @@ -1,18 +1,17 @@ import { ChipLink, StatusPageContent } from '@sim/emcn' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { organizationRoutes } from '@/lib/navigation/paths' +import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' import { resolveSimLink } from '@/lib/sim-links/application/use-cases' import { simLinkNameSchema } from '@/lib/sim-links/validation' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' -import { LogoShell } from '@/app/(landing)/components/logo-shell' interface OrganizationLinkPageProps { params: Promise<{ organizationId: string; name: string }> } -/** A link in one named organization, whichever organization the session is currently active in. */ +/** Bookmarks stay in their exporting organization even after the browser's active team changes. */ export default async function OrganizationLinkPage({ params }: OrganizationLinkPageProps) { const { organizationId, name: encodedName } = await params const name = decodeURIComponent(encodedName) @@ -26,29 +25,27 @@ export default async function OrganizationLinkPage({ params }: OrganizationLinkP isInviteFlow: false, }) ) - const result = await resolveSimLink - .execute({ - principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, - input: { organizationId, name, values: [], query: '' }, - }) - .catch((error: unknown) => { - if (error instanceof OrchestrationError) return null - throw error - }) - if (!result) notFound() + const principal = { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + } as const + if ((await resolveSimLinkOrganization(principal)) !== organizationId) notFound() + const result = await resolveSimLink.execute({ + principal, + input: { organizationId, name, values: [], query: '' }, + }) if (result.kind === 'redirect') redirect(result.url) return ( - - - Open Links - - + + Open Links + ) } diff --git a/apps/sim/app/sim-links/recover/route.ts b/apps/sim/app/sim-links/recover/route.ts index 8642607a360..89b43f25fa3 100644 --- a/apps/sim/app/sim-links/recover/route.ts +++ b/apps/sim/app/sim-links/recover/route.ts @@ -1,11 +1,10 @@ import { SIM_LINK_NAME_MAX_LENGTH } from '@sim/browser-extension-protocol' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { stripSimLinkPrefix } from '@/lib/sim-links/validation' +import { SIM_LINK_NAME_PATTERN, stripSimLinkPrefix } from '@/lib/sim-links/validation' const MAX_VALUES = 8 const MAX_VALUE_LENGTH = 256 const MAX_DECODED_LENGTH = 5 + SIM_LINK_NAME_MAX_LENGTH + MAX_VALUES * (MAX_VALUE_LENGTH + 1) -const SHORTCUT_SEGMENT = /^[A-Za-z0-9.:@&~_-]+$/ const RESPONSE_HEADERS = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', @@ -42,7 +41,7 @@ function recoverSimLinkPath(search: string): string | null { segments.length > MAX_VALUES + 1 || segments.some( (segment) => - !SHORTCUT_SEGMENT.test(segment) || + !SIM_LINK_NAME_PATTERN.test(segment) || segment === '.' || segment === '..' || segment.length > MAX_VALUE_LENGTH diff --git a/apps/sim/app/sim/[[...path]]/page.tsx b/apps/sim/app/sim/[[...path]]/page.tsx index 0c2ec380ce1..4dcd852ff41 100644 --- a/apps/sim/app/sim/[[...path]]/page.tsx +++ b/apps/sim/app/sim/[[...path]]/page.tsx @@ -2,14 +2,18 @@ import { ChipLink, StatusPageContent } from '@sim/emcn' import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths' +import { APP_ENTRY_PATH, organizationRoutes, simLinkPath } from '@/lib/navigation/paths' import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' import { resolveSimLink, type SimLinkResolution, suggestSimLinks, } from '@/lib/sim-links/application/use-cases' -import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation' +import { + parseTypedSimLink, + SIM_LINK_SEARCH_MAX_LENGTH, + simLinkNameSchema, +} from '@/lib/sim-links/validation' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { LogoShell } from '@/app/(landing)/components/logo-shell' import { serializeSimLinkView } from '@/app/o/[organizationId]/links/search-params' @@ -85,11 +89,8 @@ export default async function SimLinkPage({ params, searchParams }: SimLinkPageP if (!organizationId) { return ( - - Open Sim + + Open Sim ) @@ -107,11 +108,15 @@ export default async function SimLinkPage({ params, searchParams }: SimLinkPageP const validName = simLinkNameSchema.safeParse(name).success // A name can't hold a space, so `sim card prices` is someone looking for a link, not naming one. if (result.kind === 'not_found' && /\s/.test(name)) { - redirect(serializeSimLinkView(library, { search: name })) + redirect(serializeSimLinkView(library, { search: name.slice(0, SIM_LINK_SEARCH_MAX_LENGTH) })) } const creatable = result.kind === 'not_found' && validName const similar = creatable - ? (await suggestSimLinks.execute({ principal, input: { query: name } })).links.slice(0, 3) + ? await suggestSimLinks + .execute({ principal, input: { query: name } }) + .then(({ links }) => links.slice(0, 3)) + // Similar names are a courtesy: failing to find them must not take down this page. + .catch(() => []) : [] return ( diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index 868ea36e863..59ec9bd02f5 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -6,7 +6,11 @@ import { import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { organizationIdSchema } from '@/lib/api/contracts/primitives' -import { SIM_LINK_SORTS, simLinkFieldsSchema } from '@/lib/sim-links/validation' +import { + SIM_LINK_SEARCH_MAX_LENGTH, + SIM_LINK_SORTS, + simLinkFieldsSchema, +} from '@/lib/sim-links/validation' import { SORT_DIRECTIONS } from '@/lib/url-state' const params = z.object({ id: organizationIdSchema }) @@ -38,7 +42,7 @@ const linkResponse = z.object({ link: simLinkSchema }) export const listSimLinksQuerySchema = z .object({ - search: z.string().max(200).optional(), + search: z.string().max(SIM_LINK_SEARCH_MAX_LENGTH).optional(), /** Only links whose destination is exactly this URL, such as one shown in a search result. */ url: z.string().min(1).max(SIM_LINK_URL_MAX_LENGTH).optional(), cursor: z.string().min(1).max(128).optional(), @@ -47,7 +51,7 @@ export const listSimLinksQuerySchema = z }) .refine((query) => !(query.sort === 'popular' && query.cursor), { path: ['cursor'], - message: 'The most-used view is a single page', + message: 'The Opens sort is a single page', }) export const listSimLinksContract = defineRouteContract({ diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index eb1b045e975..3c861a01742 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -66,9 +66,9 @@ export function simLinkPath(...segments: string[]): string { return ['/sim', ...segments.map(encodeURIComponent)].join('/') } -/** The full address of a link on `origin`, which opens it in this organization for any member. */ -export function simLinkUrl(origin: string, organizationId: string, name: string): string { - return new URL(organizationRoutes(organizationId).openSimLink(name), origin).href +/** The full address of `sim/` on `origin`, which opens it with or without the extension. */ +export function simLinkUrl(origin: string, name: string): string { + return new URL(simLinkPath(name), origin).href } /** Whether `pathname` is `root` itself or a route beneath it. */ diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index 6eca3094bfe..7196407efb2 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -14,7 +14,6 @@ import { requireOrganizationSubjectMembership, } from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { getBaseUrl } from '@/lib/core/utils/urls' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import type { DbTransaction } from '@/lib/db/types' import { simLinkOperations } from '@/lib/sim-links/application/operations' @@ -24,7 +23,6 @@ import { requireSimLinksEnabled } from '@/lib/sim-links/feature-flag' import type { SimLinkAccess, SimLinkRecord, SimLinkViewer } from '@/lib/sim-links/repository' import * as repository from '@/lib/sim-links/repository' import { - isSimLinkLoop, type SimLinkFields, type SimLinkSort, stripSimLinkPrefix, @@ -201,21 +199,11 @@ export const getSimLinkView = defineAuthorizedOrganizationUseCase({ }, }) -function requireExternalDestination({ url, defaultUrl }: SimLinkFields) { - const appOrigin = new URL(getBaseUrl()).origin - if (isSimLinkLoop(url, appOrigin) || (defaultUrl && isSimLinkLoop(defaultUrl, appOrigin))) - throw new OrchestrationError( - 'validation', - "A link can't point at another link. Use the page that link opens." - ) -} - export const createSimLink = defineAuthorizedOrganizationUseCase({ operation: simLinkOperations.create, ...rollout, async execute({ input, context }: OrganizationUseCaseContext) { const { organizationId, ...fields } = input - requireExternalDestination(fields) return withUniqueName(organizationId, fields.name, () => db.transaction(async (tx) => { /** A removal deletes this row before handing off the member's links, so it waits for this insert. */ @@ -244,7 +232,6 @@ export const updateSimLink = defineAuthorizedOrganizationUseCase({ ...rollout, async execute({ input, context }: OrganizationUseCaseContext) { const { organizationId, linkId, ...fields } = input - requireExternalDestination(fields) const viewer = viewerOf(context) return withUniqueName(organizationId, fields.name, () => db.transaction(async (tx) => { diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts index ec9eb6cbdd0..90424a70647 100644 --- a/apps/sim/lib/sim-links/repository.ts +++ b/apps/sim/lib/sim-links/repository.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { simLink, user } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { escapeLikePattern } from '@sim/utils/string' -import { and, asc, desc, eq, gt, ilike, inArray, like, lt, or, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gt, ilike, like, lt, or, type SQL, sql } from 'drizzle-orm' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { normalizeSimLinkName, @@ -98,7 +98,8 @@ export async function listSimLinks( const query = search && stripSimLinkPrefix(search.trim()) if (query) { const pattern = `%${escapeLikePattern(query)}%` - const normalized = normalizeSimLinkName(query) + // Names hold no spaces, so a typed phrase like `team handbook` still finds `team-handbook`. + const normalized = normalizeSimLinkName(query).replace(/\s+/g, '') conditions.push( or( ilike(simLink.url, pattern), diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index 95d49b2ad7d..ff2659a4359 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -1,15 +1,15 @@ import { SIM_LINK_HOST_PATTERN, - SIM_LINK_HOSTS, SIM_LINK_NAME_MAX_LENGTH, SIM_LINK_URL_MAX_LENGTH, } from '@sim/browser-extension-protocol' import { z } from 'zod' -const NAME_PATTERN = /^[A-Za-z0-9.:@&~_-]+$/ +export const SIM_LINK_NAME_PATTERN = /^[A-Za-z0-9.:@&~_-]+$/ const NAME_PUNCTUATION = /[.:@&~_-]/g const NAME_DISALLOWED = /[^A-Za-z0-9.:@&~_-]/g export const SIM_LINK_DESCRIPTION_MAX_LENGTH = 500 +export const SIM_LINK_SEARCH_MAX_LENGTH = 200 /** List orders: alphabetical by name, or most visited first. */ export const SIM_LINK_SORTS = ['name', 'popular'] as const @@ -122,7 +122,7 @@ export const simLinkNameSchema = z .trim() .min(1, 'Enter a name') .max(SIM_LINK_NAME_MAX_LENGTH, `Use at most ${SIM_LINK_NAME_MAX_LENGTH} characters`) - .regex(NAME_PATTERN, 'Use letters, numbers, and . : @ & ~ - _') + .regex(SIM_LINK_NAME_PATTERN, 'Use letters, numbers, and . : @ & ~ - _') .refine((name) => normalizeSimLinkName(name).length > 0, 'Include a letter or number') const destinationSchema = z @@ -131,14 +131,6 @@ const destinationSchema = z .min(1, 'Enter a destination URL') .max(SIM_LINK_URL_MAX_LENGTH, `Use at most ${SIM_LINK_URL_MAX_LENGTH} characters`) -/** Whether a destination leads back into link resolution, which would redirect forever. */ -export function isSimLinkLoop(url: string, appOrigin: string): boolean { - const parsed = parseHttpUrl(url.replaceAll(SIM_LINK_VARIABLE, 'value')) - if (!parsed) return false - if ((SIM_LINK_HOSTS as readonly string[]).includes(parsed.hostname)) return true - return parsed.origin === appOrigin && /^\/sim(?:-links)?(?:\/|$)/.test(parsed.pathname) -} - const simLinkUrlSchema = destinationSchema.refine( (url) => simLinkOrigin(url) !== null, `Enter a full http:// or https:// URL. ${SIM_LINK_VARIABLE} can't replace the domain.` diff --git a/apps/sim/scripts/test-browser-extension-e2e.ts b/apps/sim/scripts/test-browser-extension-e2e.ts index c27aea377ab..5724146db2a 100644 --- a/apps/sim/scripts/test-browser-extension-e2e.ts +++ b/apps/sim/scripts/test-browser-extension-e2e.ts @@ -1573,7 +1573,6 @@ try { await mic.click() await expect(stop).toBeVisible() await expect.poll(() => sessions.length).toBe(3) - assert.equal(sessions[1].closed, false, 'Stale-transcript grace must still be open') sessions[1].socket.send( JSON.stringify({ message_type: 'partial_transcript', text: 'stale thought' }) ) diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts index b351a0172f5..a8153443419 100644 --- a/apps/sim/scripts/test-sim-links-e2e.ts +++ b/apps/sim/scripts/test-sim-links-e2e.ts @@ -433,41 +433,6 @@ async function run() { } ) - await check( - 'a member of two organizations opens links in the organization they name', - async () => { - const both = await seedMember(orgId, 'member') - await sql`insert into member (id, user_id, organization_id, role) - values (${generateId()}, ${both.userId}, ${outsider.orgId}, 'member')` - for (const [organizationId, destination] of [ - [orgId, 'https://team.example.com/help'], - [outsider.orgId, 'https://another-team.example.com/help'], - ]) { - const opened = await send( - `/sim-links/${encodeURIComponent(organizationId)}/managed-help`, - both - ) - assert.equal(opened.status, 307, organizationId) - assert.equal(opened.headers.get('location'), destination, organizationId) - } - } - ) - - await check('a link cannot point back into link resolution', async () => { - for (const url of [ - 'http://go/managed-help', - 'https://sim/x', - new URL('/sim/managed-help', baseUrl).href, - ]) { - await api(owner, '', { - method: 'POST', - body: fields({ name: 'looping', url }), - expected: 400, - }) - } - await create(owner, { name: 'simulate', url: new URL('/simulate', baseUrl).href }) - }) - await check('the address bar learns whether links are on and matches inside names', async () => { const suggest = (caller: Member | null, q: string) => send(`/api/sim-links/suggestions?q=${encodeURIComponent(q)}`, caller) @@ -478,12 +443,25 @@ async function run() { } assert.equal(enabled, true) assert(links.some((link) => link.name === 'Team-Handbook')) + const withoutOrganization = await seedMember(orgId, 'member') + await sql`delete from member where user_id = ${withoutOrganization.userId}` + assert.deepEqual(await (await suggest(withoutOrganization, '')).json(), { + enabled: false, + links: [], + }) + assert.match((await open(withoutOrganization, 'handbook')).text, /aren.*t available/) }) await check('a typed phrase searches links and a near miss offers what exists', async () => { const phrase = await open(member, 'team%20handbook') assert(phrase.location, 'Expected a redirect to search') assert.equal(new URL(phrase.location).searchParams.get('search'), 'team handbook') + const { links } = await api(member, '?search=team%20handbook', {}) + assert( + Array.isArray(links) && + links.some((link) => isRecordLike(link) && link.name === 'Team-Handbook'), + 'Expected a typed phrase to find the hyphenated name' + ) assert.match((await open(member, 'handbook')).text, /Team-Handbook/) }) diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts index 7567552046d..6b16b9c0fdc 100644 --- a/packages/browser-extension-protocol/src/index.ts +++ b/packages/browser-extension-protocol/src/index.ts @@ -54,6 +54,7 @@ export type ExtensionAction = z.infer export const appToExtensionMessageSchema = z.discriminatedUnion('type', [ z.strictObject({ ...envelope, type: z.literal('theme'), theme: z.enum(['light', 'dark']) }), + z.strictObject({ ...envelope, type: z.literal('signed-in') }), z.strictObject({ ...envelope, type: z.literal('ready'), sessionId: z.uuid() }), z.strictObject({ ...envelope, type: z.literal('composer-focused'), sessionId: z.uuid() }), z.strictObject({ ...envelope, type: z.literal('unavailable'), sessionId: z.uuid() }), From 83b0c876bd97bef619520d0e272538b214973195 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:53:31 -0800 Subject: [PATCH 6/8] improvement(extension): remove opt-in search recovery Shortcuts now work without setup, so recovery was extra config that needed search-site access and could catch real searches like go/ast. An update clears the rules and setting from earlier versions, and the recover route stays until those versions have updated. --- .../content/docs/browser-extension/index.mdx | 2 +- apps/docs/content/docs/search/sim-links.mdx | 6 - apps/extension/DESIGN.md | 2 +- apps/extension/README.md | 5 +- apps/extension/scripts/test-e2e.ts | 176 +--------------- apps/extension/src/background.ts | 8 +- apps/extension/src/extension-dialog.tsx | 40 ---- apps/extension/src/search-recovery-dialog.tsx | 143 ------------- apps/extension/src/search-recovery.ts | 196 ------------------ apps/extension/src/sidepanel.tsx | 12 -- apps/extension/src/site-access.tsx | 23 +- .../app/(landing)/privacy/privacy-content.tsx | 2 +- apps/sim/app/extension/hooks/use-composer.tsx | 9 +- .../app/extension/hooks/use-page-context.ts | 8 - apps/sim/app/sim-links/recover/route.ts | 1 + .../browser-extension-protocol/src/index.ts | 2 - 16 files changed, 35 insertions(+), 600 deletions(-) delete mode 100644 apps/extension/src/extension-dialog.tsx delete mode 100644 apps/extension/src/search-recovery-dialog.tsx delete mode 100644 apps/extension/src/search-recovery.ts diff --git a/apps/docs/content/docs/browser-extension/index.mdx b/apps/docs/content/docs/browser-extension/index.mdx index 87334fcae21..0ed7bb43c28 100644 --- a/apps/docs/content/docs/browser-extension/index.mdx +++ b/apps/docs/content/docs/browser-extension/index.mdx @@ -35,7 +35,7 @@ Change the binding in Chrome’s **Extensions → Keyboard shortcuts**. If Chrom Type a shortcut such as `go/handbook` or `sim/handbook` in Chrome’s address bar. Both work once you're signed in to Sim and Links is on for your organization. -For suggestions, type `sim`, press **Space**, and enter part of a link name. If Chrome searches instead of opening a shortcut, open the Sim extension and sign in. If it still searches, enable your search engine under the extension’s **+ → Browser shortcuts**. Other searches stay unchanged. +For suggestions, type `sim`, press **Space**, and enter part of a link name. If Chrome searches instead of opening a shortcut, open the Sim extension and sign in. See [Links](/search/sim-links#open-links-from-the-address-bar) for supported inputs, Glean conflicts, and team rollout. Administrators can [allow the extension for a Chrome organizational unit](https://support.google.com/chrome/a/answer/6306504). diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index 18cbd9e2a50..906b6d22573 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -23,12 +23,6 @@ For suggestions, type `sim`, press **Space**, and enter part of a name. Matching This can happen if you installed the extension while signed out, or just cleared your browsing history. Opening the Sim extension and signing in fixes the first case, and the second fixes itself within a week. Until then, a trailing slash (`go/handbook/`) or `http://go/handbook` tells Chrome to navigate directly. -To catch these searches automatically, open the extension’s **+ → Browser shortcuts** and turn on your search engine. Approve Chrome’s site-access prompt if shown. Google (`google.com`), Bing, DuckDuckGo, and Yahoo are supported. Other searches and your default search engine stay unchanged. - -This handles whole shortcuts such as `go/handbook` or `go/jira/123`. For spaces, query parameters, unsupported search sites, or blocked site access, use `sim` + **Space** or the full `https://www.sim.ai/sim/` address. - -Turning shortcuts off keeps site access. To remove it, open Chrome’s **Extensions → Manage extensions → Sim → Details**. - ### Roll out to your team Administrators can [force-install Sim for Chrome](https://support.google.com/chrome/a/answer/6306504) for an organizational unit. Shortcuts start working once each person signs in through the extension, or the next time Chrome starts after they've signed in. diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index 69ac2a23bed..e3881a9b502 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -68,7 +68,7 @@ other workspace resources or browser-control tools. capture across sites; switching tabs alone does not extract content. - No history permission, bookmarks, cookie API, debugger, native messaging, external messaging, or arbitrary remote code execution. The only network interception is the static - `sim/` and `go/` redirect rule above, plus opt-in search recovery. + `sim/` and `go/` redirect rule above. - Background network requests are limited to two. Once a week at browser start, and after signing in to the panel, the worker asks the Sim origin whether links are on for the session; if so, it opens `http://go/` and `http://sim/` in background tabs that close themselves, which diff --git a/apps/extension/README.md b/apps/extension/README.md index 43570275cc8..b5a4621c7d1 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -32,7 +32,6 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app - Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*` and `http(s)://go/*`. - Type `sim`, press Space, enter the name, and press Enter to open an organization's [link](https://docs.sim.ai/search/sim-links). `go/` opens the same link. Chrome treats bare `go/` or `sim/` as a search until its history holds that short host, so on install, and at browser start once a week, the extension opens `http://go/` and `http://sim/` in background tabs that close themselves. It does this only once Sim says links are on for the signed-in session, so `go/x` stays an ordinary search for everyone else; signing in through the side panel checks again. Chrome counts a visit to an unfamiliar single-label host as typed, which is what the address bar checks, and repeating covers history that expires after 90 days or is cleared. A trailing slash (`go//`) always selects navigation. A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` and `http(s)://go/*` to `/sim/*` with the path and query unchanged. The `sim` and `go` host permissions exist only for that rule: every navigation to those hosts is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` or `go` should not install this build, since its links would be redirected to Sim. When another extension (such as another go-links tool) also redirects `go/`, Chrome applies the most recently installed extension's rule. - The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. For these suggestions, 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 `/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. -- **+ → Browser shortcuts** separately opts into recovery for exact Google, Bing, DuckDuckGo, or Yahoo HTTPS search hosts. After a direct Chrome permission grant, dynamic request rules redirect only top-level GET requests with one query parameter containing a whole `go/` or `sim/` shortcut. Only that value reaches `/sim-links/recover`, which validates it and redirects to the existing authenticated resolver. Ordinary search terms, additional search parameters, subframes, and unsupported inputs are not forwarded. Chrome applies these rules without an extension search-URL observer. Turning a provider off removes its rules while preserving site access; revoking access clears its recovery choice. This does not enable page reference or change the default search provider. - Optional HTTP/HTTPS access is requested together by **Allow across websites**, from a direct user action. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. - 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. - Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation. @@ -40,11 +39,11 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app - Source URLs omit credentials, query parameters, and fragments. Human-readable text can still contain sensitive information; it is the user-selected material they review and send into their conversation. - Snapshots stay in memory until handed to Sim and acknowledged or expired after 30 seconds. For captures, extension storage contains only a short-lived intent with a tab/document identity and a SHA-256 URL digest, never raw URLs, captured text, or credentials. Closing a tab or navigating invalidates its pending intent. Sim applies its normal conversation retention once a message is sent. - No browser history permission, bookmarks, cookie access, debugger access, remote extension JavaScript, arbitrary network proxy, or background content collection is requested. Incognito use is disabled. -- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, the native page-access and browser-shortcut dialogs, or the browser's fixed microphone settings page. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned page-access control and browser approval. Shortcut settings are advertised separately so older installed shells keep working without the new action. +- The iframe bridge checks both the exact configured origin and the exact frame, then validates versioned messages. The hosted composer may request only page capture, selection capture, the native page-access dialog, or the browser's fixed microphone settings page. Requests require the current conversation nonce and Chrome transient user activation; arbitrary URLs, tabs, script execution, and network operations are never accepted. Permission grants still require a click in the extension-owned page-access control and browser approval. - Captures and queued toolbar intents are scoped to the receiving conversation. Changing conversation, workspace, or account discards an unfinished handoff. Captured attachments remain explicit snapshots when the source tab changes. - The complete hosted document, including portaled source dialogs and menus, is blocked from session replay. Clipboard writing and microphone capture are delegated only to the configured Sim origin; clipboard reading and camera access are not delegated. Voice input starts only after the user clicks the microphone and the browser grants permission. The shared Sim voice service streams microphone audio to ElevenLabs for transcription into the editable draft. Stop ends capture while allowing the final transcript to arrive; Send and conversation changes cancel unfinished transcription. There is no tab-audio capture or background recording. -With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. Local storage holds only this preference, search recovery choices, and when the short hosts were last visited. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. **Allow across websites** works even when Chrome hides the active address; no address entry is required. The grant covers HTTP and HTTPS sites across tabs until the user revokes or restricts it in browser settings. Uploaded screenshot files stay in the hosted app’s normal attachment flow. +With **Reference current page** enabled, a composer Send requests a single fresh snapshot for that conversation. Local storage holds only this preference and when the short hosts were last visited. A tab change does not read any content. Explicit snapshots take precedence; access denial, navigation and timeouts keep the draft unsent. **Allow across websites** works even when Chrome hides the active address; no address entry is required. The grant covers HTTP and HTTPS sites across tabs until the user revokes or restricts it in browser settings. Uploaded screenshot files stay in the hosted app’s normal attachment flow. ## Verification diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index a4b53aa5722..151f24c82d3 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -96,14 +96,6 @@ const fixture = Bun.serve({ ) }, }) -const searchProviders = [ - { id: 'google', hosts: ['www.google.com', 'google.com'], path: '/search', parameter: 'q' }, - { id: 'bing', hosts: ['www.bing.com', 'bing.com'], path: '/search', parameter: 'q' }, - { id: 'duckduckgo', hosts: ['duckduckgo.com', 'www.duckduckgo.com'], path: '/', parameter: 'q' }, - { id: 'yahoo', hosts: ['search.yahoo.com'], path: '/search', parameter: 'p' }, -] as const -const searchRequests: { url: string; method: string }[] = [] -let searchFixture: ReturnType | undefined const temporary = await mkdtemp(resolve(tmpdir(), 'sim-extension-e2e-')) const extensionPath = resolve(temporary, 'extension') let context: BrowserContext | undefined @@ -147,50 +139,6 @@ try { ) assert(!existsSync(rejectedOutput), 'Invalid key material reached build output') }) - const searchKey = resolve(temporary, 'search-key.pem') - const searchCertificate = resolve(temporary, 'search-cert.pem') - const certificate = spawnSync( - 'openssl', - [ - 'req', - '-x509', - '-newkey', - 'rsa:2048', - '-nodes', - '-days', - '1', - '-keyout', - searchKey, - '-out', - searchCertificate, - '-subj', - '/CN=extension-search.test', - ], - { encoding: 'utf8', timeout: 10_000 } - ) - assert(certificate.status === 0, 'Could not create the isolated search fixture certificate') - searchFixture = Bun.serve({ - hostname: '127.0.0.1', - port: 0, - tls: { key: await readFile(searchKey), cert: await readFile(searchCertificate) }, - fetch(request) { - const path = new URL(request.url).pathname - if (path === '/' || path === '/search') - searchRequests.push({ url: request.url, method: request.method }) - return new Response( - 'Local search fixture

Search fixture

', - { - headers: { 'content-type': 'text/html' }, - } - ) - }, - }) - const searchHosts = searchProviders.flatMap((provider) => [...provider.hosts]) - const searchPort = searchFixture.port - const fixtureSearchHosts = [...searchHosts, 'm.google.com', 'www.google.com.evil.test'] - const resolverRules = fixtureSearchHosts - .map((host) => `MAP ${host} 127.0.0.1:${searchPort}`) - .join(', ') const origin = `http://127.0.0.1:${fixture.port}` const pageOrigin = `http://localhost:${fixture.port}` const built = spawnSync(process.execPath, ['scripts/build.ts', `--outdir=${extensionPath}`], { @@ -200,18 +148,14 @@ try { }) assert(built.status === 0, `Extension build failed: ${built.stderr}`) const manifest = JSON.parse(await readFile(resolve(extensionPath, 'manifest.json'), 'utf8')) - // Pregrants isolate routing from consent; approved search hosts resolve to the local fixture. - manifest.host_permissions.push( - `${pageOrigin}/*`, - ...fixtureSearchHosts.map((host) => `https://${host}/*`) - ) + // Pregrants isolate routing from consent. + manifest.host_permissions.push(`${pageOrigin}/*`) await writeFile(resolve(extensionPath, 'manifest.json'), JSON.stringify(manifest)) context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), { channel: 'chromium', headless: true, ignoreHTTPSErrors: true, args: [ - `--host-resolver-rules=${resolverRules}`, '--no-proxy-server', '--enable-unsafe-extension-debugging', `--disable-extensions-except=${extensionPath}`, @@ -1430,121 +1374,6 @@ try { await expect(app.locator('body')).toHaveAttribute('data-reference-request', denied.requestId) } ) - await check( - 'Opted-in shortcut recovery redirects only explicit searches on the chosen providers', - async () => { - const tab = await page.context().newPage() - const toggle = async (provider: string, enabled: boolean) => { - const result = await panel.evaluate( - ({ provider, enabled }) => - chrome.runtime.sendMessage({ type: 'set-search-recovery', provider, enabled }), - { provider, enabled } - ) - assert( - result?.ok === true, - `Could not ${enabled ? 'enable' : 'disable'} ${provider} recovery` - ) - } - const unchanged = async (url: string) => { - await tab.goto(url) - assert(tab.url() === url, `An ordinary or ambiguous search was redirected: ${url}`) - assert( - (await tab.title()) === 'Local search fixture', - 'A provider request escaped the local fixture' - ) - } - try { - for (const provider of searchProviders) { - const endpoint = `https://${provider.hosts[0]}${provider.path}` - const url = `${endpoint}?${provider.parameter}=sim%2Faskit` - await unchanged(url) - await toggle(provider.id, true) - for (const host of provider.hosts) { - for (const shortcut of SIM_LINK_HOSTS.flatMap((short) => [ - `${short}%2Faskit`, - `${short}/jira/ABC-123`, - ])) { - const candidate = `https://${host}${provider.path}?client=chrome&${provider.parameter}=${shortcut}&source=qa` - const prior = searchRequests.filter((request) => request.url === candidate).length - await tab.goto(candidate) - assert( - tab.url() === `${origin}/sim-links/recover?q=${shortcut}`, - `${provider.id} lost the explicit shortcut` - ) - assert( - searchRequests.filter((request) => request.url === candidate).length === prior, - 'The matched shortcut reached the search provider' - ) - } - } - for (const query of [ - `${provider.parameter}=quarterly+planning`, - `${provider.parameter}=sim%2Faskit%20help`, - `${provider.parameter}=sim%2Faskit+help`, - `${provider.parameter}=sim%252Faskit`, - `${provider.parameter}=sim%2Faskit%ZZ`, - `${provider.parameter}=sim%2Faskit&${provider.parameter}=other`, - `${provider.parameter}=other&${provider.parameter}=sim%2Faskit`, - `${provider.parameter}=sim%2Faskit&%${provider.parameter.charCodeAt(0).toString(16)}=other`, - ]) - await unchanged(`${endpoint}?${query}`) - await toggle(provider.id, false) - await unchanged(url) - assert( - await worker.evaluate( - (origins) => chrome.permissions.contains({ origins }), - provider.hosts.map((host) => `https://${host}/*`) - ), - 'Disabling recovery removed a capture permission' - ) - } - await toggle('google', true) - for (const url of [ - 'https://www.google.com/other?q=sim%2Faskit', - 'https://m.google.com/search?q=sim%2Faskit', - 'https://www.google.com.evil.test/search?q=sim%2Faskit', - 'https://www.google.com/search?%71=sim%2Faskit', - ]) - await unchanged(url) - const search = 'https://www.google.com/search?q=sim%2Faskit' - await tab.goto(`${origin}/post-fixture`) - await Promise.all([ - tab.waitForNavigation(), - tab.evaluate((url) => { - const form = document.createElement('form') - form.method = 'POST' - form.action = url - document.body.append(form) - form.submit() - }, search), - ]) - assert( - tab.url() === search && - searchRequests.some((request) => request.url === search && request.method === 'POST'), - 'A POST search was recovered' - ) - await tab.goto(`${origin}/frame-fixture`) - await Promise.all([ - tab.waitForEvent( - 'framenavigated', - (child) => child.parentFrame() !== null && child.url() === search - ), - tab.evaluate((url) => { - const child = document.createElement('iframe') - child.src = url - document.body.append(child) - }, search), - ]) - assert( - tab.url() === `${origin}/frame-fixture`, - 'A subframe search changed the top-level page' - ) - await toggle('google', false) - } finally { - await tab.close() - } - } - ) await mkdir(dirname(output), { recursive: true }) await panel.screenshot({ path: output.replace(/\.json$/, '.png'), fullPage: true }) } catch (error) { @@ -1559,7 +1388,6 @@ try { } finally { await context?.close() fixture.stop(true) - searchFixture?.stop(true) await mkdir(dirname(output), { recursive: true }) await writeFile(output, JSON.stringify({ suite: 'browser-extension', checks }, null, 2)) await rm(temporary, { recursive: true, force: true }) diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 65f6049c4d7..43769fd563c 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -10,7 +10,6 @@ import { } from '@/config' import { captureIntentSchema } from '@/intent' import { registerSimLinkOmnibox } from '@/omnibox' -import { registerSearchRecovery } from '@/search-recovery' import { registerShortHostVisits } from '@/short-hosts' import { digestUrl } from '@/utils' @@ -35,6 +34,12 @@ async function configure() { documentUrlPatterns: ['http://*/*', 'https://*/*'], targetUrlPatterns: ['http://*/*', 'https://*/*'], }) + // Earlier versions could add search redirect rules, which an update must not leave running. + const staleRules = await chrome.declarativeNetRequest.getDynamicRules() + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: staleRules.map((rule) => rule.id), + }) + await chrome.storage.local.remove('searchRecovery') } function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') { @@ -92,7 +97,6 @@ function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') } registerSimLinkOmnibox() -registerSearchRecovery() registerShortHostVisits() chrome.runtime.onInstalled.addListener(() => { void configure().catch(() => logger.warn('Could not initialize extension controls')) diff --git a/apps/extension/src/extension-dialog.tsx b/apps/extension/src/extension-dialog.tsx deleted file mode 100644 index b565d510309..00000000000 --- a/apps/extension/src/extension-dialog.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ReactNode } from 'react' - -interface ExtensionDialogProps { - labelledBy: string - describedBy?: string - onClose: () => void - children: ReactNode -} - -/** Chrome owns modality so the dialog works without the scroll-lock stylesheet the CSP blocks. */ -function showDialog(dialog: HTMLDialogElement | null) { - if (!dialog) return - dialog.showModal() - return () => dialog.close() -} - -export function ExtensionDialog({ - labelledBy, - describedBy, - onClose, - children, -}: ExtensionDialogProps) { - return ( - { - event.preventDefault() - onClose() - }} - onClick={(event) => { - if (event.target === event.currentTarget) onClose() - }} - > - {children} - - ) -} diff --git a/apps/extension/src/search-recovery-dialog.tsx b/apps/extension/src/search-recovery-dialog.tsx deleted file mode 100644 index d92dc791b7d..00000000000 --- a/apps/extension/src/search-recovery-dialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { - Chip, - ChipModalBody, - ChipModalDescription, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - ChipModalSurface, - Switch, -} from '@sim/emcn' -import { ExtensionDialog } from '@/extension-dialog' -import { - readSearchRecovery, - SEARCH_RECOVERY_KEY, - SEARCH_RECOVERY_PROVIDERS, - setSearchRecovery, -} from '@/search-recovery' - -interface SearchRecoveryDialogProps { - onClose: () => void -} - -type SearchProvider = (typeof SEARCH_RECOVERY_PROVIDERS)[number] -type SearchRecovery = Awaited> - -export function SearchRecoveryDialog({ onClose }: SearchRecoveryDialogProps) { - const generation = useRef(0) - const [settings, setSettings] = useState() - const [busyProvider, setBusyProvider] = useState(null) - const [notices, setNotices] = useState>>({}) - const [loadError, setLoadError] = useState(false) - - const refresh = useCallback(async () => { - const current = ++generation.current - try { - const next = await readSearchRecovery() - if (current === generation.current) { - setSettings(next) - setLoadError(false) - } - } catch { - if (current === generation.current) setLoadError(true) - } - }, []) - - useEffect(() => { - const update = () => void refresh() - const stored = (changes: Record, area: string) => { - if (area === 'local' && SEARCH_RECOVERY_KEY in changes) update() - } - update() - chrome.permissions.onAdded.addListener(update) - chrome.permissions.onRemoved.addListener(update) - chrome.storage.onChanged.addListener(stored) - return () => { - generation.current++ - chrome.permissions.onAdded.removeListener(update) - chrome.permissions.onRemoved.removeListener(update) - chrome.storage.onChanged.removeListener(stored) - } - }, [refresh]) - - async function changeProvider(provider: SearchProvider, enabled: boolean) { - if (!settings || busyProvider || loadError) return - setBusyProvider(provider.id) - setNotices((previous) => ({ ...previous, [provider.id]: '' })) - try { - if (enabled && !(await chrome.permissions.request({ origins: [...provider.origins] }))) { - setNotices((previous) => ({ - ...previous, - [provider.id]: 'Allow site access to turn on shortcuts.', - })) - return - } - await setSearchRecovery(provider.id, enabled) - await refresh() - } catch { - setNotices((previous) => ({ - ...previous, - [provider.id]: 'Could not change this shortcut. Try again.', - })) - await refresh() - } finally { - setBusyProvider(null) - } - } - - return ( - - - - Browser shortcuts - - - - If Chrome searches for a go/ or sim/ link instead of opening it, open the link anyway. - Other searches stay unchanged. - - {SEARCH_RECOVERY_PROVIDERS.map((provider) => ( - void changeProvider(provider, enabled)} - /> - } - submitOnEnter={false} - > - {notices[provider.id] && ( -

- {notices[provider.id]} -

- )} -
- ))} -

- Turning shortcuts off keeps browser site access. -

- {loadError && ( -
-

- Could not load browser shortcuts. -

- void refresh()}>Retry -
- )} -
- -
-
- ) -} diff --git a/apps/extension/src/search-recovery.ts b/apps/extension/src/search-recovery.ts deleted file mode 100644 index 1c43d3568c1..00000000000 --- a/apps/extension/src/search-recovery.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { SIM_LINK_HOST_PATTERN } from '@sim/browser-extension-protocol' -import { createLogger } from '@sim/logger' -import { isRecordLike } from '@sim/utils/object' -import { escapeRegExp } from '@sim/utils/string' -import { SIM_ORIGIN } from '@/config' - -export const SEARCH_RECOVERY_KEY = 'searchRecovery' -export const SEARCH_RECOVERY_PROVIDERS = [ - { - id: 'google', - label: 'Google', - origins: ['https://www.google.com/*', 'https://google.com/*'], - path: '/search', - parameter: 'q', - ruleId: 101, - }, - { - id: 'bing', - label: 'Bing', - origins: ['https://www.bing.com/*', 'https://bing.com/*'], - path: '/search', - parameter: 'q', - ruleId: 102, - }, - { - id: 'duckduckgo', - label: 'DuckDuckGo', - origins: ['https://duckduckgo.com/*', 'https://www.duckduckgo.com/*'], - path: '/', - parameter: 'q', - ruleId: 103, - }, - { - id: 'yahoo', - label: 'Yahoo', - origins: ['https://search.yahoo.com/*'], - path: '/search', - parameter: 'p', - ruleId: 104, - }, -] as const - -type SearchRecoveryProvider = (typeof SEARCH_RECOVERY_PROVIDERS)[number] -type ProviderId = (typeof SEARCH_RECOVERY_PROVIDERS)[number]['id'] -type RecoveryPreferences = Record -const logger = createLogger('BrowserShortcutRecovery') -const MESSAGE_TYPE = 'set-search-recovery' - -/** Search text is never observed in JavaScript; Chrome matches only explicit shortcut requests. */ -function recoveryRules( - provider: (typeof SEARCH_RECOVERY_PROVIDERS)[number] -): chrome.declarativeNetRequest.Rule[] { - const hosts = provider.origins.map((origin) => escapeRegExp(new URL(origin).host)).join('|') - const character = '(?:[a-z0-9._~:@/-]|%(?:2[6def]|3[0-9a]|4[0-9a-f]|5[0-9af]|6[1-9a-f]|7[0-9ae]))' - const shortcut = `(?:${SIM_LINK_HOST_PATTERN})(?:/|%2f)${character}+` - const condition = { - isUrlFilterCaseSensitive: false, - requestDomains: provider.origins.map((origin) => new URL(origin).hostname), - resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], - requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET], - } - // Separate ambiguity guards keep the redirect below Chrome's compiled-regex memory limit. - return [ - { - id: provider.ruleId + 100, - priority: 2, - action: { type: chrome.declarativeNetRequest.RuleActionType.ALLOW }, - condition: { - ...condition, - regexFilter: `[?&]${provider.parameter}=.*&${provider.parameter}=`, - }, - }, - { - id: provider.ruleId + 200, - priority: 2, - action: { type: chrome.declarativeNetRequest.RuleActionType.ALLOW }, - condition: { ...condition, regexFilter: '[?&][^=&]*%[^=&]*=' }, - }, - { - id: provider.ruleId, - priority: 1, - action: { - type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, - redirect: { regexSubstitution: `${SIM_ORIGIN}/sim-links/recover?q=\\1` }, - }, - condition: { - ...condition, - regexFilter: `^https://(?:${hosts})${escapeRegExp(provider.path)}\\?(?:[^#]*&)?${provider.parameter}=(${shortcut})(?:&[^#]*)?$`, - }, - }, - ] -} - -/** A remembered choice is effective only while Chrome still grants that provider's sites. */ -export async function readSearchRecovery(): Promise { - const stored = (await chrome.storage.local.get(SEARCH_RECOVERY_KEY))[SEARCH_RECOVERY_KEY] - const preferences: RecoveryPreferences = { - google: false, - bing: false, - duckduckgo: false, - yahoo: false, - } - for (const provider of SEARCH_RECOVERY_PROVIDERS) { - preferences[provider.id] = - isRecordLike(stored) && - stored[provider.id] === true && - (await chrome.permissions.contains({ origins: [...provider.origins] })) - } - return preferences -} - -/** The worker serializes settings and rule updates across every open side panel. */ -export async function setSearchRecovery(provider: ProviderId, enabled: boolean): Promise { - const response: unknown = await chrome.runtime.sendMessage({ - type: MESSAGE_TYPE, - provider, - enabled, - }) - if (!isRecordLike(response) || response.ok !== true) - throw new Error('Could not update browser shortcuts.') -} - -/** Reconcile persisted, permission-bound rules at startup, on revocation, and after an explicit choice. */ -export function registerSearchRecovery() { - let pending = Promise.resolve() - const update = (choice?: { provider: SearchRecoveryProvider; enabled: boolean }) => { - const operation = pending.then(async () => { - const preferences = await readSearchRecovery() - if (choice) { - if ( - choice.enabled && - !(await chrome.permissions.contains({ origins: [...choice.provider.origins] })) - ) - throw new Error('Search site access has not been granted.') - preferences[choice.provider.id] = choice.enabled - } - const rules = SEARCH_RECOVERY_PROVIDERS.filter( - (provider) => preferences[provider.id] - ).flatMap(recoveryRules) - for (const rule of rules) { - const supported = await chrome.declarativeNetRequest.isRegexSupported({ - regex: rule.condition.regexFilter ?? '', - isCaseSensitive: false, - requireCapturing: true, - }) - if (!supported.isSupported) throw new Error('Chrome cannot apply shortcut recovery.') - } - const ruleIds = SEARCH_RECOVERY_PROVIDERS.flatMap((provider) => [ - provider.ruleId, - provider.ruleId + 100, - provider.ruleId + 200, - ]) - const previousRules = (await chrome.declarativeNetRequest.getDynamicRules()).filter((rule) => - ruleIds.includes(rule.id) - ) - await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: ruleIds, - addRules: rules, - }) - try { - await chrome.storage.local.set({ [SEARCH_RECOVERY_KEY]: preferences }) - } catch (error) { - await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: ruleIds, - addRules: previousRules, - }) - throw error - } - }) - pending = operation.catch(() => logger.warn('Could not update browser shortcut recovery')) - return operation - } - const reconcile = () => { - void update().catch(() => {}) - } - chrome.runtime.onInstalled.addListener(reconcile) - chrome.runtime.onStartup.addListener(reconcile) - chrome.permissions.onRemoved.addListener(reconcile) - chrome.runtime.onMessage.addListener((message: unknown, sender, respond) => { - if ( - sender.id !== chrome.runtime.id || - !sender.url?.startsWith(chrome.runtime.getURL('')) || - !isRecordLike(message) || - message.type !== MESSAGE_TYPE || - typeof message.enabled !== 'boolean' - ) - return - const provider = SEARCH_RECOVERY_PROVIDERS.find((provider) => provider.id === message.provider) - if (!provider) return - void update({ provider, enabled: message.enabled }).then( - () => respond({ ok: true }), - () => respond({ ok: false }) - ) - return true - }) -} diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index 36d97101d8d..9c5b764f80d 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -22,7 +22,6 @@ import { } from '@/config' import { type CaptureIntent, captureIntentSchema } from '@/intent' import { createReferenceCapture, PAGE_ATTACHMENT_PENDING_MESSAGE } from '@/reference' -import { SearchRecoveryDialog } from '@/search-recovery-dialog' import { PageAccessButton, SiteAccessDialog } from '@/site-access' import '@/shell.css' @@ -49,7 +48,6 @@ function SidePanel({ origin }: SidePanelProps) { const intentCutoffRef = useRef(0) const busyRef = useRef(false) const [accessOpen, setAccessOpen] = useState(false) - const [shortcutsOpen, setShortcutsOpen] = useState(false) const [status, setStatus] = useState('') const referenceRef = useRef | null>(null) @@ -115,7 +113,6 @@ function SidePanel({ origin }: SidePanelProps) { setStatus((status) => (typeof status === 'string' ? status : '')) intentCutoffRef.current = Date.now() setAccessOpen(false) - setShortcutsOpen(false) if (pendingRef.current || busyRef.current) setStatus('The chat changed. Attach the page again.') captureGenerationRef.current++ pendingRef.current = null @@ -316,13 +313,6 @@ function SidePanel({ origin }: SidePanelProps) { sessionId, } frameRef.current?.contentWindow?.postMessage(connected, SIM_ORIGIN) - const shortcuts: ExtensionToAppMessage = { - channel: 'sim-extension', - version: 1, - type: 'shortcuts-available', - sessionId, - } - frameRef.current?.contentWindow?.postMessage(shortcuts, SIM_ORIGIN) deliver() if (!pendingRef.current) deliverFocus() } @@ -352,7 +342,6 @@ function SidePanel({ origin }: SidePanelProps) { } else if (parsed.data.type === 'action') { if (!navigator.userActivation.isActive) return if (parsed.data.action === 'site-access') setAccessOpen(true) - else if (parsed.data.action === 'browser-shortcuts') setShortcutsOpen(true) else if (parsed.data.action === 'microphone-settings') void chrome.tabs.create({ url: 'chrome://settings/content/microphone' }) else void capture(parsed.data.action === 'capture-page' ? 'page' : 'selection') @@ -436,7 +425,6 @@ function SidePanel({ origin }: SidePanelProps) { }} /> )} - {shortcutsOpen && setShortcutsOpen(false)} />} ) } diff --git a/apps/extension/src/site-access.tsx b/apps/extension/src/site-access.tsx index 73c8a45ba68..0ee685ade23 100644 --- a/apps/extension/src/site-access.tsx +++ b/apps/extension/src/site-access.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn' import { PAGE_ACCESS_ORIGINS } from '@/config' -import { ExtensionDialog } from '@/extension-dialog' interface PageAccessButtonProps { allowed?: boolean @@ -54,6 +53,13 @@ interface SiteAccessDialogProps { onAccessGranted: () => void } +/** Native modality avoids the inline scroll-lock stylesheet blocked by the extension CSP. */ +function mountAccessDialog(dialog: HTMLDialogElement | null) { + if (!dialog) return + dialog.showModal() + return () => dialog.close() +} + export function SiteAccessDialog({ onClose, onAccessGranted }: SiteAccessDialogProps) { const [allowed, setAllowed] = useState() @@ -81,7 +87,18 @@ export function SiteAccessDialog({ onClose, onAccessGranted }: SiteAccessDialogP }, []) return ( - + { + event.preventDefault() + onClose() + }} + onClick={(event) => { + if (event.target === event.currentTarget) onClose() + }} + > Page access @@ -95,6 +112,6 @@ export function SiteAccessDialog({ onClose, onAccessGranted }: SiteAccessDialogP - + ) } diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index 50767ef6118..1fded4b24d3 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -848,7 +848,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'When Links are available for Your organization, the extension opens sim/ and go/ about once a week in background tabs that close themselves, so Chrome recognizes these shortcuts in the address bar. Those tabs load Sim’s link page with Your session, send no browsing data, and appear in Your browser history. Browser shortcuts can optionally recover an explicit sim/ or go/ shortcut that Chrome sends to a supported search engine. After You enable a provider and approve access to its listed sites, Chrome applies request rules that send only the matching shortcut name and path values to Sim’s normal link resolver. Other search parameters and ordinary search terms are not sent to Sim by this feature. The extension stores Your provider choices locally and does not change Your default search engine. These site permissions can also allow page attachment when You explicitly request it; enabling shortcut recovery does not enable Reference current page or background capture. Turning recovery off stops these redirects. Site access remains until You revoke or restrict it in Chrome settings.' + 'When Links are available for Your organization, the extension opens sim/ and go/ about once a week in background tabs that close themselves, so Chrome recognizes these shortcuts in the address bar. Those tabs load Sim’s link page with Your session, send no browsing data, and appear in Your browser history.' ), }, { diff --git a/apps/sim/app/extension/hooks/use-composer.tsx b/apps/sim/app/extension/hooks/use-composer.tsx index 68a98bade55..18c58fc8b12 100644 --- a/apps/sim/app/extension/hooks/use-composer.tsx +++ b/apps/sim/app/extension/hooks/use-composer.tsx @@ -26,7 +26,6 @@ export function useExtensionComposer(scopeKey: string) { clearSnapshots, removeSnapshot, connected, - shortcutsAvailable, requestAction, referenceEnabled, setReferenceEnabled, @@ -70,15 +69,9 @@ export function useExtensionComposer(scopeKey: string) { Page access - {shortcutsAvailable && ( - requestAction('browser-shortcuts')}> - - Browser shortcuts - - )} ) : undefined, - [connected, requestAction, referenceEnabled, setReferenceEnabled, shortcutsAvailable] + [connected, requestAction, referenceEnabled, setReferenceEnabled] ) const prepareSubmission = useCallback( (text, contexts, send, reference = true) => { diff --git a/apps/sim/app/extension/hooks/use-page-context.ts b/apps/sim/app/extension/hooks/use-page-context.ts index a8c9c01eed1..a030ce27269 100644 --- a/apps/sim/app/extension/hooks/use-page-context.ts +++ b/apps/sim/app/extension/hooks/use-page-context.ts @@ -18,12 +18,10 @@ interface CaptureState { error?: string connected?: boolean referenceEnabled?: boolean - shortcutsAvailable?: boolean } type CaptureAction = | { type: 'connected'; scopeKey: string } - | { type: 'shortcuts'; scopeKey: string } | { type: 'preference'; enabled: boolean; scopeKey: string } | { type: 'receive'; snapshot: PageSnapshot; scopeKey: string } | { type: 'scope'; scopeKey: string } @@ -42,7 +40,6 @@ function reduceCaptures(state: CaptureState, action: CaptureAction): CaptureStat if (action.scopeKey !== state.scopeKey) return state if (action.type === 'preference') return { ...state, referenceEnabled: action.enabled } if (action.type === 'connected') return { ...state, connected: true } - if (action.type === 'shortcuts') return { ...state, shortcutsAvailable: true } if (state.snapshots.some((snapshot) => snapshot.id === action.snapshot.id)) return state const snapshots = [...state.snapshots, action.snapshot] const error = getPageContextBudgetError( @@ -110,10 +107,6 @@ export function usePageContext(origins: string[], scopeKey: string) { } return } - if (parsed.data.type === 'shortcuts-available') { - if (bridgeRef.current?.sessionId === sessionId) dispatch({ type: 'shortcuts', scopeKey }) - return - } if (parsed.data.type === 'reference-preference') { dispatch({ type: 'preference', enabled: parsed.data.enabled, scopeKey }) if (!parsed.data.enabled && pending.current?.sessionId === sessionId) { @@ -247,7 +240,6 @@ export function usePageContext(origins: string[], scopeKey: string) { setReferenceEnabled, captureForSend, connected: Boolean(state.connected), - shortcutsAvailable: Boolean(state.shortcutsAvailable), requestAction, snapshots: state.snapshots, captureError: state.error, diff --git a/apps/sim/app/sim-links/recover/route.ts b/apps/sim/app/sim-links/recover/route.ts index 89b43f25fa3..83468b617ef 100644 --- a/apps/sim/app/sim-links/recover/route.ts +++ b/apps/sim/app/sim-links/recover/route.ts @@ -11,6 +11,7 @@ const RESPONSE_HEADERS = { } as const /** + * Search redirect rules from extension versions before 0.1.5 land here until those versions update. * Next normalizes query spelling; the handoff still accepts only one q value. Rejecting other * parameters and surviving percent escapes prevents ambiguous or repeatedly decoded shortcuts. */ diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts index 6b16b9c0fdc..df0798afd6a 100644 --- a/packages/browser-extension-protocol/src/index.ts +++ b/packages/browser-extension-protocol/src/index.ts @@ -47,7 +47,6 @@ const extensionActionSchema = z.enum([ 'capture-page', 'capture-selection', 'site-access', - 'browser-shortcuts', 'microphone-settings', ]) export type ExtensionAction = z.infer @@ -88,7 +87,6 @@ export type AppToExtensionMessage = z.infer export const extensionToAppMessageSchema = z.discriminatedUnion('type', [ z.strictObject({ ...envelope, type: z.literal('focus-composer'), sessionId: z.uuid() }), z.strictObject({ ...envelope, type: z.literal('connected'), sessionId: z.uuid() }), - z.strictObject({ ...envelope, type: z.literal('shortcuts-available'), sessionId: z.uuid() }), z.strictObject({ ...envelope, type: z.literal('reference-preference'), From 7dd20ad190b427951516146795fd27e0e4d8c300 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:57:24 -0800 Subject: [PATCH 7/8] improvement(links): remove the search recovery and organization link routes Links has few users so far, so nothing needs to keep working from earlier versions: no bookmarks export remains to serve, and no recovery rules to catch. --- apps/extension/src/background.ts | 6 -- .../[organizationId]/[name]/page.tsx | 51 ---------- apps/sim/app/sim-links/recover/route.ts | 69 -------------- apps/sim/lib/navigation/paths.ts | 2 - apps/sim/lib/sim-links/validation.ts | 4 +- apps/sim/scripts/test-sim-links-e2e.ts | 95 ------------------- 6 files changed, 2 insertions(+), 225 deletions(-) delete mode 100644 apps/sim/app/sim-links/[organizationId]/[name]/page.tsx delete mode 100644 apps/sim/app/sim-links/recover/route.ts diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 43769fd563c..a21909266b0 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -34,12 +34,6 @@ async function configure() { documentUrlPatterns: ['http://*/*', 'https://*/*'], targetUrlPatterns: ['http://*/*', 'https://*/*'], }) - // Earlier versions could add search redirect rules, which an update must not leave running. - const staleRules = await chrome.declarativeNetRequest.getDynamicRules() - await chrome.declarativeNetRequest.updateDynamicRules({ - removeRuleIds: staleRules.map((rule) => rule.id), - }) - await chrome.storage.local.remove('searchRecovery') } function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') { diff --git a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx b/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx deleted file mode 100644 index f1245dda8ed..00000000000 --- a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ChipLink, StatusPageContent } from '@sim/emcn' -import { notFound, redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { organizationRoutes } from '@/lib/navigation/paths' -import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' -import { resolveSimLink } from '@/lib/sim-links/application/use-cases' -import { simLinkNameSchema } from '@/lib/sim-links/validation' -import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' - -interface OrganizationLinkPageProps { - params: Promise<{ organizationId: string; name: string }> -} - -/** Bookmarks stay in their exporting organization even after the browser's active team changes. */ -export default async function OrganizationLinkPage({ params }: OrganizationLinkPageProps) { - const { organizationId, name: encodedName } = await params - const name = decodeURIComponent(encodedName) - if (!simLinkNameSchema.safeParse(name).success) notFound() - const routes = organizationRoutes(organizationId) - const session = await getSession() - if (!session?.user) - redirect( - buildAuthCrossLink('/login', { - callbackUrl: routes.openSimLink(name), - isInviteFlow: false, - }) - ) - const principal = { - kind: 'session', - userId: session.user.id, - sessionId: session.session.id, - } as const - if ((await resolveSimLinkOrganization(principal)) !== organizationId) notFound() - const result = await resolveSimLink.execute({ - principal, - input: { organizationId, name, values: [], query: '' }, - }) - if (result.kind === 'redirect') redirect(result.url) - return ( - - Open Links - - ) -} diff --git a/apps/sim/app/sim-links/recover/route.ts b/apps/sim/app/sim-links/recover/route.ts deleted file mode 100644 index 83468b617ef..00000000000 --- a/apps/sim/app/sim-links/recover/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { SIM_LINK_NAME_MAX_LENGTH } from '@sim/browser-extension-protocol' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SIM_LINK_NAME_PATTERN, stripSimLinkPrefix } from '@/lib/sim-links/validation' - -const MAX_VALUES = 8 -const MAX_VALUE_LENGTH = 256 -const MAX_DECODED_LENGTH = 5 + SIM_LINK_NAME_MAX_LENGTH + MAX_VALUES * (MAX_VALUE_LENGTH + 1) -const RESPONSE_HEADERS = { - 'Cache-Control': 'no-store', - 'Referrer-Policy': 'no-referrer', -} as const - -/** - * Search redirect rules from extension versions before 0.1.5 land here until those versions update. - * Next normalizes query spelling; the handoff still accepts only one q value. Rejecting other - * parameters and surviving percent escapes prevents ambiguous or repeatedly decoded shortcuts. - */ -function recoverSimLinkPath(search: string): string | null { - if ( - !search.startsWith('?q=') || - search.length > 3 + MAX_DECODED_LENGTH * 3 || - search.includes('&') - ) { - return null - } - - let shortcut: string - try { - shortcut = decodeURIComponent(search.slice(3)) - } catch { - return null - } - const path = stripSimLinkPrefix(shortcut) - if (shortcut.length > MAX_DECODED_LENGTH || path === shortcut) return null - - const segments = path.replace(/\/$/, '').split('/') - const [name] = segments - if ( - !name || - name.length > SIM_LINK_NAME_MAX_LENGTH || - !/[A-Za-z0-9]/.test(name) || - segments.length > MAX_VALUES + 1 || - segments.some( - (segment) => - !SIM_LINK_NAME_PATTERN.test(segment) || - segment === '.' || - segment === '..' || - segment.length > MAX_VALUE_LENGTH - ) - ) { - return null - } - return `/sim/${segments.map(encodeURIComponent).join('/')}` -} - -/** - * Search recovery navigation: no page is rendered and the canonical Sim link route owns - * sign-in, current organization membership, feature availability, and destination resolution. - */ -export const GET = withRouteHandler((request) => { - const destination = recoverSimLinkPath(request.nextUrl.search) - if (!destination) { - return new Response('Invalid Sim shortcut.', { status: 400, headers: RESPONSE_HEADERS }) - } - return new Response(null, { - status: 307, - headers: { ...RESPONSE_HEADERS, Location: destination }, - }) -}) diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index 3c861a01742..18f18ae0afd 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -45,8 +45,6 @@ export function organizationRoutes(organizationId: string) { benchmark: `${root}/benchmark`, memberSecrets: `${root}/integrations/secrets`, simLinks: `${root}/links`, - openSimLink: (name: string) => - `/sim-links/${encodeURIComponent(organizationId)}/${encodeURIComponent(name)}`, people: `${root}/people`, person: (memberId: string) => `${root}/people/${encodeURIComponent(memberId)}`, organizationSecrets: `${root}/settings/integrations/secrets`, diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index ff2659a4359..34bef39bfc6 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -5,7 +5,7 @@ import { } from '@sim/browser-extension-protocol' import { z } from 'zod' -export const SIM_LINK_NAME_PATTERN = /^[A-Za-z0-9.:@&~_-]+$/ +const NAME_PATTERN = /^[A-Za-z0-9.:@&~_-]+$/ const NAME_PUNCTUATION = /[.:@&~_-]/g const NAME_DISALLOWED = /[^A-Za-z0-9.:@&~_-]/g export const SIM_LINK_DESCRIPTION_MAX_LENGTH = 500 @@ -122,7 +122,7 @@ export const simLinkNameSchema = z .trim() .min(1, 'Enter a name') .max(SIM_LINK_NAME_MAX_LENGTH, `Use at most ${SIM_LINK_NAME_MAX_LENGTH} characters`) - .regex(SIM_LINK_NAME_PATTERN, 'Use letters, numbers, and . : @ & ~ - _') + .regex(NAME_PATTERN, 'Use letters, numbers, and . : @ & ~ - _') .refine((name) => normalizeSimLinkName(name).length > 0, 'Include a letter or number') const destinationSchema = z diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts index a8153443419..feb3ecb8370 100644 --- a/apps/sim/scripts/test-sim-links-e2e.ts +++ b/apps/sim/scripts/test-sim-links-e2e.ts @@ -175,73 +175,6 @@ async function auditActions(orgId: string): Promise { } async function run() { - await check( - 'search recovery hands safe shortcuts to the canonical resolver without a page', - async () => { - const maximum = `sim/${'a'.repeat(64)}/${Array(8).fill('v'.repeat(256)).join('/')}` - const cases = [ - ['sim/bookface', '/sim/bookface'], - ['SIM/Book-Face/', '/sim/Book-Face'], - ['sim/a.:@&~_-', '/sim/a.%3A%40%26~_-'], - ['sim/jira/PROD-123', '/sim/jira/PROD-123'], - ['go/bookface', '/sim/bookface'], - ['GO/jira/PROD-123/', '/sim/jira/PROD-123'], - [`${maximum}/`, `/${maximum}`], - ] - for (const [shortcut, destination] of cases) { - const response = await send(`/sim-links/recover?q=${encodeURIComponent(shortcut)}`, null) - assert.equal(response.status, 307, shortcut) - assert.equal(response.headers.get('location'), destination) - assert.equal(response.headers.get('cache-control'), 'no-store') - assert.equal(response.headers.get('referrer-policy'), 'no-referrer') - assert.equal(await response.text(), '') - } - } - ) - - await check( - 'search recovery rejects ambiguous, escaping and oversized input without reflecting it', - async () => { - const invalidShortcuts = [ - 'sim/', - 'sim/...', - 'web/handbook', - 'https://evil.example.com', - 'sim/a/../private', - 'sim/a/./private', - 'sim/a//private', - 'sim/a/%2e%2e', - 'sim/a\\private', - 'sim/a?private=secret', - 'sim/a#private', - 'sim/a private', - `sim/${'a'.repeat(65)}`, - `sim/a/${'v'.repeat(257)}`, - `sim/a/${Array(9).fill('v').join('/')}`, - `sim/a/${'v'.repeat(6400)}`, - ] - const queries = [ - '', - '?q=', - '?q=sim%2Fa&q=sim%2Fb', - '?q=sim%2Fa&%71=sim%2Fb', - '?q=sim%2Fa&extra=private', - '?x=sim%2Fa', - '?q=sim%2Fa%ZZ', - '?q=sim%2Fa+private', - ...invalidShortcuts.map((shortcut) => `?q=${encodeURIComponent(shortcut)}`), - ] - for (const query of queries) { - const response = await send(`/sim-links/recover${query}`, null) - assert.equal(response.status, 400, query) - assert.equal(response.headers.get('location'), null) - assert.equal(response.headers.get('cache-control'), 'no-store') - assert.equal(response.headers.get('referrer-policy'), 'no-referrer') - assert.equal(await response.text(), 'Invalid Sim shortcut.') - } - } - ) - const orgId = await seedOrganization() const owner = await seedMember(orgId, 'owner') const member = await seedMember(orgId, 'member') @@ -405,34 +338,6 @@ async function run() { await api({ ...outsider, orgId }, '', { expected: 404 }) }) - await check( - 'organization links preserve encoded names and organization across sign-in', - async () => { - const destination = 'https://team.example.com/help' - for (const name of ['managed-help', 'managed:help@team&docs']) { - const path = `/sim-links/${encodeURIComponent(orgId)}/${encodeURIComponent(name)}` - await create(owner, { name, url: destination }) - await create(outsider, { name, url: 'https://another-team.example.com/help' }) - const signedOut = await send(path, null) - assert.equal(signedOut.status, 307, `${name}: expected sign-in redirect`) - const login = new URL(signedOut.headers.get('location') ?? '/', baseUrl) - assert.equal(login.pathname, '/login', name) - assert.equal(login.searchParams.get('callbackUrl'), path, name) - const opened = await send(path, member) - assert.equal(opened.status, 307, `${name}: expected destination redirect`) - assert.equal(opened.headers.get('location'), destination, name) - const denied = await send(path, outsider) - assert.equal(denied.status, 404, name) - assert(!denied.headers.get('location'), name) - } - for (const encodedName of ['%256Danaged-help', 'managed%2Fhelp']) { - const denied = await send(`/sim-links/${encodeURIComponent(orgId)}/${encodedName}`, member) - assert.equal(denied.status, 404, encodedName) - assert(!denied.headers.get('location'), encodedName) - } - } - ) - await check('the address bar learns whether links are on and matches inside names', async () => { const suggest = (caller: Member | null, q: string) => send(`/api/sim-links/suggestions?q=${encodeURIComponent(q)}`, caller) From ed29306df934a213303ea1e7051ec9b6c60c74bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 19:08:47 -0800 Subject: [PATCH 8/8] improvement(extension): disclose the keyboard shortcut in the privacy policy --- apps/sim/app/(landing)/privacy/privacy-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index 1fded4b24d3..c90897a0c81 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -812,7 +812,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'When You ask Sim to attach a page or selection, or open Sim from its toolbar button with Reference current page off, the browser extension reads the requested text from the top frame of that page and transfers it, the page title, and a normalized page address to the hosted Sim composer. URL credentials, query parameters, and fragments are removed. The extractor skips form inputs, editable fields, scripts, embedded frames, and elements it detects as hidden. Page text, including text in collapsed sections, may still contain personal or sensitive information, such as personal communications or health, financial, or authentication information.' + 'When You ask Sim to attach a page or selection, or open Sim from its toolbar button or keyboard shortcut with Reference current page off, the browser extension reads the requested text from the top frame of that page and transfers it, the page title, and a normalized page address to the hosted Sim composer. URL credentials, query parameters, and fragments are removed. The extractor skips form inputs, editable fields, scripts, embedded frames, and elements it detects as hidden. Page text, including text in collapsed sections, may still contain personal or sensitive information, such as personal communications or health, financial, or authentication information.' ), }, {