Skip to content

feat: harden the session lifecycle for the 1.0.0 stable release - #5

Merged
AmisKwok merged 7 commits into
mainfrom
feat/1.0.0-lifecycle-hardening
Sep 21, 2026
Merged

AmisKwok merged 7 commits into
mainfrom
feat/1.0.0-lifecycle-hardening

Conversation

@AmisKwok

@AmisKwok AmisKwok commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Summary / 摘要

First stable release: closes the remaining lifecycle holes (a transient renewal failure no longer signs the user out, a stale refresh can no longer overwrite a newer login) and adds clock-skew tolerance, AuthSession.tryFromJson and a renewing AuthTokenSource.validAccessToken().
首个稳定版本:补上剩余的生命周期漏洞(续期瞬时失败不再让用户登出、迟到的刷新无法覆盖更新的登录),并新增时钟偏移容忍、AuthSession.tryFromJson 与会续期的 AuthTokenSource.validAccessToken()。

Changes / 变更

  • BREAKING: AuthTokenSource gained validAccessToken({Duration? leeway}). Implementations that only expose accessToken must add one line: @override Future<String?> validAccessToken({Duration? leeway}) async => accessToken;. Everything else is additive.
    破坏性:AuthTokenSource 新增 validAccessToken({Duration? leeway})。 只暴露 accessToken 的实现需补一行(同上)。其余均为追加式变更。
  • restore() keeps the persisted session on a transient renewal failure instead of clearing the store and landing on Unauthenticated — the opposite of what refreshFailurePolicy promised. Only a terminal failure clears it now. Concurrent restore() calls share one attempt.
    restore() 在续期瞬时失败时保留持久化会话,不再清空存储并落到 Unauthenticated(那与 refreshFailurePolicy 的承诺相反)。现在只有终局失败才清空;并发 restore() 共享同一次尝试。
  • login / register / loginWith / updateSession bump the epoch, so an in-flight refresh cannot overwrite the session they install (or write back a token minted from an already-rotated refresh token). Single-flight sharing is now epoch-scoped.
    login / register / loginWith / updateSession 会递增 epoch,进行中的刷新无法覆盖它们安装的会话(也不会写回由已轮换的刷新令牌换来的令牌)。单飞共享现在限定在同一 epoch 内。
  • A renewal keeps the signed-in identity (userId / displayName / claims) when the backend returns tokens only; preserveSessionDetails: false opts out. This also fixes an empty SessionHandle.userId on logout.
    续期会保留已登录身份(userId / displayName / claims)当后端只返回令牌时;preserveSessionDetails: false 可关闭。同时修复了登出时 SessionHandle.userId 为空的问题。
  • clockSkew (default 30s) + validAccessToken(leeway:) stop handing out tokens that would die in flight; autoRefreshMinInterval (default 5s) throttles an already-due proactive renewal so very short-lived tokens cannot cause a tight loop.
    clockSkew(默认 30 秒)与 validAccessToken(leeway:) 不再发出途中会失效的令牌;autoRefreshMinInterval(默认 5 秒)为已到期的主动续期设下限,极短寿命令牌不会造成紧密循环。
  • AuthSession.tryFromJson returns null on malformed input; claims now compare (and hash) structurally; mapAuthFailure reads the code of a bare AuthFail.
    AuthSession.tryFromJson 遇畸形数据返回 null;claims 改为结构化比较与哈希;mapAuthFailure 会读取裸 AuthFail 的 code。
  • Storage failures are contained: a store failing on clear() can no longer strand the refresh future (it used to hang and leak an unhandled async error), and updateSession maps a failing save() to UnexpectedAuthException.
    存储失败被收敛:clear() 失败不再让刷新的 Future 悬挂(过去会挂住并泄漏未处理异步错误);updateSession 中失败的 save() 会映射为 UnexpectedAuthException。
  • AuthManagerGroup: forwards every manager knob, adds managerFactory, an account-tagged onStateChanged, activeIdChanges, validAccessToken(), and a restoreAll() that keeps going when one account fails. Also forgets a manager disposed outside the group, and contains a throwing onStateChanged.
    AuthManagerGroup:完整转发管理器调参,新增 managerFactory、带账号标记的 onStateChanged、activeIdChanges、validAccessToken(),以及某账号失败也会继续的 restoreAll();同时会遗忘在分组之外被释放的管理器,并隔离观察者抛出的异常。
  • Docs: README / README_zh / USAGE / CHANGELOG / website pages + docs/ rebuilt; new KNOWN_RISKS.md (repo-internal) lists the accepted risks and the post-release watchlist.
    文档:README / README_zh / USAGE / CHANGELOG / 站点页面与重建后的 docs/;新增 KNOWN_RISKS.md(仓库内部)记录已接受的风险与发版后观察清单。

Context / 背景

Ahead of tagging 1.0.0, the lifecycle was re-reviewed end to end. Four issues were reproduced with throwaway probes before being fixed: a transient renewal failure during restore() destroyed the persisted session; a refresh in flight overwrote a newer login; a renewal erased the identity; and a bare AuthFail lost its code. Each now has a regression test in test/auth_manager_v1_test.dart. 1.0.0 freezes the public surface under semver, so the one breaking change (AuthTokenSource) had to ship now or wait for 2.0.
在打 1.0.0 之前对生命周期做了端到端复查。四个问题先用一次性探针复现再修复:restore() 期间续期瞬时失败会毁掉持久化会话;飞行中的刷新覆盖更新的登录;续期抹掉身份;裸 AuthFail 丢失 code。每个都在 test/auth_manager_v1_test.dart 中留了回归测试。1.0.0 会按 semver 冻结公共 API,因此唯一的破坏性变更(AuthTokenSource)只能现在发,否则要等到 2.0。

Checklist / 检查项

  • Title follows Conventional Commits / 标题符合约定式提交
  • dart analyze + flutter test pass / 静态分析与测试通过(dart analyze 0 issue、dart test 129 passed、example 2 passed、server 0 issue)
  • README.md and README_zh.md kept in sync / 中英文档同步更新(含版本号四处与升级 callout)
  • dart format . reports no changes / 格式化无变更
  • dart pub publish --dry-run — only the "uncommitted files" warning / 发布预演仅剩「未提交文件」告警

Test plan

  • dart analyze && dart test / 包内静态分析与 129 个单元测试
  • cd example && flutter analyze && flutter test / 示例 App 分析与 widget 测试
  • End to end: cd server && dart run bin/server.dart, then cd example && flutter run — log in with user / user, press Call /me, then Expire in 10s and call again to watch the transparent renewal / 端到端:启动后端并运行示例,用 user / user 登录,点 Call /me,再点 Expire in 10s 后重新调用,观察透明续期
  • Cold start offline: kill the backend, restart the app — the persisted session must survive (previously it signed the user out) / 断网冷启动:关掉后端后重启应用,持久化会话应保留(过去会登出)
  • CI green: pana ≥ 120, link-check, pr-title-check / CI 各项通过
  • After merge: cut release/v1.0.0 and tag v1.0.0 to publish / 合并后切 release/v1.0.0 并打 v1.0.0 标签发布

🤖 Generated with Zero Buddy

AmisKwok and others added 7 commits September 22, 2026 02:56
- restore() keeps the persisted session when a renewal fails transiently, instead of clearing the store and signing the user out

- login/register/loginWith/updateSession bump the epoch, so an in-flight refresh can never overwrite the session they install

- single-flight refresh is scoped to the epoch it started in

- a renewal carries userId/displayName/claims over when the backend returns tokens only (preserveSessionDetails)

- clockSkew (default 30s) plus validAccessToken(leeway:) stop handing out tokens that die in flight

- an already-due proactive renewal is throttled by autoRefreshMinInterval instead of spinning

- mapAuthFailure reads the code of a bare AuthFail; claims compare structurally; AuthSession.tryFromJson never throws

- a TokenStore failing on save/clear can no longer strand the refresh future or leak a raw error

- AuthState.session, AuthManagerGroup tuning pass-through, activeIdChanges and restoreAll that keeps going

- onStateChanged errors are contained; the group forgets a manager disposed outside it

BREAKING CHANGE: AuthTokenSource gained validAccessToken({Duration? leeway}). Implementations that only expose accessToken must add:

  @OverRide

  Future<String?> validAccessToken({Duration? leeway}) async => accessToken;
- README.md / README_zh.md: version 1.0.0, stability callout, new knobs (clockSkew, preserveSessionDetails, autoRefreshMinInterval) and API tables

- USAGE.md: renewing AuthTokenSource, clock skew and throttling rows, AuthFail mapping, new pitfalls

- CHANGELOG.md: the 1.0.0 section with fixes, breaking change, additions and the stability promise

- website: Network-Integration, Configuration, Errors, Multi-Account, Auth-State-Machine, Persistence, Token-Store, FAQ and index

- KNOWN_RISKS.md: the accepted risks and the post-release watchlist (repo-internal, excluded via .pubignore)

- AGENTS.md: epoch and transient-failure invariants for future agents
First stable release. The public surface is frozen under semver from here on; see the 1.0.0 changelog entry for the one migration step (AuthTokenSource.validAccessToken).
The root command skips sub-packages that own a pubspec, so server/ and example/ were never formatted before. Also re-runs the formatter with the current toolchain, which indents switch-expression cases like the rest of the code base. No behaviour change: dart analyze and all 129 tests still pass.
It has no analysis_options.yaml, so it inherited the root one, which includes package:flutter_lints - not a dependency here. Every dart command in server/ therefore printed a package-resolution warning. The new file mirrors the root rule set without the Flutter-only bits.
@AmisKwok
AmisKwok merged commit aa4cca9 into main Sep 21, 2026
@AmisKwok
AmisKwok deleted the feat/1.0.0-lifecycle-hardening branch September 21, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant