Refine account sync bridge state model

This commit is contained in:
Haitao Pan 2026-04-19 19:42:00 +08:00
parent 81bb1adff0
commit af1a4e5661
8 changed files with 180 additions and 25 deletions

View File

@ -0,0 +1,118 @@
# Account Sync, Settings, and Bridge State Model
Last Updated: 2026-04-19
This document is the canonical state model for:
- `SettingsSnapshot`
- `AccountSyncState`
- `BRIDGE_SERVER_URL`
- `BRIDGE_AUTH_TOKEN`
The goal is to keep three ownership layers distinct:
- **Persistent settings**: user-visible, non-secret configuration
- **Account sync metadata**: sync outcome plus bridge metadata derived from protected account responses
- **Secure secrets**: tokens and other material that must never enter normal settings persistence
## Ownership Model
```mermaid
flowchart TD
A["accounts.svc.plus\nprotected login / MFA / sync / bootstrap response"] --> B["xworkmate-app"]
B --> C["SettingsSnapshot\npersistent settings"]
B --> D["AccountSyncState\nsync result + metadata"]
B --> E["Secure storage / managed secret\nBRIDGE_AUTH_TOKEN"]
D --> D1["syncState / syncMessage / lastSyncAtMs / lastSyncError"]
D --> D2["syncedDefaults.bridgeServerUrl\nmetadata only"]
D --> D3["tokenConfigured.bridge\nbridge token availability flag"]
E --> E1["bridge.auth_token"]
E --> E2["Authorization: Bearer <token>"]
C --> F["AcpBridgeServerModeConfig.selfHosted"]
D --> G["AcpBridgeServerModeConfig.cloudSynced"]
F --> H["AcpBridgeServerModeConfig.effective"]
G --> H
H --> I["runtime effective endpoint"]
```
## State Flow
```mermaid
stateDiagram-v2
[*] --> SignedOut
SignedOut --> SavingProfile: user edits account/base url/bridge url
SavingProfile --> SignedOut: snapshot saved
SignedOut --> LoggingIn: loginAccount(baseUrl, identifier, password)
LoggingIn --> MfaRequired: server requests MFA
LoggingIn --> Syncing: login succeeds
MfaRequired --> Syncing: MFA verified
Syncing --> Ready: BRIDGE_AUTH_TOKEN + BRIDGE_SERVER_URL processed
Syncing --> Blocked: bridge auth token missing
Syncing --> Blocked: bridge endpoint unavailable
Ready --> SignedOut: logout / clear session
Blocked --> SignedOut: logout / clear session
```
## Field Semantics
```mermaid
flowchart LR
A["BRIDGE_SERVER_URL"] --> B["AccountSyncState.syncedDefaults.bridgeServerUrl"]
A --> C["account sync metadata only"]
A --> D["not runtime source of truth"]
E["BRIDGE_AUTH_TOKEN"] --> F["secure storage / managed secret"]
E --> G["never in SettingsSnapshot"]
E --> H["runtime Authorization header"]
```
## Runtime Resolution
```mermaid
flowchart TD
A["AcpBridgeServerModeConfig.selfHosted"] --> C["effective endpoint"]
B["AccountSyncState + secure storage"] --> D["cloudSynced path"]
D --> C
C --> E["bridge runtime"]
note1["Priority order\n1. selfHosted\n2. cloudSynced when account sync is ready and token exists\n3. default managed bridge endpoint"] --> C
```
### Runtime Invariants
- `selfHosted` always wins when it is configured.
- `cloudSynced` is valid only when account sync is ready and the managed bridge token exists.
- `BRIDGE_SERVER_URL` may be retained in `AccountSyncState.syncedDefaults.bridgeServerUrl`, but it is metadata only.
- `BRIDGE_AUTH_TOKEN` is written to secure storage only, never to normal settings.
- Bridge runtime requests use `Authorization: Bearer <token>` from secure storage.
- The runtime endpoint remains the managed bridge endpoint unless manual `selfHosted` is configured.
## Persistence Rules
- `SettingsSnapshot`
- stores user-facing configuration and bridge mode config
- stores the effective `AcpBridgeServerModeConfig`
- must not store `BRIDGE_AUTH_TOKEN`
- `AccountSyncState`
- stores sync state, timestamps, sync error, and token availability flags
- stores `BRIDGE_SERVER_URL` as sync metadata only
- can be persisted safely in secure storage because it contains no raw token
- Secure storage / managed secret
- stores `BRIDGE_AUTH_TOKEN`
- is the only place that should hold the bridge authorization token
## Cross-References
- [Bridge Sync Contract Chain](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/testing/bridge-sync-contract-chain.md)
- [Settings Integration Configuration Model](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/settings-integration-configuration-model.md)
- [Secure Development Rules](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/security/secure-development-rules.md)

View File

@ -13,6 +13,14 @@ Last Updated: 2026-04-19
- `cloudSynced` 只在 manual Bridge 未配置时作为有效回退来源 - `cloudSynced` 只在 manual Bridge 未配置时作为有效回退来源
- app 不从本地 endpoint preset、旧 module 配置、历史 fallback 恢复 provider catalog - app 不从本地 endpoint preset、旧 module 配置、历史 fallback 恢复 provider catalog
- `xworkmate-bridge` 仍然是 provider catalog、gateway capability、routing resolve 的唯一真源 - `xworkmate-bridge` 仍然是 provider catalog、gateway capability、routing resolve 的唯一真源
- `BRIDGE_SERVER_URL` 只属于 `AccountSyncState` 元数据
- `BRIDGE_AUTH_TOKEN` 只进入 secure storage / managed secret
## Canonical State Model
For the detailed state diagram and ownership rules, see:
- [Account Sync, Settings, and Bridge State Model](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/account-sync-settings-bridge-state-model.md)
## Bridge-Owned Source Of Truth ## Bridge-Owned Source Of Truth

View File

@ -2,18 +2,16 @@
## Scope ## Scope
This note documents the account-driven bridge sync chain after the naming unification to: This note is a companion to the canonical state model in
[Account Sync, Settings, and Bridge State Model](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app/docs/architecture/account-sync-settings-bridge-state-model.md).
- `BRIDGE_SERVER_URL` It focuses on the sync-contract path between:
- `BRIDGE_AUTH_TOKEN`
It focuses on the runtime data path:
- `accounts.svc.plus` - `accounts.svc.plus`
- `xworkmate-app` - `xworkmate-app`
- `xworkmate-bridge` - `xworkmate-bridge`
and the two key client-side parsing assertions: and the two client-side parsing assertions:
- `BRIDGE_SERVER_URL` may be retained in account sync metadata, but does not drive runtime endpoint selection - `BRIDGE_SERVER_URL` may be retained in account sync metadata, but does not drive runtime endpoint selection
- `BRIDGE_AUTH_TOKEN` is written into secure storage - `BRIDGE_AUTH_TOKEN` is written into secure storage
@ -26,7 +24,7 @@ flowchart LR
A["accounts.svc.plus\nprotected login / MFA / sync / bootstrap response"] -->|returns| B["xworkmate-app\nparse BRIDGE_SERVER_URL metadata\nparse BRIDGE_AUTH_TOKEN"] A["accounts.svc.plus\nprotected login / MFA / sync / bootstrap response"] -->|returns| B["xworkmate-app\nparse BRIDGE_SERVER_URL metadata\nparse BRIDGE_AUTH_TOKEN"]
B -->|write metadata only| C["AccountSyncState.syncedDefaults.bridgeServerUrl"] B -->|write metadata only| C["AccountSyncState.syncedDefaults.bridgeServerUrl"]
B -->|write secure only| D["Secure Storage\nbridge.auth_token"] B -->|write secure only| D["Secure Storage\nbridge.auth_token"]
B -->|pin runtime origin| E["cloudSynced.remoteServerSummary.endpoint\nhttps://xworkmate-bridge.svc.plus"] B -->|resolve runtime endpoint via settings + sync state| E["AcpBridgeServerModeConfig.effective.endpoint\nhttps://xworkmate-bridge.svc.plus"]
D -->|Authorization: Bearer <token>| F["xworkmate-app runtime requests"] D -->|Authorization: Bearer <token>| F["xworkmate-app runtime requests"]
F --> G["xworkmate-bridge"] F --> G["xworkmate-bridge"]
``` ```
@ -41,7 +39,7 @@ flowchart TD
B["xworkmate-app"] --> B1["sync state\nmay retain BRIDGE_SERVER_URL-derived bridgeServerUrl as metadata"] B["xworkmate-app"] --> B1["sync state\nmay retain BRIDGE_SERVER_URL-derived bridgeServerUrl as metadata"]
B --> B2["secure storage\nstores BRIDGE_AUTH_TOKEN as bridge.auth_token"] B --> B2["secure storage\nstores BRIDGE_AUTH_TOKEN as bridge.auth_token"]
B --> B3["normal settings/profile\nmust not persist BRIDGE_AUTH_TOKEN"] B --> B3["normal settings/profile\nmust not persist BRIDGE_AUTH_TOKEN"]
B --> B4["runtime bridge origin\nfixed to https://xworkmate-bridge.svc.plus"] B --> B4["runtime bridge origin\nfixed to managed bridge unless selfHosted is configured"]
C["xworkmate-bridge"] --> C1["consume runtime request"] C["xworkmate-bridge"] --> C1["consume runtime request"]
C1 --> C2["does not depend on BRIDGE_SERVER_URL"] C1 --> C2["does not depend on BRIDGE_SERVER_URL"]
@ -61,7 +59,7 @@ sequenceDiagram
Accounts->>App: protected response\nBRIDGE_SERVER_URL\nBRIDGE_AUTH_TOKEN Accounts->>App: protected response\nBRIDGE_SERVER_URL\nBRIDGE_AUTH_TOKEN
App->>SyncState: save bridgeServerUrl as metadata when present App->>SyncState: save bridgeServerUrl as metadata when present
App->>SecureStore: save bridge.auth_token from BRIDGE_AUTH_TOKEN App->>SecureStore: save bridge.auth_token from BRIDGE_AUTH_TOKEN
App->>App: resolve runtime bridge origin = https://xworkmate-bridge.svc.plus App->>App: resolve runtime bridge origin from settings effective config
App->>Bridge: connect with Authorization: Bearer <token> App->>Bridge: connect with Authorization: Bearer <token>
``` ```
@ -70,7 +68,7 @@ sequenceDiagram
```mermaid ```mermaid
flowchart TD flowchart TD
T["Account sync parsing tests"] --> T1["assert BRIDGE_SERVER_URL metadata can enter AccountSyncState.syncedDefaults.bridgeServerUrl"] T["Account sync parsing tests"] --> T1["assert BRIDGE_SERVER_URL metadata can enter AccountSyncState.syncedDefaults.bridgeServerUrl"]
T --> T2["assert runtime bridge endpoint stays pinned to https://xworkmate-bridge.svc.plus"] T --> T2["assert runtime bridge endpoint stays pinned to managed bridge unless selfHosted is configured"]
T --> T3["assert BRIDGE_AUTH_TOKEN -> secure storage target bridge.auth_token"] T --> T3["assert BRIDGE_AUTH_TOKEN -> secure storage target bridge.auth_token"]
T --> T4["assert BRIDGE_AUTH_TOKEN never enters normal settings/profile persistence"] T --> T4["assert BRIDGE_AUTH_TOKEN never enters normal settings/profile persistence"]
T --> T5["assert offline path can still read token from secure storage"] T --> T5["assert offline path can still read token from secure storage"]
@ -79,7 +77,7 @@ flowchart TD
## Expected Invariants ## Expected Invariants
- Runtime bridge endpoint selection must not depend on `BRIDGE_SERVER_URL`. - Runtime bridge endpoint selection must not depend on `BRIDGE_SERVER_URL`.
- The app-facing managed bridge origin is fixed to `https://xworkmate-bridge.svc.plus`. - The app-facing managed bridge origin is fixed to `https://xworkmate-bridge.svc.plus` unless manual `selfHosted` is configured.
- `BRIDGE_SERVER_URL`, when present, is metadata only. - `BRIDGE_SERVER_URL`, when present, is metadata only.
- `BRIDGE_AUTH_TOKEN` is the only bridge token field used by the sync contract. - `BRIDGE_AUTH_TOKEN` is the only bridge token field used by the sync contract.
- `INTERNAL_SERVICE_TOKEN` is not part of the app-side account sync token contract. - `INTERNAL_SERVICE_TOKEN` is not part of the app-side account sync token contract.

View File

@ -635,14 +635,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
} }
Uri? resolveBridgeAcpEndpointInternal() { Uri? resolveBridgeAcpEndpointInternal() {
final explicitBridgeServerUrl =
runtimeEnvironmentValueInternal('BRIDGE_SERVER_URL')?.trim() ?? '';
if (isSupportedExternalAcpEndpoint(explicitBridgeServerUrl)) {
final uri = Uri.tryParse(explicitBridgeServerUrl);
if (uri != null) {
return uri.replace(query: null, fragment: null);
}
}
final modeConfig = settings.acpBridgeServerModeConfig; final modeConfig = settings.acpBridgeServerModeConfig;
final candidate = modeConfig.usesSelfHostedBase final candidate = modeConfig.usesSelfHostedBase
? modeConfig.selfHosted.serverUrl.trim() ? modeConfig.selfHosted.serverUrl.trim()
@ -683,9 +675,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
normalizedHost == bridgeHost && normalizedHost == bridgeHost &&
(bridgePort <= 0 || endpoint.port == bridgePort); (bridgePort <= 0 || endpoint.port == bridgePort);
if (matchesBridgeEndpoint) { if (matchesBridgeEndpoint) {
final bridgeToken = final bridgeToken = (await storeInternal.loadAccountManagedSecret(
runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN') ??
(await storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken, target: kAccountManagedSecretTargetBridgeAuthToken,
))?.trim() ?? ))?.trim() ??
await settingsControllerInternal.loadEffectiveGatewayToken( await settingsControllerInternal.loadEffectiveGatewayToken(

View File

@ -319,6 +319,7 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
apisix: false, apisix: false,
), ),
); );
await _persistAccountSyncStateInternal(controller, nextState);
final currentSettings = controller.snapshotInternal; final currentSettings = controller.snapshotInternal;
final currentModeConfig = currentSettings.acpBridgeServerModeConfig; final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
@ -622,6 +623,10 @@ String _resolveCurrentBridgeServerUrl(
SettingsController controller, { SettingsController controller, {
String bridgeServerUrlOverride = '', String bridgeServerUrlOverride = '',
}) { }) {
final override = bridgeServerUrlOverride.trim();
if (override.isNotEmpty) {
return override;
}
return controller.snapshotInternal.acpBridgeServerModeConfig.effective.endpoint; return controller.snapshotInternal.acpBridgeServerModeConfig.effective.endpoint;
} }

View File

@ -28,6 +28,8 @@ void main() {
accountIdentifierController: controllers.identifier, accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password, accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode, accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: () async {}, onSaveAccountProfile: () async {},
onLogin: () async { onLogin: () async {
loginCount += 1; loginCount += 1;
@ -122,6 +124,8 @@ void main() {
accountIdentifierController: controllers.identifier, accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password, accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode, accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: () async {}, onSaveAccountProfile: () async {},
onLogin: () async {}, onLogin: () async {},
onVerifyMfa: () async {}, onVerifyMfa: () async {},
@ -197,6 +201,8 @@ void main() {
accountIdentifierController: controllers.identifier, accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password, accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode, accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: () async {}, onSaveAccountProfile: () async {},
onLogin: () async {}, onLogin: () async {},
onVerifyMfa: () async {}, onVerifyMfa: () async {},
@ -250,6 +256,8 @@ void main() {
accountIdentifierController: controllers.identifier, accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password, accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode, accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: () async {}, onSaveAccountProfile: () async {},
onLogin: () async {}, onLogin: () async {},
onVerifyMfa: () async {}, onVerifyMfa: () async {},
@ -289,11 +297,19 @@ class _TestControllers {
); );
final TextEditingController password = TextEditingController(); final TextEditingController password = TextEditingController();
final TextEditingController mfaCode = TextEditingController(); final TextEditingController mfaCode = TextEditingController();
final TextEditingController bridgeUrl = TextEditingController(
text: 'https://xworkmate-bridge.svc.plus',
);
final TextEditingController bridgeToken = TextEditingController(
text: 'bridge-token',
);
void dispose() { void dispose() {
baseUrl.dispose(); baseUrl.dispose();
identifier.dispose(); identifier.dispose();
password.dispose(); password.dispose();
mfaCode.dispose(); mfaCode.dispose();
bridgeUrl.dispose();
bridgeToken.dispose();
} }
} }

View File

@ -9,14 +9,19 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
void main() { void main() {
group('Bridge runtime cleanup', () { group('Bridge runtime cleanup', () {
test( test(
'resolves the current synced bridge endpoint before env leftovers', 'keeps runtime pinned to managed bridge while preserving synced metadata',
() async { () async {
final storeRoot = await Directory.systemTemp.createTemp( final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-bridge-runtime-cleanup-', 'xworkmate-bridge-runtime-cleanup-',
); );
addTearDown(() async { addTearDown(() async {
if (await storeRoot.exists()) { if (await storeRoot.exists()) {
await storeRoot.delete(recursive: true); try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Best-effort cleanup. Flutter tests can still hold temporary files
// briefly when teardown starts.
}
} }
}); });
@ -47,7 +52,7 @@ void main() {
expect( expect(
controller.resolveBridgeAcpEndpointInternal()?.toString(), controller.resolveBridgeAcpEndpointInternal()?.toString(),
'https://xworkmate-bridge-alt.svc.plus', kManagedBridgeServerUrl,
); );
expect( expect(
controller controller
@ -55,6 +60,14 @@ void main() {
AssistantExecutionTarget.gateway, AssistantExecutionTarget.gateway,
) )
?.toString(), ?.toString(),
kManagedBridgeServerUrl,
);
expect(
await store.loadAccountSyncState(),
isNotNull,
);
expect(
(await store.loadAccountSyncState())!.syncedDefaults.bridgeServerUrl,
'https://xworkmate-bridge-alt.svc.plus', 'https://xworkmate-bridge-alt.svc.plus',
); );
}, },

View File

@ -127,6 +127,13 @@ void main() {
controller.accountSyncState!.syncedDefaults.bridgeServerUrl, controller.accountSyncState!.syncedDefaults.bridgeServerUrl,
'https://xworkmate-bridge-alt.svc.plus', 'https://xworkmate-bridge-alt.svc.plus',
); );
final persisted = await store.loadAccountSyncState();
expect(persisted, isNotNull);
expect(persisted!.syncState, 'ready');
expect(
persisted.syncedDefaults.bridgeServerUrl,
'https://xworkmate-bridge-alt.svc.plus',
);
expect( expect(
controller controller
.snapshot .snapshot