Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
| [Token Lifecycle](token-lifecycle.md) | Token resolution algorithm, refresh, exchange, recovery, session monitoring |
| [Platform Adapters](platform-adapters.md) | StorageProvider (via plugins) and NetworkDelegate interfaces |
| [Architecture](architecture.md) | Internal design: module structure, HTTP pipeline, dependency graph |
| [Dart parity](dart-parity.md) | Dart/Flutter SDK roadmap, `packages/dart/morph_core` scaffold ([issue #1](https://github.com/burgan-tech/morph-api-client/issues/1)) |
| [Dart parity](dart-parity.md) | Dart/Flutter SDK status vs TypeScript ([issue #1](https://github.com/burgan-tech/morph-api-client/issues/1), [issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)), Flutter PoC parity, backlog |

| Document | Description |
|----------|-------------|
Expand Down
12 changes: 8 additions & 4 deletions docs/dart-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ Base branch for work is **`f/plugin`** unless release policy changes.
| Memory storage | **Done:** `morph_storage` module + plugin for tests/tools. |
| OAuth return / redirect | **Done:** `oauthRedirectBase` on `MorphOptions`; `completeOAuthReturn` with conditional `dart:html` + optional `Uri? currentUri`. |
| Typed `MorphConfig` DTOs | **Backlog:** hand-written or codegen from JSON boundary. |
| Persistent / browser storage | **Done:** `morph_core_storage` adapter in `morph-data-store` bridges `StorageProvider` → `IContextStore` (Keychain/KeyStore); `poc/flutter-poc` switched from in-memory to persistent storage. |
| Sample app | **Done:** `poc/flutter-poc` Flutter app — token status, OAuth login, mock-API call, HTTP trace log (closes #24). |
| Persistent storage (adapter) | **Done:** `morph_core_storage` in **morph-data-store** bridges `morph_core.StorageProvider` → `IContextStore` (Device / platform secure storage). Suitable for **mobile/desktop** PoC and production apps that set **ContextStore identity** before user-scoped writes. |
| Flutter PoC (`poc/flutter-poc`) | **Done:** Feature parity with **`poc/ts-vue`** (closes [#27](https://github.com/burgan-tech/morph-api-client/issues/27), PR [#28](https://github.com/burgan-tech/morph-api-client/pull/28)): provider-grouped status, dynamic actions, mock-API sheet + HTTP trace, provider config sheet, JSON-driven simulation (`assets/poc-simulation.json` copy of `docs/poc/poc-simulation.json`). **Web:** uses **in-memory** `StorageProvider` — full-page OAuth reload cannot satisfy ContextStore **user** boundary before the first token write; **non-web** uses **ContextStore** when init succeeds. **`run_web.sh`** defaults to **`--profile`** (fast single bundle; debug reload is slow and can race Keycloak code expiry). Keycloak **`webOrigins`** must include `http://localhost:4200` for **morph-device** and **morph-session** as well as morph-login (browser `POST /token`). **Unit tests:** `poc/flutter-poc/test/poc_simulation_test.dart` (+ `flutter test`); `parsePocSimulationJson`, `isPocSessionDeadStop`, mocked fetch steps. |
| Sample app (minimal) | **Done (earlier milestone):** baseline Flutter app — token status, OAuth, mock call, trace log ([#24](https://github.com/burgan-tech/morph-api-client/issues/24)). |

## CI

**.github/workflows/dart.yml** runs `dart analyze --fatal-infos` and `dart test` for `morph_core`, `morph_oauth2`, and `morph_logger`. **`morph_storage`** is intentionally omitted from the matrix while it stays test-only transitively; add when it has standalone coverage.
**.github/workflows/dart.yml** runs `dart analyze --fatal-infos` and `dart test` for **`morph_core`**, **`morph_oauth2`**, and **`morph_logger`**.

- **`morph_storage`** is intentionally omitted from the matrix while it stays test-only transitively; tracked in backlog [#21](https://github.com/burgan-tech/morph-api-client/issues/21).
- **`poc/flutter-poc`** is **not** in CI yet: `pubspec` uses **`path:`** dependencies on **morph-data-store** checked out beside this repo locally. Adding CI would require a second checkout or publishing those packages.

## Design intent (aligned with TS)

Expand All @@ -54,4 +58,4 @@ Per [architecture.md](architecture.md):

Acceptance criteria and epic tracking: **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)**.

When starting work on a backlog row (persistent / browser storage, typed `MorphConfig`, `poc/` sample, adding `morph_storage` to CI), open a **dedicated GitHub issue** and drive it with the [morph-api-client-issue-pr-merge](../.cursor/skills/morph-api-client-issue-pr-merge/SKILL.md) workflow so each slice stays reviewable.
When starting work on a backlog row (typed `MorphConfig`, **`morph_storage` in CI**, façade polish, OAuth hardening, `package:web` migration), use the open issues **[#18](https://github.com/burgan-tech/morph-api-client/issues/18)**–**[#22](https://github.com/burgan-tech/morph-api-client/issues/22)** and epic **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)**. Drive each slice with the [morph-api-client-issue-pr-merge](../.cursor/skills/morph-api-client-issue-pr-merge/SKILL.md) workflow so it stays reviewable.
18 changes: 18 additions & 0 deletions docs/poc-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ Test users in Keycloak:

---

## Flutter PoC (`poc/flutter-poc`)

The Flutter sample mirrors **`poc/ts-vue`** against the **same** Keycloak (8080) and Mock API (3000). Issue [#27](https://github.com/burgan-tech/morph-api-client/issues/27) / PR [#28](https://github.com/burgan-tech/morph-api-client/pull/28): grouped token status, dynamic actions from `grantHint`, mock-API bottom sheet + HTTP trace, provider metadata sheet, and **`docs/poc/poc-simulation.json`** bundled as **`assets/poc-simulation.json`**.

| Topic | Flutter notes |
|--------|----------------|
| Run (Chrome + backends) | From `poc/flutter-poc`: `./run_web.sh` starts Keycloak, mock-api, and Flutter web on **localhost:4200** (defaults to **`--profile`**). Flags: `--no-keycloak`, `--no-mock-api`, `--debug`. From repo root Keycloak compose path is **`poc/keycloak`** (not `flutter-poc/poc/keycloak`). |
| OAuth on web | Redirect **`http://localhost:4200/`** is registered in **`poc/keycloak/morph-realm.json`**. Use **`webOnlyWindowName: '_self'`** so login stays in one tab. |
| CORS | Keycloak clients **`morph-login`**, **`morph-device`**, **`morph-session`** need **`webOrigins`** including **`http://localhost:4200`** so browser token `POST` succeeds (not only the login client). |
| Storage | **Web:** in-memory tokens for the PoC (session-scoped Keycloak tokens vs ContextStore **user** identity — see [dart-parity.md](dart-parity.md)). **iOS/Android/desktop:** **ContextStore** via **`morph_core_storage`** when initialization succeeds. |
| Simulation UI | **Run simulation** runs the auto-step list on demand (Vue uses a timed tick by default). Session-dead stopping uses the same JSON `sessionDeadCheck` idea; see [poc/simulation.md](poc/simulation.md). |
| Tests | `cd poc/flutter-poc && flutter test` — parser, session-dead helper, mocked HTTP fetch, real asset load. |
| Deeper detail | [dart-parity.md](dart-parity.md); app-specific **`poc/flutter-poc/README.md`** (Android emulator host, secrets, web troubleshooting). |

Then continue with Vue flow below, or use Flutter for the same conceptual steps (acquire device token, Keycloak login, exchange, mock API, simulation).

---

## The Home Page

Open **http://127.0.0.1:5173/** in your browser. The Home page has four main sections:
Expand Down
10 changes: 10 additions & 0 deletions docs/poc/simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ The table shows **status**, **total ms**, and a short **detail** line. **Verbose

If **one tick** yields **AUTH** for **every** auth id listed in **`sessionDeadCheck.authIds`** in `docs/poc/poc-simulation.json` (default: **1fa** and **2fa**), the loop **stops** and the banner shows **`sessionDeadCheck.message`**. Adjust the list or message in JSON if your scenario differs.

## Flutter PoC executor

The Flutter app at **`poc/flutter-poc`** includes a copy of this document’s JSON as **`assets/poc-simulation.json`**. Execution is implemented in **`lib/poc_simulation.dart`**:

- **`fetch`** — `GET` via `package:http` to `mockApi.baseUrl` (with timeout); same step shape as Vue.
- **`host`** — `MorphRuntime.http.hostFetch` with `method`, `path`, `auth`, optional `body` / `headers`.
- **`logout_provider`** — `MorphClient.auth(providerKey).logout()`.

Parsing is synchronous via **`parsePocSimulationJson`** (tests) and **`loadPocSimulation`** (loads the asset). The **Simulation** panel runs steps **when the user taps Run** (not on a fixed interval like the Vue dev server’s default tick). **Session dead:** after an **AUTH** result, **`isPocSessionDeadStop`** requires the step’s host **`auth`** to be listed in **`sessionDeadCheck.authIds`** *and* `invalid_grant` or `Token is not active` in the error detail (parentheses matter — see unit tests). **Unit tests:** `poc/flutter-poc/test/poc_simulation_test.dart`.

## Short token lifetimes (Keycloak)

**Fresh** imports use the short defaults below (`morph-realm.json`). If Keycloak was created from an older export or you ran **`restore-simulation-lifetimes.sh`** (long-lived PoC), re-apply the short profile (Keycloak must be up):
Expand Down
46 changes: 43 additions & 3 deletions poc/flutter-poc/lib/poc_simulation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:morph_core/morph_core.dart';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -73,6 +74,23 @@ final class PocSimStepResult {
}
}

/// When an auto-simulation step returns [PocSimStepResult.status] `AUTH`, decide
/// whether to stop the loop (stale Keycloak session). Kept pure for unit tests.
bool isPocSessionDeadStop({
required PocSimStepResult result,
required PocSimStep step,
required List<String> sessionDeadAuthIds,
}) {
if (result.status != 'AUTH') return false;
final isSessionDead = sessionDeadAuthIds.any(
(id) => step is PocSimHostStep && step.auth == id,
);
final detail = result.detail ?? '';
return isSessionDead &&
(detail.contains('invalid_grant') ||
detail.contains('Token is not active'));
}

// ---------------------------------------------------------------------------
// Config loader
// ---------------------------------------------------------------------------
Expand All @@ -93,10 +111,16 @@ class PocSimulationConfig {

Future<PocSimulationConfig> loadPocSimulation(String mockApiBase) async {
final raw = await rootBundle.loadString('assets/poc-simulation.json');
return parsePocSimulationJson(raw, mockApiBase);
}

/// Parses the PoC simulation document (same shape as [assets/poc-simulation.json]).
/// [mockApiFallback] is used when the document omits `mockApi.baseUrl`.
PocSimulationConfig parsePocSimulationJson(String raw, String mockApiFallback) {
final json = jsonDecode(raw) as Map<String, dynamic>;

final mockApi = (json['mockApi'] as Map<String, dynamic>?) ?? {};
final baseUrl = (mockApi['baseUrl'] as String?) ?? mockApiBase;
final baseUrl = (mockApi['baseUrl'] as String?) ?? mockApiFallback;

final sessionDeadCheck =
(json['sessionDeadCheck'] as Map<String, dynamic>?) ?? {};
Expand Down Expand Up @@ -185,10 +209,15 @@ Future<PocSimStepResult> runPocSimStep(
}

Future<PocSimStepResult> _runFetch(
PocSimFetchStep step, String mockApiBase) async {
PocSimFetchStep step,
String mockApiBase, [
http.Client? clientOverride,
]) async {
final url = '$mockApiBase${step.path}';
final client = clientOverride ?? http.Client();
final ownsClient = clientOverride == null;
try {
final res = await http
final res = await client
.get(Uri.parse(url))
.timeout(const Duration(seconds: 10));
final expectedStatus = step.expectStatus;
Expand All @@ -210,9 +239,20 @@ Future<PocSimStepResult> _runFetch(
} catch (e) {
return PocSimStepResult(
label: step.label, status: 'NET', detail: e.toString());
} finally {
if (ownsClient) client.close();
}
}

/// Calls [_runFetch] with an injected HTTP client for unit tests.
@visibleForTesting
Future<PocSimStepResult> runPocSimFetchForTesting(
PocSimFetchStep step,
String mockApiBase,
http.Client httpClient,
) =>
_runFetch(step, mockApiBase, httpClient);

Future<PocSimStepResult> _runHost(
MorphClient morph, PocSimHostStep step) async {
try {
Expand Down
18 changes: 7 additions & 11 deletions poc/flutter-poc/lib/widgets/simulation_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,13 @@ class _SimulationPanelState extends State<SimulationPanel> {
setState(() => _results.add(result));

// Session dead check
if (result.status == 'AUTH') {
final detail = result.detail ?? '';
final isSessionDead = widget.cfg.sessionDeadAuthIds.any(
(id) => step is PocSimHostStep && step.auth == id,
);
if (isSessionDead &&
(detail.contains('invalid_grant') ||
detail.contains('Token is not active'))) {
setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage);
break;
}
if (isPocSessionDeadStop(
result: result,
step: step,
sessionDeadAuthIds: widget.cfg.sessionDeadAuthIds,
)) {
setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage);
break;
}
}

Expand Down
Loading
Loading