diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json index 7702e85f480..66eea5c9a92 100644 --- a/apps/docs/content/docs/search/meta.json +++ b/apps/docs/content/docs/search/meta.json @@ -6,7 +6,6 @@ "find-content", "ask-questions", "sim-links", - "people", "troubleshooting", "---Connect sources---", "connect-your-account", diff --git a/apps/docs/content/docs/search/people.mdx b/apps/docs/content/docs/search/people.mdx deleted file mode 100644 index e4a168f1798..00000000000 --- a/apps/docs/content/docs/search/people.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: People -description: Your organization's people, with titles, departments, locations, and who reports to whom ---- - -**People** in the organization sidebar lists everyone active in your organization. Search by name, email, title, department, or location, or filter by department. Select a person to see their profile, the managers above them, and the people who report to them. - -Organization admins keep this information up to date by importing it from your HR system or editing a profile. - -## Import people data - -1. Export your people from your HR system as CSV or Excel. -2. Open **People** and select **Import**, then choose the file. -3. Review the preview, then select **Import**. - -Each row is matched to a member by email. The file needs an `email` column and any of: - -| Column | Also recognized as | -| --- | --- | -| `title` | `job title`, `position` | -| `department` | `dept` | -| `location` | `office`; or `city`, `state`, and `country`, which are combined | -| `start_date` | `hire date`. Use `2024-01-31` or `1/31/2024`. | -| `manager_email` | `manager`, `reports to`, `supervisor`. Use the manager's email. | - -**Download template** in the import dialog gives a file with these columns. - -- A column the file includes replaces that field for every listed person; a blank cell clears it. A column the file leaves out is not changed. -- Rows are skipped when the person isn't a member, the email or date is invalid, a title, department, or location is over 200 characters, the person is named as their own manager, or an earlier row already updated the same person. The preview lists the first 200 issues with their row numbers. -- A manager who isn't a member, or who would make a reporting loop, is left as it was, and the preview says so. -- Nothing changes until you confirm the preview. Import up to 10,000 rows at a time. - -## Edit a profile - -Organization admins can open a person and select **Edit** to change their title, department, location, start date, or manager. - -When someone leaves your organization, their profile is removed, and anyone who reported to them no longer has a manager recorded. - -Imports and edits are recorded in your organization's [audit logs](/platform/enterprise/audit-logs). - -## Related docs - -- [Sim Links](/search/sim-links) diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index bf26c207846..f45b2fd980b 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -5,7 +5,7 @@ description: Short sim/ links that take everyone in your organization to the rig Sim Links are short names for pages your team opens often, like `sim/handbook` or `sim/okta`. Every member of your organization can use them, with or without a Search seat. -Open **Sim Links** in the organization sidebar to see your organization's links. Search by name, destination, or description, or use **Sort → Opens** to see the most used links. +Open **Sim Links** in the organization sidebar to see your organization's links. Search by name, destination, or description, or use **Sort → Opens** to see the 50 most opened links. ## Open a link @@ -15,7 +15,7 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -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. +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. Unlisted links aren't suggested. Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead: @@ -52,7 +52,7 @@ Values fill each `{*}` in order, and the last `{*}` takes the rest. A link with By default, any member can edit or delete a link. The link's creator and organization admins can change two options under **Access**: - **Hide from the Sim Links page**: only the creator and admins see the link in the list, marked **Unlisted**. Anyone with the name can still open it. -- **Only you and admins can edit**: only the creator and admins can edit or delete the link, marked **Restricted**. +- **Only the creator and admins can edit**: only the creator and admins can edit or delete the link, marked **Restricted**. Changes to links are recorded in your organization's [audit logs](/platform/enterprise/audit-logs). diff --git a/apps/extension/DESIGN.md b/apps/extension/DESIGN.md index 86bfc5c55b2..be04a71d45d 100644 --- a/apps/extension/DESIGN.md +++ b/apps/extension/DESIGN.md @@ -60,8 +60,14 @@ other workspace resources or browser-control tools. - Other HTTP(S) origins are optional grants requested for the current site from a user gesture. Each grant can be revoked. Keeping the panel open is not a grant to read every subsequently selected tab. -- No history, bookmarks, cookie API, debugger, network interception, native - messaging, external messaging, or arbitrary remote code execution. +- No history, bookmarks, cookie API, debugger, native messaging, external messaging, + or arbitrary remote code execution. The only network interception is the static + `sim/` redirect rule above. +- The only background network request 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 + content is never sent, and unlisted links are never suggested. - No always-running page reader. Extraction is tied to an explicit attachment action. - Page bytes are not written to extension local/sync storage. Temporary context-menu handoff uses trusted-context session storage, expiration and navigation cleanup. diff --git a/apps/extension/README.md b/apps/extension/README.md index b9ba156c936..93a7729c8b8 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -27,7 +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/` 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/*` 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 `/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. +- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the text typed before the first `/` or `?`, and nothing else, unless that text contains a space (a search) or is longer than a link name can be, to `/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. diff --git a/apps/extension/package.json b/apps/extension/package.json index 0b9cab24e1d..e92c11c5047 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@sim/extension", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/apps/extension/scripts/build.ts b/apps/extension/scripts/build.ts index 0f6524e7cf6..626c4fc7b7f 100644 --- a/apps/extension/scripts/build.ts +++ b/apps/extension/scripts/build.ts @@ -107,9 +107,9 @@ await writeFile( { manifest_version: 3, name: development ? 'Sim (Development)' : 'Sim', - version: '0.1.1', + version: '0.1.2', description: - 'Ask Sim about the page you are reading, with your existing Sim conversations and tools.', + "Ask Sim about the page you are reading, and open your organization's sim/ links from the address bar.", minimum_chrome_version: '120', permissions: [ 'activeTab', diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts index 52c1095b0c4..56c2e39be62 100644 --- a/apps/extension/src/omnibox.ts +++ b/apps/extension/src/omnibox.ts @@ -1,4 +1,5 @@ import { + SIM_LINK_NAME_MAX_LENGTH, SIM_LINK_SUGGESTIONS_PATH, simLinkSuggestionsSchema, } from '@sim/browser-extension-protocol' @@ -9,8 +10,8 @@ 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 +/** Longer than the gap between a typist's keystrokes, so a name is looked up once it settles. */ +const SUGGEST_DELAY_MS = 220 /** A Sim link as suggested: its name, and what it shows beside the name. */ interface SuggestedLink { @@ -20,42 +21,68 @@ interface SuggestedLink { /** * 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. + * (the `sim/` is optional) has the name `jira` and the rest `/ABC-1?tab=all`. Surrounding spaces, + * the prefix, leading slashes, and any `#fragment` are dropped, as the address bar would; values + * and the query otherwise stay exactly as typed. */ function readTyped(text: string): { name: string; rest: string } { - const typed = text.trimStart().replace(/^sim\//i, '') + const typed = text + .trim() + .replace(/^sim\//i, '') + .replace(/^\/+/, '') + .replace(/#.*$/, '') 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. */ +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?"']/g, (character) => `&#${character.charCodeAt(0)};`) + return text + .replace(NON_XML_CHARACTER, '') + .replace(LONE_SURROGATE, '') + .replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`) +} + +/** `encodeURIComponent`, except a lone surrogate (which it refuses) is dropped. */ +function encodeSegment(segment: string): string { + return encodeURIComponent(segment.replace(LONE_SURROGATE, '')) } /** * The resolver address for what was typed, as the address bar would send `sim/`: each - * `/` starts a value, kept in position even when empty, and a trailing `/` adds none. + * `/` starts a value, kept in position even when empty, and a trailing `/` adds none. The query + * is passed on as typed. */ 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}` : ''}` + const segments = path ? path.split('/').map(encodeSegment) : [] + const url = new URL(`${SIM_ORIGIN}/${['sim', ...segments].join('/')}`) + if (queryStart !== -1) url.search = rest.slice(queryStart + 1) + return url.toString() } -async function fetchSuggestedLinks(name: string, signal: AbortSignal): Promise { +/** The links Sim suggests for `name`, or `null` when it couldn't answer (signed out, rate limited). */ +async function fetchSuggestedLinks( + name: string, + signal: AbortSignal +): Promise { 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 [] + if (!response.ok) return null const { links } = simLinkSuggestionsSchema.parse(await response.json()) return links.map((link) => ({ name: link.name, detail: link.description ?? link.url })) } @@ -91,6 +118,11 @@ export function registerSimLinkOmnibox() { chrome.omnibox.setDefaultSuggestion({ description: `Open sim/${escapeXml(`${name}${rest}`)}`, }) + // 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) { + suggest([]) + return + } const key = name.toLowerCase() const known = linksByName.get(key) if (known) { @@ -102,9 +134,11 @@ export function registerSimLinkOmnibox() { void (async () => { await sleep(SUGGEST_DELAY_MS) if (controller.signal.aborted) return + // Signed out or rate limited: the typed link still opens through the resolver, and the name + // is asked about again on the next keystroke rather than remembered as having no links. const links = await fetchSuggestedLinks(key, controller.signal) - linksByName.set(key, links) - if (!controller.signal.aborted) suggest(toSuggestions(links, rest)) + if (links) 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') }) diff --git a/apps/sim/app/api/favicon/route.ts b/apps/sim/app/api/favicon/route.ts index 1a52f8417b1..195e1e7b668 100644 --- a/apps/sim/app/api/favicon/route.ts +++ b/apps/sim/app/api/favicon/route.ts @@ -17,8 +17,9 @@ export const GET = defineInternalBinaryRoute({ operation: getFavicon.operation, rateLimit: internalRateLimits.user({ bucketName: 'favicon', - /** A page can render dozens of icons at once; the browser caches each one after its first load. */ - config: { maxTokens: 300, refillRate: 300, refillIntervalMs: 60_000 }, + // A table can show hundreds of sites at once; requests that reach the icon providers are + // budgeted separately (favicon use case), so this only bounds cheap cached reads. + config: { maxTokens: 1200, refillRate: 1200, refillIntervalMs: 60_000 }, }), errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ query }) => ({ host: query.host }), diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 081b2b437a2..f55676e9f04 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' @@ -66,19 +65,13 @@ export default async function OrganizationLayout({ if (!context) { return } - const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role).catch( - (error) => { - logger.warn('Could not resolve People navigation access', { - organizationId, - error: getErrorMessage(error), - }) - return false - } - ) + const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role) + const simLinksEnabledLookup = isSimLinksEnabled(organizationId) if ( !context.mothershipAvailable && !context.searchAccess.memberScoped && - !(await peopleEnabledLookup) + !(await peopleEnabledLookup) && + !(await simLinksEnabledLookup) ) redirect(WORKSPACE_SETTINGS_PATH) @@ -110,7 +103,7 @@ export default async function OrganizationLayout({ isWorkflowTestsEnabled(organizationId), isChangelogEnabled(organizationId), isProjectsEnabled(), - isSimLinksEnabled(organizationId), + simLinksEnabledLookup, peopleEnabledLookup, ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' diff --git a/apps/sim/app/o/[organizationId]/links/search-params.ts b/apps/sim/app/o/[organizationId]/links/search-params.ts index 5549f6ea3ed..17b39723673 100644 --- a/apps/sim/app/o/[organizationId]/links/search-params.ts +++ b/apps/sim/app/o/[organizationId]/links/search-params.ts @@ -7,13 +7,13 @@ const simLinkViewParsers = { create: parseAsString } as const export const serializeSimLinkView = createSerializer(simLinkViewParsers) -/** `search` filters by name, destination, or description; only its URL write is debounced. */ +/** `search` filters by name, destination, or description. */ export const simLinksParsers = { ...simLinkViewParsers, search: parseAsString.withDefault(''), } as const -/** Shared `sort` + `dir` params for the Sim Links list. Default: alphabetical. */ +/** Shared `sort` + `dir` params for the Sim Links list. */ export const simLinksSortParams = createSortParams(SIM_LINK_SORTS, { column: 'name', direction: 'asc', diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx index 4ce55a6a8af..5647e2f0b6c 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx @@ -1,6 +1,7 @@ 'use client' import { useId, useState } from 'react' +import { SIM_LINK_NAME_MAX_LENGTH } from '@sim/browser-extension-protocol' import { Checkbox, ChipModal, @@ -10,12 +11,12 @@ import { ChipModalFooter, ChipModalHeader, } from '@sim/emcn' +import { SIM_DOCS_URL } from '@sim/utils/site' import { isApiClientError } from '@/lib/api/client/errors' import type { SimLink, SimLinkBody } from '@/lib/api/contracts/sim-links' import { isVariableSimLinkUrl, SIM_LINK_DESCRIPTION_MAX_LENGTH, - SIM_LINK_NAME_MAX_LENGTH, SIM_LINK_VARIABLE, simLinkFieldsSchema, toSimLinkName, @@ -24,7 +25,7 @@ import { useCreateSimLink, useUpdateSimLink } from '@/hooks/queries/sim-links' type Field = 'name' | 'url' | 'defaultUrl' | 'description' -const SIM_LINKS_DOCS_URL = 'https://docs.sim.ai/search/sim-links' +const SIM_LINKS_DOCS_URL = `${SIM_DOCS_URL}/search/sim-links` function openDocs() { window.open(SIM_LINKS_DOCS_URL, '_blank', 'noopener,noreferrer') @@ -173,7 +174,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim onCheckedChange={(checked) => change('restrictEdit', checked === true)} /> - Only you and admins can edit + Only the creator and admins can edit diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 74ae9ef5214..7f00eae3bff 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -1,12 +1,13 @@ 'use client' -import { useMemo, useState } from 'react' +import { type MouseEvent, useCallback, useMemo, useState } from 'react' import { Avatar, ChipConfirmModal, ChipModalError, 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 { simLinkPath } from '@/lib/navigation/paths' +import { toSimLinkName } from '@/lib/sim-links/validation' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { NEW_SIM_LINK_ACTION, @@ -90,6 +91,8 @@ export function SimLinks({ organizationId }: SimLinksProps) { const loaded = useMemo(() => links.data?.pages.flatMap((page) => page.links) ?? [], [links.data]) const linksById = useMemo(() => new Map(loaded.map((link) => [link.id, link])), [loaded]) const menuLink = menuLinkId ? linksById.get(menuLinkId) : undefined + // A row can leave the results while its menu is open; forgetting it keeps the menu from + // reappearing at the old position when the row comes back. if (menuLinkId && !menuLink) setMenuLinkId(null) const rows: ResourceRow[] = useMemo( @@ -128,6 +131,25 @@ export function SimLinks({ organizationId }: SimLinksProps) { } const sortConfig: SortConfig = { options: SORT_OPTIONS, active: activeSort, onSort, onClear } const startCreate = () => void setView({ create: '' }) + const { fetchNextPage } = links + const { handleContextMenu } = contextMenu + // Rows are memoized, so stable handlers keep a keystroke from re-rendering every row. + const openRow = useCallback( + (id: string) => { + const link = linksById.get(id) + if (link) openLink(link.name) + }, + [linksById] + ) + const openRowMenu = useCallback( + (event: MouseEvent, id: string) => { + if (!linksById.has(id)) return + setMenuLinkId(id) + handleContextMenu(event) + }, + [linksById, handleContextMenu] + ) + const loadMore = useCallback(() => void fetchNextPage(), [fetchNextPage]) if (!links.data && !links.isLoadingError) return @@ -142,16 +164,10 @@ export function SimLinks({ organizationId }: SimLinksProps) { { - const link = linksById.get(id) - if (link) openLink(link.name) - }} - onRowContextMenu={(event, id) => { - if (!linksById.has(id)) return - setMenuLinkId(id) - contextMenu.handleContextMenu(event) - }} - onLoadMore={() => void links.fetchNextPage()} + virtualized + onRowClick={openRow} + onRowContextMenu={openRowMenu} + onLoadMore={loadMore} hasMore={links.hasNextPage} isLoadingMore={links.isFetchingNextPage} isFetching={links.isFetching} @@ -163,7 +179,7 @@ export function SimLinks({ organizationId }: SimLinksProps) { setSearch('')} /> ) : ( @@ -193,7 +209,7 @@ export function SimLinks({ organizationId }: SimLinksProps) { void setView({ create: null })} /> )} diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx index 91ba4ea63e6..82b486cc5cf 100644 --- a/apps/sim/app/o/[organizationId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/page.tsx @@ -3,6 +3,7 @@ import { getSession } from '@/lib/auth' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { canBrowsePeople } from '@/lib/people/feature-flag' +import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' export default async function OrganizationPage({ @@ -20,9 +21,6 @@ export default async function OrganizationPage({ if (!context) notFound() if (context.mothershipAvailable && context.canBuild) redirect(routes.home) if (context.searchAccess.memberScoped) redirect(routes.search) - redirect( - (await canBrowsePeople(organizationId, context.viewer.role)) - ? routes.people - : WORKSPACE_SETTINGS_PATH - ) + if (await canBrowsePeople(organizationId, context.viewer.role)) redirect(routes.people) + redirect((await isSimLinksEnabled(organizationId)) ? routes.simLinks : WORKSPACE_SETTINGS_PATH) } diff --git a/apps/sim/app/sim/[[...path]]/page.tsx b/apps/sim/app/sim/[[...path]]/page.tsx index b29066a2c27..59da34a8eb6 100644 --- a/apps/sim/app/sim/[[...path]]/page.tsx +++ b/apps/sim/app/sim/[[...path]]/page.tsx @@ -31,7 +31,10 @@ 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. */ +/** + * The query, from the params Next has already parsed and normalized (it re-serializes every + * request's query before app code sees it, so the typed spelling isn't recoverable here). + */ function toQueryString(searchParams: Record): string { const query = new URLSearchParams() for (const [key, value] of Object.entries(searchParams)) { diff --git a/apps/sim/lib/api/server/routes/definition.test.ts b/apps/sim/lib/api/server/routes/definition.test.ts index 0d624df5c4a..a0a21328671 100644 --- a/apps/sim/lib/api/server/routes/definition.test.ts +++ b/apps/sim/lib/api/server/routes/definition.test.ts @@ -3,8 +3,10 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { methodMatchesContract, + requireBinaryRouteDefinition, requireJsonRouteDefinition, } from '@/lib/api/server/routes/definition' +import { defineOperation } from '@/lib/core/application' const renameOperation = { id: 'files.rename', @@ -26,6 +28,26 @@ describe('declarative route definition invariants', () => { ) ).toThrow('does not match') }) + + it('refuses a binary route whose first status cannot carry the body it serves', () => { + const contract = (status: number[]) => + defineRouteContract({ + method: 'GET', + path: '/icon', + response: { mode: 'binary', status }, + }) + const operation = defineOperation({ + id: 'icons.get', + principalKinds: ['session'], + capability: 'none', + }) + expect(() => requireBinaryRouteDefinition(contract([204, 200]), operation, operation)).toThrow( + 'carries a body first' + ) + expect( + requireBinaryRouteDefinition(contract([200, 204]), operation, operation).successStatus + ).toBe(200) + }) }) describe('methodMatchesContract', () => { diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts index f870dbd3280..fdcb9522a04 100644 --- a/apps/sim/lib/api/server/routes/definition.ts +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -23,6 +23,9 @@ export function requireJsonRouteDefinition( return declaredSuccessStatuses(contract) } +/** Statuses a response body can't accompany, so never a binary route's default. */ +const BODYLESS_STATUSES = new Set([204, 205]) + export function requireBinaryRouteDefinition( contract: AnyApiRouteContract, declaredOperation: ApplicationOperation, @@ -36,7 +39,13 @@ export function requireBinaryRouteDefinition( `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}` ) } - return declaredSuccessStatuses(contract) + const metadata = declaredSuccessStatuses(contract) + if (BODYLESS_STATUSES.has(metadata.successStatus)) { + throw new Error( + `${contract.method} ${contract.path} must list a status that carries a body first; it is the status a body is served with` + ) + } + return metadata } /** The contract's success statuses, 200 by default; the first is a route's default status. */ diff --git a/apps/sim/lib/favicon/application.ts b/apps/sim/lib/favicon/application.ts index c1934af7424..533a07c0a42 100644 --- a/apps/sim/lib/favicon/application.ts +++ b/apps/sim/lib/favicon/application.ts @@ -1,6 +1,20 @@ +import type { SessionPrincipal } from '@sim/auth/principal' import { defineOperation } from '@/lib/core/application' +import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' -import { resolveFavicon } from '@/lib/favicon/resolve.server' +import { cachedFavicon, type FaviconResolution, resolveFavicon } from '@/lib/favicon/resolve.server' + +const rateLimiter = new RateLimiter() + +/** + * Lookups that may reach the icon providers, per user; an icon already cached is served without + * charge. Each lookup can ask the providers about several hosts, and caches what it finds for days. + */ +const PROVIDER_LOOKUP_LIMIT: TokenBucketConfig = { + maxTokens: 120, + refillRate: 60, + refillIntervalMs: 60_000, +} /** A site's icon for the signed-in user, or why there is none to serve. */ export const getFavicon = defineAuthorizedCredentialUserUseCase({ @@ -10,5 +24,19 @@ export const getFavicon = defineAuthorizedCredentialUserUseCase({ principalKinds: ['session'], capability: 'none', }), - execute: ({ input }: { input: { host: string } }) => resolveFavicon(input.host), + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: { host: string } + }): Promise { + const cached = await cachedFavicon(input.host) + if (cached) return cached + const { allowed } = await rateLimiter.checkRateLimitDirect( + `route:favicon-lookup:user:${principal.userId}`, + PROVIDER_LOOKUP_LIMIT + ) + return allowed ? resolveFavicon(input.host) : { kind: 'unavailable' } + }, }) diff --git a/apps/sim/lib/favicon/hosts.test.ts b/apps/sim/lib/favicon/hosts.test.ts index c47ced23dd2..634ca278efd 100644 --- a/apps/sim/lib/favicon/hosts.test.ts +++ b/apps/sim/lib/favicon/hosts.test.ts @@ -113,12 +113,18 @@ describe('faviconHostCandidates', () => { expect(faviconHostCandidates('gitlab.gitlab.com')).toEqual(['gitlab.gitlab.com', 'gitlab.com']) }) - it('tries at most three parents', () => { + it('tries at most three parents, always ending at the registrable domain', () => { expect(faviconHostCandidates('a.b.c.d.e.example.com')).toEqual([ 'a.b.c.d.e.example.com', 'b.c.d.e.example.com', 'c.d.e.example.com', - 'd.e.example.com', + 'example.com', + ]) + expect(faviconHostCandidates('a.b.c.d.example.co.uk')).toEqual([ + 'a.b.c.d.example.co.uk', + 'b.c.d.example.co.uk', + 'c.d.example.co.uk', + 'example.co.uk', ]) }) diff --git a/apps/sim/lib/favicon/hosts.ts b/apps/sim/lib/favicon/hosts.ts index c7b85840f29..5d9101b0a77 100644 --- a/apps/sim/lib/favicon/hosts.ts +++ b/apps/sim/lib/favicon/hosts.ts @@ -1,34 +1,36 @@ import { parse } from 'tldts' import { platformSite } from '@/lib/favicon/platforms' -const MAX_PARENT_HOSTS = 3 +/** Parents nearest the host tried before the registrable domain, which is always tried last. */ +const NEAREST_PARENT_HOSTS = 2 const HOST_SHAPE = /^[^\s/?#@\\]+$/ /** * Hosts to ask a favicon provider about, most specific first. A private tenant such as * `acme.atlassian.net` is invisible to providers, but its platform is not, so each parent - * domain follows — at most three, stopping before the ICANN public suffix (`gitlab.io` is a valid - * last step; `io` and `co.uk` are not). A host on a platform no provider can see even at the - * parent (`x.awsapps.com`, a self-hosted `gitlab.`) ends at the platform's own site, after - * its own host and parents so a tenant's custom icon still wins. Internal hosts — IP literals, single labels, and names outside - * the public DNS — yield nothing, so they never leave the server, and neither does a name with - * characters DNS can't hold, which could otherwise reshape a provider's URL. + * domain follows — the two nearest, then the registrable domain, which is most likely to have an + * icon, never the ICANN public suffix (`gitlab.io` is a valid last step; `io` and `co.uk` are + * not). A host on a platform no provider can see even at the parent (`x.awsapps.com`, a + * self-hosted `gitlab.`) ends at the platform's own site, after its own host and parents + * so a tenant's custom icon still wins. Internal hosts — IP literals, single labels, and names + * outside the public DNS — yield nothing, so they never leave the server, and neither does a name + * with characters DNS can't hold, which could otherwise reshape a provider's URL. */ export function faviconHostCandidates(input: string): string[] { const url = HOST_SHAPE.test(input) ? URL.parse(`https://${input}`) : null if (!url) return [] const hostname = url.hostname.replace(/\.$/, '') if (hostname.split('.').some((label) => !label)) return [] - const { isIp, isIcann, publicSuffix } = parse(hostname, { + const { isIp, isIcann, publicSuffix, domain } = parse(hostname, { allowPrivateDomains: false, validateHostname: true, }) - if (isIp || !isIcann || !publicSuffix || /(^|\.)arpa$/.test(publicSuffix)) return [] + if (isIp || !isIcann || !publicSuffix || !domain || /(^|\.)arpa$/.test(publicSuffix)) return [] const labels = hostname.split('.') - const candidateCount = labels.length - publicSuffix.split('.').length - const candidates = labels - .slice(0, Math.min(candidateCount, MAX_PARENT_HOSTS + 1)) + const nearest = labels + .slice(0, Math.min(labels.length - publicSuffix.split('.').length, NEAREST_PARENT_HOSTS + 1)) .map((_, index) => labels.slice(index).join('.')) + const candidates = nearest.includes(domain) ? nearest : [...nearest, domain] const platform = platformSite(hostname) return platform && !candidates.includes(platform) ? [...candidates, platform] : candidates } diff --git a/apps/sim/lib/favicon/probe.ts b/apps/sim/lib/favicon/probe.ts index 7df7c3c1ed6..2f2b2d94055 100644 --- a/apps/sim/lib/favicon/probe.ts +++ b/apps/sim/lib/favicon/probe.ts @@ -1,7 +1,10 @@ import { sniffImageContentType } from '@/lib/uploads/utils/validation' -/** Favicons are a few kilobytes; anything far larger is not one. */ -export const MAX_ICON_BYTES = 256 * 1024 +/** + * Favicons are a few kilobytes; anything far larger is not one, and every icon is cached, so the + * cap also bounds what one lookup can store. + */ +export const MAX_ICON_BYTES = 64 * 1024 const ICO_MAGIC_BYTES = [0x00, 0x00, 0x01, 0x00] diff --git a/apps/sim/lib/favicon/resolve.server.test.ts b/apps/sim/lib/favicon/resolve.server.test.ts index cc89109539d..96e55710302 100644 --- a/apps/sim/lib/favicon/resolve.server.test.ts +++ b/apps/sim/lib/favicon/resolve.server.test.ts @@ -5,7 +5,8 @@ import { import { createMockRedis } from '@sim/testing/mocks/redis.mock' import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveFavicon } from '@/lib/favicon/resolve.server' +import { MAX_ICON_BYTES } from '@/lib/favicon/probe' +import { cachedFavicon, resolveFavicon } from '@/lib/favicon/resolve.server' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) @@ -71,4 +72,30 @@ describe('resolveFavicon', () => { expect((await resolveFavicon('acme.example.com')).kind).toBe('hit') expect(ttlOf('acme.example.com')).toBe(30 * DAY) }) + + it('looks up an icon cached under an earlier, larger cap again instead of serving it', async () => { + answers = { 'acme.example.com': 'icon' } + const oversized = Buffer.concat([Buffer.from('h'), PNG, Buffer.alloc(MAX_ICON_BYTES)]) + stored.set('favicon:v3:acme.example.com', { value: oversized, ttl: 30 * DAY }) + const result = await resolveFavicon('acme.example.com') + expect(result.kind === 'hit' && result.bytes.length).toBe(PNG.length) + expect(inputValidationMockFns.mockSecureFetchWithValidation).toHaveBeenCalled() + }) + + it('reports nothing cached until a lookup has stored an answer', async () => { + answers = { 'acme.example.com': 'icon' } + expect(await cachedFavicon('acme.example.com')).toBeNull() + await resolveFavicon('acme.example.com') + expect((await cachedFavicon('acme.example.com'))?.kind).toBe('hit') + }) + + it('shares one lookup between concurrent callers of a host', async () => { + answers = { 'acme.example.com': 'icon' } + const results = await Promise.all([ + resolveFavicon('acme.example.com'), + resolveFavicon('acme.example.com'), + ]) + expect(results.map((result) => result.kind)).toEqual(['hit', 'hit']) + expect(inputValidationMockFns.mockSecureFetchWithValidation).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/sim/lib/favicon/resolve.server.ts b/apps/sim/lib/favicon/resolve.server.ts index 53caf74f274..0fdc920d2b7 100644 --- a/apps/sim/lib/favicon/resolve.server.ts +++ b/apps/sim/lib/favicon/resolve.server.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { coalesceLocally } from '@/lib/concurrency/singleflight' import { getRedisClient } from '@/lib/core/config/redis' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' @@ -34,13 +35,11 @@ const PROVIDERS: readonly ((host: string) => string)[] = [ (host) => `https://icons.duckduckgo.com/ip3/${host}.ico`, ] -type FaviconResolution = +export type FaviconResolution = | { kind: 'hit'; contentType: string; bytes: Buffer; provisional?: true } | { kind: 'miss' } | { kind: 'unavailable' } -const inFlight = new Map>() - const cacheKey = (host: string) => `favicon:v3:${host}` /** A host's key holds the result of the walk that starts at that host. */ @@ -53,6 +52,8 @@ async function readCache(host: string): Promise { if (cached.toString() === UNAVAILABLE_MARKER) return { kind: 'unavailable' } const prefix = cached.subarray(0, 1).toString() const bytes = cached.subarray(1) + // Cached under an earlier, larger cap: look it up again rather than serve it or call it a miss. + if (bytes.length > MAX_ICON_BYTES) return null const result = classifyProviderResponse(200, bytes) if (result.kind !== 'hit') return { kind: 'miss' } return prefix === PROVISIONAL_HIT_PREFIX @@ -150,6 +151,12 @@ async function walk(candidates: string[], signal: AbortSignal): Promise { + const requested = faviconHostCandidates(host)[0] + return requested ? readCache(requested) : { kind: 'miss' } +} + /** * The icon for a host, or its nearest parent domain's, from public icon providers — Sim never * contacts the destination itself (a provider may, from its own servers), so a host behind a @@ -161,10 +168,7 @@ export async function resolveFavicon(host: string): Promise { const candidates = faviconHostCandidates(host) const requested = candidates[0] if (!requested) return { kind: 'miss' } - const pending = inFlight.get(requested) - if (pending) return pending - - const resolution = (async (): Promise => { + return coalesceLocally(`favicon:${requested}`, async (): Promise => { const cached = await readCache(requested) if (cached) return cached const resolved = await runWithOutboundOrganization(null, () => @@ -172,7 +176,5 @@ export async function resolveFavicon(host: string): Promise { ).catch((): FaviconResolution => ({ kind: 'unavailable' })) await writeCache(requested, resolved) return resolved - })().finally(() => inFlight.delete(requested)) - inFlight.set(requested, resolution) - return resolution + }).catch((): FaviconResolution => ({ kind: 'unavailable' })) } diff --git a/apps/sim/lib/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index c49055ee25f..601e2f892ac 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -457,7 +457,6 @@ export const DOCS_MANIFEST: readonly string[] = [ 'search/lucid.mdx', 'search/mcp.mdx', 'search/notion.mdx', - 'search/people.mdx', 'search/quickstart.mdx', 'search/sim-links.mdx', 'search/slack.mdx', diff --git a/apps/sim/lib/people/feature-flag.ts b/apps/sim/lib/people/feature-flag.ts index 1b8cd6e00fe..7054328e162 100644 --- a/apps/sim/lib/people/feature-flag.ts +++ b/apps/sim/lib/people/feature-flag.ts @@ -1,4 +1,6 @@ +import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { getErrorMessage } from '@sim/utils/errors' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' @@ -13,15 +15,26 @@ export async function requirePeopleEnabled(organizationId: string): Promise { - if (!(await isPeopleEnabled(organizationId))) return false - return ( - isOrgAdminRole(role) || - !(await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) - ) + try { + if (!(await isPeopleEnabled(organizationId))) return false + return ( + isOrgAdminRole(role) || + !(await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory')) + ) + } catch (error) { + logger.warn('Could not resolve People navigation access', { + organizationId, + error: getErrorMessage(error), + }) + return false + } } diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index 6b7ab865042..6fc3ffafb2c 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -249,7 +249,7 @@ export const resolveSimLink = defineAuthorizedOrganizationUseCase({ >): Promise { const link = await repository.findSimLinkDestination(input.organizationId, input.name) if (!link) return { kind: 'not_found' } - const target = resolveSimLinkTarget(link, input.values, new URLSearchParams(input.query)) + const target = resolveSimLinkTarget(link, input.values, input.query) if (target.kind === 'redirect') await countVisit(input.organizationId, link.id) return target }, diff --git a/apps/sim/lib/sim-links/destination.test.ts b/apps/sim/lib/sim-links/destination.test.ts index 19e5f826477..b3727299100 100644 --- a/apps/sim/lib/sim-links/destination.test.ts +++ b/apps/sim/lib/sim-links/destination.test.ts @@ -20,7 +20,7 @@ function redirectUrl( defaultUrl: string | null = null, query?: string ): string { - const target = resolveSimLinkTarget({ url, defaultUrl }, values, new URLSearchParams(query)) + const target = resolveSimLinkTarget({ url, defaultUrl }, values, query) if (target.kind !== 'redirect') throw new Error(`Expected a redirect, got ${target.kind}`) return target.url } @@ -137,7 +137,15 @@ describe('Sim link destinations', () => { }) it.each([ - ['https://ex.com/p', [], null, 'a=1&b=two words', 'https://ex.com/p?a=1&b=two+words'], + [ + 'https://ex.com/p', + [], + null, + 'a=1&b=two%20words&edit', + 'https://ex.com/p?a=1&b=two%20words&edit', + ], + ['https://ex.com/p', [], null, 'q=a b', 'https://ex.com/p?q=a%20b'], + ['https://ex.com/p', [], null, 'q=&flag', 'https://ex.com/p?q=&flag'], ['https://ex.com/p?x=1#top', ['y'], null, 'x=2', 'https://ex.com/p/y?x=1&x=2#top'], [ 'https://ex.com/search?q={*}', @@ -156,6 +164,18 @@ describe('Sim link destinations', () => { }) describe('parseTypedSimLink', () => { + it.each([ + [' onboarding', 'onboarding'], + ['onboarding ', 'onboarding'], + ['onboarding ).', 'onboarding'], + ])('drops the spaces around %j, which no name can hold', (name, trimmed) => { + expect(parseTypedSimLink([name]).name).toBe(trimmed) + }) + + it('trims the name but keeps values verbatim when values follow', () => { + expect(parseTypedSimLink([' jira ', ' ABC-1 '])).toEqual({ name: 'jira', values: [' ABC-1 '] }) + }) + it.each([ ['onboarding).', 'onboarding'], ['onboarding,', 'onboarding'], diff --git a/apps/sim/lib/sim-links/destination.ts b/apps/sim/lib/sim-links/destination.ts index 358d2d36a09..dfbb5f35e02 100644 --- a/apps/sim/lib/sim-links/destination.ts +++ b/apps/sim/lib/sim-links/destination.ts @@ -26,13 +26,13 @@ function encodeSegments(values: readonly string[]): string { /** * Values fill only what follows a closed authority, so leaving `origin` is a broken invariant. - * The typed query follows the destination's own parameters, whose encoding is kept as stored. + * The typed query follows the destination's own parameters, both kept as spelled; the `search` + * setter percent-encodes anything a query can't hold, so a `#` in it can't start a fragment. */ -function redirectWithin(origin: string, url: string, query: URLSearchParams): SimLinkTarget { +function redirectWithin(origin: string, url: string, query: string): SimLinkTarget { const parsed = parseHttpUrl(url) if (parsed?.origin !== origin) throw new Error('Sim link resolved outside its fixed origin') - const typed = query.toString() - if (typed) parsed.search = parsed.search ? `${parsed.search}&${typed}` : typed + if (query) parsed.search = parsed.search ? `${parsed.search}&${query}` : query return { kind: 'redirect', url: parsed.toString() } } @@ -45,7 +45,7 @@ function redirectWithin(origin: string, url: string, query: URLSearchParams): Si export function resolveSimLinkTarget( link: SimLinkDestination, values: readonly string[], - query: URLSearchParams = new URLSearchParams() + query = '' ): SimLinkTarget { const origin = simLinkOrigin(link.url) if (!origin) throw new Error('Sim link destination has no fixed http(s) origin') diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index 9eb8a986c48..38ac06393cc 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -1,9 +1,9 @@ +import { SIM_LINK_NAME_MAX_LENGTH } from '@sim/browser-extension-protocol' 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 SIM_LINK_NAME_MAX_LENGTH = 64 export const SIM_LINK_DESCRIPTION_MAX_LENGTH = 500 const URL_MAX_LENGTH = 2048 @@ -38,19 +38,21 @@ export function toSimLinkName(input: string): string { const PASTED_TRAILING_PUNCTUATION = /[.,;:!?'"’”)\]}>]+$/ /** - * `sim//` split into the name and its values. A name typed alone loses the - * punctuation it picked up from the sentence it was copied out of, so `sim/onboarding).` opens - * `sim/onboarding`: none of these characters can end a distinct name, since `.` and `:` are - * ignored when matching and the rest are not allowed. Values are kept verbatim, and a name that - * is only punctuation stays as typed, so it still fails validation. + * `sim//` split into the name and its values. The name loses surrounding spaces, + * which no name can hold, and a name typed alone also loses the punctuation it picked up from the + * sentence it was copied out of, so `sim/onboarding).` opens `sim/onboarding`: none of these + * characters can end a distinct name, since `.` and `:` are ignored when matching and the rest + * are not allowed. Values are kept verbatim, and a name that is only punctuation stays as typed, + * so it still fails validation. */ export function parseTypedSimLink(segments: readonly string[]): { name: string values: string[] } { - const [name = '', ...values] = segments + const [typedName = '', ...values] = segments + const name = typedName.trim() if (values.length) return { name, values } - return { name: name.replace(PASTED_TRAILING_PUNCTUATION, '') || name, values } + return { name: name.replace(PASTED_TRAILING_PUNCTUATION, '').trimEnd() || name, values } } export function isVariableSimLinkUrl(url: string): boolean { diff --git a/bun.lock b/bun.lock index 9eed2f057c8..0758e450d2a 100644 --- a/bun.lock +++ b/bun.lock @@ -150,7 +150,7 @@ }, "apps/extension": { "name": "@sim/extension", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@sim/browser-extension-protocol": "workspace:*", "@sim/emcn": "workspace:*", diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts index 305f47ad633..c25ee669317 100644 --- a/packages/browser-extension-protocol/src/index.ts +++ b/packages/browser-extension-protocol/src/index.ts @@ -75,12 +75,18 @@ export const extensionToAppMessageSchema = z.discriminatedUnion('type', [ ]) export type ExtensionToAppMessage = z.infer +/** The longest Sim link name; the extension asks about nothing longer. */ +export const SIM_LINK_NAME_MAX_LENGTH = 64 + /** Where the extension asks for the address-bar suggestions behind its `sim` keyword. */ export const SIM_LINK_SUGGESTIONS_PATH = '/api/sim-links/suggestions' -/** Listed Sim links whose name starts with what was typed, most visited first. */ -export const simLinkSuggestionsSchema = z.strictObject({ +/** + * 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. + */ +export const simLinkSuggestionsSchema = z.object({ links: z.array( - z.strictObject({ name: z.string(), description: z.string().nullable(), url: z.string() }) + z.object({ name: z.string(), description: z.string().nullable(), url: z.string() }) ), })