feat(dart): Dart/Flutter SDK parity with TypeScript — morph_core, morph_oauth2, morph_logger, morph_storage + Flutter POC#37
feat(dart): Dart/Flutter SDK parity with TypeScript — morph_core, morph_oauth2, morph_logger, morph_storage + Flutter POC#37cemal-yilmaz-bt wants to merge 35 commits into
Conversation
- Add packages/dart/morph_core with stub MorphClient.init (UnimplementedError) - Document Dart parity roadmap in docs/dart-parity.md - GitHub Actions: dart analyze + dart test - Makefile: dart-get, dart-analyze, dart-test, dart-all - Issue body template at docs/github-issue-1-body.md for GitHub #1 Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
feat(dart): morph_core scaffold, CI, and dart-parity docs
- Add validateAndIndexConfig parity with packages/core/src/config/validate.ts - ConfigValidationError, CtxRef, ResolvedMorphConfig, normalizeExchangeSources, listAuthIdsForProvider - MorphClient.init validates JSON map then UnimplementedError - Tests + docs/dart-parity.md + README updates Made-with: Cursor
feat(dart): Morph config validation parity with TS
- Add TS-parity typed models (MorphConfig, providers/contexts/hosts), ResolvedMorphConfig with typed indexes, CtxRef. - validateAndIndexConfig accepts MorphConfig or JSON Map; interpolate + full morph errors export. - Port duration/url/jwt/oauth helpers + expiry parity builders. - Add HostPipeline (http package) aligned with TS hostPipeline. - New packages morph_logger (createLogger), morph_storage (MemoryStorageProvider). OAuth2 plugin/token lifecycle, plugin runtime/MorphRuntime, HostClient/AuthHandle, and full MorphClient wiring remain TODO. Made-with: Cursor
feat(dart): typed config, utils, HostPipeline, morph_logger, morph_storage
- Add morph_oauth2 package (token HTTP helpers, vault, lifecycle AuthPlugin impl) - Add oauth2Plugin() with MorphOAuthCallbacksPartial and storage/logger resolution - Add memoryStorageMorphPlugin to morph_storage for tests and CI - Extend Dart CI workflow to matrix morph_core and morph_oauth2 Refs #6 Made-with: Cursor
feat(dart): morph_oauth2 parity (TokenVault, TokenLifecycle, oauth2Plugin)
feat(dart): morph_logger LoggerPluginOptions + loggerPlugin parity
- Add topoSortPlugins + installMorphPlugins (parity packages/core/src/runtime.ts) - Add MorphRuntime (tokens, storage, HostPipeline, config/token/OAuth helpers) - MorphClient.init validates config and boots runtime; expose .runtime - Export HostPipeline; validate unsatisfied plugin requires even for a single plugin - dev_deps: morph_oauth2 + morph_storage for bootstrap integration test Closes #10 Refs #3 Co-authored-by: Cursor <cursoragent@cursor.com>
- Drop split('.').length JWT probe; decode via try/catch like access branch
- hasRefreshToken: set?.refreshToken?.isNotEmpty ?? false
Co-authored-by: Cursor <cursoragent@cursor.com>
…trap feat(dart): MorphRuntime + MorphClient bootstrap (plugin install parity)
Closes #12 Co-authored-by: u0b002 <cyilmaz@burgan.com.tr> Co-authored-by: Cursor <cursoragent@cursor.com>
- Add MorphOptions.oauthRedirectBase for root callback redirect Uri
- completeOAuthReturn({Uri? currentUri}) + conditional browser helpers
- Tests on MorphRuntime OAuth return parsing
Closes #13
Co-authored-by: u0b002 <cyilmaz@burgan.com.tr>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(dart): OAuth redirect base and completeOAuthReturn parity
- Add MorphOptions.oauthRedirectBase for root callback redirect Uri
- completeOAuthReturn({Uri? currentUri}) + conditional browser helpers
- Tests on MorphRuntime OAuth return parsing
Closes #13
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(dart): MorphClient + HostClient + AuthHandle façade parity
- Add HostClient and AuthHandle mirroring `@morph/core` clients
- Extend MorphClient with TS-equivalent façade methods and exports
Closes #14
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: u0b002 <cyilmaz@burgan.com.tr>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(poc): add Flutter sample app (poc/flutter-poc) — closes #24 Adds a Flutter PoC that mirrors poc/ts-vue against the same Keycloak + mock-api backend: - lib/morph_init.dart — MorphClient init with loggerPlugin, oauth2Plugin, memoryStorageMorphPlugin and an onHttpTrace collector - lib/main.dart — MaterialApp + app_links deep-link listener for morphpoc://oauth/callback - lib/screens/home_screen.dart — token status cards, action buttons (acquire device token, login, exchange, logout), mock API call, HTTP trace log panel - lib/widgets/token_status_card.dart — per-authId card + JWT claims sheet - lib/widgets/http_trace_log.dart — expandable MorphHttpTraceEvent log - Android morphpoc:// intent-filter; iOS CFBundleURLSchemes - assets/poc-config.json (copy of docs/poc/poc-config.json) - README.md with prereqs, run steps, OAuth flow, feature parity table docs/dart-parity.md: marks "Sample app" row as Done. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(poc/flutter): address Gemini review comments - http_trace_log: rewrite _DetailPanel content switch as a Dart 3 switch expression (cleaner exhaustiveness, no ambiguity) - main.dart: store StreamSubscription and cancel it in dispose() to prevent deep-link listener leak (dart:async import added) - morph_init.dart: detect Platform.isAndroid and replace localhost with 10.0.2.2 so the app works on Android emulators - http_trace_log: add comment explaining shrinkWrap tradeoff - README: document Android emulator networking (10.0.2.2) Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: u0b002 <cyilmaz@burgan.com.tr> Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(poc): switch flutter-poc to persistent ContextStore storage Replaces the in-memory memoryStorageMorphPlugin with the new morph_core_storage adapter backed by morph_data_store's ContextStore. Tokens now survive app restarts via platform Keychain / KeyStore. - pubspec.yaml: add morph_core_storage + morph_data_store path deps - morph_init.dart: create ContextStore, wire contextStoreStoragePlugin before oauth2Plugin; remove morph_storage import - docs/dart-parity.md: mark "Persistent / browser storage" as Done Co-authored-by: Cursor <cursoragent@cursor.com> * fix(poc): address Gemini review comments on storage init - Extract _appendLog helper: consistent [$level] format used by both MorphOptions.onLog and ContextStoreOptions.onLog; eliminates duplication - Wrap ContextStore.create in try/catch: falls back to in-memory storage (memoryStorageMorphPlugin) if Keychain is unavailable (e.g. simulator without entitlements); logs a warn entry either way - Restore morph_storage dep: required for the in-memory fallback path - Comment: note that onRequestServerTime returns null (PoC tradeoff; ContextStore falls back to device time per grand-rules §9) - Note: cross-repo path: deps are expected for this local-workspace PoC; publish_to: "none" moratorium means no CI publish concern Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: u0b002 <cyilmaz@burgan.com.tr> Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(poc): Flutter POC feature parity with TS Vue POC Closes #27 - Add assets/poc-simulation.json (copy of docs/poc/poc-simulation.json) - Add lib/poc_simulation.dart: PocSimStep sealed classes + runPocSimStep executor for fetch / host / logout_provider steps - Add lib/widgets/mock_api_sheet.dart: bottom sheet with all 9 simulation steps as buttons + integrated HTTP trace log - Add lib/widgets/provider_config_sheet.dart: shows getProviderMeta() as formatted JSON - Add lib/widgets/simulation_panel.dart: sequential auto-loop runner with per-step result rows, 404-probe toggle, and session-dead guard - Refactor home_screen.dart: status grouped by provider, per-row dynamic actions driven by grantHint, Config button, token exchange dropdown - Add getMockApiBase() to morph_init.dart (Android 10.0.2.2 aware) - Add http as direct dependency; fix morph_realm.json localhost:4200 URIs - flutter analyze: no issues Co-authored-by: Cursor <cursoragent@cursor.com> * fix(poc): complete web OAuth callback before runApp to fix token visibility Co-authored-by: Cursor <cursoragent@cursor.com> * fix(poc): navigate current tab for web OAuth login instead of opening new tab Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): switch to profile mode and extend OAuth code lifetime Debug mode loads 596 separate JS files (30-60s) causing the Keycloak authorization code (60s default) to expire before completeOAuthCallback can exchange it, resulting in a blank screen / no-token state. - run_web.sh: default to --profile (single bundled JS, ~2s load); --debug flag available when hot-reload is needed - morph-realm.json: accessCodeLifespan 60s→300s so even slower loads won't race the code expiry window Co-authored-by: Cursor <cursoragent@cursor.com> * chore(web-poc): add missing web/ platform scaffold Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): add CORS webOrigins for device+session clients, add SDK log panel Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): route SDK logs to browser console, remove UI log panel Co-authored-by: Cursor <cursoragent@cursor.com> * debug(web-poc): add exhaustive tracing to catch profile-mode silent crashes Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): use in-memory storage on web to avoid ContextStore identity deadlock ContextStore.setData with Boundary.user silently drops writes when _activeUser is not set. On web the OAuth redirect reloads the page so the user identity is never established before the token write, causing all session-scoped tokens to be silently discarded. Use memoryStorageMorphPlugin on web: completeOAuthCallback() runs in main() before runApp(), so the token is in the same JS heap the UI then reads from — no persistence needed for the current page load. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): render UI before processing OAuth callback to unblock blank screen The blank screen was caused by completeOAuthCallback hanging or crashing silently before runApp() was called. Render the UI first, then process the OAuth callback in initState via addPostFrameCallback. HomeScreen exposes a public refreshStatus() that the parent app calls after the callback completes. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(poc): address PR #28 review (precedence, timeouts, UI dupes) - simulation_panel: fix &&/|| precedence in session-dead guard - simulation_panel: drop duplicate Simulation title; use Spacer in toolbar - mock_api_sheet: message color from result.isError; remove redundant ternary - poc_simulation: 10s timeout on http.get; document PoC string-matching tradeoff Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: u0b002 <cyilmaz@burgan.com.tr> Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(poc): Flutter POC feature parity with TS Vue POC Closes #27 - Add assets/poc-simulation.json (copy of docs/poc/poc-simulation.json) - Add lib/poc_simulation.dart: PocSimStep sealed classes + runPocSimStep executor for fetch / host / logout_provider steps - Add lib/widgets/mock_api_sheet.dart: bottom sheet with all 9 simulation steps as buttons + integrated HTTP trace log - Add lib/widgets/provider_config_sheet.dart: shows getProviderMeta() as formatted JSON - Add lib/widgets/simulation_panel.dart: sequential auto-loop runner with per-step result rows, 404-probe toggle, and session-dead guard - Refactor home_screen.dart: status grouped by provider, per-row dynamic actions driven by grantHint, Config button, token exchange dropdown - Add getMockApiBase() to morph_init.dart (Android 10.0.2.2 aware) - Add http as direct dependency; fix morph_realm.json localhost:4200 URIs - flutter analyze: no issues Co-authored-by: Cursor <cursoragent@cursor.com> * fix(poc): complete web OAuth callback before runApp to fix token visibility Co-authored-by: Cursor <cursoragent@cursor.com> * fix(poc): navigate current tab for web OAuth login instead of opening new tab Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): switch to profile mode and extend OAuth code lifetime Debug mode loads 596 separate JS files (30-60s) causing the Keycloak authorization code (60s default) to expire before completeOAuthCallback can exchange it, resulting in a blank screen / no-token state. - run_web.sh: default to --profile (single bundled JS, ~2s load); --debug flag available when hot-reload is needed - morph-realm.json: accessCodeLifespan 60s→300s so even slower loads won't race the code expiry window Co-authored-by: Cursor <cursoragent@cursor.com> * chore(web-poc): add missing web/ platform scaffold Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): add CORS webOrigins for device+session clients, add SDK log panel Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): route SDK logs to browser console, remove UI log panel Co-authored-by: Cursor <cursoragent@cursor.com> * debug(web-poc): add exhaustive tracing to catch profile-mode silent crashes Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): use in-memory storage on web to avoid ContextStore identity deadlock ContextStore.setData with Boundary.user silently drops writes when _activeUser is not set. On web the OAuth redirect reloads the page so the user identity is never established before the token write, causing all session-scoped tokens to be silently discarded. Use memoryStorageMorphPlugin on web: completeOAuthCallback() runs in main() before runApp(), so the token is in the same JS heap the UI then reads from — no persistence needed for the current page load. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-poc): render UI before processing OAuth callback to unblock blank screen The blank screen was caused by completeOAuthCallback hanging or crashing silently before runApp() was called. Render the UI first, then process the OAuth callback in initState via addPostFrameCallback. HomeScreen exposes a public refreshStatus() that the parent app calls after the callback completes. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(poc): address PR #28 review (precedence, timeouts, UI dupes) - simulation_panel: fix &&/|| precedence in session-dead guard - simulation_panel: drop duplicate Simulation title; use Spacer in toolbar - mock_api_sheet: message color from result.isError; remove redundant ternary - poc_simulation: 10s timeout on http.get; document PoC string-matching tradeoff Co-authored-by: Cursor <cursoragent@cursor.com> * test(poc): add unit tests for poc_simulation + injectable fetch client - Extract parsePocSimulationJson for JSON parsing without rootBundle - Add isPocSessionDeadStop (used by SimulationPanel) with regression tests - Refactor _runFetch to optional http.Client override + runPocSimFetchForTesting - Add 15 tests: models, parser, session guard, mocked fetch, asset load - Replace empty widget_test placeholder with minimal smoke test Co-authored-by: Cursor <cursoragent@cursor.com> * docs: sync Dart/Flutter PoC parity, web/runtime notes, and test coverage - dart-parity: Flutter PoC parity (#27/#28), web vs native storage, Keycloak webOrigins, run_web.sh profile default, unit tests, CI scope for flutter-poc - README: dart-parity description reflects current status - poc-guide: new Flutter PoC section (run paths, OAuth, CORS, simulation, tests) - poc/simulation: Flutter executor + isPocSessionDeadStop + test pointer Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: u0b002 <cyilmaz@burgan.com.tr> Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: nest TypeScript SDK under packages/ts/ - Move core, oauth2, logger, browser-storage to packages/ts/ for parity with packages/dart/ - Point npm workspaces and Makefile/package scripts at packages/ts/* - Update poc/ts-vue file: deps, README, dart-parity, architecture tree, Dart parity comments Closes #31 Co-authored-by: Cursor <cursoragent@cursor.com> * docs: align architecture project tree (Gemini review) - Align branch comments and restore browserStorage.ts description - Regenerate package-lock.json after clean npm install (remove extraneous entries) Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: u0b002 <cyilmaz@burgan.com.tr> Co-authored-by: Cursor <cursoragent@cursor.com>
…+ reuse http.Client (#36) - isPocSessionDeadStop: replace any() closure with type check + contains() - SimulationPanel: shared http.Client for TCP keep-alive across sim steps - runPocSimStep: optional http.Client? param threaded to _runFetch
There was a problem hiding this comment.
Sorry @cemal-yilmaz-bt, your pull request is larger than the review limit of 150000 diff characters
|
Important Review skippedToo many files! This PR contains 193 files, which is 43 over the limit of 150. To get a review, narrow the scope: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (35)
📒 Files selected for processing (193)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request restructures the repository into a monorepo with TypeScript workspaces under packages/ts/ and introduces a Dart/Flutter SDK port under packages/dart/, complete with a Flutter PoC application. The code review identified several critical issues in the Dart implementation: potential runtime errors in host_pipeline.dart due to unsafe header interpolation, empty body bytes on GET/HEAD requests, and binary body corruption; a performance bottleneck in token_lifecycle.dart from over-locking resolveAccessToken; a deadlock risk in validate_config.dart due to missing cycle detection in exchangeSource configurations; invalid URL formatting in oauth_authorize.dart when query parameters already exist; and a potential type error in token_http.dart when parsing expires_in as a string.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| String? serialized; | ||
| if (body == null) { | ||
| serialized = null; | ||
| } else if (body is String || body is List<int>) { | ||
| serialized = body.toString(); | ||
| } else { | ||
| serialized = jsonEncode(body); | ||
| } | ||
|
|
||
| if (sign) { | ||
| if (_options.onSignPayload == null || serialized == null || serialized.isEmpty) { | ||
| throw StateError('sign: true requires a JSON body string and onSignPayload'); | ||
| } | ||
| final sig = await _options.onSignPayload!(serialized, authId); | ||
| headers = {...?headers, 'X-JWS-Signature': sig}; | ||
| } | ||
|
|
||
| final mergedHost = interpolateRecord(host.headers, _variables); | ||
| final headerMap = <String, String>{ | ||
| ...?mergedHost, | ||
| ...?_interpolateHeaders(headers), | ||
| headerCfg.name: '${headerCfg.scheme} $token', | ||
| }; | ||
|
|
||
| Future<http.Response> once(Map<String, String> hdrs) async { | ||
| final req = http.Request(method, uri); | ||
| req.headers.addAll(hdrs); | ||
| final b = serialized; | ||
| req.bodyBytes = utf8.encode(b ?? ''); | ||
| if (serialized != null) { | ||
| req.headers['content-type'] = req.headers['content-type'] ?? 'application/json;charset=utf-8'; | ||
| } | ||
| return _client.send(req).timeout(Duration(milliseconds: timeoutMs)).then(http.Response.fromStream); | ||
| } |
There was a problem hiding this comment.
There are three distinct issues in this block:\n\n1. Request-time headers interpolation: Passing request-time headers to _interpolateHeaders is unsafe. Programmatically generated headers (like cryptographic signatures or custom tokens) can contain $ characters, which will trigger interpolateString and throw a StateError at runtime.\n2. Empty bodyBytes for GET/HEAD requests: Setting req.bodyBytes to utf8.encode(b ?? '') when b is null forces an empty body ([]) and a Content-Length: 0 header, which can cause issues with some servers/proxies for GET or HEAD requests.\n3. Binary body corruption: Calling body.toString() on a List<int> converts it to a literal string representation (e.g., "[1, 2, 3]") instead of sending the raw binary bytes.
| String? serialized; | |
| if (body == null) { | |
| serialized = null; | |
| } else if (body is String || body is List<int>) { | |
| serialized = body.toString(); | |
| } else { | |
| serialized = jsonEncode(body); | |
| } | |
| if (sign) { | |
| if (_options.onSignPayload == null || serialized == null || serialized.isEmpty) { | |
| throw StateError('sign: true requires a JSON body string and onSignPayload'); | |
| } | |
| final sig = await _options.onSignPayload!(serialized, authId); | |
| headers = {...?headers, 'X-JWS-Signature': sig}; | |
| } | |
| final mergedHost = interpolateRecord(host.headers, _variables); | |
| final headerMap = <String, String>{ | |
| ...?mergedHost, | |
| ...?_interpolateHeaders(headers), | |
| headerCfg.name: '${headerCfg.scheme} $token', | |
| }; | |
| Future<http.Response> once(Map<String, String> hdrs) async { | |
| final req = http.Request(method, uri); | |
| req.headers.addAll(hdrs); | |
| final b = serialized; | |
| req.bodyBytes = utf8.encode(b ?? ''); | |
| if (serialized != null) { | |
| req.headers['content-type'] = req.headers['content-type'] ?? 'application/json;charset=utf-8'; | |
| } | |
| return _client.send(req).timeout(Duration(milliseconds: timeoutMs)).then(http.Response.fromStream); | |
| } | |
| String? serialized;\n List<int>? bodyBytes;\n if (body == null) {\n serialized = null;\n } else if (body is List<int>) {\n bodyBytes = body;\n } else if (body is String) {\n serialized = body;\n } else {\n serialized = jsonEncode(body);\n }\n\n if (sign) {\n if (_options.onSignPayload == null || serialized == null || serialized.isEmpty) {\n throw StateError('sign: true requires a JSON body string and onSignPayload');\n }\n final sig = await _options.onSignPayload!(serialized, authId);\n headers = {...?headers, 'X-JWS-Signature': sig};\n }\n\n final mergedHost = interpolateRecord(host.headers, _variables);\n final headerMap = <String, String>{\n ...?mergedHost,\n ...?headers,\n headerCfg.name: '${headerCfg.scheme} $token',\n };\n\n Future<http.Response> once(Map<String, String> hdrs) async {\n final req = http.Request(method, uri);\n req.headers.addAll(hdrs);\n if (bodyBytes != null) {\n req.bodyBytes = bodyBytes;\n } else if (serialized != null) {\n req.bodyBytes = utf8.encode(serialized);\n req.headers['content-type'] = req.headers['content-type'] ?? 'application/json;charset=utf-8';\n }\n return _client.send(req).timeout(Duration(milliseconds: timeoutMs)).then(http.Response.fromStream);\n } |
There was a problem hiding this comment.
Fixed in commit 0bbf751. Three bugs addressed: (1) List body now uses req.bodyBytes directly — no more toString() corruption. (2) GET/HEAD with no body no longer sets bodyBytes = utf8.encode('') — no spurious Content-Length: 0. (3) Request-time headers are no longer passed through _interpolateHeaders — host config headers are interpolated from variables, request headers passed verbatim.
| Future<String> resolveAccessToken(String aid, CtxRef ref, String mode) => | ||
| _withLock<String>(aid, () => _resolveAccessInner(aid, ref, mode)); | ||
|
|
There was a problem hiding this comment.
Wrapping the entire resolveAccessToken process in _withLock serializes all concurrent requests even when the token is already valid and just needs to be read from storage. This introduces a major performance bottleneck, especially on mobile platforms where secure storage reads are slow.\n\nConsider:\n1. Implementing an in-memory cache of the active TokenSet in TokenLifecycle or TokenVault so that valid tokens can be returned synchronously/immediately without async storage reads.\n2. Only acquiring the lock when a token refresh or exchange is actually required, rather than locking the entire resolution process.
There was a problem hiding this comment.
Fixed in commit 0bbf751. Added _tokenCache (Map<String, TokenSet>) to TokenLifecycle, populated on every _persist call and invalidated on clear/logout. resolveAccessToken now checks the cache first and returns immediately if the token is still valid, skipping both the lock and the storage read on the hot path.
| for (final entry in contextByAuthId.entries) { | ||
| final authId = entry.key; | ||
| final c = entry.value.context; | ||
| for (final src in normalizeExchangeSourcesFromTokenBlock(c.token)) { | ||
| if (!contextByAuthId.containsKey(src)) { | ||
| errors.add('$authId: token.exchangeSource references unknown context $src'); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
There is no cycle detection for exchangeSource configurations. Circular dependencies (e.g., A -> B -> A) will cause a silent deadlock at runtime due to nested _withLock calls in TokenLifecycle.resolveAccessToken.\n\nConsider adding a cycle detection check using a simple DFS/visited set during config validation to catch these errors early.
There was a problem hiding this comment.
Fixed in commit 0bbf751. Added DFS cycle detection in validateAndIndexConfig after the unknown-ref check. Circular exchangeSource chains (A->B->A) now raise a ConfigValidationError at init time instead of silently deadlocking at runtime.
| if (scopes != null && scopes.isNotEmpty) { | ||
| q['scope'] = scopes.join(' '); | ||
| } | ||
| final buf = StringBuffer('$u?'); |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in commit 0bbf751. Now uses u.contains('?') ? '&' : '?' before appending query params, so endpoints that already contain query parameters are handled correctly.
| OAuthTokenResponse oauthResponseFromJson(dynamic json) { | ||
| if (json is! Map) throw FormatException('OAuth token JSON must be object'); | ||
| final m = Map<String, dynamic>.from(json.cast<dynamic, dynamic>()); | ||
| return OAuthTokenResponse( | ||
| accessToken: m['access_token'] as String? ?? '', | ||
| refreshToken: m['refresh_token'] as String?, | ||
| expiresIn: (m['expires_in'] as num?)?.toInt(), | ||
| tokenType: m['token_type'] as String?, | ||
| ); |
There was a problem hiding this comment.
Casting expires_in directly to num? and calling toInt() will throw a TypeError at runtime if an identity provider returns expires_in as a string (e.g., "3600"). Parsing should be robust enough to handle both types.
OAuthTokenResponse oauthResponseFromJson(dynamic json) {\n if (json is! Map) throw FormatException('OAuth token JSON must be object');\n final m = Map<String, dynamic>.from(json.cast<dynamic, dynamic>());\n final rawExpiresIn = m['expires_in'];\n int? expiresIn;\n if (rawExpiresIn is num) {\n expiresIn = rawExpiresIn.toInt();\n } else if (rawExpiresIn is String) {\n expiresIn = int.tryParse(rawExpiresIn);\n }\n return OAuthTokenResponse(\n accessToken: m['access_token'] as String? ?? '',\n refreshToken: m['refresh_token'] as String?,\n expiresIn: expiresIn,\n tokenType: m['token_type'] as String?,\n );\n}There was a problem hiding this comment.
Fixed in commit 0bbf751. expires_in is now parsed robustly: num -> toInt(), String -> int.tryParse(), anything else -> null. No more TypeError when an identity provider returns a string.
**High #1 — host_pipeline.dart: three body/header bugs** - List<int> body was calling toString() → binary corruption; now uses bodyBytes directly - GET/HEAD with no body no longer sets bodyBytes = utf8.encode('') → no spurious Content-Length: 0 - Request-time headers (may contain $ in JWS signatures) are no longer passed through interpolateString; only host config headers are interpolated from variables **High #2 — token_lifecycle.dart: resolveAccessToken lock contention** - Add _tokenCache (Map<String, TokenSet>) populated on every _persist - resolveAccessToken checks cache first; returns immediately if token is valid (skips lock acquisition and storage read on the hot path) - Cache is invalidated on clear/logout via _persist(set: null) **Medium #1 — validate_config.dart: no exchangeSource cycle detection** - Add DFS cycle detection after the unknown-ref check - Circular A→B→A chains now raise a ConfigValidationError at init time instead of deadlocking at runtime via nested _withLock calls **Medium #2 — oauth_authorize.dart: blindly appending '?'** - Use '&' instead of '?' when the resolved endpoint already contains query params **Medium #3 — token_http.dart: expires_in string cast** - Parse expires_in robustly: num.toInt() or int.tryParse(String) so identity providers returning "3600" no longer throw TypeError
- validate_config: rename _dfsSeen/_hasCycle local vars (no leading underscore for locals) - host_pipeline: remove now-unused _interpolateHeaders method (headers no longer interpolated at request time per Gemini fix)
Overview
This PR merges 33 commits from
f/pluginintomaster, delivering a near-complete Dart/Flutter port of the TypeScript Morph API Client SDK, validated end-to-end with a Flutter POC app against a real Keycloak instance.What was built
packages/dart/morph_core— Core SDK (parity with@morph/core)types.tsMorphConfig,AuthContextConfig,ProviderConfig,HostConfig,MorphOptions,TokenSet,OAuthReturnResult,MorphContextMeta,MorphProviderMeta, etc.config/validate.tsvalidateAndIndexConfig→ResolvedMorphConfigconfig/interpolate.tsinterpolateStringruntime.tsgetAuthorizationUrl,getExchangeSources,getExchangeTargets,getTokenStatus,completeOAuthCallback,completeOAuthReturn,isAuthContextReady,isProviderEnvReadyhttp/hostPipeline.tshostFetch, 401 recovery, timeout, HTTP trace emissionclient/MorphClient.tsclient/AuthHandle.tssubmitCode,acquireWithClientCredentials,exchangeToken,setTokens,clearTokens,logout,hasValidToken,refreshTokens,peekTokens,getClaimsclient/HostClient.tsget,post,put,patch,delete,requestruntime.ts(topoSort + install)plugin_install.dart— topological sort,MorphPluginContext,provideAuth,provideStorageutil/*.tspackages/dart/morph_oauth2— OAuth2 plugin (parity with@morph/oauth2)TokenLifecycleloadTokens,submitCode,acquireWithClientCredentials,exchangeToken,setTokens,clearTokens,logout,logoutProvider,hasValidTokenContext,hasValidTokenProvider,handle401Recovery,resolveAccessToken,refreshTokensManualTokenVaultStorageConfigscopingTokenHttpoauth2PluginMorphPluginwrapper — topological install,AuthPluginprovisionpackages/dart/morph_logger— Logger plugin (parity with@morph/logger)✅
loggerPlugin,createLogger,LoggerPluginOptions(level, prefix, onLog, onHttpTrace, httpTrace flag),morphDefaultHttpTrace,morphHttpTraceMessagepackages/dart/morph_storage— Storage plugin✅
memoryStorageMorphPlugin— in-memoryStorageProviderfor tests and VM targets.poc/flutter-poc— Flutter POC app✅ End-to-end proof of concept against real Keycloak:
PocSimStepsealed classes,runPocSimStep,isPocSessionDeadStop)poc_simulation_test.dart)webOrigins, profile-mode runner,memoryStorageMorphPluginon web to avoid ContextStore identity deadlock on redirectParity status
getAuthorizationUrl,getExchangeSources,getExchangeTargetsKnown open issues (all low priority)
AuthHandledouble-parse per callmorph_storageCI matrixUri.base.origincrash onfile://URIsdart-parity.mdconcrete file pointersTest coverage
What's next (post-merge)
flutter_secure_storage-backedmorph_storageplugin for mobile (issue [priority: low] CI: add morph_storage to Dart workflow matrix when tests land #21)Uri.base.originhardening — guardfile://URI case (issue [priority: low] morph_core: OAuth redirect resolver hardening (Uri.origin / file URIs) #19)morph_storageCI matrix — add todart.ymlonce tests land (issue [priority: low] CI: add morph_storage to Dart workflow matrix when tests land #21)🤖 Generated with Claude Code