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
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,44 @@ here. The release version is defined in the workspace root `package.json`.

## [Unreleased]

## [0.4.1] - 2026-09-14

### Fixed

- Anam, Avatar and Recall integrations appear in the integrations list in
every environment. The three types were marked private and only listed in
development and staging, so an integration created through a blueprint was
reachable by URL but missing from the list and its total.
- Hydration no longer fails on pages that link to the page's own origin or to
a portal or space host. The router read the request host cookie on the
server but not on the first client render, and the apex hooks were seeded
from server-only environment, so the server HTML never matched the browser.
Both now resolve after hydration, as `useHost` already did.
- Sitemap integrations accept source URLs up to 768 bytes. The `url` column
was a plain db string, so a long sitemap URL failed the onboarding wizard
with a 191-byte limit error.
- A stored image that fails to decode gets the generated icon thumbnail
without being reported as an error, and a GitHub integration reply that
fails on an expected GitHub 4xx is logged without being reported either.
- The Notion integration token column is optional, as the credential columns
of every other integration are, so creating one without a token no longer
fails in the database. A sync on an integration without a token answers
409 instead of launching the crawler. Creating or updating a dataset record
with empty or blank text answers 400 instead of failing in the vector store.
- Creating a conversation message of type `activity`, or updating a message
so that it becomes one without its activity meta, answers 400 as the
stateless completion already did. A stored activity message without that
meta broke every later completion of that conversation.
- Code blocks highlight with shiki's JavaScript regex engine instead of the
WebAssembly one, so pages with code no longer fail in iOS Lockdown Mode.
- App actions report only unexpected failures. An expected answer such as
reaching the account limits is returned to the app without being reported
as an error.
- Integration setup, session create, record, conversation create and the
conversation completion routes report only unexpected failures. An answer
with a known code - not found, bad request, limits reached, a completion
timeout - reaches the caller as before without being reported as an error.

## [0.4.0] - 2026-09-10

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "platform",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"license": "Apache-2.0",
"packageManager": "[email protected]",
Expand Down
4 changes: 2 additions & 2 deletions packages/db-spec/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2202,7 +2202,7 @@ model SitemapIntegration {
name String @default("")
description String @default(dbgenerated("(_utf8mb4\\'\\')")) @db.Text

url String?
url String? @db.VarChar(768)

glob String?

Expand Down Expand Up @@ -2256,7 +2256,7 @@ model NotionIntegration {
name String @default("")
description String @default(dbgenerated("(_utf8mb4\\'\\')")) @db.Text

token String @db.Text /// @encrypted
token String? @db.Text /// @encrypted

expiresIn Float?

Expand Down
2 changes: 1 addition & 1 deletion packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2256,7 +2256,7 @@ model NotionIntegration {
name String @default("")
description String @default("")

token String /// @encrypted
token String? /// @encrypted

expiresIn Float?

Expand Down
6 changes: 6 additions & 0 deletions packages/db/src/constraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ const SQLITE_MAX_LENGTH = 1000000000
*/
export const MAX_DB_STRING_BYTES_LENGTH = SQLITE_MAX_LENGTH

/**
* The max length of what the blueprint declares `@db.VarChar(768)` - a source
* URL - stored here as an unconstrained TEXT column.
*/
export const MAX_DB_SOURCE_URL_BYTES_LENGTH = SQLITE_MAX_LENGTH

/**
* The max length of what the blueprint declares `@db.Text`, stored here as an
* unconstrained TEXT column.
Expand Down
10 changes: 5 additions & 5 deletions platform/components/IntegrationList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import clsx from 'clsx'

// Integrations render as a single merged list across every integration type.
// The table below is the source of truth: it drives the combined GraphQL
// query, the type tag on every row, and the private (development and staging
// only) gating.
// query, the type tag on every row, and the optional `private` (development
// and staging only) gating; no type is gated at the moment.
export const INTEGRATION_TYPES = [
{ type: 'widget', connection: 'widgetIntegrations' },
{ type: 'slack', connection: 'slackIntegrations' },
Expand Down Expand Up @@ -59,9 +59,9 @@ export const INTEGRATION_TYPES = [
},
{ type: 'mcpserver', connection: 'mcpserverIntegrations' },
{ type: 'skillserver', connection: 'skillserverIntegrations' },
{ type: 'anam', connection: 'anamIntegrations', private: true },
{ type: 'avatar', connection: 'avatarIntegrations', private: true },
{ type: 'recall', connection: 'recallIntegrations', private: true },
{ type: 'anam', connection: 'anamIntegrations' },
{ type: 'avatar', connection: 'avatarIntegrations' },
{ type: 'recall', connection: 'recallIntegrations' },
]

// @note every connection is capped at its first 100 integrations. When a
Expand Down
39 changes: 31 additions & 8 deletions platform/components/IntegrationList.utest.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import IntegrationList from './IntegrationList'
import IntegrationList, { INTEGRATION_TYPES } from './IntegrationList'

import '@testing-library/jest-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
Expand Down Expand Up @@ -340,22 +340,45 @@ describe('IntegrationList', () => {
expect(screen.queryByText('Load more')).not.toBeInTheDocument()
})

it('should query the private connections only when they are shown', async () => {
it('should query every integration connection without the private flag', async () => {
respondWith({})

const { unmount } = render(<IntegrationList authenticated={true} />)
render(<IntegrationList authenticated={true} />)

await waitFor(() => expect(mockFetch).toHaveBeenCalled())

expect(lastQuery()).not.toContain('anamIntegrations')
for (const { connection } of INTEGRATION_TYPES) {
expect(lastQuery()).toContain(connection)
}
})

unmount()
it('should query the private connections only when they are shown', async () => {
respondWith({})

const privateTypes = [
...INTEGRATION_TYPES,
{ type: 'hidden', connection: 'hiddenIntegrations', private: true },
]

INTEGRATION_TYPES.splice(0, INTEGRATION_TYPES.length, ...privateTypes)

try {
const { unmount } = render(<IntegrationList authenticated={true} />)

await waitFor(() => expect(mockFetch).toHaveBeenCalled())

expect(lastQuery()).not.toContain('hiddenIntegrations')

unmount()

render(<IntegrationList authenticated={true} showPrivateIntegrations />)
render(<IntegrationList authenticated={true} showPrivateIntegrations />)

await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))

expect(lastQuery()).toContain('anamIntegrations')
expect(lastQuery()).toContain('hiddenIntegrations')
} finally {
INTEGRATION_TYPES.pop()
}
})
})

Expand Down
14 changes: 11 additions & 3 deletions platform/hooks/useHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
import { parse } from '@/lib/cookie'
import { isProduction } from '@/lib/env'
import { getExternalAPIHost } from '@/lib/host'
import { isLocalhost } from '@/lib/localhost'
import { hostToHostname, normalizeRequestHost } from '@/lib/host.parse'
import { isLocalhost } from '@/lib/localhost'

import useCookie from '@/hooks/useCookie'
import useHydrated from '@/hooks/useHydrated'
Expand Down Expand Up @@ -97,7 +97,11 @@ export function useAPIHost(): string {
}

export function usePortalApex(): string {
const [apex, setApex] = useState<string>(portalApex || '')
// @note the apexes are server-only environment, so the constant is set on
// the server and empty in the browser - seed empty on both sides and resolve
// in the layout effect, or the server HTML never matches the first render

const [apex, setApex] = useState<string>('')

useHydrationSafeLayoutEffect(() => {
setApex(document.documentElement.dataset.portalApex || portalApex || '')
Expand All @@ -107,7 +111,11 @@ export function usePortalApex(): string {
}

export function useSpaceApex(): string {
const [apex, setApex] = useState<string>(spaceApex || '')
// @note the apexes are server-only environment, so the constant is set on
// the server and empty in the browser - seed empty on both sides and resolve
// in the layout effect, or the server HTML never matches the first render

const [apex, setApex] = useState<string>('')

useHydrationSafeLayoutEffect(() => {
setApex(document.documentElement.dataset.spaceApex || spaceApex || '')
Expand Down
19 changes: 19 additions & 0 deletions platform/hooks/useHost.utest.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,25 @@ describe('configured apexes', () => {
expect(result.current).toBe('portal.example.com')
})

it('should seed the apexes empty on the server render', () => {
// @note the apexes are server-only environment - the browser bundle has
// none, so a seeded server render would never match the first client one
let portal = 'unset'
let space = 'unset'

function Probe() {
portal = usePortalApex()
space = useSpaceApex()

return null
}

renderToString(<Probe />)

expect(portal).toBe('')
expect(space).toBe('')
})

it('should resolve the space apex from the document', () => {
document.documentElement.dataset.spaceApex = 'space.brand.example'

Expand Down
12 changes: 11 additions & 1 deletion platform/hooks/useRouter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useCookieHost,
useSiteHost,
} from '@/hooks/useHost'
import useHydrated from '@/hooks/useHydrated'

import i18n from '@/i18n.config'
import base from '@/next.config.d/base.config'
Expand Down Expand Up @@ -169,10 +170,19 @@ export default function useRouter() {
// host cookie carry them; the app tables hold hosts too and are looked up
// by hostname

const cookieHost = useCookieHost()
const requestCookieHost = useCookieHost()
const audienceHost = useAudienceHost()
const siteHostRuntime = useSiteHost()

// @note the cookie is read from the request on the server but is not
// readable during the first client render, so it is ignored until hydration
// - otherwise the server strips own-origin hrefs the client keeps absolute
// and hydration fails - see useHost

const hydrated = useHydrated()

const cookieHost = hydrated ? requestCookieHost : ''

const host = cookieHost || audienceHost
const hostname = hostToHostname(host)

Expand Down
18 changes: 18 additions & 0 deletions platform/hooks/useRouter.utest.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jest.mock('@/hooks/useHost', () => ({
useSiteHost: jest.fn(),
}))

jest.mock('@/hooks/useHydrated', () => jest.fn(() => true))

jest.mock('@/i18n.config', () => ({
__esModule: true,
default: { locales: ['en'], defaultLocale: 'en', domainLocales: [] },
Expand Down Expand Up @@ -85,6 +87,22 @@ describe('useRouter href resolution by host', () => {
jest.clearAllMocks()
})

describe('before hydration', () => {
it('ignores the request cookie host so the server and first client render agree', () => {
const useHydrated = require('@/hooks/useHydrated')

useHydrated.mockReturnValueOnce(false)

const router = setup({ cookieHostname: 'site.example.com' })

// @note the server reads the cookie from the request and the browser
// cannot until it has hydrated - the absolute href is left as is on both
expect(router.resolveHref('https://site.example.com/pricing')).toBe(
'https://site.example.com/pricing'
)
})
})

describe('on the site host', () => {
it('keeps the /apps prefix', () => {
const router = setup({ cookieHostname: 'site.example.com' })
Expand Down
6 changes: 4 additions & 2 deletions platform/lib/app.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import {
} from '@/lib/app.context'
import { APP_AUDIENCE } from '@/lib/audience.consts'
import { setupHeadersContext } from '@/lib/context.setup'
import { captureUnknownException } from '@/lib/response'
import {
getContextFrontendHost,
getContextRequestHost,
runInContext,
} from '@/lib/context.store'
import { captureException } from '@/lib/error'
import type { ZodSchema } from '@/lib/zod.schema'
import schema from '@/lib/zod.schema'

Expand Down Expand Up @@ -107,7 +107,9 @@ export function appActionHandler<U, T, R>(

return it
} catch (e) {
await captureException(e)
// @note an error with a known code - not found, not authorized, limits
// reached - is an expected answer the caller renders, not a fault
await captureUnknownException(e)

return {
error: {
Expand Down
12 changes: 6 additions & 6 deletions platform/lib/app.action.utest.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
getContextFrontendHost,
getContextRequestHost,
} from '@/lib/context.store'
import { captureException } from '@/lib/error'
import { captureUnknownException } from '@/lib/response'
import schema from '@/lib/zod.schema'

import {
Expand Down Expand Up @@ -47,8 +47,8 @@ jest.mock('@/lib/context.setup', () => ({
setupHeadersContext: jest.fn(),
}))

jest.mock('@/lib/error', () => ({
captureException: jest.fn(),
jest.mock('@/lib/response', () => ({
captureUnknownException: jest.fn(),
}))

describe('app.action', () => {
Expand Down Expand Up @@ -195,7 +195,7 @@ describe('app.action', () => {
message: 'Test error',
},
})
expect(captureException).toHaveBeenCalledWith(mockError)
expect(captureUnknownException).toHaveBeenCalledWith(mockError)
})

it('should handle validation errors in input schema', async () => {
Expand All @@ -216,7 +216,7 @@ describe('app.action', () => {
const result = await handler({ name: 123 })

expect(result).toHaveProperty('error')
expect(captureException).toHaveBeenCalled()
expect(captureUnknownException).toHaveBeenCalled()
})

it('should handle validation errors in config schema', async () => {
Expand All @@ -237,7 +237,7 @@ describe('app.action', () => {
const result = await handler({})

expect(result).toHaveProperty('error')
expect(captureException).toHaveBeenCalled()
expect(captureUnknownException).toHaveBeenCalled()
})
})

Expand Down
10 changes: 9 additions & 1 deletion platform/lib/highlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ let highlighterPromise: Promise<HighlighterGeneric<string, string>> | null =

export function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = import('shiki').then(({ createHighlighter }) =>
// @note the JavaScript engine rather than the default Oniguruma one: the
// latter needs WebAssembly, which iOS Lockdown Mode removes, and the page
// then fails with an unhandled rejection. Forgiving so a grammar the
// engine cannot compile degrades to plain text instead of throwing.
highlighterPromise = Promise.all([
import('shiki'),
import('shiki/engine/javascript'),
]).then(([{ createHighlighter }, { createJavaScriptRegexEngine }]) =>
createHighlighter({
themes: [githubDark, githubLight],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
)
}
Expand Down
Loading