收口HostBridge请求信封校验
共享契约新增HostBridge method白名单和request id归一规则 Expo与Tauri壳在能力分发前拒绝非法id和未知method 补充两端测试、配置门禁、原生壳方案和共享决策记录
This commit is contained in:
@@ -705,6 +705,12 @@ const requiredMainSnippets = [
|
||||
'DownloadEvent::Requested { .. } => false',
|
||||
'.on_download(|_webview, event| should_allow_desktop_webview_download(&event))',
|
||||
'NewWindowResponse::Deny',
|
||||
'HOST_BRIDGE_METHODS',
|
||||
'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH',
|
||||
'normalize_request_id',
|
||||
'is_host_bridge_method',
|
||||
'invalid host bridge request id',
|
||||
'invalid host bridge method',
|
||||
'HostBridgeReplayState',
|
||||
'HostBridgeReplayReservation',
|
||||
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
|
||||
|
||||
@@ -24,6 +24,31 @@ use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
|
||||
const HOST_BRIDGE_VERSION: u8 = 1;
|
||||
const HOST_BRIDGE_METHODS: [&str; 23] = [
|
||||
"host.getRuntime",
|
||||
"appearance.getColorScheme",
|
||||
"auth.requestLogin",
|
||||
"payment.request",
|
||||
"share.setTarget",
|
||||
"share.open",
|
||||
"navigation.openNativePage",
|
||||
"app.reloadWebView",
|
||||
"app.openExternalUrl",
|
||||
"app.setTitle",
|
||||
"app.setBadgeCount",
|
||||
"network.status",
|
||||
"clipboard.writeText",
|
||||
"clipboard.readText",
|
||||
"file.exportText",
|
||||
"file.importText",
|
||||
"file.exportImage",
|
||||
"file.importImage",
|
||||
"file.captureImage",
|
||||
"file.importAudio",
|
||||
"file.exportAudio",
|
||||
"haptics.impact",
|
||||
"notification.showLocal",
|
||||
];
|
||||
const WEB_APP_ORIGIN: &str = "https://app.genarrative.world";
|
||||
const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"];
|
||||
const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024;
|
||||
@@ -39,6 +64,7 @@ const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200;
|
||||
const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80;
|
||||
const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240;
|
||||
const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
|
||||
const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120;
|
||||
const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128;
|
||||
const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray";
|
||||
const TRAY_MENU_SHOW: &str = "show-main-window";
|
||||
@@ -296,15 +322,54 @@ fn color_scheme_from_theme(theme: Theme) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_control_character(value: &str) -> bool {
|
||||
value.chars().any(|character| {
|
||||
let code_point = character as u32;
|
||||
code_point <= 31 || code_point == 127
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_request_id(raw_id: &str) -> Option<String> {
|
||||
let id = raw_id.trim();
|
||||
if id.is_empty()
|
||||
|| id.chars().count() > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH
|
||||
|| has_control_character(id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(id.to_string())
|
||||
}
|
||||
|
||||
fn is_host_bridge_method(method: &str) -> bool {
|
||||
HOST_BRIDGE_METHODS.contains(&method)
|
||||
}
|
||||
|
||||
fn validate_request(request: &HostBridgeRequest) -> Option<HostBridgeResponse> {
|
||||
let Some(request_id) = normalize_request_id(&request.id) else {
|
||||
return Some(failed(
|
||||
"invalid".to_string(),
|
||||
"invalid_request",
|
||||
"invalid host bridge request id",
|
||||
));
|
||||
};
|
||||
|
||||
if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION {
|
||||
return Some(failed(
|
||||
request.id.clone(),
|
||||
request_id,
|
||||
"invalid_request",
|
||||
"invalid host bridge envelope",
|
||||
));
|
||||
}
|
||||
|
||||
if !is_host_bridge_method(&request.method) {
|
||||
return Some(failed(
|
||||
request_id,
|
||||
"invalid_request",
|
||||
"invalid host bridge method",
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1676,11 +1741,12 @@ async fn execute_host_bridge_request(
|
||||
async fn host_bridge_request(
|
||||
app: tauri::AppHandle,
|
||||
replay_state: tauri::State<'_, HostBridgeReplayState>,
|
||||
request: HostBridgeRequest,
|
||||
mut request: HostBridgeRequest,
|
||||
) -> Result<HostBridgeResponse, String> {
|
||||
if let Some(response) = validate_request(&request) {
|
||||
return Ok(response);
|
||||
}
|
||||
request.id = normalize_request_id(&request.id).unwrap_or(request.id);
|
||||
|
||||
let response = match replay_state.reserve(&request.id) {
|
||||
HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot),
|
||||
@@ -1903,6 +1969,38 @@ mod tests {
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_request_id_and_unknown_method_are_rejected() {
|
||||
for id in ["", "request\n1"] {
|
||||
let mut invalid = request("share.open");
|
||||
invalid.id = id.to_string();
|
||||
|
||||
let response = resolve_host_bridge_request(invalid);
|
||||
|
||||
assert!(!response.ok);
|
||||
assert_eq!(response.id, "invalid");
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
}
|
||||
|
||||
let mut oversized = request("share.open");
|
||||
oversized.id = "a".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + 1);
|
||||
let response = resolve_host_bridge_request(oversized);
|
||||
assert!(!response.ok);
|
||||
assert_eq!(response.id, "invalid");
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
|
||||
let mut multibyte_boundary = request("host.getRuntime");
|
||||
multibyte_boundary.id = "作".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH);
|
||||
let response = resolve_host_bridge_request(multibyte_boundary);
|
||||
assert!(response.ok);
|
||||
|
||||
let response = resolve_host_bridge_request(request("host.runArbitraryCommand"));
|
||||
assert!(!response.ok);
|
||||
let error = response.error.expect("error");
|
||||
assert_eq!(error.code, "invalid_request");
|
||||
assert_eq!(error.message, "invalid host bridge method");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() {
|
||||
let replay_state = HostBridgeReplayState::default();
|
||||
|
||||
@@ -649,6 +649,8 @@ for (const snippet of [
|
||||
'normalizeHostBridgeExportFileName',
|
||||
'normalizeHostBridgeClipboardText',
|
||||
'base64Data',
|
||||
'isHostBridgeMethod',
|
||||
'normalizeHostBridgeRequestId',
|
||||
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
|
||||
'completedHostBridgeResponses',
|
||||
'inFlightHostBridgeResponses',
|
||||
|
||||
@@ -198,6 +198,21 @@ async function send(requestValue: HostBridgeRequest) {
|
||||
return response;
|
||||
}
|
||||
|
||||
async function sendRaw(requestValue: unknown) {
|
||||
const responses: HostBridgeResponse[] = [];
|
||||
|
||||
await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) =>
|
||||
responses.push(response),
|
||||
);
|
||||
|
||||
const response = responses[0];
|
||||
if (!response) {
|
||||
throw new Error('host bridge response missing');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function expectOk(response: HostBridgeResponse) {
|
||||
if (!response.ok) {
|
||||
throw new Error('expected ok host bridge response');
|
||||
@@ -431,6 +446,31 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
},
|
||||
);
|
||||
|
||||
test('拒绝非法 request id 和未知 method', async () => {
|
||||
const invalidId = await sendRaw({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: 'request\n1',
|
||||
method: 'share.open',
|
||||
});
|
||||
const unknownMethod = await sendRaw({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: 'request-unknown',
|
||||
method: 'host.runArbitraryCommand',
|
||||
});
|
||||
|
||||
expect(expectFailed(invalidId).error).toEqual({
|
||||
code: 'invalid_request',
|
||||
message: 'invalid host bridge request',
|
||||
});
|
||||
expect(expectFailed(unknownMethod).error).toEqual({
|
||||
code: 'invalid_request',
|
||||
message: 'invalid host bridge request',
|
||||
});
|
||||
expect(Share.share).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('app.reloadWebView 刷新移动壳当前 WebView', async () => {
|
||||
const reloadWebView = vi.fn();
|
||||
configureMobileHostBridgeNavigation({
|
||||
|
||||
@@ -37,12 +37,14 @@ import {
|
||||
type HostBridgeResponse,
|
||||
type HostBridgeTextMimeType,
|
||||
type NavigateNativePagePayload,
|
||||
isHostBridgeMethod,
|
||||
normalizeHostBridgeBadgeCount,
|
||||
normalizeHostBridgeClipboardText,
|
||||
normalizeHostBridgeColorScheme,
|
||||
normalizeHostBridgeExportFileName,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
normalizeHostBridgeLocalNotification,
|
||||
normalizeHostBridgeRequestId,
|
||||
type OpenExternalUrlPayload,
|
||||
type SetBadgeCountPayload,
|
||||
type ShareOpenPayload,
|
||||
@@ -204,11 +206,12 @@ function isHostBridgeRequest(value: unknown): value is HostBridgeRequest {
|
||||
}
|
||||
|
||||
const candidate = value as Partial<HostBridgeRequest>;
|
||||
const requestId = normalizeHostBridgeRequestId(candidate.id);
|
||||
return (
|
||||
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
||||
candidate.version === HOST_BRIDGE_VERSION &&
|
||||
typeof candidate.id === 'string' &&
|
||||
typeof candidate.method === 'string'
|
||||
requestId !== null &&
|
||||
isHostBridgeMethod(candidate.method)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1040,6 +1043,7 @@ export async function handleMobileHostBridgeMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
parsed.id = normalizeHostBridgeRequestId(parsed.id) ?? parsed.id;
|
||||
sendResponse(await resolveMobileHostBridgeResponse(parsed));
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
- 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。
|
||||
- 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。
|
||||
- 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。
|
||||
- 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。
|
||||
- 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。
|
||||
- 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 也会统一扫描两端壳生产源码,防止把临时替身、占位文案或伪实现带进可分发壳。
|
||||
- 2026-06-18 移动壳启动页与 adaptive icon:Expo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。
|
||||
@@ -2392,6 +2393,13 @@
|
||||
- 影响范围:`apps/mobile-shell/src/mobileHostBridge.ts`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。
|
||||
- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-18 HostBridge request envelope 校验
|
||||
|
||||
- 背景:HostBridge 请求来自 H5 WebView / Tauri 注入通道,TypeScript 类型不能替代宿主运行时校验;空 id、控制字符 id、过长 id 或未知 method 如果进入 replay / 能力分发,可能污染缓存、绕过方法白名单或造成错误语义混乱。
|
||||
- 决策:共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 `HOST_BRIDGE_METHODS` 和 request id 规则。request id 归一后必须为 1-120 字符且不含控制字符;未知 method 在进入能力分发前返回 `invalid_request`,只有白名单内但当前壳未实现的登录 / 支付等 method 返回 `unsupported_method`。
|
||||
- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/mobileHostBridge.ts`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。
|
||||
- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-18 移动壳渠道 SDK 依赖收口
|
||||
|
||||
- 背景:Expo 移动壳运行时依赖可能从根安装树解析;如果只检查 `apps/mobile-shell/package.json`,根 H5 包仍可能直接引入 Expo Updates、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等移动端发布通道、崩溃上报或 analytics SDK,让壳边界绕过真实渠道契约。
|
||||
|
||||
@@ -229,7 +229,7 @@ GameBridge 禁止:
|
||||
|
||||
## 安全约束
|
||||
|
||||
- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。
|
||||
- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape;`id` 归一后必须是 1-120 字符且不含控制字符,`method` 必须来自共享白名单,未知 method 作为非法 request 拒绝。
|
||||
- 壳层只接受来自允许 origin / packaged asset 的消息。
|
||||
- H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。
|
||||
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。
|
||||
@@ -350,6 +350,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:HostBridge request id 进入宿主侧 replay 门禁。Expo 壳会缓存已完成响应并让进行中的同 id 请求共用同一执行结果;Tauri 壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 对同 id 请求做等待 / 回放。重复 id 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。
|
||||
|
||||
2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。
|
||||
|
||||
2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*` 或 `ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp`。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。
|
||||
|
||||
2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
isHostBridgeMethod,
|
||||
isHostBridgeCapability,
|
||||
normalizeHostBridgeBadgeCount,
|
||||
normalizeHostBridgeClipboardText,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
normalizeHostBridgeExternalUrl,
|
||||
normalizeHostBridgeLifecycleState,
|
||||
normalizeHostBridgeLocalNotification,
|
||||
normalizeHostBridgeRequestId,
|
||||
} from './hostBridge';
|
||||
|
||||
describe('HostBridge shared contract helpers', () => {
|
||||
@@ -51,6 +53,10 @@ describe('HostBridge shared contract helpers', () => {
|
||||
});
|
||||
|
||||
test('识别 HostBridge 能力白名单', () => {
|
||||
expect(isHostBridgeMethod('host.getRuntime')).toBe(true);
|
||||
expect(isHostBridgeMethod('share.open')).toBe(true);
|
||||
expect(isHostBridgeMethod('app.lifecycle')).toBe(false);
|
||||
expect(isHostBridgeMethod('unknown.method')).toBe(false);
|
||||
expect(isHostBridgeCapability('appearance.getColorScheme')).toBe(true);
|
||||
expect(isHostBridgeCapability('share.open')).toBe(true);
|
||||
expect(isHostBridgeCapability('app.reloadWebView')).toBe(true);
|
||||
@@ -71,6 +77,14 @@ describe('HostBridge shared contract helpers', () => {
|
||||
expect(isHostBridgeCapability(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('归一化 HostBridge request id', () => {
|
||||
expect(normalizeHostBridgeRequestId(' request-1 ')).toBe('request-1');
|
||||
expect(normalizeHostBridgeRequestId('')).toBeNull();
|
||||
expect(normalizeHostBridgeRequestId('request\n1')).toBeNull();
|
||||
expect(normalizeHostBridgeRequestId('a'.repeat(121))).toBeNull();
|
||||
expect(normalizeHostBridgeRequestId(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('归一化宿主剪贴板读取文本', () => {
|
||||
expect(normalizeHostBridgeClipboardText('作品号 PZ-1')).toEqual({
|
||||
text: '作品号 PZ-1',
|
||||
|
||||
@@ -41,6 +41,13 @@ export const HOST_BRIDGE_METHODS = [
|
||||
|
||||
export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number];
|
||||
|
||||
export function isHostBridgeMethod(value: unknown): value is HostBridgeMethod {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
HOST_BRIDGE_METHODS.includes(value as HostBridgeMethod)
|
||||
);
|
||||
}
|
||||
|
||||
export const HOST_BRIDGE_CAPABILITIES = [
|
||||
...HOST_BRIDGE_METHODS,
|
||||
'host.events',
|
||||
@@ -78,6 +85,25 @@ export type HostBridgeRequest<Payload = unknown> = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120;
|
||||
|
||||
export function normalizeHostBridgeRequestId(rawId: unknown) {
|
||||
if (typeof rawId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = rawId.trim();
|
||||
if (
|
||||
!id ||
|
||||
id.length > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH ||
|
||||
hasHostBridgeControlCharacter(id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export type HostBridgeError = {
|
||||
code:
|
||||
| 'invalid_request'
|
||||
|
||||
Reference in New Issue
Block a user