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 未配置时作为有效回退来源
- app 不从本地 endpoint preset、旧 module 配置、历史 fallback 恢复 provider catalog
- `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

View File

@ -2,18 +2,16 @@
## 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`
- `BRIDGE_AUTH_TOKEN`
It focuses on the runtime data path:
It focuses on the sync-contract path between:
- `accounts.svc.plus`
- `xworkmate-app`
- `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_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"]
B -->|write metadata only| C["AccountSyncState.syncedDefaults.bridgeServerUrl"]
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"]
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 --> B2["secure storage\nstores BRIDGE_AUTH_TOKEN as 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"]
C1 --> C2["does not depend on BRIDGE_SERVER_URL"]
@ -61,7 +59,7 @@ sequenceDiagram
Accounts->>App: protected response\nBRIDGE_SERVER_URL\nBRIDGE_AUTH_TOKEN
App->>SyncState: save bridgeServerUrl as metadata when present
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>
```
@ -70,7 +68,7 @@ sequenceDiagram
```mermaid
flowchart TD
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 --> T4["assert BRIDGE_AUTH_TOKEN never enters normal settings/profile persistence"]
T --> T5["assert offline path can still read token from secure storage"]
@ -79,7 +77,7 @@ flowchart TD
## Expected Invariants
- 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_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.

View File

@ -635,14 +635,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
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 candidate = modeConfig.usesSelfHostedBase
? modeConfig.selfHosted.serverUrl.trim()
@ -683,9 +675,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
normalizedHost == bridgeHost &&
(bridgePort <= 0 || endpoint.port == bridgePort);
if (matchesBridgeEndpoint) {
final bridgeToken =
runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN') ??
(await storeInternal.loadAccountManagedSecret(
final bridgeToken = (await storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
))?.trim() ??
await settingsControllerInternal.loadEffectiveGatewayToken(

View File

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

View File

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

View File

@ -9,14 +9,19 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
group('Bridge runtime cleanup', () {
test(
'resolves the current synced bridge endpoint before env leftovers',
'keeps runtime pinned to managed bridge while preserving synced metadata',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-bridge-runtime-cleanup-',
);
addTearDown(() async {
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(
controller.resolveBridgeAcpEndpointInternal()?.toString(),
'https://xworkmate-bridge-alt.svc.plus',
kManagedBridgeServerUrl,
);
expect(
controller
@ -55,6 +60,14 @@ void main() {
AssistantExecutionTarget.gateway,
)
?.toString(),
kManagedBridgeServerUrl,
);
expect(
await store.loadAccountSyncState(),
isNotNull,
);
expect(
(await store.loadAccountSyncState())!.syncedDefaults.bridgeServerUrl,
'https://xworkmate-bridge-alt.svc.plus',
);
},

View File

@ -127,6 +127,13 @@ void main() {
controller.accountSyncState!.syncedDefaults.bridgeServerUrl,
'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(
controller
.snapshot