Refine account sync bridge state model
This commit is contained in:
parent
81bb1adff0
commit
af1a4e5661
118
docs/architecture/account-sync-settings-bridge-state-model.md
Normal file
118
docs/architecture/account-sync-settings-bridge-state-model.md
Normal 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)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user