From 591f874dad30887a80143a061a44bd3ca7ee3299 Mon Sep 17 00:00:00 2001 From: qiaoyanfei Date: Fri, 21 Aug 2026 13:55:14 +0800 Subject: [PATCH 1/4] fix(android): stabilize IME composition and deletion handling (#15370) ## Summary Fix Android editor IME corruption around composition, autocorrect replay, delete, Enter, and old WebView delete behavior. ## What changed - Add Android WebView InputConnection wrapper for editor IME handling. - Route Android delete events through BlockSuite editor input. - Guard against keyboard autocorrect/composition replay after delete or space. - Stabilize delete fallback on older Android/WebView versions. - Gate IME diagnostic logs behind debug builds. - Add Android IME fix notes and regression coverage. ## Validation - Manual Android testing passed. - `git diff --check upstream/canary...HEAD` ## Summary by CodeRabbit * **Bug Fixes** * Improved Android text editing for Backspace, Delete, Enter, composing text, and keyboard events. * Prevented input methods from targeting the wrong editor area. * Improved caret-based text handling, focus synchronization, and composing-session cleanup. * **Platform Improvements** * Added a dedicated Android input bridge for smoother IME interactions and fallback keyboard behavior. * Improved editor actions, input recovery, and trusted-page validation for Android communication. --------- Co-authored-by: DarkSky --- .../blocks/root/src/page/page-root-block.ts | 6 +- blocksuite/affine/rich-text/src/utils.ts | 18 +- .../std/src/__tests__/keymap.unit.spec.ts | 88 ++++- blocksuite/framework/std/src/event/keymap.ts | 38 +- .../std/src/inline/services/event.ts | 163 +++++++++ .../app/affine/pro/AffineEditorWebView.kt | 80 +++++ .../java/app/affine/pro/AffineImeBridge.kt | 64 ++++ .../app/affine/pro/AffineImeWebDispatcher.kt | 70 ++++ .../app/affine/pro/AffineInputConnection.kt | 332 ++++++++++++++++++ .../app/affine/pro/AffineWebViewClient.kt | 25 ++ .../java/app/affine/pro/AndroidImeState.kt | 27 ++ .../app/affine/pro/ImeReplayController.kt | 217 ++++++++++++ .../main/java/app/affine/pro/MainActivity.kt | 18 + .../layout/capacitor_bridge_layout_main.xml | 13 + 14 files changed, 1142 insertions(+), 17 deletions(-) create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineEditorWebView.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeBridge.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeWebDispatcher.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineInputConnection.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineWebViewClient.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AndroidImeState.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ImeReplayController.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/res/layout/capacitor_bridge_layout_main.xml diff --git a/blocksuite/affine/blocks/root/src/page/page-root-block.ts b/blocksuite/affine/blocks/root/src/page/page-root-block.ts index 11b468dad4429..4ddc75b6e90df 100644 --- a/blocksuite/affine/blocks/root/src/page/page-root-block.ts +++ b/blocksuite/affine/blocks/root/src/page/page-root-block.ts @@ -19,6 +19,7 @@ import { getScrollContainer, matchModels, } from '@blocksuite/affine-shared/utils'; +import { IS_ANDROID } from '@blocksuite/global/env'; import { Point } from '@blocksuite/global/gfx'; import type { PointerEventState } from '@blocksuite/std'; import { BlockComponent, BlockSelection, TextSelection } from '@blocksuite/std'; @@ -413,7 +414,10 @@ export class PageRootBlockComponent extends BlockComponent { return !(isNote && displayOnEdgeless); }); - this.contentEditable = String(!this.store.readonly$.value); + // Android IMEs can target this outer editable root instead of a block's + // inline editor, leaving composition text in the DOM without committing it + // to the document model. Keep only the block editors editable on Android. + this.contentEditable = String(!this.store.readonly$.value && !IS_ANDROID); return html`
${children} ${widgets}
diff --git a/blocksuite/affine/rich-text/src/utils.ts b/blocksuite/affine/rich-text/src/utils.ts index 8317ba49a5057..6dbbfb916dc0d 100644 --- a/blocksuite/affine/rich-text/src/utils.ts +++ b/blocksuite/affine/rich-text/src/utils.ts @@ -57,12 +57,14 @@ export function getPrefixText(inlineEditor: InlineEditor) { const inlineRange = inlineEditor.getInlineRange(); if (!inlineRange || inlineRange.length > 0) return ''; - const nearestLineBreakIndex = inlineEditor.yTextString - .slice(0, inlineRange.index) - .lastIndexOf('\n'); - const prefixText = inlineEditor.yTextString.slice( - nearestLineBreakIndex + 1, - inlineRange.index - ); - return prefixText; + const maxMarkdownPrefixLength = 512; + const prefixStart = Math.max(0, inlineRange.index - maxMarkdownPrefixLength); + const yTextString = inlineEditor.yTextString; + const prefixWindow = yTextString.slice(prefixStart, inlineRange.index); + const nearestLineBreakIndex = prefixWindow.lastIndexOf('\n'); + if (nearestLineBreakIndex === -1 && prefixStart > 0) return ''; + + return nearestLineBreakIndex === -1 + ? prefixWindow + : prefixWindow.slice(nearestLineBreakIndex + 1); } diff --git a/blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts b/blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts index e9c0ba05b7b52..1f597ab0d9bd1 100644 --- a/blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts +++ b/blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; -import { bindKeymap } from '../event/keymap.js'; +import { UIEventState, UIEventStateContext } from '../event/base.js'; +import { androidBindKeymapPatch, bindKeymap } from '../event/keymap.js'; const createKeyboardEvent = (options: { key: string; @@ -117,3 +118,86 @@ describe('bindKeymap', () => { expect(handled).toBe(false); }); }); + +describe('androidBindKeymapPatch', () => { + const beforeInputCtx = (inputType: string) => { + const event = new InputEvent('beforeinput', { + inputType, + cancelable: true, + }); + return { ctx: UIEventStateContext.from(new UIEventState(event)), event }; + }; + + test('routes deleteContentBackward to the Backspace binding', () => { + const backspace = vi.fn(() => true); + const handler = androidBindKeymapPatch({ Backspace: backspace }); + const { ctx } = beforeInputCtx('deleteContentBackward'); + + expect(handler(ctx)).toBe(true); + expect(backspace).toHaveBeenCalledOnce(); + }); + + test('routes insertParagraph to the Enter binding', () => { + const enter = vi.fn((ctx: UIEventStateContext) => { + ctx.get('keyboardState').raw.preventDefault(); + return true; + }); + const handler = androidBindKeymapPatch({ Enter: enter }); + const { ctx, event } = beforeInputCtx('insertParagraph'); + const preventDefault = vi.spyOn(event, 'preventDefault'); + + expect(handler(ctx)).toBe(true); + expect(enter).toHaveBeenCalledOnce(); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(ctx.get('keyboardState').raw.key).toBe('Enter'); + expect(ctx.get('keyboardState').composing).toBe(false); + }); + + test('propagates preventDefault when the binding returns false', () => { + const backspace = vi.fn((ctx: UIEventStateContext) => { + ctx.get('keyboardState').raw.preventDefault(); + return false; + }); + const handler = androidBindKeymapPatch({ Backspace: backspace }); + const { ctx, event } = beforeInputCtx('deleteContentBackward'); + + expect(handler(ctx)).toBe(false); + expect(event.defaultPrevented).toBe(true); + }); + + test('does nothing for insertParagraph without an Enter binding', () => { + const handler = androidBindKeymapPatch({ Backspace: vi.fn(() => true) }); + const { ctx } = beforeInputCtx('insertParagraph'); + + expect(handler(ctx)).toBe(false); + expect(ctx.has('keyboardState')).toBe(false); + }); + + test('ignores non-input events', () => { + const enter = vi.fn(() => true); + const backspace = vi.fn(() => true); + const ctx = UIEventStateContext.from( + new UIEventState(new KeyboardEvent('keydown', { key: 'Enter' })) + ); + + expect( + androidBindKeymapPatch({ Enter: enter, Backspace: backspace })(ctx) + ).toBeUndefined(); + expect(enter).not.toHaveBeenCalled(); + expect(backspace).not.toHaveBeenCalled(); + }); + + test('ignores unrelated input types', () => { + const enter = vi.fn(() => true); + const backspace = vi.fn(() => true); + const handler = androidBindKeymapPatch({ + Enter: enter, + Backspace: backspace, + }); + const { ctx } = beforeInputCtx('insertText'); + + expect(handler(ctx)).toBe(false); + expect(enter).not.toHaveBeenCalled(); + expect(backspace).not.toHaveBeenCalled(); + }); +}); diff --git a/blocksuite/framework/std/src/event/keymap.ts b/blocksuite/framework/std/src/event/keymap.ts index ebacbdeb20d88..1f2b07e028c8d 100644 --- a/blocksuite/framework/std/src/event/keymap.ts +++ b/blocksuite/framework/std/src/event/keymap.ts @@ -3,6 +3,7 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { base, keyName } from 'w3c-keyname'; import type { UIEventHandler } from './base.js'; +import { KeyboardEventState } from './state/index.js'; function normalizeKeyName(name: string) { const parts = name.split(/-(?!$)/); @@ -127,13 +128,38 @@ export function androidBindKeymapPatch( const event = ctx.get('defaultState').event; if (!(event instanceof InputEvent)) return; - if ( - event.inputType === 'deleteContentBackward' && - 'Backspace' in bindings - ) { - return bindings['Backspace'](ctx); + const bindingName = + event.inputType === 'deleteContentBackward' + ? 'Backspace' + : event.inputType === 'deleteContentForward' + ? 'Delete' + : event.inputType === 'insertParagraph' + ? 'Enter' + : undefined; + if (!bindingName || !(bindingName in bindings)) return false; + + if (!ctx.has('keyboardState')) { + const keyboardEvent = new KeyboardEvent('keydown', { + key: bindingName, + code: bindingName, + cancelable: true, + }); + Object.defineProperty(keyboardEvent, 'isComposing', { + configurable: true, + value: event.isComposing, + }); + ctx.add( + new KeyboardEventState({ + event: keyboardEvent, + composing: event.isComposing, + }) + ); } - return false; + const handled = bindings[bindingName](ctx); + if (handled || ctx.get('keyboardState').raw.defaultPrevented) { + event.preventDefault(); + } + return handled; }; } diff --git a/blocksuite/framework/std/src/inline/services/event.ts b/blocksuite/framework/std/src/inline/services/event.ts index 5765b313589f5..4d8387ae0e5d1 100644 --- a/blocksuite/framework/std/src/inline/services/event.ts +++ b/blocksuite/framework/std/src/inline/services/event.ts @@ -13,11 +13,58 @@ import { isMaybeInlineRangeEqual } from '../utils/inline-range.js'; import { transformInput } from '../utils/transform-input.js'; import type { BeforeinputHookCtx, CompositionEndHookCtx } from './hook.js'; +type AndroidIMEInputType = 'deleteContentBackward' | 'deleteContentForward'; + +type AndroidIMEInputDetail = { + inputType?: AndroidIMEInputType; + handled?: boolean; +}; + +type AndroidIMEBridge = { + getProtocolVersion?: () => number; + finishComposingSession?: () => void; + finishDeleteSession?: () => void; + setEditorFocused?: (focused: boolean) => void; +}; + +declare global { + interface HTMLElementEventMap { + 'affine-android-ime-input': CustomEvent; + } +} + export class EventService { private _compositionInlineRange: InlineRange | null = null; private _isComposing = false; + private readonly _androidIMEBridge = () => { + const bridge = ( + globalThis as typeof globalThis & { + AffineAndroidIME?: AndroidIMEBridge; + } + ).AffineAndroidIME; + return bridge?.getProtocolVersion?.() === 1 ? bridge : undefined; + }; + + private readonly _finishAndroidComposingSession = (isDelete: boolean) => { + if (!IS_ANDROID) return; + + window.setTimeout(() => { + const bridge = this._androidIMEBridge(); + if (isDelete) { + bridge?.finishDeleteSession?.(); + } else { + bridge?.finishComposingSession?.(); + } + }, 0); + }; + + private readonly _setAndroidEditorFocused = (focused: boolean) => { + if (!IS_ANDROID) return; + this._androidIMEBridge()?.setEditorFocused?.(focused); + }; + private readonly _getClosestInlineRoot = (node: Node): Element | null => { const el = node instanceof Element ? node : node.parentElement; return el?.closest(`[${INLINE_ROOT_ATTR}]`) ?? null; @@ -203,6 +250,97 @@ export class EventService { ); this.editor.slots.inputting.next(event.data ?? ''); + + if ( + IS_ANDROID && + (ctx.raw.inputType === 'deleteContentBackward' || + ctx.raw.inputType === 'deleteContentForward' || + ctx.raw.inputType === 'insertParagraph' || + ctx.raw.inputType === 'insertLineBreak' || + (ctx.raw.inputType === 'insertText' && + (ctx.data === ' ' || ctx.data === '\n'))) + ) { + this._finishAndroidComposingSession( + ctx.raw.inputType === 'deleteContentBackward' || + ctx.raw.inputType === 'deleteContentForward' + ); + } + }; + + private readonly _onAndroidIMEInput = async ( + event: CustomEvent + ) => { + if (!IS_ANDROID) return; + + const inputType = event.detail?.inputType; + if ( + inputType !== 'deleteContentBackward' && + inputType !== 'deleteContentForward' + ) { + return; + } + + const range = this.editor.rangeService.getNativeRange(); + if (!range || !this._isRangeCompletelyInRoot(range)) return; + + event.detail.handled = true; + event.preventDefault(); + event.stopPropagation(); + + if (this.editor.isReadonly) return; + + let inlineRange = this.editor.toInlineRange(range); + if (!inlineRange) { + this.editor.rerenderWholeEditor(); + await this.editor.waitForUpdate(); + const newRange = this.editor.rangeService.getNativeRange(); + inlineRange = newRange ? this.editor.toInlineRange(newRange) : null; + if (!inlineRange) return; + } + + if (inlineRange.length === 0) { + if (inputType === 'deleteContentBackward') { + if (inlineRange.index === 0) return; + inlineRange = { + index: inlineRange.index - 1, + length: 1, + }; + } else { + if (inlineRange.index >= this.editor.yTextLength) return; + inlineRange = { + index: inlineRange.index, + length: 1, + }; + } + } + + this._isComposing = false; + this._compositionInlineRange = null; + + const raw = new InputEvent('beforeinput', { + inputType, + bubbles: true, + cancelable: true, + composed: true, + }); + const ctx: BeforeinputHookCtx = { + inlineEditor: this.editor, + raw, + inlineRange, + data: null, + attributes: {} as TextAttributes, + }; + this.editor.hooks.beforeinput?.(ctx); + + transformInput( + ctx.raw.inputType, + ctx.data, + ctx.attributes, + ctx.inlineRange, + this.editor as never + ); + this.editor.slots.inputting.next(''); + this._finishAndroidComposingSession(true); }; private readonly _onClick = (event: MouseEvent) => { @@ -265,6 +403,7 @@ export class EventService { } this.editor.slots.inputting.next(event.data ?? ''); + this._finishAndroidComposingSession(false); }; private readonly _onCompositionStart = (event: CompositionEvent) => { @@ -432,6 +571,30 @@ export class EventService { this.editor.disposables.addFromEvent(eventSource, 'beforeinput', e => { this._onBeforeInput(e).catch(console.error); }); + this.editor.disposables.addFromEvent( + eventSource, + 'affine-android-ime-input', + e => { + this._onAndroidIMEInput(e).catch(console.error); + } + ); + this.editor.disposables.addFromEvent(eventSource, 'focusin', () => { + this._setAndroidEditorFocused(true); + }); + this.editor.disposables.addFromEvent( + eventSource, + 'focusout', + (event: FocusEvent) => { + const relatedTarget = event.relatedTarget; + if ( + relatedTarget instanceof Node && + this.editor.rootElement?.contains(relatedTarget) + ) { + return; + } + this._setAndroidEditorFocused(false); + } + ); this.editor.disposables.addFromEvent( eventSource, 'compositionstart', diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineEditorWebView.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineEditorWebView.kt new file mode 100644 index 0000000000000..0aa74d5a3bbc4 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineEditorWebView.kt @@ -0,0 +1,80 @@ +package app.affine.pro + +import android.content.Context +import android.util.AttributeSet +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputMethodManager +import com.getcapacitor.CapacitorWebView + +class AffineEditorWebView( + context: Context, + attrs: AttributeSet, +) : CapacitorWebView(context, attrs) { + private val imeState = AndroidImeState() + private var imeBridgeInstalled = false + @Volatile + private var isTrustedPage = false + + private val imeBridge = AffineImeBridge( + isTrustedPage = { this.isTrustedPage }, + clearComposingState = { imeState.nextClearRequestGeneration() }, + requestRestartInput = ::requestRestartInput, + onEditorFocusedChanged = { focused -> imeState.editorFocused = focused }, + ) + + fun updateAndroidIMEBridge(url: String?, expectedOrigin: String?) { + val shouldInstallBridge = isTrustedAffineOrigin(url, expectedOrigin) + if (shouldInstallBridge == imeBridgeInstalled) { + isTrustedPage = shouldInstallBridge + return + } + + if (shouldInstallBridge) { + addJavascriptInterface(imeBridge, AFFINE_IME_BRIDGE_NAME) + imeBridgeInstalled = true + isTrustedPage = true + } else { + isTrustedPage = false + imeBridgeInstalled = false + removeJavascriptInterface(AFFINE_IME_BRIDGE_NAME) + imeState.editorFocused = false + } + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val connection = super.onCreateInputConnection(outAttrs) ?: return null + return AffineInputConnection( + connection, + imeState, + dispatchDeleteBackward = { + dispatchAndroidEditorInput(this, AndroidImeInputType.BACKWARD_DELETE) + }, + dispatchDeleteForward = { + dispatchAndroidEditorInput(this, AndroidImeInputType.FORWARD_DELETE) + }, + ) + } + + private fun requestRestartInput(delayMs: Long) { + val restartGeneration = imeState.nextRestartGeneration() + if (delayMs <= 0L) { + post { restartInput() } + return + } + + postDelayed( + { + if (restartGeneration != imeState.restartInputGeneration) return@postDelayed + restartInput() + }, + delayMs, + ) + } + + private fun restartInput() { + val inputMethodManager = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + inputMethodManager?.restartInput(this) + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeBridge.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeBridge.kt new file mode 100644 index 0000000000000..6dbc964796c66 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeBridge.kt @@ -0,0 +1,64 @@ +package app.affine.pro + +import android.net.Uri +import android.webkit.JavascriptInterface + +internal const val AFFINE_IME_BRIDGE_NAME = "AffineAndroidIME" +internal const val AFFINE_IME_BRIDGE_PROTOCOL_VERSION = 1 +internal const val DELETE_RESTART_INPUT_DEBOUNCE_MS = 120L + +internal fun normalizeAffineOrigin(url: String?): String? { + val uri = Uri.parse(url ?: return null) + val scheme = uri.scheme?.lowercase() ?: return null + val host = uri.host?.lowercase() ?: return null + val isSupportedOrigin = + (scheme == "https" && host == "localhost") || + (BuildConfig.DEBUG && + scheme == "http" && + host in setOf("localhost", "127.0.0.1", "10.0.2.2")) + if (!isSupportedOrigin) return null + + val port = when { + uri.port == -1 -> "" + scheme == "https" && uri.port == 443 -> "" + scheme == "http" && uri.port == 80 -> "" + else -> ":${uri.port}" + } + return "$scheme://$host$port" +} + +internal fun isTrustedAffineOrigin(url: String?, expectedOrigin: String?): Boolean { + return expectedOrigin != null && normalizeAffineOrigin(url) == expectedOrigin +} + +internal class AffineImeBridge( + private val isTrustedPage: () -> Boolean, + private val clearComposingState: () -> Unit, + private val requestRestartInput: (Long) -> Unit, + private val onEditorFocusedChanged: (Boolean) -> Unit, +) { + @JavascriptInterface + fun getProtocolVersion(): Int { + return if (isTrustedPage()) AFFINE_IME_BRIDGE_PROTOCOL_VERSION else 0 + } + + @JavascriptInterface + fun finishComposingSession() { + if (!isTrustedPage()) return + clearComposingState() + requestRestartInput(0L) + } + + @JavascriptInterface + fun finishDeleteSession() { + if (!isTrustedPage()) return + requestRestartInput(DELETE_RESTART_INPUT_DEBOUNCE_MS) + } + + @JavascriptInterface + fun setEditorFocused(focused: Boolean) { + if (isTrustedPage()) { + onEditorFocusedChanged(focused) + } + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeWebDispatcher.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeWebDispatcher.kt new file mode 100644 index 0000000000000..0c6fd8b4e2cf8 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineImeWebDispatcher.kt @@ -0,0 +1,70 @@ +package app.affine.pro + +import android.webkit.WebView + +internal enum class AndroidImeInputType( + val value: String, + val key: String, + val keyCode: Int, +) { + BACKWARD_DELETE("deleteContentBackward", "Backspace", 8), + FORWARD_DELETE("deleteContentForward", "Delete", 46), +} + +internal fun dispatchAndroidEditorInput( + webView: WebView, + inputType: AndroidImeInputType, +) { + webView.post { + webView.evaluateJavascript( + """ + (() => { + try { + const selection = document.getSelection(); + let target = selection?.anchorNode ?? document.activeElement ?? document.body; + if (target && target.nodeType === Node.TEXT_NODE) { + target = target.parentElement; + } + if (!(target instanceof EventTarget)) { + target = document.activeElement ?? document.body; + } + const detail = { + inputType: '${inputType.value}', + handled: false, + }; + const event = new CustomEvent('affine-android-ime-input', { + detail, + bubbles: true, + cancelable: true, + composed: true, + }); + const dispatched = target.dispatchEvent(event); + const handled = detail.handled || !dispatched; + let fallbackKey = null; + if (!handled) { + fallbackKey = '${inputType.key}'; + target.dispatchEvent(new KeyboardEvent('keydown', { + key: '${inputType.key}', + code: '${inputType.key}', + keyCode: ${inputType.keyCode}, + which: ${inputType.keyCode}, + bubbles: true, + cancelable: true, + composed: true, + })); + } + return { + inputType: '${inputType.value}', + handled, + fallbackKey, + }; + } catch (error) { + console.error('[AffineIME] dispatch editor input failed', error); + return { error: String(error) }; + } + })(); + """.trimIndent(), + null, + ) + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineInputConnection.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineInputConnection.kt new file mode 100644 index 0000000000000..0f9024dc219fc --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineInputConnection.kt @@ -0,0 +1,332 @@ +package app.affine.pro + +import android.view.KeyEvent +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper + +internal class AffineInputConnection( + target: InputConnection, + private val state: AndroidImeState, + private val dispatchDeleteBackward: () -> Unit, + private val dispatchDeleteForward: () -> Unit, +) : InputConnectionWrapper(target, true) { + private var handledClearRequestGeneration = state.clearRequestGeneration + private var composingText = "" + private var isComposingTextActive = false + private var isConsumingDeleteKeyEvent = false + + private val replay = ImeReplayController( + deleteBefore = { length -> super.deleteSurroundingText(length, 0) }, + recordDeleteIntent = { recordDeleteIntent() }, + hasRecentDeleteIntent = { currentTime -> hasRecentDeleteIntent(currentTime) }, + ) + + override fun setComposingRegion(start: Int, end: Int): Boolean { + consumeClearRequest() + val regionText = getTextForRegion(start, end) + + val nextComposingText = replay.updateComposingRegion( + regionText, + composingText, + isComposingTextActive, + ) + if (nextComposingText != null) { + composingText = nextComposingText + isComposingTextActive = nextComposingText.isNotEmpty() + } + + // Keep the native composing region untouched so IME autocorrect replay stays in the + // explicit replay state machine instead of being applied twice by the platform. + return true + } + + override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean { + consumeClearRequest() + val nextText = text?.toString() ?: "" + + if (replay.shouldAdoptExternalRegionForReplacement(nextText)) { + replay.clearDroppingReplay() + } + + if (replay.shouldDropExternalReplay(nextText)) { + replay.markDroppingReplay() + replay.deleteForShrinkingExternalReplay(nextText.length) + return true + } + + val adoptedText = replay.adoptExternalRegionAsComposingTextIfNeeded(nextText) + if (adoptedText != null) { + composingText = adoptedText + isComposingTextActive = adoptedText.isNotEmpty() + } + replay.clearExternalRegion() + return applyComposingText(nextText) + } + + override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { + consumeClearRequest() + val committedText = text?.toString() ?: "" + + if ( + replay.shouldDeleteExternalRegionOnEmptyCommit( + committedText, + isComposingTextActive, + ) + ) { + replay.deleteRemainingExternalReplayText() + replay.clearExternalRegion() + return true + } + + if (replay.isDroppingReplay) { + if ( + committedText.isEmpty() || + committedText == replay.currentExternalRegionText + ) { + if (committedText.isEmpty()) { + replay.deleteRemainingExternalReplayTextAfterShrink() + } + return true + } + replay.clearExternalRegion() + } + + if (isComposingTextActive && committedText.isNotEmpty()) { + if (isWordBoundaryCommit(committedText)) { + resetComposingText() + replay.clearExternalRegion() + return super.commitText(text, newCursorPosition) + } + + val result = applyComposingText(committedText) + resetComposingText() + return result + } + + if (committedText.isNotEmpty()) { + replay.clearExternalRegion() + } + + return super.commitText(text, newCursorPosition) + } + + override fun finishComposingText(): Boolean { + consumeClearRequest() + resetComposingText() + replay.clearExternalRegion() + return super.finishComposingText() + } + + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + consumeClearRequest() + recordDeleteIntent(beforeLength, afterLength) + if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) { + replay.clearExternalRegion() + return true + } + replay.clearExternalRegion() + if (isComposingTextActive && beforeLength > 0) { + trimComposingTail(beforeLength, codePoints = false) + } + return super.deleteSurroundingText(beforeLength, afterLength) + } + + override fun deleteSurroundingTextInCodePoints( + beforeLength: Int, + afterLength: Int, + ): Boolean { + consumeClearRequest() + recordDeleteIntent(beforeLength, afterLength) + if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) { + replay.clearExternalRegion() + return true + } + replay.clearExternalRegion() + if (isComposingTextActive && beforeLength > 0) { + trimComposingTail(beforeLength, codePoints = true) + } + return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) + } + + override fun setSelection(start: Int, end: Int): Boolean { + consumeClearRequest() + replay.clearExternalRegion() + resetComposingText() + return super.setSelection(start, end) + } + + override fun performEditorAction(editorAction: Int): Boolean { + consumeClearRequest() + resetComposingText() + replay.clearExternalRegion() + return super.performEditorAction(editorAction) + } + + override fun sendKeyEvent(event: KeyEvent): Boolean { + val isDeleteActionDown = + event.action == KeyEvent.ACTION_DOWN && + (event.keyCode == KeyEvent.KEYCODE_DEL || + event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) + consumeClearRequest(skipNativeFinish = isDeleteActionDown) + + if (event.keyCode == KeyEvent.KEYCODE_DEL) { + if (!state.editorFocused && !isConsumingDeleteKeyEvent) { + return super.sendKeyEvent(event) + } + if (event.action == KeyEvent.ACTION_DOWN) { + recordDeleteIntent() + dispatchDeleteBackward() + isConsumingDeleteKeyEvent = true + return true + } + if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) { + isConsumingDeleteKeyEvent = false + return true + } + } + + if (event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) { + if (!state.editorFocused && !isConsumingDeleteKeyEvent) { + return super.sendKeyEvent(event) + } + if (event.action == KeyEvent.ACTION_DOWN) { + recordDeleteIntent() + dispatchDeleteForward() + isConsumingDeleteKeyEvent = true + return true + } + if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) { + isConsumingDeleteKeyEvent = false + return true + } + } + + if ( + event.action == KeyEvent.ACTION_DOWN && + (event.keyCode == KeyEvent.KEYCODE_SPACE || + event.keyCode == KeyEvent.KEYCODE_ENTER) + ) { + resetComposingText() + replay.clearExternalRegion() + } + return super.sendKeyEvent(event) + } + + private fun applyComposingText(nextText: String): Boolean { + val previousText = composingText + val prefixLength = commonPrefixLength(previousText, nextText) + val deleteCount = previousText.length - prefixLength + val insertText = nextText.substring(prefixLength) + + if (deleteCount > 0) { + super.deleteSurroundingText(deleteCount, 0) + } + if (insertText.isNotEmpty()) { + super.commitText(insertText, 1) + } + + composingText = nextText + isComposingTextActive = nextText.isNotEmpty() + return true + } + + private fun trimComposingTail(beforeLength: Int, codePoints: Boolean) { + val length = + if (codePoints) { + beforeLength.coerceAtMost(composingText.codePointCount(0, composingText.length)) + } else { + beforeLength.coerceAtMost(composingText.length) + } + composingText = + if (codePoints) { + val end = composingText.offsetByCodePoints(composingText.length, -length) + composingText.substring(0, end) + } else { + composingText.dropLast(length) + } + if (composingText.isEmpty()) { + resetComposingText() + } + } + + private fun resetComposingText() { + composingText = "" + isComposingTextActive = false + } + + private fun consumeClearRequest(skipNativeFinish: Boolean = false) { + val clearRequestGeneration = state.clearRequestGeneration + if ( + clearRequestGeneration == 0L || + clearRequestGeneration == handledClearRequestGeneration + ) { + return + } + + handledClearRequestGeneration = clearRequestGeneration + resetComposingText() + replay.clearExternalRegion() + if (skipNativeFinish) { + return + } + super.finishComposingText() + } + + private fun getTextForRegion(start: Int, end: Int): String { + if (start < 0 || end <= start) return "" + + val extractedText = + try { + getExtractedText(ExtractedTextRequest(), 0) + } catch (_: Exception) { + null + } ?: return "" + val text = extractedText.text?.toString().orEmpty() + val localStart = start - extractedText.startOffset + val localEnd = end - extractedText.startOffset + if (localStart < 0 || localEnd > text.length) return "" + + return text.substring(localStart, localEnd) + } + + private fun recordDeleteIntent(beforeLength: Int, afterLength: Int) { + if (beforeLength <= 0 || afterLength != 0) return + recordDeleteIntent() + } + + private fun recordDeleteIntent() { + state.lastDeleteIntentAtMs = android.os.SystemClock.uptimeMillis() + } + + private fun hasRecentDeleteIntent(currentTime: Long): Boolean { + return currentTime - state.lastDeleteIntentAtMs <= IME_REPLAY_DELETE_WINDOW_MS + } + + private fun isWordBoundaryCommit(text: String): Boolean { + return text == " " || text == "\n" + } + + private fun commonPrefixLength(left: String, right: String): Int { + val maxLength = minOf(left.length, right.length) + for (index in 0 until maxLength) { + if (left[index] != right[index]) { + return snapToCodePointBoundary(left, index) + } + } + return snapToCodePointBoundary(left, maxLength) + } + + private fun snapToCodePointBoundary(text: String, index: Int): Int { + return if ( + index > 0 && + index < text.length && + Character.isHighSurrogate(text[index - 1]) && + Character.isLowSurrogate(text[index]) + ) { + index - 1 + } else { + index + } + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineWebViewClient.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineWebViewClient.kt new file mode 100644 index 0000000000000..f3f388588dcab --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineWebViewClient.kt @@ -0,0 +1,25 @@ +package app.affine.pro + +import android.webkit.WebResourceRequest +import android.webkit.WebView +import com.getcapacitor.Bridge +import com.getcapacitor.BridgeWebViewClient + +internal class AffineWebViewClient( + bridge: Bridge, + private val trustedOrigin: String?, +) : BridgeWebViewClient(bridge) { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + val shouldOverride = super.shouldOverrideUrlLoading(view, request) + if (!shouldOverride && request.isForMainFrame) { + (view as? AffineEditorWebView)?.updateAndroidIMEBridge( + request.url.toString(), + trustedOrigin, + ) + } + return shouldOverride + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AndroidImeState.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AndroidImeState.kt new file mode 100644 index 0000000000000..9b86719e319a0 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AndroidImeState.kt @@ -0,0 +1,27 @@ +package app.affine.pro + +internal class AndroidImeState { + @Volatile + var clearRequestGeneration: Long = 0L + + @Volatile + var editorFocused: Boolean = false + + @Volatile + var lastDeleteIntentAtMs: Long = 0L + + @Volatile + var restartInputGeneration: Int = 0 + + @Synchronized + fun nextRestartGeneration(): Int { + restartInputGeneration += 1 + return restartInputGeneration + } + + @Synchronized + fun nextClearRequestGeneration(): Long { + clearRequestGeneration += 1 + return clearRequestGeneration + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ImeReplayController.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ImeReplayController.kt new file mode 100644 index 0000000000000..aaace82506bc6 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ImeReplayController.kt @@ -0,0 +1,217 @@ +package app.affine.pro + +import android.os.SystemClock + +internal const val IME_REPLAY_DELETE_WINDOW_MS = 500L + +internal class ImeReplayController( + private val deleteBefore: (Int) -> Unit, + private val recordDeleteIntent: () -> Unit, + private val hasRecentDeleteIntent: (Long) -> Boolean, + private val now: () -> Long = SystemClock::uptimeMillis, +) { + private var externalRegionText = "" + private var externalRegionAtMs = 0L + private var isDroppingExternalReplay = false + private var lastExternalReplayTextLength = -1 + private var lastExternalReplayTextAtMs = 0L + private var lastExternalReplayDeletedLength = 0 + private var syntheticExternalDeleteAtMs = 0L + + val currentExternalRegionText: String + get() = externalRegionText + + val isDroppingReplay: Boolean + get() = isDroppingExternalReplay + + fun updateComposingRegion( + regionText: String, + composingText: String, + isComposingTextActive: Boolean, + ): String? { + if (!isComposingTextActive) { + externalRegionText = regionText + externalRegionAtMs = now() + isDroppingExternalReplay = false + lastExternalReplayTextLength = regionText.length + lastExternalReplayTextAtMs = externalRegionAtMs + lastExternalReplayDeletedLength = 0 + return null + } + + if (regionText == composingText) return null + + clearExternalRegion() + return regionText + } + + fun shouldAdoptExternalRegionForReplacement(nextText: String): Boolean { + if (nextText.isEmpty() || externalRegionText.isEmpty()) return false + if (externalRegionText.startsWith(nextText)) return false + + val commonPrefixLength = commonPrefixLength(externalRegionText, nextText) + val minPrefixLength = minOf( + MIN_REPLACEMENT_COMMON_PREFIX_LENGTH, + externalRegionText.length, + nextText.length, + ) + + return commonPrefixLength >= minPrefixLength && + nextText.length >= externalRegionText.length + } + + fun shouldDropExternalReplay(nextText: String): Boolean { + if (nextText.isEmpty()) return false + if (isDroppingExternalReplay) { + return !shouldAdoptExternalRegionForReplacement(nextText) + } + if (externalRegionText.isEmpty()) return false + + val currentTime = now() + if ( + currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS && + !shouldAdoptExternalRegionForReplacement(nextText) + ) { + clearExternalRegion() + return false + } + + val isSameRegionReplay = + externalRegionText == nextText && hasRecentDeleteIntent(currentTime) + val isDeleteShrinkReplay = + externalRegionText.startsWith(nextText) && hasRecentDeleteIntent(currentTime) + val isLikelyPassiveReplay = nextText.length > 1 || externalRegionText.length > 1 + + return (isSameRegionReplay || isDeleteShrinkReplay) && isLikelyPassiveReplay + } + + fun markDroppingReplay() { + isDroppingExternalReplay = true + } + + fun clearDroppingReplay() { + isDroppingExternalReplay = false + } + + fun adoptExternalRegionAsComposingTextIfNeeded(nextText: String): String? { + if (externalRegionText.isEmpty()) return null + + val currentTime = now() + if ( + currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS && + !shouldAdoptExternalRegionForReplacement(nextText) + ) { + return null + } + + return externalRegionText + } + + fun clearExternalRegion() { + externalRegionText = "" + externalRegionAtMs = 0L + isDroppingExternalReplay = false + lastExternalReplayTextLength = -1 + lastExternalReplayTextAtMs = 0L + lastExternalReplayDeletedLength = 0 + } + + fun deleteForShrinkingExternalReplay(nextTextLength: Int) { + val currentTime = now() + val deleteCount = + if ( + lastExternalReplayTextLength > 0 && + nextTextLength < lastExternalReplayTextLength && + currentTime - lastExternalReplayTextAtMs <= IME_REPLAY_DELETE_WINDOW_MS + ) { + lastExternalReplayTextLength - nextTextLength + } else { + 0 + } + + if (deleteCount > 0) { + recordDeleteIntent() + deleteBefore(deleteCount) + lastExternalReplayDeletedLength += deleteCount + syntheticExternalDeleteAtMs = now() + } + + lastExternalReplayTextLength = nextTextLength + lastExternalReplayTextAtMs = currentTime + } + + fun deleteRemainingExternalReplayText() { + val currentTime = now() + if ( + lastExternalReplayTextLength <= 0 || + currentTime - lastExternalReplayTextAtMs > IME_REPLAY_DELETE_WINDOW_MS + ) { + return + } + + recordDeleteIntent() + deleteBefore(lastExternalReplayTextLength) + syntheticExternalDeleteAtMs = now() + lastExternalReplayTextLength = 0 + lastExternalReplayTextAtMs = currentTime + } + + fun deleteRemainingExternalReplayTextAfterShrink() { + if (lastExternalReplayDeletedLength <= 0) return + deleteRemainingExternalReplayText() + } + + fun shouldDropNativeDeleteAfterSyntheticExternalDelete( + beforeLength: Int, + afterLength: Int, + ): Boolean { + val currentTime = now() + return beforeLength > 0 && + afterLength == 0 && + currentTime - syntheticExternalDeleteAtMs <= + SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS + } + + fun shouldDeleteExternalRegionOnEmptyCommit( + committedText: String, + isComposingTextActive: Boolean, + ): Boolean { + if (committedText.isNotEmpty()) return false + if (isComposingTextActive || externalRegionText.isEmpty()) return false + if (lastExternalReplayTextLength <= 0) return false + if (lastExternalReplayDeletedLength <= 0) return false + + val currentTime = now() + return currentTime - externalRegionAtMs <= IME_REPLAY_DELETE_WINDOW_MS && + hasRecentDeleteIntent(currentTime) + } + + private fun commonPrefixLength(left: String, right: String): Int { + val maxLength = minOf(left.length, right.length) + for (index in 0 until maxLength) { + if (left[index] != right[index]) { + return snapToCodePointBoundary(left, index) + } + } + return snapToCodePointBoundary(left, maxLength) + } + + private fun snapToCodePointBoundary(text: String, index: Int): Int { + return if ( + index > 0 && + index < text.length && + Character.isHighSurrogate(text[index - 1]) && + Character.isLowSurrogate(text[index]) + ) { + index - 1 + } else { + index + } + } + + private companion object { + const val EXTERNAL_REGION_REPLAY_WINDOW_MS = 500L + const val SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS = 120L + const val MIN_REPLACEMENT_COMMON_PREFIX_LENGTH = 2 + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt index 35fd864a9dd94..fc7612d334ab9 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.view.Gravity import android.view.View import android.webkit.WebSettings +import android.webkit.WebView import androidx.activity.enableEdgeToEdge import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat @@ -30,6 +31,7 @@ import app.affine.pro.service.WebService import app.affine.pro.utils.px2dp import app.affine.pro.utils.dp2px import com.getcapacitor.BridgeActivity +import com.getcapacitor.WebViewListener import com.google.android.material.floatingactionbutton.FloatingActionButton import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -99,10 +101,26 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi override fun load() { super.load() + configureAndroidIMEBridge() AuthInitializer.initialize(bridge) configureEditorWebView() } + private fun configureAndroidIMEBridge() { + val trustedOrigin = normalizeAffineOrigin(bridge.localUrl) + bridge.setWebViewClient(AffineWebViewClient(bridge, trustedOrigin)) + bridge.addWebViewListener(object : WebViewListener() { + override fun onPageCommitVisible(view: WebView?, url: String?) { + (view as? AffineEditorWebView)?.updateAndroidIMEBridge(url, trustedOrigin) + } + }) + + (bridge.webView as? AffineEditorWebView)?.updateAndroidIMEBridge( + bridge.webView.url ?: bridge.localUrl, + trustedOrigin, + ) + } + override fun onTrimMemory(level: Int) { super.onTrimMemory(level) if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { diff --git a/packages/frontend/apps/android/App/app/src/main/res/layout/capacitor_bridge_layout_main.xml b/packages/frontend/apps/android/App/app/src/main/res/layout/capacitor_bridge_layout_main.xml new file mode 100644 index 0000000000000..29e02335b02d7 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/res/layout/capacitor_bridge_layout_main.xml @@ -0,0 +1,13 @@ + + + + + + From dec1a014499af33c211b8557c1244fca4095bb55 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:47:21 +0800 Subject: [PATCH 2/4] fix(server): improve self hosted usability (#15510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #15505 fix #15502 fix #15496 fix #15491 #### PR Dependency Tree * **PR #15510** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit - **New Features** - Added configurable delays for invitations, invite links, and document publishing by newly created accounts. - Added workspace action checks that explain blocked actions and retry timing. - BYOK setup now verifies model capabilities and saves only validated options. - **Bug Fixes** - Improved BYOK probing for chat, structured responses, tool calls, embeddings, reranking, and image generation. - Preserved probe request order and strengthened response validation. - Authentication configuration changes now reload correctly. --- .docker/selfhost/schema.json | 4 +- Cargo.lock | 4 +- packages/backend/native/index.d.ts | 7 + .../src/runtime/backend_runtime/byok/probe.rs | 163 ++++++++++++-- .../rolling_quota/invite_abuse_actions.rs | 4 +- .../backend_runtime/rolling_quota/mod.rs | 8 +- .../rolling_quota/workspace_invite.rs | 87 +++++++- .../rolling_quota/workspace_invite_policy.rs | 94 +++++++- packages/backend/native/src/runtime/config.rs | 19 +- packages/backend/native/src/runtime/types.rs | 7 + .../backend/server/src/core/auth/config.ts | 6 +- .../src/core/backend-runtime/provider.ts | 17 ++ .../core/workspaces/__tests__/abuse.spec.ts | 73 +++++-- .../server/src/core/workspaces/abuse.ts | 36 ++- .../src/core/workspaces/resolvers/doc.ts | 52 +---- .../src/core/workspaces/resolvers/member.ts | 67 +----- packages/frontend/admin/src/config.json | 4 +- .../admin/src/modules/settings/config.ts | 4 +- .../workspace-setting/byok/add-key-modal.tsx | 206 +++++++++--------- .../byok/model-utils.spec.ts | 50 +++++ .../workspace-setting/byok/model-utils.ts | 49 +++++ scripts/set-version.sh | 1 - 22 files changed, 683 insertions(+), 279 deletions(-) diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 6f00d1a3e9616..fea7344555203 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -199,9 +199,9 @@ "description": "Whether require email verification before accessing restricted resources(not implemented).\n@default true", "default": true }, - "newAccountShareActionDelay": { + "newAccountActionDelay": { "type": "number", - "description": "Minimum account age in seconds before new accounts can invite members or create share links.\n@default 86400", + "description": "Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable.\n@default 86400", "default": 86400 }, "trustedCloudflareHeaders": { diff --git a/Cargo.lock b/Cargo.lock index ed7070811343c..03d420c6f1f41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4758,9 +4758,9 @@ dependencies = [ [[package]] name = "llm_adapter" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d13be366ea35d2a9966ad5770e3d070e495af4e1e6dd009638dee4b55ab45ed" +checksum = "02c6b4fa5178b8183331a7d51e8f7ece5421ecb966d3431f3d9ce3c724c6fcc0" dependencies = [ "base64", "jsonschema", diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index e080782cace3b..a9b036b896637 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -34,6 +34,7 @@ export declare class BackendRuntime { claimInviteAbuseAction(actionId: string, workerId: string): Promise claimRetryableInviteAbuseActions(workerId: string, limit: number): Promise> markInviteAbuseAction(actionId: string, workerId: string, status: string, error?: string | undefined | null): Promise + evaluateWorkspaceActionV1(actorUserId: string, workspaceId: string): Promise assertWorkspaceInviteQuotaV1(input: RuntimeWorkspaceInviteQuotaInput): Promise commitWorkspaceInviteQuotaV1(reservationId: string, usage: RuntimeWorkspaceInviteQuotaUsage): Promise releaseWorkspaceInviteQuotaV1(reservationId: string): Promise @@ -1496,6 +1497,12 @@ export interface RuntimeVerificationTokenRecord { expiresAtMs: number } +export interface RuntimeWorkspaceActionDecision { + allowed: boolean + retryAfterSeconds?: number + reason?: string +} + export interface RuntimeWorkspaceArtifact { id: string workspaceId: string diff --git a/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs b/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs index 83275e081d8aa..e9ac38616373a 100644 --- a/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs +++ b/packages/backend/native/src/runtime/backend_runtime/byok/probe.rs @@ -6,10 +6,10 @@ use llm_adapter::{ AttachmentKind, AttachmentSource, ModelFeature, ModelInput, ModelOutput, ModelRequirements, declared_model_matches, }, core::{ - CoreContent, CoreMessage, CoreRequest, CoreRole, CoreToolDefinition, EmbeddingRequest, ImageOptions, - ImageProviderOptions, ImageRequest, RerankCandidate, RerankRequest, StructuredRequest, + CoreContent, CoreMessage, CoreRequest, CoreRole, CoreToolChoice, CoreToolDefinition, EmbeddingRequest, + ImageOptions, ImageProviderOptions, ImageRequest, RerankCandidate, RerankRequest, StructuredRequest, }, - router::{ExecutablePreparedRoute, ExecutableRequest, dispatch_prepared_route}, + router::{ExecutablePreparedRoute, ExecutableRequest, ExecutableResponse, dispatch_prepared_route}, target::{ BackendCredential, BackendEndpoint, BackendOperation, BackendTargetInput, EgressPolicy, compile_backend_target, }, @@ -30,9 +30,11 @@ pub(super) async fn execute_probe( checks: Vec, ) -> RuntimeResult { let tested_at_ms = chrono::Utc::now().timestamp_millis(); - let mut requested = HashSet::new(); + let mut requested = Vec::new(); + let mut requested_set = HashSet::new(); for check in checks { - if !requested.insert((check.model_id.clone(), check.operation.clone())) { + let key = (check.model_id.clone(), check.operation.clone()); + if !requested_set.insert(key.clone()) { return Err(RuntimeError::invalid_input("duplicate BYOK probe check")); } if !matches!( @@ -41,6 +43,7 @@ pub(super) async fn execute_probe( ) { return Err(RuntimeError::invalid_input("unknown BYOK probe operation")); } + requested.push(key); } let mut models = Vec::new(); @@ -160,7 +163,49 @@ fn dispatch_check( Err(_) => return failed(checked_at, "invalid_probe_request"), }; match dispatch_prepared_route(&DefaultHttpClient::default(), &route) { - Ok(_) => verified(checked_at), + Ok(ExecutableResponse::Chat(response)) => { + let valid = if operation == "tool_calling" { + response + .message + .content + .iter() + .any(|content| matches!(content, CoreContent::ToolCall { name, .. } if name == "byok_probe")) + } else { + response + .message + .content + .iter() + .any(|content| matches!(content, CoreContent::Text { text } if !text.trim().is_empty())) + }; + if valid { + verified(checked_at) + } else { + failed(checked_at, "invalid_response") + } + } + Ok(ExecutableResponse::Structured(response)) => { + let valid = response.output_json.as_ref().is_some_and(|output| { + let ExecutableRequest::Structured(request) = &route.request else { + return false; + }; + llm_adapter::schema::validate_json_schema(&request.schema, output).is_ok() + }); + if valid { + verified(checked_at) + } else { + failed(checked_at, "invalid_response") + } + } + Ok(ExecutableResponse::Embedding(response)) if operation == "embedding" && !response.embeddings.is_empty() => { + verified(checked_at) + } + Ok(ExecutableResponse::Rerank(response)) if operation == "rerank" && !response.scores.is_empty() => { + verified(checked_at) + } + Ok(ExecutableResponse::Image(response)) if operation == "image" && !response.images.is_empty() => { + verified(checked_at) + } + Ok(_) => failed(checked_at, "invalid_response"), Err(error) => failed(checked_at, backend_error_kind(&error)), } } @@ -169,7 +214,11 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest { let message = CoreMessage { role: CoreRole::User, content: vec![CoreContent::Text { - text: "Reply with OK.".to_string(), + text: match operation { + "tool_calling" => "Call the byok_probe tool.".to_string(), + "structured" => "Return exactly {\"ok\":true}.".to_string(), + _ => "Reply with OK.".to_string(), + }, }], }; match operation { @@ -177,8 +226,8 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest { model: String::new(), messages: vec![message], stream: false, - max_tokens: Some(8), - temperature: Some(0.0), + max_tokens: Some(64), + temperature: None, tools: if operation == "tool_calling" { vec![CoreToolDefinition { name: "byok_probe".to_string(), @@ -188,7 +237,9 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest { } else { vec![] }, - tool_choice: None, + tool_choice: (operation == "tool_calling").then_some(CoreToolChoice::Specific { + name: "byok_probe".to_string(), + }), include: None, reasoning: None, response_schema: None, @@ -202,8 +253,8 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest { "required": ["ok"], "additionalProperties": false }), - max_tokens: Some(16), - temperature: Some(0.0), + max_tokens: Some(128), + temperature: None, reasoning: None, strict: Some(true), response_mime_type: Some("application/json".to_string()), @@ -426,7 +477,48 @@ mod tests { let mut stream = stream.unwrap(); let request = read_request(&mut stream); let responses = request.starts_with("POST /v1/responses "); - let body = if responses { + let embedding = request.starts_with("POST /v1/embeddings "); + let image = request.starts_with("POST /v1/images/generations "); + let rerank = request.contains("\"logprobs\":true"); + let tool_calling = request.contains("byok_probe"); + let body = if embedding { + json!({ + "model": "smoke-model", + "data": [{ "embedding": [0.1], "index": 0 }], + "usage": { "prompt_tokens": 1, "total_tokens": 1 } + }) + } else if image { + json!({ + "created": 0, + "data": [{ "url": "https://example.com/smoke.png" }] + }) + } else if rerank { + json!({ + "model": "smoke-model", + "choices": [{ + "logprobs": { "content": [{ + "top_logprobs": [ + { "token": "Yes", "logprob": 0.0 }, + { "token": "No", "logprob": -1.0 } + ] + }] } + }] + }) + } else if responses && tool_calling { + json!({ + "id": "resp_smoke", + "model": "smoke-model", + "status": "completed", + "output": [{ + "type": "function_call", + "id": "fc_smoke", + "call_id": "call_smoke", + "name": "byok_probe", + "arguments": "{}" + }], + "usage": { "input_tokens": 1, "output_tokens": 1, "total_tokens": 2 } + }) + } else if responses { json!({ "id": "resp_smoke", "model": "smoke-model", @@ -439,6 +531,25 @@ mod tests { }], "usage": { "input_tokens": 1, "output_tokens": 1, "total_tokens": 2 } }) + } else if tool_calling { + json!({ + "id": "chat_smoke", + "model": "smoke-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_smoke", + "type": "function", + "function": { "name": "byok_probe", "arguments": "{}" } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }) } else { json!({ "id": "chat_smoke", @@ -489,7 +600,7 @@ mod tests { #[test] fn openai_compatible_probe_smoke_uses_the_selected_dialect() { - let operations = ["chat", "structured", "tool_calling"]; + let operations = ["chat", "structured", "tool_calling", "embedding", "rerank", "image"]; let (endpoint, requests, server) = serve_openai_compatible(operations.len() * 2); for dialect in [OpenAiDialect::Responses, OpenAiDialect::ChatCompletions] { @@ -520,19 +631,39 @@ mod tests { .iter() .filter(|request| request.starts_with("POST /v1/responses ")) .count(), - operations.len() + 3 ); assert_eq!( requests .iter() .filter(|request| request.starts_with("POST /v1/chat/completions ")) .count(), - operations.len() + 5 + ); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /v1/embeddings ")) + .count(), + 2 + ); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /v1/images/generations ")) + .count(), + 2 ); assert!(requests.iter().all(|request| !request.contains("/models"))); assert_eq!( requests.iter().filter(|request| request.contains("byok_probe")).count(), 2 ); + assert!( + requests + .iter() + .filter(|request| !request.contains("\"logprobs\":true")) + .all(|request| !request.contains("\"temperature\"")) + ); } } diff --git a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/invite_abuse_actions.rs b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/invite_abuse_actions.rs index 175ca87369b08..0069d36d3cc17 100644 --- a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/invite_abuse_actions.rs +++ b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/invite_abuse_actions.rs @@ -5,7 +5,7 @@ use super::{ BackendRuntime, RuntimeError, RuntimeInviteAbuseClaimedAction, RuntimeResult, napi_error, workspace_subject_key, }; -async fn invite_abuse_user_quarantined_or_banned(pool: &PgPool, user_id: &str) -> RuntimeResult { +pub(super) async fn invite_abuse_user_quarantined_or_banned(pool: &PgPool, user_id: &str) -> RuntimeResult { let row: Option = sqlx::query_scalar( r#" SELECT 1 @@ -22,7 +22,7 @@ async fn invite_abuse_user_quarantined_or_banned(pool: &PgPool, user_id: &str) - Ok(row.is_some()) } -async fn invite_abuse_workspace_quarantined(pool: &PgPool, workspace_id: &str) -> RuntimeResult { +pub(super) async fn invite_abuse_workspace_quarantined(pool: &PgPool, workspace_id: &str) -> RuntimeResult { let row: Option = sqlx::query_scalar( r#" SELECT 1 diff --git a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/mod.rs b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/mod.rs index 7eac4810077e3..5113354df9818 100644 --- a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/mod.rs +++ b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/mod.rs @@ -6,6 +6,7 @@ mod reservation; mod workspace_invite; mod workspace_invite_policy; +use invite_abuse_actions::{invite_abuse_user_quarantined_or_banned, invite_abuse_workspace_quarantined}; use mail_delivery::{build_mail_scopes, decision_from_violation as mail_decision_from_violation, mail_class}; use napi::Result; use reservation::{ @@ -15,8 +16,8 @@ use reservation::{ use sha2::{Digest, Sha256}; use workspace_invite_policy::{ ActorFacts, InviteAbuseDecision, InviteActivityFacts, QuotaFacts, WorkspaceFacts, build_invite_scopes, - evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, source_cohort_subject_key, - subject_hash, sum_domains, + evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, new_account_action_retry_after, + source_cohort_subject_key, subject_hash, sum_domains, }; #[cfg(test)] @@ -26,7 +27,8 @@ pub(super) use super::{ types::{ RuntimeInviteAbuseActionRequired, RuntimeInviteAbuseClaimedAction, RuntimeMailDeliveryQuotaDecision, RuntimeMailDeliveryQuotaInput, RuntimeQuotaSourceInput, RuntimeQuotaTargetDomainInput, - RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage, + RuntimeWorkspaceActionDecision, RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput, + RuntimeWorkspaceInviteQuotaUsage, }, }; diff --git a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite.rs b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite.rs index 560006d032407..d001c0e6ddcab 100644 --- a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite.rs +++ b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite.rs @@ -5,19 +5,32 @@ use sqlx::{PgPool, Row}; use super::{ ActorFacts, BackendRuntime, InviteAbuseDecision, InviteActivityFacts, InviteQuotaConfig, QuotaFacts, QuotaViolation, - RuntimeError, RuntimeInviteAbuseActionRequired, RuntimeResult, RuntimeWorkspaceInviteQuotaDecision, - RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage, WorkspaceFacts, build_invite_scopes, - commit_reservation, evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, napi_error, - normalize_domain, release_reservation, reserve_scopes, short_hash, source_cohort_subject_key, source_prefix, - subject_hash, sum_domains, workspace_subject_key, + RuntimeError, RuntimeInviteAbuseActionRequired, RuntimeResult, RuntimeWorkspaceActionDecision, + RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage, + WorkspaceFacts, build_invite_scopes, commit_reservation, evaluate_projection, high_confidence_invite_abuse, + invite_abuse_user_quarantined_or_banned, invite_abuse_workspace_quarantined, invite_commit_usage_for_scope, + napi_error, new_account_action_retry_after, normalize_domain, release_reservation, reserve_scopes, short_hash, + source_cohort_subject_key, source_prefix, subject_hash, sum_domains, workspace_subject_key, }; async fn load_actor(pool: &PgPool, user_id: &str) -> RuntimeResult { let row = sqlx::query( r#" - SELECT email, created_at, registered, email_verified IS NOT NULL AS email_verified, disabled + SELECT + users.email, + users.created_at, + users.registered, + users.email_verified IS NOT NULL AS email_verified, + users.disabled, + CASE + WHEN quota.known + AND NOT quota.stale + AND (quota.stale_after IS NULL OR quota.stale_after > clock_timestamp()) + THEN quota.plan + END AS quota_plan FROM users - WHERE id = $1 + LEFT JOIN effective_user_quota_states quota ON quota.user_id = users.id + WHERE users.id = $1 "#, ) .bind(user_id) @@ -32,6 +45,7 @@ async fn load_actor(pool: &PgPool, user_id: &str) -> RuntimeResult { registered: row.get("registered"), email_verified: row.get("email_verified"), disabled: row.get("disabled"), + quota_plan: row.get("quota_plan"), }) } @@ -285,6 +299,49 @@ fn decision_from_violation(violation: QuotaViolation, reason: &str) -> RuntimeWo #[napi_derive::napi] impl BackendRuntime { + #[napi] + pub async fn evaluate_workspace_action_v1( + &self, + actor_user_id: String, + workspace_id: String, + ) -> Result { + let runtime_config = self.config()?; + let pool = self.pool().await?; + if invite_abuse_user_quarantined_or_banned(&pool, &actor_user_id).await? { + return Ok(RuntimeWorkspaceActionDecision { + allowed: false, + retry_after_seconds: None, + reason: Some("abuse_subject".to_string()), + }); + } + if invite_abuse_workspace_quarantined(&pool, &workspace_id).await? { + return Ok(RuntimeWorkspaceActionDecision { + allowed: false, + retry_after_seconds: None, + reason: Some("abuse_workspace".to_string()), + }); + } + let now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&pool) + .await + .map_err(|err| RuntimeError::database("failed to read database clock", err))?; + let actor = load_actor(&pool, &actor_user_id).await?; + let quota = load_quota(&pool, &workspace_id).await?; + let current_quota = quota.as_ref().filter(|quota| evaluate_projection(quota, now).is_none()); + let retry_after_seconds = new_account_action_retry_after( + runtime_config.deployment, + &runtime_config.invite_quota, + &actor, + current_quota, + now, + ); + Ok(RuntimeWorkspaceActionDecision { + allowed: retry_after_seconds.is_none(), + retry_after_seconds, + reason: retry_after_seconds.map(|_| "new_account_action_delay".to_string()), + }) + } + #[napi] pub async fn assert_workspace_invite_quota_v1( &self, @@ -388,6 +445,22 @@ impl BackendRuntime { action_required: None, }); } + if let Some(retry_after_seconds) = + new_account_action_retry_after(runtime_config.deployment, config, &actor, Some("a), now) + { + return Ok(RuntimeWorkspaceInviteQuotaDecision { + allowed: false, + reservation_id: None, + retry_after_seconds: Some(retry_after_seconds), + reason: Some("new_account_action_delay".to_string()), + scope_key: None, + window_seconds: None, + limit: None, + current: None, + requested: Some(input.target_count), + action_required: None, + }); + } if let Some(abuse_decision) = high_confidence_invite_abuse(&input, &actor, config) { let reason = abuse_decision.reason; let scope_key = match abuse_decision.subject_kind { diff --git a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite_policy.rs b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite_policy.rs index 56b3b539e322f..e49705025507b 100644 --- a/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite_policy.rs +++ b/packages/backend/native/src/runtime/backend_runtime/rolling_quota/workspace_invite_policy.rs @@ -10,6 +10,7 @@ use super::{ InviteQuotaConfig, RuntimeQuotaTargetDomainInput, RuntimeWorkspaceInviteQuotaInput, ScopeLimit, bucket_seconds, high_risk_domain, napi_error, normalize_domain, scope, short_hash, source_prefix, workspace_subject_key, }; +use crate::llm::Deployment; #[derive(Clone, Debug)] pub(super) struct ActorFacts { @@ -18,6 +19,7 @@ pub(super) struct ActorFacts { pub(super) registered: bool, pub(super) email_verified: bool, pub(super) disabled: bool, + pub(super) quota_plan: Option, } #[derive(Clone, Debug)] @@ -108,9 +110,6 @@ fn base_invite_limits( let mut per_day = 15; let mut per_week = 30; - if account_age < Duration::hours(24) { - return (0, 0, 0, 0); - } if !actor.email_verified { single = 1; per_hour = 1; @@ -185,6 +184,31 @@ pub(super) fn evaluate_projection(quota: &QuotaFacts, now: DateTime) -> Opt None } +pub(super) fn new_account_action_retry_after( + deployment: Deployment, + config: &InviteQuotaConfig, + actor: &ActorFacts, + workspace_quota: Option<&QuotaFacts>, + now: DateTime, +) -> Option { + if deployment == Deployment::SelfHosted || config.new_account_action_delay_seconds <= 0 { + return None; + } + if actor + .quota_plan + .as_deref() + .is_some_and(|plan| matches!(plan, "pro" | "lifetime_pro" | "ai")) + || workspace_quota.map(|quota| quota.plan.as_str()).is_some_and(|plan| { + matches!(plan, "pro" | "lifetime_pro" | "ai") || plan.contains("team") && !plan.contains("trial") + }) + { + return None; + } + + let remaining = config.new_account_action_delay_seconds - (now - actor.created_at).num_seconds(); + (remaining > 0).then(|| remaining.min(i64::from(i32::MAX)) as i32) +} + pub(super) fn build_invite_scopes( input: &RuntimeWorkspaceInviteQuotaInput, actor: &ActorFacts, @@ -442,6 +466,7 @@ mod tests { registered: true, email_verified: true, disabled: false, + quota_plan: None, } } @@ -467,7 +492,7 @@ mod tests { } #[test] - fn seat_based_weekly_limit_binds_paid_team_and_high_risk_domain() { + fn invite_scopes_apply_plan_ceiling_domain_risk_and_graduated_limits() { let now = Utc.with_ymd_and_hms(2026, 7, 6, 0, 0, 0).single().unwrap(); let input = RuntimeWorkspaceInviteQuotaInput { actor_user_id: "u1".to_string(), @@ -500,6 +525,67 @@ mod tests { .find(|scope| scope.scope_key == "invite:quota_subject_domain:workspace:w1:qq.com") .unwrap(); assert_eq!(high_risk.limit, 5); + + let fresh_actor_scopes = build_invite_scopes( + &input, + &user(now - Duration::hours(1)), + &workspace(now - Duration::hours(1)), + "a("paid_team", 10), + &InviteActivityFacts::default(), + &invite_config(), + now, + ) + .unwrap(); + assert_eq!(fresh_actor_scopes[0].limit, 3); + + let mut fresh_actor = user(now - Duration::hours(1)); + assert_eq!( + new_account_action_retry_after( + Deployment::Cloud, + &invite_config(), + &fresh_actor, + Some("a("free", 3)), + now, + ), + Some(23 * 60 * 60) + ); + assert_eq!( + new_account_action_retry_after( + Deployment::SelfHosted, + &invite_config(), + &fresh_actor, + Some("a("free", 3)), + now, + ), + None + ); + assert_eq!( + new_account_action_retry_after( + Deployment::Cloud, + &invite_config(), + &fresh_actor, + Some("a("paid_team", 10)), + now, + ), + None + ); + let mut no_delay = invite_config(); + no_delay.new_account_action_delay_seconds = 0; + assert_eq!( + new_account_action_retry_after(Deployment::Cloud, &no_delay, &fresh_actor, Some("a("free", 3)), now,), + None + ); + fresh_actor.quota_plan = Some("pro".to_string()); + assert_eq!( + new_account_action_retry_after( + Deployment::Cloud, + &invite_config(), + &fresh_actor, + Some("a("free", 3)), + now, + ), + None + ); } #[test] diff --git a/packages/backend/native/src/runtime/config.rs b/packages/backend/native/src/runtime/config.rs index 59d42a89fb851..a89fc78c41e6e 100644 --- a/packages/backend/native/src/runtime/config.rs +++ b/packages/backend/native/src/runtime/config.rs @@ -319,6 +319,7 @@ impl TryFrom for CopilotManagedProfileConfig { #[derive(Clone, Debug)] pub(crate) struct InviteQuotaConfig { + pub(crate) new_account_action_delay_seconds: i64, pub(crate) high_risk_target_domains: Vec, pub(crate) subject_hash_salt: String, pub(crate) mail_class_mapping: BTreeMap, @@ -327,6 +328,7 @@ pub(crate) struct InviteQuotaConfig { impl Default for InviteQuotaConfig { fn default() -> Self { Self { + new_account_action_delay_seconds: 24 * 60 * 60, high_risk_target_domains: [ "qq.com", "proton.me", @@ -479,12 +481,19 @@ fn deployment_from_env() -> Deployment { #[derive(Default, Deserialize)] struct AppConfigFile { + auth: Option, db: Option, crypto: Option, copilot: Option, indexer: Option, } +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthConfigFile { + new_account_action_delay: Option, +} + #[derive(Default, Deserialize)] #[serde(rename_all = "camelCase", default)] struct SearchRuntimeConfigFile { @@ -542,7 +551,11 @@ impl AppConfigFile { } fn invite_quota_config(&self) -> InviteQuotaConfig { - InviteQuotaConfig::default() + let mut config = InviteQuotaConfig::default(); + if let Some(delay) = self.auth.as_ref().and_then(|auth| auth.new_account_action_delay) { + config.new_account_action_delay_seconds = delay.max(0); + } + config } } @@ -985,14 +998,16 @@ mod tests { } #[test] - fn invite_quota_policy_is_internal_not_app_configurable() { + fn invite_abuse_policy_is_internal_while_action_delay_is_configurable() { let app_config = app_config_from_flat_overrides([ + ("auth.newAccountActionDelay", serde_json::json!(123)), ("auth.untrustedPolicyOverride", serde_json::json!("runtime-salt-v2")), ("auth.untrustedDomainList", serde_json::json!(["Example.COM."])), ]) .unwrap(); let config = app_config.invite_quota_config(); + assert_eq!(config.new_account_action_delay_seconds, 123); assert!(!config.high_risk_target_domains.contains(&"example.com".to_string())); assert_ne!(config.subject_hash_salt, "runtime-salt-v2"); assert_eq!( diff --git a/packages/backend/native/src/runtime/types.rs b/packages/backend/native/src/runtime/types.rs index fff6a6c66496e..0ccc09041886a 100644 --- a/packages/backend/native/src/runtime/types.rs +++ b/packages/backend/native/src/runtime/types.rs @@ -263,6 +263,13 @@ pub struct RuntimeWorkspaceInviteQuotaUsage { pub target_domains: Vec, } +#[napi_derive::napi(object)] +pub struct RuntimeWorkspaceActionDecision { + pub allowed: bool, + pub retry_after_seconds: Option, + pub reason: Option, +} + #[napi_derive::napi(object)] pub struct RuntimeInviteAbuseActionRequired { pub action: String, diff --git a/packages/backend/server/src/core/auth/config.ts b/packages/backend/server/src/core/auth/config.ts index 8bc07c6d009c7..de693945c9bda 100644 --- a/packages/backend/server/src/core/auth/config.ts +++ b/packages/backend/server/src/core/auth/config.ts @@ -18,7 +18,7 @@ export interface AuthConfig { allowSignupForOauth: boolean; requireEmailDomainVerification: boolean; requireEmailVerification: boolean; - newAccountShareActionDelay: number; + newAccountActionDelay: number; trustedCloudflareHeaders: boolean; signInRateLimit: ConfigItem<{ ttl: number; @@ -56,8 +56,8 @@ defineModuleConfig('auth', { desc: 'Whether require email verification before accessing restricted resources(not implemented).', default: true, }, - newAccountShareActionDelay: { - desc: 'Minimum account age in seconds before new accounts can invite members or create share links.', + newAccountActionDelay: { + desc: 'Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable.', default: 24 * 60 * 60, shape: z.number().int().min(0), }, diff --git a/packages/backend/server/src/core/backend-runtime/provider.ts b/packages/backend/server/src/core/backend-runtime/provider.ts index 42e4431d2f73a..6ce331e8e2354 100644 --- a/packages/backend/server/src/core/backend-runtime/provider.ts +++ b/packages/backend/server/src/core/backend-runtime/provider.ts @@ -121,6 +121,12 @@ export type RuntimeWorkspaceInviteQuotaUsage = { targetDomains: RuntimeQuotaTargetDomainInput[]; }; +export type RuntimeWorkspaceActionDecision = { + allowed: boolean; + retryAfterSeconds?: number; + reason?: string; +}; + export type RuntimeInviteAbuseAction = | 'ban_actor' | 'quarantine_actor' @@ -213,6 +219,10 @@ export type RuntimeMailDeliveryQuotaDecision = { }; type RuntimeQuotaMethods = RuntimeInstance & { + evaluateWorkspaceActionV1( + actorUserId: string, + workspaceId: string + ): Promise; assertWorkspaceInviteQuotaV1( input: RuntimeWorkspaceInviteQuotaInput ): Promise; @@ -328,6 +338,7 @@ export class BackendRuntimeProvider !updates.copilot && !updates.crypto && !updates.db && + !updates.auth && !updates.indexer && !updates.storages ) { @@ -537,6 +548,12 @@ export class BackendRuntimeProvider ); } + async evaluateWorkspaceActionV1(actorUserId: string, workspaceId: string) { + return await this.measured('evaluateWorkspaceActionV1', rt => + this.quotaRuntime(rt).evaluateWorkspaceActionV1(actorUserId, workspaceId) + ); + } + async commitWorkspaceInviteQuotaV1( reservationId: string, usage: RuntimeWorkspaceInviteQuotaUsage diff --git a/packages/backend/server/src/core/workspaces/__tests__/abuse.spec.ts b/packages/backend/server/src/core/workspaces/__tests__/abuse.spec.ts index fd900083745ed..73e7bcfbd3a82 100644 --- a/packages/backend/server/src/core/workspaces/__tests__/abuse.spec.ts +++ b/packages/backend/server/src/core/workspaces/__tests__/abuse.spec.ts @@ -15,6 +15,9 @@ import { Mockers } from '../../../__tests__/mocks'; import { Config } from '../../../base'; import { ActionForbidden, TooManyRequest } from '../../../base/error'; import { Models, WorkspaceRole } from '../../../models'; +import { BackendRuntimeProvider } from '../../backend-runtime'; +import { EntitlementService } from '../../entitlement'; +import { QuotaService } from '../../quota'; import { getAbuseRequestSource, InviteAbuseDispositionService, @@ -23,6 +26,7 @@ import { let app: TestingApp; const quota = { + assertWorkspaceActionAllowed: Sinon.stub(), assertWorkspaceInviteQuota: Sinon.stub(), commitWorkspaceInviteQuota: Sinon.stub(), releaseWorkspaceInviteQuota: Sinon.stub(), @@ -41,6 +45,7 @@ test.before(async () => { }); test.beforeEach(() => { + quota.assertWorkspaceActionAllowed.reset(); quota.assertWorkspaceInviteQuota.reset(); quota.commitWorkspaceInviteQuota.reset(); quota.releaseWorkspaceInviteQuota.reset(); @@ -345,8 +350,8 @@ test('workspace quarantine blocks invite link creation', async t => { updated_at = now() `; - const previousDelay = config.auth.newAccountShareActionDelay; - config.auth.newAccountShareActionDelay = 0; + const previousDelay = config.auth.newAccountActionDelay; + config.auth.newAccountActionDelay = 0; try { await app.login(owner); await t.throwsAsync( @@ -359,32 +364,58 @@ test('workspace quarantine blocks invite link creation', async t => { }) ); } finally { - config.auth.newAccountShareActionDelay = previousDelay; + config.auth.newAccountActionDelay = previousDelay; } }); -test('domain workspace name blocks invite link creation', async t => { - const config = app.get(Config); +test('workspace action admission applies exemption before content policy', async t => { + const db = app.get(PrismaClient); + const inviteQuota = new InviteQuotaAssertService( + app.get(Config), + app.get(QuotaService), + app.get(BackendRuntimeProvider), + app.get(InviteAbuseDispositionService) + ); const owner = await app.create(Mockers.User); + await db.user.update({ + where: { id: owner.id }, + data: { createdAt: new Date() }, + }); const workspace = await app.create(Mockers.Workspace, { owner, name: 'Join example.com', }); - const previousDelay = config.auth.newAccountShareActionDelay; - config.auth.newAccountShareActionDelay = 0; - try { - await app.login(owner); - await t.throwsAsync( - app.gql({ - query: createInviteLinkMutation, - variables: { - workspaceId: workspace.id, - expireTime: WorkspaceInviteLinkExpireTime.OneDay, - }, - }) - ); - } finally { - config.auth.newAccountShareActionDelay = previousDelay; - } + await t.throwsAsync( + inviteQuota.assertWorkspaceActionAllowed({ + actorUserId: owner.id, + workspaceId: workspace.id, + action: 'inviteMember', + }), + { instanceOf: ActionForbidden } + ); + + await app.get(EntitlementService).upsertAdminGrant({ + targetType: 'user', + targetId: owner.id, + plan: 'pro', + }); + await t.notThrowsAsync( + inviteQuota.assertWorkspaceActionAllowed({ + actorUserId: owner.id, + workspaceId: workspace.id, + action: 'inviteMember', + }) + ); + + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: createInviteLinkMutation, + variables: { + workspaceId: workspace.id, + expireTime: WorkspaceInviteLinkExpireTime.OneDay, + }, + }) + ); }); diff --git a/packages/backend/server/src/core/workspaces/abuse.ts b/packages/backend/server/src/core/workspaces/abuse.ts index bcdde29b03133..49cd8dd97f1dc 100644 --- a/packages/backend/server/src/core/workspaces/abuse.ts +++ b/packages/backend/server/src/core/workspaces/abuse.ts @@ -39,14 +39,6 @@ declare global { } } -export function canUserExecuteLimitedActions( - user: { createdAt: Date }, - minimumAccountAgeMs: number -) { - if (minimumAccountAgeMs <= 0) return true; - return Date.now() - user.createdAt.getTime() >= minimumAccountAgeMs; -} - function parseAsn(value: string | undefined) { if (!value) { return; @@ -237,6 +229,28 @@ export class InviteQuotaAssertService { private readonly disposition: InviteAbuseDispositionService ) {} + async assertWorkspaceActionAllowed(input: { + actorUserId: string; + workspaceId: string; + action: 'inviteMember' | 'createInviteLink' | 'publishDoc'; + docId?: string; + }) { + const decision = await this.runtime.evaluateWorkspaceActionV1( + input.actorUserId, + input.workspaceId + ); + if (decision.allowed) return; + + this.logger.warn('Workspace action rejected', { + ...input, + reason: decision.reason, + retryAfter: decision.retryAfterSeconds, + }); + throw new ActionForbidden( + 'This feature is temporarily unavailable for you.' + ); + } + async assertWorkspaceInviteQuota(input: { actorUserId: string; workspaceId: string; @@ -384,7 +398,11 @@ export class InviteQuotaAssertService { private mapDecision( decision: RuntimeWorkspaceInviteQuotaDecision ): UserFriendlyError { - if (decision.reason === 'abuse_subject' || decision.actionRequired) { + if ( + decision.reason === 'abuse_subject' || + decision.reason === 'new_account_action_delay' || + decision.actionRequired + ) { return new ActionForbidden('This feature is temporarily unavailable.'); } return new TooManyRequest(); diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 6668c05422060..7123743def2a2 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -15,9 +15,7 @@ import { Prisma, PrismaClient } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; import { - ActionForbidden, Cache, - Config, DocActionDenied, DocDefaultRoleCanNotBeOwner, DocNotFound, @@ -46,7 +44,7 @@ import { PermissionAccess, } from '../../permission'; import { PublicUserType, WorkspaceUserType } from '../../user'; -import { canUserExecuteLimitedActions } from '../abuse'; +import { InviteQuotaAssertService } from '../abuse'; import { DocGrantsService } from '../doc-grants'; import { WorkspaceType } from '../types'; import { TimeBucket, TimeWindow } from './analytics-types'; @@ -302,51 +300,10 @@ export class WorkspaceDocResolver { private readonly models: Models, private readonly cache: Cache, private readonly event: EventBus, - private readonly config: Config, - private readonly runtime: BackendRuntimeProvider + private readonly runtime: BackendRuntimeProvider, + private readonly inviteQuota: InviteQuotaAssertService ) {} - private async assertCanShare( - userId: string, - context: { workspaceId: string; docId: string; action: 'publishDoc' } - ) { - if (await this.runtime.isInviteAbuseUserQuarantinedOrBanned(userId)) { - this.logger.warn('Share action blocked for quarantined actor', { - userId, - ...context, - }); - throw new ActionForbidden( - 'This feature is temporarily unavailable for you.' - ); - } - if ( - await this.runtime.isInviteAbuseWorkspaceQuarantined(context.workspaceId) - ) { - this.logger.warn('Share action blocked for quarantined workspace', { - userId, - ...context, - }); - throw new ActionForbidden( - 'This feature is temporarily unavailable for you.' - ); - } - const user = await this.models.user.get(userId); - const newAccountAgeMs = this.config.auth.newAccountShareActionDelay * 1000; - if (!user || !canUserExecuteLimitedActions(user, newAccountAgeMs)) { - this.logger.warn('Share action blocked for new account', { - userId, - email: user?.email, - createdAt: user?.createdAt, - accountAgeMs: user ? Date.now() - user.createdAt.getTime() : null, - minimumAccountAgeMs: newAccountAgeMs, - ...context, - }); - throw new ActionForbidden( - 'This feature is temporarily unavailable for you.' - ); - } - } - @ResolveField(() => WorkspaceDocMeta, { description: 'Cloud page metadata of workspace', complexity: 2, @@ -475,7 +432,8 @@ export class WorkspaceDocResolver { } await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); - await this.assertCanShare(user.id, { + await this.inviteQuota.assertWorkspaceActionAllowed({ + actorUserId: user.id, workspaceId, docId, action: 'publishDoc', diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index 3ab1afb64d1a6..fa6a61e8d2620 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -1,4 +1,3 @@ -import { Logger } from '@nestjs/common'; import { Args, Context, @@ -39,7 +38,6 @@ import { import type { GraphqlContext } from '../../../base/graphql'; import { Models, type WorkspaceUserCompat } from '../../../models'; import { CurrentUser, Public } from '../../auth'; -import { BackendRuntimeProvider } from '../../backend-runtime'; import { containsUrlOrDomain } from '../../content-policy'; import { PermissionAccess, @@ -49,11 +47,7 @@ import { import { QuotaService } from '../../quota'; import { UserType } from '../../user'; import { validators } from '../../utils/validators'; -import { - canUserExecuteLimitedActions, - getAbuseRequestSource, - InviteQuotaAssertService, -} from '../abuse'; +import { getAbuseRequestSource, InviteQuotaAssertService } from '../abuse'; import { WorkspaceService } from '../service'; import { InvitationType, @@ -92,8 +86,6 @@ function aggregateTargetDomains(candidates: InviteCandidate[]) { */ @Resolver(() => WorkspaceType) export class WorkspaceMemberResolver { - private readonly logger = new Logger(WorkspaceMemberResolver.name); - constructor( private readonly cache: Cache, private readonly event: EventBus, @@ -105,55 +97,9 @@ export class WorkspaceMemberResolver { private readonly workspaceService: WorkspaceService, private readonly quota: QuotaService, private readonly config: Config, - private readonly inviteQuota: InviteQuotaAssertService, - private readonly runtime: BackendRuntimeProvider + private readonly inviteQuota: InviteQuotaAssertService ) {} - private async assertCanInviteOrShare( - userId: string, - context: { - workspaceId: string; - action: 'createInviteLink'; - } - ) { - if (await this.runtime.isInviteAbuseUserQuarantinedOrBanned(userId)) { - this.logger.warn('Share action blocked for quarantined actor', { - userId, - ...context, - }); - throw new ActionForbidden( - 'This feature is temporarily unavailable for you.' - ); - } - if ( - await this.runtime.isInviteAbuseWorkspaceQuarantined(context.workspaceId) - ) { - this.logger.warn('Share action blocked for quarantined workspace', { - userId, - ...context, - }); - throw new ActionForbidden( - 'This feature is temporarily unavailable for you.' - ); - } - // Member invites are owned by native quota; this guard stays for invite links until share/link actions migrate. - const user = await this.models.user.get(userId); - const newAccountAgeMs = this.config.auth.newAccountShareActionDelay * 1000; - if (!user || !canUserExecuteLimitedActions(user, newAccountAgeMs)) { - this.logger.warn('Share action blocked for new account', { - userId, - email: user?.email, - createdAt: user?.createdAt, - accountAgeMs: user ? Date.now() - user.createdAt.getTime() : null, - minimumAccountAgeMs: newAccountAgeMs, - ...context, - }); - throw new ActionForbidden( - 'This feature is temporarily unavailable for you.' - ); - } - } - private async assertWorkspaceNameCanInvite(workspaceId: string) { const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId); if (containsUrlOrDomain(workspace.name)) { @@ -287,6 +233,12 @@ export class WorkspaceMemberResolver { return results; } + await this.inviteQuota.assertWorkspaceActionAllowed({ + actorUserId: me.id, + workspaceId, + action: 'inviteMember', + }); + // lock to prevent concurrent invite const lockFlag = `invite:${workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); @@ -452,7 +404,8 @@ export class WorkspaceMemberResolver { .user(user.id) .workspace(workspaceId) .assert('Workspace.Users.Manage'); - await this.assertCanInviteOrShare(user.id, { + await this.inviteQuota.assertWorkspaceActionAllowed({ + actorUserId: user.id, workspaceId, action: 'createInviteLink', }); diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index f1f7c6cc96b46..a32b209aa7485 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -87,9 +87,9 @@ "type": "Boolean", "desc": "Whether require email verification before accessing restricted resources(not implemented)." }, - "newAccountShareActionDelay": { + "newAccountActionDelay": { "type": "Number", - "desc": "Minimum account age in seconds before new accounts can invite members or create share links." + "desc": "Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable." }, "trustedCloudflareHeaders": { "type": "Boolean", diff --git a/packages/frontend/admin/src/modules/settings/config.ts b/packages/frontend/admin/src/modules/settings/config.ts index b1197c15e46be..d75037ddedc96 100644 --- a/packages/frontend/admin/src/modules/settings/config.ts +++ b/packages/frontend/admin/src/modules/settings/config.ts @@ -58,9 +58,9 @@ export const KNOWN_CONFIG_GROUPS = [ 'allowSignup', 'allowSignupForOauth', { - key: 'newAccountShareActionDelay', + key: 'newAccountActionDelay', type: 'Number', - desc: 'Minimum account age in seconds before new accounts can invite members or create share links.', + desc: 'Minimum account age in seconds before accounts can invite members, create invite links, or publish documents. Set to 0 to disable.', }, // nested json object { diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx index 7fb1269a36957..64ed1ba12ff6c 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/add-key-modal.tsx @@ -27,6 +27,7 @@ import { type ModelDeclaration, modelUseCases, probeChecks, + retainVerifiedCapabilities, } from './model-utils'; import type { ByokDefinition, ByokKey, ByokSettings, GqlFn } from './types'; import { ByokStorage } from './types'; @@ -138,7 +139,7 @@ export const AddKeyModal = ({ const invalidateTest = () => setTestStatus(null); const runProbe = useCallback(async () => { - if (!gql) return false; + if (!gql) return { passed: false, definition }; const canReuseServerCredential = editingKey?.storage === ByokStorage.server && !apiKey; const checks = probeChecks(models, includeImageProbe); @@ -159,21 +160,19 @@ export const AddKeyModal = ({ }, }); const probe = result.probeWorkspaceByokDraft; - const verifiedChecks = new Set( - probe.models.flatMap(model => - model.checks - .filter(check => check.status.kind === 'verified') - .map(check => `${model.modelId}\0${check.operation}`) - ) + const nextModels = retainVerifiedCapabilities(models, probe.models); + const nextDefinition = { ...definition, models: nextModels }; + const hasVerifiedCheck = probe.models.some(model => + model.checks.some(check => check.status.kind === 'verified') ); const passed = checks.length > 0 && probe.connection.kind === 'verified' && - checks.every(check => - verifiedChecks.has(`${check.modelId}\0${check.operation}`) - ); + hasVerifiedCheck && + nextModels.some(model => model.enabled && model.capabilities.length > 0); + if (passed) setModels(nextModels); setTestStatus(passed ? 'passed' : 'failed'); - return passed; + return { passed, definition: nextDefinition }; }, [ apiKey, definition, @@ -185,112 +184,118 @@ export const AddKeyModal = ({ workspaceId, ]); - const persist = useCallback(async () => { - if (!gql) return; - if (storage === ByokStorage.local) { - const saved = await upsertLocalKey(workspaceId, { - id: - editingKey?.storage === ByokStorage.local - ? editingKey.id - : crypto.randomUUID(), - provider, - name, - description, - credential: apiKey, - definition, - sortOrder: - editingKey?.storage === ByokStorage.local - ? editingKey.sortOrder - : localKeys.length, - enabled: profileEnabled, - }); - if (!saved) { - notify.error({ - title: byokT(t, 'notify.local-save-failed.title'), - message: byokT(t, 'notify.local-save-failed.message'), - }); - return; - } - setLocalKeys(await readLocalKeys(workspaceId)); - } else if (editingKey?.storage === ByokStorage.server) { - if (editingKey.revision === undefined) { - notify.error({ - title: byokT(t, 'notify.reload-required.title'), - message: byokT(t, 'notify.reload-required.message'), + const persist = useCallback( + async (persistedDefinition = definition) => { + if (!gql) return; + if (storage === ByokStorage.local) { + const saved = await upsertLocalKey(workspaceId, { + id: + editingKey?.storage === ByokStorage.local + ? editingKey.id + : crypto.randomUUID(), + provider, + name, + description, + credential: apiKey, + definition: persistedDefinition, + sortOrder: + editingKey?.storage === ByokStorage.local + ? editingKey.sortOrder + : localKeys.length, + enabled: profileEnabled, }); - return; - } - await gql({ - query: replaceWorkspaceByokProfileMutation, - variables: { - input: { - workspaceId, - profileId: editingKey.id, - expectedRevision: editingKey.revision, - name, - description: description || null, - credential: apiKey || null, - definition, - enabled: profileEnabled, + if (!saved) { + notify.error({ + title: byokT(t, 'notify.local-save-failed.title'), + message: byokT(t, 'notify.local-save-failed.message'), + }); + return; + } + setLocalKeys(await readLocalKeys(workspaceId)); + } else if (editingKey?.storage === ByokStorage.server) { + if (editingKey.revision === undefined) { + notify.error({ + title: byokT(t, 'notify.reload-required.title'), + message: byokT(t, 'notify.reload-required.message'), + }); + return; + } + await gql({ + query: replaceWorkspaceByokProfileMutation, + variables: { + input: { + workspaceId, + profileId: editingKey.id, + expectedRevision: editingKey.revision, + name, + description: description || null, + credential: apiKey || null, + definition: persistedDefinition, + enabled: profileEnabled, + }, }, - }, - }); - await onSaved(); - } else { - await gql({ - query: createWorkspaceByokProfileMutation, - variables: { - input: { - workspaceId, - provider, - name, - description: description || null, - credential: apiKey, - definition, - enabled: profileEnabled, + }); + await onSaved(); + } else { + await gql({ + query: createWorkspaceByokProfileMutation, + variables: { + input: { + workspaceId, + provider, + name, + description: description || null, + credential: apiKey, + definition: persistedDefinition, + enabled: profileEnabled, + }, }, - }, - }); - await onSaved(); - } - onOpenChange(false); - }, [ - apiKey, - definition, - description, - editingKey, - gql, - localKeys.length, - name, - onOpenChange, - onSaved, - provider, - profileEnabled, - setLocalKeys, - storage, - t, - workspaceId, - ]); + }); + await onSaved(); + } + onOpenChange(false); + }, + [ + apiKey, + definition, + description, + editingKey, + gql, + localKeys.length, + name, + onOpenChange, + onSaved, + provider, + profileEnabled, + setLocalKeys, + storage, + t, + workspaceId, + ] + ); const connect = useCallback(async () => { if (busyRef.current) return; busyRef.current = true; setBusy(true); try { - const passed = testStatus === 'passed' || (await runProbe()); - if (!passed) { + const probe = + testStatus === 'passed' + ? { passed: true, definition } + : await runProbe(); + if (!probe.passed) { notify.error({ title: byokT(t, 'notify.test-failed.title'), message: byokT(t, 'notify.operation-failed.message'), }); return; } - await persist(); + await persist(probe.definition); } finally { busyRef.current = false; setBusy(false); } - }, [persist, runProbe, t, testStatus]); + }, [definition, persist, runProbe, t, testStatus]); const testConnection = useCallback(async () => { if (busyRef.current) return; @@ -309,7 +314,10 @@ export const AddKeyModal = ({ !!name.trim() && hasCredential && models.length > 0 && - models.every(model => model.modelId.trim() && model.capabilities.length) && + models.every( + model => + model.modelId.trim() && (!model.enabled || model.capabilities.length) + ) && new Set(models.map(model => model.modelId.trim())).size === models.length && (!customEndpoint || (!!endpoint.trim() && dialect !== null)); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts index c5ea485c86541..83b2db8e9ac65 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.spec.ts @@ -11,6 +11,7 @@ import { capabilitiesForUseCases, type ModelDeclaration, modelUseCases, + retainVerifiedCapabilities, } from './model-utils'; describe('BYOK model capabilities', () => { @@ -60,4 +61,53 @@ describe('BYOK model capabilities', () => { capabilitiesForUseCases(model, ['chat', 'actions', 'vision']) ).toEqual([capability]); }); + + test('drops capabilities that imply failed uses while keeping independent uses', () => { + const embeddingCapability = { + input: [ByokModelInput.text], + output: [ByokModelOutput.embedding], + features: [], + attachmentKinds: [], + attachmentSources: [], + }; + const model: ModelDeclaration = { + modelId: 'multimodal-tools', + enabled: true, + capabilities: [ + { + input: [ByokModelInput.text, ByokModelInput.image], + output: [ByokModelOutput.text], + features: [ByokModelFeature.tool_calling], + attachmentKinds: [ByokAttachmentKind.image], + attachmentSources: [ + ByokAttachmentSource.url, + ByokAttachmentSource.data, + ByokAttachmentSource.bytes, + ByokAttachmentSource.file_handle, + ], + }, + embeddingCapability, + ], + }; + + const retained = retainVerifiedCapabilities( + [model], + [ + { + modelId: model.modelId, + checks: [ + { operation: 'chat', status: { kind: 'failed' } }, + { operation: 'tool_calling', status: { kind: 'verified' } }, + { operation: 'embedding', status: { kind: 'verified' } }, + ], + }, + ] + )[0]; + + expect(retained).toEqual({ + ...model, + capabilities: [embeddingCapability], + }); + expect(modelUseCases(retained)).toEqual(['embedding']); + }); }); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts index bf3fd054c9eaa..183eee8ceec57 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/byok/model-utils.ts @@ -163,6 +163,55 @@ export function probeChecks(models: ModelDeclaration[], includeImage: boolean) { ); } +export function retainVerifiedCapabilities( + models: ModelDeclaration[], + probeModels: Array<{ + modelId: string; + checks: Array<{ operation: string; status: { kind: string } }>; + }> +) { + return models.map(model => { + const probe = probeModels.find(item => item.modelId === model.modelId); + if (!probe) return model; + const failedOperations = new Set( + probe.checks + .filter(check => check.status.kind !== 'verified') + .map(check => check.operation) + ); + const selectedUseCases = modelUseCases(model); + const verifiedUseCases = selectedUseCases.filter(useCase => { + const operation = useCase === 'actions' ? 'tool_calling' : useCase; + if (failedOperations.has(operation)) return false; + + const represented = modelUseCases({ + ...model, + capabilities: [capabilityForUseCase(useCase)], + }); + return represented.every( + representedUseCase => + !failedOperations.has( + representedUseCase === 'actions' + ? 'tool_calling' + : representedUseCase + ) + ); + }); + const rebuiltCapabilities = + selectedUseCases.length > 0 && + verifiedUseCases.length === selectedUseCases.length + ? model.capabilities + : verifiedUseCases.map(capabilityForUseCase); + const capabilities = rebuiltCapabilities.length + ? rebuiltCapabilities + : model.capabilities; + return { + ...model, + enabled: rebuiltCapabilities.length > 0 && model.enabled, + capabilities, + }; + }); +} + export function catalogModels(settings: ByokSettings, provider: ByokProvider) { return ( settings.catalog.providers.find(item => item.provider === provider) diff --git a/scripts/set-version.sh b/scripts/set-version.sh index 38cde249f38c6..6c2e1dfc8c138 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -147,7 +147,6 @@ echo "iOS MARKETING_VERSION: $ios_new_version (app version: $new_version)" update_app_version_in_helm_charts ".github/helm/affine/Chart.yaml" "$new_version" update_app_version_in_helm_charts ".github/helm/affine/charts/graphql/Chart.yaml" "$new_version" update_app_version_in_helm_charts ".github/helm/affine/charts/front/Chart.yaml" "$new_version" -update_app_version_in_helm_charts ".github/helm/affine/charts/doc/Chart.yaml" "$new_version" update_app_stream_version "packages/frontend/apps/electron/resources/affine.metainfo.xml" "$new_version" From 47306699c44229524c2f746674e8d62401dcb622 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Sat, 22 Aug 2026 06:01:41 +0800 Subject: [PATCH 3/4] chore(server): fix connection --- .../native/src/runtime/backend_runtime/mod.rs | 2 +- .../src/runtime/backend_runtime/search/mod.rs | 2 + .../backend_runtime/search/provider/remote.rs | 6 ++- packages/backend/native/src/runtime/http.rs | 13 ++++++ packages/backend/native/src/runtime/mod.rs | 2 + .../src/runtime/object_storage/client.rs | 20 +++------ .../native/src/runtime/object_storage/mod.rs | 2 + .../migration.sql | 38 ++++++++++------- ...0-migrate-legacy-context-blob-artifacts.ts | 42 +++++++++++-------- packages/backend/server/src/server.ts | 4 +- 10 files changed, 79 insertions(+), 52 deletions(-) create mode 100644 packages/backend/native/src/runtime/http.rs diff --git a/packages/backend/native/src/runtime/backend_runtime/mod.rs b/packages/backend/native/src/runtime/backend_runtime/mod.rs index 5b7ad241670b3..7b7491c28257d 100644 --- a/packages/backend/native/src/runtime/backend_runtime/mod.rs +++ b/packages/backend/native/src/runtime/backend_runtime/mod.rs @@ -41,7 +41,7 @@ pub(crate) use super::types; pub(super) use super::{ BackendRuntimeConfig, ConfigSource, InviteQuotaConfig, RuntimeError, RuntimeResult, migrations::{embedding_schema_health, migrate_all_tables}, - napi_error, to_napi_error, + napi_error, to_napi_error, webpki_tls_config, }; use crate::llm::{ ByokLocalLeaseOutput, ByokPolicyOutput, ByokProbeResultOutput, ByokProfileOutput, CreateByokLocalLeaseInput, diff --git a/packages/backend/native/src/runtime/backend_runtime/search/mod.rs b/packages/backend/native/src/runtime/backend_runtime/search/mod.rs index 8b781605e7b15..77f0707d2cf55 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/mod.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/mod.rs @@ -11,6 +11,8 @@ mod worker; pub(super) use runtime::SearchRuntime; pub(super) use types::{RuntimeAggregateRequest, RuntimeSearchRequest}; +pub(super) use super::webpki_tls_config; + const SCHEMA_FINGERPRINT: &str = "search-runtime-v5"; fn exact_token(value: &str) -> String { diff --git a/packages/backend/native/src/runtime/backend_runtime/search/provider/remote.rs b/packages/backend/native/src/runtime/backend_runtime/search/provider/remote.rs index 4ca7ae2a5b377..1bb19e2f0fb40 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/provider/remote.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/provider/remote.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use sqlx::PgPool; use super::{ - super::{store::SearchChange, types::SearchTable}, + super::{store::SearchChange, types::SearchTable, webpki_tls_config}, manticore::{manticore_exact_tokens, manticore_fields, prepare_manticore_payload, prepare_manticore_search}, }; use crate::runtime::{RuntimeError, RuntimeResult, SearchRuntimeConfig}; @@ -33,6 +33,10 @@ impl RemoteProvider { return Err(RuntimeError::config("invalid search provider endpoint")); } let mut client = Client::builder() + .tls_backend_preconfigured( + webpki_tls_config() + .map_err(|error| RuntimeError::invalid_state(format!("search TLS config failed: {error}")))?, + ) .redirect(Policy::none()) .timeout(Duration::from_secs(30)); if config.provider == "manticoresearch" { diff --git a/packages/backend/native/src/runtime/http.rs b/packages/backend/native/src/runtime/http.rs new file mode 100644 index 0000000000000..128f9d2bfcc1a --- /dev/null +++ b/packages/backend/native/src/runtime/http.rs @@ -0,0 +1,13 @@ +use rustls::{ClientConfig, RootCertStore}; + +pub(in crate::runtime) fn webpki_tls_config() -> Result { + let roots = RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + Ok( + ClientConfig::builder_with_provider(rustls::crypto::aws_lc_rs::default_provider().into()) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(), + ) +} diff --git a/packages/backend/native/src/runtime/mod.rs b/packages/backend/native/src/runtime/mod.rs index 0c102ec7e5fe5..4911201dc59dd 100644 --- a/packages/backend/native/src/runtime/mod.rs +++ b/packages/backend/native/src/runtime/mod.rs @@ -4,6 +4,7 @@ pub mod storage_runtime; pub(crate) mod config; mod config_descriptor; pub(crate) mod error; +mod http; pub(crate) mod migrations; pub(crate) mod object_storage; pub(crate) mod types; @@ -15,3 +16,4 @@ pub(crate) use config::{ use config::{SUPPORTED_BYOK_PROVIDERS, validate_copilot_config}; pub use config_descriptor::{AppConfigDescriptor, app_config_descriptors, validate_app_config_value}; pub(crate) use error::{RuntimeError, RuntimeResult, napi_error, to_napi_error}; +pub(in crate::runtime) use http::webpki_tls_config; diff --git a/packages/backend/native/src/runtime/object_storage/client.rs b/packages/backend/native/src/runtime/object_storage/client.rs index 4d4c693d20bd6..dd242caf00c22 100644 --- a/packages/backend/native/src/runtime/object_storage/client.rs +++ b/packages/backend/native/src/runtime/object_storage/client.rs @@ -8,7 +8,6 @@ use reqwest::{ Client as ReqwestClient, Method, StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE, ETAG, HeaderMap, HeaderName, HeaderValue, LAST_MODIFIED}, }; -use rustls::RootCertStore; use rusty_s3::{ Bucket, Credentials, actions::{ @@ -27,6 +26,7 @@ use super::{ ObjectListPage, ObjectMetadata, ObjectPrefix, ObjectPutMetadata, PresignedObjectRequest, completed_multipart_parts, trim_etag, }, + webpki_tls_config, }; const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; @@ -60,7 +60,10 @@ struct ReqwestStorageHttpClient { impl ReqwestStorageHttpClient { fn new(request_timeout_ms: Option) -> ObjectStorageResult { let builder = ReqwestClient::builder() - .tls_backend_preconfigured(Self::webpki_tls_config()?) + .tls_backend_preconfigured( + webpki_tls_config() + .map_err(|err| ObjectStorageError::Config(format!("ObjectStorage TLS config failed: {err}")))?, + ) .timeout(Duration::from_millis( request_timeout_ms.unwrap_or(DEFAULT_REQUEST_TIMEOUT_MS), )); @@ -69,19 +72,6 @@ impl ReqwestStorageHttpClient { }) } - fn webpki_tls_config() -> ObjectStorageResult { - let roots = RootCertStore { - roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), - }; - Ok( - rustls::ClientConfig::builder_with_provider(rustls::crypto::aws_lc_rs::default_provider().into()) - .with_safe_default_protocol_versions() - .map_err(|err| ObjectStorageError::Config(format!("ObjectStorage TLS config failed: {err}")))? - .with_root_certificates(roots) - .with_no_client_auth(), - ) - } - async fn execute(&self, request: StorageHttpRequest) -> ObjectStorageResult { let mut builder = self.client.request(request.method, request.url); for (key, value) in request.headers { diff --git a/packages/backend/native/src/runtime/object_storage/mod.rs b/packages/backend/native/src/runtime/object_storage/mod.rs index 3c0e0a68f6b5b..e67a16bba639c 100644 --- a/packages/backend/native/src/runtime/object_storage/mod.rs +++ b/packages/backend/native/src/runtime/object_storage/mod.rs @@ -14,4 +14,6 @@ pub(in crate::runtime) use backend::{FsStorageConfig, StorageBackendConfig}; pub(in crate::runtime) use config::ObjectStorageConfig; pub(crate) use service::ObjectStorageService; +pub(super) use super::webpki_tls_config; + pub(in crate::runtime) const MAX_BLOB_SIZE: i64 = i32::MAX as i64; diff --git a/packages/backend/server/migrations/20260820120000_cleanup_legacy_copilot_runtime/migration.sql b/packages/backend/server/migrations/20260820120000_cleanup_legacy_copilot_runtime/migration.sql index febc82dba72c4..980cc76716df8 100644 --- a/packages/backend/server/migrations/20260820120000_cleanup_legacy_copilot_runtime/migration.sql +++ b/packages/backend/server/migrations/20260820120000_cleanup_legacy_copilot_runtime/migration.sql @@ -4,26 +4,32 @@ DO $$ BEGIN IF to_regclass('public.ai_contexts') IS NOT NULL AND EXISTS ( + WITH referenced_blobs AS ( + SELECT DISTINCT + session.workspace_id, + value #>> '{}' AS blob_key + FROM ai_contexts context + JOIN ai_sessions_metadata session ON session.id = context.session_id + CROSS JOIN LATERAL jsonb_path_query( + context.config::jsonb, + '$.** ? (@.type() == "string")' + ) AS referenced_value(value) + ) SELECT 1 - FROM ai_contexts context - JOIN ai_sessions_metadata session ON session.id = context.session_id + FROM referenced_blobs referenced JOIN blobs blob - ON blob.workspace_id = session.workspace_id + ON blob.workspace_id = referenced.workspace_id + AND blob.key = referenced.blob_key AND blob.deleted_at IS NULL AND blob.status = 'completed' - WHERE jsonb_path_exists( - context.config::jsonb, - '$.** ? (@ == $blobKey)', - jsonb_build_object('blobKey', to_jsonb(blob.key::text)) - ) - AND NOT EXISTS ( - SELECT 1 - FROM workspace_artifacts artifact - WHERE artifact.workspace_id = session.workspace_id - AND artifact.status = 'ready' - AND artifact.storage_scope = 'blob' - AND artifact.storage_key = concat(session.workspace_id, '/', blob.key) - ) + WHERE NOT EXISTS ( + SELECT 1 + FROM workspace_artifacts artifact + WHERE artifact.workspace_id = referenced.workspace_id + AND artifact.status = 'ready' + AND artifact.storage_scope = 'blob' + AND artifact.storage_key = concat(referenced.workspace_id, '/', blob.key) + ) ) THEN RAISE EXCEPTION 'legacy context blob artifact admission is incomplete; run the data migration before cleanup'; diff --git a/packages/backend/server/src/data/migrations/1786820000000-migrate-legacy-context-blob-artifacts.ts b/packages/backend/server/src/data/migrations/1786820000000-migrate-legacy-context-blob-artifacts.ts index c5c1e68528180..4ad6afa176a97 100644 --- a/packages/backend/server/src/data/migrations/1786820000000-migrate-legacy-context-blob-artifacts.ts +++ b/packages/backend/server/src/data/migrations/1786820000000-migrate-legacy-context-blob-artifacts.ts @@ -39,30 +39,36 @@ export class MigrateLegacyContextBlobArtifacts1786820000000 { const runtime = injector.get(BackendRuntimeProvider, { strict: false }); const blobs = await db.$queryRaw` - SELECT DISTINCT - session.workspace_id AS "workspaceId", + WITH referenced_blobs AS ( + SELECT DISTINCT + session.workspace_id, + value #>> '{}' AS blob_key + FROM ai_contexts context + JOIN ai_sessions_metadata session ON session.id = context.session_id + CROSS JOIN LATERAL jsonb_path_query( + context.config::jsonb, + '$.** ? (@.type() == "string")' + ) AS referenced_value(value) + ) + SELECT + referenced.workspace_id AS "workspaceId", blob.key AS "blobId", blob.mime AS "mimeType" - FROM ai_contexts context - JOIN ai_sessions_metadata session ON session.id = context.session_id + FROM referenced_blobs referenced JOIN blobs blob - ON blob.workspace_id = session.workspace_id + ON blob.workspace_id = referenced.workspace_id + AND blob.key = referenced.blob_key AND blob.deleted_at IS NULL AND blob.status = 'completed' - WHERE jsonb_path_exists( - context.config::jsonb, - '$.** ? (@ == $blobKey)', - jsonb_build_object('blobKey', to_jsonb(blob.key::text)) + WHERE NOT EXISTS ( + SELECT 1 + FROM workspace_artifacts artifact + WHERE artifact.workspace_id = referenced.workspace_id + AND artifact.storage_scope = 'blob' + AND artifact.storage_key = concat(referenced.workspace_id, '/', blob.key) + AND artifact.status = 'ready' ) - AND NOT EXISTS ( - SELECT 1 - FROM workspace_artifacts artifact - WHERE artifact.workspace_id = session.workspace_id - AND artifact.storage_scope = 'blob' - AND artifact.storage_key = concat(session.workspace_id, '/', blob.key) - AND artifact.status = 'ready' - ) - ORDER BY session.workspace_id, blob.key + ORDER BY referenced.workspace_id, blob.key `; for (const blob of blobs) { diff --git a/packages/backend/server/src/server.ts b/packages/backend/server/src/server.ts index d574059f47177..d34f701fbda70 100644 --- a/packages/backend/server/src/server.ts +++ b/packages/backend/server/src/server.ts @@ -42,7 +42,9 @@ export async function run() { const url = app.get(URLHelper); let telemetry: TelemetryService | null = null; try { - telemetry = app.get(TelemetryService, { strict: false }); + if (env.role !== ServerRole.Worker) { + telemetry = app.get(TelemetryService, { strict: false }); + } } catch { telemetry = null; } From 478a1a0f30c39abf5dbf08785264a8e630bd1b4c Mon Sep 17 00:00:00 2001 From: DarkSky Date: Sat, 22 Aug 2026 07:29:58 +0800 Subject: [PATCH 4/4] fix(server): clean up unnecessary refreshes --- .../runtime/backend_runtime/search/runtime.rs | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs b/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs index b353d78ead6f6..ebf75ddb9654f 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs @@ -79,7 +79,6 @@ impl SearchRuntime { } generation::activate(&self.pool, &active).await?; *self.generation.write().await = Some(active); - self.refresh_all_permission_cursors().await?; Ok(()) } @@ -322,7 +321,7 @@ impl SearchRuntime { .await .map_err(|error| RuntimeError::database("load search permission cursor", error))? }; - let applied = applied.unwrap_or(-1); + let applied = applied.unwrap_or_default(); if applied >= revision { return Ok(()); } @@ -330,13 +329,11 @@ impl SearchRuntime { } async fn refresh_permission_cursor(&self, workspace_id: &str) -> RuntimeResult<()> { - let revision: i64 = sqlx::query_scalar( - "SELECT coalesce(max(revision),0)::bigint FROM workspace_permission_changes WHERE workspace_id=$1", - ) - .bind(workspace_id) - .fetch_one(&self.pool) - .await - .map_err(|error| RuntimeError::database("load search permission revision", error))?; + let revision: i64 = sqlx::query_scalar("SELECT revision FROM workspace_permission_revisions WHERE workspace_id=$1") + .bind(workspace_id) + .fetch_one(&self.pool) + .await + .map_err(|error| RuntimeError::database("load search permission revision", error))?; let generation = self.active_generation().await?; if self.remote.is_none() { let mut cursors = self.embedded_permission_cursors.write().await; @@ -358,17 +355,6 @@ impl SearchRuntime { Ok(()) } - async fn refresh_all_permission_cursors(&self) -> RuntimeResult<()> { - let workspace_ids: Vec = sqlx::query_scalar("SELECT id FROM workspaces") - .fetch_all(&self.pool) - .await - .map_err(|error| RuntimeError::database("load search permission workspaces", error))?; - for workspace_id in workspace_ids { - self.refresh_permission_cursor(&workspace_id).await?; - } - Ok(()) - } - pub(in crate::runtime::backend_runtime) async fn reconcile_workspace( &self, capability: SystemSearchCapability,