Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/docs/content/docs/search/sim-links.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook`

## Open links from the address bar

Add Sim as a Chrome site search shortcut to type `sim handbook` instead:
With the Sim Chrome extension, type `sim/handbook` in the address bar. To find a link, type `sim`, press **Space**, and start typing its name: matching links appear as you type, most opened first.

Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead:

1. Open Chrome **Settings → Search engine → Manage search engines and site search**.
2. Next to **Site search**, select **Add**.
Expand All @@ -33,7 +35,7 @@ To share a link, right-click it and select **Copy link**.

## Add values to a link

Anything after the name is added to the destination: if `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing` opens `https://docs.example.com/billing`.
Anything after the name is added to the destination: if `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing` opens `https://docs.example.com/billing`. A query is passed along too, so `sim/docs?lang=fr` opens `https://docs.example.com/?lang=fr`.

To place a value elsewhere, put `{*}` in the destination URL:

Expand Down
1 change: 1 addition & 0 deletions apps/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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/*`.
- Typing `sim/<name>` in the address bar opens the organization's [Sim link](https://docs.sim.ai/search/sim-links). A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `<Sim origin>/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim.
- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the typed text, and nothing else, to `<Sim origin>/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested.
- Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites.
- 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.
Expand Down
1 change: 1 addition & 0 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@sim/emcn": "workspace:*",
"@sim/logger": "workspace:*",
"@sim/utils": "workspace:*",
"lru-cache": "11.5.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "4.3.6"
Expand Down
3 changes: 2 additions & 1 deletion apps/extension/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ await writeFile(
},
optional_host_permissions: ['https://*/*', 'http://*/*'],
background: { service_worker: 'background.js' },
omnibox: { keyword: 'sim' },
side_panel: { default_path: 'sidepanel.html' },
action: { default_title: 'Open Sim' },
icons: { 16: 'icon-16.png', 32: 'icon-32.png', 128: 'icon-128.png' },
Expand All @@ -135,7 +136,7 @@ await writeFile(
},
},
content_security_policy: {
extension_pages: `default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-src ${origin.origin}; connect-src 'none'; object-src 'none'; base-uri 'none'`,
extension_pages: `default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-src ${origin.origin}; connect-src ${origin.origin}; object-src 'none'; base-uri 'none'`,
},
incognito: 'not_allowed',
...(key ? { key } : {}),
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { CAPTURE_INTENT_KEY, captureIntentKey } from '@/config'
import { captureIntentSchema } from '@/intent'
import { registerSimLinkOmnibox } from '@/omnibox'
import { digestUrl } from '@/utils'

const logger = createLogger('BrowserExtension')
Expand Down Expand Up @@ -50,6 +51,7 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') {
.catch(() => logger.warn('Could not attach this page; the side panel remains available'))
}

registerSimLinkOmnibox()
chrome.runtime.onInstalled.addListener(() => {
void configure().catch(() => logger.warn('Could not initialize extension controls'))
})
Expand Down
124 changes: 124 additions & 0 deletions apps/extension/src/omnibox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
SIM_LINK_SUGGESTIONS_PATH,
simLinkSuggestionsSchema,
} from '@sim/browser-extension-protocol'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { LRUCache } from 'lru-cache'
import { SIM_ORIGIN } from '@/config'

const logger = createLogger('BrowserExtensionOmnibox')

/** Long enough to skip the requests a fast typist's intermediate keystrokes would make. */
const SUGGEST_DELAY_MS = 120

/** A Sim link as suggested: its name, and what it shows beside the name. */
interface SuggestedLink {
name: string
detail: string
}

/**
* What was typed after the `sim` keyword, split where the name ends: `sim/jira/ABC-1?tab=all`
* (the `sim/` is optional) has the name `jira` and the rest `/ABC-1?tab=all`. Only the space
* before the name and the prefix are dropped; values and the query stay exactly as typed.
*/
function readTyped(text: string): { name: string; rest: string } {
const typed = text.trimStart().replace(/^sim\//i, '')
const nameEnd = typed.search(/[/?]/)
return nameEnd === -1
? { name: typed, rest: '' }
: { name: typed.slice(0, nameEnd), rest: typed.slice(nameEnd) }
}

/** Suggestion text is XML, where these five characters are markup. */
function escapeXml(text: string): string {
return text.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`)
}

/**
* The resolver address for what was typed, as the address bar would send `sim/<typed>`: each
* `/` starts a value, kept in position even when empty, and a trailing `/` adds none.
*/
function simLinkUrl(text: string): string {
const { name, rest } = readTyped(text)
const queryStart = rest.indexOf('?')
const path = `${name}${queryStart === -1 ? rest : rest.slice(0, queryStart)}`.replace(/\/$/, '')
const query = queryStart === -1 ? '' : new URLSearchParams(rest.slice(queryStart + 1)).toString()
const segments = path ? path.split('/').map(encodeURIComponent) : []
return `${SIM_ORIGIN}/${['sim', ...segments].join('/')}${query ? `?${query}` : ''}`
}

async function fetchSuggestedLinks(name: string, signal: AbortSignal): Promise<SuggestedLink[]> {
const response = await fetch(
`${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}?${new URLSearchParams({ q: name })}`,
{ credentials: 'include', signal }
)
// Signed out, or Sim links are unavailable: the typed link still opens through the resolver.
if (!response.ok) return []
const { links } = simLinkSuggestionsSchema.parse(await response.json())
return links.map((link) => ({ name: link.name, detail: link.description ?? link.url }))
}

/** Each link as a suggestion that keeps whatever was typed after the name. */
function toSuggestions(
links: readonly SuggestedLink[],
rest: string
): chrome.omnibox.SuggestResult[] {
return links.map((link) => ({
content: `${link.name}${rest}`,
description: `<match>sim/${escapeXml(`${link.name}${rest}`)}</match> <dim>${escapeXml(link.detail)}</dim>`,
}))
}

/**
* The `sim` address-bar keyword: `sim`, then Space, lists matching Sim links as you type, and
* Enter opens what was typed through the resolver, which sends a missing link to its create form.
*/
export function registerSimLinkOmnibox() {
let pending: AbortController | null = null
/**
* Links by typed name, emptied as each keyword session starts, so editing values or retyping
* a name in one session asks Sim once per name.
*/
const linksByName = new LRUCache<string, SuggestedLink[]>({ max: 100 })

chrome.omnibox.onInputStarted.addListener(() => linksByName.clear())

chrome.omnibox.onInputChanged.addListener((text, suggest) => {
pending?.abort()
const { name, rest } = readTyped(text)
chrome.omnibox.setDefaultSuggestion({
description: `Open <match>sim/${escapeXml(`${name}${rest}`)}</match>`,
})
const key = name.toLowerCase()
const known = linksByName.get(key)
if (known) {
suggest(toSuggestions(known, rest))
return
}
const controller = new AbortController()
pending = controller
void (async () => {
await sleep(SUGGEST_DELAY_MS)
if (controller.signal.aborted) return
const links = await fetchSuggestedLinks(key, controller.signal)
linksByName.set(key, links)
if (!controller.signal.aborted) suggest(toSuggestions(links, rest))
})().catch(() => {
if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions')
})
})

chrome.omnibox.onInputCancelled.addListener(() => pending?.abort())

chrome.omnibox.onInputEntered.addListener((text, disposition) => {
pending?.abort()
const url = simLinkUrl(text)
const opened =
disposition === 'currentTab'
? chrome.tabs.update({ url })
: chrome.tabs.create({ url, active: disposition === 'newForegroundTab' })
void opened.catch(() => logger.warn('Could not open the Sim link'))
})
}
24 changes: 24 additions & 0 deletions apps/sim/app/api/sim-links/suggestions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { suggestSimLinksContract } from '@/lib/api/contracts/sim-links'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { suggestSimLinks } from '@/lib/sim-links/application/use-cases'

export const GET = defineInternalJsonRoute({
contract: suggestSimLinksContract,
operation: suggestSimLinks.operation,
useCase: suggestSimLinks,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({
bucketName: 'sim-link-suggestions',
// The address bar asks again as each character is typed.
config: { maxTokens: 120, refillRate: 120, refillIntervalMs: 60_000 },
}),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: ({ query }) => ({ query: query.q }),
present: (links) => ({ links }),
})
42 changes: 26 additions & 16 deletions apps/sim/app/sim/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import { ChipLink, StatusPageContent } from '@sim/emcn'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths'
import { resolveOrganizationLanding } from '@/lib/organizations/surface'
import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
import { simLinkNameSchema } from '@/lib/sim-links/validation'
import { parseTypedSimLink, 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'
Expand All @@ -19,6 +17,7 @@ export const metadata: Metadata = {

interface SimLinkPageProps {
params: Promise<{ path?: string[] }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}

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

/** The typed `?…` query, rebuilt from Next's parsed params, which group repeated keys. */
function toQueryString(searchParams: Record<string, string | string[] | undefined>): string {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(searchParams)) {
for (const item of [value ?? []].flat()) query.append(key, item)
}
return query.toString()
}

/**
* Why a link did not redirect. Only a valid link name is echoed back, so an arbitrary path
* can't put its own text on this page.
Expand All @@ -47,32 +55,34 @@ function describeUnresolved(result: SimLinkResolution, validName: boolean, name:
}

/**
* Where `sim/<name>/<values…>` lands: the viewer's organization link's destination,
* Where `sim/<name>/<values…>?<query>` lands: the viewer's organization link's destination,
* or a page that offers to create the link when nobody has yet.
*/
export default async function SimLinkPage({ params }: SimLinkPageProps) {
const { path = [] } = await params
export default async function SimLinkPage({ params, searchParams }: SimLinkPageProps) {
const [{ path = [] }, query] = await Promise.all([params, searchParams.then(toQueryString)])
const segments = decodeSimLinkPath(path)
const session = await getSession()
if (!session?.user)
redirect(
buildAuthCrossLink('/login', {
callbackUrl: simLinkPath(...segments),
callbackUrl: `${simLinkPath(...segments)}${query ? `?${query}` : ''}`,
isInviteFlow: false,
})
)
const organizationId = await resolveOrganizationLanding(
session.user.id,
getActiveOrganizationId(session)
)
if (!organizationId || !(await isSimLinksEnabled(organizationId))) notFound()
const principal = {
kind: 'session',
userId: session.user.id,
sessionId: session.session.id,
} as const
const organizationId = await resolveSimLinkOrganization(principal)
if (!organizationId) notFound()
const library = organizationRoutes(organizationId).simLinks
const [name, ...values] = segments
const { name, values } = parseTypedSimLink(segments)
if (!name) redirect(library)

const result = await resolveSimLink.execute({
principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
input: { organizationId, name, values },
principal,
input: { organizationId, name, values, query },
})
if (result.kind === 'redirect') redirect(result.url)

Expand Down
12 changes: 12 additions & 0 deletions apps/sim/lib/api/contracts/sim-links.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
SIM_LINK_SUGGESTIONS_PATH,
simLinkSuggestionsSchema,
} from '@sim/browser-extension-protocol'
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts'
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
Expand Down Expand Up @@ -74,3 +78,11 @@ export const deleteSimLinkContract = defineRouteContract({
params: linkParams,
response: { mode: 'json', schema: z.object({ success: z.literal(true) }) },
})

/** The browser extension's address-bar suggestions, from the organization `sim/` opens in. */
export const suggestSimLinksContract = defineRouteContract({
method: 'GET',
path: SIM_LINK_SUGGESTIONS_PATH,
query: z.object({ q: z.string().max(200).default('') }),
response: { mode: 'json', schema: simLinkSuggestionsSchema },
})
21 changes: 19 additions & 2 deletions apps/sim/lib/organizations/surface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { cache } from 'react'
import type { SessionPrincipal } from '@sim/auth/principal'
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { asc, count, eq } from 'drizzle-orm'
import { member, organization, session } from '@sim/db/schema'
import { and, asc, count, eq } from 'drizzle-orm'
import {
getOrganizationSettingsFeatures,
type OrganizationSettingsFeatures,
Expand Down Expand Up @@ -172,3 +173,19 @@ export async function resolveOrganizationLanding(
memberships.some((row) => row.organizationId === activeOrganizationId)
return isActiveMember ? activeOrganizationId : memberships[0].organizationId
}

/**
* {@link resolveOrganizationLanding} for a session known only by its principal, reading the
* active organization stored on the session, so an API call lands where a page load would.
*/
export async function resolveSessionOrganizationLanding({
userId,
sessionId,
}: SessionPrincipal): Promise<string | null> {
const [row] = await db
.select({ activeOrganizationId: session.activeOrganizationId })
.from(session)
.where(and(eq(session.id, sessionId), eq(session.userId, userId)))
.limit(1)
return resolveOrganizationLanding(userId, row?.activeOrganizationId ?? null)
}
8 changes: 8 additions & 0 deletions apps/sim/lib/sim-links/application/operations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defineOperation } from '@/lib/core/application'
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'

const policy = { minimumRole: 'member', principalKinds: ['session'] } as const
Expand Down Expand Up @@ -34,4 +35,11 @@ export const simLinkOperations = {
...policy,
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({
id: 'sim_links.suggest',
principalKinds: ['session'],
capability: 'none',
}),
} as const
15 changes: 15 additions & 0 deletions apps/sim/lib/sim-links/application/organization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { SessionPrincipal } from '@sim/auth/principal'
import { resolveSessionOrganizationLanding } from '@/lib/organizations/surface'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'

/**
* The organization whose links `sim/` opens for this session: where the session lands, or `null`
* when it belongs to none or Sim links are off there. Following a link and suggesting one both
* read this, so a suggestion always resolves where it is followed.
*/
export async function resolveSimLinkOrganization(
principal: SessionPrincipal
): Promise<string | null> {
const organizationId = await resolveSessionOrganizationLanding(principal)
return organizationId && (await isSimLinksEnabled(organizationId)) ? organizationId : null
}
Loading
Loading