diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 0000000..77e7f6b --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,32 @@ +name: Dart (packages) + +on: + push: + branches: [main, f/plugin, "feat/**"] + pull_request: + branches: [main, f/plugin] + +jobs: + morph_dart: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: + - morph_core + - morph_oauth2 + - morph_logger + defaults: + run: + working-directory: packages/dart/${{ matrix.package }} + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - name: Install dependencies + run: dart pub get + - name: Analyze + run: dart analyze --fatal-infos + - name: Test + run: dart test diff --git a/.gitignore b/.gitignore index 8cf973d..605c8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ dist/ .env .env.local .DS_Store + +# Dart (packages/dart/*) +.dart_tool/ +**/packages/dart/**/.dart_tool/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..df140de --- /dev/null +++ b/Makefile @@ -0,0 +1,114 @@ +.PHONY: help install build dev mock-api \ + dart-get dart-analyze dart-test dart-all \ + keycloak-up keycloak-down keycloak-setup keycloak-test keycloak-logs \ + keycloak-short-tokens keycloak-restore-tokens \ + up down stop clean + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}' + +# --------------------------------------------------------------------------- +# Dependencies & build +# --------------------------------------------------------------------------- + +install: ## Install all npm dependencies (packages + vue + mock-api) + cd packages/ts/core && npm install + cd packages/ts/oauth2 && npm install + cd packages/ts/browser-storage && npm install + cd packages/ts/logger && npm install + cd poc/ts-vue && npm install + cd poc/mock-api && npm install + +build: ## Build all SDK packages (core → oauth2 → browser-storage → logger) + cd packages/ts/core && npm run build + cd packages/ts/oauth2 && npm run build + cd packages/ts/browser-storage && npm run build + cd packages/ts/logger && npm run build + +dart-get: ## Dart: pub get (packages/dart/morph_core) + cd packages/dart/morph_core && dart pub get + +dart-analyze: ## Dart: analyze morph_core + cd packages/dart/morph_core && dart analyze --fatal-infos + +dart-test: ## Dart: test morph_core + cd packages/dart/morph_core && dart test + +dart-all: dart-get dart-analyze dart-test ## Dart: get + analyze + test morph_core + +# --------------------------------------------------------------------------- +# Dev servers +# --------------------------------------------------------------------------- + +dev: ## Start Vue PoC dev server (http://127.0.0.1:5173) + npm run dev + +mock-api: ## Start mock API server (http://localhost:3000) + cd poc/mock-api && npm start + +# --------------------------------------------------------------------------- +# Keycloak (Docker) +# --------------------------------------------------------------------------- + +keycloak-up: ## Start Keycloak container (port 8080) + docker compose -f poc/keycloak/docker-compose.yml up -d + +keycloak-down: ## Stop and remove Keycloak container + docker compose -f poc/keycloak/docker-compose.yml down + +keycloak-logs: ## Tail Keycloak container logs + docker compose -f poc/keycloak/docker-compose.yml logs -f + +keycloak-setup: ## Run Keycloak realm setup (clients, redirects, lifetimes, tests) + bash poc/keycloak/setup.sh + +keycloak-test: ## Run OAuth2 flow smoke tests + bash poc/keycloak/test-flows.sh + +keycloak-short-tokens: ## Apply PoC short token lifetimes (15s/30s/20s) + bash poc/keycloak/set-simulation-lifetimes.sh + +keycloak-restore-tokens: ## Restore long-lived token lifetimes + bash poc/keycloak/restore-simulation-lifetimes.sh + +# --------------------------------------------------------------------------- +# Full stack +# --------------------------------------------------------------------------- + +up: keycloak-up ## Start full stack: Keycloak → setup → mock-api (bg) → Vue dev + @-lsof -ti :5173 | xargs kill -9 2>/dev/null || true + @-lsof -ti :3000 | xargs kill -9 2>/dev/null || true + @echo "Waiting for Keycloak to become ready…" + @for i in $$(seq 1 60); do \ + curl -sf http://localhost:8080/realms/master > /dev/null 2>&1 && break; \ + [ "$$i" -eq 60 ] && { echo "ERROR: Keycloak not ready after 60s"; exit 1; }; \ + sleep 1; \ + done + bash poc/keycloak/setup.sh + @echo "" + cd poc/mock-api && npm start & + @sleep 1 + npm run dev + +down: keycloak-down ## Stop Keycloak and kill mock-api / Vite processes + -@pkill -f "node.*poc/mock-api/server.js" 2>/dev/null || true + -@pkill -f "vite.*--host 127.0.0.1" 2>/dev/null || true + -@lsof -ti :5173 | xargs kill -9 2>/dev/null || true + -@lsof -ti :3000 | xargs kill -9 2>/dev/null || true + @echo "All services stopped." + +stop: down ## Alias for 'down' + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +clean: ## Remove node_modules and dist artefacts + rm -rf packages/ts/core/node_modules packages/ts/core/dist + rm -rf packages/ts/oauth2/node_modules packages/ts/oauth2/dist + rm -rf packages/ts/browser-storage/node_modules packages/ts/browser-storage/dist + rm -rf packages/ts/logger/node_modules packages/ts/logger/dist + rm -rf poc/ts-vue/node_modules poc/ts-vue/dist + rm -rf poc/mock-api/node_modules + rm -rf packages/dart/morph_core/.dart_tool diff --git a/README.md b/README.md index f5c1d5f..4547228 100644 --- a/README.md +++ b/README.md @@ -2,67 +2,81 @@ Config-driven, multi-context HTTP client with built-in OAuth2 token lifecycle management. -## Quick start (Vue PoC) +## Quick start **Prerequisites:** Docker, Node.js 20+, python3, curl -### 1. Keycloak + Mock API +### First time setup ```bash -# Start Keycloak (first run pulls image ~500MB) -cd poc/keycloak && docker compose up -d - -# Wait for Keycloak & configure realm (takes ~30s on first run) -bash setup.sh - -# Start mock API (separate terminal) -cd poc/mock-api && npm install && node server.js -``` - -If you see "Realm 'morph' not found", the Docker volume has stale data: - -```bash -docker compose down -v && docker compose up -d && bash setup.sh -``` - -### 2. SDK + Vue app - -```bash -# From repository root -cd core && npm install && npm run build -cd ../poc/ts-vue && npm install -cd ../.. -npm run dev -``` - -Opens **http://127.0.0.1:5173/**. Rebuild the SDK after `core/` changes: - -```bash -npm run build:core +make install # install all npm dependencies +make build # build all SDK packages +cp poc/ts-vue/.env.example poc/ts-vue/.env # create env with client secrets +make up # start Keycloak + setup + mock API + Vue dev server ``` -### 3. Verify +### Day-to-day ```bash -cd poc/keycloak && bash test-flows.sh +make up # start full stack (Keycloak -> setup -> mock API -> Vue) +make down # stop everything ``` -All 5 tests should pass (device token, login, refresh rotation, token exchange, JWT decode). - -### Optional: Google OAuth - -See [docs/poc/google-setup.md](docs/poc/google-setup.md) for external IdP integration. +Opens **http://127.0.0.1:5173/** (Vue PoC). Run `make help` to see all targets. + +### Makefile targets + +| Target | Description | +|--------|-------------| +| `make install` | Install npm deps for all packages, Vue PoC, and mock API | +| `make build` | Build all SDK packages (core -> oauth2 -> browser-storage) | +| `make up` | Start full stack: Keycloak -> setup -> mock API -> Vue | +| `make down` | Stop all services | +| `make dev` | Start Vue dev server only (http://127.0.0.1:5173) | +| `make mock-api` | Start mock API server only (http://localhost:3000) | +| `make keycloak-up` | Start Keycloak container (port 8080) | +| `make keycloak-down` | Stop Keycloak container | +| `make keycloak-setup` | Run realm setup (clients, redirects, lifetimes, tests) | +| `make keycloak-test` | Run OAuth2 flow smoke tests | +| `make keycloak-logs` | Tail Keycloak logs | +| `make keycloak-short-tokens` | Apply short token lifetimes (15s/30s/20s) | +| `make keycloak-restore-tokens` | Restore long-lived token lifetimes | +| `make clean` | Remove node_modules and dist | + +### Services + +| Service | URL | +|---------|-----| +| Vue PoC | http://127.0.0.1:5173 | +| Mock API | http://localhost:3000 | +| Keycloak Admin | http://localhost:8080/admin (admin/admin) | + +## Documentation + +See **[docs/README.md](docs/README.md)** for the full documentation index. + +| Document | Description | +|----------|-------------| +| [Overview](docs/overview.md) | System architecture, auth flow diagrams, Keycloak client mapping | +| [PoC Guide](docs/poc-guide.md) | Step-by-step walkthrough of the Vue PoC app | +| [Troubleshooting](docs/troubleshooting.md) | Common errors and fixes | +| [Getting Started](docs/getting-started.md) | SDK installation and basic usage | +| [Writing Plugins](docs/writing-plugins.md) | How to create custom plugins (auth, storage, or utility) | +| [Configuration](docs/configuration.md) | Full config field reference | +| [API Reference](docs/api-reference.md) | Complete public API | +| [Architecture](docs/architecture.md) | Internal design and module structure | +| [Dart parity](docs/dart-parity.md) | Dart/Flutter SDK roadmap (scaffold under `packages/dart/morph_core`) | ## Layout -| Path | Role | -|------|------| -| `core/` | `morph-api-client` TypeScript SDK package | -| `core/src/runtime.ts` | MorphRuntime — thin coordinator | -| `core/src/tokens/` | TokenLifecycle + TokenVault | -| `core/src/http/` | HostPipeline (host HTTP + 401 recovery) | -| `core/src/client/` | MorphClient, HostClient, AuthHandle facades | -| `poc/ts-vue/` | Vue 3 demo app | -| `poc/keycloak/` | Docker Keycloak realm + setup/test scripts | -| `poc/mock-api/` | Mock REST API (Express, validates JWT via Keycloak/Google) | -| `docs/` | Design & API docs | +| Path | Package | Role | +|------|---------|------| +| `packages/ts/core/` | `@morph/core` | Types, config, HTTP pipeline, MorphClient, AuthHandle | +| `packages/ts/oauth2/` | `@morph/oauth2` | TokenLifecycle, TokenVault, OAuth helpers | +| `packages/ts/browser-storage/` | `@morph/browser-storage` | sessionStorage / localStorage adapters | +| `packages/ts/logger/` | `@morph/logger` | Structured logging and HTTP trace plugin | +| `packages/dart/morph_core/` | `morph_core` | **Dart** — config validation + indexes (parity with TS `validate.ts`); runtime/OAuth TBD — [docs/dart-parity.md](docs/dart-parity.md) | +| `poc/ts-vue/` | | Vue 3 demo app | +| `poc/keycloak/` | | Docker Keycloak realm + setup/test scripts | +| `poc/mock-api/` | | Mock REST API (Express, validates JWT) | +| `docs/` | | Design & API docs | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2648bc1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,28 @@ +# Documentation + +## Start here + +| Document | Description | +|----------|-------------| +| [Overview](overview.md) | System architecture, auth flow diagrams, Keycloak client mapping, token storage model | +| [PoC Guide](poc-guide.md) | Step-by-step walkthrough of the Vue PoC app (sign-in, token exchange, simulation) | +| [Troubleshooting](troubleshooting.md) | Common errors and how to fix them | + +## SDK Reference + +| Document | Description | +|----------|-------------| +| [Getting Started](getting-started.md) | SDK installation, initialization, making API calls, providing tokens | +| [Writing Plugins](writing-plugins.md) | How to create custom MorphPlugin implementations (auth, storage, or custom) | +| [Configuration](configuration.md) | Full reference for every config field (providers, contexts, hosts, variables) | +| [API Reference](api-reference.md) | Complete public API: MorphClient, HostClient, AuthHandle, types, errors | +| [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 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 | +|----------|-------------| +| [poc/google-setup.md](poc/google-setup.md) | Google Cloud Console setup for the external IdP integration | +| [poc/simulation.md](poc/simulation.md) | How the simulation panel works (`poc-simulation.json` schema) | +| [poc/test-scenarios.md](poc/test-scenarios.md) | Nine test scenarios with curl recipes for all OAuth flows | diff --git a/docs/api-reference.md b/docs/api-reference.md index 2059633..1be59d2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -17,27 +17,48 @@ The main entry point. Created via the static `init` factory. Creates and configures the SDK. Validates the config, initializes all providers, auth contexts, hosts, refresh schedulers, and session monitors. ```typescript -import { MorphClient } from 'morph-api-client'; +import { MorphClient } from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { browserStoragePlugin } from '@morph/browser-storage'; +import { loggerPlugin } from '@morph/logger'; import config from './morph-config.json'; -const tokenStore = new Map(); +const logger = loggerPlugin({ level: 'info' }); const morph = MorphClient.init(config, { - storage: { - read: async (key, storageConfig) => tokenStore.get(key) ?? null, - write: async (key, value, storageConfig) => { tokenStore.set(key, value); }, - delete: async (key, storageConfig) => { tokenStore.delete(key); }, - deleteByPrefix: async (prefix, storageConfig) => { - for (const k of tokenStore.keys()) if (k.startsWith(prefix)) tokenStore.delete(k); - }, - }, + plugins: [ + logger, + oauth2Plugin({ + logger, + storage: browserStoragePlugin({ prefix: 'myapp:tk:', logger }), + callbacks: { + onAuthRequired: (authId, metadata) => { + if (metadata.interaction === 'non-interactive') { + morph.auth(authId).acquireWithClientCredentials(); + } else if (metadata.interaction === 'interactive') { + router.navigate('/login', { authId }); + } + }, + onTokenChange: (authId, tokens) => { + if (authId === 'burgan-auth/1fa') setIsLoggedIn(!!tokens); + if (authId === 'burgan-auth/2fa') setCanTransfer(!!tokens); + }, + }, + variables: { + deviceClientId: 'abc-123', + deviceId: getDeviceId(), + installationId: getInstallationId(), + }, + autoAcquireNonInteractive: true, + }), + ], callbacks: { onAuthRequired: (authId, metadata) => { // authId format: 'provider/context', e.g. 'burgan-auth/device' if (metadata.interaction === 'non-interactive') { - morph.auth(authId).acquireWithClientCredentials(); + morph.auth(authId).acquire(); } else if (metadata.interaction === 'interactive') { router.navigate('/login', { authId, workflow: metadata.workflow }); @@ -73,44 +94,13 @@ const morph = MorphClient.init(config, { 'api.burgan.com.tr': ['sha256/AAAA...', 'sha256/BBBB...'], 'payment.burgan.com.tr': ['sha256/CCCC...', 'sha256/DDDD...'], }; - - // mTLS client cert from platform keychain (only for hosts that require it) - const mtlsCert = ['api.burgan.com.tr', 'payment.burgan.com.tr'].includes(hostname) - ? { cert: await keychain.getCert(), key: await keychain.getKey() } - : undefined; - return { certificatePins: PINS[hostname] ?? undefined, - clientCertificate: mtlsCert, proxy: __DEV__ ? { url: 'http://localhost:8888' } : undefined, }; }, }, - // --- Data --- - - variables: { - deviceClientId: 'abc-123', - '1faClientId': 'def-456', - googleClientId: '789.apps.googleusercontent.com', - deviceId: getDeviceId(), - installationId: getInstallationId(), - appVersion: '2.4.1', - }, - - // --- Functional delegates (continued) --- - - onTokenExchange: async (grant) => { - if (grant.type === 'token_exchange' && grant.authId === 'burgan-auth/2fa') { - const res = await fetch('/api/custom-step-up', { - headers: { 'Authorization': `Bearer ${grant.sourceToken}` }, - method: 'POST', - }); - return await res.json(); // { accessToken, refreshToken } - } - return null; // standard OAuth2 exchange - }, - onSignPayload: async (payload, authId) => { return await cryptoModule.sign(payload, privateKey); }, @@ -118,16 +108,17 @@ const morph = MorphClient.init(config, { onDecryptResponse: async (encryptedBody, authId) => { return await cryptoModule.decrypt(encryptedBody, decryptionKey); }, - - onLog: (level, message, error, context) => { - console.log(`[morph:${level}] ${message}`, context ?? ''); - }, }); ``` Throws `ConfigValidationError` if the config is invalid. -Include **`deviceId`** and **`installationId`** in `variables` when the JSON config interpolates them (e.g. `X-Device-Id`, `X-Installation-Id`). On mobile, `installationId` is usually a GUID created on first install after download; the Vue web PoC simulates that with browser-local and session-scoped ids (see `poc/ts-vue/README.md`). +**What goes where:** +- **Logger** (`loggerPlugin({ ... })`): Create once, pass to every plugin via `logger` option. Controls log level, prefix, and output format. +- **Plugin options** (`oauth2Plugin({ ... })`): `logger`, `storage`, `callbacks`, `variables`, `onTokenExchange`, `onClientJwtAssertion`, `autoAcquireNonInteractive` +- **Core options** (`MorphOptions`): `onLog`, `onHttpTrace`, `onSignPayload`, `onDecryptResponse`, `networkDelegate`, shared `variables` (like `deviceId`) + +Include **`deviceId`** and **`installationId`** in `variables` when the JSON config interpolates them (e.g. `X-Device-Id`, `X-Installation-Id`). On mobile, `installationId` is usually a GUID created on first install after download; the Vue web PoC simulates that with browser-local and session-scoped ids. --- @@ -383,12 +374,12 @@ await morph.auth('edevlet-auth/edevlet').submitCode(code, { codeVerifier: pkceVe --- -### auth.acquireWithClientCredentials() +### auth.acquire() For non-interactive contexts. The SDK calls the token endpoint with `clientId` + `clientSecret` from config (resolved via `$variable`). Resulting tokens are stored in **this** context. ```typescript -await morph.auth('burgan-auth/device').acquireWithClientCredentials(); +await morph.auth('burgan-auth/device').acquire(); // SDK calls POST with grant_type=client_credentials // client_id=, client_secret=, // audience= @@ -470,7 +461,7 @@ await morph.auth('burgan-auth/1fa').setTokens({ }); ``` -For normal flows, prefer `submitCode()`, `acquireWithClientCredentials()`, or `exchangeToken()` — they handle the token endpoint call, `onTokenExchange` delegate check, and storage atomically. +For normal flows, prefer `submitCode()`, `acquire()`, or `exchangeToken()` — they handle the token endpoint call, `onTokenExchange` delegate check, and storage atomically. --- @@ -521,21 +512,21 @@ async function getInitialRoute(): Promise { const isLoggedIn = await morph.auth('burgan-auth/1fa').hasValidToken(); ``` -For synchronous UI state (render loops, build methods), use the `onTokenChange` callback to maintain local state: +For synchronous UI state (render loops, build methods), pass `onTokenChange` to `oauth2Plugin`: ```typescript -// In your app state / store let isLoggedIn = false; let canTransfer = false; -// onTokenChange keeps these in sync reactively -callbacks: { - onTokenChange: (authId, tokens) => { - if (authId === 'burgan-auth/1fa') isLoggedIn = !!tokens; - if (authId === 'burgan-auth/2fa') canTransfer = !!tokens; +oauth2Plugin({ + storage: browserStoragePlugin('myapp:tk:'), + callbacks: { + onTokenChange: (authId, tokens) => { + if (authId === 'burgan-auth/1fa') isLoggedIn = !!tokens; + if (authId === 'burgan-auth/2fa') canTransfer = !!tokens; + }, }, -} - +}) // Then in UI: use isLoggedIn / canTransfer (sync, always fresh) ``` @@ -671,44 +662,38 @@ const mainApi: HostConfig = { ```typescript interface MorphOptions { - // Data - storage: StorageProvider; + // Plugins — declare capabilities at init time + plugins: MorphPlugin[]; variables?: Record; - // Notifications (void callbacks — inform the host app) - callbacks: MorphCallbacks; - - // Functional delegates (return values — SDK needs the result) + // Core-level delegates (shared, not plugin-specific) networkDelegate?: NetworkDelegate; - onTokenExchange?: (grant: TokenExchangeGrant) => Promise; onSignPayload?: (payload: string, authId: string) => Promise; onDecryptResponse?: (encryptedBody: string, authId: string) => Promise; onLog?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, error?: Error, context?: Record) => void; - /** After each `host().get/post/...` attempt (401 refresh retry = second event). */ onHttpTrace?: (event: MorphHttpTraceEvent) => void; - - /** When `clientAuth` is `private_key_jwt`, return a signed client assertion JWT for the token endpoint. If omitted and `clientSecret` is present, `client_secret` is used instead. */ - onClientJwtAssertion?: (authId: string) => Promise; - /** Automatically acquire client_credentials for non-interactive contexts on `onAuthRequired`. Default false. */ - autoAcquireNonInteractive?: boolean; } ``` -**Data:** -- `storage` — Token storage delegate. SDK provides `createBrowserSessionStorage(prefix?)` and `createBrowserLocalStorage(prefix?)` for web apps. -- `variables` — Variable map for `$variable` interpolation in config. +**Plugins:** +- `plugins` — Array of `MorphPlugin` instances. Plugins declare `provides` and `requires` for automatic dependency resolution (topological sort). The combined set must include exactly one plugin that calls `ctx.provideAuth()` and one that calls `ctx.provideStorage()`. Order does not matter. See [Writing Plugins](writing-plugins.md). +- `variables` — Shared variable map for `$variable` interpolation in config (host headers, etc.). Plugin-specific variables (client secrets) should be passed to the plugin directly. + +**Core delegates (shared):** +- `networkDelegate` -- SSL pins, mTLS cert, proxy per hostname. Lazy, per first request. +- `onSignPayload` -- JWS signing. Called when `sign: true`. Returns signature string. +- `onDecryptResponse` -- Response decryption. Called when `encrypted: true`. Returns decrypted plaintext. +- `onLog` -- Low-level log hook. Prefer `loggerPlugin()` and pass the `logger` instance to each plugin. +- `onHttpTrace` -- Structured host HTTP trace. Fired once per `fetch()`. -**Notifications (callbacks):** -- `callbacks` — Auth lifecycle notifications: `onAuthRequired`, `onLogout`, `onTokenChange`. All `void` — inform the host app, don't return values. +**Logging:** Use `@morph/logger` -- create one `loggerPlugin()` instance and pass it as the `logger` option to `oauth2Plugin`, `browserStoragePlugin`, etc. All plugins route their log output through the shared logger. -**Functional delegates (SDK needs the result):** -- `networkDelegate` — SSL pins, mTLS cert, proxy per hostname. Lazy, per first request. -- `onTokenExchange` — Custom token exchange logic. Return `TokenSet` to override, `null` to let SDK handle it. -- `onSignPayload` — JWS signing. Called when `sign: true`. Returns signature string → SDK attaches as `X-JWS-Signature`. Throws if missing when `sign: true`. -- `onDecryptResponse` — Response decryption. Called when `encrypted: true`. Returns decrypted plaintext → SDK parses as JSON. Throws if missing when `encrypted: true`. -- `onLog` — Log delegate. Omit for silence. The SDK emits **`info`** for successful refresh, client_credentials renewal, token exchange, authorization_code storage, and 401-driven refresh (context includes `authId`). Failures use **`warn`** / **`error`**. -- `onHttpTrace` — Structured **host HTTP** trace (method, URL, path, `authId`, request/response headers, parsed response body, duration). Fired once per underlying `fetch()` (so a 401 followed by refresh + retry produces **two** events). `Authorization` in `requestHeaders` is redacted (`Bearer `). Distinct from `onLog` (human-oriented strings). Omit if you do not need request/response introspection. -- `onClientJwtAssertion` — Called when `clientAuth` is `private_key_jwt`. Returns a signed JWT client assertion for the token endpoint. When omitted and `clientSecret` is present, the SDK falls back to `client_secret_post`. +**Plugin-level options (passed to each plugin factory):** +- `logger` -- `MorphPlugin | LogFn` -- the shared logger for this plugin +- `storage` -- `StorageProvider | MorphPlugin` -- inline storage (oauth2Plugin only) +- `callbacks` -- `Partial` -- auth lifecycle (oauth2Plugin only) +- `variables` -- plugin-specific variables (client secrets, redirect URIs) +- `onTokenExchange`, `onClientJwtAssertion`, `autoAcquireNonInteractive` -- oauth2Plugin only --- @@ -735,7 +720,7 @@ interface MorphHttpTraceEvent { ### MorphCallbacks -All callbacks are `void` — they notify the host app, they don't return values. All receive `authId` in `provider/context` format. +Passed to `oauth2Plugin({ callbacks })`. All callbacks are `void` and receive `authId` in `provider/context` format. All fields are **optional** -- the plugin provides defaults. ```typescript interface MorphCallbacks { @@ -745,7 +730,24 @@ interface MorphCallbacks { } ``` +**Defaults (when not overridden):** +- `onAuthRequired` -- logs via the resolved `logger` at `warn` level (or no-op if no logger) +- `onLogout` -- logs via the resolved `logger` at `info` level (or no-op if no logger) +- `onTokenChange` -- not set (no-op) + +**Override only what you need:** + ```typescript +oauth2Plugin({ + storage: browserStoragePlugin('myapp:tk:'), + callbacks: { + onLogout: (authId, reason) => { + if (reason === 'session_expired') showToast('Session expired.'); + if (authId === 'burgan-auth/1fa') router.navigate('/login'); + }, + onTokenChange: (authId, tokens) => { + if (authId === 'burgan-auth/1fa') setIsLoggedIn(!!tokens); + }, const callbacks: MorphCallbacks = { onAuthRequired: (authId, metadata) => { // authId = 'burgan-auth/device', 'burgan-auth/1fa', 'edevlet-auth/edevlet', etc. @@ -753,7 +755,7 @@ const callbacks: MorphCallbacks = { switch (metadata.interaction) { case 'non-interactive': - morph.auth(authId).acquireWithClientCredentials(); + morph.auth(authId).acquire(); break; case 'interactive': router.navigate('/login', { authId }); @@ -763,21 +765,7 @@ const callbacks: MorphCallbacks = { break; } }, - - onLogout: (authId, reason) => { - if (reason === 'session_expired') { - showToast('Session expired. Please log in again.'); - } - if (authId === 'burgan-auth/1fa') { - router.navigate('/login'); - } - }, - - onTokenChange: (authId, tokens) => { - if (authId === 'burgan-auth/1fa') setIsLoggedIn(!!tokens); - if (authId === 'burgan-auth/2fa') setCanTransfer(!!tokens); - }, -}; +}) ``` --- @@ -799,9 +787,9 @@ interface TokenExchangeGrant { ``` ```typescript -// Provided at init as a functional delegate (not inside callbacks) -const morph = MorphClient.init(config, { - // ... +// Passed to oauth2Plugin (not MorphOptions) +oauth2Plugin({ + storage: browserStoragePlugin('myapp:tk:'), onTokenExchange: async (grant) => { if (grant.type === 'token_exchange' && grant.authId === 'burgan-auth/2fa') { const res = await fetch('/api/custom-step-up', { @@ -811,9 +799,9 @@ const morph = MorphClient.init(config, { const tokens = await res.json(); return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token }; } - return null; // all other exchanges → let SDK handle standard OAuth2 + return null; // standard OAuth2 exchange }, -}); +}) ``` --- @@ -835,7 +823,7 @@ onAuthRequired: (authId, metadata) => { // metadata = { workflow: 'step-up-auth', grantHint: 'token_exchange', interaction: 'interactive' } if (metadata.workflow === 'device-auth') { - morph.auth(authId).acquireWithClientCredentials(); + morph.auth(authId).acquire(); } else if (metadata.workflow === 'login') { router.navigate('/login', { authId }); // later: morph.auth(authId).submitCode(code) @@ -864,7 +852,7 @@ interface TokenSet { ``` ```typescript -// Normally handled by submitCode() / acquireWithClientCredentials(). +// Normally handled by submitCode() / acquire(). // Escape hatch: await morph.auth('burgan-auth/1fa').setTokens({ accessToken: 'eyJhbGciOiJSUzI1NiIs...', @@ -1030,7 +1018,7 @@ const networkDelegate: NetworkDelegate = { Builds the query string for an OAuth 2.0 **authorize** redirect (`client_id`, `redirect_uri`, `response_type`, `scope`, `state`, plus any `extraParams`). Combine with `authorization.endpoint` from config: if `endpoint` is an **absolute** URL, it is used as-is (so authorize still hits the real IdP when `provider.baseUrl` is rewritten for dev token proxying). Optional fields on `AuthContextConfig.authorization`: `responseType`, `extraParams`. ```typescript -import { buildOAuth2AuthorizationUrl } from 'morph-api-client'; +import { buildOAuth2AuthorizationUrl } from '@morph/core'; ``` --- @@ -1103,7 +1091,7 @@ if (res instanceof AuthError) { Thrown when a token endpoint (refresh, client_credentials, authorization_code exchange, token exchange) returns a non-2xx HTTP response. ```typescript -import { TokenEndpointError } from 'morph-api-client'; +import { TokenEndpointError } from '@morph/core'; try { await morph.auth('morph-auth/1fa').submitCode(code); @@ -1121,16 +1109,42 @@ try { ### Browser Storage Factories -Ready-made `StorageProvider` implementations for web apps. +Ready-made `StorageProvider` implementations for web apps, available as a plugin or standalone factory. + +```typescript +import { MorphClient } from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { browserStoragePlugin } from '@morph/browser-storage'; + +// As a plugin (recommended) — order does not matter +MorphClient.init(config, { + plugins: [ + browserStoragePlugin('myapp:tk:'), // sessionStorage (default) + oauth2Plugin(), + ], + // ... +}); + +// localStorage variant +MorphClient.init(config, { + plugins: [ + browserStoragePlugin('myapp:tk:', 'local'), + oauth2Plugin(), + ], + // ... +}); +``` + +Standalone factories are also available for custom plugins or direct dependency injection: ```typescript -import { createBrowserSessionStorage, createBrowserLocalStorage } from 'morph-api-client'; +import { createBrowserSessionStorage, createBrowserLocalStorage } from '@morph/browser-storage'; -// sessionStorage — tokens survive SPA reload but not new tabs -const storage = createBrowserSessionStorage('myapp:tk:'); +const sessionStore = createBrowserSessionStorage('myapp:tk:'); +const localStore = createBrowserLocalStorage('myapp:tk:'); -// localStorage — tokens persist across tabs and sessions -const storage = createBrowserLocalStorage('myapp:tk:'); +// Pass directly to oauth2Plugin via explicit DI +plugins: [oauth2Plugin({ storage: sessionStore })] ``` ### OAuth State Helpers @@ -1138,7 +1152,7 @@ const storage = createBrowserLocalStorage('myapp:tk:'); Encode/decode `authId` in the OAuth `state` parameter. Used internally by `getAuthorizationUrl` and `completeOAuthCallback`. Exposed for custom flows. ```typescript -import { encodeOAuthState, decodeOAuthState } from 'morph-api-client'; +import { encodeOAuthState, decodeOAuthState } from '@morph/core'; const state = encodeOAuthState('morph-auth/2fa'); // 'morph1.eyJhIjo...' const decoded = decodeOAuthState(state); // { authId: 'morph-auth/2fa' } @@ -1150,7 +1164,7 @@ decodeOAuthState('random-opaque-state'); // null Strips OAuth return query params (`code`, `state`, `session_state`, `iss`, `scope`, `error`, `error_description`) from the current browser URL via `history.replaceState`. No-op outside browser. ```typescript -import { cleanOAuthReturnFromBrowser } from 'morph-api-client'; +import { cleanOAuthReturnFromBrowser } from '@morph/core'; cleanOAuthReturnFromBrowser(); ``` @@ -1159,7 +1173,7 @@ cleanOAuthReturnFromBrowser(); Normalizes IPv6 loopback origins to `http://localhost:PORT` for consistent `redirect_uri` matching. ```typescript -import { normalizeLoopbackOrigin } from 'morph-api-client'; +import { normalizeLoopbackOrigin } from '@morph/core'; normalizeLoopbackOrigin('http://[::1]:5173'); // 'http://localhost:5173' normalizeLoopbackOrigin('http://localhost:5173'); // unchanged ``` diff --git a/docs/architecture.md b/docs/architecture.md index 61cbc91..e178f08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,18 +56,22 @@ The SDK is not a standalone token manager that hands tokens to a separate HTTP c │ └───────┬──────────┘ │ │ ┌───────▼──────────────────────▼───────────────────────┐ -│ Platform Abstractions │ -│ StorageProvider · NetworkDelegate │ -│ (injected at init, platform-specific impls) │ +│ Plugin System + Platform Abstractions │ +│ MorphPlugin[] → topoSort → install │ +│ StorageProvider (via plugin) · NetworkDelegate │ └──────────────────────────────────────────────────────┘ ``` +### Plugin System + +At init time, `MorphClient.init()` topologically sorts the `plugins[]` array using each plugin's `provides` and `requires` declarations, then calls `install(ctx)` on each in dependency order. Plugins register capabilities via `ctx.provideAuth()` and `ctx.provideStorage()`. This decouples the core from any specific auth or storage implementation. + ### Module Responsibilities -- **MorphRuntime** — Thin coordinator. Owns config queries (`getHost`, `parseAuthRef`, `getTokenStatus`, `getProviderMeta`), OAuth flow orchestration (`getAuthorizationUrl`, `completeOAuthCallback`, `completeOAuthReturn`), and delegates token/HTTP work to sub-modules. -- **TokenLifecycle** — All token operations: consolidated `executeGrant` helper (replaces 4 separate grant functions), lock management, refresh, client credentials, token exchange, `resolveAccessToken` (the core resolution algorithm), and `handle401Recovery` (for the host pipeline's 401 path). -- **HostPipeline** — Host HTTP requests: URL resolution, header merging, `fetchWithTrace` (timeout + abort + trace), 401 recovery delegation, response parsing, and sign/decrypt delegate calls. -- **TokenVault** — Storage I/O: key interpolation, serialization, and delegation to the injected `StorageProvider`. +- **MorphRuntime** — Thin coordinator. Installs plugins (topo sort + install loop), owns config queries (`getHost`, `parseAuthRef`, `getTokenStatus`, `getProviderMeta`), OAuth flow orchestration (`getAuthorizationUrl`, `completeOAuthCallback`, `completeOAuthReturn`), and delegates token/HTTP work to sub-modules. +- **TokenLifecycle** (`@morph/oauth2`) — All token operations: consolidated `executeGrant` helper, lock management, refresh, client credentials, token exchange, `resolveAccessToken` (the core resolution algorithm), and `handle401Recovery` (for the host pipeline's 401 path). Implements the `AuthPlugin` interface. +- **HostPipeline** — Host HTTP requests: URL resolution, header merging, `fetchWithTrace` (timeout + abort + trace), 401 recovery delegation, response parsing, and sign/decrypt delegate calls. Depends on `AuthPlugin` interface (not `TokenLifecycle` directly). +- **TokenVault** (`@morph/oauth2`) — Storage I/O: key interpolation, serialization, and delegation to the `StorageProvider` registered by the storage plugin. ### Dependency Graph @@ -105,8 +109,8 @@ Auth contexts are grouped under **providers**. A provider represents a single au providers ├── morph-auth (baseUrl: .../realms/morph) │ ├── device (non-interactive, device-scoped) -│ ├── 1fa (interactive login, user-scoped) -│ └── 2fa (step-up via token exchange, session-scoped) +│ ├── 2fa (interactive login, session-scoped) +│ └── 1fa (session token via exchange from 2fa, user-scoped) │ └── google-auth (baseUrl: accounts.google.com) └── google (redirect, PKCE, session-scoped) @@ -118,13 +122,13 @@ While contexts within a provider share the auth server, each context operates in - **device** — Machine-level credentials. Acquired non-interactively (client credentials). Stored persistently at device scope. Used for unauthenticated-user API calls (public content, pre-login flows). -- **1fa** — First-factor user authentication. Acquired interactively (authorization code). Token is user-scoped and persisted. Represents a logged-in user session. +- **2fa** — Interactive user login. Acquired via authorization code flow (browser redirect to Keycloak). Session-scoped in-memory tokens. Subject to session timeouts (background, inactivity). -- **2fa** — Step-up authentication. Acquired via token exchange from the 1fa context. Subject to session timeouts (background, inactivity). +- **1fa** — Long-lived session token. Acquired via token exchange from the 2fa context (`token.exchangeSource: "morph-auth/2fa"`). User-scoped persistent encrypted storage. Auto-exchanged during token resolution. - **google** — External OAuth2 provider. Full authorization code flow with PKCE. Session-scoped tokens. -The SDK does not enforce a hierarchy between contexts — each operates independently. The progressive chain (device → 1fa → 2fa) is expressed through the host application's auth flow logic and the `delegateMetadata.grantHint` values. +The SDK does not enforce a hierarchy between contexts — each operates independently. The progressive chain (device → 2fa login → 1fa via exchange) is expressed through the host application's auth flow logic and the `delegateMetadata.grantHint` values. ## Host Model @@ -138,8 +142,7 @@ Hosts represent the API servers that the application calls. Each host has: ``` hosts ├── main-api (baseUrl: localhost:3000) -│ ├── allowedAuth: [morph-auth/device, morph-auth/1fa, morph-auth/2fa] -│ └── defaultAuth: morph-auth/2fa +│ └── allowedAuth: [morph-auth/device, morph-auth/1fa, morph-auth/2fa, google-auth/google] │ └── google-api (baseUrl: googleapis.com) ├── allowedAuth: [google-auth/google] @@ -152,24 +155,41 @@ The `allowedAuth` list enables **request-time validation**: if a request through ``` morph-api-client/ -├── core/ # morph-api-client npm package -│ └── src/ -│ ├── client/ # MorphClient, HostClient, AuthHandle (facades) -│ ├── config/ # validate (CtxRef, hostByKey), interpolate ($variable) -│ ├── tokens/ # TokenLifecycle, TokenVault -│ ├── http/ # HostPipeline -│ ├── oauth/ # tokenHttp (grant HTTP), oauthAuthorize (URL builder) -│ ├── util/ # jwt, expiry, url, oauthState, oauthReturn, httpTrace -│ ├── storage/ # createBrowserSessionStorage, createBrowserLocalStorage -│ ├── runtime.ts # MorphRuntime coordinator -│ ├── types.ts # Public interfaces -│ ├── errors.ts # Error classes -│ └── index.ts # Public exports +├── packages/ +│ ├── ts/ # TypeScript npm workspaces (@morph/*) +│ │ ├── core/ # @morph/core — types, config, HTTP pipeline, client facades +│ │ │ └── src/ +│ │ │ ├── client/ # MorphClient, HostClient, AuthHandle +│ │ │ ├── config/ # validate (CtxRef, hostByKey), interpolate ($variable) +│ │ │ ├── http/ # HostPipeline (depends on AuthPlugin interface) +│ │ │ ├── util/ # jwt, expiry, url, duration, httpTrace, oauthState +│ │ │ ├── runtime.ts # MorphRuntime coordinator +│ │ │ ├── types.ts # Public interfaces (AuthPlugin, StorageProvider, …) +│ │ │ ├── errors.ts # Error classes +│ │ │ └── index.ts # Public exports +│ │ ├── oauth2/ # @morph/oauth2 — OAuth2 token lifecycle plugin +│ │ │ └── src/ +│ │ │ ├── tokens/ # TokenLifecycle (implements AuthPlugin), TokenVault +│ │ │ ├── oauth/ # tokenHttp (grant HTTP) +│ │ │ ├── util/ # interpolate, expiry, exchangeSources +│ │ │ └── index.ts # oauth2Plugin() MorphPlugin factory +│ │ ├── browser-storage/ # @morph/browser-storage — browser storage adapters +│ │ │ └── src/ +│ │ │ ├── browserStorage.ts # createBrowserSessionStorage, createBrowserLocalStorage +│ │ │ └── index.ts # browserStoragePlugin() MorphPlugin factory +│ │ └── logger/ # @morph/logger — structured logging plugin +│ │ └── src/ +│ │ └── index.ts # loggerPlugin() MorphPlugin factory +│ └── dart/ # Dart Pub packages (morph_*), parity with TS track +│ ├── morph_core/ +│ ├── morph_oauth2/ +│ ├── morph_logger/ +│ └── morph_storage/ ├── poc/ -│ ├── ts-vue/ # Vue 3 PoC app -│ ├── keycloak/ # Docker Keycloak realm -│ └── mock-api/ # Mock REST API -└── docs/ # Design & API documentation +│ ├── ts-vue/ # Vue 3 PoC app +│ ├── keycloak/ # Docker Keycloak realm +│ └── mock-api/ # Mock REST API +└── docs/ # Design & API documentation ``` ## Transport Security @@ -214,9 +234,9 @@ Authentication UX varies wildly: biometric prompts, WebView redirects, OTP scree Different tokens have different security and lifecycle requirements: - Device tokens are long-lived and only need secure storage. -- 1fa tokens are user-specific and need encryption at rest. -- 2fa access tokens are ephemeral and can live in memory. -- 2fa refresh tokens need persistent encrypted storage to survive app restarts. +- 2fa tokens are session-scoped and can live in memory (ephemeral login session). +- 1fa access and refresh tokens are user-scoped and need persistent encrypted storage to survive app restarts. +- Google tokens are session-scoped and can live in memory. A single storage strategy cannot serve all of these correctly. diff --git a/docs/configuration.md b/docs/configuration.md index d52845d..ad506f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,7 +48,7 @@ Morph API Client is initialized with a JSON configuration **object** containing } ``` -The configuration is validated during `MorphClient.init()`. Invalid configurations produce descriptive errors at initialization time, not at request time. +The configuration is validated during `MorphClient.init()`. Invalid configurations produce descriptive errors at initialization time, not at request time. In addition to the JSON config, `MorphClient.init()` requires a `plugins` array with at least an auth plugin and a storage plugin. See [Getting Started](getting-started.md) for initialization and [Writing Plugins](writing-plugins.md) for custom plugins. --- @@ -98,7 +98,7 @@ A context represents a single authentication scheme within a provider. Each cont | `key` | `string` | Yes | Unique identifier for this auth context within the provider. Combined with provider key to form auth id: `morph-auth/device`. Also available as the `$key` interpolation variable. | | `clientId` | `string` | No | OAuth2 client identifier. Supports `$variable` interpolation. | | `clientSecret` | `string` | No | OAuth2 client secret. Supports `$variable` interpolation. **Never exposed** via `getProviderMeta()`. | -| `clientAuth` | `string` | No | Client authentication method: `"client_secret_post"` (default) or `"private_key_jwt"`. When `private_key_jwt`, the SDK calls `MorphOptions.onClientJwtAssertion` to obtain a signed assertion. | +| `clientAuth` | `string` | No | Client authentication method: `"client_secret_post"` (default) or `"private_key_jwt"`. When `private_key_jwt`, the auth plugin calls `onClientJwtAssertion` (passed via `oauth2Plugin({ onClientJwtAssertion })`) to obtain a signed assertion. | ### identity diff --git a/docs/dart-parity.md b/docs/dart-parity.md new file mode 100644 index 0000000..a372681 --- /dev/null +++ b/docs/dart-parity.md @@ -0,0 +1,61 @@ +# Dart / Flutter SDK parity + +The TypeScript implementation is the reference (`packages/ts/core`, `packages/ts/oauth2`, +`packages/ts/browser-storage`, `packages/ts/logger`). **Dart/Flutter parity** is tracked on +GitHub: +- **[issue #1](https://github.com/burgan-tech/morph-api-client/issues/1)** — scaffold (`morph_core`, CI entry). +- **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)** — **full Dart/TS feature parity** (remaining gaps). + +Base branch for work is **`f/plugin`** unless release policy changes. + +**Execution discipline:** milestones are shipped via the repo skill [morph-api-client-issue-pr-merge](../.cursor/skills/morph-api-client-issue-pr-merge/SKILL.md) (issue → PR with `Closes #n` → merge → update docs). + +## Current packages + +| Dart package | Role | Status | +|-------------|------|--------| +| [`packages/dart/morph_core`](../packages/dart/morph_core) | Config validation, `MorphClient`, `MorphRuntime`, `HostPipeline`, types | **Shipped** (facade parity with TS `MorphClient` / `HostClient` / `AuthHandle` tracked in codebase) | +| [`packages/dart/morph_oauth2`](../packages/dart/morph_oauth2) | Token lifecycle, vault, `oauth2Plugin` | **Shipped** | +| [`packages/dart/morph_storage`](../packages/dart/morph_storage) | In-memory storage plugin | **VM / tests** (`@morph/browser-storage` analogue for apps not yet ported) | +| [`packages/dart/morph_logger`](../packages/dart/morph_logger) | Logger plugin + traces | **Shipped** | + +**Public import (core):** `package:morph_core/morph_core.dart` + +## Done vs next (milestone summary) + +| Milestone | Dart status | +|-----------|--------------| +| Config validation | **Done:** `validateAndIndexConfig` parity with `validate.ts`. | +| `MorphClient.init` → runtime | **Done:** Builds `MorphRuntime` (plugins, HTTP pipeline), not a stub. | +| Runtime + plugins | **Done:** topological install, auth/storage providers wired. | +| HTTP host pipeline | **Done:** `HostPipeline.hostFetch`; `MorphClient.host()` exposes `HostClient`. | +| Public client API | **Done:** TS-style `MorphClient` methods + `AuthHandle` for token helpers. | +| OAuth2 plugin | **Done:** `@morph/oauth2` analogue in `morph_oauth2`. | +| Logger | **Done:** `@morph/logger` analogue in `morph_logger`. | +| 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 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; 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) + +Per [architecture.md](architecture.md): + +- Same **JSON config** shape and validation behavior. +- **Mirrored public API** with Dart idioms (`snake_case`, `Future` where async). +- **Platform adapters** for storage and HTTP: [platform-adapters.md](platform-adapters.md). + +## Detailed backlog + +Acceptance criteria and epic tracking: **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)**. + +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. diff --git a/docs/getting-started.md b/docs/getting-started.md index 4c103af..90dde31 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ ## Installation ```bash -npm install morph-api-client +npm install @morph/core @morph/oauth2 @morph/browser-storage @morph/logger ``` --- @@ -84,17 +84,38 @@ See [Configuration Reference](configuration.md) for a full field reference. ## Initialization +Plugins provide auth and storage capabilities. All `oauth2Plugin()` options are optional -- the plugin provides sensible defaults for callbacks. The runtime topologically sorts plugins by `provides` / `requires`, so order does not matter. + ```typescript -import { MorphClient, createBrowserSessionStorage } from 'morph-api-client'; +import { MorphClient } from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { browserStoragePlugin } from '@morph/browser-storage'; +import { loggerPlugin } from '@morph/logger'; import config from './morph-config.json'; +const logger = loggerPlugin({ level: 'info' }); + const morph = MorphClient.init(config, { + plugins: [ + logger, + oauth2Plugin({ + logger, + storage: browserStoragePlugin({ prefix: 'myapp:tk:', logger }), + variables: { + deviceClientSecret: 'device-secret-xyz', + userClientSecret: 'user-secret-uvw', + oauthRedirectUri: `${window.location.origin}/oauth/callback`, + deviceId: getDeviceId(), + }, + autoAcquireNonInteractive: true, + }), + ], storage: createBrowserSessionStorage('myapp:tk:'), callbacks: { onAuthRequired: (authId, metadata) => { if (metadata.interaction === 'non-interactive') { - morph.auth(authId).acquireWithClientCredentials(); + morph.auth(authId).acquire(); } else if (metadata.interaction === 'interactive') { router.navigate('/login', { authId }); } @@ -118,6 +139,25 @@ const morph = MorphClient.init(config, { }); ``` +Create one logger and pass it to every plugin. All log output flows through a single pipeline. Override only the callbacks you need (all are optional with defaults): + +```typescript +oauth2Plugin({ + logger, + storage: browserStoragePlugin({ prefix: 'myapp:tk:', logger }), + callbacks: { + onAuthRequired: (authId, metadata) => { + if (metadata.interaction === 'interactive') router.navigate('/login', { authId }); + }, + onLogout: (authId, reason) => { + if (authId === 'my-auth/user') router.navigate('/login'); + }, + }, +}) +``` + +Core `MorphOptions` only holds shared concerns (`onHttpTrace`, `onSignPayload`, `onDecryptResponse`). Logging is handled by `@morph/logger` -- create one logger plugin and pass it to every plugin that needs it. Callbacks and variables are passed directly to each plugin factory. + --- ## Making API Calls @@ -166,7 +206,7 @@ await morph.auth('google-auth/google').submitCode(code, { codeVerifier: pkceVeri For non-interactive flows (device tokens), the SDK can acquire tokens autonomously: ```typescript -await morph.auth('my-auth/device').acquireWithClientCredentials(); +await morph.auth('my-auth/device').acquire(); ``` For step-up auth, the SDK exchanges one token for another. Called on the **source**, target is the parameter: @@ -190,20 +230,19 @@ if (await morph.auth('my-auth/user').hasValidToken()) { } ``` -For synchronous UI state, use the `onTokenChange` callback: +For synchronous UI state, pass `onTokenChange` to `oauth2Plugin`: ```typescript let isLoggedIn = false; -const morph = MorphClient.init(config, { - // ... +oauth2Plugin({ + storage: browserStoragePlugin('myapp:tk:'), callbacks: { - // ... onTokenChange: (authId, tokens) => { if (authId === 'my-auth/user') isLoggedIn = !!tokens; }, }, -}); +}) ``` --- diff --git a/docs/github-issue-1-body-issue-only.md b/docs/github-issue-1-body-issue-only.md new file mode 100644 index 0000000..e2de29f --- /dev/null +++ b/docs/github-issue-1-body-issue-only.md @@ -0,0 +1,28 @@ +## Goal + +Add a **Dart/Flutter** SDK that mirrors the TypeScript Morph API client: same JSON config and public API shape (`docs/architecture.md`, `docs/api-reference.md`). + +Base branch for all Dart work: **`f/plugin`**. + +--- + +## Phase 1 (scaffold) — landed via PR `feat/dart-sdk-scaffold` → `f/plugin` + +Use **`Refs #1`** in that PR description to keep this issue as the tracking epic, or **`Closes #1`** if you only wanted the scaffold. + +- [x] `packages/dart/morph_core` with `pubspec`, analysis, stub `MorphClient.init` (throws `UnimplementedError`) +- [x] GitHub Actions: `dart analyze` + `dart test` (`.github/workflows/dart.yml`) +- [x] `docs/dart-parity.md` + index in `docs/README.md` + +Local run: `make dart-all` or `cd packages/dart/morph_core && dart pub get && dart analyze && dart test`. + +--- + +## Later phases (sub-issues or checklist) + +- [ ] Strongly typed `MorphConfig` / `MorphOptions` aligned with `packages/ts/core/src/types.ts` +- [ ] Runtime + topological plugin install (parity with `packages/ts/core/src/runtime.ts`) +- [ ] Port `@morph/oauth2` (new package e.g. `packages/dart/morph_oauth2` or `lib/src/oauth2/`) +- [ ] Storage adapters (Flutter / VM) per `docs/platform-adapters.md` +- [ ] Logger / HTTP trace hooks +- [ ] Optional Dart/Flutter sample under `poc/` diff --git a/docs/github-issue-1-body.md b/docs/github-issue-1-body.md new file mode 100644 index 0000000..4fdf59c --- /dev/null +++ b/docs/github-issue-1-body.md @@ -0,0 +1,33 @@ +Paste this body into GitHub issue [#1](https://github.com/burgan-tech/morph-api-client/issues/1), or edit the existing issue description. + +````markdown +## Goal + +Add a **Dart/Flutter** SDK that mirrors the TypeScript Morph API client: same JSON config and public API shape (`docs/architecture.md`, `docs/api-reference.md`). + +Base branch for all Dart work: **`f/plugin`**. + +--- + +## Phase 1 (scaffold) — landed via PR `feat/dart-sdk-scaffold` → `f/plugin` + +Use **`Refs #1`** in that PR description to keep this issue as the tracking epic, or **`Closes #1`** if you only wanted the scaffold. + +- [x] `packages/dart/morph_core` with `pubspec`, analysis, stub `MorphClient.init` (throws `UnimplementedError`) +- [x] GitHub Actions: `dart analyze` + `dart test` (`.github/workflows/dart.yml`) +- [x] `docs/dart-parity.md` + index in `docs/README.md` + +Local run: `make dart-all` or `cd packages/dart/morph_core && dart pub get && dart analyze && dart test`. + +--- + +## Later phases (sub-issues or checklist) + +- [ ] Strongly typed `MorphConfig` / `MorphOptions` aligned with `packages/ts/core/src/types.ts` +- [ ] Runtime + topological plugin install (parity with `packages/ts/core/src/runtime.ts`) +- [ ] Port `@morph/oauth2` (new package e.g. `packages/dart/morph_oauth2` or `lib/src/oauth2/`) +- [ ] Storage adapters (Flutter / VM) per `docs/platform-adapters.md` +- [ ] Logger / HTTP trace hooks +- [ ] Optional Dart/Flutter sample under `poc/` + +```` diff --git a/docs/github-issue-dart-full-parity-body.md b/docs/github-issue-dart-full-parity-body.md new file mode 100644 index 0000000..1c642c2 --- /dev/null +++ b/docs/github-issue-dart-full-parity-body.md @@ -0,0 +1,50 @@ +## Goal + +Deliver a **Dart/Flutter Morph API Client** with **functional parity** to the existing TypeScript implementation (`packages/ts/core`, `packages/ts/oauth2`, `packages/ts/browser-storage`, `packages/ts/logger`): same **JSON configuration schema**, same **OAuth2 / multi-context token lifecycle**, **HTTP pipeline behavior** (auth resolution, retry, trace, 401 recovery), and **plugin composition** (`provides` / `requires`, `provideAuth` / `provideStorage`, logger chaining). + +Foundation: Phase 1 scaffold in `packages/dart/morph_core` ([#1](https://github.com/burgan-tech/morph-api-client/issues/1), base branch **`f/plugin`**). + +References: +- [docs/architecture.md](https://github.com/burgan-tech/morph-api-client/blob/f/plugin/docs/architecture.md) +- [docs/api-reference.md](https://github.com/burgan-tech/morph-api-client/blob/f/plugin/docs/api-reference.md) +- [docs/configuration.md](https://github.com/burgan-tech/morph-api-client/blob/f/plugin/docs/configuration.md) +- [docs/platform-adapters.md](https://github.com/burgan-tech/morph-api-client/blob/f/plugin/docs/platform-adapters.md) +- [docs/token-lifecycle.md](https://github.com/burgan-tech/morph-api-client/blob/f/plugin/docs/token-lifecycle.md) + +## Scope (parity targets) + +| TS package | Dart target (name TBD in PRs; split like TS) | Feature parity checklist | +|------------|---------------------------------------------|---------------------------| +| `@morph/core` | `morph_core` (expand scaffold) | `MorphClient`, `MorphRuntime`-equivalent, `MorphConfig` validation/interpolation, `HostPipeline`, `HostClient`, `AuthHandle`, errors, HTTP trace events, plugin topo-sort + `MorphPluginContext` | +| `@morph/oauth2` | e.g. `morph_oauth2` | `AuthPlugin`, `TokenLifecycle`, `TokenVault`, OAuth helpers, refresh / exchange / 401 recovery behavior aligned with [token-lifecycle.md](docs/token-lifecycle.md) | +| `@morph/browser-storage` | e.g. `morph_secure_storage` or VM-first | `StorageProvider`; Flutter: secure storage / memory paths per [platform-adapters.md](docs/platform-adapters.md) | +| `@morph/logger` | packaged or nested | Optional logger plugin chaining `onLog` / `onHttpTrace` like TS | + +**Non-functional:** idiomatic Dart (`snake_case` public APIs, `Future`/streams where async), deterministic tests mirroring TS test intent where configs align. + +## Out of scope (unless explicitly expanded) + +- Changing the **canonical JSON schema** consumed by TS (Dart must **validate** against the same semantics; schema evolution stays coordinated). +- UI samples (optional follow-up PoC); this issue tracks **SDK parity** first. + +## Acceptance criteria + +- [ ] **`dart analyze`** and **`dart test`** pass for every shipped Dart package in CI (extend [`.github/workflows/dart.yml`](.github/workflows/dart.yml) or split jobs per package). +- [ ] **`MorphClient.init`** with real config + `@morph/oauth2`-parity + storage plugin can obtain tokens and execute **authenticated host requests** (VM or Flutter test harness), behavior consistent with documented TS flows. +- [ ] **Docs:** [docs/dart-parity.md](docs/dart-parity.md) updated when parity milestones land; parity matrix references TS APIs. +- [ ] **Regression parity:** Critical paths covered by tests analogous to TS (fixture JSON configs where shared). + +## Suggested sequencing (milestones inside this issue) + +1. Typed **`MorphConfig` / `MorphOptions`** + validators + interpolate variables (Dart). +2. **Runtime + plugins** equivalent to TS `MorphRuntime`, `runtime.ts`. +3. **OAuth2 package** parity (`TokenLifecycle`, vault, OAuth URL/callback helpers). +4. **Storage** plugins (VM then Flutter secure storage adapters). +5. **Logger** plugin parity. +6. **Integration / gold tests** against same sample config files as TS (where practical). + +--- + +**Blocked by / related:** [#1](https://github.com/burgan-tech/morph-api-client/issues/1) (scaffold + CI entry point). + +**Branch policy:** Deliver against **`f/plugin`** unless release policy moves default branch. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..7a049c3 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,269 @@ +# Project Overview + +Morph API Client is a **config-driven, multi-context HTTP client** with built-in OAuth2 token lifecycle management. The SDK handles token acquisition, storage, refresh, exchange, and recovery transparently so the host application never manages tokens directly. + +This document provides a visual overview of how all the pieces fit together. + +--- + +## System Architecture + +```mermaid +graph TB + subgraph browser ["Browser (http://127.0.0.1:5173)"] + VueApp["Vue PoC App"] + SDK["Morph SDK (in-browser)"] + Storage["sessionStorage (tokens)"] + end + + subgraph backend ["Backend Services"] + MockAPI["Mock API :3000"] + KC["Keycloak :8080"] + end + + VueApp -->|"morph.host('main-api').get('/accounts')"| SDK + SDK -->|"resolve token, attach Bearer header"| MockAPI + SDK -->|"token endpoint (via Vite proxy)"| KC + SDK <-->|"read/write tokens"| Storage + MockAPI -->|"validate JWT via JWKS"| KC + VueApp -->|"browser redirect for login"| KC + KC -->|"redirect back with code"| VueApp +``` + +**How it works:** + +1. The app initializes `MorphClient` with a `plugins` array. Plugins are topologically sorted by `provides`/`requires` and installed in dependency order. Auth (`@morph/oauth2`) and storage (`@morph/browser-storage`) are registered via plugins -- order in the array does not matter. +2. The Vue app makes API calls through the SDK: `morph.host('main-api').get('/accounts')` +3. The SDK resolves the appropriate token for the request (refresh if expired, exchange if needed) +4. The SDK attaches the `Authorization: Bearer ` header and sends the request to the Mock API +5. The Mock API validates the JWT against Keycloak's JWKS endpoint +6. Tokens are stored in the browser's `sessionStorage` (prefixed `morph-poc:tk:`) + +--- + +## Auth Providers and Contexts + +The SDK organizes authentication into **providers** (auth servers) and **contexts** (token types within a provider). Each context has its own grant type, storage policy, and lifecycle. + +### Keycloak Provider (`morph-auth`) + +```mermaid +graph LR + subgraph morphAuth ["morph-auth (Keycloak)"] + Device["device\nclient_credentials"] + TwoFA["2fa\nauthorization_code\n(interactive login)"] + OneFA["1fa\ntoken_exchange\n(session token)"] + end + + TwoFA -->|"exchange 2fa token for 1fa"| OneFA +``` + +### Google Provider (`google-auth`) + +```mermaid +graph LR + subgraph googleAuth ["google-auth (Google OAuth)"] + Google["google\nauthorization_code + PKCE\n(redirect flow)"] + end +``` + +--- + +## Keycloak Client Mapping + +Each SDK auth context maps to a specific Keycloak client. This is the most important table in the project: + +| SDK Auth ID | Keycloak Client | Grant Type | Purpose | Token Lifetime | +|---|---|---|---|---| +| `morph-auth/device` | `morph-device` | `client_credentials` | Machine-level API access without user login | 15s access | +| `morph-auth/2fa` | `morph-login` | `authorization_code` | Interactive user login (browser redirect to Keycloak) | 30s access, 60s refresh idle | +| `morph-auth/1fa` | `morph-session` | `token_exchange` (RFC 8693) | Long-lived session derived from 2fa login | 20s access, 60s refresh idle | +| `google-auth/google` | (Google Cloud OAuth) | `authorization_code` + PKCE | External identity verification | Google-managed | + +**Why "2fa" for login and "1fa" for session?** The naming reflects the authentication assurance level, not the order of acquisition. The 2fa context represents a fresh interactive login (higher assurance), while 1fa is a derived session token (lower assurance, longer-lived). + +--- + +## Auth Flow: Login and Token Exchange + +This is the complete flow from unauthenticated to fully authenticated: + +```mermaid +sequenceDiagram + participant User + participant Vue as Vue App + participant SDK as Morph SDK + participant KC as Keycloak + participant API as Mock API + + Note over User,API: Step 1 - Device Token (automatic) + SDK->>KC: POST /token (client_credentials, morph-device) + KC-->>SDK: access_token (device) + SDK->>SDK: Store device token + + Note over User,API: Step 2 - User Login (interactive) + User->>Vue: Click "Keycloak login" + Vue->>KC: Redirect to /auth (morph-login) + KC->>User: Show login form + User->>KC: Enter testuser / TestPass123! + KC->>Vue: Redirect to /oauth/callback?code=... + Vue->>SDK: completeOAuthCallback(code, state) + SDK->>KC: POST /token (authorization_code, morph-login) + KC-->>SDK: access_token + refresh_token (2fa) + SDK->>SDK: Store 2fa tokens + + Note over User,API: Step 3 - Token Exchange (automatic or manual) + SDK->>KC: POST /token (token_exchange, morph-session) + Note right of SDK: subject_token = 2fa access_token + KC-->>SDK: access_token + refresh_token (1fa) + SDK->>SDK: Store 1fa tokens + + Note over User,API: Step 4 - API Calls (transparent) + Vue->>SDK: morph.host('main-api').get('/accounts') + SDK->>SDK: Resolve 2fa token (refresh if needed) + SDK->>API: GET /accounts (Bearer 2fa-token) + API->>KC: Validate JWT via JWKS + API-->>SDK: 200 OK + accounts data + SDK-->>Vue: MorphResponse +``` + +--- + +## Host Configuration + +Hosts define API servers and which auth contexts can be used with them: + +| Host Key | Base URL | Allowed Auth IDs | Purpose | +|---|---|---|---| +| `main-api` | `http://localhost:3000` | `morph-auth/device`, `morph-auth/1fa`, `morph-auth/2fa`, `google-auth/google` | Mock banking API | +| `google-api` | `https://www.googleapis.com` | `google-auth/google` | Google APIs | + +Each request through `morph.host('main-api')` must specify an `auth` parameter (or the host must have a `defaultAuth`). The SDK validates the auth ID against the host's `allowedAuth` list at request time. + +--- + +## Token Storage Model + +Different contexts store tokens differently based on security and lifetime requirements: + +| Context | Storage Type | Scope | Where | Survives Reload? | Survives New Tab? | +|---|---|---|---|---|---| +| `morph-auth/device` | persistent / secure | device | sessionStorage | Yes | No (sessionStorage) | +| `morph-auth/2fa` | memory / secure | session | sessionStorage | Yes | No | +| `morph-auth/1fa` | persistent / encrypted | user | sessionStorage | Yes | No | +| `google-auth/google` | memory / secure | session | sessionStorage | Yes | No | + +All tokens use the browser's `sessionStorage` with the prefix `morph-poc:tk:`. The SDK's `StorageProvider` interface abstracts the actual storage mechanism, so production apps can use Keychain, Keystore, or encrypted storage. + +--- + +## Mock API Endpoints + +The Mock API at `http://localhost:3000` validates Keycloak JWTs and provides test endpoints: + +### Public (no auth required) + +| Method | Path | Description | +|---|---|---| +| GET | `/health` | Health check | +| GET | `/public/config` | App configuration | +| GET | `/sim/not-found` | Returns 404 (simulation probe) | + +### Protected (Keycloak JWT required) + +| Method | Path | Auth Level | Description | +|---|---|---|---| +| GET | `/accounts` | device, 1fa, or 2fa | List bank accounts | +| POST | `/transfers` | device, 1fa, or 2fa | Create a transfer | +| GET | `/profile` | device, 1fa, or 2fa | User profile | + +### Google (Google token required) + +| Method | Path | Description | +|---|---|---| +| GET | `/identity/verify` | Verify Google identity | + +The Mock API determines the auth level from the JWT's `azp` (authorized party) claim: +- `azp: morph-device` -> auth level: `device` +- `azp: morph-login` -> auth level: `2fa` +- `azp: morph-session` -> auth level: `1fa` + +--- + +## Environment Variables + +The Vue PoC app uses `VITE_*` environment variables (loaded from `poc/ts-vue/.env`): + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `VITE_DEVICE_CLIENT_SECRET` | Yes | (empty) | Secret for `morph-device` Keycloak client | +| `VITE_LOGIN_CLIENT_SECRET` | Yes | (empty) | Secret for `morph-login` Keycloak client | +| `VITE_SESSION_CLIENT_SECRET` | Yes | (empty) | Secret for `morph-session` Keycloak client | +| `VITE_KEYCLOAK_ORIGIN` | No | `http://localhost:8080` | Keycloak base URL | +| `VITE_OAUTH_REDIRECT_URI` | No | `{origin}/oauth/callback` | OAuth callback URL (auto-detected in dev) | +| `VITE_DEVICE_ID` | No | (auto UUID) | Pin device ID for demos | +| `VITE_INSTALLATION_ID` | No | (auto UUID) | Pin installation ID for demos | +| `VITE_GOOGLE_CLIENT_ID` | No | (empty) | Google OAuth client ID | +| `VITE_GOOGLE_CLIENT_SECRET` | No | (empty) | Google OAuth client secret | +| `VITE_SIMULATION_MODE` | No | (unset) | When `true`, sets 5s proactive refresh on all contexts | +| `VITE_MOCK_API_BASE` | No | `http://localhost:3000` | Mock API base URL | + +Copy `poc/ts-vue/.env.example` to `poc/ts-vue/.env` to get started. The three client secrets are required for any token operations to work. + +--- + +## Project Structure + +``` +morph-api-client/ +├── packages/ +│ ├── core/ # @morph/core — types, config, HTTP pipeline, client facades +│ │ └── src/ +│ │ ├── client/ # MorphClient, HostClient, AuthHandle +│ │ ├── config/ # Config validation, variable interpolation +│ │ ├── http/ # HostPipeline (depends on AuthPlugin interface) +│ │ ├── util/ # JWT, expiry, URL, duration, httpTrace +│ │ ├── runtime.ts # MorphRuntime coordinator +│ │ ├── types.ts # AuthPlugin, StorageProvider, all shared types +│ │ └── errors.ts # Error classes +│ ├── oauth2/ # @morph/oauth2 — OAuth2 token lifecycle plugin +│ │ └── src/ +│ │ ├── tokens/ # TokenLifecycle (implements AuthPlugin), TokenVault +│ │ ├── oauth/ # tokenHttp (grant HTTP) +│ │ └── util/ # interpolate, expiry, exchangeSources +│ ├── browser-storage/ # @morph/browser-storage — browser storage adapters +│ │ └── src/ +│ │ ├── browserStorage.ts +│ │ └── index.ts # browserStoragePlugin() factory +│ └── logger/ # @morph/logger — structured logging plugin +│ └── src/ +│ └── index.ts # loggerPlugin() factory +├── poc/ +│ ├── ts-vue/ # Vue 3 PoC app +│ │ ├── src/views/ # HomeView, OAuthCallbackView +│ │ ├── src/components/ # PocSimulationPanel +│ │ ├── src/morph.ts # SDK initialization + config +│ │ └── .env.example # Environment template +│ ├── keycloak/ # Docker Keycloak + setup scripts +│ │ ├── docker-compose.yml # Keycloak container +│ │ ├── morph-realm.json # Realm export (auto-imported) +│ │ ├── setup.sh # Full setup (clients + lifetimes + tests) +│ │ └── test-flows.sh # OAuth flow smoke tests +│ └── mock-api/ # Express mock API +│ └── server.js # JWT validation + test endpoints +├── docs/ # Documentation +│ └── poc/ # PoC-specific config files +│ ├── poc-config.json # SDK config for Vue PoC +│ └── poc-simulation.json # Simulation step definitions +├── Makefile # Build + run targets +└── README.md # Quick start +``` + +--- + +## Next Steps + +- [PoC Guide](poc-guide.md) -- Step-by-step walkthrough of the Vue app +- [Getting Started](getting-started.md) -- SDK usage for your own app +- [Architecture](architecture.md) -- Internal design and module structure +- [Troubleshooting](troubleshooting.md) -- Common errors and fixes diff --git a/docs/platform-adapters.md b/docs/platform-adapters.md index bbeb7a4..68f308f 100644 --- a/docs/platform-adapters.md +++ b/docs/platform-adapters.md @@ -1,6 +1,6 @@ # Platform Adapters -The SDK defines two interfaces that the host application can implement to bridge platform-specific capabilities: **StorageProvider** (required) and **NetworkDelegate** (optional). Logging is handled via the `onLog` callback on `MorphOptions`. +The SDK defines two interfaces that the host application provides via plugins: **StorageProvider** (required, registered by a storage plugin like `browserStoragePlugin()`) and **NetworkDelegate** (optional, passed directly in `MorphOptions`). Logging is handled via the `onLog` callback on `MorphOptions`. See [Writing Plugins](writing-plugins.md) for how to create custom storage plugins. > **Dart/Flutter parity is planned.** This document currently covers TypeScript. The Dart SDK will expose the same interfaces with language-appropriate idioms. @@ -8,7 +8,7 @@ The SDK defines two interfaces that the host application can implement to bridge ## StorageProvider -The storage provider is responsible for persisting and retrieving token data. A single `StorageProvider` instance is passed at init time. The SDK passes the full `StorageConfig` from the config JSON to every call, so the delegate has all the context it needs to make storage decisions. +The storage provider is responsible for persisting and retrieving token data. A `StorageProvider` is registered by a storage plugin (via `ctx.provideStorage()`) during `MorphClient.init()`. The SDK passes the full `StorageConfig` from the config JSON to every call, so the delegate has all the context it needs to make storage decisions. ### Interface @@ -67,23 +67,41 @@ The storage provider stores and returns this string as-is. Deserialization is ha --- -## SDK-Provided Browser Storage Factories +## SDK-Provided Browser Storage Plugin -For web applications, the SDK ships two ready-made `StorageProvider` implementations: +For web applications, the `@morph/browser-storage` package provides a ready-made plugin: ```typescript -import { createBrowserSessionStorage, createBrowserLocalStorage } from 'morph-api-client'; +import { MorphClient } from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { browserStoragePlugin } from '@morph/browser-storage'; +import { loggerPlugin } from '@morph/logger'; + +const logger = loggerPlugin({ level: 'info' }); + +MorphClient.init(config, { + plugins: [ + logger, + oauth2Plugin({ + logger, + storage: browserStoragePlugin({ prefix: 'myapp:tk:', logger }), + }), + ], +}); +``` -// sessionStorage — tokens survive SPA reload but not new tabs -const storage = createBrowserSessionStorage('myapp:tk:'); +Short form (without explicit logger): -// localStorage — tokens persist across tabs and sessions -const storage = createBrowserLocalStorage('myapp:tk:'); +```typescript +oauth2Plugin({ + storage: browserStoragePlugin('myapp:tk:'), // sessionStorage (default) + // storage: browserStoragePlugin('myapp:tk:', 'local'), // localStorage +}) ``` -These factories create a `StorageProvider` that prefixes all keys with the given string and delegates to the browser's `sessionStorage` or `localStorage`. They ignore `StorageConfig.protection` (browser storage has no encryption layer — use a custom implementation for encrypted storage). +The plugin creates a `StorageProvider` that prefixes all keys with the given string and delegates to the browser's `sessionStorage` or `localStorage`. It ignores `StorageConfig.protection` (browser storage has no encryption layer -- use a custom plugin for encrypted storage). -For production apps with sensitive tokens, implement a custom `StorageProvider` that uses Web Crypto API for the `"encrypted"` protection level. +For production apps with sensitive tokens, write a custom storage plugin using the `MorphPlugin` interface. See [Writing Plugins](writing-plugins.md). --- @@ -132,25 +150,38 @@ const storage: StorageProvider = { ## In-Memory Storage (Dev/Test) -For development and testing, a simple in-memory storage is sufficient: +For development and testing, write a simple in-memory storage plugin: ```typescript -const tokenStore = new Map(); +import { MorphClient, type MorphPlugin } from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; + +function memoryStoragePlugin(): MorphPlugin { + const store = new Map(); + return { + name: 'memory-storage', + provides: ['storage'], + install(ctx) { + ctx.provideStorage({ + read: async (key) => store.get(key) ?? null, + write: async (key, value) => { store.set(key, value); }, + delete: async (key) => { store.delete(key); }, + deleteByPrefix: async (prefix) => { + for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k); + }, + }); + }, + }; +} const morph = MorphClient.init(config, { - storage: { - read: async (key) => tokenStore.get(key) ?? null, - write: async (key, value) => { tokenStore.set(key, value); }, - delete: async (key) => { tokenStore.delete(key); }, - deleteByPrefix: async (prefix) => { - for (const k of tokenStore.keys()) if (k.startsWith(prefix)) tokenStore.delete(k); - }, - }, - callbacks: { /* ... */ }, + plugins: [ + oauth2Plugin({ storage: memoryStoragePlugin() }), + ], }); ``` -In-memory storage is not suitable for production — all tokens are lost when the process terminates. +In-memory storage is not suitable for production -- all tokens are lost when the process terminates. --- @@ -227,24 +258,37 @@ const morph = MorphClient.init(config, { ## Logging -The SDK uses a single `onLog` callback on `MorphOptions` for all diagnostic output: +Use the `@morph/logger` plugin for structured logging. Create one logger instance and pass it to every plugin: ```typescript -const morph = MorphClient.init(config, { +import { loggerPlugin } from '@morph/logger'; + +const logger = loggerPlugin({ level: 'info', prefix: '[morph] ' }); + +MorphClient.init(config, { + plugins: [ + logger, + oauth2Plugin({ logger, storage: browserStoragePlugin({ prefix: 'myapp:', logger }) }), + ], +}); +``` + +All plugins route their log output through the shared logger. Custom log backends (remote telemetry, file logging) can be provided via the `onLog` option on `loggerPlugin()`: + +```typescript +const logger = loggerPlugin({ onLog: (level, message, error, context) => { - console.log(`[morph:${level}] ${message}`, context ?? ''); - if (error) console.error(error); + myTelemetry.send({ level, message, error, ...context }); }, - // ... }); ``` ### Log Levels -- **debug** — Token resolution steps, storage reads/writes, low-level details. -- **info** — Successful token refresh, client_credentials renewal, token exchange, authorization_code storage, 401-driven refresh. -- **warn** — Proactive refresh failure, exchange failure, logout endpoint failure. -- **error** — Unrecoverable failures. +- **debug** -- Token resolution steps, storage reads/writes, low-level details. +- **info** -- Successful token refresh, client_credentials renewal, token exchange, authorization_code storage, 401-driven refresh. +- **warn** -- Proactive refresh failure, exchange failure, logout endpoint failure, default `onAuthRequired`. +- **error** -- Unrecoverable failures. All log messages include `authId` in the context map for filtering. diff --git a/docs/poc-guide.md b/docs/poc-guide.md new file mode 100644 index 0000000..40b5cef --- /dev/null +++ b/docs/poc-guide.md @@ -0,0 +1,248 @@ +# PoC Guide + +Step-by-step walkthrough of the Vue PoC application. This guide assumes you have the full stack running. + +--- + +## Prerequisites + +```bash +make install # install all npm dependencies +make build # build all SDK packages (core, oauth2, browser-storage) +cp poc/ts-vue/.env.example poc/ts-vue/.env # create env with client secrets +make up # start Keycloak + setup + mock API + Vue dev server +``` + +The Vue PoC initializes the SDK with two plugins: `browserStoragePlugin('morph-poc:tk:')` for token persistence and `oauth2Plugin()` for OAuth2 token lifecycle. The runtime auto-sorts them by dependency -- order in the `plugins` array doesn't matter. See `poc/ts-vue/src/morph.ts` for the full init code. + +After `make up` completes, three services are running: + +| Service | URL | +|---------|-----| +| Vue PoC | http://127.0.0.1:5173 | +| Mock API | http://localhost:3000 | +| Keycloak Admin | http://localhost:8080/admin (admin/admin) | + +Test users in Keycloak: + +| Username | Password | +|----------|----------| +| testuser | TestPass123! | +| admin | AdminPass123! | + +--- + +## 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: + +### 1. Status Table + +Shows every auth context configured in the SDK. Each row displays: + +- **Auth ID** (e.g., `morph-auth/device`, `morph-auth/2fa`, `morph-auth/1fa`) +- **Status**: `--` (no token), `OK` (valid token), `expired` +- **Action buttons** depending on the context type + +Initially all rows show `--` because no tokens have been acquired yet. + +### 2. Action Buttons per Context + +Each context has different actions: + +| Context | Button | What It Does | +|---------|--------|--------------| +| `morph-auth/device` | **Acquire token** | Calls `client_credentials` grant to Keycloak | +| `morph-auth/2fa` | **Keycloak login** | Redirects the browser to Keycloak login page | +| `morph-auth/1fa` | **Exchange** | Exchanges a 2fa token for a 1fa session token | +| `google-auth/google` | **Google login** | Redirects to Google OAuth (needs `VITE_GOOGLE_*` env vars) | + +### 3. Mock API Playground + +Click "Mock API" to open a modal with buttons for each simulation step. You can run individual API calls and see the HTTP traces (request/response headers, body, timing). + +### 4. Simulation Panel + +A loop that repeatedly runs API calls to test token refresh, exchange, and recovery. More on this below. + +--- + +## Step 1: Acquire a Device Token + +1. Find the **Device** row in the Status table +2. Click **Acquire token** +3. The SDK calls Keycloak with `grant_type=client_credentials` using the `morph-device` client +4. The row updates to show **OK** with a JWT preview button + +This is a non-interactive flow -- no user login required. The device token is used for pre-login API calls. + +If you see "Invalid client credentials", your `.env` file is missing or the Vite dev server needs a restart after creating it. + +--- + +## Step 2: Sign In with Keycloak + +1. Find the **Login (2fa)** row in the Status table +2. Click **Keycloak login** +3. The browser redirects to `http://localhost:8080/realms/morph/protocol/openid-connect/auth` +4. Keycloak shows the login form +5. Enter `testuser` / `TestPass123!` and submit +6. Keycloak redirects back to `http://127.0.0.1:5173/oauth/callback?code=...&state=...` +7. The SDK exchanges the authorization code for tokens +8. You are redirected back to Home with the **Login (2fa)** row showing **OK** + +Behind the scenes: +- The SDK encodes the `authId` (`morph-auth/2fa`) in the OAuth `state` parameter +- The callback page decodes the state, determines which context to use, and calls `morph.completeOAuthCallback()` +- The token exchange goes through the Vite proxy (`/__keycloak`) to avoid CORS issues +- Both access token and refresh token are stored in `sessionStorage` + +--- + +## Step 3: Exchange for a Session Token + +After signing in with 2fa, you can get a 1fa session token: + +1. Find the **Session (1fa)** row in the Status table +2. The **Subject** dropdown should show `Login (2fa) morph-auth/2fa` as the source +3. Click **Exchange** +4. The SDK takes the 2fa access token and calls Keycloak's token endpoint with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` +5. The **Session (1fa)** row updates to show **OK** + +The 1fa token has `exchangeSource: ["morph-auth/2fa"]` in the config, so the SDK can also perform this exchange **automatically** during token resolution when an API call needs a 1fa token. + +--- + +## Step 4: JWT Preview + +Click the **JWT** button on any row with a token to see the decoded JWT payload: + +- **Access tab**: Shows claims like `sub`, `iss`, `azp`, `exp`, `aud` +- **Refresh tab**: Shows refresh token claims (if the token is a JWT; opaque tokens show a note) + +The **IdP Refresh** button in the modal manually triggers a token refresh against Keycloak. + +--- + +## Step 5: Run the Simulation + +The Simulation panel at the bottom of the page runs a loop of API calls: + +### Configuration + +- **Interval (ms)**: How often each tick runs (default: 5000ms) +- **Verbose console**: Enables detailed SDK logging in the browser console +- **404 probe**: Adds a 404 test request to each tick + +### What Each Tick Does + +Each tick runs these steps in order: + +| Step | Type | Auth | Description | +|------|------|------|-------------| +| GET /public/config | fetch | none | Raw fetch to mock API (no SDK) | +| GET /health | fetch | none | Raw fetch to mock API (no SDK) | +| GET /profile (1fa) | host | `morph-auth/1fa` | SDK resolves 1fa token, calls mock API | +| GET /accounts (2fa) | host | `morph-auth/2fa` | SDK resolves 2fa token, calls mock API | +| POST /transfers (2fa) | host | `morph-auth/2fa` | SDK resolves 2fa token, POST to mock API | +| GET /profile + headers | host | `morph-auth/1fa` | SDK resolves 1fa token, adds custom headers | +| GET /public/config (device) | host | `morph-auth/device` | SDK resolves device token | + +### Status Codes in the Log + +| Status | Meaning | +|--------|---------| +| `200` | Successful API call | +| `401` | Unauthorized (token invalid/expired, SDK will try recovery) | +| `404` | Not found (expected for the 404 probe) | +| `AUTH` | SDK could not resolve a token for this context | +| `SKIP` | Conditional block skipped (condition not met) | +| `STOP` | Simulation stopped (session dead check triggered) | +| `NET` | Network error (service unreachable) | +| `ERR` | Unexpected error | + +### Session Dead Check + +If **both** `morph-auth/1fa` and `morph-auth/2fa` fail with `AUTH` in the same tick, the simulation stops and shows: + +> Keycloak session is dead (refresh: invalid_grant / Token is not active). Simulation stopped -- sign in again from Home. + +This means your tokens have expired and cannot be refreshed. Go to Home and sign in with Keycloak again. + +### Starting the Simulation + +1. Sign in with Keycloak and acquire device tokens first (Steps 1-3 above) +2. Click **Start** +3. Watch the log table fill with results +4. Click **Stop** to end the loop + +--- + +## Token Refresh Behavior + +With the default PoC token lifetimes (set by `setup.sh`): + +| Context | Access Token | Refresh Idle | Proactive Refresh | +|---------|-------------|--------------|-------------------| +| device | 15s | (no refresh) | 5s before expiry | +| 2fa (login) | 30s | 60s | 10s before expiry | +| 1fa (session) | 20s | 60s | 8s before expiry | + +The SDK refreshes tokens **before** they expire (proactive refresh). If the simulation is running, you will see tokens being refreshed transparently in the verbose console output. + +To use longer-lived tokens during development: + +```bash +make keycloak-restore-tokens # 5min access, 10min refresh +``` + +To re-apply short tokens for testing refresh flows: + +```bash +make keycloak-short-tokens # 15s/30s/20s +``` + +--- + +## Vite Proxy for CORS + +In development, the browser cannot POST directly to Keycloak (CORS). The Vite dev server proxies token requests: + +``` +Browser POST /__keycloak/realms/morph/protocol/openid-connect/token + | + v +Vite proxy rewrites path, forwards to http://localhost:8080 + | + v +Keycloak processes the token request +``` + +This proxy is configured in `poc/ts-vue/vite.config.ts` and is only active in dev mode. The SDK config uses the `tokenHttpBaseUrl` field (resolved via `$pocKeycloakTokenHttpBase` variable) to route token HTTP through the proxy while keeping `baseUrl` as the real Keycloak issuer for JWT validation. + +--- + +## Next Steps + +- [Overview](overview.md) -- System architecture and auth flow diagrams +- [Troubleshooting](troubleshooting.md) -- Common errors and fixes +- [Configuration](configuration.md) -- Full SDK config reference +- [API Reference](api-reference.md) -- Complete SDK API documentation diff --git a/docs/poc/simulation.md b/docs/poc/simulation.md index d6a8390..0e17db1 100644 --- a/docs/poc/simulation.md +++ b/docs/poc/simulation.md @@ -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): diff --git a/docs/poc/test-scenarios.md b/docs/poc/test-scenarios.md index 780a651..25ef512 100644 --- a/docs/poc/test-scenarios.md +++ b/docs/poc/test-scenarios.md @@ -197,7 +197,7 @@ curl -H "Authorization: Bearer $TOKEN_1FA" http://localhost:3000/profile 2. Make request with `{ auth: 'morph-auth/device' }` 3. SDK follows `recoveryPolicy.onUnauthorized = 'delegate'` 4. `onAuthRequired` fires with `interaction: 'non-interactive'` -5. Host app calls `morph.auth('morph-auth/device').acquireWithClientCredentials()` +5. Host app calls `morph.auth('morph-auth/device').acquire()` ### 5c: Refresh Failure → Delegate (1FA context) diff --git a/docs/token-lifecycle.md b/docs/token-lifecycle.md index 10271ac..2707c12 100644 --- a/docs/token-lifecycle.md +++ b/docs/token-lifecycle.md @@ -58,7 +58,8 @@ All token endpoint calls (authorization_code, refresh_token, client_credentials, ### autoAcquireNonInteractive -When `MorphOptions.autoAcquireNonInteractive` is `true` and `onAuthRequired` fires for a context with `interaction: 'non-interactive'`, the SDK automatically calls `acquireWithClientCredentials` for that context. This avoids requiring the host app to handle device-token acquisition in every `onAuthRequired` implementation. +When `oauth2Plugin({ autoAcquireNonInteractive: true })` is set and `onAuthRequired` fires for a context with `interaction: 'non-interactive'`, the plugin automatically calls `acquireWithClientCredentials` for that context. This avoids requiring the host app to handle device-token acquisition in every `onAuthRequired` callback. +When `MorphOptions.autoAcquireNonInteractive` is `true` and `onAuthRequired` fires for a context with `interaction: 'non-interactive'`, the SDK automatically calls `acquire` for that context. This avoids requiring the host app to handle device-token acquisition in every `onAuthRequired` implementation. ### Proactive vs. Reactive Refresh @@ -94,7 +95,7 @@ Storage keys support runtime variables that are resolved when reading or writing ## Token Storage -Tokens are stored using the `StorageProvider` interface injected at init time. The SDK passes the full `StorageConfig` from the JSON config to every call, so the delegate has all the context it needs. +Tokens are stored using the `StorageProvider` interface registered by a storage plugin (e.g. `browserStoragePlugin()`) during initialization. The SDK passes the full `StorageConfig` from the JSON config to every call, so the delegate has all the context it needs. See [Writing Plugins](writing-plugins.md) for how storage plugins work. ### Storage Flow diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..aaa28cb --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,226 @@ +# Troubleshooting + +Common errors and their fixes when running the Morph PoC. + +--- + +## "Keycloak session is dead" in Simulation + +**Error:** +> Keycloak session is dead (refresh: invalid_grant / Token is not active). Simulation stopped -- sign in again from Home. + +**Cause:** Both `morph-auth/1fa` and `morph-auth/2fa` tokens failed authentication in the same simulation tick. This happens when: +- You haven't signed in yet (no tokens at all) +- Your tokens expired and the refresh token's idle timeout has passed + +**Fix:** +1. Go to the Home page +2. Click **Keycloak login** on the Login (2fa) row +3. Sign in with `testuser / TestPass123!` +4. (Optional) Click **Exchange** on the Session (1fa) row, or let the simulation auto-exchange +5. Click **Start** on the Simulation panel + +If tokens expire too quickly during development, use longer lifetimes: +```bash +make keycloak-restore-tokens +``` + +--- + +## "unauthorized_client / Invalid client credentials" + +**Error:** +```json +{ + "error": "unauthorized_client", + "error_description": "Invalid client or Invalid client credentials" +} +``` + +**Cause:** The `VITE_*_CLIENT_SECRET` environment variables are missing or empty. The SDK sends blank credentials to Keycloak. + +**Fix:** +1. Ensure `poc/ts-vue/.env` exists: + ```bash + cp poc/ts-vue/.env.example poc/ts-vue/.env + ``` +2. Verify it contains: + ``` + VITE_DEVICE_CLIENT_SECRET=morph-device-secret + VITE_LOGIN_CLIENT_SECRET=morph-login-secret + VITE_SESSION_CLIENT_SECRET=morph-session-secret + ``` +3. **Restart the Vite dev server** (Vite reads `.env` only at startup): + ```bash + make down + make dev + ``` + +--- + +## "Realm 'morph' not found" + +**Error:** +> Realm 'morph' not found (HTTP 404). This means --import-realm did not run or the volume has stale data. + +**Cause:** The Keycloak Docker container has stale volume data from a previous run, or the realm JSON was not imported. + +**Fix:** Destroy volumes and re-run: +```bash +make keycloak-down +docker compose -f poc/keycloak/docker-compose.yml down -v +make up +``` + +--- + +## "Port 5173 is already in use" + +**Error:** +``` +Error: Port 5173 is already in use +``` + +**Cause:** A previous Vite process is still running on port 5173. + +**Fix:** +```bash +lsof -ti :5173 | xargs kill -9 +make dev +``` + +`make up` does this automatically before starting. + +--- + +## "Port 3000 is already in use" / Mock API won't start + +**Cause:** A previous mock API process is still running. + +**Fix:** +```bash +lsof -ti :3000 | xargs kill -9 +make mock-api +``` + +--- + +## redirect_uri_mismatch from Keycloak + +**Error:** Keycloak shows "Invalid parameter: redirect_uri" after the login form. + +**Cause:** The OAuth redirect URI sent by the SDK doesn't match what's registered on the Keycloak client. + +**Fix:** +1. Re-run the Keycloak setup to update redirect URIs: + ```bash + make keycloak-setup + ``` +2. Ensure you're accessing the Vue app at `http://127.0.0.1:5173` or `http://localhost:5173` (both are registered). +3. If using a different host/port, add it to the `redirectUris` list in `poc/keycloak/setup.sh` and re-run. + +--- + +## CORS errors on token endpoint + +**Error:** Browser console shows CORS errors when the SDK tries to call the Keycloak token endpoint. + +**Cause:** In development, the browser blocks cross-origin POST requests to `localhost:8080`. The Vite proxy (`/__keycloak`) solves this, but only works when the Vite dev server is running. + +**Fix:** +- Make sure you are running the app with `make dev` or `make up` (not a static build) +- The SDK config sets `tokenHttpBaseUrl` to the Vite proxy path automatically in dev mode + +--- + +## Token exchange fails + +**Error:** Clicking **Exchange** on the 1fa row fails, or the simulation shows AUTH for 1fa steps. + +**Cause:** Token exchange requires a valid `morph-auth/2fa` access token. The 2fa token is sent as the `subject_token` in the exchange request. + +**Fix:** +1. Sign in with Keycloak first (click **Keycloak login** on the 2fa row) +2. Verify the 2fa row shows **OK** +3. Then click **Exchange** on the 1fa row + +If the 2fa token has expired, sign in again. + +--- + +## Keycloak container won't start + +**Cause:** Docker is not running, or port 8080 is occupied. + +**Fix:** +1. Start Docker Desktop (or the Docker daemon) +2. Check if port 8080 is in use: + ```bash + lsof -i :8080 + ``` +3. Start Keycloak: + ```bash + make keycloak-up + ``` +4. Check logs if it fails: + ```bash + make keycloak-logs + ``` + +--- + +## "Keycloak not ready after 60s" + +**Cause:** Keycloak is taking longer than expected to start (first run pulls a ~500MB Docker image). + +**Fix:** +1. Check if the image is still downloading: + ```bash + docker compose -f poc/keycloak/docker-compose.yml logs -f + ``` +2. Wait for the message "Keycloak ... started in ..." then re-run: + ```bash + make keycloak-setup + make mock-api & + make dev + ``` + +--- + +## Google login button is disabled + +**Cause:** The `VITE_GOOGLE_CLIENT_ID` and `VITE_GOOGLE_CLIENT_SECRET` variables are not set. Both are required for the Google provider to be enabled. + +**Fix:** See [docs/poc/google-setup.md](poc/google-setup.md) for Google Cloud Console setup instructions. Add the credentials to `poc/ts-vue/.env`: +``` +VITE_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +VITE_GOOGLE_CLIENT_SECRET=GOCSPX-your-secret +``` + +Then restart the Vite dev server. + +--- + +## All tests pass but the Vue app doesn't work + +If `make keycloak-test` passes (all 5 OAuth flow tests) but the Vue app shows errors: + +1. **Check `.env`**: The test script uses hardcoded secrets; the Vue app needs `VITE_*` variables +2. **Restart Vite**: Env changes require a server restart +3. **Clear browser storage**: Open DevTools > Application > Session Storage > clear `morph-poc:tk:*` entries +4. **Hard refresh**: `Cmd+Shift+R` / `Ctrl+Shift+R` to bypass cache + +--- + +## Useful commands + +| Command | Purpose | +|---------|---------| +| `make help` | List all Makefile targets | +| `make keycloak-test` | Run OAuth flow smoke tests | +| `make keycloak-logs` | Tail Keycloak container logs | +| `make keycloak-setup` | Re-run full Keycloak setup | +| `make keycloak-short-tokens` | Apply short token lifetimes (15s/30s/20s) | +| `make keycloak-restore-tokens` | Restore long-lived token lifetimes | +| `make down` | Stop all services | +| `make clean` | Remove all node_modules and dist | diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md new file mode 100644 index 0000000..769e1c3 --- /dev/null +++ b/docs/writing-plugins.md @@ -0,0 +1,401 @@ +# Writing Plugins + +Morph uses a plugin system to keep the core SDK small and extensible. Plugins register capabilities (auth, storage, or custom behavior) during initialization. The core never needs to change when a new plugin is added. + +--- + +## Plugin Interface + +Every plugin implements the `MorphPlugin` interface: + +```typescript +import type { MorphPlugin, MorphPluginContext } from '@morph/core'; + +interface MorphPlugin { + name: string; + provides?: string[]; + requires?: string[]; + install(ctx: MorphPluginContext): void; + dispose?(): void; +} +``` + +| Field | Description | +|-------|-------------| +| `name` | Unique identifier for the plugin (convention: npm package name) | +| `provides` | Capabilities this plugin registers (e.g. `['auth']`, `['storage']`). Used for dependency resolution. | +| `requires` | Capabilities this plugin needs (e.g. `['storage']`). The runtime ensures providers are installed first. | +| `install(ctx)` | Called once during `MorphClient.init()` with the resolved config and options | +| `dispose()` | Optional. Called when `MorphClient.dispose()` is invoked. Use for cleanup (timers, listeners, etc.) | + +--- + +## Plugin Context + +The `install` method receives a `MorphPluginContext` with access to config and registration methods: + +```typescript +interface MorphPluginContext { + resolved: ResolvedMorphConfig; + options: MorphOptions; + variables: Record; + provideAuth(auth: AuthPlugin): void; + provideStorage(storage: StorageProvider): void; +} +``` + +| Field | Description | +|-------|-------------| +| `resolved` | Validated and indexed config (providers, contexts, hosts, auth id maps) | +| `options` | The full `MorphOptions` passed to `MorphClient.init()` | +| `variables` | Resolved variable map for `$variable` interpolation | +| `provideAuth(auth)` | Register an `AuthPlugin` implementation. Exactly one plugin must call this. | +| `provideStorage(storage)` | Register a `StorageProvider` implementation. Exactly one plugin must call this. | + +--- + +## How Plugins Are Loaded + +```mermaid +sequenceDiagram + participant App + participant Client as MorphClient.init + participant Runtime as MorphRuntime + participant Sort as Topological Sort + participant P1 as Storage Plugin + participant P2 as Auth Plugin + + App->>Client: init(config, { plugins: [p2, p1], ... }) + Client->>Runtime: createRuntime(config, options) + Runtime->>Runtime: validateAndIndexConfig(config) + Runtime->>Sort: topoSort(plugins) + Note over Sort: p2 requires 'storage', p1 provides 'storage' + Sort-->>Runtime: sorted: [p1, p2] + Runtime->>P1: p1.install(ctx) + P1->>Runtime: ctx.provideStorage(storage) + Runtime->>P2: p2.install(ctx) + P2->>Runtime: ctx.provideAuth(auth) + Runtime->>Runtime: Verify auth + storage provided + Runtime->>Runtime: Create HostPipeline with auth + Note over App: MorphClient ready +``` + +**Order doesn't matter.** The runtime reads `provides` and `requires` from each plugin and topologically sorts them before installing. Providers are always installed before consumers: + +```typescript +// Both work identically -- the runtime sorts them: +plugins: [oauth2Plugin(), browserStoragePlugin('myapp:tk:')] +plugins: [browserStoragePlugin('myapp:tk:'), oauth2Plugin()] +``` + +### Dependency Resolution + +Each plugin declares what it **provides** and what it **requires**: + +```typescript +// browserStoragePlugin provides 'storage', requires nothing +{ name: '@morph/browser-storage', provides: ['storage'] } + +// oauth2Plugin provides 'auth', requires 'storage' +{ name: '@morph/oauth2', provides: ['auth'], requires: ['storage'] } +``` + +The runtime builds a dependency graph and installs plugins in the correct order. Missing or circular dependencies produce clear errors: + +``` +Error: Plugin '@morph/oauth2' requires 'storage' but no plugin provides it. + Add a plugin with provides: ['storage'] or pass the dependency via plugin options. + +Error: Circular plugin dependency detected among: @morph/a, @morph/b +``` + +### Explicit Dependency Injection + +If a plugin accepts its dependencies as constructor options, it can skip the `requires` declaration entirely: + +```typescript +// No storage plugin needed -- oauth2 gets storage directly +plugins: [ + oauth2Plugin({ storage: myCustomEncryptedStorage }), +] +``` + +When `storage` is passed via options, `oauth2Plugin` sets `requires: []` so the topo sort doesn't look for a storage provider. + +--- + +## Built-in Plugins + +### `@morph/oauth2` -- Auth Plugin + +Provides OAuth2 token lifecycle management (refresh, exchange, client credentials, authorization code). All options are **optional** -- the plugin provides default callbacks. + +```typescript +import { oauth2Plugin } from '@morph/oauth2'; +import { browserStoragePlugin } from '@morph/browser-storage'; + +// Minimal -- defaults handle onAuthRequired + onLogout +plugins: [ + oauth2Plugin({ + storage: browserStoragePlugin('myapp:tk:'), + variables: { deviceClientSecret: 'secret' }, + }), +] + +// Full options (all optional) +plugins: [ + oauth2Plugin({ + storage: browserStoragePlugin('myapp:tk:'), // or a raw StorageProvider, or omit for separate plugin + variables: { deviceClientSecret: 'secret' }, + autoAcquireNonInteractive: true, + callbacks: { // Partial -- only override what you need + onTokenChange: (authId, tokens) => { /* ... */ }, + }, + onTokenExchange: async (grant) => null, + onClientJwtAssertion: async (authId) => null, + }), +] +``` + +**Callback defaults:** `onAuthRequired` logs at `warn` level via the resolved `logger`, `onLogout` logs at `info` level, `onTokenChange` is a no-op. Override only the callbacks you need. + +**Storage options:** Pass `browserStoragePlugin(...)` (a `MorphPlugin`) or a raw `StorageProvider`, or omit and add a separate storage plugin to the array. + +Calls `ctx.provideAuth()` with a `TokenLifecycle` instance that implements the `AuthPlugin` interface. + +### `@morph/browser-storage` -- Storage Plugin + +Provides `sessionStorage` or `localStorage` based token persistence. + +```typescript +import { browserStoragePlugin } from '@morph/browser-storage'; + +plugins: [ + browserStoragePlugin('myapp:tk:'), // sessionStorage (default) + browserStoragePlugin('myapp:tk:', 'local'), // localStorage +] +``` + +Calls `ctx.provideStorage()` with a `StorageProvider` backed by browser storage. + +### `@morph/logger` -- Logger Plugin + +Structured logging with level filtering and HTTP trace output. A utility plugin -- it does not provide auth or storage. + +```typescript +import { loggerPlugin } from '@morph/logger'; + +plugins: [ + loggerPlugin(), // defaults: level 'info', prefix '[morph] ' + loggerPlugin({ level: 'debug' }), // verbose: includes debug messages + loggerPlugin({ level: 'warn', prefix: '' }), // quiet: only warnings and errors + loggerPlugin({ // custom: bring your own log function + onLog: (level, msg, err, ctx) => myLogger.log(level, msg, { err, ...ctx }), + onHttpTrace: (event) => myTracer.record(event), + }), +] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `level` | `'info'` | Minimum log level: `'debug'`, `'info'`, `'warn'`, `'error'` | +| `prefix` | `'[morph] '` | Prepended to each log line | +| `onLog` | console output | Custom log handler (replaces default console output) | +| `onHttpTrace` | console.log | Custom HTTP trace handler | +| `httpTrace` | `true` | Enable/disable HTTP trace logging | + +The plugin wraps `MorphOptions.onLog` and `MorphOptions.onHttpTrace`, chaining with any existing handlers. Multiple logger plugins can be stacked (e.g., one for console + one for remote telemetry). + +--- + +## Writing a Custom Storage Plugin + +The simplest plugin type. Implement the `StorageProvider` interface and register it via `ctx.provideStorage()`. + +```typescript +import type { MorphPlugin, StorageProvider, StorageConfig } from '@morph/core'; + +function secureStoragePlugin(encryptionKey: string): MorphPlugin { + const memoryCache = new Map(); + + async function encrypt(value: string): Promise { + // Use Web Crypto API, Node.js crypto, or platform keychain + return btoa(value); // placeholder + } + + async function decrypt(value: string): Promise { + return atob(value); // placeholder + } + + const provider: StorageProvider = { + async read(key, config) { + if (config.type === 'memory') return memoryCache.get(key) ?? null; + const raw = localStorage.getItem(key); + if (!raw) return null; + return config.protection === 'encrypted' ? decrypt(raw) : raw; + }, + + async write(key, value, config) { + if (config.type === 'memory') { memoryCache.set(key, value); return; } + const stored = config.protection === 'encrypted' ? await encrypt(value) : value; + localStorage.setItem(key, stored); + }, + + async delete(key, config) { + if (config.type === 'memory') { memoryCache.delete(key); return; } + localStorage.removeItem(key); + }, + + async deleteByPrefix(prefix, config) { + if (config.type === 'memory') { + for (const k of memoryCache.keys()) if (k.startsWith(prefix)) memoryCache.delete(k); + return; + } + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i); + if (k?.startsWith(prefix)) localStorage.removeItem(k); + } + }, + }; + + return { + name: 'secure-storage', + provides: ['storage'], + install(ctx) { + ctx.provideStorage(provider); + }, + }; +} +``` + +Usage: + +```typescript +MorphClient.init(config, { + plugins: [ + secureStoragePlugin('my-encryption-key'), + oauth2Plugin(), + ], + callbacks: { ... }, +}); +``` + +--- + +## Writing a Custom Auth Plugin + +For non-OAuth2 auth schemes (API keys, custom token servers, biometric flows). Implement the `AuthPlugin` interface and register via `ctx.provideAuth()`. + +```typescript +import type { MorphPlugin, AuthPlugin, CtxRef, TokenSet, LogoutReason, AuthContextConfig } from '@morph/core'; + +function apiKeyPlugin(apiKey: string): MorphPlugin { + const auth: AuthPlugin = { + async resolveAccessToken(_authId, _ref, _mode) { + return apiKey; + }, + async handle401Recovery() { + throw new Error('API key rejected'); + }, + fireAuthRequired(_authId, _ctx) { /* no-op for static keys */ }, + async submitCode() { throw new Error('Not supported'); }, + async acquireWithClientCredentials() { /* no-op */ }, + async exchangeToken() { throw new Error('Not supported'); }, + async setTokens() { /* no-op */ }, + async clearTokens() { /* no-op */ }, + async loadTokens() { return { accessToken: apiKey }; }, + async logout() { /* no-op */ }, + async logoutProvider() { /* no-op */ }, + async hasValidTokenContext() { return true; }, + async hasValidTokenProvider() { return true; }, + async refreshTokensManual() { /* no-op */ }, + dispose() { /* no-op */ }, + }; + + return { + name: 'api-key-auth', + provides: ['auth'], + install(ctx) { + ctx.provideAuth(auth); + }, + }; +} +``` + +For more complex auth plugins, look at `@morph/oauth2`'s `TokenLifecycle` as a reference implementation. + +--- + +## Writing a Utility Plugin + +Not all plugins need to provide auth or storage. A utility plugin hooks into the system for logging, analytics, or other cross-cutting concerns by wrapping `MorphOptions` callbacks. The built-in `@morph/logger` is an example. Here's a custom analytics plugin: + +```typescript +import type { MorphPlugin } from '@morph/core'; + +function analyticsPlugin(endpoint: string): MorphPlugin { + return { + name: 'analytics', + install(ctx) { + const original = ctx.options.onHttpTrace; + ctx.options.onHttpTrace = (event) => { + fetch(endpoint, { + method: 'POST', + body: JSON.stringify({ method: event.method, path: event.path, status: event.statusCode, ms: event.durationMs }), + }).catch(() => {}); + original?.(event); + }; + }, + }; +} +``` + +--- + +## Constraints + +- Exactly **one** plugin must call `ctx.provideAuth()`. Zero or more than one is an error. +- Exactly **one** plugin must call `ctx.provideStorage()`. Zero or more than one is an error. +- Plugins are **topologically sorted** by `provides` / `requires` before installation. Array order is a tiebreaker for plugins with no dependency relationship. +- `install()` is synchronous (not async). Use constructor options for async initialization. +- A plugin's `dispose()` is called when `MorphClient.dispose()` is invoked. Use for timer cleanup, event listener removal, etc. +- Plugin names should be unique for debugging purposes but are not enforced at runtime. +- Circular dependencies (A requires B, B requires A) are detected and throw an error at init time. +- If a dependency is satisfied via plugin options (e.g. `oauth2Plugin({ storage })`), set `requires: []` so the topo sort doesn't look for a provider. + +--- + +## Full Example: Custom Encrypted Storage + OAuth2 + Logger + +```typescript +import { MorphClient } from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { loggerPlugin } from '@morph/logger'; +import config from './morph-config.json'; + +const logger = loggerPlugin({ level: 'debug' }); + +const morph = MorphClient.init(config, { + plugins: [ + logger, + oauth2Plugin({ + logger, + storage: secureStoragePlugin('encryption-key-from-keychain'), + variables: { deviceClientSecret: '...' }, + callbacks: { + onLogout: (authId, reason) => { + if (reason === 'session_expired') showToast('Session expired.'); + }, + }, + }), + ], +}); +``` + +--- + +## Next Steps + +- [API Reference](api-reference.md) -- `MorphPlugin`, `MorphPluginContext`, `AuthPlugin`, `StorageProvider` type definitions +- [Platform Adapters](platform-adapters.md) -- `StorageProvider` interface details and `NetworkDelegate` +- [Token Lifecycle](token-lifecycle.md) -- How `AuthPlugin.resolveAccessToken` fits into the token resolution algorithm diff --git a/core/package-lock.json b/package-lock.json similarity index 84% rename from core/package-lock.json rename to package-lock.json index 3839cee..d4d3374 100644 --- a/core/package-lock.json +++ b/package-lock.json @@ -1,17 +1,13 @@ { - "name": "morph-api-client", - "version": "0.1.0", + "name": "morph-api-client-repo", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "morph-api-client", - "version": "0.1.0", - "devDependencies": { - "typescript": "^5.4.5", - "vite": "^5.2.11", - "vite-plugin-dts": "^3.9.1" - } + "name": "morph-api-client-repo", + "workspaces": [ + "packages/ts/*" + ] }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", @@ -34,9 +30,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -546,6 +542,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@morph/browser-storage": { + "resolved": "packages/ts/browser-storage", + "link": true + }, + "node_modules/@morph/core": { + "resolved": "packages/ts/core", + "link": true + }, + "node_modules/@morph/logger": { + "resolved": "packages/ts/logger", + "link": true + }, + "node_modules/@morph/oauth2": { + "resolved": "packages/ts/oauth2", + "link": true + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -570,9 +582,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "cpu": [ "arm" ], @@ -584,9 +596,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -598,9 +610,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -612,9 +624,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -626,9 +638,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "cpu": [ "arm64" ], @@ -640,9 +652,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -654,9 +666,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], @@ -668,9 +680,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ "arm" ], @@ -682,9 +694,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], @@ -696,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ "arm64" ], @@ -710,9 +722,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ "loong64" ], @@ -724,9 +736,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ "loong64" ], @@ -738,9 +750,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ "ppc64" ], @@ -752,9 +764,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ "ppc64" ], @@ -766,9 +778,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ "riscv64" ], @@ -780,9 +792,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ "riscv64" ], @@ -794,9 +806,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "cpu": [ "s390x" ], @@ -808,9 +820,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "cpu": [ "x64" ], @@ -822,9 +834,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "cpu": [ "x64" ], @@ -836,9 +848,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "cpu": [ "x64" ], @@ -850,9 +862,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -864,9 +876,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ "arm64" ], @@ -878,9 +890,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ "ia32" ], @@ -892,9 +904,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -906,9 +918,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "cpu": [ "x64" ], @@ -1031,28 +1043,28 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" } }, "node_modules/@vue/language-core": { @@ -1082,9 +1094,9 @@ } }, "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1108,9 +1120,9 @@ } }, "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", "dev": true, "license": "MIT" }, @@ -1149,9 +1161,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1222,6 +1234,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1340,9 +1362,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -1493,9 +1515,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1533,9 +1555,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1546,9 +1568,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -1585,12 +1607,13 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -1606,9 +1629,9 @@ } }, "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1622,31 +1645,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, @@ -1751,7 +1774,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1781,9 +1803,9 @@ } }, "node_modules/validator": { - "version": "13.15.26", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", - "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", "dev": true, "license": "MIT", "engines": { @@ -1796,7 +1818,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -1935,6 +1956,51 @@ "optionalDependencies": { "commander": "^9.4.1" } + }, + "packages/ts/browser-storage": { + "name": "@morph/browser-storage", + "version": "0.1.0", + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "packages/ts/core": { + "name": "@morph/core", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "packages/ts/logger": { + "name": "@morph/logger", + "version": "0.1.0", + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "packages/ts/oauth2": { + "name": "@morph/oauth2", + "version": "0.1.0", + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } } } } diff --git a/package.json b/package.json index 1f581ed..96add7e 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,18 @@ { "name": "morph-api-client-repo", "private": true, - "description": "Morph API Client — TS SDK (core) + PoC apps", + "description": "Morph API Client — TS SDK packages + PoC apps", + "workspaces": [ + "packages/ts/*" + ], "scripts": { "dev": "npm run dev --prefix poc/ts-vue -- --host 127.0.0.1", - "build:core": "npm run build --prefix core", + "build:core": "npm run build --prefix packages/ts/core", + "build:oauth2": "npm run build --prefix packages/ts/oauth2", + "build:storage": "npm run build --prefix packages/ts/browser-storage", + "build:logger": "npm run build --prefix packages/ts/logger", + "build:packages": "npm run build:core && npm run build:oauth2 && npm run build:storage && npm run build:logger", "build:vue": "npm run build --prefix poc/ts-vue", - "build": "npm run build:core && npm run build:vue" + "build": "npm run build:packages && npm run build:vue" } } diff --git a/packages/dart/morph_core/README.md b/packages/dart/morph_core/README.md new file mode 100644 index 0000000..b590859 --- /dev/null +++ b/packages/dart/morph_core/README.md @@ -0,0 +1,25 @@ +# morph_core (Dart) + +Dart port of **`@morph/core`** (Morph API Client). See +[`docs/dart-parity.md`](../../../docs/dart-parity.md). + +## Implemented (vs TypeScript) + +- **`validateAndIndexConfig`** — same validation rules and indexes as + `packages/ts/core/src/config/validate.ts` (throws **`ConfigValidationError`** with + aggregated messages). +- **`normalizeExchangeSources`**, **`listAuthIdsForProvider`** — parity with TS helpers. +- **`MorphClient`**, **`MorphRuntime`**, plugin install (**`provides` / `requires`**), + **`HostPipeline.hostFetch`**, and **`MorphPlugin`** surface — shipped; mirrors TS + client/runtime/pipeline behavior per **`docs/dart-parity.md`**. +- Public OAuth helpers (authorize URL, OAuth state/return utilities), JWT helpers, + and types aligned with **`packages/ts/core`** exports where applicable. + +Typed **`MorphConfig` / strong `MorphOptions`** DTOs (vs `Map`) remain +**backlog**; config is validated as decoded JSON maps for now ([#3](https://github.com/burgan-tech/morph-api-client/issues/3)). + +```bash +dart pub get +dart analyze --fatal-infos +dart test +``` diff --git a/packages/dart/morph_core/analysis_options.yaml b/packages/dart/morph_core/analysis_options.yaml new file mode 100644 index 0000000..d04adaf --- /dev/null +++ b/packages/dart/morph_core/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/packages/dart/morph_core/lib/morph_core.dart b/packages/dart/morph_core/lib/morph_core.dart new file mode 100644 index 0000000..f3021f7 --- /dev/null +++ b/packages/dart/morph_core/lib/morph_core.dart @@ -0,0 +1,33 @@ +/// Morph API Client — Dart parity. See `docs/dart-parity.md`. +library; + +export 'src/morph_client.dart'; +export 'src/client/host_client.dart'; +export 'src/client/auth_handle.dart'; + +export 'src/runtime/morph_runtime.dart'; +export 'src/runtime/plugin_install.dart'; + +export 'src/http/host_pipeline.dart'; + +export 'src/config/ctx_ref.dart'; +export 'src/config/exchange_sources.dart'; +export 'src/config/interpolate_config.dart'; +export 'src/config/list_auth_ids.dart'; +export 'src/config/resolved_morph_config.dart'; +export 'src/config/validate_config.dart'; + +export 'src/errors/morph_errors.dart'; + +export 'src/types/morph_types.dart'; +export 'src/types/morph_surface.dart'; + +export 'src/util/duration_ms.dart'; +export 'src/util/resolve_endpoint.dart'; +export 'src/util/jwt_utils.dart'; +export 'src/util/oauth_authorize.dart'; +export 'src/util/oauth_return.dart'; +export 'src/util/oauth_state.dart'; +export 'src/util/normalize_origin.dart'; +export 'src/util/http_trace_helpers.dart'; +export 'src/util/expiry.dart'; diff --git a/packages/dart/morph_core/lib/src/client/auth_handle.dart b/packages/dart/morph_core/lib/src/client/auth_handle.dart new file mode 100644 index 0000000..198f1d8 --- /dev/null +++ b/packages/dart/morph_core/lib/src/client/auth_handle.dart @@ -0,0 +1,124 @@ +import '../config/list_auth_ids.dart'; +import '../runtime/morph_runtime.dart'; +import '../types/morph_surface.dart'; +import '../util/jwt_utils.dart'; + +/// Parity: [`AuthHandle`](packages/ts/core/src/client/AuthHandle.ts). +final class AuthHandle { + AuthHandle(this._rt, this.authId); + + final MorphRuntime _rt; + final String authId; + + Future submitCode(String code, {String? codeVerifier, String? redirectUriOverride}) async { + final r = _rt.parseAuthRef(authId); + if (r is! ParsedAuthContext) { + throw StateError('submitCode requires a provider/context auth id'); + } + _rt.assertAlive(); + await _rt.tokens.submitCode(r.authId, r.ref, code, + codeVerifier: codeVerifier, redirectUriOverride: redirectUriOverride); + } + + Future acquireWithClientCredentials() async { + final r = _rt.parseAuthRef(authId); + if (r is! ParsedAuthContext) { + throw StateError('acquireWithClientCredentials requires a provider/context auth id'); + } + _rt.assertAlive(); + await _rt.tokens.acquireWithClientCredentials(r.authId, r.ref); + } + + Future exchangeToken(String targetAuthId) async { + final r = _rt.parseAuthRef(authId); + if (r is! ParsedAuthContext) { + throw StateError('exchangeToken requires a provider/context auth id as the source'); + } + _rt.assertAlive(); + await _rt.tokens.exchangeToken(r.authId, r.ref, targetAuthId); + } + + Future setTokens(TokenSet tokens) async { + final r = _rt.parseAuthRef(authId); + if (r is! ParsedAuthContext) { + throw StateError('setTokens requires a provider/context auth id'); + } + _rt.assertAlive(); + await _rt.tokens.setTokens(r.authId, r.ref, tokens); + } + + Future clearTokens() async { + final r = _rt.parseAuthRef(authId); + _rt.assertAlive(); + if (r is ParsedAuthContext) { + await _rt.tokens.clearTokens(r.authId, r.ref); + return; + } + final pk = r is ParsedAuthProvider ? r.providerKey : ''; + for (final id in listAuthIdsForProvider(pk, _rt.resolved)) { + final ref = _rt.resolved.contextByAuthId[id]; + if (ref != null) await _rt.tokens.clearTokens(id, ref); + } + } + + Future logout([String reason = 'user_initiated']) async { + final r = _rt.parseAuthRef(authId); + _rt.assertAlive(); + if (r is ParsedAuthContext) { + await _rt.tokens.logout(r.authId, r.ref, reason); + return; + } + if (r is ParsedAuthProvider) { + await _rt.tokens.logoutProvider(r.providerKey, reason); + } + } + + Future hasValidToken() async { + final r = _rt.parseAuthRef(authId); + _rt.assertAlive(); + if (r is ParsedAuthContext) { + return _rt.tokens.hasValidTokenContext(r.authId, r.ref); + } + if (r is ParsedAuthProvider) { + return _rt.tokens.hasValidTokenProvider(r.providerKey); + } + return false; + } + + Future refreshTokens() async { + final r = _rt.parseAuthRef(authId); + if (r is! ParsedAuthContext) { + throw StateError('refreshTokens requires a provider/context auth id'); + } + _rt.assertAlive(); + await _rt.tokens.refreshTokensManual(r.authId, r.ref); + } + + Future peekTokens() async { + final r = _rt.parseAuthRef(authId); + if (r is! ParsedAuthContext) { + throw StateError('peekTokens requires a provider/context auth id'); + } + _rt.assertAlive(); + return _rt.tokens.loadTokens(r.authId, r.ref); + } + + /// Decoded access token JWT claims, or null when no token / opaque token. No network, no refresh. + Future getClaims() async { + final r = _rt.parseAuthRef(authId); + if (r is! ParsedAuthContext) { + throw StateError('getClaims requires a provider/context auth id'); + } + _rt.assertAlive(); + if ((r.ref.context.tokenTypes['access']?.format ?? 'jwt') == 'opaque') { + return null; + } + final set = await _rt.tokens.loadTokens(r.authId, r.ref); + if (set?.accessToken == null || set!.accessToken.isEmpty) return null; + try { + return decodeJwtPayload(set.accessToken); + } catch (_) { + return null; + } + } +} diff --git a/packages/dart/morph_core/lib/src/client/host_client.dart b/packages/dart/morph_core/lib/src/client/host_client.dart new file mode 100644 index 0000000..e0ea1ca --- /dev/null +++ b/packages/dart/morph_core/lib/src/client/host_client.dart @@ -0,0 +1,122 @@ +import '../runtime/morph_runtime.dart'; +import '../types/morph_surface.dart'; +import '../types/morph_types.dart'; + +/// Parity: [`HostClient`](packages/ts/core/src/client/HostClient.ts). +final class HostClient { + HostClient(this._rt, this.host); + + final MorphRuntime _rt; + final HostConfig host; + + String get key => host.key; + + String? get defaultAuth => host.defaultAuth; + + Future> get(String path, [HostRequestOptions? opts]) => + _rt.http.hostFetch( + host, + path, + method: 'GET', + auth: opts?.auth, + headers: opts?.headers, + queryParams: opts?.queryParams, + timeout: opts?.timeout, + sign: opts?.sign ?? false, + encrypted: opts?.encrypted ?? false, + ); + + Future> post(String path, [Object? body, HostRequestOptions? opts]) => + _rt.http.hostFetch( + host, + path, + method: 'POST', + body: body, + auth: opts?.auth, + headers: opts?.headers, + queryParams: opts?.queryParams, + timeout: opts?.timeout, + sign: opts?.sign ?? false, + encrypted: opts?.encrypted ?? false, + ); + + Future> put(String path, [Object? body, HostRequestOptions? opts]) => + _rt.http.hostFetch( + host, + path, + method: 'PUT', + body: body, + auth: opts?.auth, + headers: opts?.headers, + queryParams: opts?.queryParams, + timeout: opts?.timeout, + sign: opts?.sign ?? false, + encrypted: opts?.encrypted ?? false, + ); + + Future> patch(String path, [Object? body, HostRequestOptions? opts]) => + _rt.http.hostFetch( + host, + path, + method: 'PATCH', + body: body, + auth: opts?.auth, + headers: opts?.headers, + queryParams: opts?.queryParams, + timeout: opts?.timeout, + sign: opts?.sign ?? false, + encrypted: opts?.encrypted ?? false, + ); + + Future> delete(String path, [HostRequestOptions? opts]) => + _rt.http.hostFetch( + host, + path, + method: 'DELETE', + auth: opts?.auth, + headers: opts?.headers, + queryParams: opts?.queryParams, + timeout: opts?.timeout, + sign: opts?.sign ?? false, + encrypted: opts?.encrypted ?? false, + ); + + Future> head(String path, [HostRequestOptions? opts]) => + _rt.http.hostFetch( + host, + path, + method: 'HEAD', + auth: opts?.auth, + headers: opts?.headers, + queryParams: opts?.queryParams, + timeout: opts?.timeout, + sign: opts?.sign ?? false, + encrypted: opts?.encrypted ?? false, + ); + + Future> options(String path, [HostRequestOptions? opts]) => + _rt.http.hostFetch( + host, + path, + method: 'OPTIONS', + auth: opts?.auth, + headers: opts?.headers, + queryParams: opts?.queryParams, + timeout: opts?.timeout, + sign: opts?.sign ?? false, + encrypted: opts?.encrypted ?? false, + ); + + Future> request(HostFullRequestOptions opts) => _rt.http.hostFetch( + host, + opts.path, + method: opts.method, + body: opts.body, + auth: opts.auth, + headers: opts.headers, + queryParams: opts.queryParams, + timeout: opts.timeout, + sign: opts.sign ?? false, + encrypted: opts.encrypted ?? false, + ); +} diff --git a/packages/dart/morph_core/lib/src/config/ctx_ref.dart b/packages/dart/morph_core/lib/src/config/ctx_ref.dart new file mode 100644 index 0000000..6987650 --- /dev/null +++ b/packages/dart/morph_core/lib/src/config/ctx_ref.dart @@ -0,0 +1,2 @@ +// Re-export [CtxRef] from typed models. +export 'package:morph_core/src/types/morph_types.dart' show CtxRef; diff --git a/packages/dart/morph_core/lib/src/config/exchange_sources.dart b/packages/dart/morph_core/lib/src/config/exchange_sources.dart new file mode 100644 index 0000000..090e273 --- /dev/null +++ b/packages/dart/morph_core/lib/src/config/exchange_sources.dart @@ -0,0 +1,34 @@ +import 'package:morph_core/src/types/morph_types.dart'; + +/// Parity with TS [normalizeExchangeSources](packages/ts/core/src/util/exchangeSources.ts). +List normalizeExchangeSourcesFromTokenBlock(TokenBlock token) { + final ex = token.exchangeSource; + if (ex == null) return []; + if (ex is List) { + return ex.map((s) => s.toString().trim()).where((s) => s.isNotEmpty).toList(); + } + if (ex is String) { + final t = ex.trim(); + if (t.isNotEmpty) return [t]; + } + return []; +} + +bool hasExchangeSourcesFromTokenBlock(TokenBlock token) => + normalizeExchangeSourcesFromTokenBlock(token).isNotEmpty; + +/// Legacy: token as JSON-like map `{ exchangeSource?: ... }`. +List normalizeExchangeSources(Map token) { + final ex = token['exchangeSource']; + if (ex == null) return []; + if (ex is List) { + return ex.map((e) => e.toString().trim()).where((s) => s.isNotEmpty).toList(); + } + if (ex is String) { + final t = ex.trim(); + if (t.isNotEmpty) return [t]; + } + return []; +} + +bool hasExchangeSources(Map token) => normalizeExchangeSources(token).isNotEmpty; diff --git a/packages/dart/morph_core/lib/src/config/interpolate_config.dart b/packages/dart/morph_core/lib/src/config/interpolate_config.dart new file mode 100644 index 0000000..86a8838 --- /dev/null +++ b/packages/dart/morph_core/lib/src/config/interpolate_config.dart @@ -0,0 +1,30 @@ +final _varPattern = RegExp(r'\$([a-zA-Z_][a-zA-Z0-9_]*)'); + +/// Replaces `$variable` tokens (parity: [packages/ts/core/src/config/interpolate.ts]). +String interpolateString( + String template, + Map variables, [ + Map? extras, +]) { + final map = {...variables, ...?extras}; + return template.replaceAllMapped(_varPattern, (m) { + final name = m.group(1)!; + if (!map.containsKey(name)) { + throw StateError('Missing variable: \$$name in "$template"'); + } + return map[name]!; + }); +} + +Map? interpolateRecord( + Map? record, + Map variables, [ + Map? extras, +]) { + if (record == null) return null; + final out = {}; + record.forEach((k, v) { + out[k] = interpolateString(v, variables, extras); + }); + return out; +} diff --git a/packages/dart/morph_core/lib/src/config/list_auth_ids.dart b/packages/dart/morph_core/lib/src/config/list_auth_ids.dart new file mode 100644 index 0000000..4606527 --- /dev/null +++ b/packages/dart/morph_core/lib/src/config/list_auth_ids.dart @@ -0,0 +1,10 @@ +import 'package:morph_core/src/config/resolved_morph_config.dart'; + +/// Parity with TS [listAuthIdsForProvider](packages/ts/core/src/config/validate.ts). +List listAuthIdsForProvider(String providerKey, ResolvedMorphConfig resolved) { + final ctxs = resolved.contextsByProvider[providerKey] ?? []; + final pIndex = resolved.config.providers.indexWhere((x) => x.key == providerKey); + if (pIndex < 0) return []; + final p = resolved.config.providers[pIndex]; + return ctxs.map((c) => '${p.key}/${c.key}').toList(); +} diff --git a/packages/dart/morph_core/lib/src/config/resolved_morph_config.dart b/packages/dart/morph_core/lib/src/config/resolved_morph_config.dart new file mode 100644 index 0000000..120da60 --- /dev/null +++ b/packages/dart/morph_core/lib/src/config/resolved_morph_config.dart @@ -0,0 +1,19 @@ +import 'package:morph_core/src/types/morph_types.dart'; + +/// Result of validating and indexing Morph config (TS `ResolvedMorphConfig`). +final class ResolvedMorphConfig { + const ResolvedMorphConfig({ + required this.config, + required this.contextByAuthId, + required this.contextsByProvider, + required this.hostByKey, + }); + + final MorphConfig config; + final Map contextByAuthId; + + /// Contexts keyed by provider `key`. + final Map> contextsByProvider; + + final Map hostByKey; +} diff --git a/packages/dart/morph_core/lib/src/config/validate_config.dart b/packages/dart/morph_core/lib/src/config/validate_config.dart new file mode 100644 index 0000000..4fa443a --- /dev/null +++ b/packages/dart/morph_core/lib/src/config/validate_config.dart @@ -0,0 +1,145 @@ +import 'package:morph_core/src/config/exchange_sources.dart'; +import 'package:morph_core/src/config/resolved_morph_config.dart'; +import 'package:morph_core/src/errors/morph_errors.dart'; +import 'package:morph_core/src/types/morph_types.dart'; + +/// Validates and indexes Morph config (parity with TS [validateAndIndexConfig]). +/// +/// Accepts a [MorphConfig] or a JSON [Map] (converted via [MorphConfig.fromJson]). +/// +/// Throws [ConfigValidationError] when validation fails with the same error strings as +/// [@morph/core](https://github.com/burgan-tech/morph-api-client/blob/f/plugin/packages/ts/core/src/config/validate.ts). +ResolvedMorphConfig validateAndIndexConfig(dynamic raw) { + final MorphConfig config; + if (raw is MorphConfig) { + config = raw; + } else if (raw is Map) { + config = MorphConfig.fromJson(Map.from(raw)); + } else if (raw is Map) { + config = MorphConfig.fromJson(Map.from(raw)); + } else { + throw ArgumentError('validateAndIndexConfig: expected MorphConfig or Map, got ${raw.runtimeType}'); + } + + final errors = []; + if (config.providers.isEmpty) errors.add('At least one provider is required'); + if (config.hosts.isEmpty) errors.add('At least one host is required'); + + final contextByAuthId = {}; + final contextsByProvider = >{}; + final providerKeys = {}; + + for (final p in config.providers) { + if (p.key.isEmpty) errors.add('Provider missing key'); + if (providerKeys.contains(p.key)) errors.add('Duplicate provider key: ${p.key}'); + providerKeys.add(p.key); + if (p.type != 'oauth2') errors.add('Provider ${p.key}: only oauth2 is supported'); + if (p.baseUrl.isEmpty) errors.add('Provider ${p.key}: baseUrl is required'); + final ctxKeys = {}; + for (final c in p.contexts) { + if (c.key.isEmpty) errors.add('Provider ${p.key}: context missing key'); + if (ctxKeys.contains(c.key)) { + errors.add('Provider ${p.key}: duplicate context key ${c.key}'); + } + ctxKeys.add(c.key); + if (c.token.endpoint.isEmpty) { + errors.add('Provider ${p.key}/${c.key}: token.endpoint is required'); + } + if (!c.tokenTypes.containsKey('access')) { + errors.add('Provider ${p.key}/${c.key}: tokenTypes.access is required'); + } + final authId = '${p.key}/${c.key}'; + if (contextByAuthId.containsKey(authId)) { + errors.add('Duplicate auth id $authId'); + } else { + contextByAuthId[authId] = CtxRef(provider: p, context: c); + } + } + contextsByProvider[p.key] = List.from(p.contexts); + } + + final hostByKey = {}; + final hostKeys = {}; + + for (final h in config.hosts) { + if (h.key.isEmpty) errors.add('Host missing key'); + if (hostKeys.contains(h.key)) errors.add('Duplicate host key: ${h.key}'); + hostKeys.add(h.key); + hostByKey[h.key] = h; + if (h.baseUrl.isEmpty) errors.add('Host ${h.key}: baseUrl is required'); + if (h.allowedAuth.isEmpty) { + errors.add('Host ${h.key}: allowedAuth must be a non-empty array'); + } else { + for (final aid in h.allowedAuth) { + if (!contextByAuthId.containsKey(aid)) { + errors.add('Host ${h.key}: allowedAuth references unknown $aid'); + } + } + } + final def = h.defaultAuth; + if (def != null) { + if (!contextByAuthId.containsKey(def)) { + errors.add('Host ${h.key}: defaultAuth $def is unknown'); + } + if (!h.allowedAuth.contains(def)) { + errors.add('Host ${h.key}: defaultAuth must be listed in allowedAuth'); + } + } + // Optional host headers: typed as Map; no further validation. + } + + 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'); + } + } + } + + // DFS cycle detection — circular exchangeSource chains (A→B→A) would deadlock + // at runtime via nested _withLock calls in TokenLifecycle.resolveAccessToken. + final dfsSeen = {}; + bool hasCycle(String node, Set inStack) { + if (inStack.contains(node)) return true; + if (dfsSeen.contains(node)) return false; + dfsSeen.add(node); + inStack.add(node); + final ctx = contextByAuthId[node]; + if (ctx != null) { + for (final src in normalizeExchangeSourcesFromTokenBlock(ctx.context.token)) { + if (hasCycle(src, {...inStack})) return true; + } + } + return false; + } + for (final authId in contextByAuthId.keys) { + if (hasCycle(authId, {})) { + errors.add('Circular token.exchangeSource dependency detected involving "$authId"'); + break; + } + } + + final rootCallback = config.rootCallbackAuthId?.trim(); + if (config.rootCallbackAuthId != null) { + if (rootCallback == null || rootCallback.isEmpty) { + errors.add('rootCallbackAuthId must be a non-empty string when set'); + } else if (!contextByAuthId.containsKey(rootCallback)) { + errors.add('rootCallbackAuthId: unknown auth id $rootCallback'); + } + } + + if (errors.isNotEmpty) throw ConfigValidationError(errors); + + return ResolvedMorphConfig( + config: config, + contextByAuthId: contextByAuthId, + contextsByProvider: contextsByProvider, + hostByKey: hostByKey, + ); +} + +/// Same as [validateAndIndexConfig] but parses from JSON [raw]. +ResolvedMorphConfig validateAndIndexConfigFromJson(Map raw) => + validateAndIndexConfig(MorphConfig.fromJson(Map.from(raw))); diff --git a/packages/dart/morph_core/lib/src/errors/morph_errors.dart b/packages/dart/morph_core/lib/src/errors/morph_errors.dart new file mode 100644 index 0000000..e63dfc1 --- /dev/null +++ b/packages/dart/morph_core/lib/src/errors/morph_errors.dart @@ -0,0 +1,99 @@ +// Core errors mirroring `@morph/core` packages/ts/core/src/errors.ts. + +final class ConfigValidationError implements Exception { + ConfigValidationError(this.errors) : assert(errors.isNotEmpty); + + final List errors; + + static const String errorName = 'ConfigValidationError'; + + @override + String toString() => errors.join('; '); +} + +final class UnknownHostError implements Exception { + UnknownHostError(this.key); + + final String key; + + static const String errorName = 'UnknownHostError'; + + @override + String toString() => 'Unknown host: $key'; +} + +final class UnknownProviderError implements Exception { + UnknownProviderError(this.key); + + final String key; + + static const String errorName = 'UnknownProviderError'; + + @override + String toString() => 'Unknown provider: $key'; +} + +final class UnknownContextError implements Exception { + UnknownContextError(this.authId); + + final String authId; + + static const String errorName = 'UnknownContextError'; + + @override + String toString() => 'Unknown auth: $authId'; +} + +final class InvalidAuthForHostError implements Exception { + InvalidAuthForHostError(this.hostKey, this.authId, this.allowedAuth); + + final String hostKey; + final String authId; + final List allowedAuth; + + static const String errorName = 'InvalidAuthForHostError'; + + @override + String toString() => 'Auth $authId is not allowed for host $hostKey'; +} + +final class AuthError implements Exception { + AuthError(this.authId, this.reason, [this.message]); + + final String authId; + + /// `no_token` | `refresh_failed` | `delegation_required` | `exchange_failed` + final String reason; + final String? message; + + static const String errorName = 'AuthError'; + + @override + String toString() => message ?? reason; +} + +final class TokenEndpointError implements Exception { + TokenEndpointError(this.statusCode, this.responseText); + + final int statusCode; + final String responseText; + + static const String errorName = 'TokenEndpointError'; + + @override + String toString() => 'Token endpoint failed: $statusCode $responseText'; +} + +final class MorphHttpError implements Exception { + MorphHttpError(this.statusCode, this.path, this.body, [this.resolvedAuth]); + + final int statusCode; + final String path; + final Object? body; + final String? resolvedAuth; + + static const String errorName = 'MorphHttpError'; + + @override + String toString() => 'HTTP $statusCode for $path'; +} diff --git a/packages/dart/morph_core/lib/src/http/host_pipeline.dart b/packages/dart/morph_core/lib/src/http/host_pipeline.dart new file mode 100644 index 0000000..27ee5d1 --- /dev/null +++ b/packages/dart/morph_core/lib/src/http/host_pipeline.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:morph_core/morph_core.dart'; + +/// HTTP host pipeline parity with `packages/ts/core/src/http/hostPipeline.ts`. +final class HostPipeline { + HostPipeline({ + required ResolvedMorphConfig resolved, + required MorphOptions options, + required Map variables, + required AuthPlugin tokens, + http.Client? httpClient, + }) : _resolved = resolved, + _options = options, + _variables = variables, + _tokens = tokens, + _client = httpClient ?? http.Client(); + + final ResolvedMorphConfig _resolved; + final MorphOptions _options; + final Map _variables; + final AuthPlugin _tokens; + final http.Client _client; + + Future> hostFetch( + HostConfig host, + String path, { + required String method, + Object? body, + Object? auth, + Map? headers, + Map? queryParams, + String? timeout, + bool sign = false, + bool encrypted = false, + }) async { + final authIds = normalizeAuth(host, auth); + Object? lastErr; + for (final authId in authIds) { + ensureAuthAllowed(host, authId); + final ref = _resolved.contextByAuthId[authId]; + if (ref == null) throw UnknownContextError(authId); + try { + final token = await _tokens.resolveAccessToken(authId, ref, 'http'); + return await _performFetch( + host, + path, + method: method, + body: body, + headers: headers, + queryParams: queryParams, + timeout: timeout, + sign: sign, + encrypted: encrypted, + token: token, + authId: authId, + ); + } catch (e) { + lastErr = e; + } + } + final errObj = lastErr; + if (errObj != null) { + throw errObj; + } + throw StateError('hostFetch failed'); + } + + List normalizeAuth(HostConfig host, Object? auth) { + if (auth == null) { + final d = host.defaultAuth; + if (d == null || d.isEmpty) { + throw StateError('Host ${host.key} has no defaultAuth; pass auth in request options.'); + } + return [d]; + } + if (auth is String) return [auth]; + if (auth is Iterable) return auth.map((e) => e.toString()).toList(); + throw ArgumentError('auth'); + } + + void ensureAuthAllowed(HostConfig host, String authId) { + if (!host.allowedAuth.contains(authId)) { + throw InvalidAuthForHostError(host.key, authId, host.allowedAuth); + } + } + + Future> _performFetch( + HostConfig host, + String path, { + required String method, + Object? body, + Map? headers, + Map? queryParams, + String? timeout, + required bool sign, + required bool encrypted, + required String token, + required String authId, + }) async { + final ref = _resolved.contextByAuthId[authId]!; + final accessType = ref.context.tokenTypes['access']; + final headerCfg = accessType?.header ?? const TokenHeaderConfig(name: 'Authorization', scheme: 'Bearer'); + + var urlStr = resolveEndpoint(host.baseUrl, path.startsWith('/') ? path : '/$path'); + + Uri uri = Uri.parse(urlStr); + if (queryParams != null && queryParams.isNotEmpty) { + uri = uri.replace(queryParameters: {...uri.queryParameters, ...queryParams}); + } + + final timeoutMs = parseDurationMs(timeout, 30000); + + String? serialized; + List? bodyBytes; + if (body == null) { + serialized = null; + } else if (body is List) { + // Raw binary — keep as bytes, do NOT call toString() which produces "[1, 2, 3]". + bodyBytes = body; + } else if (body is String) { + serialized = body; + } 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}; + } + + // Merge host-level config headers (interpolated from variables), then + // request-time headers verbatim — never interpolate them because they may + // contain $ characters (e.g. cryptographic signatures, JWS tokens). + final mergedHost = interpolateRecord(host.headers, _variables); + final headerMap = { + ...?mergedHost, + ...?headers, + headerCfg.name: '${headerCfg.scheme} $token', + }; + + Future once(Map hdrs) async { + final req = http.Request(method, uri); + req.headers.addAll(hdrs); + if (bodyBytes != null) { + req.bodyBytes = bodyBytes; + } else if (serialized != null) { + req.bodyBytes = utf8.encode(serialized); + req.headers['content-type'] = req.headers['content-type'] ?? 'application/json;charset=utf-8'; + } + // GET/HEAD with no body: bodyBytes stays unset → no Content-Length: 0 injected. + return _client.send(req).timeout(Duration(milliseconds: timeoutMs)).then(http.Response.fromStream); + } + + final sw = DateTime.now(); + var resp = await once(headerMap); + + if (resp.statusCode == 401 && ref.context.recoveryPolicy?.onUnauthorized == 'refresh') { + await _tokens.handle401Recovery(authId, ref); + final hdr2 = Map.from(headerMap); + final newTok = await _tokens.resolveAccessToken(authId, ref, 'http'); + hdr2[headerCfg.name] = '${headerCfg.scheme} $newTok'; + resp = await once(hdr2); + } + + if (resp.statusCode == 401 && ref.context.recoveryPolicy?.onUnauthorized == 'delegate') { + _tokens.fireAuthRequired(authId, ref.context); + throw AuthError(authId, 'delegation_required'); + } + + var text = utf8.decode(resp.bodyBytes); + + if (encrypted) { + if (_options.onDecryptResponse == null) { + throw StateError('encrypted response requires onDecryptResponse'); + } + text = await _options.onDecryptResponse!(text, authId); + } + + Object? parsedBody = text.isEmpty ? null : text; + + final ct = resp.headers['content-type'] ?? ''; + if (parsedBody != null && + ct.contains('application/json') && + parsedBody is String && + parsedBody.isNotEmpty) { + try { + parsedBody = jsonDecode(parsedBody); + } catch (_) {} + } + + final headersOut = {}; + resp.headers.forEach((k, v) { + headersOut[k.toLowerCase()] = v; + }); + + if (resp.statusCode < 200 || resp.statusCode >= 300) { + throw MorphHttpError(resp.statusCode, path, parsedBody, authId); + } + + final durationMs = DateTime.now().difference(sw).inMilliseconds; + _options.onHttpTrace?.call(MorphHttpTraceEvent( + kind: 'host_http', + hostKey: host.key, + method: method, + url: uri.toString(), + path: path.startsWith('/') ? path : '/$path', + authId: authId, + requestHeaders: redactedRequestHeadersMap(headerMap), + statusCode: resp.statusCode, + responseHeaders: headersOut, + responseBody: parsedBody, + durationMs: durationMs, + )); + + return MorphResponse( + statusCode: resp.statusCode, + headers: headersOut, + body: parsedBody as T, + resolvedAuth: authId, + raw: resp, + ); + } + +} diff --git a/packages/dart/morph_core/lib/src/morph_client.dart b/packages/dart/morph_core/lib/src/morph_client.dart new file mode 100644 index 0000000..0ab4a7b --- /dev/null +++ b/packages/dart/morph_core/lib/src/morph_client.dart @@ -0,0 +1,92 @@ +import 'config/validate_config.dart'; +import 'client/auth_handle.dart'; +import 'client/host_client.dart'; +import 'runtime/morph_runtime.dart'; +import 'types/morph_surface.dart'; + +/// Public facade mirroring [`MorphClient`] from `@morph/core`. +final class MorphClient { + MorphClient._(this.runtime); + + /// Resolved config + plugins + HTTP pipeline (parity [`MorphRuntime`]). + final MorphRuntime runtime; + + static MorphClient init(dynamic config, MorphOptions options) { + final resolved = validateAndIndexConfig(config); + final vars = Map.from(options.variables ?? const {}); + return MorphClient._(MorphRuntime(resolved, options, vars)); + } + + HostClient host(String key) { + runtime.assertAlive(); + return HostClient(runtime, runtime.getHost(key)); + } + + AuthHandle auth(String authId) { + runtime.assertAlive(); + runtime.parseAuthRef(authId); + return AuthHandle(runtime, authId); + } + + Future> getTokenStatus() { + runtime.assertAlive(); + return runtime.getTokenStatus(); + } + + MorphProviderMeta getProviderMeta(String providerKey) { + runtime.assertAlive(); + return runtime.getProviderMeta(providerKey); + } + + List getExchangeTargets(String sourceAuthId) { + runtime.assertAlive(); + return runtime.getExchangeTargets(sourceAuthId); + } + + List getExchangeSources(String targetAuthId) { + runtime.assertAlive(); + return runtime.getExchangeSources(targetAuthId); + } + + bool isAuthContextReady(String authId) { + runtime.assertAlive(); + return runtime.isAuthContextReady(authId); + } + + bool isProviderEnvReady(String providerKey) { + runtime.assertAlive(); + return runtime.isProviderEnvReady(providerKey); + } + + String getAuthorizationUrl(String authId, {String? state}) { + runtime.assertAlive(); + return runtime.getAuthorizationUrl(authId, state: state); + } + + Future completeOAuthCallback({ + String? code, + String? state, + String? error, + String? errorDescription, + }) { + runtime.assertAlive(); + return runtime.completeOAuthCallback( + code: code, + state: state, + error: error, + errorDescription: errorDescription, + ); + } + + Future completeOAuthReturn({Uri? currentUri}) { + runtime.assertAlive(); + return runtime.completeOAuthReturn(currentUri: currentUri); + } + + Future completeAuthorizationReturnFromUrl({Uri? currentUri}) { + runtime.assertAlive(); + return runtime.completeAuthorizationReturnFromUrl(currentUri: currentUri); + } + + void dispose() => runtime.dispose(); +} diff --git a/packages/dart/morph_core/lib/src/runtime/morph_runtime.dart b/packages/dart/morph_core/lib/src/runtime/morph_runtime.dart new file mode 100644 index 0000000..46651bb --- /dev/null +++ b/packages/dart/morph_core/lib/src/runtime/morph_runtime.dart @@ -0,0 +1,433 @@ +import '../config/exchange_sources.dart'; +import '../config/interpolate_config.dart'; +import '../config/resolved_morph_config.dart'; +import '../errors/morph_errors.dart'; +import '../http/host_pipeline.dart'; +import '../types/morph_surface.dart'; +import '../types/morph_types.dart'; +import '../util/jwt_utils.dart'; +import '../util/oauth_authorize.dart'; +import '../util/oauth_state.dart'; +import 'oauth_return_browser_stub.dart' + if (dart.library.html) 'oauth_return_browser.dart'; +import 'plugin_install.dart'; + +/// Result of [MorphRuntime.parseAuthRef] (parity TS union on `parseAuthRef`). +sealed class ParsedAuthRef { + const ParsedAuthRef(); +} + +final class ParsedAuthContext extends ParsedAuthRef { + ParsedAuthContext({required this.authId, required this.ref}); + + final String authId; + final CtxRef ref; +} + +final class ParsedAuthProvider extends ParsedAuthRef { + ParsedAuthProvider({required this.providerKey}); + + final String providerKey; +} + +/// Parity: [`MorphRuntime`](/packages/ts/core/src/runtime.ts). +final class MorphRuntime { + MorphRuntime( + this.resolved, + this.options, + Map variables, + ) : _variables = Map.from(variables) { + final installed = installMorphPlugins( + options.plugins, + resolved, + options, + _variables, + ); + tokens = installed.auth; + storage = installed.storage; + options.resolvedAuth = tokens; + options.resolvedStorage = storage; + http = HostPipeline( + resolved: resolved, + options: options, + variables: _variables, + tokens: tokens, + ); + _plugins = List.from(options.plugins); + } + + final ResolvedMorphConfig resolved; + final MorphOptions options; + final Map _variables; + + late final AuthPlugin tokens; + late final StorageProvider storage; + late final HostPipeline http; + + late final List _plugins; + var _disposed = false; + + void log(String level, String message, [Object? err, Map? ctx]) { + options.onLog?.call(level, message, err, ctx); + } + + void dispose() { + _disposed = true; + for (final plugin in _plugins) { + plugin.dispose(); + } + tokens.dispose(); + } + + void assertAlive() { + if (_disposed) throw StateError('MorphClient has been disposed'); + } + + // ── Config queries ──────────────────────────────────────────────────────── + + HostConfig getHost(String key) { + final h = resolved.hostByKey[key]; + if (h == null) throw UnknownHostError(key); + return h; + } + + ParsedAuthRef parseAuthRef(String authId) { + final parts = authId.split('/'); + if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + final ref = resolved.contextByAuthId[authId]; + if (ref == null) throw UnknownContextError(authId); + return ParsedAuthContext(authId: authId, ref: ref); + } + if (parts.length == 1 && parts[0].isNotEmpty) { + final pk = parts[0]; + if (!resolved.contextsByProvider.containsKey(pk)) throw UnknownContextError(authId); + return ParsedAuthProvider(providerKey: pk); + } + throw UnknownContextError(authId); + } + + bool isAuthContextReady(String authId) { + try { + final r = parseAuthRef(authId); + if (r is! ParsedAuthContext) return false; + final c = r.ref.context; + if (c.delegateMetadata?.grantHint != 'authorization_code') return false; + if (c.clientId == null || c.clientId!.trim().isEmpty) return false; + if (c.clientSecret == null || c.clientSecret!.trim().isEmpty) return false; + try { + if (interpolateString(c.clientId!.trim(), _variables).trim().isEmpty) return false; + if (interpolateString(c.clientSecret!.trim(), _variables).trim().isEmpty) return false; + } catch (_) { + return false; + } + final authz = c.authorization; + if (authz == null || + authz.endpoint.trim().isEmpty || + authz.redirectUri == null || + authz.redirectUri!.trim().isEmpty) { + return false; + } + try { + interpolateString(authz.redirectUri!.trim(), _variables); + } catch (_) { + return false; + } + return true; + } catch (_) { + return false; + } + } + + bool isProviderEnvReady(String providerKey) { + try { + final ctxs = resolved.contextsByProvider[providerKey] ?? const []; + for (final c in ctxs) { + if (c.delegateMetadata?.grantHint == 'authorization_code') { + if (!isAuthContextReady('$providerKey/${c.key}')) return false; + } + } + return true; + } catch (_) { + return false; + } + } + + Future> getTokenStatus() async { + assertAlive(); + final ids = resolved.contextByAuthId.keys.toList()..sort(); + final out = []; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + for (final authId in ids) { + final ref = resolved.contextByAuthId[authId]!; + final set = await tokens.loadTokens(authId, ref); + final hasAccessToken = set?.accessToken.isNotEmpty ?? false; + final exp = set?.expiresAt; + int? jwtExp; + Map? claims; + String? decodeError; + int? refreshJwtExp; + Map? refreshClaims; + String? refreshDecodeError; + + final accessFormat = ref.context.tokenTypes['access']?.format ?? 'jwt'; + final refreshFormat = ref.context.tokenTypes['refresh']?.format ?? 'jwt'; + + if (set != null && set.accessToken.isNotEmpty && accessFormat == 'jwt') { + try { + final payload = decodeJwtPayload(set.accessToken); + final expVal = payload['exp']; + jwtExp = expVal is int + ? expVal + : expVal is num + ? expVal.toInt() + : null; + claims = Map.from(payload); + } catch (e) { + decodeError = e is Exception ? e.toString() : '$e'; + } + } + final refreshTok = set?.refreshToken; + if (refreshTok != null && + refreshTok.isNotEmpty && + refreshFormat == 'jwt') { + try { + final rp = decodeJwtPayload(refreshTok); + final rj = rp['exp']; + refreshJwtExp = rj is int + ? rj + : rj is num + ? rj.toInt() + : null; + refreshClaims = Map.from(rp); + } catch (e) { + refreshDecodeError = e is Exception ? e.toString() : '$e'; + } + } + + out.add( + MorphTokenStatus( + authId: authId, + providerKey: ref.provider.key, + contextKey: ref.context.key, + grantHint: ref.context.delegateMetadata?.grantHint, + hasAccessToken: hasAccessToken, + hasRefreshToken: set?.refreshToken?.isNotEmpty ?? false, + accessLikelyValid: hasAccessToken && (exp == null || exp > now), + expiresAt: exp, + jwtExp: jwtExp, + claims: claims, + decodeError: decodeError, + refreshClaims: refreshClaims, + refreshJwtExp: refreshJwtExp, + refreshDecodeError: refreshDecodeError, + ), + ); + } + return out; + } + + MorphProviderMeta getProviderMeta(String providerKey) { + assertAlive(); + ProviderConfig? p; + for (final x in resolved.config.providers) { + if (x.key == providerKey) { + p = x; + break; + } + } + if (p == null) throw UnknownProviderError(providerKey); + final contexts = []; + for (final c in p.contexts) { + contexts.add(MorphContextMeta( + key: c.key, + authId: '${p.key}/${c.key}', + clientId: c.clientId, + clientAuth: c.clientAuth, + audience: c.audience, + identity: c.identity, + authorization: c.authorization, + token: c.token, + logout: c.logout, + scopes: c.scopes, + pkce: c.pkce, + refreshPolicy: c.refreshPolicy, + recoveryPolicy: c.recoveryPolicy, + delegateMetadata: c.delegateMetadata, + sessionPolicy: c.sessionPolicy, + networkPolicy: c.networkPolicy, + headers: c.headers, + tokenTypes: c.tokenTypes, + )); + } + return MorphProviderMeta( + key: p.key, + type: p.type, + baseUrl: p.baseUrl, + authorizationBrowserBaseUrl: p.authorizationBrowserBaseUrl, + tokenHttpBaseUrl: p.tokenHttpBaseUrl, + networkPolicy: p.networkPolicy, + headers: p.headers, + contexts: contexts, + ); + } + + List getExchangeTargets(String sourceAuthId) { + assertAlive(); + final parsed = parseAuthRef(sourceAuthId); + if (parsed is! ParsedAuthContext) { + throw StateError('getExchangeTargets($sourceAuthId): expected provider/context'); + } + final targets = []; + for (final e in resolved.contextByAuthId.entries) { + final ctx = e.value.context; + if (normalizeExchangeSourcesFromTokenBlock(ctx.token).contains(sourceAuthId)) { + targets.add(e.key); + } + } + targets.sort(); + return targets; + } + + List getExchangeSources(String targetAuthId) { + assertAlive(); + final parsed = parseAuthRef(targetAuthId); + if (parsed is! ParsedAuthContext) { + throw StateError('getExchangeSources($targetAuthId): expected provider/context'); + } + return normalizeExchangeSourcesFromTokenBlock(parsed.ref.context.token); + } + + String getAuthorizationUrl(String authId, {String? state}) { + assertAlive(); + if (!isAuthContextReady(authId)) { + throw StateError('$authId: not ready for authorize'); + } + final r = parseAuthRef(authId); + if (r is! ParsedAuthContext) throw UnknownContextError(authId); + final p = r.ref.provider; + final c = r.ref.context; + final authz = c.authorization!; + final redirectUri = interpolateString(authz.redirectUri!.trim(), _variables); + final clientId = interpolateString(c.clientId!.trim(), _variables); + final st = state ?? encodeOAuthState(authId); + final browserBaseRaw = p.authorizationBrowserBaseUrl?.trim(); + final authorizeBase = browserBaseRaw != null && browserBaseRaw.isNotEmpty + ? interpolateString(browserBaseRaw, _variables) + : interpolateString(p.baseUrl.trim(), _variables); + return buildOAuth2AuthorizationUrl( + baseUrl: authorizeBase, + authorizationPath: interpolateString(authz.endpoint.trim(), _variables), + clientId: clientId, + redirectUri: redirectUri, + scopes: c.scopes, + responseType: authz.responseType, + extraParams: authz.extraParams, + state: st, + ); + } + + Future completeOAuthCallback({ + String? code, + String? state, + String? error, + String? errorDescription, + }) async { + assertAlive(); + if (error != null) { + final msg = errorDescription != null ? ' — $errorDescription' : ''; + return OAuthReturnResult(status: 'oauth_error', message: 'OAuth error: $error$msg'); + } + if (code == null || code.isEmpty) { + return const OAuthReturnResult(status: 'none'); + } + + final decoded = state != null ? decodeOAuthState(state) : null; + if (decoded != null) { + final ref = resolved.contextByAuthId[decoded.authId]; + if (ref == null) { + return OAuthReturnResult( + status: 'error', + message: 'Unknown auth id from state: ${decoded.authId}', + ); + } + try { + await tokens.submitCode(decoded.authId, ref, code); + return OAuthReturnResult(status: 'success', message: 'Signed in (${decoded.authId}).'); + } catch (e) { + return OAuthReturnResult(status: 'error', message: e is Exception ? e.toString() : '$e'); + } + } + + final rootId = resolved.config.rootCallbackAuthId?.trim(); + if (rootId == null || rootId.isEmpty) { + return const OAuthReturnResult( + status: 'error', + message: 'Missing or invalid OAuth state and no rootCallbackAuthId configured.', + ); + } + final ref = resolved.contextByAuthId[rootId]; + if (ref == null) { + return OAuthReturnResult(status: 'error', message: 'Unknown rootCallbackAuthId: $rootId'); + } + final redirectOverride = _resolvedRootOAuthRedirectUriOverride(); + try { + await tokens.submitCode( + rootId, + ref, + code, + redirectUriOverride: redirectOverride, + ); + return const OAuthReturnResult( + status: 'success', + message: 'Authorization code exchanged.', + ); + } catch (e) { + return OAuthReturnResult(status: 'error', message: e is Exception ? e.toString() : '$e'); + } + } + + String _resolvedRootOAuthRedirectUriOverride() { + final raw = options.oauthRedirectBase?.trim(); + if (raw != null && raw.isNotEmpty) { + final withScheme = raw.contains('://') ? raw : 'https://$raw'; + final u = Uri.tryParse(withScheme); + if (u != null && (u.scheme == 'http' || u.scheme == 'https')) { + return '${u.origin}/'; + } + } + return '${Uri.base.origin}/'; + } + + /// Browser-oriented when compiled with dart:html, or pass [currentUri] on any platform. + /// If the URI path is non-root (`/` or empty), returns `{ status: none }`. + Future completeOAuthReturn({Uri? currentUri}) async { + assertAlive(); + final loc = currentUri ?? oauthReturnReadLocationUri(); + if (loc == null) return const OAuthReturnResult(status: 'none'); + final path = loc.path; + if (path.isNotEmpty && path != '/') return const OAuthReturnResult(status: 'none'); + final qp = loc.queryParameters; + final result = await completeOAuthCallback( + code: qp['code'], + state: qp['state'], + error: qp['error'], + errorDescription: qp['error_description'], + ); + if (result.status != 'none') oauthReturnReplaceLocationHref(loc.toString()); + return result; + } + + Future completeAuthorizationReturnFromUrl({Uri? currentUri}) => + completeOAuthReturn(currentUri: currentUri); +} + +MorphRuntime createMorphRuntime( + ResolvedMorphConfig resolved, + MorphOptions options, [ + Map? variables, +]) => + MorphRuntime( + resolved, + options, + variables ?? options.variables ?? const {}, + ); diff --git a/packages/dart/morph_core/lib/src/runtime/oauth_return_browser.dart b/packages/dart/morph_core/lib/src/runtime/oauth_return_browser.dart new file mode 100644 index 0000000..540b758 --- /dev/null +++ b/packages/dart/morph_core/lib/src/runtime/oauth_return_browser.dart @@ -0,0 +1,13 @@ +// dart:html is deprecated in favor of package:web; conditional web OAuth helper kept minimal. +// ignore_for_file: deprecated_member_use + +import 'dart:html' as html; + +import '../util/oauth_return.dart'; + +Uri? oauthReturnReadLocationUri() => Uri.tryParse(html.window.location.href); + +void oauthReturnReplaceLocationHref(String href) { + final stripped = stripOAuthReturnSearchParams(href); + html.window.history.replaceState(null, '', stripped); +} diff --git a/packages/dart/morph_core/lib/src/runtime/oauth_return_browser_stub.dart b/packages/dart/morph_core/lib/src/runtime/oauth_return_browser_stub.dart new file mode 100644 index 0000000..be2d4f2 --- /dev/null +++ b/packages/dart/morph_core/lib/src/runtime/oauth_return_browser_stub.dart @@ -0,0 +1,4 @@ +/// VM / non-web: no browser location. +Uri? oauthReturnReadLocationUri() => null; + +void oauthReturnReplaceLocationHref(String href) {} diff --git a/packages/dart/morph_core/lib/src/runtime/plugin_install.dart b/packages/dart/morph_core/lib/src/runtime/plugin_install.dart new file mode 100644 index 0000000..d7faa33 --- /dev/null +++ b/packages/dart/morph_core/lib/src/runtime/plugin_install.dart @@ -0,0 +1,118 @@ +import '../config/resolved_morph_config.dart'; +import '../types/morph_surface.dart'; + +/// Topological install order for plugins whose [MorphPlugin.requires] reference +/// [MorphPlugin.provides] from other plugins (parity `packages/ts/core/src/runtime.ts`). +List topoSortPlugins(List plugins) { + if (plugins.isEmpty) return []; + + final capToProvider = {}; + for (final p in plugins) { + for (final cap in p.provides ?? const []) { + capToProvider[cap] = p; + } + } + + final adjOut = >{}; + final inDeg = {}; + for (final p in plugins) { + adjOut[p] = {}; + inDeg[p] = 0; + } + + for (final p in plugins) { + for (final req in p.requires ?? const []) { + final provider = capToProvider[req]; + if (provider == null) { + throw StateError( + "Plugin '${p.name}' requires '$req' but no plugin provides it. " + "Add a plugin with provides: ['$req'] or pass the dependency via plugin options.", + ); + } + if (identical(provider, p)) continue; + if (!adjOut[provider]!.contains(p)) { + adjOut[provider]!.add(p); + inDeg[p] = inDeg[p]! + 1; + } + } + } + + final queue = []; + for (final p in plugins) { + if (inDeg[p] == 0) queue.add(p); + } + + final sorted = []; + while (queue.isNotEmpty) { + final p = queue.removeAt(0); + sorted.add(p); + for (final dep in adjOut[p]!) { + final d = inDeg[dep]! - 1; + inDeg[dep] = d; + if (d == 0) queue.add(dep); + } + } + + if (sorted.length != plugins.length) { + final unsorted = plugins.where((p) => !sorted.contains(p)).map((p) => p.name).join(', '); + throw StateError('Circular plugin dependency detected among: $unsorted'); + } + + return sorted; +} + +/// Result of [installMorphPlugins] (parity `installPlugins`). +final class InstalledPlugins { + InstalledPlugins({required this.auth, required this.storage}); + + final AuthPlugin auth; + final StorageProvider storage; +} + +/// Runs [topoSortPlugins] then [MorphPlugin.install]; ensures exactly one auth + storage. +InstalledPlugins installMorphPlugins( + List plugins, + ResolvedMorphConfig resolved, + MorphOptions options, + Map variables, +) { + final sorted = topoSortPlugins(plugins); + + AuthPlugin? auth; + StorageProvider? storage; + + final ctx = MorphPluginContext( + resolved: resolved, + options: options, + variables: variables, + provideAuth: (AuthPlugin a) { + if (auth != null) { + throw StateError('Multiple plugins called provideAuth(). Only one auth plugin is allowed.'); + } + auth = a; + options.resolvedAuth = a; + }, + provideStorage: (StorageProvider s) { + if (storage != null) { + throw StateError('Multiple plugins called provideStorage(). Only one storage plugin is allowed.'); + } + storage = s; + options.resolvedStorage = s; + }, + ); + + for (final plugin in sorted) { + plugin.install(ctx); + } + + if (auth == null) { + throw StateError( + 'No plugin called provideAuth(). Add an auth plugin (e.g. oauth2Plugin()) to MorphOptions.plugins.'); + } + if (storage == null) { + throw StateError( + 'No plugin called provideStorage(). Add a storage plugin (e.g. browserStoragePlugin()) to MorphOptions.plugins.'); + } + + return InstalledPlugins(auth: auth!, storage: storage!); +} diff --git a/packages/dart/morph_core/lib/src/types/json_helpers.dart b/packages/dart/morph_core/lib/src/types/json_helpers.dart new file mode 100644 index 0000000..0f0d67e --- /dev/null +++ b/packages/dart/morph_core/lib/src/types/json_helpers.dart @@ -0,0 +1,43 @@ +/// Low-level coercion helpers for JSON maps (camelCase keys, TS-aligned). +Map? asJsonMap(dynamic v) { + if (v == null) return null; + if (v is Map) return v; + if (v is Map) return v.map((k, val) => MapEntry(k.toString(), val)); + return null; +} + +List? asJsonList(dynamic v) { + if (v is List) return v; + if (v is List) return List.from(v); + return null; +} + +String? asString(dynamic v) => v is String ? v : null; + +bool asBool(dynamic v, {bool fallback = false}) { + if (v is bool) return v; + return fallback; +} + +int? asInt(dynamic v) { + if (v is int) return v; + if (v is num) return v.round(); + return null; +} + +double? asDouble(dynamic v) { + if (v is double) return v; + if (v is int) return v.toDouble(); + if (v is num) return v.toDouble(); + return null; +} + +Map? stringMapFromJson(dynamic v) { + final m = asJsonMap(v); + if (m == null) return null; + final out = {}; + for (final e in m.entries) { + if (e.value is String) out[e.key] = e.value as String; + } + return out; +} diff --git a/packages/dart/morph_core/lib/src/types/morph_surface.dart b/packages/dart/morph_core/lib/src/types/morph_surface.dart new file mode 100644 index 0000000..857ecfb --- /dev/null +++ b/packages/dart/morph_core/lib/src/types/morph_surface.dart @@ -0,0 +1,359 @@ +import 'package:morph_core/src/config/resolved_morph_config.dart'; +import 'package:morph_core/src/types/morph_types.dart'; + +typedef OAuthReturnStatus = String; + +final class OAuthReturnResult { + const OAuthReturnResult({required this.status, this.message}); + + final OAuthReturnStatus status; + final String? message; + + Map toJson() => { + 'status': status, + if (message != null) 'message': message, + }; +} + +final class MorphContextMeta { + const MorphContextMeta({ + required this.key, + required this.authId, + this.clientId, + this.clientAuth, + this.audience, + this.identity, + this.authorization, + required this.token, + this.logout, + this.scopes, + this.pkce, + this.refreshPolicy, + this.recoveryPolicy, + this.delegateMetadata, + this.sessionPolicy, + this.networkPolicy, + this.headers, + required this.tokenTypes, + }); + + final String key; + final String authId; + final String? clientId; + final String? clientAuth; + final String? audience; + final IdentityBlock? identity; + final AuthorizationBlock? authorization; + final TokenBlock token; + final LogoutBlock? logout; + final List? scopes; + final PkceBlock? pkce; + final RefreshPolicyBlock? refreshPolicy; + final RecoveryPolicy? recoveryPolicy; + final DelegateMetadata? delegateMetadata; + final Map? sessionPolicy; + final NetworkPolicy? networkPolicy; + final Map? headers; + final Map tokenTypes; +} + +final class MorphProviderMeta { + const MorphProviderMeta({ + required this.key, + required this.type, + required this.baseUrl, + this.authorizationBrowserBaseUrl, + this.tokenHttpBaseUrl, + this.networkPolicy, + this.headers, + required this.contexts, + }); + + final String key; + final String type; + final String baseUrl; + final String? authorizationBrowserBaseUrl; + final String? tokenHttpBaseUrl; + final NetworkPolicy? networkPolicy; + final Map? headers; + final List contexts; +} + +final class TokenSet { + const TokenSet({ + required this.accessToken, + this.refreshToken, + this.expiresAt, + this.metadata, + }); + + factory TokenSet.fromJson(Map m) => TokenSet( + accessToken: m['accessToken'] as String? ?? '', + refreshToken: m['refreshToken'] as String?, + expiresAt: (m['expiresAt'] as num?)?.toInt(), + metadata: m['metadata'] is Map ? Map.from(m['metadata'] as Map) : null, + ); + + final String accessToken; + final String? refreshToken; + final int? expiresAt; + final Map? metadata; + + Map toJson() => { + 'accessToken': accessToken, + if (refreshToken != null) 'refreshToken': refreshToken, + if (expiresAt != null) 'expiresAt': expiresAt, + if (metadata != null) 'metadata': metadata, + }; +} + +final class MorphTokenStatus { + const MorphTokenStatus({ + required this.authId, + required this.providerKey, + required this.contextKey, + this.grantHint, + required this.hasAccessToken, + required this.hasRefreshToken, + required this.accessLikelyValid, + this.expiresAt, + this.jwtExp, + this.claims, + this.decodeError, + this.refreshClaims, + this.refreshJwtExp, + this.refreshDecodeError, + }); + + final String authId; + final String providerKey; + final String contextKey; + final String? grantHint; + final bool hasAccessToken; + final bool hasRefreshToken; + final bool accessLikelyValid; + final int? expiresAt; + final int? jwtExp; + final Map? claims; + final String? decodeError; + final Map? refreshClaims; + final int? refreshJwtExp; + final String? refreshDecodeError; +} + +typedef MorphLogFn = void Function( + String level, + String message, [ + Object? error, + Map? context, +]); + +final class ProxyConfig { + const ProxyConfig({required this.url}); + final String url; +} + +final class ClientCertificate { + const ClientCertificate({ + required this.cert, + required this.key, + this.passphrase, + }); + final String cert; + final String key; + final String? passphrase; +} + +final class NetworkConfig { + const NetworkConfig({this.certificatePins, this.proxy, this.clientCertificate}); + final List? certificatePins; + final ProxyConfig? proxy; + final ClientCertificate? clientCertificate; +} + +final class TokenExchangeGrant { + const TokenExchangeGrant({ + required this.type, + required this.authId, + this.code, + this.codeVerifier, + this.sourceAuthId, + this.sourceToken, + this.refreshToken, + }); + + final String type; + final String authId; + final String? code; + final String? codeVerifier; + final String? sourceAuthId; + final String? sourceToken; + final String? refreshToken; +} + +abstract class StorageProvider { + Future read(String key, StorageConfig storageConfig); + Future write(String key, String value, StorageConfig storageConfig); + Future delete(String key, StorageConfig storageConfig); + Future deleteByPrefix(String prefix, StorageConfig storageConfig); +} + +abstract class AuthPlugin { + Future resolveAccessToken(String authId, CtxRef ref, String mode); + Future handle401Recovery(String authId, CtxRef ref); + void fireAuthRequired(String authId, AuthContextConfig ctx); + Future submitCode(String authId, CtxRef ref, String code, + {String? codeVerifier, String? redirectUriOverride}); + Future acquireWithClientCredentials(String authId, CtxRef ref); + Future exchangeToken(String sourceAuthId, CtxRef sourceRef, String targetAuthId); + Future setTokens(String authId, CtxRef ref, TokenSet tokens); + Future clearTokens(String authId, CtxRef ref); + Future loadTokens(String authId, CtxRef ref); + Future logout(String authId, CtxRef ref, String reason); + Future logoutProvider(String providerKey, String reason); + Future hasValidTokenContext(String authId, CtxRef ref); + Future hasValidTokenProvider(String providerKey); + Future refreshTokensManual(String authId, CtxRef ref); + void dispose(); +} + +typedef MorphSignPayloadFn = Future Function(String payload, String authId); +typedef MorphDecryptFn = Future Function(String encryptedBody, String authId); + +abstract class MorphPlugin { + String get name; + List? get provides => null; + List? get requires => null; + + void install(MorphPluginContext ctx); + void dispose() {} +} + +final class MorphOptions { + MorphOptions({ + required this.plugins, + this.variables, + this.oauthRedirectBase, + this.networkDelegate, + this.onSignPayload, + this.onDecryptResponse, + this.onLog, + this.onHttpTrace, + }); + + final List plugins; + final Map? variables; + + /// When set (e.g. full `https://` callback origin), overrides [Uri.base] for root + /// OAuth code exchange redirect URI. Prefer on Flutter VM/desktop/native where + /// `Uri.base` is not the web app origin. + final String? oauthRedirectBase; + + final Future Function(String hostname)? networkDelegate; + final MorphSignPayloadFn? onSignPayload; + final MorphDecryptFn? onDecryptResponse; + MorphLogFn? onLog; + void Function(MorphHttpTraceEvent event)? onHttpTrace; + + AuthPlugin? resolvedAuth; + StorageProvider? resolvedStorage; +} + +final class MorphPluginContext { + MorphPluginContext({ + required this.resolved, + required this.options, + required this.variables, + required this.provideAuth, + required this.provideStorage, + }); + + final ResolvedMorphConfig resolved; + final MorphOptions options; + final Map variables; + + final void Function(AuthPlugin auth) provideAuth; + final void Function(StorageProvider storage) provideStorage; +} + +final class MorphHttpTraceEvent { + const MorphHttpTraceEvent({ + required this.kind, + required this.hostKey, + required this.method, + required this.url, + required this.path, + required this.authId, + required this.requestHeaders, + required this.statusCode, + required this.responseHeaders, + required this.responseBody, + required this.durationMs, + this.networkError, + }); + + final String kind; + final String hostKey; + final String method; + final String url; + final String path; + final String authId; + final Map requestHeaders; + final int statusCode; + final Map responseHeaders; + final Object? responseBody; + final int durationMs; + final String? networkError; +} + +final class HostRequestOptions { + const HostRequestOptions({ + this.auth, + this.headers, + this.queryParams, + this.timeout, + this.sign, + this.encrypted, + }); + + final Object? auth; + final Map? headers; + final Map? queryParams; + final String? timeout; + final bool? sign; + final bool? encrypted; +} + +final class HostFullRequestOptions extends HostRequestOptions { + const HostFullRequestOptions({ + required this.method, + required this.path, + this.body, + super.auth, + super.headers, + super.queryParams, + super.timeout, + super.sign, + super.encrypted, + }); + + final String method; + final String path; + final Object? body; +} + +final class MorphResponse { + const MorphResponse({ + required this.statusCode, + required this.headers, + required this.body, + required this.resolvedAuth, + this.raw, + }); + + final int statusCode; + final Map headers; + final T body; + final String resolvedAuth; + final Object? raw; +} diff --git a/packages/dart/morph_core/lib/src/types/morph_types.dart b/packages/dart/morph_core/lib/src/types/morph_types.dart new file mode 100644 index 0000000..d29fc16 --- /dev/null +++ b/packages/dart/morph_core/lib/src/types/morph_types.dart @@ -0,0 +1,507 @@ +// TypeScript `types.ts` parity — data classes and callback typedefs for Morph API Client. + +import 'package:morph_core/src/types/json_helpers.dart'; + +// --- Enums / literals --- + +typedef LogoutReason = String; // 'user_initiated' | 'unauthorized' | ... + +typedef InteractionMode = String; // 'interactive' | 'non-interactive' | 'redirect' + +final class DelegateMetadata { + const DelegateMetadata({ + required this.workflow, + required this.grantHint, + required this.interaction, + }); + + factory DelegateMetadata.fromJson(Map m) => DelegateMetadata( + workflow: asString(m['workflow']) ?? '', + grantHint: asString(m['grantHint']) ?? '', + interaction: asString(m['interaction']) ?? 'interactive', + ); + + final String workflow; + final String grantHint; + final String interaction; + + Map toJson() => { + 'workflow': workflow, + 'grantHint': grantHint, + 'interaction': interaction, + }; +} + +final class NetworkRetry { + const NetworkRetry({this.count, this.delay}); + + factory NetworkRetry.fromJson(Map m) => + NetworkRetry(count: asInt(m['count']), delay: asString(m['delay'])); + + final int? count; + final String? delay; + + Map toJson() => { + if (count != null) 'count': count, + if (delay != null) 'delay': delay, + }; +} + +final class NetworkPolicy { + const NetworkPolicy({this.timeout, this.retry}); + + factory NetworkPolicy.fromJson(Map m) => NetworkPolicy( + timeout: asString(m['timeout']), + retry: m['retry'] is Map ? NetworkRetry.fromJson(asJsonMap(m['retry'])!) : null, + ); + + final String? timeout; + final NetworkRetry? retry; + + Map toJson() => { + if (timeout != null) 'timeout': timeout, + if (retry != null) 'retry': retry!.toJson(), + }; +} + +final class TokenHeaderConfig { + const TokenHeaderConfig({required this.name, required this.scheme}); + + factory TokenHeaderConfig.fromJson(Map m) => TokenHeaderConfig( + name: asString(m['name']) ?? '', + scheme: asString(m['scheme']) ?? '', + ); + + final String name; + final String scheme; + + Map toJson() => {'name': name, 'scheme': scheme}; +} + +final class StorageConfig { + const StorageConfig({ + required this.scope, + required this.type, + required this.protection, + required this.key, + }); + + factory StorageConfig.fromJson(Map m) => StorageConfig( + scope: asString(m['scope']) ?? '', + type: asString(m['type']) ?? '', + protection: asString(m['protection']) ?? '', + key: asString(m['key']) ?? '', + ); + + final String scope; + final String type; + final String protection; + final String key; + + Map toJson() => { + 'scope': scope, + 'type': type, + 'protection': protection, + 'key': key, + }; +} + +final class TokenTypeConfig { + const TokenTypeConfig({ + this.format, + this.header, + required this.expiryPolicy, + this.maxTtl, + required this.storage, + }); + + factory TokenTypeConfig.fromJson(Map m) { + final stor = asJsonMap(m['storage']); + return TokenTypeConfig( + format: asString(m['format']), + header: m['header'] is Map ? TokenHeaderConfig.fromJson(asJsonMap(m['header'])!) : null, + expiryPolicy: asString(m['expiryPolicy']) ?? '', + maxTtl: asString(m['maxTtl']), + storage: stor != null ? StorageConfig.fromJson(stor) : StorageConfig(scope: '', type: '', protection: '', key: ''), + ); + } + + final String? format; + final TokenHeaderConfig? header; + final String expiryPolicy; + final String? maxTtl; + final StorageConfig storage; + + Map toJson() => { + if (format != null) 'format': format, + if (header != null) 'header': header!.toJson(), + 'expiryPolicy': expiryPolicy, + if (maxTtl != null) 'maxTtl': maxTtl, + 'storage': storage.toJson(), + }; +} + +final class RecoveryPolicy { + const RecoveryPolicy({this.onUnauthorized, this.onRefreshFail}); + + factory RecoveryPolicy.fromJson(Map m) => RecoveryPolicy( + onUnauthorized: asString(m['onUnauthorized']), + onRefreshFail: asString(m['onRefreshFail']), + ); + + final String? onUnauthorized; + final String? onRefreshFail; + + Map toJson() => { + if (onUnauthorized != null) 'onUnauthorized': onUnauthorized, + if (onRefreshFail != null) 'onRefreshFail': onRefreshFail, + }; +} + +final class AuthorizationBlock { + const AuthorizationBlock({ + required this.endpoint, + this.redirectUri, + this.responseType, + this.extraParams, + }); + + factory AuthorizationBlock.fromJson(Map m) => AuthorizationBlock( + endpoint: asString(m['endpoint']) ?? '', + redirectUri: asString(m['redirectUri']), + responseType: asString(m['responseType']), + extraParams: stringMapFromJson(m['extraParams']), + ); + + final String endpoint; + final String? redirectUri; + final String? responseType; + final Map? extraParams; + + Map toJson() => { + 'endpoint': endpoint, + if (redirectUri != null) 'redirectUri': redirectUri, + if (responseType != null) 'responseType': responseType, + if (extraParams != null) 'extraParams': extraParams, + }; +} + +final class IdentityBlock { + const IdentityBlock({this.subject, this.actor}); + + factory IdentityBlock.fromJson(Map m) => + IdentityBlock(subject: asString(m['subject']), actor: asString(m['actor'])); + + final String? subject; + final String? actor; + + Map toJson() => { + if (subject != null) 'subject': subject, + if (actor != null) 'actor': actor, + }; +} + +final class TokenBlock { + const TokenBlock({ + required this.endpoint, + this.exchangeEndpoint, + this.exchangeSource, + }); + + factory TokenBlock.fromJson(Map m) => TokenBlock( + endpoint: asString(m['endpoint']) ?? '', + exchangeEndpoint: asString(m['exchangeEndpoint']), + exchangeSource: m['exchangeSource'], + ); + + /// `exchangeSource` stays dynamic: `String` or `List` in TS. + final String endpoint; + final String? exchangeEndpoint; + final Object? exchangeSource; + + Map toJson() => { + 'endpoint': endpoint, + if (exchangeEndpoint != null) 'exchangeEndpoint': exchangeEndpoint, + if (exchangeSource != null) 'exchangeSource': exchangeSource, + }; +} + +final class LogoutBlock { + const LogoutBlock({required this.endpoint}); + + factory LogoutBlock.fromJson(Map m) => + LogoutBlock(endpoint: asString(m['endpoint']) ?? ''); + + final String endpoint; + + Map toJson() => {'endpoint': endpoint}; +} + +final class PkceBlock { + const PkceBlock({this.codeChallengeMethod}); + + factory PkceBlock.fromJson(Map m) => + PkceBlock(codeChallengeMethod: asString(m['codeChallengeMethod'])); + + final String? codeChallengeMethod; + + Map toJson() => { + if (codeChallengeMethod != null) 'codeChallengeMethod': codeChallengeMethod, + }; +} + +final class RefreshPolicyBlock { + const RefreshPolicyBlock({this.strategy, this.refreshBeforeExpiry}); + + factory RefreshPolicyBlock.fromJson(Map m) => RefreshPolicyBlock( + strategy: asString(m['strategy']), + refreshBeforeExpiry: asString(m['refreshBeforeExpiry']), + ); + + final String? strategy; + final String? refreshBeforeExpiry; + + Map toJson() => { + if (strategy != null) 'strategy': strategy, + if (refreshBeforeExpiry != null) 'refreshBeforeExpiry': refreshBeforeExpiry, + }; +} + +final class AuthContextConfig { + const AuthContextConfig({ + required this.key, + this.clientId, + this.clientSecret, + this.clientAuth, + this.audience, + this.identity, + this.authorization, + required this.token, + this.logout, + this.scopes, + this.pkce, + this.refreshPolicy, + this.recoveryPolicy, + this.delegateMetadata, + this.sessionPolicy, + this.networkPolicy, + this.headers, + required this.tokenTypes, + }); + + factory AuthContextConfig.fromJson(Map m) { + final tt = asJsonMap(m['tokenTypes']) ?? {}; + final types = {}; + for (final e in tt.entries) { + final sub = asJsonMap(e.value); + if (sub != null) types[e.key] = TokenTypeConfig.fromJson(sub); + } + return AuthContextConfig( + key: asString(m['key']) ?? '', + clientId: asString(m['clientId']), + clientSecret: asString(m['clientSecret']), + clientAuth: asString(m['clientAuth']), + audience: asString(m['audience']), + identity: m['identity'] is Map ? IdentityBlock.fromJson(asJsonMap(m['identity'])!) : null, + authorization: m['authorization'] is Map ? AuthorizationBlock.fromJson(asJsonMap(m['authorization'])!) : null, + token: TokenBlock.fromJson(asJsonMap(m['token']) ?? {}), + logout: m['logout'] is Map ? LogoutBlock.fromJson(asJsonMap(m['logout'])!) : null, + scopes: asJsonList(m['scopes'])?.map((e) => e.toString()).toList(), + pkce: m['pkce'] is Map ? PkceBlock.fromJson(asJsonMap(m['pkce'])!) : null, + refreshPolicy: m['refreshPolicy'] is Map ? RefreshPolicyBlock.fromJson(asJsonMap(m['refreshPolicy'])!) : null, + recoveryPolicy: m['recoveryPolicy'] is Map ? RecoveryPolicy.fromJson(asJsonMap(m['recoveryPolicy'])!) : null, + delegateMetadata: + m['delegateMetadata'] is Map ? DelegateMetadata.fromJson(asJsonMap(m['delegateMetadata'])!) : null, + sessionPolicy: stringMapFromJson(m['sessionPolicy']), + networkPolicy: m['networkPolicy'] is Map ? NetworkPolicy.fromJson(asJsonMap(m['networkPolicy'])!) : null, + headers: stringMapFromJson(m['headers']), + tokenTypes: types, + ); + } + + final String key; + final String? clientId; + final String? clientSecret; + final String? clientAuth; + final String? audience; + final IdentityBlock? identity; + final AuthorizationBlock? authorization; + final TokenBlock token; + final LogoutBlock? logout; + final List? scopes; + final PkceBlock? pkce; + final RefreshPolicyBlock? refreshPolicy; + final RecoveryPolicy? recoveryPolicy; + final DelegateMetadata? delegateMetadata; + final Map? sessionPolicy; + final NetworkPolicy? networkPolicy; + final Map? headers; + final Map tokenTypes; + + Map toJson() => { + 'key': key, + if (clientId != null) 'clientId': clientId, + if (clientSecret != null) 'clientSecret': clientSecret, + if (clientAuth != null) 'clientAuth': clientAuth, + if (audience != null) 'audience': audience, + if (identity != null) 'identity': identity!.toJson(), + if (authorization != null) 'authorization': authorization!.toJson(), + 'token': token.toJson(), + if (logout != null) 'logout': logout!.toJson(), + if (scopes != null) 'scopes': scopes, + if (pkce != null) 'pkce': pkce!.toJson(), + if (refreshPolicy != null) 'refreshPolicy': refreshPolicy!.toJson(), + if (recoveryPolicy != null) 'recoveryPolicy': recoveryPolicy!.toJson(), + if (delegateMetadata != null) 'delegateMetadata': delegateMetadata!.toJson(), + if (sessionPolicy != null) 'sessionPolicy': sessionPolicy, + if (networkPolicy != null) 'networkPolicy': networkPolicy!.toJson(), + if (headers != null) 'headers': headers, + 'tokenTypes': tokenTypes.map((k, v) => MapEntry(k, v.toJson())), + }; +} + +final class ProviderConfig { + const ProviderConfig({ + required this.key, + required this.type, + required this.baseUrl, + this.authorizationBrowserBaseUrl, + this.tokenHttpBaseUrl, + this.networkPolicy, + this.headers, + required this.contexts, + }); + + factory ProviderConfig.fromJson(Map m) { + final ctxs = []; + final raw = asJsonList(m['contexts']); + if (raw != null) { + for (final c in raw) { + final cm = asJsonMap(c); + if (cm != null) ctxs.add(AuthContextConfig.fromJson(cm)); + } + } + return ProviderConfig( + key: asString(m['key']) ?? '', + type: asString(m['type']) ?? 'oauth2', + baseUrl: asString(m['baseUrl']) ?? '', + authorizationBrowserBaseUrl: asString(m['authorizationBrowserBaseUrl']), + tokenHttpBaseUrl: asString(m['tokenHttpBaseUrl']), + networkPolicy: m['networkPolicy'] is Map ? NetworkPolicy.fromJson(asJsonMap(m['networkPolicy'])!) : null, + headers: stringMapFromJson(m['headers']), + contexts: ctxs, + ); + } + + final String key; + /// Always `oauth2` in current SDK. + final String type; + final String baseUrl; + final String? authorizationBrowserBaseUrl; + final String? tokenHttpBaseUrl; + final NetworkPolicy? networkPolicy; + final Map? headers; + final List contexts; + + Map toJson() => { + 'key': key, + 'type': type, + 'baseUrl': baseUrl, + if (authorizationBrowserBaseUrl != null) 'authorizationBrowserBaseUrl': authorizationBrowserBaseUrl, + if (tokenHttpBaseUrl != null) 'tokenHttpBaseUrl': tokenHttpBaseUrl, + if (networkPolicy != null) 'networkPolicy': networkPolicy!.toJson(), + if (headers != null) 'headers': headers, + 'contexts': contexts.map((c) => c.toJson()).toList(), + }; +} + +final class HostConfig { + const HostConfig({ + required this.key, + required this.baseUrl, + required this.allowedAuth, + this.defaultAuth, + this.headers, + }); + + factory HostConfig.fromJson(Map m) { + final aa = []; + final raw = asJsonList(m['allowedAuth']); + if (raw != null) { + for (final a in raw) { + if (a is String) aa.add(a); + } + } + return HostConfig( + key: asString(m['key']) ?? '', + baseUrl: asString(m['baseUrl']) ?? '', + allowedAuth: aa, + defaultAuth: asString(m['defaultAuth']), + headers: stringMapFromJson(m['headers']), + ); + } + + final String key; + final String baseUrl; + final List allowedAuth; + final String? defaultAuth; + final Map? headers; + + Map toJson() => { + 'key': key, + 'baseUrl': baseUrl, + 'allowedAuth': allowedAuth, + if (defaultAuth != null) 'defaultAuth': defaultAuth, + if (headers != null) 'headers': headers, + }; +} + +final class MorphConfig { + const MorphConfig({ + required this.providers, + required this.hosts, + this.rootCallbackAuthId, + }); + + factory MorphConfig.fromJson(Map m) { + final prov = []; + for (final p in asJsonList(m['providers']) ?? const []) { + final pm = asJsonMap(p); + if (pm != null) prov.add(ProviderConfig.fromJson(pm)); + } + final hs = []; + for (final h in asJsonList(m['hosts']) ?? const []) { + final hm = asJsonMap(h); + if (hm != null) hs.add(HostConfig.fromJson(hm)); + } + return MorphConfig( + providers: prov, + hosts: hs, + rootCallbackAuthId: asString(m['rootCallbackAuthId']), + ); + } + + final List providers; + final List hosts; + final String? rootCallbackAuthId; + + Map toJson() => { + 'providers': providers.map((p) => p.toJson()).toList(), + 'hosts': hosts.map((h) => h.toJson()).toList(), + if (rootCallbackAuthId != null) 'rootCallbackAuthId': rootCallbackAuthId, + }; +} + +/// Reference to a resolved provider + auth context (TS `CtxRef`). +final class CtxRef { + const CtxRef({required this.provider, required this.context}); + + final ProviderConfig provider; + final AuthContextConfig context; + + String get providerKey => provider.key; + + String get contextKey => context.key; + + String get authId => '${provider.key}/${context.key}'; +} diff --git a/packages/dart/morph_core/lib/src/util/duration_ms.dart b/packages/dart/morph_core/lib/src/util/duration_ms.dart new file mode 100644 index 0000000..df3fcbb --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/duration_ms.dart @@ -0,0 +1,24 @@ +const Map _unitMs = { + 'ms': 1, + 's': 1000, + 'm': 60000, + 'h': 3600000, + 'd': 86400000, +}; + +/// Parses strings like `"200ms"`, `"10s"`, `"30d"` into milliseconds. +/// Parity: [packages/ts/core/src/util/duration.ts](packages/ts/core/src/util/duration.ts). +int parseDurationMs(String? input, [int? fallbackMs]) { + if (input == null || input.trim().isEmpty) { + if (fallbackMs != null) return fallbackMs; + throw ArgumentError('Missing duration'); + } + final re = RegExp(r'^(\d+(?:\.\d+)?)(ms|s|m|h|d)$', caseSensitive: false); + final m = re.firstMatch(input.trim()); + if (m == null) throw ArgumentError('Invalid duration: $input'); + final n = double.parse(m.group(1)!); + final u = m.group(2)!.toLowerCase(); + final mult = _unitMs[u]; + if (mult == null) throw ArgumentError('Invalid duration unit: $input'); + return (n * mult).round(); +} diff --git a/packages/dart/morph_core/lib/src/util/expiry.dart b/packages/dart/morph_core/lib/src/util/expiry.dart new file mode 100644 index 0000000..7401620 --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/expiry.dart @@ -0,0 +1,32 @@ +import 'package:morph_core/src/util/duration_ms.dart'; +import 'package:morph_core/src/util/jwt_utils.dart'; + +/// Parity [packages/ts/oauth2/src/util/expiry.ts]. +int? computeExpiresAt(String accessToken, int? expiresIn, String? maxTtl) { + final fromJwt = getJwtExpirySeconds(accessToken); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + var exp = fromJwt; + if (exp == null && expiresIn != null) { + exp = now + expiresIn; + } + if (exp == null) return null; + if (maxTtl != null && maxTtl.isNotEmpty) { + int? iat; + try { + final payload = decodeJwtPayload(accessToken)['iat']; + if (payload is num) iat = payload.round(); + } catch (_) { + iat = null; + } + final issued = iat ?? now; + final cap = issued + (parseDurationMs(maxTtl) ~/ 1000); + exp = exp < cap ? exp : cap; + } + return exp; +} + +bool isExpired(int? expiresAt, int skewSeconds) { + if (expiresAt == null) return false; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return now >= expiresAt - skewSeconds; +} diff --git a/packages/dart/morph_core/lib/src/util/http_trace_helpers.dart b/packages/dart/morph_core/lib/src/util/http_trace_helpers.dart new file mode 100644 index 0000000..a037b6c --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/http_trace_helpers.dart @@ -0,0 +1,13 @@ +/// Redacts `Authorization`-style traces (parity TS [httpTrace.ts] helpers). +Map redactedRequestHeadersMap(Map hdr) { + final o = {}; + hdr.forEach((k, v) { + if (k.toLowerCase() == 'authorization') { + final parts = v.trim().split(RegExp(r'\s+')); + o[k] = parts.length >= 2 ? '${parts[0]} ' : ''; + } else { + o[k] = v; + } + }); + return o; +} diff --git a/packages/dart/morph_core/lib/src/util/jwt_utils.dart b/packages/dart/morph_core/lib/src/util/jwt_utils.dart new file mode 100644 index 0000000..127d2b2 --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/jwt_utils.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +typedef JwtPayload = Map; + +String _base64UrlDecode(String s) { + var b64 = s.replaceAll('-', '+').replaceAll('_', '/'); + final pad = (4 - (b64.length % 4)) % 4; + b64 += '=' * pad; + return utf8.decode(base64Decode(b64)); +} + +/// Parity: [packages/ts/core/src/util/jwt.ts](packages/ts/core/src/util/jwt.ts). +JwtPayload decodeJwtPayload(String token) { + final parts = token.split('.'); + if (parts.length < 2) throw FormatException('Invalid JWT format'); + final jsonStr = _base64UrlDecode(parts[1]); + final decoded = jsonDecode(jsonStr); + if (decoded is! Map) throw const FormatException('JWT payload must be JSON object'); + return Map.from(decoded.cast()); +} + +int? getJwtExpirySeconds(String token) { + try { + final exp = decodeJwtPayload(token)['exp']; + if (exp is int) return exp; + if (exp is num) return exp.round(); + return null; + } catch (_) { + return null; + } +} + +String? getJwtSubject(String token, String? claim) { + if (claim == null || claim.isEmpty) return null; + try { + final p = decodeJwtPayload(token)[claim]; + return p is String ? p : null; + } catch (_) { + return null; + } +} diff --git a/packages/dart/morph_core/lib/src/util/normalize_origin.dart b/packages/dart/morph_core/lib/src/util/normalize_origin.dart new file mode 100644 index 0000000..92fcf61 --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/normalize_origin.dart @@ -0,0 +1,16 @@ +/// Normalizes IPv6 loopback origins to localhost (parity TS [normalizeOrigin.ts]). +String normalizeLoopbackOrigin(String origin) { + try { + final u = Uri.parse(origin); + final h = u.host.toLowerCase(); + final ipv6 = + h == '::1' || h == '[::1]' || h == '[::ffff:127.0.0.1]' || h == '::ffff:127.0.0.1'; + if (ipv6) { + final port = u.hasPort ? u.port : (u.scheme == 'https' ? 443 : 80); + return '${u.scheme}://localhost:$port'; + } + } catch (_) { + /* ignore */ + } + return origin; +} diff --git a/packages/dart/morph_core/lib/src/util/oauth_authorize.dart b/packages/dart/morph_core/lib/src/util/oauth_authorize.dart new file mode 100644 index 0000000..a18cbf3 --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/oauth_authorize.dart @@ -0,0 +1,40 @@ +import 'package:morph_core/src/util/resolve_endpoint.dart'; + +/// Builds an OAuth 2.0 authorization redirect URL. +/// Parity: [packages/ts/core/src/util/oauthAuthorize.ts](packages/ts/core/src/util/oauthAuthorize.ts). +String buildOAuth2AuthorizationUrl({ + required String baseUrl, + required String authorizationPath, + required String clientId, + required String redirectUri, + List? scopes, + String? responseType, + Map? extraParams, + required String state, +}) { + final u = resolveEndpoint(baseUrl, authorizationPath); + final q = { + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'response_type': responseType ?? 'code', + 'state': state, + }; + if (scopes != null && scopes.isNotEmpty) { + q['scope'] = scopes.join(' '); + } + final buf = StringBuffer(u)..write(u.contains('?') ? '&' : '?'); + var first = true; + void add(String k, String v) { + if (!first) buf.write('&'); + first = false; + buf.write('${Uri.encodeQueryComponent(k)}=${Uri.encodeQueryComponent(v)}'); + } + + q.forEach(add); + if (extraParams != null) { + for (final e in extraParams.entries) { + if (e.value.isNotEmpty) add(e.key, e.value); + } + } + return buf.toString(); +} diff --git a/packages/dart/morph_core/lib/src/util/oauth_return.dart b/packages/dart/morph_core/lib/src/util/oauth_return.dart new file mode 100644 index 0000000..c1819a2 --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/oauth_return.dart @@ -0,0 +1,28 @@ +/// Strips common OAuth return query params without changing path/hash structure. +/// Browser `history` unchanged here (caller may use [Uri] + window). +String stripOAuthReturnSearchParams(String href) { + final u = Uri.parse(href); + const keys = [ + 'code', + 'state', + 'session_state', + 'iss', + 'scope', + 'error', + 'error_description', + ]; + final qp = Map.from(u.queryParameters); + for (final k in keys) { + qp.remove(k); + } + final rebuilt = Uri( + scheme: u.scheme, + userInfo: u.userInfo, + host: u.host, + port: u.hasPort ? u.port : null, + path: u.path, + queryParameters: qp.isEmpty ? null : qp, + fragment: u.fragment.isEmpty ? null : u.fragment, + ); + return rebuilt.toString(); +} diff --git a/packages/dart/morph_core/lib/src/util/oauth_state.dart b/packages/dart/morph_core/lib/src/util/oauth_state.dart new file mode 100644 index 0000000..f3747db --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/oauth_state.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; +import 'dart:math'; + +const _prefix = 'morph1.'; + +/// Encodes [authId] into OAuth state (URL-safe base64 payload). +/// Parity: [packages/ts/core/src/util/oauthState.ts](packages/ts/core/src/util/oauthState.ts). +String encodeOAuthState(String authId) { + final n = '${DateTime.now().microsecondsSinceEpoch}-${Random.secure().nextInt(4294967295)}'; + final payload = jsonEncode({'a': authId, 'n': n}); + var b64 = base64Encode(utf8.encode(payload)); + b64 = b64.replaceAll('+', '-').replaceAll('/', '_').replaceAll(RegExp(r'=+$'), ''); + return '$_prefix$b64'; +} + +/// Decodes [authId] from OAuth state, or returns null when format is unrecognized. +({String authId})? decodeOAuthState(String state) { + if (!state.startsWith(_prefix)) return null; + try { + var b = state.substring(_prefix.length).replaceAll('-', '+').replaceAll('_', '/'); + while (b.length % 4 != 0) { + b += '='; + } + final decoded = utf8.decode(base64Decode(b)); + final o = jsonDecode(decoded); + if (o is Map && o['a'] is String) { + final a = o['a'] as String; + if (a.contains('/')) return (authId: a); + } + } catch (_) { + /* ignore */ + } + return null; +} diff --git a/packages/dart/morph_core/lib/src/util/resolve_endpoint.dart b/packages/dart/morph_core/lib/src/util/resolve_endpoint.dart new file mode 100644 index 0000000..e7cad9a --- /dev/null +++ b/packages/dart/morph_core/lib/src/util/resolve_endpoint.dart @@ -0,0 +1,9 @@ +/// Combines base URL and path/absolute URL (parity: [packages/ts/core/src/util/url.ts]). +String resolveEndpoint(String baseUrl, String endpoint) { + if (RegExp(r'^https?:\/\/', caseSensitive: false).hasMatch(endpoint)) { + return endpoint; + } + final b = baseUrl.replaceAll(RegExp(r'/+$'), ''); + final e = endpoint.startsWith('/') ? endpoint : '/$endpoint'; + return '$b$e'; +} diff --git a/packages/dart/morph_core/pubspec.lock b/packages/dart/morph_core/pubspec.lock new file mode 100644 index 0000000..c0c154b --- /dev/null +++ b/packages/dart/morph_core/pubspec.lock @@ -0,0 +1,411 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + morph_oauth2: + dependency: "direct dev" + description: + path: "../morph_oauth2" + relative: true + source: path + version: "0.1.0" + morph_storage: + dependency: "direct dev" + description: + path: "../morph_storage" + relative: true + source: path + version: "0.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/dart/morph_core/pubspec.yaml b/packages/dart/morph_core/pubspec.yaml new file mode 100644 index 0000000..f4a6c83 --- /dev/null +++ b/packages/dart/morph_core/pubspec.yaml @@ -0,0 +1,20 @@ +name: morph_core +description: > + Dart port of @morph/core — MorphClient, config validation, MorphRuntime, HTTP host + pipeline, plugin composition, and shared types (parity with packages/ts/core). +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + http: ^1.2.2 + +dev_dependencies: + lints: ^5.1.1 + test: ^1.25.8 + morph_oauth2: + path: ../morph_oauth2 + morph_storage: + path: ../morph_storage diff --git a/packages/dart/morph_core/test/config_and_morph_client_test.dart b/packages/dart/morph_core/test/config_and_morph_client_test.dart new file mode 100644 index 0000000..9a980dc --- /dev/null +++ b/packages/dart/morph_core/test/config_and_morph_client_test.dart @@ -0,0 +1,40 @@ +import 'package:morph_core/morph_core.dart'; +import 'package:test/test.dart'; + +import 'minimal_morph_config.dart'; + +void main() { + group('validateAndIndexConfig', () { + test('accepts minimal valid Morph config', () { + expect(() => validateAndIndexConfig(minimalValidConfig()), returnsNormally); + final r = validateAndIndexConfig(minimalValidConfig()); + expect(r.contextByAuthId['p/c'], isNotNull); + expect(r.hostByKey['api']?.baseUrl, 'https://api.example'); + }); + + test('matches TS errors when providers missing', () { + expect( + () => validateAndIndexConfig({ + 'providers': [], + 'hosts': [], + }), + throwsA( + predicate( + (e) => + e.errors.contains('At least one provider is required') && + e.errors.contains('At least one host is required'), + ), + ), + ); + }); + }); + + group('MorphClient.init', () { + test('throws when plugins list is empty (no auth/storage)', () { + expect( + () => MorphClient.init(minimalValidConfig(), MorphOptions(plugins: const [])), + throwsA(isA().having((e) => e.toString(), 'txt', contains('provideAuth'))), + ); + }); + }); +} diff --git a/packages/dart/morph_core/test/minimal_morph_config.dart b/packages/dart/morph_core/test/minimal_morph_config.dart new file mode 100644 index 0000000..6fad2d9 --- /dev/null +++ b/packages/dart/morph_core/test/minimal_morph_config.dart @@ -0,0 +1,34 @@ +/// Shared minimal Morph JSON for tests (parity with `config_and_morph_client_test`). +Map minimalValidConfig() => { + 'providers': [ + { + 'key': 'p', + 'type': 'oauth2', + 'baseUrl': 'https://issuer.example', + 'contexts': [ + { + 'key': 'c', + 'token': {'endpoint': '/token'}, + 'tokenTypes': { + 'access': { + 'expiryPolicy': 'token', + 'storage': { + 'scope': 's', + 'type': 'memory', + 'protection': 'secure', + 'key': 'access-key', + }, + }, + }, + }, + ], + }, + ], + 'hosts': [ + { + 'key': 'api', + 'baseUrl': 'https://api.example', + 'allowedAuth': ['p/c'], + }, + ], + }; diff --git a/packages/dart/morph_core/test/morph_client_bootstrap_test.dart b/packages/dart/morph_core/test/morph_client_bootstrap_test.dart new file mode 100644 index 0000000..06f9ff0 --- /dev/null +++ b/packages/dart/morph_core/test/morph_client_bootstrap_test.dart @@ -0,0 +1,25 @@ +import 'package:morph_core/morph_core.dart'; +import 'package:morph_oauth2/morph_oauth2.dart'; +import 'package:morph_storage/morph_storage.dart'; +import 'package:test/test.dart'; + +import 'minimal_morph_config.dart'; + +void main() { + group('MorphClient.init', () { + test('bootstrap succeeds with memory storage + oauth2 plugins', () { + final client = MorphClient.init( + minimalValidConfig(), + MorphOptions( + plugins: [ + memoryStorageMorphPlugin(), + oauth2Plugin(), + ], + ), + ); + expect(client.runtime.tokens, isA()); + expect(client.runtime.storage, isA()); + client.dispose(); + }); + }); +} diff --git a/packages/dart/morph_core/test/morph_client_facade_test.dart b/packages/dart/morph_core/test/morph_client_facade_test.dart new file mode 100644 index 0000000..95c79a5 --- /dev/null +++ b/packages/dart/morph_core/test/morph_client_facade_test.dart @@ -0,0 +1,85 @@ +import 'package:morph_core/morph_core.dart'; +import 'package:morph_oauth2/morph_oauth2.dart'; +import 'package:morph_storage/morph_storage.dart'; +import 'package:test/test.dart'; + +import 'minimal_morph_config.dart'; + +void main() { + MorphClient makeClient() => MorphClient.init( + minimalValidConfig(), + MorphOptions( + plugins: [ + memoryStorageMorphPlugin(), + oauth2Plugin(), + ], + ), + ); + + group('MorphClient facade', () { + test('dispose then host throws StateError', () { + final c = makeClient(); + c.dispose(); + expect(() => c.host('api'), throwsA(isA())); + }); + + test('dispose then getTokenStatus throws StateError', () async { + final c = makeClient(); + c.dispose(); + try { + await c.getTokenStatus(); + fail('expected StateError'); + } catch (e) { + expect(e, isA()); + } + }); + + test('host exposes key and defaultAuth from config', () { + final c = makeClient(); + final h = c.host('api'); + expect(h.key, 'api'); + expect(h.defaultAuth, isNull); + c.dispose(); + }); + + test('unknown host key throws UnknownHostError', () { + final c = makeClient(); + expect(() => c.host('nope'), throwsA(isA())); + c.dispose(); + }); + + test('invalid auth id throws UnknownContextError', () { + final c = makeClient(); + expect(() => c.auth('nosuch'), throwsA(isA())); + c.dispose(); + }); + + test('auth(peekTokens) returns null for fresh context', () async { + final c = makeClient(); + final toks = await c.auth('p/c').peekTokens(); + expect(toks, isNull); + c.dispose(); + }); + + test('completeOAuthReturn with non-root path returns none without callback', () async { + final c = makeClient(); + final r = await c.completeOAuthReturn( + currentUri: Uri.parse('https://app.example/callback?code=x'), + ); + expect(r.status, 'none'); + c.dispose(); + }); + + test('completeOAuthReturn forwards OAuth error query', () async { + final c = makeClient(); + final r = await c.completeOAuthReturn( + currentUri: Uri.parse( + 'https://app.example/?error=access_denied&error_description=nope', + ), + ); + expect(r.status, 'oauth_error'); + expect(r.message, contains('access_denied')); + c.dispose(); + }); + }); +} diff --git a/packages/dart/morph_core/test/oauth_runtime_return_test.dart b/packages/dart/morph_core/test/oauth_runtime_return_test.dart new file mode 100644 index 0000000..88e6461 --- /dev/null +++ b/packages/dart/morph_core/test/oauth_runtime_return_test.dart @@ -0,0 +1,42 @@ +import 'package:morph_core/morph_core.dart'; +import 'package:morph_oauth2/morph_oauth2.dart'; +import 'package:morph_storage/morph_storage.dart'; +import 'package:test/test.dart'; + +import 'minimal_morph_config.dart'; + +/// Covers OAuth return / redirect parity on `MorphRuntime` (issue #13); façade tests live in morph_client_facade_test.dart (#14). +void main() { + MorphClient client() => MorphClient.init( + minimalValidConfig(), + MorphOptions( + plugins: [ + memoryStorageMorphPlugin(), + oauth2Plugin(), + ], + ), + ); + + group('MorphRuntime OAuth return', () { + test('completeOAuthReturn with non-root path returns none', () async { + final c = client(); + final r = await c.runtime.completeOAuthReturn( + currentUri: Uri.parse('https://app.example/callback?code=x'), + ); + expect(r.status, 'none'); + c.dispose(); + }); + + test('completeOAuthReturn forwards OAuth error query on root-ish path', () async { + final c = client(); + final r = await c.runtime.completeOAuthReturn( + currentUri: Uri.parse( + 'https://app.example/?error=access_denied&error_description=nope', + ), + ); + expect(r.status, 'oauth_error'); + expect(r.message, contains('access_denied')); + c.dispose(); + }); + }); +} diff --git a/packages/dart/morph_core/test/plugin_install_test.dart b/packages/dart/morph_core/test/plugin_install_test.dart new file mode 100644 index 0000000..b904923 --- /dev/null +++ b/packages/dart/morph_core/test/plugin_install_test.dart @@ -0,0 +1,192 @@ +import 'package:morph_core/morph_core.dart'; +import 'package:test/test.dart'; + +import 'minimal_morph_config.dart'; + +final class _MemStorage implements StorageProvider { + final Map _m = {}; + String _k(String key, StorageConfig c) => '${c.scope}:${c.type}:${c.protection}:${c.key}:$key'; + + @override + Future delete(String key, StorageConfig storageConfig) async => _m.remove(_k(key, storageConfig)); + + @override + Future deleteByPrefix(String prefix, StorageConfig storageConfig) async { + final p = _k(prefix, storageConfig); + _m.removeWhere((k, _) => k.startsWith(p)); + } + + @override + Future read(String key, StorageConfig storageConfig) async => _m[_k(key, storageConfig)]; + + @override + Future write(String key, String value, StorageConfig storageConfig) async { + _m[_k(key, storageConfig)] = value; + } +} + +final class _StubAuth implements AuthPlugin { + @override + void dispose() {} + + @override + Future acquireWithClientCredentials(String authId, CtxRef ref) async {} + + @override + Future clearTokens(String authId, CtxRef ref) async {} + + @override + Future exchangeToken(String sourceAuthId, CtxRef sourceRef, String targetAuthId) async {} + + @override + void fireAuthRequired(String authId, AuthContextConfig ctx) {} + + @override + Future hasValidTokenContext(String authId, CtxRef ref) async => false; + + @override + Future hasValidTokenProvider(String providerKey) async => false; + + @override + Future handle401Recovery(String authId, CtxRef ref) async {} + + @override + Future loadTokens(String authId, CtxRef ref) async => null; + + @override + Future logout(String authId, CtxRef ref, String reason) async {} + + @override + Future logoutProvider(String providerKey, String reason) async {} + + @override + Future refreshTokensManual(String authId, CtxRef ref) async {} + + @override + Future resolveAccessToken(String authId, CtxRef ref, String mode) async => ''; + + @override + Future setTokens(String authId, CtxRef ref, TokenSet tokens) async {} + + @override + Future submitCode(String authId, CtxRef ref, String code, + {String? codeVerifier, String? redirectUriOverride}) async {} +} + +final class _StoragePlugin implements MorphPlugin { + @override + String get name => 'test-storage'; + + @override + List? get provides => const ['storage']; + + @override + List? get requires => null; + + @override + void dispose() {} + + @override + void install(MorphPluginContext ctx) { + ctx.provideStorage(_MemStorage()); + } +} + +final class _AuthPlugin implements MorphPlugin { + @override + String get name => 'test-auth'; + + @override + List? get provides => const ['auth']; + + @override + List? get requires => const ['storage']; + + @override + void dispose() {} + + @override + void install(MorphPluginContext ctx) { + ctx.provideAuth(_StubAuth()); + } +} + +void main() { + group('topoSortPlugins', () { + test('orders dependency before dependent', () { + final storage = _StoragePlugin(); + final auth = _AuthPlugin(); + final sorted = topoSortPlugins([auth, storage]); + expect(sorted.first.name, 'test-storage'); + expect(sorted[1].name, 'test-auth'); + }); + + test('throws on circular requires', () { + final a = _CycleA(); + final b = _CycleB(); + expect( + () => topoSortPlugins([a, b]), + throwsA(predicate((e) => e.message.contains('Circular'))), + ); + }); + + test('throws when requirement has no provider', () { + final orphan = _AuthPlugin(); + expect( + () => topoSortPlugins([orphan]), + throwsA(predicate((e) => e.message.contains('no plugin provides'))), + ); + }); + }); + + group('installMorphPlugins', () { + test('resolves auth and storage', () { + final resolved = validateAndIndexConfig(minimalValidConfig()); + final options = MorphOptions( + plugins: [_StoragePlugin(), _AuthPlugin()], + ); + final r = installMorphPlugins( + options.plugins, + resolved, + options, + {}, + ); + expect(r.auth, isA<_StubAuth>()); + expect(r.storage, isA<_MemStorage>()); + }); + }); +} + +final class _CycleA implements MorphPlugin { + @override + String get name => 'a'; + + @override + List? get provides => const ['cap-a']; + + @override + List? get requires => const ['cap-b']; + + @override + void dispose() {} + + @override + void install(MorphPluginContext ctx) {} +} + +final class _CycleB implements MorphPlugin { + @override + String get name => 'b'; + + @override + List? get provides => const ['cap-b']; + + @override + List? get requires => const ['cap-a']; + + @override + void dispose() {} + + @override + void install(MorphPluginContext ctx) {} +} diff --git a/packages/dart/morph_logger/analysis_options.yaml b/packages/dart/morph_logger/analysis_options.yaml new file mode 100644 index 0000000..1498fff --- /dev/null +++ b/packages/dart/morph_logger/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true diff --git a/packages/dart/morph_logger/lib/create_logger.dart b/packages/dart/morph_logger/lib/create_logger.dart new file mode 100644 index 0000000..1253b8f --- /dev/null +++ b/packages/dart/morph_logger/lib/create_logger.dart @@ -0,0 +1,65 @@ +import 'dart:developer' as dev; + +import 'package:morph_core/morph_core.dart'; + +/// Same level names as TS [`LogLevel`](/packages/ts/logger/src/index.ts). +typedef LogLevel = String; + +const Map _levelOrder = { + 'debug': 0, + 'info': 1, + 'warn': 2, + 'error': 3, +}; + +/// Parity [`LoggerPluginOptions`](/packages/ts/logger/src/index.ts). +final class LoggerPluginOptions { + const LoggerPluginOptions({ + this.level = 'info', + this.prefix = '[morph] ', + this.onLog, + this.onHttpTrace, + this.httpTrace, + }); + + final LogLevel level; + final String prefix; + final MorphLogFn? onLog; + + /// Overrides the default HTTP trace line writer. + final void Function(MorphHttpTraceEvent event)? onHttpTrace; + + /// When `true` (default), chains [MorphOptions.onHttpTrace] with defaults. + final bool? httpTrace; +} + +/// Human-readable trace line without emitting (TS `defaultHttpTrace` text). +String morphHttpTraceMessage(String prefix, MorphHttpTraceEvent event) { + final status = + event.networkError != null ? 'ERR ${event.networkError}' : '${event.statusCode}'; + return '$prefix${event.method} ${event.path} → $status (${event.durationMs}ms)'; +} + +/// Default HTTP trace line (TS `defaultHttpTrace`). +void Function(MorphHttpTraceEvent event) morphDefaultHttpTrace(String prefix) { + return (MorphHttpTraceEvent event) { + dev.log(morphHttpTraceMessage(prefix, event)); + }; +} + +MorphLogFn _defaultLog(String prefix, LogLevel minLevel) { + final min = _levelOrder[minLevel] ?? 1; + return (String level, String message, [Object? error, Map? context]) { + final ord = _levelOrder[level] ?? 1; + if (ord < min) return; + dev.log('$prefix[$level] $message', level: ord * 250, error: error); + if (context != null && context.isNotEmpty) { + dev.log('$prefix ctx: $context', level: ord * 250); + } + }; +} + +/// Returns a [`MorphLogFn`] (TS [`createLogger`](/packages/ts/logger/src/index.ts)). +MorphLogFn createLogger([LoggerPluginOptions opts = const LoggerPluginOptions()]) { + return opts.onLog ?? _defaultLog(opts.prefix, opts.level); +} diff --git a/packages/dart/morph_logger/lib/logger_plugin.dart b/packages/dart/morph_logger/lib/logger_plugin.dart new file mode 100644 index 0000000..69c8edf --- /dev/null +++ b/packages/dart/morph_logger/lib/logger_plugin.dart @@ -0,0 +1,45 @@ +import 'package:morph_core/morph_core.dart'; + +import 'create_logger.dart'; + +/// Parity [`loggerPlugin`](/packages/ts/logger/src/index.ts). +MorphPlugin loggerPlugin([LoggerPluginOptions? opts]) => + _LoggerMorphPlugin(opts ?? const LoggerPluginOptions()); + +final class _LoggerMorphPlugin implements MorphPlugin { + _LoggerMorphPlugin(this.opts); + + final LoggerPluginOptions opts; + + @override + String get name => '@morph/logger'; + + @override + List? get provides => const ['logger']; + + @override + List? get requires => null; + + @override + void dispose() {} + + @override + void install(MorphPluginContext ctx) { + final logFn = createLogger(opts); + final prevLog = ctx.options.onLog; + ctx.options.onLog = (String lvl, String msg, [Object? err, Map? context]) { + logFn(lvl, msg, err, context); + prevLog?.call(lvl, msg, err, context); + }; + + final wantsTrace = opts.httpTrace ?? true; + if (wantsTrace) { + final traceFn = opts.onHttpTrace ?? morphDefaultHttpTrace(opts.prefix); + final prevTrace = ctx.options.onHttpTrace; + ctx.options.onHttpTrace = (MorphHttpTraceEvent event) { + traceFn(event); + prevTrace?.call(event); + }; + } + } +} diff --git a/packages/dart/morph_logger/lib/morph_logger.dart b/packages/dart/morph_logger/lib/morph_logger.dart new file mode 100644 index 0000000..6347c63 --- /dev/null +++ b/packages/dart/morph_logger/lib/morph_logger.dart @@ -0,0 +1,5 @@ +/// Dart parity for `@morph/logger` (`createLogger`, `loggerPlugin`). +library; + +export 'create_logger.dart'; +export 'logger_plugin.dart'; diff --git a/packages/dart/morph_logger/pubspec.lock b/packages/dart/morph_logger/pubspec.lock new file mode 100644 index 0000000..daa20fb --- /dev/null +++ b/packages/dart/morph_logger/pubspec.lock @@ -0,0 +1,404 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + morph_core: + dependency: "direct main" + description: + path: "../morph_core" + relative: true + source: path + version: "0.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/dart/morph_logger/pubspec.yaml b/packages/dart/morph_logger/pubspec.yaml new file mode 100644 index 0000000..dd5f214 --- /dev/null +++ b/packages/dart/morph_logger/pubspec.yaml @@ -0,0 +1,15 @@ +name: morph_logger +description: Dart parity for @morph/logger. +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + morph_core: + path: ../morph_core + +dev_dependencies: + lints: ^5.1.1 + test: ^1.25.8 diff --git a/packages/dart/morph_logger/test/logger_plugin_test.dart b/packages/dart/morph_logger/test/logger_plugin_test.dart new file mode 100644 index 0000000..449dffa --- /dev/null +++ b/packages/dart/morph_logger/test/logger_plugin_test.dart @@ -0,0 +1,201 @@ +import 'package:morph_core/morph_core.dart'; +import 'package:morph_logger/morph_logger.dart'; +import 'package:test/test.dart'; + +ResolvedMorphConfig minimalResolved() { + final raw = { + 'providers': [ + { + 'key': 'p', + 'type': 'oauth2', + 'baseUrl': 'https://issuer.example', + 'contexts': [ + { + 'key': 'c', + 'token': {'endpoint': '/token'}, + 'tokenTypes': { + 'access': { + 'expiryPolicy': 'token', + 'storage': { + 'scope': 's', + 'type': 'memory', + 'protection': 'secure', + 'key': 'access-key', + }, + }, + }, + }, + ], + }, + ], + 'hosts': [ + { + 'key': 'api', + 'baseUrl': 'https://api.example', + 'allowedAuth': ['p/c'], + }, + ], + }; + return validateAndIndexConfig(raw); +} + +void main() { + group('createLogger', () { + test('uses onLog when provided', () { + final captured = []; + createLogger( + LoggerPluginOptions( + onLog: + (String level, String msg, [Object? err, Map? c]) => + captured.add('$level:$msg'), + ), + )('info', 'hello', null, null); + expect(captured, ['info:hello']); + }); + + test('when custom onLog is supplied, level option is delegated to caller', () { + final lines = []; + final fn = createLogger( + LoggerPluginOptions( + level: 'warn', + prefix: '[t] ', + onLog: (String lvl, String msg, [Object? err, Map? ctx]) => + lines.add(msg), + ), + ); + fn('debug', 'a', null, null); + fn('warn', 'b', null, null); + expect(lines, ['a', 'b']); + }); + + test('default MorphLogFn ignores unknown level labels but still runs', () { + final fn = createLogger(const LoggerPluginOptions(level: 'warn')); + expect(fn, isA()); + expect(() => fn('warn', 'w', null, null), returnsNormally); + }); + }); + + group('morphHttpTraceMessage', () { + test('formats status vs network error', () { + final ok = MorphHttpTraceEvent( + kind: 'request', + hostKey: 'h', + method: 'GET', + url: 'https://x', + path: '/a', + authId: 'a', + requestHeaders: {}, + statusCode: 200, + responseHeaders: {}, + responseBody: null, + durationMs: 12, + ); + expect( + morphHttpTraceMessage('[p] ', ok), + '[p] GET /a → 200 (12ms)', + ); + + final err = MorphHttpTraceEvent( + kind: 'request', + hostKey: 'h', + method: 'POST', + url: 'https://x', + path: '/b', + authId: 'a', + requestHeaders: {}, + statusCode: 0, + responseHeaders: {}, + responseBody: null, + durationMs: 5, + networkError: 'timeout', + ); + expect( + morphHttpTraceMessage('[p] ', err), + '[p] POST /b → ERR timeout (5ms)', + ); + }); + }); + + group('loggerPlugin', () { + MorphPluginContext ctx(MorphOptions o) => MorphPluginContext( + resolved: minimalResolved(), + options: o, + variables: {}, + provideAuth: (_) {}, + provideStorage: (_) {}, + ); + + test('chains plugin onLog before existing MorphOptions.onLog', () { + final seq = []; + final o = MorphOptions(plugins: const []); + o.onLog = (String lvl, String msg, [Object? err, Map? context]) => + seq.add('prev:$msg'); + + loggerPlugin( + LoggerPluginOptions( + onLog: (String _, String msg, [Object? err, Map? ctx]) => + seq.add('new:$msg'), + ), + ).install(ctx(o)); + + o.onLog?.call('info', 'x', null, null); + + expect(seq, ['new:x', 'prev:x']); + }); + + test('chains plugin onHttpTrace before existing handler', () { + final seq = []; + final o = MorphOptions(plugins: const []); + o.onHttpTrace = (e) => seq.add('prev:${e.path}'); + + loggerPlugin(LoggerPluginOptions( + prefix: '[l] ', + onHttpTrace: (e) => seq.add('new:${e.path}'), + )).install(ctx(o)); + + o.onHttpTrace?.call( + MorphHttpTraceEvent( + kind: 'k', + hostKey: 'h', + method: 'GET', + url: 'u', + path: '/p', + authId: 'a', + requestHeaders: {}, + statusCode: 201, + responseHeaders: {}, + responseBody: null, + durationMs: 3, + ), + ); + + expect(seq, ['new:/p', 'prev:/p']); + }); + + test('does not wrap onHttpTrace when httpTrace is false', () { + var hits = 0; + final o = MorphOptions(plugins: const []); + o.onHttpTrace = (_) => hits++; + + loggerPlugin(const LoggerPluginOptions(httpTrace: false)).install(ctx(o)); + + o.onHttpTrace?.call( + MorphHttpTraceEvent( + kind: 'k', + hostKey: 'h', + method: 'GET', + url: 'u', + path: '/p', + authId: 'a', + requestHeaders: {}, + statusCode: 200, + responseHeaders: {}, + responseBody: null, + durationMs: 1, + ), + ); + + expect(hits, 1); + }); + }); +} diff --git a/packages/dart/morph_oauth2/analysis_options.yaml b/packages/dart/morph_oauth2/analysis_options.yaml new file mode 100644 index 0000000..1498fff --- /dev/null +++ b/packages/dart/morph_oauth2/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true diff --git a/packages/dart/morph_oauth2/lib/morph_oauth2.dart b/packages/dart/morph_oauth2/lib/morph_oauth2.dart new file mode 100644 index 0000000..20e94e8 --- /dev/null +++ b/packages/dart/morph_oauth2/lib/morph_oauth2.dart @@ -0,0 +1,7 @@ +/// Dart parity for `@morph/oauth2` (TokenLifecycle, TokenVault, plugin). +library; + +export 'src/oauth_callbacks.dart'; +export 'src/oauth2_plugin.dart'; +export 'src/token_lifecycle.dart'; +export 'src/token_vault.dart'; diff --git a/packages/dart/morph_oauth2/lib/src/oauth2_plugin.dart b/packages/dart/morph_oauth2/lib/src/oauth2_plugin.dart new file mode 100644 index 0000000..ee7b4c4 --- /dev/null +++ b/packages/dart/morph_oauth2/lib/src/oauth2_plugin.dart @@ -0,0 +1,124 @@ +import 'package:morph_core/morph_core.dart'; + +import 'oauth_callbacks.dart'; +import 'token_lifecycle.dart'; + +/// Options for [oauth2Plugin]. TS parity: [`OAuth2PluginOptions`](/packages/ts/oauth2/src/index.ts). +final class OAuth2PluginOptions { + const OAuth2PluginOptions({ + this.storage, + this.logger, + this.variables, + this.callbacks, + this.onTokenExchange, + this.onClientJwtAssertion, + this.autoAcquireNonInteractive, + }); + + /// [StorageProvider] or inline plugin that exposes storage via [MorphPlugin.install]. + final Object? storage; + + /// Plugin (e.g. logger) installed on the same [MorphPluginContext], or [MorphLogFn]. + final Object? logger; + + final Map? variables; + final MorphOAuthCallbacksPartial? callbacks; + final Future Function(TokenExchangeGrant grant)? onTokenExchange; + final Future Function(String authId)? onClientJwtAssertion; + final bool? autoAcquireNonInteractive; +} + +MorphLogFn? _resolveLogFn(Object? logger, MorphPluginContext ctx) { + if (logger == null) return ctx.options.onLog; + if (logger is MorphPlugin) { + logger.install(ctx); + return ctx.options.onLog; + } + if (logger is MorphLogFn) return logger; + return ctx.options.onLog; +} + +/// OAuth2 auth plugin parity with TS [`oauth2Plugin`](/packages/ts/oauth2/src/index.ts). +MorphPlugin oauth2Plugin([OAuth2PluginOptions? opts]) => + _OAuth2MorphPlugin(opts ?? const OAuth2PluginOptions()); + +final class _OAuth2MorphPlugin implements MorphPlugin { + _OAuth2MorphPlugin(this.opts); + + final OAuth2PluginOptions opts; + + @override + void dispose() {} + + @override + String get name => '@morph/oauth2'; + + @override + List? get provides => const ['auth']; + + @override + List? get requires { + final storageOpt = opts.storage; + final hasExplicit = + storageOpt is MorphPlugin || storageOpt is StorageProvider; + return hasExplicit ? [] : const ['storage']; + } + + @override + void install(MorphPluginContext ctx) { + final log = _resolveLogFn(opts.logger, ctx); + + StorageProvider? direct; + final storageOpt = opts.storage; + if (storageOpt is MorphPlugin) { + storageOpt.install(ctx); + } else if (storageOpt is StorageProvider) { + direct = storageOpt; + } + + final resolvedStorage = direct ?? ctx.options.resolvedStorage; + if (resolvedStorage == null) { + throw StateError( + 'OAuth2 plugin requires storage. Pass storage in options or add a storage plugin.', + ); + } + + void defaultOnAuthRequired(String authId, DelegateMetadata metadata) { + log?.call('warn', 'onAuthRequired: $authId', null, + {'authId': authId}); + } + + void defaultOnLogout(String authId, LogoutReason reason) { + log?.call( + 'info', + 'onLogout: $authId ($reason)', + null, + {'authId': authId, 'reason': reason}, + ); + } + + final variables = { + ...ctx.variables, + ...?opts.variables, + }; + + final partial = opts.callbacks; + final callbacks = MorphOAuthCallbacks( + onAuthRequired: partial?.onAuthRequired ?? defaultOnAuthRequired, + onLogout: partial?.onLogout ?? defaultOnLogout, + onTokenChange: partial?.onTokenChange, + ); + + final tokenOpts = OAuth2TokenOptions( + callbacks: callbacks, + variables: variables, + onTokenExchange: opts.onTokenExchange, + onClientJwtAssertion: opts.onClientJwtAssertion, + autoAcquireNonInteractive: opts.autoAcquireNonInteractive, + onLog: log, + ); + + final auth = TokenLifecycle(ctx.resolved, tokenOpts, variables, log, resolvedStorage); + ctx.provideAuth(auth); + } +} diff --git a/packages/dart/morph_oauth2/lib/src/oauth_callbacks.dart b/packages/dart/morph_oauth2/lib/src/oauth_callbacks.dart new file mode 100644 index 0000000..dd52adf --- /dev/null +++ b/packages/dart/morph_oauth2/lib/src/oauth_callbacks.dart @@ -0,0 +1,30 @@ +import 'package:morph_core/morph_core.dart'; + +/// Partial callbacks (`Partial` parity) for assembling [MorphOAuthCallbacks]. +final class MorphOAuthCallbacksPartial { + const MorphOAuthCallbacksPartial({ + this.onAuthRequired, + this.onLogout, + this.onTokenChange, + }); + + final void Function(String authId, DelegateMetadata meta)? onAuthRequired; + final void Function(String authId, LogoutReason reason)? onLogout; + final void Function(String authId, TokenSet? tokens)? onTokenChange; +} + +/// Callbacks matching TS [`MorphCallbacks`](/packages/ts/core/src/types.ts). +final class MorphOAuthCallbacks { + MorphOAuthCallbacks({ + required this.onAuthRequired, + this.onLogout, + this.onTokenChange, + }); + + final void Function(String authId, DelegateMetadata meta) onAuthRequired; + + /// Same semantics as TS `MorphCallbacks.onLogout` (often optional). + final void Function(String authId, String reason)? onLogout; + + final void Function(String authId, TokenSet? tokens)? onTokenChange; +} diff --git a/packages/dart/morph_oauth2/lib/src/token_http.dart b/packages/dart/morph_oauth2/lib/src/token_http.dart new file mode 100644 index 0000000..e6519f6 --- /dev/null +++ b/packages/dart/morph_oauth2/lib/src/token_http.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:morph_core/morph_core.dart'; + +final class OAuthTokenResponse { + OAuthTokenResponse({ + required this.accessToken, + this.refreshToken, + this.expiresIn, + this.tokenType, + }); + + final String accessToken; + final String? refreshToken; + final int? expiresIn; + final String? tokenType; +} + +OAuthTokenResponse oauthResponseFromJson(dynamic json) { + if (json is! Map) throw FormatException('OAuth token JSON must be object'); + final m = Map.from(json.cast()); + final rawExp = m['expires_in']; + final int? expiresIn; + if (rawExp is num) { + expiresIn = rawExp.toInt(); + } else if (rawExp is String) { + expiresIn = int.tryParse(rawExp); + } else { + expiresIn = null; + } + return OAuthTokenResponse( + accessToken: m['access_token'] as String? ?? '', + refreshToken: m['refresh_token'] as String?, + expiresIn: expiresIn, + tokenType: m['token_type'] as String?, + ); +} + +Map mergeTokenHeaders( + ProviderConfig provider, + AuthContextConfig ctx, + Map variables, +) { + final base = interpolateRecord(provider.headers, variables) ?? {}; + final extra = interpolateRecord(ctx.headers, variables, {'key': ctx.key}) ?? {}; + return {...base, ...extra}; +} + +Future postTokenRequest( + String url, + Map body, + Map headers, + NetworkPolicy? policy, + MorphLogFn? log, +) async { + final timeoutMs = parseDurationMs(policy?.timeout, 30000); + final retries = policy?.retry?.count ?? 0; + final delayMs = parseDurationMs(policy?.retry?.delay, 200); + + Object? lastErr; + for (var attempt = 0; attempt <= retries; attempt++) { + try { + final res = await http + .post( + Uri.parse(url), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...headers, + }, + body: Uri(queryParameters: body).query, + encoding: utf8, + ) + .timeout(Duration(milliseconds: timeoutMs)); + + final text = res.body; + dynamic json; + try { + json = jsonDecode(text); + } catch (_) { + json = {'raw': text}; + } + if (res.statusCode < 200 || res.statusCode >= 300) { + log?.call('warn', 'Token endpoint HTTP ${res.statusCode}', null, {'url': url, 'json': json}); + throw TokenEndpointError(res.statusCode, text); + } + return oauthResponseFromJson(json); + } catch (e) { + if (e is TokenEndpointError) rethrow; + lastErr = e; + if (attempt < retries) await Future.delayed(Duration(milliseconds: delayMs)); + } + } + throw lastErr ?? StateError('postTokenRequest exhausted retries'); +} + +String tokenEndpointUrl( + String tokenHttpBase, + AuthContextConfig ctx, + bool useExchangeEndpoint, + Map variables, +) { + final raw = useExchangeEndpoint && (ctx.token.exchangeEndpoint?.trim().isNotEmpty ?? false) + ? ctx.token.exchangeEndpoint!.trim() + : ctx.token.endpoint.trim(); + final ep = interpolateString(raw, variables, {'key': ctx.key}); + return resolveEndpoint(tokenHttpBase, ep); +} + +Future> buildClientAuthFields({ + required String authId, + required AuthContextConfig ctx, + required Map variables, + Future Function(String authId)? onClientJwtAssertion, +}) async { + final clientId = + ctx.clientId != null ? interpolateString(ctx.clientId!, variables, {'key': ctx.key}) : ''; + final out = {'client_id': clientId}; + final auth = ctx.clientAuth ?? 'client_secret_post'; + if (auth == 'private_key_jwt') { + final assertion = await onClientJwtAssertion?.call(authId); + if (assertion != null && assertion.isNotEmpty) { + out['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + out['client_assertion'] = assertion; + return out; + } + } + if (ctx.clientSecret != null && ctx.clientSecret!.trim().isNotEmpty) { + out['client_secret'] = interpolateString(ctx.clientSecret!, variables, {'key': ctx.key}); + } + return out; +} diff --git a/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart b/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart new file mode 100644 index 0000000..55010df --- /dev/null +++ b/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart @@ -0,0 +1,482 @@ +import 'package:morph_core/morph_core.dart'; + +import 'oauth_callbacks.dart'; +import 'token_http.dart' as th; +import 'token_vault.dart'; + +/// Parity: `OAuth2TokenOptions` (TS). +final class OAuth2TokenOptions { + OAuth2TokenOptions({ + required this.callbacks, + required this.variables, + this.onTokenExchange, + this.onClientJwtAssertion, + this.autoAcquireNonInteractive, + this.onLog, + }); + + final MorphOAuthCallbacks callbacks; + final Map variables; + final Future Function(TokenExchangeGrant grant)? onTokenExchange; + final Future Function(String authId)? onClientJwtAssertion; + final bool? autoAcquireNonInteractive; + final MorphLogFn? onLog; +} + +const _grantTokenExchange = + 'urn:ietf:params:oauth:grant-type:token-exchange'; +const _accessTokenSubjectType = 'urn:ietf:params:oauth:token-type:access_token'; + +/// Implements [`AuthPlugin`] — parity `packages/ts/oauth2/src/tokens/tokenLifecycle.ts`. +final class TokenLifecycle implements AuthPlugin { + TokenLifecycle( + this.resolved, + this.opts, + Map variablesIn, + this.log, + StorageProvider storage, + ) : variables = {...variablesIn}, + vault = TokenVault(variablesIn, storage); + + final ResolvedMorphConfig resolved; + final OAuth2TokenOptions opts; + final Map variables; + final MorphLogFn? log; + final TokenVault vault; + + final Map> _locks = {}; + final Map> _inflightSubmit = {}; + + /// In-memory cache of the last persisted token set per authId. + /// Allows [resolveAccessToken] to return immediately on the hot path without + /// acquiring the per-auth lock or hitting storage on every concurrent request. + final Map _tokenCache = {}; + + @override + void dispose() { + _locks.clear(); + _inflightSubmit.clear(); + _tokenCache.clear(); + } + + void _doLog(String level, String msg, [Object? err, Map? ctx]) { + log?.call(level, msg, err, ctx); + } + + Future _withLock(String authId, Future Function() fn) { + final prev = _locks[authId] ?? Future.value(); + late final Future next; + next = prev.then((_) => fn()); + _locks[authId] = next.then((_) => Future.value(), onError: (_) => Future.value()); + return next; + } + + NetworkPolicy? _policy(ProviderConfig p, AuthContextConfig ctx) => + ctx.networkPolicy ?? p.networkPolicy; + + String _tokenHttpBase(ProviderConfig p) { + final raw = p.tokenHttpBaseUrl?.trim(); + if (raw != null && raw.isNotEmpty) { + final r = interpolateString(raw, variables).trim(); + if (r.isNotEmpty) return r; + } + return interpolateString(p.baseUrl.trim(), variables); + } + + TokenSet _oauthToSet(AuthContextConfig ctx, th.OAuthTokenResponse r, + [String? preserveRefresh]) { + final tt = ctx.tokenTypes['access']!; + final exp = computeExpiresAt(r.accessToken, r.expiresIn, tt.maxTtl); + return TokenSet( + accessToken: r.accessToken, + refreshToken: r.refreshToken ?? preserveRefresh, + expiresAt: exp, + ); + } + + Future _persist(String authId, CtxRef ref, TokenSet? set) async { + if (set != null) { + await vault.save(authId, ref.provider, ref.context, set); + _tokenCache[authId] = set; // warm fast-path cache + } else { + await vault.clear(authId, ref.provider, ref.context); + _tokenCache.remove(authId); // invalidate on clear + } + opts.callbacks.onTokenChange?.call(authId, set); + } + + Future _executeGrant( + String authId, + CtxRef ref, + String grantType, + Map extras, + TokenExchangeGrant info, [ + String? preserveRefresh, + ]) async { + final provider = ref.provider; + final ctx = ref.context; + final useExchange = grantType == _grantTokenExchange && ctx.token.exchangeEndpoint != null; + final url = th.tokenEndpointUrl(_tokenHttpBase(provider), ctx, useExchange, variables); + final clientFields = await th.buildClientAuthFields( + authId: authId, + ctx: ctx, + variables: variables, + onClientJwtAssertion: opts.onClientJwtAssertion, + ); + final hdr = th.mergeTokenHeaders(provider, ctx, variables); + final body = {...clientFields, 'grant_type': grantType, ...extras}; + if (grantType != 'authorization_code' && ctx.audience != null) { + body['audience'] = interpolateString(ctx.audience!, variables, {'key': ctx.key}); + } + if (ctx.scopes != null && ctx.scopes!.isNotEmpty) { + body['scope'] = ctx.scopes!.join(' '); + } + + final custom = await opts.onTokenExchange?.call(info); + if (custom != null) return custom; + + final r = await th.postTokenRequest(url, body, hdr, _policy(provider, ctx), opts.onLog ?? log); + return _oauthToSet(ctx, r, preserveRefresh); + } + + Future _executeRefresh( + String aid, CtxRef ref, String rt) { + final preserve = + ref.context.refreshPolicy?.strategy == 'static' ? rt : null; + return _executeGrant(aid, ref, 'refresh_token', {'refresh_token': rt}, + TokenExchangeGrant(type: 'refresh_token', authId: aid, refreshToken: rt), + preserve); + } + + Future _fetchClientCred(String aid, CtxRef ref) => + _executeGrant(aid, ref, 'client_credentials', {}, + TokenExchangeGrant(type: 'client_credentials', authId: aid)); + + Future _runTokenExchange(String srcAid, String tgtAid, + CtxRef targetRef, String subject) { + return _executeGrant(tgtAid, targetRef, _grantTokenExchange, { + 'subject_token': subject, + 'subject_token_type': _accessTokenSubjectType, + }, TokenExchangeGrant( + type: 'token_exchange', + authId: tgtAid, + sourceAuthId: srcAid, + sourceToken: subject, + )); + } + + @override + Future loadTokens(String aid, CtxRef ref) => + vault.load(aid, ref.provider, ref.context); + + @override + Future submitCode(String aid, CtxRef ref, String code, + {String? codeVerifier, String? redirectUriOverride}) async { + final key = '$aid:$code'; + final existing = _inflightSubmit[key]; + if (existing != null) return existing; + final done = () async { + await _withLock(aid, () async { + final ctx = ref.context; + final red = ctx.authorization?.redirectUri; + if (red == null || red.isEmpty) { + throw StateError('authorization.redirectUri is required for submitCode'); + } + final redirectUri = redirectUriOverride != null && + redirectUriOverride.trim().isNotEmpty + ? redirectUriOverride.trim() + : interpolateString(red, variables, {'key': ctx.key}); + final extras = { + 'code': code, + 'redirect_uri': redirectUri, + }; + if (codeVerifier != null) extras['code_verifier'] = codeVerifier; + final set = await _executeGrant(aid, ref, 'authorization_code', extras, TokenExchangeGrant( + type: 'authorization_code', + authId: aid, + code: code, + codeVerifier: codeVerifier, + )); + await _persist(aid, ref, set); + _doLog('info', 'Tokens stored (authorization_code)', null, {'authId': aid}); + }); + }().whenComplete(() => _inflightSubmit.remove(key)); + _inflightSubmit[key] = done; + return done; + } + + @override + Future acquireWithClientCredentials(String aid, CtxRef ref) => + _withLock(aid, () async { + final set = await _fetchClientCred(aid, ref); + await _persist(aid, ref, set); + _doLog('info', 'Client credentials token stored', null, {'authId': aid}); + }); + + @override + Future exchangeToken(String srcAid, CtxRef srcRef, String tgtAid) { + final target = resolved.contextByAuthId[tgtAid]; + if (target == null) throw UnknownContextError(tgtAid); + return _withLock(tgtAid, () async { + final access = await resolveAccessToken(srcAid, srcRef, 'http'); + final nt = await _runTokenExchange(srcAid, tgtAid, target, access); + await _persist(tgtAid, target, nt); + _doLog( + 'info', 'Token exchange completed', null, {'sourceAuthId': srcAid, 'targetAuthId': tgtAid}); + }); + } + + @override + Future setTokens(String aid, CtxRef ref, TokenSet tokens) => + _withLock(aid, () async { + var exp = tokens.expiresAt; + exp ??= + computeExpiresAt(tokens.accessToken, null, ref.context.tokenTypes['access']!.maxTtl); + await _persist(aid, ref, TokenSet(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: exp, metadata: tokens.metadata)); + }); + + @override + Future clearTokens(String aid, CtxRef ref) => _withLock(aid, () async { + await vault.clear(aid, ref.provider, ref.context); + opts.callbacks.onTokenChange?.call(aid, null); + }); + + @override + Future logout(String aid, CtxRef ref, String reason) async { + final set = await loadTokens(aid, ref); + final p = ref.provider; + final ctx = ref.context; + final ep = ctx.logout?.endpoint; + if (ep != null && ep.isNotEmpty) { + try { + final url = resolveEndpoint(_tokenHttpBase(p), ep); + final clientFields = await th.buildClientAuthFields( + authId: aid, + ctx: ctx, + variables: variables, + onClientJwtAssertion: opts.onClientJwtAssertion, + ); + final hdr = th.mergeTokenHeaders(p, ctx, variables); + final body = {...clientFields}; + if (set?.refreshToken != null) body['refresh_token'] = set!.refreshToken!; + await th.postTokenRequest(url, body, hdr, _policy(p, ctx), opts.onLog ?? log); + } catch (e) { + _doLog('warn', 'Logout endpoint failed', e, {'authId': aid}); + } + } + await clearTokens(aid, ref); + opts.callbacks.onLogout?.call(aid, reason); + } + + @override + Future logoutProvider(String providerKey, String reason) async { + final ids = listAuthIdsForProvider(providerKey, resolved); + for (final id in ids) { + final r = resolved.contextByAuthId[id]!; + await logout(id, r, reason); + } + } + + @override + Future hasValidTokenContext(String aid, CtxRef ref) async { + try { + await resolveAccessToken(aid, ref, 'probe'); + return true; + } catch (_) { + return false; + } + } + + @override + Future hasValidTokenProvider(String pk) async { + final ids = listAuthIdsForProvider(pk, resolved); + for (final id in ids) { + final r = resolved.contextByAuthId[id]; + if (r != null && await hasValidTokenContext(id, r)) return true; + } + return false; + } + + @override + void fireAuthRequired(String aid, AuthContextConfig ctx) { + DelegateMetadata md; + if (ctx.delegateMetadata != null) { + md = ctx.delegateMetadata!; + } else { + md = DelegateMetadata(workflow: 'unknown', grantHint: 'unknown', interaction: 'interactive'); + } + opts.callbacks.onAuthRequired(aid, md); + if (opts.autoAcquireNonInteractive == true && md.interaction == 'non-interactive') { + final ref = resolved.contextByAuthId[aid]; + if (ref != null) { + acquireWithClientCredentials(aid, ref).catchError((Object e, StackTrace _) { + _doLog('warn', 'autoAcquireNonInteractive failed', e, {'authId': aid}); + }); + } + } + } + + void _emitRefreshFailCallbacks(CtxRef ref, String aid, String mode) { + if (mode != 'http') return; + if (ref.context.recoveryPolicy?.onRefreshFail == 'delegate') { + fireAuthRequired(aid, ref.context); + } + opts.callbacks.onLogout?.call(aid, 'refresh_failed'); + } + + @override + Future handle401Recovery(String aid, CtxRef ref) => + _withLock(aid, () async { + final set = await loadTokens(aid, ref); + if (set?.refreshToken != null && set!.refreshToken!.isNotEmpty) { + try { + final nw = await _executeRefresh(aid, ref, set.refreshToken!); + await _persist(aid, ref, nw); + _doLog('info', 'Access token refreshed after 401', null, {'authId': aid}); + } catch (_) { + await vault.clear(aid, ref.provider, ref.context); + opts.callbacks.onTokenChange?.call(aid, null); + _emitRefreshFailCallbacks(ref, aid, 'http'); + } + } else { + fireAuthRequired(aid, ref.context); + } + }); + + @override + Future resolveAccessToken(String aid, CtxRef ref, String mode) async { + // Fast path: return cached token immediately if still valid (skips lock + storage). + final cached = _tokenCache[aid]; + if (cached != null && !isExpired(cached.expiresAt, 30)) { + return cached.accessToken; + } + return _withLock(aid, () => _resolveAccessInner(aid, ref, mode)); + } + + Future _resolveAccessInner(String aid, CtxRef ref, String mode) async { + var set = await loadTokens(aid, ref); + const skew = 30; + var refreshBefore = skew; + final rbp = ref.context.refreshPolicy?.refreshBeforeExpiry; + if (rbp != null) { + refreshBefore = (parseDurationMs(rbp) / 1000).ceil().clamp(1, 86400); + } + if (rbp == null && set?.expiresAt != null) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final ttlSec = set!.expiresAt! - now; + if (ttlSec > 0 && ttlSec < refreshBefore) { + refreshBefore = (ttlSec - 5).clamp(1, refreshBefore); + } + } + + if (set != null && !isExpired(set.expiresAt, refreshBefore)) { + return set.accessToken; + } + + var recoveryEmitted = false; + var deferredRefreshFail = false; + + if (set?.refreshToken != null && set!.refreshToken!.isNotEmpty) { + try { + final newSet = await _executeRefresh(aid, ref, set.refreshToken!); + await _persist(aid, ref, newSet); + _doLog('info', 'Access token refreshed', null, {'authId': aid}); + return newSet.accessToken; + } catch (e) { + _doLog('warn', 'Refresh failed', e, {'authId': aid}); + await vault.clear(aid, ref.provider, ref.context); + opts.callbacks.onTokenChange?.call(aid, null); + set = null; + deferredRefreshFail = hasExchangeSourcesFromTokenBlock(ref.context.token); + if (!deferredRefreshFail) { + _emitRefreshFailCallbacks(ref, aid, mode); + recoveryEmitted = true; + } + } + } + + if (set != null && + isExpired(set.expiresAt, refreshBefore) && + (set.refreshToken == null || set.refreshToken!.isEmpty) && + ref.context.delegateMetadata?.grantHint == 'client_credentials') { + try { + final newSet = await _fetchClientCred(aid, ref); + await _persist(aid, ref, newSet); + _doLog('info', 'Access token renewed (client_credentials)', null, {'authId': aid}); + return newSet.accessToken; + } catch (e) { + _doLog('warn', 'Client credentials re-acquire failed', e, {'authId': aid}); + } + } + + final exSrcs = normalizeExchangeSourcesFromTokenBlock(ref.context.token); + if (exSrcs.isNotEmpty) { + var exchanged = false; + for (final exSrc in exSrcs) { + try { + final srcRef = resolved.contextByAuthId[exSrc]; + if (srcRef == null) throw StateError('Invalid exchangeSource $exSrc'); + final srcToken = await resolveAccessToken(exSrc, srcRef, mode); + final newSet = await _runTokenExchange(exSrc, aid, ref, srcToken); + await _persist(aid, ref, newSet); + _doLog('info', 'Access token issued (token_exchange)', null, {'authId': aid, 'exchangeSource': exSrc}); + deferredRefreshFail = false; + exchanged = true; + return newSet.accessToken; + } catch (e) { + _doLog('warn', 'Auto token exchange failed', e, {'authId': aid, 'exSrc': exSrc}); + } + } + if (!exchanged && deferredRefreshFail) { + _emitRefreshFailCallbacks(ref, aid, mode); + recoveryEmitted = true; + } + } + + if (mode == 'http' && ref.context.recoveryPolicy?.onRefreshFail == 'delegate') { + if (!recoveryEmitted) fireAuthRequired(aid, ref.context); + throw AuthError(aid, 'delegation_required'); + } + + throw AuthError(aid, set != null ? 'refresh_failed' : 'no_token'); + } + + @override + Future refreshTokensManual(String aid, CtxRef ref) => _withLock(aid, () async { + final set = await loadTokens(aid, ref); + if (set?.refreshToken != null && set!.refreshToken!.isNotEmpty) { + try { + final newSet = await _executeRefresh(aid, ref, set.refreshToken!); + await _persist(aid, ref, newSet); + _doLog('info', 'Access token refreshed (manual)', null, {'authId': aid}); + return; + } catch (e) { + final exSrcs = normalizeExchangeSourcesFromTokenBlock(ref.context.token); + for (final exSrc in exSrcs) { + try { + final srcRef = resolved.contextByAuthId[exSrc]; + if (srcRef == null) throw StateError('Invalid exchangeSource $exSrc'); + final srcToken = await resolveAccessToken(exSrc, srcRef, 'http'); + final newSet = await _runTokenExchange(exSrc, aid, ref, srcToken); + await _persist(aid, ref, newSet); + _doLog( + 'info', 'Access token issued (manual: exchange after refresh fail)', null, {'authId': aid}); + return; + } catch (_) {} + } + await vault.clear(aid, ref.provider, ref.context); + opts.callbacks.onTokenChange?.call(aid, null); + _emitRefreshFailCallbacks(ref, aid, 'http'); + rethrow; + } + } + if (ref.context.delegateMetadata?.grantHint == 'client_credentials') { + final newSet = await _fetchClientCred(aid, ref); + await _persist(aid, ref, newSet); + _doLog('info', 'Access token renewed (client_credentials, manual)', null, {'authId': aid}); + return; + } + throw StateError('$aid: no refresh token; use login or token exchange'); + }); +} + diff --git a/packages/dart/morph_oauth2/lib/src/token_vault.dart b/packages/dart/morph_oauth2/lib/src/token_vault.dart new file mode 100644 index 0000000..1ed7ea8 --- /dev/null +++ b/packages/dart/morph_oauth2/lib/src/token_vault.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:morph_core/morph_core.dart'; + +/// Path manifest JSON stored beside token blobs (`packages/ts/oauth2/src/tokens/tokenVault.ts`). +final class PathManifest { + PathManifest({required this.accessKey, this.refreshKey}); + factory PathManifest.fromJson(Map m) => + PathManifest(accessKey: m['accessKey'] as String, refreshKey: m['refreshKey'] as String?); + + final String accessKey; + final String? refreshKey; + + Map toJson() => { + 'accessKey': accessKey, + if (refreshKey != null) 'refreshKey': refreshKey, + }; +} + +StorageConfig _manifestCfg(StorageConfig accessStorage, String authId) { + return StorageConfig( + scope: accessStorage.scope, + type: accessStorage.type, + protection: accessStorage.protection, + key: 'morph:paths:${authId.replaceAll('/', '.')}', + ); +} + +final class TokenVault { + TokenVault(this._variables, StorageProvider transport) : _storage = transport; + + final Map _variables; + final StorageProvider _storage; + + StorageConfig _tokCfg(AuthContextConfig ctx, String kind) { + final tt = ctx.tokenTypes[kind]; + if (tt == null) throw StateError('Missing tokenTypes.$kind for context ${ctx.key}'); + return tt.storage; + } + + Future _readManifest(String authId, AuthContextConfig ctx) async { + final acc = _tokCfg(ctx, 'access'); + final cfg = _manifestCfg(acc, authId); + final raw = await _storage.read(cfg.key, cfg); + if (raw == null) return null; + try { + return PathManifest.fromJson(jsonDecode(raw) as Map); + } catch (_) { + return null; + } + } + + Future _writeManifest(String authId, AuthContextConfig ctx, PathManifest m) async { + final acc = _tokCfg(ctx, 'access'); + final cfg = _manifestCfg(acc, authId); + await _storage.write(cfg.key, jsonEncode(m.toJson()), cfg); + } + + Future _deleteManifest(String authId, AuthContextConfig ctx) async { + final acc = _tokCfg(ctx, 'access'); + final cfg = _manifestCfg(acc, authId); + await _storage.delete(cfg.key, cfg); + } + + Map _extrasForKeys(AuthContextConfig ctx, [String? accessToken]) { + final subClaim = ctx.identity?.subject ?? 'sub'; + final actClaim = ctx.identity?.actor ?? 'act'; + final subject = + accessToken != null ? (getJwtSubject(accessToken, subClaim) ?? 'unknown') : 'unknown'; + final actor = + accessToken != null ? (getJwtSubject(accessToken, actClaim) ?? subject) : subject; + return {'key': ctx.key, 'subject': subject, 'actor': actor}; + } + + Future load( + String authId, + ProviderConfig provider, + AuthContextConfig ctx, + ) async { + final manifest = await _readManifest(authId, ctx); + if (manifest == null) return null; + final accCfg = + StorageConfig(scope: _tokCfg(ctx, 'access').scope, type: _tokCfg(ctx, 'access').type, protection: _tokCfg(ctx, 'access').protection, key: manifest.accessKey); + final rawAccess = await _storage.read(manifest.accessKey, accCfg); + if (rawAccess == null) return null; + String? refreshToken; + if (manifest.refreshKey != null && ctx.tokenTypes.containsKey('refresh')) { + final refCfg = + StorageConfig(scope: _tokCfg(ctx, 'refresh').scope, type: _tokCfg(ctx, 'refresh').type, protection: _tokCfg(ctx, 'refresh').protection, key: manifest.refreshKey!); + refreshToken = await _storage.read(manifest.refreshKey!, refCfg); + } + try { + final map = jsonDecode(rawAccess); + if (map is! Map) return null; + final mj = Map.from(map.cast()); + if (refreshToken != null) mj['refreshToken'] = refreshToken; + return TokenSet.fromJson(mj); + } catch (_) { + return null; + } + } + + Future save(String authId, ProviderConfig provider, AuthContextConfig ctx, TokenSet tokens) async { + final extras = _extrasForKeys(ctx, tokens.accessToken); + final accessTemplate = ctx.tokenTypes['access']!.storage.key; + final accessKey = interpolateString(accessTemplate, _variables, extras); + + String? refreshKey; + if (tokens.refreshToken != null && ctx.tokenTypes.containsKey('refresh')) { + final rt = ctx.tokenTypes['refresh']!.storage.key; + refreshKey = interpolateString(rt, _variables, extras); + } + + final accessCfgFull = StorageConfig( + scope: _tokCfg(ctx, 'access').scope, + type: _tokCfg(ctx, 'access').type, + protection: _tokCfg(ctx, 'access').protection, + key: accessKey, + ); + + await _storage.write( + accessKey, + jsonEncode( + TokenSet( + accessToken: tokens.accessToken, + expiresAt: tokens.expiresAt, + metadata: tokens.metadata, + ).toJson(), + ), + accessCfgFull, + ); + + if (refreshKey != null && tokens.refreshToken != null && ctx.tokenTypes.containsKey('refresh')) { + final refCfg = StorageConfig( + scope: _tokCfg(ctx, 'refresh').scope, + type: _tokCfg(ctx, 'refresh').type, + protection: _tokCfg(ctx, 'refresh').protection, + key: refreshKey, + ); + await _storage.write(refreshKey, tokens.refreshToken!, refCfg); + } + + await _writeManifest( + authId, + ctx, + PathManifest(accessKey: accessKey, refreshKey: refreshKey), + ); + } + + Future clear(String authId, ProviderConfig provider, AuthContextConfig ctx) async { + final manifest = await _readManifest(authId, ctx); + if (manifest != null) { + final accDel = StorageConfig( + scope: _tokCfg(ctx, 'access').scope, + type: _tokCfg(ctx, 'access').type, + protection: _tokCfg(ctx, 'access').protection, + key: manifest.accessKey, + ); + await _storage.delete(manifest.accessKey, accDel); + if (manifest.refreshKey != null && ctx.tokenTypes.containsKey('refresh')) { + final refDel = StorageConfig( + scope: _tokCfg(ctx, 'refresh').scope, + type: _tokCfg(ctx, 'refresh').type, + protection: _tokCfg(ctx, 'refresh').protection, + key: manifest.refreshKey!, + ); + await _storage.delete(manifest.refreshKey!, refDel); + } + } + await _deleteManifest(authId, ctx); + } +} diff --git a/packages/dart/morph_oauth2/pubspec.lock b/packages/dart/morph_oauth2/pubspec.lock new file mode 100644 index 0000000..6710631 --- /dev/null +++ b/packages/dart/morph_oauth2/pubspec.lock @@ -0,0 +1,411 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + morph_core: + dependency: "direct main" + description: + path: "../morph_core" + relative: true + source: path + version: "0.1.0" + morph_storage: + dependency: "direct dev" + description: + path: "../morph_storage" + relative: true + source: path + version: "0.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/dart/morph_oauth2/pubspec.yaml b/packages/dart/morph_oauth2/pubspec.yaml new file mode 100644 index 0000000..a65151a --- /dev/null +++ b/packages/dart/morph_oauth2/pubspec.yaml @@ -0,0 +1,18 @@ +name: morph_oauth2 +description: Dart parity for @morph/oauth2 (OAuth2 TokenLifecycle / TokenVault / plugin). +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + http: ^1.2.2 + morph_core: + path: ../morph_core + +dev_dependencies: + lints: ^5.1.1 + test: ^1.25.8 + morph_storage: + path: ../morph_storage diff --git a/packages/dart/morph_oauth2/test/oauth2_plugin_install_test.dart b/packages/dart/morph_oauth2/test/oauth2_plugin_install_test.dart new file mode 100644 index 0000000..ba1160c --- /dev/null +++ b/packages/dart/morph_oauth2/test/oauth2_plugin_install_test.dart @@ -0,0 +1,112 @@ +import 'package:morph_core/morph_core.dart'; +import 'package:morph_oauth2/morph_oauth2.dart'; +import 'package:morph_storage/morph_storage.dart'; +import 'package:test/test.dart'; + +Map minimalValidMorphConfig() => { + 'providers': [ + { + 'key': 'p', + 'type': 'oauth2', + 'baseUrl': 'https://issuer.example', + 'contexts': [ + { + 'key': 'c', + 'token': {'endpoint': '/token'}, + 'tokenTypes': { + 'access': { + 'expiryPolicy': 'token', + 'storage': { + 'scope': 's', + 'type': 'memory', + 'protection': 'secure', + 'key': 'access-key', + }, + }, + }, + }, + ], + }, + ], + 'hosts': [ + { + 'key': 'api', + 'baseUrl': 'https://api.example', + 'allowedAuth': ['p/c'], + }, + ], + }; + +void main() { + group('oauth2Plugin', () { + late ResolvedMorphConfig resolved; + + setUp(() { + resolved = validateAndIndexConfig(minimalValidMorphConfig()); + }); + + test('provideAuth installs TokenLifecycle with direct MemoryStorageProvider', () { + final options = MorphOptions(plugins: const []); + AuthPlugin? captured; + oauth2Plugin(OAuth2PluginOptions(storage: MemoryStorageProvider())) + .install(MorphPluginContext( + resolved: resolved, + options: options, + variables: {}, + provideAuth: (a) => captured = a, + provideStorage: (_) {}, + )); + expect(captured, isA()); + }); + + test('uses MorphOptions.resolvedStorage when storage option is omitted', () { + final options = MorphOptions(plugins: const []); + options.resolvedStorage = MemoryStorageProvider(); + AuthPlugin? captured; + oauth2Plugin().install(MorphPluginContext( + resolved: resolved, + options: options, + variables: {}, + provideAuth: (a) => captured = a, + provideStorage: (_) {}, + )); + expect(captured, isA()); + }); + + test('inline storage MorphPlugin provides storage before oauth2 auth', () { + final options = MorphOptions(plugins: const []); + oauth2Plugin(OAuth2PluginOptions(storage: memoryStorageMorphPlugin())) + .install(MorphPluginContext( + resolved: resolved, + options: options, + variables: {}, + provideAuth: (_) {}, + provideStorage: (s) => options.resolvedStorage = s, + )); + + AuthPlugin? captured; + oauth2Plugin().install(MorphPluginContext( + resolved: resolved, + options: options, + variables: {}, + provideAuth: (a) => captured = a, + provideStorage: (_) {}, + )); + expect(captured, isA()); + }); + + test('throws StateError without any storage source', () { + final options = MorphOptions(plugins: const []); + expect( + () => oauth2Plugin().install(MorphPluginContext( + resolved: resolved, + options: options, + variables: {}, + provideAuth: (_) {}, + provideStorage: (_) {}, + )), + throwsStateError, + ); + }); + }); +} diff --git a/packages/dart/morph_storage/analysis_options.yaml b/packages/dart/morph_storage/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/packages/dart/morph_storage/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/dart/morph_storage/lib/memory_storage_plugin.dart b/packages/dart/morph_storage/lib/memory_storage_plugin.dart new file mode 100644 index 0000000..8ac7f74 --- /dev/null +++ b/packages/dart/morph_storage/lib/memory_storage_plugin.dart @@ -0,0 +1,29 @@ +import 'package:morph_core/morph_core.dart'; + +import 'memory_storage_provider.dart'; + +/// [`MorphPlugin`] that [`provideStorage`] with an in-memory store (parity idea: inline storage plugin). +MorphPlugin memoryStorageMorphPlugin() => _MemoryStoragePluginMorph(); + +final class _MemoryStoragePluginMorph implements MorphPlugin { + final MemoryStorageProvider _storage = MemoryStorageProvider(); + + @override + String get name => '@morph/dart-memory-storage'; + + @override + List? get requires => null; + + @override + List? get provides => const ['storage']; + + @override + void dispose() {} + + @override + void install(MorphPluginContext ctx) { + ctx.provideStorage(_storage); + ctx.options.onLog?.call('debug', + 'Storage initialized (memory, plugin: $name)', null, {'plugin': name}); + } +} diff --git a/packages/dart/morph_storage/lib/memory_storage_provider.dart b/packages/dart/morph_storage/lib/memory_storage_provider.dart new file mode 100644 index 0000000..fd95339 --- /dev/null +++ b/packages/dart/morph_storage/lib/memory_storage_provider.dart @@ -0,0 +1,29 @@ +import 'package:morph_core/morph_core.dart'; + +/// In-memory [StorageProvider] for tests / VM-only apps. +final class MemoryStorageProvider implements StorageProvider { + MemoryStorageProvider(); + + final Map _store = {}; + + String _scopedKey(String key, StorageConfig cfg) => '${cfg.scope}:${cfg.type}:${cfg.protection}:${cfg.key}:$key'; + + @override + Future delete(String key, StorageConfig storageConfig) async { + _store.remove(_scopedKey(key, storageConfig)); + } + + @override + Future deleteByPrefix(String prefix, StorageConfig storageConfig) async { + final p = _scopedKey(prefix, storageConfig); + _store.removeWhere((k, _) => k.startsWith(p)); + } + + @override + Future read(String key, StorageConfig storageConfig) async => _store[_scopedKey(key, storageConfig)]; + + @override + Future write(String key, String value, StorageConfig storageConfig) async { + _store[_scopedKey(key, storageConfig)] = value; + } +} diff --git a/packages/dart/morph_storage/lib/morph_storage.dart b/packages/dart/morph_storage/lib/morph_storage.dart new file mode 100644 index 0000000..34945c0 --- /dev/null +++ b/packages/dart/morph_storage/lib/morph_storage.dart @@ -0,0 +1,5 @@ +/// In-memory [`StorageProvider`] for tests. +library; + +export 'memory_storage_provider.dart'; +export 'memory_storage_plugin.dart'; diff --git a/packages/dart/morph_storage/pubspec.lock b/packages/dart/morph_storage/pubspec.lock new file mode 100644 index 0000000..daa20fb --- /dev/null +++ b/packages/dart/morph_storage/pubspec.lock @@ -0,0 +1,404 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + morph_core: + dependency: "direct main" + description: + path: "../morph_core" + relative: true + source: path + version: "0.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/dart/morph_storage/pubspec.yaml b/packages/dart/morph_storage/pubspec.yaml new file mode 100644 index 0000000..1b44500 --- /dev/null +++ b/packages/dart/morph_storage/pubspec.yaml @@ -0,0 +1,18 @@ +name: morph_storage +description: > + In-memory StorageProvider and MorphPlugin for Morph (tests/VM). Browser + session/local storage lives in @morph/browser-storage (TS); production Flutter + apps typically use morph-data-store via morph_core_storage. +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + morph_core: + path: ../morph_core + +dev_dependencies: + lints: ^5.1.1 + test: ^1.25.8 diff --git a/packages/ts/browser-storage/package.json b/packages/ts/browser-storage/package.json new file mode 100644 index 0000000..b2772fd --- /dev/null +++ b/packages/ts/browser-storage/package.json @@ -0,0 +1,32 @@ +{ + "name": "@morph/browser-storage", + "version": "0.1.0", + "description": "Morph browser storage adapters — sessionStorage and localStorage", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch" + }, + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + }, + "sideEffects": false +} diff --git a/core/src/storage/browserStorage.ts b/packages/ts/browser-storage/src/browserStorage.ts similarity index 96% rename from core/src/storage/browserStorage.ts rename to packages/ts/browser-storage/src/browserStorage.ts index cc66000..25c63ba 100644 --- a/core/src/storage/browserStorage.ts +++ b/packages/ts/browser-storage/src/browserStorage.ts @@ -1,4 +1,4 @@ -import type { StorageProvider } from '../types.js'; +import type { StorageProvider } from '@morph/core'; function createBrowserStorageAdapter(store: Storage, prefix: string): StorageProvider { return { diff --git a/packages/ts/browser-storage/src/index.ts b/packages/ts/browser-storage/src/index.ts new file mode 100644 index 0000000..0cec44a --- /dev/null +++ b/packages/ts/browser-storage/src/index.ts @@ -0,0 +1,44 @@ +import type { MorphPlugin } from '@morph/core'; +import { createBrowserSessionStorage, createBrowserLocalStorage } from './browserStorage.js'; + +export type LogFn = (level: 'debug' | 'info' | 'warn' | 'error', message: string, error?: Error, context?: Record) => void; + +export interface BrowserStoragePluginOptions { + prefix?: string; + type?: 'session' | 'local'; + logger?: MorphPlugin | LogFn; +} + +function isMorphPlugin(s: unknown): s is MorphPlugin { + return typeof s === 'object' && s !== null && 'install' in s && typeof (s as MorphPlugin).install === 'function'; +} + +export function browserStoragePlugin(optsOrPrefix?: string | BrowserStoragePluginOptions, type?: 'session' | 'local'): MorphPlugin { + const resolved = typeof optsOrPrefix === 'string' + ? { prefix: optsOrPrefix, type: type ?? 'session' as const, logger: undefined as MorphPlugin | LogFn | undefined } + : { prefix: optsOrPrefix?.prefix ?? 'morph:tk:', type: optsOrPrefix?.type ?? 'session' as const, logger: optsOrPrefix?.logger }; + + return { + name: '@morph/browser-storage', + provides: ['storage'], + install(ctx) { + if (resolved.logger && isMorphPlugin(resolved.logger)) { + resolved.logger.install(ctx as Parameters[0]); + } else if (typeof resolved.logger === 'function') { + const prev = ctx.options.onLog; + ctx.options.onLog = (lvl, msg, err, context) => { + (resolved.logger as LogFn)(lvl, msg, err, context); + prev?.(lvl, msg, err, context); + }; + } + + const storage = resolved.type === 'local' + ? createBrowserLocalStorage(resolved.prefix) + : createBrowserSessionStorage(resolved.prefix); + ctx.provideStorage(storage); + ctx.options.onLog?.('debug', `Storage initialized (${resolved.type}Storage, prefix: "${resolved.prefix}")`, undefined, { plugin: '@morph/browser-storage' }); + }, + }; +} + +export { createBrowserSessionStorage, createBrowserLocalStorage } from './browserStorage.js'; diff --git a/core/tsconfig.json b/packages/ts/browser-storage/tsconfig.json similarity index 100% rename from core/tsconfig.json rename to packages/ts/browser-storage/tsconfig.json diff --git a/packages/ts/browser-storage/vite.config.ts b/packages/ts/browser-storage/vite.config.ts new file mode 100644 index 0000000..97a5248 --- /dev/null +++ b/packages/ts/browser-storage/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import { resolve } from 'node:path'; + +export default defineConfig({ + plugins: [ + dts({ + include: ['src'], + rollupTypes: false, + outDir: 'dist', + }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'MorphBrowserStorage', + fileName: 'index', + formats: ['es', 'cjs'], + }, + sourcemap: true, + emptyOutDir: true, + rollupOptions: { + external: ['@morph/core'], + }, + }, +}); diff --git a/core/package.json b/packages/ts/core/package.json similarity index 82% rename from core/package.json rename to packages/ts/core/package.json index 16a9c5e..c4f237d 100644 --- a/core/package.json +++ b/packages/ts/core/package.json @@ -1,7 +1,7 @@ { - "name": "morph-api-client", + "name": "@morph/core", "version": "0.1.0", - "description": "Morph API Client SDK (TypeScript)", + "description": "Morph API Client — core types, HTTP pipeline, and client facades", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/core/src/client/AuthHandle.ts b/packages/ts/core/src/client/AuthHandle.ts similarity index 95% rename from core/src/client/AuthHandle.ts rename to packages/ts/core/src/client/AuthHandle.ts index 7a0c7f5..f825f02 100644 --- a/core/src/client/AuthHandle.ts +++ b/packages/ts/core/src/client/AuthHandle.ts @@ -17,13 +17,14 @@ export class AuthHandle { await this.rt.tokens.submitCode(r.authId, r.ref, code, opts); } - async acquireWithClientCredentials(): Promise { + async acquire(): Promise { const r = this.rt.parseAuthRef(this.authId); - if (r.kind !== 'context') throw new Error('acquireWithClientCredentials requires a provider/context auth id'); + if (r.kind !== 'context') throw new Error('acquire requires a provider/context auth id'); this.rt.assertAlive(); await this.rt.tokens.acquireWithClientCredentials(r.authId, r.ref); } + async exchangeToken(targetAuthId: string): Promise { const r = this.rt.parseAuthRef(this.authId); if (r.kind !== 'context') throw new Error('exchangeToken requires a provider/context auth id as the source'); diff --git a/core/src/client/HostClient.ts b/packages/ts/core/src/client/HostClient.ts similarity index 100% rename from core/src/client/HostClient.ts rename to packages/ts/core/src/client/HostClient.ts diff --git a/core/src/client/MorphClient.ts b/packages/ts/core/src/client/MorphClient.ts similarity index 88% rename from core/src/client/MorphClient.ts rename to packages/ts/core/src/client/MorphClient.ts index def656e..7d13695 100644 --- a/core/src/client/MorphClient.ts +++ b/packages/ts/core/src/client/MorphClient.ts @@ -1,4 +1,4 @@ -import type { MorphConfig, MorphOptions, MorphProviderMeta, MorphTokenStatus, OAuthReturnResult } from '../types.js'; +import type { MorphConfig, MorphOptions, MorphProviderMeta, MorphTokenStatus, OAuthReturnResult, ProviderConfig, HostConfig } from '../types.js'; import { createRuntime, MorphRuntime } from '../runtime.js'; import { AuthHandle } from './AuthHandle.js'; import { HostClient } from './HostClient.js'; @@ -32,6 +32,18 @@ export class MorphClient { return this.rt.getTokenStatus(); } + /** Raw provider configs directly from `resolved.config.providers`. */ + getProviders(): ProviderConfig[] { + this.rt.assertAlive(); + return this.rt.resolved.config.providers; + } + + /** Raw host configs directly from `resolved.config.hosts`. */ + getHosts(): HostConfig[] { + this.rt.assertAlive(); + return this.rt.resolved.config.hosts; + } + /** Sanitized provider + contexts (no secrets). */ getProviderMeta(providerKey: string): MorphProviderMeta { this.rt.assertAlive(); diff --git a/core/src/config/interpolate.ts b/packages/ts/core/src/config/interpolate.ts similarity index 100% rename from core/src/config/interpolate.ts rename to packages/ts/core/src/config/interpolate.ts diff --git a/core/src/config/validate.ts b/packages/ts/core/src/config/validate.ts similarity index 96% rename from core/src/config/validate.ts rename to packages/ts/core/src/config/validate.ts index 9818cb8..a76e686 100644 --- a/core/src/config/validate.ts +++ b/packages/ts/core/src/config/validate.ts @@ -1,8 +1,8 @@ -import type { AuthContextConfig, HostConfig, MorphConfig, ProviderConfig } from '../types.js'; +import type { AuthContextConfig, CtxRef, HostConfig, MorphConfig, ProviderConfig } from '../types.js'; import { ConfigValidationError } from '../errors.js'; import { normalizeExchangeSources } from '../util/exchangeSources.js'; -export type CtxRef = { provider: ProviderConfig; context: AuthContextConfig }; +export type { CtxRef } from '../types.js'; export interface ResolvedMorphConfig { config: MorphConfig; diff --git a/core/src/errors.ts b/packages/ts/core/src/errors.ts similarity index 100% rename from core/src/errors.ts rename to packages/ts/core/src/errors.ts diff --git a/core/src/http/hostPipeline.ts b/packages/ts/core/src/http/hostPipeline.ts similarity index 98% rename from core/src/http/hostPipeline.ts rename to packages/ts/core/src/http/hostPipeline.ts index b800474..c330f28 100644 --- a/core/src/http/hostPipeline.ts +++ b/packages/ts/core/src/http/hostPipeline.ts @@ -1,10 +1,10 @@ import type { + AuthPlugin, HostConfig, MorphOptions, MorphResponse, } from '../types.js'; import type { ResolvedMorphConfig } from '../config/validate.js'; -import type { TokenLifecycle } from '../tokens/tokenLifecycle.js'; import { interpolateRecord } from '../config/interpolate.js'; import { resolveEndpoint } from '../util/url.js'; import { parseDurationMs } from '../util/duration.js'; @@ -25,7 +25,7 @@ export class HostPipeline { private readonly resolved: ResolvedMorphConfig, private readonly options: MorphOptions, private readonly variables: Record, - private readonly tokens: TokenLifecycle, + private readonly tokens: AuthPlugin, ) {} // ── Public ──────────────────────────────────────────────────────────── diff --git a/core/src/index.ts b/packages/ts/core/src/index.ts similarity index 85% rename from core/src/index.ts rename to packages/ts/core/src/index.ts index ed99ba1..d6cb83d 100644 --- a/core/src/index.ts +++ b/packages/ts/core/src/index.ts @@ -15,11 +15,20 @@ export type { MorphContextMeta, TokenExchangeGrant, StorageProvider, + StorageConfig, NetworkDelegate, NetworkConfig, + NetworkPolicy, HostConfig, ProviderConfig, AuthContextConfig, + CtxRef, + AuthPlugin, + AuthPluginFactory, + MorphPlugin, + MorphPluginContext, + DelegateMetadata, + InteractionMode, LogoutReason, MorphHttpTraceEvent, OAuthReturnResult, @@ -38,7 +47,7 @@ export { } from './errors.js'; export { validateAndIndexConfig } from './config/validate.js'; -export type { ResolvedMorphConfig, CtxRef } from './config/validate.js'; +export type { ResolvedMorphConfig } from './config/validate.js'; export { decodeJwtPayload, getJwtExpirySeconds, getJwtSubject } from './util/jwt.js'; export type { JwtPayload } from './util/jwt.js'; @@ -46,4 +55,3 @@ export { buildOAuth2AuthorizationUrl } from './util/oauthAuthorize.js'; export { stripOAuthReturnSearchParams, cleanOAuthReturnFromBrowser } from './util/oauthReturn.js'; export { encodeOAuthState, decodeOAuthState } from './util/oauthState.js'; export { normalizeLoopbackOrigin } from './util/normalizeOrigin.js'; -export { createBrowserSessionStorage, createBrowserLocalStorage } from './storage/browserStorage.js'; diff --git a/core/src/runtime.ts b/packages/ts/core/src/runtime.ts similarity index 74% rename from core/src/runtime.ts rename to packages/ts/core/src/runtime.ts index 7daace9..7155404 100644 --- a/core/src/runtime.ts +++ b/packages/ts/core/src/runtime.ts @@ -1,11 +1,15 @@ import type { + AuthPlugin, HostConfig, MorphConfig, MorphContextMeta, MorphOptions, + MorphPlugin, + MorphPluginContext, MorphProviderMeta, MorphTokenStatus, OAuthReturnResult, + StorageProvider, } from './types.js'; import type { ResolvedMorphConfig, CtxRef } from './config/validate.js'; import { validateAndIndexConfig } from './config/validate.js'; @@ -16,12 +20,103 @@ import { decodeJwtPayload } from './util/jwt.js'; import { normalizeExchangeSources } from './util/exchangeSources.js'; import { stripOAuthReturnSearchParams } from './util/oauthReturn.js'; import { encodeOAuthState, decodeOAuthState } from './util/oauthState.js'; -import { TokenLifecycle } from './tokens/tokenLifecycle.js'; import { HostPipeline } from './http/hostPipeline.js'; +function topoSortPlugins(plugins: MorphPlugin[]): MorphPlugin[] { + if (plugins.length <= 1) return [...plugins]; + + const capToProvider = new Map(); + for (const p of plugins) { + for (const cap of p.provides ?? []) { + capToProvider.set(cap, p); + } + } + + const adjOut = new Map>(); + const inDeg = new Map(); + for (const p of plugins) { + adjOut.set(p, new Set()); + inDeg.set(p, 0); + } + + for (const p of plugins) { + for (const req of p.requires ?? []) { + const provider = capToProvider.get(req); + if (!provider) { + throw new Error( + `Plugin '${p.name}' requires '${req}' but no plugin provides it. ` + + `Add a plugin with provides: ['${req}'] or pass the dependency via plugin options.`, + ); + } + if (provider === p) continue; + if (!adjOut.get(provider)!.has(p)) { + adjOut.get(provider)!.add(p); + inDeg.set(p, inDeg.get(p)! + 1); + } + } + } + + const queue: MorphPlugin[] = []; + for (const p of plugins) { + if (inDeg.get(p)! === 0) queue.push(p); + } + + const sorted: MorphPlugin[] = []; + while (queue.length > 0) { + const p = queue.shift()!; + sorted.push(p); + for (const dep of adjOut.get(p)!) { + const d = inDeg.get(dep)! - 1; + inDeg.set(dep, d); + if (d === 0) queue.push(dep); + } + } + + if (sorted.length !== plugins.length) { + const unsorted = plugins.filter((p) => !sorted.includes(p)).map((p) => p.name); + throw new Error(`Circular plugin dependency detected among: ${unsorted.join(', ')}`); + } + + return sorted; +} + +function installPlugins(plugins: MorphPlugin[], resolved: ResolvedMorphConfig, options: MorphOptions, variables: Record): { auth: AuthPlugin; storage: StorageProvider } { + const sorted = topoSortPlugins(plugins); + + let auth: AuthPlugin | undefined; + let storage: StorageProvider | undefined; + + const ctx: MorphPluginContext = { + resolved, + options, + variables, + provideAuth(a) { + if (auth) throw new Error('Multiple plugins called provideAuth(). Only one auth plugin is allowed.'); + auth = a; + options._resolvedAuth = a; + }, + provideStorage(s) { + if (storage) throw new Error('Multiple plugins called provideStorage(). Only one storage plugin is allowed.'); + storage = s; + options._resolvedStorage = s; + }, + }; + + for (const plugin of sorted) { + plugin.install(ctx); + } + + if (!auth) throw new Error('No plugin called provideAuth(). Add an auth plugin (e.g. oauth2Plugin()) to MorphOptions.plugins.'); + if (!storage) throw new Error('No plugin called provideStorage(). Add a storage plugin (e.g. browserStoragePlugin()) to MorphOptions.plugins.'); + + return { auth, storage }; +} + export class MorphRuntime { - readonly tokens: TokenLifecycle; + readonly tokens: AuthPlugin; + readonly storage: StorageProvider; readonly http: HostPipeline; + private readonly plugins: MorphPlugin[]; private disposed = false; constructor( @@ -29,7 +124,12 @@ export class MorphRuntime { readonly options: MorphOptions, private readonly variables: Record, ) { - this.tokens = new TokenLifecycle(resolved, options, variables, options.onLog); + const { auth, storage } = installPlugins(options.plugins, resolved, options, variables); + this.tokens = auth; + this.storage = storage; + this.plugins = options.plugins; + options._resolvedAuth = auth; + options._resolvedStorage = storage; this.http = new HostPipeline(resolved, options, variables, this.tokens); } @@ -39,6 +139,9 @@ export class MorphRuntime { dispose(): void { this.disposed = true; + for (const plugin of this.plugins) { + plugin.dispose?.(); + } this.tokens.dispose(); } @@ -159,6 +262,9 @@ export class MorphRuntime { key: p.key, type: p.type, baseUrl: p.baseUrl, authorizationBrowserBaseUrl: p.authorizationBrowserBaseUrl, tokenHttpBaseUrl: p.tokenHttpBaseUrl, + mtlsBaseUrl: p.mtlsBaseUrl + ? interpolateString(p.mtlsBaseUrl, this.variables) + : undefined, networkPolicy: p.networkPolicy, headers: p.headers, contexts, }; } @@ -191,9 +297,9 @@ export class MorphRuntime { const r = this.parseAuthRef(authId); if (r.kind !== 'context') throw new UnknownContextError(authId); const { provider: p, context: c } = r.ref; - const authz = c.authorization!; - const redirectUri = interpolateString(authz.redirectUri!.trim(), this.variables); - const clientId = interpolateString(c.clientId!.trim(), this.variables); + const contextAuthorization = c.authorization!; + const redirectUri = interpolateString(contextAuthorization.redirectUri!.trim(), this.variables); + const clientId = c.clientId!.trim(); const state = opts?.state ?? encodeOAuthState(authId); const browserBaseRaw = p.authorizationBrowserBaseUrl?.trim(); const authorizeBase = browserBaseRaw && browserBaseRaw.length > 0 @@ -201,9 +307,9 @@ export class MorphRuntime { : interpolateString(p.baseUrl.trim(), this.variables); return buildOAuth2AuthorizationUrl({ baseUrl: authorizeBase, - authorizationPath: interpolateString(authz.endpoint.trim(), this.variables), + authorizationPath: interpolateString(contextAuthorization.endpoint.trim(), this.variables), clientId, redirectUri, scopes: c.scopes, - responseType: authz.responseType, extraParams: authz.extraParams, state, + responseType: contextAuthorization.responseType, extraParams: contextAuthorization.extraParams, state, }); } diff --git a/core/src/types.ts b/packages/ts/core/src/types.ts similarity index 70% rename from core/src/types.ts rename to packages/ts/core/src/types.ts index 81a12cf..f5d09ba 100644 --- a/core/src/types.ts +++ b/packages/ts/core/src/types.ts @@ -56,6 +56,12 @@ export interface AuthContextConfig { }; token: { endpoint: string; + /** + * Custom OAuth `grant_type` sent to the token endpoint when calling `acquire`. + * Defaults to `client_credentials`. When set to a non-standard value (e.g. a proprietary URN), + * `MorphOptions.onCustomGrant` is invoked so the host app can supply additional body fields. + */ + grantType?: string; exchangeEndpoint?: string; /** * Auth id(s) whose access token may be exchanged (RFC 8693) for this context's tokens. @@ -80,6 +86,11 @@ export interface ProviderConfig { type: 'oauth2'; /** Canonical issuer / default base (supports `$variable` interpolation). Used when `tokenHttpBaseUrl` is unset or resolves empty after interpolation. */ baseUrl: string; + /** + * Content-Type used for token endpoint POST requests. + * Defaults to `'application/x-www-form-urlencoded'` when unset. + */ + contentType?: 'application/x-www-form-urlencoded' | 'application/json'; /** * If set (supports `$variable` interpolation), browser authorize redirects use this origin instead of `baseUrl`. * Use when `baseUrl` points at a same-origin dev proxy: the IdP login page must load from the real host so `/resources/...` @@ -91,6 +102,12 @@ export interface ProviderConfig { * instead of `baseUrl`. Use for same-origin CORS proxies (e.g. Vite `/__keycloak`) while keeping `baseUrl` as the real issuer. */ tokenHttpBaseUrl?: string; + /** + * Base URL for mTLS (mutual TLS) endpoints on this provider (supports `$variable` interpolation). + * Not used by the SDK internally — exposed via `getProviderMeta` so host apps can construct + * certificate-related request URLs (e.g. `/certificate/create`). + */ + mtlsBaseUrl?: string; networkPolicy?: NetworkPolicy; headers?: Record; contexts: AuthContextConfig[]; @@ -160,6 +177,8 @@ export interface MorphProviderMeta { baseUrl: string; authorizationBrowserBaseUrl?: string; tokenHttpBaseUrl?: string; + /** mTLS base URL as configured in {@link ProviderConfig.mtlsBaseUrl} (after `$variable` interpolation). */ + mtlsBaseUrl?: string; networkPolicy?: NetworkPolicy; headers?: Record; contexts: MorphContextMeta[]; @@ -214,6 +233,12 @@ export interface MorphCallbacks { onAuthRequired: (authId: string, metadata: DelegateMetadata) => void; onLogout: (authId: string, reason: LogoutReason) => void; onTokenChange?: (authId: string, tokens: TokenSet | null) => void; + onCustomGrant?: (params: { + authId: string; + grantType: string; + context: AuthContextConfig; + variables: Record; + }) => Promise | null>; } export interface ProxyConfig { @@ -246,12 +271,50 @@ export interface TokenExchangeGrant { refreshToken?: string; } +export type CtxRef = { provider: ProviderConfig; context: AuthContextConfig }; + +export interface AuthPlugin { + resolveAccessToken(authId: string, ref: CtxRef, mode: 'probe' | 'http'): Promise; + handle401Recovery(authId: string, ref: CtxRef): Promise; + fireAuthRequired(authId: string, ctx: AuthContextConfig): void; + submitCode(authId: string, ref: CtxRef, code: string, opts?: { codeVerifier?: string; redirectUriOverride?: string }): Promise; + acquireWithClientCredentials(authId: string, ref: CtxRef): Promise; + exchangeToken(sourceAuthId: string, sourceRef: CtxRef, targetAuthId: string): Promise; + setTokens(authId: string, ref: CtxRef, tokens: TokenSet): Promise; + clearTokens(authId: string, ref: CtxRef): Promise; + loadTokens(authId: string, ref: CtxRef): Promise; + logout(authId: string, ref: CtxRef, reason: LogoutReason): Promise; + logoutProvider(providerKey: string, reason: LogoutReason): Promise; + hasValidTokenContext(authId: string, ref: CtxRef): Promise; + hasValidTokenProvider(providerKey: string): Promise; + refreshTokensManual(authId: string, ref: CtxRef): Promise; + dispose(): void; +} + +export type AuthPluginFactory = (resolved: { config: MorphConfig; contextByAuthId: Map; contextsByProvider: Map; hostByKey: Map }, options: MorphOptions, variables: Record) => AuthPlugin; + +export interface MorphPluginContext { + resolved: { config: MorphConfig; contextByAuthId: Map; contextsByProvider: Map; hostByKey: Map }; + options: MorphOptions; + variables: Record; + provideAuth(auth: AuthPlugin): void; + provideStorage(storage: StorageProvider): void; +} + +export interface MorphPlugin { + name: string; + /** Capabilities this plugin registers (e.g. `['auth']`, `['storage']`). Used for topological sort. */ + provides?: string[]; + /** Capabilities this plugin needs from other plugins (e.g. `['storage']`). The runtime installs providers first. */ + requires?: string[]; + install(ctx: MorphPluginContext): void; + dispose?(): void; +} + export interface MorphOptions { - storage: StorageProvider; + plugins: MorphPlugin[]; variables?: Record; - callbacks: MorphCallbacks; networkDelegate?: NetworkDelegate; - onTokenExchange?: (grant: TokenExchangeGrant) => Promise; onSignPayload?: (payload: string, authId: string) => Promise; onDecryptResponse?: (encryptedBody: string, authId: string) => Promise; onLog?: ( @@ -260,18 +323,30 @@ export interface MorphOptions { error?: Error, context?: Record, ) => void; - /** - * After each host HTTP attempt (including a 401 refresh retry as a second event). - * Use for debug UIs; not a substitute for application logging — see {@link onLog}. - */ onHttpTrace?: (event: MorphHttpTraceEvent) => void; /** * When `clientAuth` is `private_key_jwt`, return a signed client assertion JWT for the token endpoint. * If omitted and `clientSecret` is present, client_secret is used instead (e.g. PoC Keycloak). */ onClientJwtAssertion?: (authId: string) => Promise; - /** When true, SDK automatically calls `acquireWithClientCredentials` for contexts with `interaction: 'non-interactive'` on `onAuthRequired`. Default false. */ + /** + * Called when `token.grantType` is set to a non-standard value and `acquire` is invoked. + * Return extra body fields to merge into the token request (e.g. proprietary claims). + * Return `null` to skip custom fields and let the SDK send only the standard fields. + */ + onCustomGrant?: (params: { + authId: string; + grantType: string; + context: AuthContextConfig; + variables: Record; + }) => Promise | null>; + /** When true, SDK automatically calls `acquire` for contexts with `interaction: 'non-interactive'` on `onAuthRequired`. Default false. */ autoAcquireNonInteractive?: boolean; + + /** @internal Resolved by plugin system during init. Do not set directly. */ + _resolvedAuth?: AuthPlugin; + /** @internal Resolved by plugin system during init. Do not set directly. */ + _resolvedStorage?: StorageProvider; } export interface HostRequestOptions { diff --git a/core/src/util/duration.ts b/packages/ts/core/src/util/duration.ts similarity index 100% rename from core/src/util/duration.ts rename to packages/ts/core/src/util/duration.ts diff --git a/core/src/util/exchangeSources.ts b/packages/ts/core/src/util/exchangeSources.ts similarity index 100% rename from core/src/util/exchangeSources.ts rename to packages/ts/core/src/util/exchangeSources.ts diff --git a/core/src/util/expiry.ts b/packages/ts/core/src/util/expiry.ts similarity index 100% rename from core/src/util/expiry.ts rename to packages/ts/core/src/util/expiry.ts diff --git a/core/src/util/httpTrace.ts b/packages/ts/core/src/util/httpTrace.ts similarity index 100% rename from core/src/util/httpTrace.ts rename to packages/ts/core/src/util/httpTrace.ts diff --git a/core/src/util/jwt.ts b/packages/ts/core/src/util/jwt.ts similarity index 100% rename from core/src/util/jwt.ts rename to packages/ts/core/src/util/jwt.ts diff --git a/core/src/util/normalizeOrigin.ts b/packages/ts/core/src/util/normalizeOrigin.ts similarity index 100% rename from core/src/util/normalizeOrigin.ts rename to packages/ts/core/src/util/normalizeOrigin.ts diff --git a/core/src/util/oauthAuthorize.ts b/packages/ts/core/src/util/oauthAuthorize.ts similarity index 100% rename from core/src/util/oauthAuthorize.ts rename to packages/ts/core/src/util/oauthAuthorize.ts diff --git a/core/src/util/oauthReturn.ts b/packages/ts/core/src/util/oauthReturn.ts similarity index 100% rename from core/src/util/oauthReturn.ts rename to packages/ts/core/src/util/oauthReturn.ts diff --git a/core/src/util/oauthState.ts b/packages/ts/core/src/util/oauthState.ts similarity index 100% rename from core/src/util/oauthState.ts rename to packages/ts/core/src/util/oauthState.ts diff --git a/core/src/util/url.ts b/packages/ts/core/src/util/url.ts similarity index 100% rename from core/src/util/url.ts rename to packages/ts/core/src/util/url.ts diff --git a/packages/ts/core/tsconfig.json b/packages/ts/core/tsconfig.json new file mode 100644 index 0000000..ebeffba --- /dev/null +++ b/packages/ts/core/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/core/vite.config.ts b/packages/ts/core/vite.config.ts similarity index 93% rename from core/vite.config.ts rename to packages/ts/core/vite.config.ts index 6edc78a..aaf75eb 100644 --- a/core/vite.config.ts +++ b/packages/ts/core/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ build: { lib: { entry: resolve(__dirname, 'src/index.ts'), - name: 'MorphApiClient', + name: 'MorphCore', fileName: 'index', formats: ['es', 'cjs'], }, diff --git a/packages/ts/logger/package.json b/packages/ts/logger/package.json new file mode 100644 index 0000000..ea805f1 --- /dev/null +++ b/packages/ts/logger/package.json @@ -0,0 +1,32 @@ +{ + "name": "@morph/logger", + "version": "0.1.0", + "description": "Morph logger plugin — structured logging and HTTP trace", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch" + }, + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + }, + "sideEffects": false +} diff --git a/packages/ts/logger/src/index.ts b/packages/ts/logger/src/index.ts new file mode 100644 index 0000000..34178eb --- /dev/null +++ b/packages/ts/logger/src/index.ts @@ -0,0 +1,69 @@ +import type { MorphPlugin, MorphHttpTraceEvent } from '@morph/core'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export type LogFn = (level: LogLevel, message: string, error?: Error, context?: Record) => void; + +export interface LoggerPluginOptions { + level?: LogLevel; + prefix?: string; + onLog?: LogFn; + onHttpTrace?: (event: MorphHttpTraceEvent) => void; + httpTrace?: boolean; +} + +const LEVEL_ORDER: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + +function defaultLog(prefix: string, minLevel: LogLevel): LogFn { + const min = LEVEL_ORDER[minLevel]; + return (level, message, error, context) => { + if (LEVEL_ORDER[level] < min) return; + const line = `${prefix}[${level}] ${message}`; + const args: unknown[] = []; + if (context) args.push(context); + if (error) args.push(error); + switch (level) { + case 'error': console.error(line, ...args); break; + case 'warn': console.warn(line, ...args); break; + case 'info': console.info(line, ...args); break; + default: console.debug(line, ...args); + } + }; +} + +function defaultHttpTrace(prefix: string) { + return (event: MorphHttpTraceEvent) => { + const status = event.networkError ? `ERR ${event.networkError}` : String(event.statusCode); + console.log(`${prefix}${event.method} ${event.path} → ${status} (${event.durationMs}ms)`); + }; +} + +export function createLogger(opts?: LoggerPluginOptions): LogFn { + return opts?.onLog ?? defaultLog(opts?.prefix ?? '[morph] ', opts?.level ?? 'info'); +} + +export function loggerPlugin(opts?: LoggerPluginOptions): MorphPlugin { + const httpTrace = opts?.httpTrace ?? true; + const logFn = createLogger(opts); + const traceFn = opts?.onHttpTrace ?? defaultHttpTrace(opts?.prefix ?? '[morph] '); + + return { + name: '@morph/logger', + provides: ['logger'], + install(ctx) { + const prevLog = ctx.options.onLog; + ctx.options.onLog = (lvl, msg, err, context) => { + logFn(lvl, msg, err, context); + prevLog?.(lvl, msg, err, context); + }; + + if (httpTrace) { + const prevTrace = ctx.options.onHttpTrace; + ctx.options.onHttpTrace = (event) => { + traceFn(event); + prevTrace?.(event); + }; + } + }, + }; +} diff --git a/packages/ts/logger/tsconfig.json b/packages/ts/logger/tsconfig.json new file mode 100644 index 0000000..ebeffba --- /dev/null +++ b/packages/ts/logger/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/ts/logger/vite.config.ts b/packages/ts/logger/vite.config.ts new file mode 100644 index 0000000..441a31c --- /dev/null +++ b/packages/ts/logger/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import { resolve } from 'node:path'; + +export default defineConfig({ + plugins: [ + dts({ + include: ['src'], + rollupTypes: false, + outDir: 'dist', + }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'MorphLogger', + fileName: 'index', + formats: ['es', 'cjs'], + }, + sourcemap: true, + emptyOutDir: true, + rollupOptions: { + external: ['@morph/core'], + }, + }, +}); diff --git a/packages/ts/oauth2/package.json b/packages/ts/oauth2/package.json new file mode 100644 index 0000000..47415dc --- /dev/null +++ b/packages/ts/oauth2/package.json @@ -0,0 +1,32 @@ +{ + "name": "@morph/oauth2", + "version": "0.1.0", + "description": "Morph OAuth2 plugin — token lifecycle, vault, and OAuth helpers", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch" + }, + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + }, + "sideEffects": false +} diff --git a/packages/ts/oauth2/src/index.ts b/packages/ts/oauth2/src/index.ts new file mode 100644 index 0000000..2b70a68 --- /dev/null +++ b/packages/ts/oauth2/src/index.ts @@ -0,0 +1,141 @@ +import type { + AuthPlugin, + DelegateMetadata, + LogoutReason, + MorphCallbacks, + MorphPlugin, + ResolvedMorphConfig, + StorageProvider, + TokenExchangeGrant, + TokenSet, +} from '@morph/core'; +import { TokenLifecycle } from './tokens/tokenLifecycle.js'; +import type { OAuth2TokenOptions } from './tokens/oauth2TokenOptions.js'; + +export type { OAuth2TokenOptions } from './tokens/oauth2TokenOptions.js'; + +export type LogFn = (level: 'debug' | 'info' | 'warn' | 'error', message: string, error?: Error, context?: Record) => void; + +/** + * Inputs handed to a custom {@link AuthPluginFactory} so it can build a tailored + * {@link AuthPlugin} (typically by extending {@link TokenLifecycle}). + */ +export interface AuthPluginFactoryDeps { + resolved: ResolvedMorphConfig; + tokenOpts: OAuth2TokenOptions; + variables: Record; + storage: StorageProvider; + log?: LogFn; +} + +/** + * Build a custom {@link AuthPlugin} for {@link oauth2Plugin}. When omitted, + * the default {@link TokenLifecycle} is used. + */ +export type AuthPluginFactory = (deps: AuthPluginFactoryDeps) => AuthPlugin; + +export interface OAuth2PluginOptions { + storage?: StorageProvider | MorphPlugin; + logger?: MorphPlugin | LogFn; + variables?: Record; + callbacks?: Partial; + onTokenExchange?: (grant: TokenExchangeGrant) => Promise; + onClientJwtAssertion?: (authId: string) => Promise; + autoAcquireNonInteractive?: boolean; + /** + * Provide a custom {@link AuthPlugin} implementation. Called once during install + * with the resolved config, token options, variables, storage, and log function. + * When omitted, the default {@link TokenLifecycle} is instantiated. + */ + authPlugin?: AuthPluginFactory; +} + +function isMorphPlugin(s: unknown): s is MorphPlugin { + return typeof s === 'object' && s !== null && 'install' in s && typeof (s as MorphPlugin).install === 'function'; +} + +function resolveLogFn(logger: MorphPlugin | LogFn | undefined, ctx: { options: { onLog?: LogFn } }): LogFn | undefined { + if (!logger) return ctx.options.onLog; + if (typeof logger === 'function') return logger; + if (isMorphPlugin(logger)) { + logger.install(ctx as Parameters[0]); + return ctx.options.onLog; + } + return ctx.options.onLog; +} + +export function oauth2Plugin(opts?: OAuth2PluginOptions): MorphPlugin { + const storageOpt = opts?.storage; + const hasInlineStoragePlugin = storageOpt && isMorphPlugin(storageOpt); + const hasDirectStorage = storageOpt && !isMorphPlugin(storageOpt); + + return { + name: '@morph/oauth2', + provides: ['auth'], + requires: (hasInlineStoragePlugin || hasDirectStorage) ? [] : ['storage'], + install(ctx) { + const log = resolveLogFn(opts?.logger, ctx); + + if (hasInlineStoragePlugin) { + (storageOpt as MorphPlugin).install(ctx); + } + + const storage = hasDirectStorage + ? (storageOpt as StorageProvider) + : ctx.options._resolvedStorage; + + if (!storage) throw new Error('OAuth2 plugin requires storage. Pass storage in options or add a storage plugin.'); + + const defaultOnAuthRequired = (authId: string, _metadata: DelegateMetadata): void => { + log?.('warn', `onAuthRequired: ${authId}`, undefined, { authId }); + }; + const defaultOnLogout = (authId: string, reason: LogoutReason): void => { + log?.('info', `onLogout: ${authId} (${reason})`, undefined, { authId, reason }); + }; + + const variables = ctx.variables; + const callbacks: MorphCallbacks = { + onAuthRequired: opts?.callbacks?.onAuthRequired ?? defaultOnAuthRequired, + onLogout: opts?.callbacks?.onLogout ?? defaultOnLogout, + onTokenChange: opts?.callbacks?.onTokenChange, + onCustomGrant: opts?.callbacks?.onCustomGrant, + }; + const tokenOpts: OAuth2TokenOptions = { + callbacks, + variables, + onTokenExchange: opts?.onTokenExchange, + onClientJwtAssertion: opts?.onClientJwtAssertion, + autoAcquireNonInteractive: opts?.autoAcquireNonInteractive, + onLog: log, + }; + const auth = opts?.authPlugin + ? opts.authPlugin({ resolved: ctx.resolved, tokenOpts, variables, storage, log }) + : new TokenLifecycle(ctx.resolved, tokenOpts, variables, tokenOpts.onLog, storage); + ctx.provideAuth(auth); + }, + }; +} + +export { TokenLifecycle } from './tokens/tokenLifecycle.js'; +export { TokenVault } from './tokens/tokenVault.js'; + +// Building blocks for hosts that provide a custom AuthPlugin (see +// AuthPluginFactory). These are the internal helpers TokenLifecycle is built +// from — exporting them lets a host-owned lifecycle (e.g. the +// vnext-client-workflow-manager sample's TokenManagerLifecycle) compile +// without duplicating them. +export { buildClientAuthFields, mergeHeaders, postTokenRequest } from './oauth/tokenHttp.js'; +export type { OAuthTokenResponse } from './oauth/tokenHttp.js'; +export { computeExpiresAt, isExpired } from './util/expiry.js'; +export { parseDurationMs } from './util/duration.js'; +export { resolveEndpoint } from './util/url.js'; +export { hasExchangeSources, normalizeExchangeSources } from './util/exchangeSources.js'; +export { interpolateString } from './util/interpolate.js'; +export { listAuthIdsForProvider } from './util/listAuthIds.js'; + +export type { AuthPlugin, ResolvedMorphConfig } from '@morph/core'; + +export { buildOAuth2AuthorizationUrl } from './util/oauthAuthorize.js'; +export { stripOAuthReturnSearchParams, cleanOAuthReturnFromBrowser } from './util/oauthReturn.js'; +export { encodeOAuthState, decodeOAuthState } from './util/oauthState.js'; +export { normalizeLoopbackOrigin } from './util/normalizeOrigin.js'; diff --git a/core/src/oauth/tokenHttp.ts b/packages/ts/oauth2/src/oauth/tokenHttp.ts similarity index 76% rename from core/src/oauth/tokenHttp.ts rename to packages/ts/oauth2/src/oauth/tokenHttp.ts index 79839d5..22a4aa0 100644 --- a/core/src/oauth/tokenHttp.ts +++ b/packages/ts/oauth2/src/oauth/tokenHttp.ts @@ -1,8 +1,8 @@ import { parseDurationMs } from '../util/duration.js'; import { resolveEndpoint } from '../util/url.js'; -import type { AuthContextConfig, MorphOptions, NetworkPolicy, ProviderConfig } from '../types.js'; -import { interpolateRecord, interpolateString } from '../config/interpolate.js'; -import { TokenEndpointError } from '../errors.js'; +import type { AuthContextConfig, NetworkPolicy, ProviderConfig } from '@morph/core'; +import { TokenEndpointError } from '@morph/core'; +import { interpolateRecord, interpolateString } from '../util/interpolate.js'; export interface OAuthTokenResponse { access_token: string; @@ -21,14 +21,24 @@ function mergeHeaders( return { ...base, ...extra }; } -/** Executes form POST to token endpoint with retries. */ +function policyFor(provider: ProviderConfig, ctx: AuthContextConfig): NetworkPolicy | undefined { + return ctx.networkPolicy ?? provider.networkPolicy; +} + +/** Executes a POST to the token endpoint with retries, reading policy and content-type from provider/ctx. */ export async function postTokenRequest( url: string, body: Record, headers: Record, - policy: NetworkPolicy | undefined, - log?: MorphOptions['onLog'], + provider: ProviderConfig, + ctx: AuthContextConfig, + log?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, error?: Error, context?: Record) => void, ): Promise { + + const contentType = provider.contentType ?? 'application/x-www-form-urlencoded'; + const isJson = contentType === 'application/json'; + + const policy = policyFor(provider, ctx); const timeoutMs = parseDurationMs(policy?.timeout, 30_000); const retries = policy?.retry?.count ?? 0; const delayMs = parseDurationMs(policy?.retry?.delay, 200); @@ -42,10 +52,10 @@ export async function postTokenRequest( const res = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Type': contentType, ...headers, }, - body: new URLSearchParams(body), + body: isJson ? JSON.stringify(body) : new URLSearchParams(body), signal, }); clearTimeout(timer); @@ -93,7 +103,7 @@ export async function buildClientAuthFields( authId: string, ctx: AuthContextConfig, variables: Record, - options: Pick, + options: { onClientJwtAssertion?: (authId: string) => Promise }, ): Promise> { const clientId = ctx.clientId ? interpolateString(ctx.clientId, variables, { key: ctx.key }) : ''; const out: Record = { client_id: clientId }; diff --git a/packages/ts/oauth2/src/tokens/oauth2TokenOptions.ts b/packages/ts/oauth2/src/tokens/oauth2TokenOptions.ts new file mode 100644 index 0000000..e605a2b --- /dev/null +++ b/packages/ts/oauth2/src/tokens/oauth2TokenOptions.ts @@ -0,0 +1,10 @@ +import type { MorphCallbacks, TokenExchangeGrant, TokenSet } from '@morph/core'; + +export interface OAuth2TokenOptions { + callbacks: MorphCallbacks; + variables: Record; + onTokenExchange?: (grant: TokenExchangeGrant) => Promise; + onClientJwtAssertion?: (authId: string) => Promise; + autoAcquireNonInteractive?: boolean; + onLog?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, error?: Error, context?: Record) => void; +} diff --git a/core/src/tokens/tokenLifecycle.ts b/packages/ts/oauth2/src/tokens/tokenLifecycle.ts similarity index 87% rename from core/src/tokens/tokenLifecycle.ts rename to packages/ts/oauth2/src/tokens/tokenLifecycle.ts index aadfa71..cdbc904 100644 --- a/core/src/tokens/tokenLifecycle.ts +++ b/packages/ts/oauth2/src/tokens/tokenLifecycle.ts @@ -1,14 +1,18 @@ import type { AuthContextConfig, + AuthPlugin, + CtxRef, LogoutReason, - MorphOptions, ProviderConfig, + StorageProvider, TokenExchangeGrant, TokenSet, -} from '../types.js'; -import type { ResolvedMorphConfig, CtxRef } from '../config/validate.js'; -import { listAuthIdsForProvider } from '../config/validate.js'; -import { interpolateString } from '../config/interpolate.js'; +} from '@morph/core'; +import type { ResolvedMorphConfig } from '@morph/core'; +import { AuthError, UnknownContextError } from '@morph/core'; +import type { OAuth2TokenOptions } from './oauth2TokenOptions.js'; + +export type { OAuth2TokenOptions } from './oauth2TokenOptions.js'; import { TokenVault } from './tokenVault.js'; import { buildClientAuthFields, @@ -19,24 +23,26 @@ import { import { computeExpiresAt, isExpired } from '../util/expiry.js'; import { parseDurationMs } from '../util/duration.js'; import { resolveEndpoint } from '../util/url.js'; -import { AuthError, UnknownContextError } from '../errors.js'; import { hasExchangeSources, normalizeExchangeSources } from '../util/exchangeSources.js'; +import { interpolateString } from '../util/interpolate.js'; +import { listAuthIdsForProvider } from '../util/listAuthIds.js'; const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'; const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; -export class TokenLifecycle { +export class TokenLifecycle implements AuthPlugin { readonly vault: TokenVault; private readonly locks = new Map>(); private readonly inflightSubmitCode = new Map>(); constructor( private readonly resolved: ResolvedMorphConfig, - private readonly options: MorphOptions, + private readonly opts: OAuth2TokenOptions, private readonly variables: Record, - private readonly log: MorphOptions['onLog'], + private readonly log: OAuth2TokenOptions['onLog'], + storage: StorageProvider, ) { - this.vault = new TokenVault(variables, options.storage); + this.vault = new TokenVault(variables, storage); } dispose(): void { @@ -55,10 +61,6 @@ export class TokenLifecycle { return next; } - private policyFor(provider: ProviderConfig, ctx: AuthContextConfig) { - return ctx.networkPolicy ?? provider.networkPolicy; - } - private providerTokenHttpBase(provider: ProviderConfig): string { const raw = provider.tokenHttpBaseUrl?.trim(); if (raw && raw.length > 0) { @@ -76,7 +78,7 @@ export class TokenLifecycle { private async persistAndNotify(authId: string, ref: CtxRef, set: TokenSet | null): Promise { if (set) await this.vault.save(authId, ref.provider, ref.context, set); else await this.vault.clear(authId, ref.provider, ref.context); - this.options.callbacks.onTokenChange?.(authId, set); + this.opts.callbacks.onTokenChange?.(authId, set); } async loadTokens(authId: string, ref: CtxRef): Promise { @@ -96,7 +98,7 @@ export class TokenLifecycle { const { provider, context: ctx } = ref; const useEx = grantType === TOKEN_EXCHANGE_GRANT && !!ctx.token.exchangeEndpoint; const url = tokenUrl(this.providerTokenHttpBase(provider), ctx, useEx, this.variables); - const clientFields = await buildClientAuthFields(authId, ctx, this.variables, this.options); + const clientFields = await buildClientAuthFields(authId, ctx, this.variables, this.opts); const headers = mergeHeaders(provider, ctx, this.variables); const body: Record = { ...clientFields, grant_type: grantType, ...extraFields }; if (grantType !== 'authorization_code' && ctx.audience) { @@ -104,10 +106,10 @@ export class TokenLifecycle { } if (ctx.scopes?.length) body.scope = ctx.scopes.join(' '); - const custom = await this.options.onTokenExchange?.(grantInfo); + const custom = await this.opts.onTokenExchange?.(grantInfo); if (custom) return custom; - const r = await postTokenRequest(url, body, headers, this.policyFor(provider, ctx), this.log); + const r = await postTokenRequest(url, body, headers, provider, ctx, this.log); return this.oauthToSet(ctx, r, preserveRefresh); } @@ -117,8 +119,21 @@ export class TokenLifecycle { { type: 'refresh_token', authId, refreshToken }, preserve); } - private fetchClientCredentialsSet(authId: string, ref: CtxRef): Promise { - return this.executeGrant(authId, ref, 'client_credentials', {}, + private async fetchClientCredentialsSet( + authId: string, + ref: CtxRef, + grantType = 'client_credentials', + ): Promise { + let extraFields: Record = {}; + if (grantType !== 'client_credentials' && this.opts.callbacks.onCustomGrant) { + extraFields = await this.opts.callbacks.onCustomGrant({ + authId, + grantType, + context: ref.context, + variables: this.variables, + }) ?? {}; + } + return this.executeGrant(authId, ref, grantType, extraFields, { type: 'client_credentials', authId }); } @@ -161,7 +176,8 @@ export class TokenLifecycle { async acquireWithClientCredentials(authId: string, ref: CtxRef): Promise { await this.withLock(authId, async () => { - const set = await this.fetchClientCredentialsSet(authId, ref); + const grantType = ref.context.token.grantType ?? 'client_credentials'; + const set = await this.fetchClientCredentialsSet(authId, ref, grantType); await this.persistAndNotify(authId, ref, set); this.doLog('info', 'Client credentials token stored', undefined, { authId }); }); @@ -189,7 +205,7 @@ export class TokenLifecycle { async clearTokens(authId: string, ref: CtxRef): Promise { await this.withLock(authId, async () => { await this.vault.clear(authId, ref.provider, ref.context); - this.options.callbacks.onTokenChange?.(authId, null); + this.opts.callbacks.onTokenChange?.(authId, null); }); } @@ -199,17 +215,17 @@ export class TokenLifecycle { if (ctx.logout?.endpoint) { try { const url = resolveEndpoint(this.providerTokenHttpBase(provider), ctx.logout.endpoint); - const clientFields = await buildClientAuthFields(authId, ctx, this.variables, this.options); + const clientFields = await buildClientAuthFields(authId, ctx, this.variables, this.opts); const headers = mergeHeaders(provider, ctx, this.variables); const body: Record = { ...clientFields }; if (set?.refreshToken) body.refresh_token = set.refreshToken; - await postTokenRequest(url, body, headers, this.policyFor(provider, ctx), this.log); + await postTokenRequest(url, body, headers, provider, ctx, this.log); } catch (e) { this.doLog('warn', 'Logout endpoint failed', e instanceof Error ? e : undefined, { authId }); } } await this.clearTokens(authId, ref); - this.options.callbacks.onLogout(authId, reason); + this.opts.callbacks.onLogout(authId, reason); } async logoutProvider(providerKey: string, reason: LogoutReason): Promise { @@ -239,11 +255,11 @@ export class TokenLifecycle { fireAuthRequired(authId: string, ctx: AuthContextConfig): void { const meta = ctx.delegateMetadata; if (!meta) { - this.options.callbacks.onAuthRequired(authId, { workflow: 'unknown', grantHint: 'unknown', interaction: 'interactive' }); + this.opts.callbacks.onAuthRequired(authId, { workflow: 'unknown', grantHint: 'unknown', interaction: 'interactive' }); return; } - this.options.callbacks.onAuthRequired(authId, meta); - if (this.options.autoAcquireNonInteractive && meta.interaction === 'non-interactive') { + this.opts.callbacks.onAuthRequired(authId, meta); + if (this.opts.autoAcquireNonInteractive && meta.interaction === 'non-interactive') { const ref = this.resolved.contextByAuthId.get(authId); if (ref) { this.acquireWithClientCredentials(authId, ref).catch((e) => { @@ -256,7 +272,7 @@ export class TokenLifecycle { private emitRefreshFailCallbacks(ref: CtxRef, authId: string, mode: 'probe' | 'http'): void { if (mode !== 'http') return; if (ref.context.recoveryPolicy?.onRefreshFail === 'delegate') this.fireAuthRequired(authId, ref.context); - this.options.callbacks.onLogout?.(authId, 'refresh_failed'); + this.opts.callbacks.onLogout?.(authId, 'refresh_failed'); } // ── 401 recovery (called by HostPipeline) ───────────────────────────── @@ -271,7 +287,7 @@ export class TokenLifecycle { this.doLog('info', 'Access token refreshed after 401', undefined, { authId }); } catch { await this.vault.clear(authId, ref.provider, ref.context); - this.options.callbacks.onTokenChange?.(authId, null); + this.opts.callbacks.onTokenChange?.(authId, null); this.emitRefreshFailCallbacks(ref, authId, 'http'); } } else { @@ -309,7 +325,7 @@ export class TokenLifecycle { } catch (e) { this.doLog('warn', 'Refresh failed', e instanceof Error ? e : undefined, { authId }); await this.vault.clear(authId, ref.provider, ref.context); - this.options.callbacks.onTokenChange?.(authId, null); + this.opts.callbacks.onTokenChange?.(authId, null); set = null; deferredRefreshFail = hasExchangeSources(ref.context.token); if (!deferredRefreshFail) { @@ -387,7 +403,7 @@ export class TokenLifecycle { } catch { /* try next source */ } } await this.vault.clear(authId, ref.provider, ref.context); - this.options.callbacks.onTokenChange?.(authId, null); + this.opts.callbacks.onTokenChange?.(authId, null); this.emitRefreshFailCallbacks(ref, authId, 'http'); throw e; } diff --git a/core/src/tokens/tokenVault.ts b/packages/ts/oauth2/src/tokens/tokenVault.ts similarity index 97% rename from core/src/tokens/tokenVault.ts rename to packages/ts/oauth2/src/tokens/tokenVault.ts index f9663df..029876f 100644 --- a/core/src/tokens/tokenVault.ts +++ b/packages/ts/oauth2/src/tokens/tokenVault.ts @@ -1,6 +1,6 @@ -import type { AuthContextConfig, ProviderConfig, StorageConfig, TokenSet } from '../types.js'; -import { interpolateString } from '../config/interpolate.js'; -import { getJwtSubject } from '../util/jwt.js'; +import type { AuthContextConfig, ProviderConfig, StorageConfig, TokenSet } from '@morph/core'; +import { getJwtSubject } from '@morph/core'; +import { interpolateString } from '../util/interpolate.js'; export interface PathManifest { accessKey: string; diff --git a/packages/ts/oauth2/src/util/duration.ts b/packages/ts/oauth2/src/util/duration.ts new file mode 100644 index 0000000..820beb8 --- /dev/null +++ b/packages/ts/oauth2/src/util/duration.ts @@ -0,0 +1,24 @@ +const UNIT_MS: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +/** + * Parses strings like "200ms", "10s", "5m", "30d" into milliseconds. + */ +export function parseDurationMs(input: string | undefined, fallbackMs?: number): number { + if (input === undefined || input === '') { + if (fallbackMs !== undefined) return fallbackMs; + throw new Error('Missing duration'); + } + const m = String(input).trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i); + if (!m) throw new Error(`Invalid duration: ${input}`); + const n = Number(m[1]); + const u = m[2].toLowerCase(); + const mult = UNIT_MS[u]; + if (mult === undefined) throw new Error(`Invalid duration unit: ${input}`); + return Math.round(n * mult); +} diff --git a/packages/ts/oauth2/src/util/exchangeSources.ts b/packages/ts/oauth2/src/util/exchangeSources.ts new file mode 100644 index 0000000..d27151f --- /dev/null +++ b/packages/ts/oauth2/src/util/exchangeSources.ts @@ -0,0 +1,17 @@ +import type { AuthContextConfig } from '@morph/core'; + +/** + * Normalizes `token.exchangeSource` to a non-empty auth id list. + * Supports legacy single string or array (multiple subject-token sources for this context). + */ +export function normalizeExchangeSources(token: AuthContextConfig['token']): string[] { + const ex = token.exchangeSource; + if (ex === undefined || ex === null) return []; + if (Array.isArray(ex)) return ex.map((s) => String(s).trim()).filter(Boolean); + if (typeof ex === 'string' && ex.trim()) return [ex.trim()]; + return []; +} + +export function hasExchangeSources(token: AuthContextConfig['token']): boolean { + return normalizeExchangeSources(token).length > 0; +} diff --git a/packages/ts/oauth2/src/util/expiry.ts b/packages/ts/oauth2/src/util/expiry.ts new file mode 100644 index 0000000..42dca3d --- /dev/null +++ b/packages/ts/oauth2/src/util/expiry.ts @@ -0,0 +1,38 @@ +import { decodeJwtPayload, getJwtExpirySeconds } from '@morph/core'; +import { parseDurationMs } from './duration.js'; + +/** + * Computes expiresAt (unix seconds) from token response and JWT. + * Applies maxTtl cap from `iat` (or now) when configured. + */ +export function computeExpiresAt( + accessToken: string, + expiresIn: number | undefined, + maxTtl?: string, +): number | undefined { + const fromJwt = getJwtExpirySeconds(accessToken); + const now = Math.floor(Date.now() / 1000); + let exp = fromJwt; + if (exp === undefined && expiresIn !== undefined) { + exp = now + expiresIn; + } + if (exp === undefined) return undefined; + if (maxTtl) { + let iat: number | undefined; + try { + iat = decodeJwtPayload(accessToken).iat as number | undefined; + } catch { + iat = undefined; + } + const issued = typeof iat === 'number' ? iat : now; + const cap = issued + Math.floor(parseDurationMs(maxTtl) / 1000); + exp = Math.min(exp, cap); + } + return exp; +} + +export function isExpired(expiresAt: number | undefined, skewSeconds: number): boolean { + if (expiresAt === undefined) return false; + const now = Math.floor(Date.now() / 1000); + return now >= expiresAt - skewSeconds; +} diff --git a/packages/ts/oauth2/src/util/interpolate.ts b/packages/ts/oauth2/src/util/interpolate.ts new file mode 100644 index 0000000..7581535 --- /dev/null +++ b/packages/ts/oauth2/src/util/interpolate.ts @@ -0,0 +1,28 @@ +/** + * Replaces `$variable` tokens in a string using the provided map (and optional extras). + */ +export function interpolateString( + template: string, + variables: Record, + extras?: Record, +): string { + const map = { ...variables, ...extras }; + return template.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name: string) => { + const v = map[name]; + if (v === undefined) throw new Error(`Missing variable: $${name} in "${template}"`); + return v; + }); +} + +export function interpolateRecord( + record: Record | undefined, + variables: Record, + extras?: Record, +): Record | undefined { + if (!record) return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(record)) { + out[k] = interpolateString(v, variables, extras); + } + return out; +} diff --git a/packages/ts/oauth2/src/util/listAuthIds.ts b/packages/ts/oauth2/src/util/listAuthIds.ts new file mode 100644 index 0000000..a85d09e --- /dev/null +++ b/packages/ts/oauth2/src/util/listAuthIds.ts @@ -0,0 +1,8 @@ +import type { ResolvedMorphConfig } from '@morph/core'; + +export function listAuthIdsForProvider(providerKey: string, resolved: ResolvedMorphConfig): string[] { + const ctxs = resolved.contextsByProvider.get(providerKey) ?? []; + const p = resolved.config.providers.find((x) => x.key === providerKey); + if (!p) return []; + return ctxs.map((c) => `${p.key}/${c.key}`); +} diff --git a/packages/ts/oauth2/src/util/normalizeOrigin.ts b/packages/ts/oauth2/src/util/normalizeOrigin.ts new file mode 100644 index 0000000..8913dfa --- /dev/null +++ b/packages/ts/oauth2/src/util/normalizeOrigin.ts @@ -0,0 +1,15 @@ +/** Normalize IPv6 loopback origins (e.g. `http://[::1]:5173`) to `http://localhost:PORT`. */ +export function normalizeLoopbackOrigin(origin: string): string { + try { + const u = new URL(origin); + const h = u.hostname; + const ipv6 = h === '::1' || h === '[::1]' || h.toLowerCase() === '::ffff:127.0.0.1'; + if (ipv6) { + const port = u.port || (u.protocol === 'https:' ? '443' : '80'); + return `${u.protocol}//localhost:${port}`; + } + } catch { + /* ignore */ + } + return origin; +} diff --git a/packages/ts/oauth2/src/util/oauthAuthorize.ts b/packages/ts/oauth2/src/util/oauthAuthorize.ts new file mode 100644 index 0000000..be5d2c0 --- /dev/null +++ b/packages/ts/oauth2/src/util/oauthAuthorize.ts @@ -0,0 +1,29 @@ +import { resolveEndpoint } from './url.js'; + +/** Build an OAuth 2.0 authorization endpoint URL (browser redirect). */ +export function buildOAuth2AuthorizationUrl(opts: { + /** Provider `baseUrl` from config. */ + baseUrl: string; + /** Context `authorization.endpoint` (path or absolute URL). */ + authorizationPath: string; + clientId: string; + redirectUri: string; + scopes?: string[]; + /** Default `code`. */ + responseType?: string; + /** Provider-specific query keys, e.g. Google `access_type`, `prompt`. */ + extraParams?: Record; + state: string; +}): string { + const u = resolveEndpoint(opts.baseUrl, opts.authorizationPath); + const params = new URLSearchParams(); + params.set('client_id', opts.clientId); + params.set('redirect_uri', opts.redirectUri); + params.set('response_type', opts.responseType ?? 'code'); + if (opts.scopes?.length) params.set('scope', opts.scopes.join(' ')); + params.set('state', opts.state); + for (const [k, v] of Object.entries(opts.extraParams ?? {})) { + if (v !== undefined && v !== '') params.set(k, v); + } + return `${u}?${params.toString()}`; +} diff --git a/packages/ts/oauth2/src/util/oauthReturn.ts b/packages/ts/oauth2/src/util/oauthReturn.ts new file mode 100644 index 0000000..59ad9b5 --- /dev/null +++ b/packages/ts/oauth2/src/util/oauthReturn.ts @@ -0,0 +1,26 @@ +const OAUTH_RETURN_PARAMS = [ + 'code', + 'state', + 'session_state', + 'iss', + 'scope', + 'error', + 'error_description', +] as const; + +/** Returns `pathname`, optional `?query`, and `hash` with common OAuth return query params removed (browser `history` not updated). */ +export function stripOAuthReturnSearchParams(href: string): string { + const url = new URL(href); + for (const k of OAUTH_RETURN_PARAMS) { + url.searchParams.delete(k); + } + const qs = url.searchParams.toString(); + return qs ? `${url.pathname}?${qs}${url.hash}` : `${url.pathname}${url.hash}`; +} + +/** Strip OAuth return params from the current browser URL and update history (no-op outside browser). */ +export function cleanOAuthReturnFromBrowser(): void { + if (typeof globalThis.window === 'undefined') return; + const w = globalThis.window; + w.history.replaceState({}, '', stripOAuthReturnSearchParams(w.location.href)); +} diff --git a/packages/ts/oauth2/src/util/oauthState.ts b/packages/ts/oauth2/src/util/oauthState.ts new file mode 100644 index 0000000..74e7266 --- /dev/null +++ b/packages/ts/oauth2/src/util/oauthState.ts @@ -0,0 +1,22 @@ +const STATE_PREFIX = 'morph1.'; + +/** Encode authId into OAuth state parameter (URL-safe base64). */ +export function encodeOAuthState(authId: string): string { + const payload = JSON.stringify({ a: authId, n: crypto.randomUUID() }); + const b64 = btoa(payload); + return `${STATE_PREFIX}${b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`; +} + +/** Decode authId from OAuth state. Returns null if state format is unrecognized. */ +export function decodeOAuthState(state: string): { authId: string } | null { + if (!state.startsWith(STATE_PREFIX)) return null; + try { + let b = state.slice(STATE_PREFIX.length).replace(/-/g, '+').replace(/_/g, '/'); + while (b.length % 4) b += '='; + const o = JSON.parse(atob(b)) as { a?: unknown }; + if (typeof o.a === 'string' && o.a.includes('/')) return { authId: o.a }; + } catch { + /* ignore */ + } + return null; +} diff --git a/packages/ts/oauth2/src/util/url.ts b/packages/ts/oauth2/src/util/url.ts new file mode 100644 index 0000000..f42b283 --- /dev/null +++ b/packages/ts/oauth2/src/util/url.ts @@ -0,0 +1,8 @@ +export function resolveEndpoint(baseUrl: string, endpoint: string): string { + if (/^https?:\/\//i.test(endpoint)) { + return endpoint; + } + const b = baseUrl.replace(/\/+$/, ''); + const e = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + return `${b}${e}`; +} diff --git a/packages/ts/oauth2/tsconfig.json b/packages/ts/oauth2/tsconfig.json new file mode 100644 index 0000000..ebeffba --- /dev/null +++ b/packages/ts/oauth2/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/ts/oauth2/vite.config.ts b/packages/ts/oauth2/vite.config.ts new file mode 100644 index 0000000..03ae824 --- /dev/null +++ b/packages/ts/oauth2/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import { resolve } from 'node:path'; + +export default defineConfig({ + plugins: [ + dts({ + include: ['src'], + rollupTypes: false, + outDir: 'dist', + }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'MorphOAuth2', + fileName: 'index', + formats: ['es', 'cjs'], + }, + sourcemap: true, + emptyOutDir: true, + rollupOptions: { + external: ['@morph/core'], + }, + }, +}); diff --git a/poc/flutter-poc/.gitignore b/poc/flutter-poc/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/poc/flutter-poc/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/poc/flutter-poc/.metadata b/poc/flutter-poc/.metadata new file mode 100644 index 0000000..2742288 --- /dev/null +++ b/poc/flutter-poc/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: android + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: ios + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/poc/flutter-poc/README.md b/poc/flutter-poc/README.md new file mode 100644 index 0000000..9097088 --- /dev/null +++ b/poc/flutter-poc/README.md @@ -0,0 +1,113 @@ +# Morph Flutter PoC + +Flutter sample app for the `morph-api-client` Dart SDK. Mirrors the +[TypeScript Vue PoC](../ts-vue) against the same Keycloak + mock-API backend. + +## Prerequisites + +The same backend stack as the TS PoC is required: + +1. **Keycloak** (port 8080) + + ```bash + cd ../keycloak + docker compose up -d + # First time only: + ./setup.sh + ``` + +2. **Mock API** (port 3000) + + ```bash + cd ../mock-api + npm install + npm start + ``` + +3. **Flutter SDK** ≥ 3.x (Dart ≥ 3.6) + +## Run + +```bash +cd poc/flutter-poc +flutter pub get +flutter run +``` + +Override client secrets (default values match the imported Keycloak realm): + +```bash +flutter run \ + --dart-define=DEVICE_CLIENT_SECRET=morph-device-secret \ + --dart-define=LOGIN_CLIENT_SECRET=morph-login-secret \ + --dart-define=SESSION_CLIENT_SECRET=morph-session-secret +``` + +## Features (parity with TS PoC) + +| Feature | TS Vue | Flutter | +|---------|--------|---------| +| Token status cards | ✅ | ✅ | +| Acquire device token | ✅ | ✅ | +| Login (2fa — browser OAuth) | ✅ | ✅ | +| Token exchange (2fa → 1fa) | ✅ | ✅ | +| Logout | ✅ | ✅ | +| Mock API call (GET /ping) | ✅ | ✅ | +| HTTP trace log | ✅ | ✅ | +| JWT claims bottom sheet | ✅ | ✅ | +| Google OAuth | optional | not wired | +| Persistent token storage | browser-storage | in-memory (PoC) | + +## OAuth login flow + +1. Tap **Login (2fa)** → app builds the Keycloak authorize URL via + `MorphRuntime.getAuthorizationUrl` and opens it in the system browser + using `url_launcher`. +2. User authenticates on Keycloak; Keycloak redirects to + `morphpoc://oauth/callback?code=…&state=…`. +3. `app_links` delivers the URI to the app. +4. `MorphRuntime.completeOAuthCallback` exchanges the code for tokens. +5. The home screen refreshes token status; a snack-bar shows the result. + +### Platform deep-link registration + +| Platform | Config | +|----------|--------| +| Android | `android:scheme="morphpoc"` intent-filter in `AndroidManifest.xml` | +| iOS | `CFBundleURLSchemes: [morphpoc]` in `Info.plist` | + +The redirect URI `morphpoc://oauth/callback` must be registered in +Keycloak → client → Valid Redirect URIs. + +### Android emulator networking + +On Android emulators `localhost` resolves to the emulator's own loopback +interface, not the host machine. The app detects `Platform.isAndroid` and +automatically substitutes `10.0.2.2` for all backend URLs (Keycloak, +mock-API). On physical devices and iOS simulators, `localhost` is used as-is. + +## Architecture + +``` +lib/ + morph_init.dart MorphClient singleton (mirrors poc/ts-vue/src/morph.ts) + main.dart MaterialApp + app_links deep-link listener + screens/ + home_screen.dart Token status, actions, mock API, HTTP trace + widgets/ + token_status_card.dart Per-authId status card + JWT claims sheet + http_trace_log.dart Expandable MorphHttpTraceEvent log +``` + +SDK packages consumed (all `path:` deps from `packages/dart/`): + +- `morph_core` — `MorphClient`, `MorphRuntime`, `HostPipeline` +- `morph_oauth2` — `oauth2Plugin`, `TokenLifecycle` +- `morph_logger` — `loggerPlugin` +- `morph_storage` — `memoryStoragePlugin` (in-memory, PoC only) + +## Token storage note + +The PoC uses `morph_storage`'s in-memory provider; tokens are lost on +app restart. Persistent storage via `morph-data-store` is tracked +separately and will replace this in a future milestone. diff --git a/poc/flutter-poc/analysis_options.yaml b/poc/flutter-poc/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/poc/flutter-poc/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/poc/flutter-poc/android/.gitignore b/poc/flutter-poc/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/poc/flutter-poc/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/poc/flutter-poc/android/app/build.gradle.kts b/poc/flutter-poc/android/app/build.gradle.kts new file mode 100644 index 0000000..2dcc8b2 --- /dev/null +++ b/poc/flutter-poc/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.burgantech.morph_flutter_poc" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.burgantech.morph_flutter_poc" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/poc/flutter-poc/android/app/src/debug/AndroidManifest.xml b/poc/flutter-poc/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/poc/flutter-poc/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/poc/flutter-poc/android/app/src/main/AndroidManifest.xml b/poc/flutter-poc/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..14efade --- /dev/null +++ b/poc/flutter-poc/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/poc/flutter-poc/android/app/src/main/kotlin/com/burgantech/morph_flutter_poc/MainActivity.kt b/poc/flutter-poc/android/app/src/main/kotlin/com/burgantech/morph_flutter_poc/MainActivity.kt new file mode 100644 index 0000000..e821e3c --- /dev/null +++ b/poc/flutter-poc/android/app/src/main/kotlin/com/burgantech/morph_flutter_poc/MainActivity.kt @@ -0,0 +1,5 @@ +package com.burgantech.morph_flutter_poc + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/poc/flutter-poc/android/app/src/main/res/drawable-v21/launch_background.xml b/poc/flutter-poc/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/poc/flutter-poc/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/poc/flutter-poc/android/app/src/main/res/drawable/launch_background.xml b/poc/flutter-poc/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/poc/flutter-poc/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/poc/flutter-poc/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/poc/flutter-poc/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/poc/flutter-poc/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/poc/flutter-poc/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/poc/flutter-poc/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/poc/flutter-poc/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/poc/flutter-poc/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/poc/flutter-poc/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/poc/flutter-poc/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/poc/flutter-poc/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/poc/flutter-poc/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/poc/flutter-poc/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/poc/flutter-poc/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/poc/flutter-poc/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/poc/flutter-poc/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/poc/flutter-poc/android/app/src/main/res/values-night/styles.xml b/poc/flutter-poc/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/poc/flutter-poc/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/poc/flutter-poc/android/app/src/main/res/values/styles.xml b/poc/flutter-poc/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/poc/flutter-poc/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/poc/flutter-poc/android/app/src/profile/AndroidManifest.xml b/poc/flutter-poc/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/poc/flutter-poc/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/poc/flutter-poc/android/build.gradle.kts b/poc/flutter-poc/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/poc/flutter-poc/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/poc/flutter-poc/android/gradle.properties b/poc/flutter-poc/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/poc/flutter-poc/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/poc/flutter-poc/android/gradle/wrapper/gradle-wrapper.properties b/poc/flutter-poc/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/poc/flutter-poc/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/poc/flutter-poc/android/settings.gradle.kts b/poc/flutter-poc/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/poc/flutter-poc/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/poc/flutter-poc/assets/poc-config.json b/poc/flutter-poc/assets/poc-config.json new file mode 100644 index 0000000..742d539 --- /dev/null +++ b/poc/flutter-poc/assets/poc-config.json @@ -0,0 +1,321 @@ +{ + "providers": [ + { + "key": "morph-auth", + "type": "oauth2", + "baseUrl": "$keycloakOidcBase", + "authorizationBrowserBaseUrl": "$keycloakBrowserBaseUrl", + "tokenHttpBaseUrl": "$pocKeycloakTokenHttpBase", + + "networkPolicy": { + "timeout": "10s", + "retry": { + "count": 2, + "delay": "200ms" + } + }, + + "headers": { + "X-Device-Id": "$deviceId", + "X-Installation-Id": "$installationId" + }, + + "contexts": [ + { + "key": "device", + + "clientId": "morph-device", + "clientSecret": "$deviceClientSecret", + "clientAuth": "private_key_jwt", + + "token": { + "endpoint": "/token" + }, + + "recoveryPolicy": { + "onUnauthorized": "delegate", + "onRefreshFail": "clear" + }, + + "delegateMetadata": { + "workflow": "device-auth", + "grantHint": "client_credentials", + "interaction": "non-interactive" + }, + + "refreshPolicy": { + "refreshBeforeExpiry": "5s" + }, + + "tokenTypes": { + "access": { + "header": { + "name": "Authorization", + "scheme": "Bearer" + }, + "expiryPolicy": "token", + "storage": { + "scope": "device", + "type": "persistent", + "protection": "secure", + "key": "device.access" + } + } + } + }, + + { + "key": "2fa", + + "clientId": "morph-login", + "clientSecret": "$loginClientSecret", + "audience": "morph-session", + + "identity": { + "subject": "sub", + "actor": "sub" + }, + + "authorization": { + "endpoint": "/auth", + "redirectUri": "$oauthCallbackUri" + }, + + "token": { + "endpoint": "/token" + }, + + "logout": { + "endpoint": "/logout" + }, + + "scopes": ["openid", "profile", "email"], + + "sessionPolicy": { + "logoutOnBackgroundAfter": "1m", + "logoutOnInactivityAfter": "5m" + }, + + "refreshPolicy": { + "strategy": "rotating", + "refreshBeforeExpiry": "10s" + }, + + "recoveryPolicy": { + "onUnauthorized": "refresh", + "onRefreshFail": "delegate" + }, + + "delegateMetadata": { + "workflow": "login", + "grantHint": "authorization_code", + "interaction": "interactive" + }, + + "tokenTypes": { + "access": { + "header": { + "name": "Authorization", + "scheme": "Bearer" + }, + "expiryPolicy": "token", + "storage": { + "scope": "session", + "type": "memory", + "protection": "secure", + "key": "2fa.access.$subject" + } + }, + "refresh": { + "expiryPolicy": "token", + "storage": { + "scope": "session", + "type": "memory", + "protection": "secure", + "key": "2fa.refresh.$subject" + } + } + } + }, + + { + "key": "1fa", + + "clientId": "morph-session", + "clientSecret": "$sessionClientSecret", + + "identity": { + "subject": "sub", + "actor": "sub" + }, + + "token": { + "endpoint": "/token", + "exchangeEndpoint": "/token", + "exchangeSource": ["morph-auth/2fa"] + }, + + "refreshPolicy": { + "strategy": "rotating", + "refreshBeforeExpiry": "8s" + }, + + "recoveryPolicy": { + "onUnauthorized": "refresh", + "onRefreshFail": "delegate" + }, + + "delegateMetadata": { + "workflow": "token-exchange", + "grantHint": "token_exchange", + "interaction": "interactive" + }, + + "tokenTypes": { + "access": { + "header": { + "name": "Authorization", + "scheme": "Bearer" + }, + "expiryPolicy": "token", + "maxTtl": "30d", + "storage": { + "scope": "user", + "type": "persistent", + "protection": "encrypted", + "key": "1fa.access.$subject" + } + }, + "refresh": { + "expiryPolicy": "token", + "maxTtl": "30d", + "storage": { + "scope": "user", + "type": "persistent", + "protection": "encrypted", + "key": "1fa.refresh.$subject" + } + } + } + } + ] + }, + + { + "key": "google-auth", + "type": "oauth2", + "baseUrl": "https://accounts.google.com", + "tokenHttpBaseUrl": "$pocGoogleTokenHttpBase", + + "headers": { + "X-Device-Id": "$deviceId", + "X-Installation-Id": "$installationId" + }, + + "contexts": [ + { + "key": "google", + + "clientId": "$googleClientId", + "clientSecret": "$googleClientSecret", + + "authorization": { + "endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "redirectUri": "$oauthCallbackUri", + "responseType": "code", + "extraParams": { + "access_type": "offline", + "prompt": "consent" + } + }, + + "token": { + "endpoint": "$pocGoogleTokenEndpoint" + }, + + "scopes": [ + "openid", + "profile", + "email" + ], + + "pkce": { + "codeChallengeMethod": "S256" + }, + + "networkPolicy": { + "timeout": "15s", + "retry": { + "count": 1, + "delay": "300ms" + } + }, + + "recoveryPolicy": { + "onUnauthorized": "delegate", + "onRefreshFail": "clear" + }, + + "delegateMetadata": { + "workflow": "google-login", + "grantHint": "authorization_code", + "interaction": "redirect" + }, + + "tokenTypes": { + "access": { + "format": "opaque", + "header": { + "name": "Authorization", + "scheme": "Bearer" + }, + "expiryPolicy": "token", + "storage": { + "scope": "session", + "type": "memory", + "protection": "secure", + "key": "google.access" + } + }, + "refresh": { + "format": "opaque", + "expiryPolicy": "token", + "storage": { + "scope": "session", + "type": "memory", + "protection": "secure", + "key": "google.refresh" + } + } + } + } + ] + } + ], + + "hosts": [ + { + "key": "main-api", + "baseUrl": "http://localhost:3000", + "headers": { + "X-Device-Id": "$deviceId", + "X-Installation-Id": "$installationId" + }, + "allowedAuth": [ + "morph-auth/device", + "morph-auth/1fa", + "morph-auth/2fa", + "google-auth/google" + ] + }, + { + "key": "google-api", + "baseUrl": "https://www.googleapis.com", + "allowedAuth": [ + "google-auth/google" + ] + } + ], + + "rootCallbackAuthId": "morph-auth/2fa" +} diff --git a/poc/flutter-poc/assets/poc-simulation.json b/poc/flutter-poc/assets/poc-simulation.json new file mode 100644 index 0000000..50432a0 --- /dev/null +++ b/poc/flutter-poc/assets/poc-simulation.json @@ -0,0 +1,107 @@ +{ + "version": 1, + "description": "PoC bundle simulator: ordered mock + Morph host steps. Edit to add POST bodies, paths, or conditional blocks.", + "mockApi": { + "baseUrl": "http://localhost:3000", + "envOverride": "VITE_MOCK_API_BASE" + }, + "sessionDeadCheck": { + "authIds": ["morph-auth/1fa", "morph-auth/2fa"], + "message": "Keycloak session is dead (refresh: invalid_grant / Token is not active). Simulation stopped — sign in again from Home." + }, + "steps": [ + { "type": "fetch", "label": "GET /public/config", "path": "/public/config" }, + { "type": "fetch", "label": "GET /health", "path": "/health" }, + { + "type": "host", + "label": "GET /profile (1fa)", + "hostKey": "main-api", + "method": "GET", + "path": "/profile", + "auth": "morph-auth/1fa" + }, + { + "type": "host", + "label": "GET /accounts (2fa)", + "hostKey": "main-api", + "method": "GET", + "path": "/accounts", + "auth": "morph-auth/2fa" + }, + { + "type": "host", + "label": "POST /transfers (2fa)", + "hostKey": "main-api", + "method": "POST", + "path": "/transfers", + "auth": "morph-auth/2fa", + "body": { + "fromAccount": "ACC-001", + "toAccount": "ACC-002", + "amount": 1, + "currency": "TRY" + } + }, + { + "type": "host", + "label": "GET /profile + per-call headers", + "hostKey": "main-api", + "method": "GET", + "path": "/profile", + "auth": "morph-auth/1fa", + "headers": { "X-Poc-Sim-Extra": "static-from-json" }, + "tickHeaderName": "X-Poc-Sim-Tick" + }, + { + "type": "host", + "label": "GET /public/config (device bearer)", + "hostKey": "main-api", + "method": "GET", + "path": "/public/config", + "auth": "morph-auth/device" + }, + { + "type": "logout_provider", + "label": "Logout morph-auth", + "providerKey": "morph-auth" + } + ], + "conditionalBlocks": [ + { + "id": "google_verify", + "when": { + "type": "all", + "all": [ + { "type": "provider_env_ready", "providerKey": "google-auth" }, + { "type": "has_valid_token", "authId": "google-auth/google" } + ] + }, + "skipRow": { + "label": "GET /identity/verify (Google)", + "detail": "No Google session — use “Google login” on Home first (AUTH here would be normal)." + }, + "steps": [ + { + "type": "host", + "label": "GET /identity/verify (Google)", + "hostKey": "main-api", + "method": "GET", + "path": "/identity/verify", + "auth": "google-auth/google" + } + ] + }, + { + "id": "probe_404", + "when": { "type": "ui_flag_probe_404" }, + "steps": [ + { + "type": "fetch", + "label": "GET /sim/not-found (expect 404)", + "path": "/sim/not-found", + "expectStatus": 404 + } + ] + } + ] +} diff --git a/poc/flutter-poc/ios/.gitignore b/poc/flutter-poc/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/poc/flutter-poc/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/poc/flutter-poc/ios/Flutter/AppFrameworkInfo.plist b/poc/flutter-poc/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/poc/flutter-poc/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/poc/flutter-poc/ios/Flutter/Debug.xcconfig b/poc/flutter-poc/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/poc/flutter-poc/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/poc/flutter-poc/ios/Flutter/Release.xcconfig b/poc/flutter-poc/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/poc/flutter-poc/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/poc/flutter-poc/ios/Podfile b/poc/flutter-poc/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/poc/flutter-poc/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/poc/flutter-poc/ios/Runner.xcodeproj/project.pbxproj b/poc/flutter-poc/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6740d4d --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,623 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = H745WZL9G5; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.burgantech.morphFlutterPoc; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.burgantech.morphFlutterPoc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.burgantech.morphFlutterPoc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.burgantech.morphFlutterPoc.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = H745WZL9G5; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.burgantech.morphFlutterPoc; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = H745WZL9G5; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.burgantech.morphFlutterPoc; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/poc/flutter-poc/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/poc/flutter-poc/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/poc/flutter-poc/ios/Runner.xcworkspace/contents.xcworkspacedata b/poc/flutter-poc/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/poc/flutter-poc/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/poc/flutter-poc/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/poc/flutter-poc/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/poc/flutter-poc/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/poc/flutter-poc/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/poc/flutter-poc/ios/Runner/AppDelegate.swift b/poc/flutter-poc/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/poc/flutter-poc/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/poc/flutter-poc/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/poc/flutter-poc/ios/Runner/Base.lproj/LaunchScreen.storyboard b/poc/flutter-poc/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/poc/flutter-poc/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/poc/flutter-poc/ios/Runner/Base.lproj/Main.storyboard b/poc/flutter-poc/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/poc/flutter-poc/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/poc/flutter-poc/ios/Runner/Info.plist b/poc/flutter-poc/ios/Runner/Info.plist new file mode 100644 index 0000000..2ef4a09 --- /dev/null +++ b/poc/flutter-poc/ios/Runner/Info.plist @@ -0,0 +1,83 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.burgantech.morphpoc + CFBundleURLSchemes + + morphpoc + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Morph Flutter Poc + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + morph_flutter_poc + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/poc/flutter-poc/ios/Runner/Runner-Bridging-Header.h b/poc/flutter-poc/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/poc/flutter-poc/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/poc/flutter-poc/ios/Runner/SceneDelegate.swift b/poc/flutter-poc/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/poc/flutter-poc/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/poc/flutter-poc/ios/RunnerTests/RunnerTests.swift b/poc/flutter-poc/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/poc/flutter-poc/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/poc/flutter-poc/lib/main.dart b/poc/flutter-poc/lib/main.dart new file mode 100644 index 0000000..5ea0964 --- /dev/null +++ b/poc/flutter-poc/lib/main.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +import 'morph_init.dart'; +import 'screens/home_screen.dart'; + +/// Captures the OAuth params from the URL **synchronously** before runApp, +/// so we can clear them from `Uri.base` and process them after the UI mounts. +({String? code, String? state})? _captureWebOAuthParams() { + if (!kIsWeb) return null; + final uri = Uri.base; + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + if (code == null || state == null) return null; + return (code: code, state: state); +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // ignore: avoid_print + print('[morph-poc] main() start — ${Uri.base}'); + + final pendingOAuth = _captureWebOAuthParams(); + // ignore: avoid_print + print('[morph-poc] OAuth params present? ${pendingOAuth != null}'); + + final morph = await initMorph(); + // ignore: avoid_print + print('[morph-poc] initMorph() done — runApp now'); + + // Render UI immediately; HomeScreen will process the OAuth callback in initState. + runApp(MorphPocApp(morph: morph, pendingOAuth: pendingOAuth)); + // ignore: avoid_print + print('[morph-poc] runApp returned'); +} + +class MorphPocApp extends StatefulWidget { + const MorphPocApp({super.key, required this.morph, this.pendingOAuth}); + + final MorphClient morph; + final ({String? code, String? state})? pendingOAuth; + + @override + State createState() => _MorphPocAppState(); +} + +class _MorphPocAppState extends State { + AppLinks? _appLinks; + StreamSubscription? _linkSubscription; + String? _pendingOAuthMessage; + final GlobalKey _homeKey = GlobalKey(); + + @override + void initState() { + super.initState(); + // ignore: avoid_print + print('[morph-poc] _MorphPocAppState.initState'); + if (!kIsWeb) { + _appLinks = AppLinks(); + _linkSubscription = _appLinks!.uriLinkStream.listen(_handleIncomingUri); + } else if (widget.pendingOAuth != null) { + // Process web OAuth callback AFTER the UI mounted, so a hang/error + // here does not prevent runApp from rendering. + WidgetsBinding.instance.addPostFrameCallback((_) { + _processPendingWebOAuth(); + }); + } + } + + Future _processPendingWebOAuth() async { + final p = widget.pendingOAuth!; + // ignore: avoid_print + print('[morph-poc] _processPendingWebOAuth: calling completeOAuthCallback…'); + try { + final result = await widget.morph.completeOAuthCallback( + code: p.code, + state: p.state, + ); + // ignore: avoid_print + print('[morph-poc] completeOAuthCallback DONE: ${result.status} / ${result.message}'); + if (mounted) { + setState(() => _pendingOAuthMessage = + 'OAuth complete: ${result.status}${result.message != null ? ' — ${result.message}' : ''}'); + // Trigger HomeScreen to re-read token status after OAuth completes. + await _homeKey.currentState?.refreshStatus(); + } + } catch (e, st) { + // ignore: avoid_print + print('[morph-poc] completeOAuthCallback THREW: $e\n$st'); + if (mounted) { + setState(() => _pendingOAuthMessage = 'OAuth callback error: $e'); + } + } + } + + /// Mobile/desktop: handles deep-link OAuth callbacks via app_links. + Future _handleIncomingUri(Uri uri) async { + if (!uri.toString().startsWith(kOAuthCallbackUri)) return; + + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + + if (code == null || state == null) { + setState(() => _pendingOAuthMessage = 'OAuth callback missing code/state'); + return; + } + + try { + final result = await widget.morph.runtime.completeOAuthCallback( + code: code, + state: state, + ); + setState(() => _pendingOAuthMessage = + 'OAuth complete: ${result.status}${result.message != null ? ' — ${result.message}' : ''}'); + } catch (e) { + setState(() => _pendingOAuthMessage = 'OAuth callback error: $e'); + } + } + + @override + void dispose() { + _linkSubscription?.cancel(); + widget.morph.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Morph Flutter PoC', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + home: Builder( + builder: (context) { + if (_pendingOAuthMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_pendingOAuthMessage!), + duration: const Duration(seconds: 4), + ), + ); + setState(() => _pendingOAuthMessage = null); + }); + } + return HomeScreen(key: _homeKey, morph: widget.morph); + }, + ), + ); + } +} diff --git a/poc/flutter-poc/lib/morph_init.dart b/poc/flutter-poc/lib/morph_init.dart new file mode 100644 index 0000000..b26da67 --- /dev/null +++ b/poc/flutter-poc/lib/morph_init.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:flutter/services.dart'; +import 'package:morph_core/morph_core.dart'; +import 'package:morph_core_storage/morph_core_storage.dart'; +import 'package:morph_data_store/morph_data_store.dart'; +import 'package:morph_logger/create_logger.dart'; +import 'package:morph_logger/logger_plugin.dart'; +import 'package:morph_oauth2/morph_oauth2.dart'; +import 'package:morph_storage/memory_storage_plugin.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Deep-link URI scheme used by the Flutter PoC on mobile/desktop. +const String kOAuthCallbackUri = 'morphpoc://oauth/callback'; + +/// Returns the mock API base URL for the current platform. +/// Android emulators use 10.0.2.2 to reach the host machine's localhost. +String getMockApiBase() { + final host = (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + ? '10.0.2.2' + : 'localhost'; + return 'http://$host:3000'; +} + +/// Redirect URI used when running as a Flutter web app on Chrome. +/// Must be registered in Keycloak's morph-login client redirectUris. +const String kWebOAuthCallbackUri = 'http://localhost:4200/'; + +/// Prefix for SharedPreferences keys. +const String _kPrefsPrefix = 'morph-poc:'; + +/// Log events collected from SDK; consumed by the UI. +final List morphLogLines = []; + +/// HTTP trace events collected via [MorphOptions.onHttpTrace]; consumed by the UI. +final List morphHttpTraces = []; + +/// Shared log appender — writes to browser console AND keeps the in-memory +/// ring buffer (consumed by UI widgets). +// ignore: avoid_print +void _appendLog(String level, String message, [Object? error]) { + final entry = '[morph][$level] $message${error != null ? ' — $error' : ''}'; + // ignore: avoid_print + print(entry); + morphLogLines.add(entry); + if (morphLogLines.length > 300) morphLogLines.removeAt(0); +} + +/// Initializes and returns the [MorphClient] singleton. +/// +/// Call once from [main] after [WidgetsFlutterBinding.ensureInitialized]. +Future initMorph() async { + final config = await _loadConfig(); + final variables = await _buildVariables(); + + final logger = loggerPlugin(const LoggerPluginOptions(level: 'debug')); + + // Storage strategy: + // • Web: in-memory only. ContextStore requires an active user identity + // (Boundary.user) to build storage keys, but on web the OAuth redirect + // reloads the page — so the identity is never set before the token write. + // In-memory storage is correct here: completeOAuthCallback() runs in main() + // before runApp(), so the token lives in the same JS heap the app reads from. + // • Native: ContextStore (persistent, boundary-scoped). Deep-link callbacks + // do NOT reload the process, so identity can be set before token storage. + MorphPlugin storagePlugin; + if (kIsWeb) { + storagePlugin = memoryStorageMorphPlugin(); + _appendLog('info', 'Storage: in-memory (web — ContextStore requires user identity)'); + } else { + try { + final contextStore = await ContextStore.create(ContextStoreOptions( + onRequestServerTime: (_, __) async => null, + timeServerUrls: [], + onLog: (level, message, [err, ctx]) => + _appendLog(level.name, message, err), + )); + storagePlugin = contextStoreStoragePlugin(contextStore); + _appendLog('info', 'Storage: ContextStore (persistent)'); + } catch (e) { + storagePlugin = memoryStorageMorphPlugin(); + _appendLog('warn', 'Storage: fallback to in-memory — ContextStore init failed', e); + } + } + + final options = MorphOptions( + plugins: [ + logger, + storagePlugin, + oauth2Plugin( + OAuth2PluginOptions( + logger: logger, + variables: variables, + autoAcquireNonInteractive: true, + ), + ), + ], + variables: variables, + onLog: (level, message, [error, context]) => + _appendLog(level, message, error), + onHttpTrace: (event) { + morphHttpTraces.insert(0, event); + if (morphHttpTraces.length > 100) morphHttpTraces.removeLast(); + }, + ); + + return MorphClient.init(config, options); +} + +Future _loadConfig() async { + final raw = await rootBundle.loadString('assets/poc-config.json'); + return jsonDecode(raw); +} + +Future> _buildVariables() async { + final prefs = await SharedPreferences.getInstance(); + + final deviceId = _readOrCreate(prefs, '${_kPrefsPrefix}device-id'); + final installationId = _readOrCreate(prefs, '${_kPrefsPrefix}install-id'); + + // On Android emulators, localhost refers to the emulator's own loopback. + // Use 10.0.2.2 to reach the host machine's localhost instead. + // On web (Chrome), localhost is always correct since the browser runs on the host. + final host = (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + ? '10.0.2.2' + : 'localhost'; + final keycloakBase = + 'http://$host:8080/realms/morph/protocol/openid-connect'; + final mockApiBase = 'http://$host:3000'; + + return { + 'deviceId': deviceId, + 'installationId': installationId, + // Client secrets — override via --dart-define at build/run time. + 'deviceClientSecret': const String.fromEnvironment( + 'DEVICE_CLIENT_SECRET', + defaultValue: 'morph-device-secret', + ), + 'loginClientSecret': const String.fromEnvironment( + 'LOGIN_CLIENT_SECRET', + defaultValue: 'morph-login-secret', + ), + 'sessionClientSecret': const String.fromEnvironment( + 'SESSION_CLIENT_SECRET', + defaultValue: 'morph-session-secret', + ), + // Keycloak — direct HTTP (no CORS proxy needed on mobile/desktop). + 'keycloakOidcBase': keycloakBase, + 'keycloakBrowserBaseUrl': keycloakBase, + 'pocKeycloakTokenHttpBase': '', + 'mockApiBase': mockApiBase, + // Google (disabled by default in the PoC). + 'pocGoogleTokenHttpBase': '', + 'pocGoogleTokenEndpoint': 'https://oauth2.googleapis.com/token', + 'googleClientId': const String.fromEnvironment('GOOGLE_CLIENT_ID'), + 'googleClientSecret': const String.fromEnvironment('GOOGLE_CLIENT_SECRET'), + // OAuth redirect — HTTP redirect on web, custom scheme on mobile/desktop. + 'oauthCallbackUri': kIsWeb ? kWebOAuthCallbackUri : kOAuthCallbackUri, + }; +} + +String _readOrCreate(SharedPreferences prefs, String key) { + var value = prefs.getString(key); + if (value == null || value.isEmpty) { + value = + '${DateTime.now().millisecondsSinceEpoch}-${key.hashCode.abs()}'; + prefs.setString(key, value); + } + return value; +} diff --git a/poc/flutter-poc/lib/poc_simulation.dart b/poc/flutter-poc/lib/poc_simulation.dart new file mode 100644 index 0000000..e3a9d48 --- /dev/null +++ b/poc/flutter-poc/lib/poc_simulation.dart @@ -0,0 +1,302 @@ +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'; + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +sealed class PocSimStep { + const PocSimStep({required this.label}); + final String label; +} + +final class PocSimFetchStep extends PocSimStep { + const PocSimFetchStep({ + required super.label, + required this.path, + this.expectStatus, + this.skipInAutoSim = false, + }); + final String path; + final int? expectStatus; + final bool skipInAutoSim; +} + +final class PocSimHostStep extends PocSimStep { + const PocSimHostStep({ + required super.label, + required this.hostKey, + required this.method, + required this.path, + required this.auth, + this.body, + this.headers, + this.skipInAutoSim = false, + }); + final String hostKey; + final String method; + final String path; + final String auth; + final Object? body; + final Map? headers; + final bool skipInAutoSim; +} + +final class PocSimLogoutStep extends PocSimStep { + const PocSimLogoutStep({ + required super.label, + required this.providerKey, + }); + final String providerKey; +} + +final class PocSimStepResult { + const PocSimStepResult({ + required this.label, + required this.status, + this.detail, + this.body, + }); + final String label; + + /// HTTP status code, or one of: 'OK', 'ERR', 'NET', 'AUTH' + final Object status; + final String? detail; + final Object? body; + + bool get isError { + if (status is int) return (status as int) >= 400; + return status == 'ERR' || status == 'NET' || status == 'AUTH'; + } +} + +/// 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 sessionDeadAuthIds, +}) { + if (result.status != 'AUTH') return false; + final isSessionDead = + step is PocSimHostStep && sessionDeadAuthIds.contains(step.auth); + final detail = result.detail ?? ''; + return isSessionDead && + (detail.contains('invalid_grant') || + detail.contains('Token is not active')); +} + +// --------------------------------------------------------------------------- +// Config loader +// --------------------------------------------------------------------------- + +class PocSimulationConfig { + PocSimulationConfig({ + required this.mockApiBaseUrl, + required this.steps, + required this.sessionDeadAuthIds, + required this.sessionDeadMessage, + }); + + final String mockApiBaseUrl; + final List steps; + final List sessionDeadAuthIds; + final String sessionDeadMessage; +} + +Future 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; + + final mockApi = (json['mockApi'] as Map?) ?? {}; + final baseUrl = (mockApi['baseUrl'] as String?) ?? mockApiFallback; + + final sessionDeadCheck = + (json['sessionDeadCheck'] as Map?) ?? {}; + final sessionDeadAuthIds = (sessionDeadCheck['authIds'] as List?) + ?.cast() ?? + const []; + final sessionDeadMessage = + (sessionDeadCheck['message'] as String?) ?? 'Session expired.'; + + final steps = []; + for (final raw in (json['steps'] as List? ?? [])) { + final s = _parseStep(raw as Map); + if (s != null) steps.add(s); + } + + // Conditional blocks (skip Google, include 404 probe as optional) + for (final block + in (json['conditionalBlocks'] as List? ?? [])) { + final b = block as Map; + final id = b['id'] as String? ?? ''; + // 404 probe included without condition check — SimulationPanel handles enable/disable + if (id == 'probe_404') { + for (final raw in (b['steps'] as List? ?? [])) { + final s = _parseStep(raw as Map); + if (s != null) steps.add(s); + } + } + // Google block: skip (not configured in the PoC env) + } + + return PocSimulationConfig( + mockApiBaseUrl: baseUrl, + steps: steps, + sessionDeadAuthIds: sessionDeadAuthIds, + sessionDeadMessage: sessionDeadMessage, + ); +} + +PocSimStep? _parseStep(Map m) { + final type = m['type'] as String?; + final label = m['label'] as String? ?? ''; + switch (type) { + case 'fetch': + return PocSimFetchStep( + label: label, + path: m['path'] as String? ?? '', + expectStatus: m['expectStatus'] as int?, + skipInAutoSim: m['skipInAutoSim'] as bool? ?? false, + ); + case 'host': + final rawHeaders = m['headers'] as Map?; + return PocSimHostStep( + label: label, + hostKey: m['hostKey'] as String? ?? 'main-api', + method: (m['method'] as String? ?? 'GET').toUpperCase(), + path: m['path'] as String? ?? '', + auth: m['auth'] as String? ?? '', + body: m['body'], + headers: rawHeaders?.cast(), + skipInAutoSim: m['skipInAutoSim'] as bool? ?? false, + ); + case 'logout_provider': + return PocSimLogoutStep( + label: label, + providerKey: m['providerKey'] as String? ?? '', + ); + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Step executor +// --------------------------------------------------------------------------- + +Future runPocSimStep( + MorphClient morph, + PocSimulationConfig cfg, + PocSimStep step, [ + http.Client? httpClient, +]) async { + return switch (step) { + PocSimFetchStep s => _runFetch(s, cfg.mockApiBaseUrl, httpClient), + PocSimHostStep s => _runHost(morph, s), + PocSimLogoutStep s => _runLogout(morph, s), + }; +} + +Future _runFetch( + 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 client + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 10)); + final expectedStatus = step.expectStatus; + final ok = expectedStatus == null + ? res.statusCode < 400 + : res.statusCode == expectedStatus; + Object? body; + try { + body = jsonDecode(res.body); + } catch (_) { + body = res.body; + } + return PocSimStepResult( + label: step.label, + status: res.statusCode, + detail: ok ? null : 'Unexpected status ${res.statusCode}', + body: body, + ); + } 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 runPocSimFetchForTesting( + PocSimFetchStep step, + String mockApiBase, + http.Client httpClient, +) => + _runFetch(step, mockApiBase, httpClient); + +Future _runHost( + MorphClient morph, PocSimHostStep step) async { + try { + final host = morph.runtime.getHost(step.hostKey); + final res = await morph.runtime.http.hostFetch( + host, + step.path, + method: step.method, + body: step.body != null ? jsonEncode(step.body) : null, + auth: step.auth, + headers: step.headers, + ); + return PocSimStepResult( + label: step.label, + status: res.statusCode, + body: res.body, + ); + } catch (e) { + // PoC: morph_core/hostFetch errors are surfaced as opaque strings until + // typed error parity exists; keep heuristic aligned with Gemini review (#28). + final msg = e.toString(); + final isAuth = msg.contains('401') || + msg.contains('403') || + msg.contains('Unauthorized') || + msg.contains('invalid_grant'); + return PocSimStepResult( + label: step.label, + status: isAuth ? 'AUTH' : 'ERR', + detail: msg, + ); + } +} + +Future _runLogout( + MorphClient morph, PocSimLogoutStep step) async { + try { + await morph.auth(step.providerKey).logout(); + return PocSimStepResult( + label: step.label, + status: 'OK', + detail: 'Logged out ${step.providerKey}', + ); + } catch (e) { + return PocSimStepResult( + label: step.label, status: 'ERR', detail: e.toString()); + } +} diff --git a/poc/flutter-poc/lib/screens/home_screen.dart b/poc/flutter-poc/lib/screens/home_screen.dart new file mode 100644 index 0000000..30a2b9f --- /dev/null +++ b/poc/flutter-poc/lib/screens/home_screen.dart @@ -0,0 +1,624 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../morph_init.dart'; +import '../poc_simulation.dart'; +import '../widgets/mock_api_sheet.dart'; +import '../widgets/provider_config_sheet.dart'; +import '../widgets/simulation_panel.dart'; +import '../widgets/token_status_card.dart'; + + +const _kStatusLabels = { + 'morph-auth/device': 'Device', + 'morph-auth/2fa': 'Login (2fa)', + 'morph-auth/1fa': 'Session (1fa)', + 'google-auth/google': 'Google', +}; + +String _labelFor(MorphTokenStatus s) => + _kStatusLabels[s.authId] ?? s.authId; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key, required this.morph}); + + final MorphClient morph; + + @override + State createState() => HomeScreenState(); +} + +class HomeScreenState extends State { + /// Public entrypoint so the parent app can request a refresh after OAuth. + Future refreshStatus() => _refreshStatus(); + + List _tokenStatus = []; + String _message = ''; + bool _busy = false; + PocSimulationConfig? _simCfg; + + MorphClient get _morph => widget.morph; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + try { + print('[morph-poc] _init start'); + await _refreshStatus(); + print('[morph-poc] _init: loading sim config…'); + final cfg = await loadPocSimulation(getMockApiBase()); + print('[morph-poc] _init: sim config loaded (${cfg.steps.length} steps)'); + if (mounted) setState(() => _simCfg = cfg); + } catch (e, st) { + print('[morph-poc] _init THREW: $e\n$st'); + if (mounted) setState(() => _message = 'Init error: $e'); + } + } + + Future _refreshStatus() async { + try { + print('[morph-poc] _refreshStatus start'); + final status = await _morph.getTokenStatus(); + print('[morph-poc] _refreshStatus: ${status.length} entries — ${status.map((s) => "${s.authId}:${s.hasAccessToken ? 'token' : 'none'}").join(", ")}'); + if (mounted) setState(() => _tokenStatus = status); + } catch (e, st) { + print('[morph-poc] _refreshStatus THREW: $e\n$st'); + _setMessage('Error refreshing status: $e'); + } + } + + void _setMessage(String msg) { + if (mounted) setState(() => _message = msg); + } + + void _setBusy(bool v) { + if (mounted) setState(() => _busy = v); + } + + // ── Per-row dynamic actions (mirrors TS buildActionsForRow) ─────────────── + + List<_ContextAction> _actionsForRow(MorphTokenStatus row) { + final actions = <_ContextAction>[]; + final gh = row.grantHint; + + if (gh == 'client_credentials') { + actions.add(_ContextAction( + label: 'Acquire token', + onPressed: _busy ? null : () => _runAcquire(row.authId), + )); + } + if (gh == 'authorization_code') { + if (row.providerKey == 'morph-auth') { + actions.add(_ContextAction( + label: 'Keycloak login', + onPressed: _busy ? null : _startLogin, + )); + } + // Google: show disabled button with hint + if (row.providerKey == 'google-auth') { + actions.add(_ContextAction( + label: 'Google login', + onPressed: null, + tooltip: 'Configure GOOGLE_CLIENT_ID env (not set in PoC)', + )); + } + } + if (row.hasAccessToken || row.hasRefreshToken) { + actions.add(_ContextAction( + label: 'Logout', + danger: true, + onPressed: _busy ? null : () => _runLogout(row.authId), + )); + } + return actions; + } + + // ── Auth actions ────────────────────────────────────────────────────────── + + Future _runAcquire(String authId) async { + _setBusy(true); + _setMessage(''); + try { + await _morph.auth(authId).acquireWithClientCredentials(); + _setMessage('Token acquired ($authId).'); + } catch (e) { + _setMessage('Acquire failed: $e'); + } finally { + _setBusy(false); + await _refreshStatus(); + } + } + + Future _startLogin() async { + const authId = 'morph-auth/2fa'; + _setMessage(''); + try { + final url = _morph.getAuthorizationUrl(authId); + if (kIsWeb) { + // On web: navigate the CURRENT tab so Keycloak redirects back here + // with ?code=...&state=... — handled in main() before runApp. + // webOnlyWindowName: '_self' replaces the current tab instead of opening a new one. + await launchUrl(Uri.parse(url), webOnlyWindowName: '_self'); + } else { + if (!await launchUrl(Uri.parse(url), + mode: LaunchMode.externalApplication)) { + _setMessage('Could not open browser for login.'); + } + } + } catch (e) { + _setMessage('Login failed: $e'); + } + } + + Future _runLogout(String authId) async { + _setBusy(true); + _setMessage(''); + try { + await _morph.auth(authId).logout(); + _setMessage('Logged out ($authId).'); + } catch (e) { + _setMessage('Logout failed: $e'); + } finally { + _setBusy(false); + await _refreshStatus(); + } + } + + Future _runExchange(String sourceAuthId, String targetAuthId) async { + _setBusy(true); + _setMessage(''); + try { + await _morph.auth(sourceAuthId).exchangeToken(targetAuthId); + _setMessage('Exchanged $sourceAuthId → $targetAuthId.'); + } catch (e) { + _setMessage('Exchange failed: $e'); + } finally { + _setBusy(false); + await _refreshStatus(); + } + } + + // ── Open sheets ─────────────────────────────────────────────────────────── + + void _openMockApi() { + final cfg = _simCfg; + if (cfg == null) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => MockApiSheet(morph: _morph, cfg: cfg), + ).then((_) => _refreshStatus()); + } + + void _openProviderConfig(String providerKey) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => + ProviderConfigSheet(morph: _morph, providerKey: providerKey), + ); + } + + // ── Build ───────────────────────────────────────────────────────────────── + + /// Group token statuses by providerKey preserving order. + Map> get _byProvider { + final out = >{}; + for (final s in _tokenStatus) { + out.putIfAbsent(s.providerKey, () => []).add(s); + } + return out; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Morph Flutter PoC'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Reload token snapshot', + onPressed: _refreshStatus, + ), + ], + ), + body: RefreshIndicator( + onRefresh: _refreshStatus, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Status ── + _SectionHeader('Status'), + if (_tokenStatus.isEmpty) + const Padding( + padding: EdgeInsets.all(12), + child: Text('Loading…'), + ) + else + ..._byProvider.entries.map((entry) { + try { + return _ProviderSection( + providerKey: entry.key, + rows: entry.value, + busy: _busy, + morph: _morph, + actionsForRow: _actionsForRow, + onExchange: _runExchange, + onConfigTap: () => _openProviderConfig(entry.key), + onJwtTap: (s) => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TokenClaimsSheet(status: s), + ), + ); + } catch (e, st) { + print('[morph-poc] _ProviderSection build THREW for ${entry.key}: $e\n$st'); + return Padding( + padding: const EdgeInsets.all(12), + child: Text('UI error (${entry.key}): $e', + style: const TextStyle(color: Colors.red, fontSize: 11)), + ); + } + }), + + // ── Mock API ── + _SectionHeader('Mock API'), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ElevatedButton.icon( + icon: const Icon(Icons.api_outlined), + label: const Text('Open Mock API & HTTP log'), + onPressed: + (_simCfg == null || _busy) ? null : _openMockApi, + ), + ), + + // ── Status message ── + if (_message.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: Text(_message, + style: const TextStyle(fontSize: 13)), + ), + if (_busy) + const Padding( + padding: EdgeInsets.all(8), + child: LinearProgressIndicator(), + ), + + // ── Simulation ── + const _SectionHeader('Simulation'), + if (_simCfg == null) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text('Loading simulation config…', + style: TextStyle(fontSize: 13)), + ) + else + SimulationPanel( + morph: _morph, + cfg: _simCfg!, + onStatusChanged: _refreshStatus, + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Provider section widget +// --------------------------------------------------------------------------- + +class _ProviderSection extends StatefulWidget { + const _ProviderSection({ + required this.providerKey, + required this.rows, + required this.busy, + required this.morph, + required this.actionsForRow, + required this.onExchange, + required this.onConfigTap, + required this.onJwtTap, + }); + + final String providerKey; + final List rows; + final bool busy; + final MorphClient morph; + final List<_ContextAction> Function(MorphTokenStatus) actionsForRow; + final Future Function(String source, String target) onExchange; + final VoidCallback onConfigTap; + final void Function(MorphTokenStatus) onJwtTap; + + @override + State<_ProviderSection> createState() => _ProviderSectionState(); +} + +class _ProviderSectionState extends State<_ProviderSection> { + /// Selected exchange source per target authId. + final Map _exchangePick = {}; + + @override + void didUpdateWidget(_ProviderSection oldWidget) { + super.didUpdateWidget(oldWidget); + _syncExchangePicks(); + } + + @override + void initState() { + super.initState(); + _syncExchangePicks(); + } + + void _syncExchangePicks() { + for (final row in widget.rows) { + final sources = widget.morph.getExchangeSources(row.authId); + if (sources.isEmpty) { + _exchangePick.remove(row.authId); + } else { + final cur = _exchangePick[row.authId]; + if (cur == null || !sources.contains(cur)) { + _exchangePick[row.authId] = sources.first; + } + } + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Provider header row + Row( + children: [ + Text( + widget.providerKey.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.06, + color: Colors.grey.shade600, + ), + ), + const Spacer(), + TextButton( + onPressed: widget.onConfigTap, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle(fontSize: 11)), + child: const Text('Config'), + ), + ], + ), + // Context rows + Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Column( + children: widget.rows.asMap().entries.map((e) { + final idx = e.key; + final row = e.value; + final isLast = idx == widget.rows.length - 1; + final sources = + widget.morph.getExchangeSources(row.authId); + final actions = widget.actionsForRow(row); + + return Column( + children: [ + // Token summary button (tap → JWT claims) + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: TokenStatusCard( + status: row, + label: _labelFor(row), + onTap: () => widget.onJwtTap(row), + ), + ), + // Action buttons + if (actions.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: actions.map((a) { + return _SmallButton( + label: a.label, + danger: a.danger, + tooltip: a.tooltip, + onPressed: widget.busy ? null : a.onPressed, + ); + }).toList(), + ), + ), + // Exchange dropdown (only for target contexts) + if (sources.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: _ExchangeRow( + targetAuthId: row.authId, + sources: sources, + picked: _exchangePick[row.authId] ?? sources.first, + busy: widget.busy, + onPickChanged: (v) => + setState(() => _exchangePick[row.authId] = v), + onExchange: () { + final src = _exchangePick[row.authId]; + if (src != null) { + widget.onExchange(src, row.authId); + } + }, + ), + ), + if (!isLast) + const Divider(height: 12, indent: 8, endIndent: 8), + if (isLast) const SizedBox(height: 8), + ], + ); + }).toList(), + ), + ), + ], + ), + ); + } +} + +class _ExchangeRow extends StatelessWidget { + const _ExchangeRow({ + required this.targetAuthId, + required this.sources, + required this.picked, + required this.busy, + required this.onPickChanged, + required this.onExchange, + }); + + final String targetAuthId; + final List sources; + final String picked; + final bool busy; + final void Function(String) onPickChanged; + final VoidCallback onExchange; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Text('Subject', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Color(0xFF6B21A8))), + const SizedBox(width: 6), + Expanded( + child: DropdownButton( + value: picked, + isExpanded: true, + isDense: true, + style: const TextStyle(fontSize: 11, color: Color(0xFF1E1B4B)), + items: sources + .map((s) => DropdownMenuItem( + value: s, + child: Text( + '${_kStatusLabels[s] ?? s} ($s)', + overflow: TextOverflow.ellipsis), + )) + .toList(), + onChanged: busy ? null : (v) => v != null ? onPickChanged(v) : null, + ), + ), + const SizedBox(width: 6), + _SmallButton( + label: 'Exchange', + onPressed: busy ? null : onExchange, + ), + const SizedBox(width: 4), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Small shared widgets +// --------------------------------------------------------------------------- + +class _ContextAction { + const _ContextAction({ + required this.label, + this.onPressed, + this.danger = false, + this.tooltip, + }); + final String label; + final VoidCallback? onPressed; + final bool danger; + final String? tooltip; +} + +class _SmallButton extends StatelessWidget { + const _SmallButton({ + required this.label, + this.onPressed, + this.danger = false, + this.tooltip, + }); + final String label; + final VoidCallback? onPressed; + final bool danger; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final btn = OutlinedButton( + onPressed: onPressed, + style: danger + ? OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide( + color: Theme.of(context).colorScheme.error.withValues(alpha: 0.4)), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle(fontSize: 11)) + : OutlinedButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle(fontSize: 11)), + child: Text(label), + ); + if (tooltip != null) { + return Tooltip(message: tooltip!, child: btn); + } + return btn; + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title); + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 16, 12, 4), + child: Text( + title, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(color: Theme.of(context).colorScheme.primary), + ), + ); + } +} + diff --git a/poc/flutter-poc/lib/widgets/http_trace_log.dart b/poc/flutter-poc/lib/widgets/http_trace_log.dart new file mode 100644 index 0000000..34caee0 --- /dev/null +++ b/poc/flutter-poc/lib/widgets/http_trace_log.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +/// Scrollable log of [MorphHttpTraceEvent] entries. +class HttpTraceLog extends StatefulWidget { + const HttpTraceLog({super.key, required this.traces, this.onClear}); + + final List traces; + final VoidCallback? onClear; + + @override + State createState() => _HttpTraceLogState(); +} + +class _HttpTraceLogState extends State { + String? _expandedId; + _TraceTab _tab = _TraceTab.request; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Text('HTTP Trace', + style: Theme.of(context).textTheme.titleSmall), + ), + const Spacer(), + if (widget.onClear != null) + TextButton(onPressed: widget.onClear, child: const Text('Clear')), + ], + ), + if (widget.traces.isEmpty) + const Padding( + padding: EdgeInsets.all(12), + child: Text('No traces yet.', + style: TextStyle(color: Colors.grey)), + ) + else + // shrinkWrap + NeverScrollableScrollPhysics is intentional here: + // the list is capped at 100 entries so lazy-loading yields no benefit, + // and nesting inside SingleChildScrollView requires a bounded height. + // For unbounded production logs, prefer CustomScrollView + SliverList. + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.traces.length, + itemBuilder: (_, i) => _TraceRow( + event: widget.traces[i], + expanded: _expandedId == _idFor(widget.traces[i], i), + currentTab: _tab, + onTap: () => setState(() { + final id = _idFor(widget.traces[i], i); + if (_expandedId == id) { + _expandedId = null; + } else { + _expandedId = id; + _tab = _TraceTab.request; + } + }), + onTabChange: (t) => setState(() => _tab = t), + ), + ), + ], + ); + } + + String _idFor(MorphHttpTraceEvent e, int index) => + '${e.method}-${e.url}-$index'; +} + +enum _TraceTab { request, response, body } + +class _TraceRow extends StatelessWidget { + const _TraceRow({ + required this.event, + required this.expanded, + required this.currentTab, + required this.onTap, + required this.onTabChange, + }); + + final MorphHttpTraceEvent event; + final bool expanded; + final _TraceTab currentTab; + final VoidCallback onTap; + final void Function(_TraceTab) onTabChange; + + Color _statusColor(int code) { + if (code >= 200 && code < 300) return Colors.green; + if (code >= 400 && code < 500) return Colors.orange; + if (code >= 500) return Colors.red; + return Colors.grey; + } + + @override + Widget build(BuildContext context) { + final statusOk = event.statusCode >= 200 && event.statusCode < 300; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + child: Column( + children: [ + ListTile( + dense: true, + leading: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _statusColor(event.statusCode).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${event.statusCode}', + style: TextStyle( + color: _statusColor(event.statusCode), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + title: Text( + '${event.method} ${event.path}', + style: const TextStyle(fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${event.hostKey} · ${event.durationMs}ms · ${event.authId}', + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!statusOk && event.networkError != null) + const Icon(Icons.error_outline, + size: 16, color: Colors.red), + Icon(expanded ? Icons.expand_less : Icons.expand_more, + size: 16), + ], + ), + onTap: onTap, + ), + if (expanded) _DetailPanel(event: event, tab: currentTab, onTabChange: onTabChange), + ], + ), + ); + } +} + +class _DetailPanel extends StatelessWidget { + const _DetailPanel({ + required this.event, + required this.tab, + required this.onTabChange, + }); + + final MorphHttpTraceEvent event; + final _TraceTab tab; + final void Function(_TraceTab) onTabChange; + + @override + Widget build(BuildContext context) { + const enc = JsonEncoder.withIndent(' '); + + final content = switch (tab) { + _TraceTab.request => enc.convert(event.requestHeaders), + _TraceTab.response => enc.convert(event.responseHeaders), + _TraceTab.body => switch (event.responseBody) { + null => '(empty)', + final b when b is Map || b is List => enc.convert(b), + final b => b.toString(), + }, + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: _TraceTab.values.map((t) { + final label = switch (t) { + _TraceTab.request => 'Request headers', + _TraceTab.response => 'Response headers', + _TraceTab.body => 'Body', + }; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(label, style: const TextStyle(fontSize: 11)), + selected: tab == t, + onSelected: (_) => onTabChange(t), + visualDensity: VisualDensity.compact, + ), + ); + }).toList(), + ), + const SizedBox(height: 6), + SelectableText( + content, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ], + ), + ); + } +} diff --git a/poc/flutter-poc/lib/widgets/mock_api_sheet.dart b/poc/flutter-poc/lib/widgets/mock_api_sheet.dart new file mode 100644 index 0000000..3bf0fb2 --- /dev/null +++ b/poc/flutter-poc/lib/widgets/mock_api_sheet.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +import '../../morph_init.dart'; +import '../poc_simulation.dart'; +import 'http_trace_log.dart'; + +class MockApiSheet extends StatefulWidget { + const MockApiSheet({super.key, required this.morph, required this.cfg}); + + final MorphClient morph; + final PocSimulationConfig cfg; + + @override + State createState() => _MockApiSheetState(); +} + +class _MockApiSheetState extends State { + bool _busy = false; + String _message = ''; + bool _lastIsError = false; + Object? _lastBody; + + Future _run(PocSimStep step) async { + setState(() { + _busy = true; + _message = ''; + _lastBody = null; + }); + final result = await runPocSimStep(widget.morph, widget.cfg, step); + if (!mounted) return; + setState(() { + _busy = false; + _lastIsError = result.isError; + _message = + '${result.status}${result.detail != null ? ' — ${result.detail}' : ''}'; + _lastBody = result.body; + }); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Expanded( + child: Text( + 'Mock API', + style: + TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + Text( + 'docs/poc/poc-simulation.json', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.grey.shade600), + ), + ], + ), + ), + const Divider(height: 1), + // Step buttons + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: widget.cfg.steps.map((step) { + final isDanger = step is PocSimLogoutStep; + return FilledButton.tonal( + onPressed: _busy ? null : () => _run(step), + style: isDanger + ? FilledButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.errorContainer, + foregroundColor: + Theme.of(context).colorScheme.onErrorContainer, + ) + : null, + child: Text(step.label, + style: const TextStyle(fontSize: 12)), + ); + }).toList(), + ), + ), + // Status message + if (_busy) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: LinearProgressIndicator(), + ), + if (_message.isNotEmpty) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + _message, + style: TextStyle( + fontSize: 12, + color: _lastIsError + ? Colors.red.shade700 + : Colors.green.shade700, + fontFamily: 'monospace', + ), + ), + ), + if (_lastBody != null) + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + constraints: const BoxConstraints(maxHeight: 120), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(6), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(10), + child: SelectableText( + _lastBody is Map || _lastBody is List + ? const JsonEncoder.withIndent(' ') + .convert(_lastBody) + : _lastBody.toString(), + style: const TextStyle( + color: Color(0xFFE2E8F0), + fontSize: 11, + fontFamily: 'monospace'), + ), + ), + ), + const Divider(height: 1), + // HTTP trace log + Expanded( + child: StatefulBuilder( + builder: (_, refresh) => HttpTraceLog( + traces: List.from(morphHttpTraces), + onClear: () { + morphHttpTraces.clear(); + refresh(() {}); + }, + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/poc/flutter-poc/lib/widgets/provider_config_sheet.dart b/poc/flutter-poc/lib/widgets/provider_config_sheet.dart new file mode 100644 index 0000000..91c5d2e --- /dev/null +++ b/poc/flutter-poc/lib/widgets/provider_config_sheet.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +class ProviderConfigSheet extends StatelessWidget { + const ProviderConfigSheet({ + super.key, + required this.morph, + required this.providerKey, + }); + + final MorphClient morph; + final String providerKey; + + @override + Widget build(BuildContext context) { + Object meta; + try { + final m = morph.getProviderMeta(providerKey); + meta = { + 'key': m.key, + 'type': m.type, + 'baseUrl': m.baseUrl, + if (m.authorizationBrowserBaseUrl != null) + 'authorizationBrowserBaseUrl': m.authorizationBrowserBaseUrl, + if (m.tokenHttpBaseUrl != null) + 'tokenHttpBaseUrl': m.tokenHttpBaseUrl, + 'contexts': m.contexts + .map((c) => { + 'key': c.key, + 'authId': c.authId, + if (c.clientId != null) 'clientId': c.clientId, + if (c.clientAuth != null) 'clientAuth': c.clientAuth, + if (c.audience != null) 'audience': c.audience, + if (c.scopes != null) 'scopes': c.scopes, + }) + .toList(), + }; + } catch (e) { + meta = {'_error': e.toString()}; + } + + final formatted = + const JsonEncoder.withIndent(' ').convert(meta); + + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Provider config', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700), + ), + Text( + "morph.getProviderMeta('$providerKey')", + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.grey.shade600), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(16), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(12), + child: SelectableText( + formatted, + style: const TextStyle( + color: Color(0xFFE2E8F0), + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/poc/flutter-poc/lib/widgets/simulation_panel.dart b/poc/flutter-poc/lib/widgets/simulation_panel.dart new file mode 100644 index 0000000..448d277 --- /dev/null +++ b/poc/flutter-poc/lib/widgets/simulation_panel.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:morph_core/morph_core.dart'; + +import '../poc_simulation.dart'; + +class SimulationPanel extends StatefulWidget { + const SimulationPanel({ + super.key, + required this.morph, + required this.cfg, + this.onStatusChanged, + }); + + final MorphClient morph; + final PocSimulationConfig cfg; + final VoidCallback? onStatusChanged; + + @override + State createState() => _SimulationPanelState(); +} + +class _SimulationPanelState extends State { + bool _running = false; + bool _probe404Enabled = false; + final List _results = []; + String _sessionDeadMessage = ''; + final http.Client _httpClient = http.Client(); + + @override + void dispose() { + _httpClient.close(); + super.dispose(); + } + + List get _autoSteps => widget.cfg.steps + .where((s) => + s is! PocSimFetchStep || !s.skipInAutoSim) + .where((s) => + s is! PocSimHostStep || !s.skipInAutoSim) + .where((s) { + if (s is PocSimFetchStep && s.path == '/sim/not-found') { + return _probe404Enabled; + } + return true; + }) + .toList(); + + Future _runAll() async { + setState(() { + _running = true; + _results.clear(); + _sessionDeadMessage = ''; + }); + + for (final step in _autoSteps) { + if (!mounted) break; + final result = await runPocSimStep(widget.morph, widget.cfg, step, _httpClient); + + if (!mounted) break; + setState(() => _results.add(result)); + + // Session dead check + if (isPocSessionDeadStop( + result: result, + step: step, + sessionDeadAuthIds: widget.cfg.sessionDeadAuthIds, + )) { + setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage); + break; + } + } + + if (mounted) { + setState(() => _running = false); + widget.onStatusChanged?.call(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: Row( + children: [ + // Title lives in HomeScreen (`_SectionHeader('Simulation')`). + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('404 probe', + style: TextStyle( + fontSize: 11, color: Colors.grey.shade600)), + Switch.adaptive( + value: _probe404Enabled, + onChanged: _running + ? null + : (v) => setState(() => _probe404Enabled = v), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + const Spacer(), + FilledButton.icon( + icon: _running + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow, size: 16), + label: Text(_running ? 'Running…' : 'Run simulation'), + onPressed: _running ? null : _runAll, + ), + if (_results.isNotEmpty) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: _running + ? null + : () => setState(() { + _results.clear(); + _sessionDeadMessage = ''; + }), + child: const Text('Clear'), + ), + ], + ], + ), + ), + if (_sessionDeadMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 0), + child: Text( + _sessionDeadMessage, + style: TextStyle( + fontSize: 12, color: colorScheme.error), + ), + ), + if (_results.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 0), + child: Column( + children: _results + .map((r) => _ResultRow(result: r)) + .toList(), + ), + ), + if (_running && _results.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: LinearProgressIndicator(), + ), + ], + ); + } +} + +class _ResultRow extends StatelessWidget { + const _ResultRow({required this.result}); + final PocSimStepResult result; + + @override + Widget build(BuildContext context) { + final isOk = !result.isError; + final statusStr = result.status.toString(); + + return Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Row( + children: [ + Icon( + isOk ? Icons.check_circle_outline : Icons.cancel_outlined, + size: 14, + color: isOk ? Colors.green.shade700 : Colors.red.shade700, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + result.label, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + statusStr, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + color: isOk ? Colors.green.shade800 : Colors.red.shade800, + ), + ), + if (result.detail != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + result.detail!, + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ); + } +} diff --git a/poc/flutter-poc/lib/widgets/token_status_card.dart b/poc/flutter-poc/lib/widgets/token_status_card.dart new file mode 100644 index 0000000..414312d --- /dev/null +++ b/poc/flutter-poc/lib/widgets/token_status_card.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +/// Labels that mirror the TS PoC `STATUS_LABELS` map. +const _kLabels = { + 'morph-auth/device': 'Device', + 'morph-auth/2fa': 'Login (2fa)', + 'morph-auth/1fa': 'Session (1fa)', + 'google-auth/google': 'Google', +}; + +/// A card displaying the status of one [MorphTokenStatus] entry. +class TokenStatusCard extends StatelessWidget { + const TokenStatusCard({super.key, required this.status, this.label, this.onTap}); + + final MorphTokenStatus status; + + /// Optional display label; falls back to [_kLabels] map then [authId]. + final String? label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final label = this.label ?? _kLabels[status.authId] ?? status.authId; + final hasToken = status.hasAccessToken; + final valid = status.accessLikelyValid; + final expiry = _formatExp(status); + + return Card( + margin: EdgeInsets.zero, + elevation: 0, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + hasToken + ? (valid ? Icons.check_circle : Icons.warning_amber) + : Icons.cancel_outlined, + color: hasToken + ? (valid ? Colors.green : Colors.orange) + : Colors.grey, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle(fontWeight: FontWeight.w600)), + Text( + hasToken ? expiry : 'No token', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (status.grantHint != null) + Chip( + label: Text( + status.grantHint!, + style: const TextStyle(fontSize: 11), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ), + ); + } + + String _formatExp(MorphTokenStatus s) { + final sec = s.jwtExp ?? s.expiresAt; + if (sec == null) return 'No expiry info'; + final exp = DateTime.fromMillisecondsSinceEpoch(sec * 1000); + final left = exp.difference(DateTime.now()); + final iso = '${exp.toUtc().toIso8601String().substring(0, 19)}Z'; + if (left.isNegative) { + return '$iso (expired ${left.inSeconds.abs()}s ago)'; + } + return '$iso (in ${left.inSeconds}s)'; + } +} + +/// Bottom sheet showing JWT claims for the given [MorphTokenStatus]. +class TokenClaimsSheet extends StatelessWidget { + const TokenClaimsSheet({super.key, required this.status}); + + final MorphTokenStatus status; + + @override + Widget build(BuildContext context) { + final label = _kLabels[status.authId] ?? status.authId; + return DraggableScrollableSheet( + initialChildSize: 0.6, + maxChildSize: 0.95, + expand: false, + builder: (_, controller) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$label — JWT claims', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (status.decodeError != null) + Text('Decode error: ${status.decodeError}', + style: const TextStyle(color: Colors.red)) + else if (status.claims != null) + Expanded( + child: SingleChildScrollView( + controller: controller, + child: _JsonView(data: status.claims!), + ), + ) + else + const Text('No claims available'), + ], + ), + ), + ); + } +} + +class _JsonView extends StatelessWidget { + const _JsonView({required this.data}); + final Map data; + + @override + Widget build(BuildContext context) { + const encoder = JsonEncoder.withIndent(' '); + return SelectableText( + encoder.convert(data), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ); + } +} diff --git a/poc/flutter-poc/pubspec.lock b/poc/flutter-poc/pubspec.lock new file mode 100644 index 0000000..5e132ab --- /dev/null +++ b/poc/flutter-poc/pubspec.lock @@ -0,0 +1,740 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_secure_storage: + dependency: transitive + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + morph_core: + dependency: "direct main" + description: + path: "../../packages/dart/morph_core" + relative: true + source: path + version: "0.1.0" + morph_core_storage: + dependency: "direct main" + description: + path: "../../../morph-data-store/adapters/dart-morph-core-storage" + relative: true + source: path + version: "0.1.0" + morph_data_store: + dependency: "direct main" + description: + path: "../../../morph-data-store/core/dart-morph-data-store" + relative: true + source: path + version: "0.1.0" + morph_logger: + dependency: "direct main" + description: + path: "../../packages/dart/morph_logger" + relative: true + source: path + version: "0.1.0" + morph_oauth2: + dependency: "direct main" + description: + path: "../../packages/dart/morph_oauth2" + relative: true + source: path + version: "0.1.0" + morph_storage: + dependency: "direct main" + description: + path: "../../packages/dart/morph_storage" + relative: true + source: path + version: "0.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/poc/flutter-poc/pubspec.yaml b/poc/flutter-poc/pubspec.yaml new file mode 100644 index 0000000..e0bdd60 --- /dev/null +++ b/poc/flutter-poc/pubspec.yaml @@ -0,0 +1,41 @@ +name: morph_flutter_poc +description: > + Flutter PoC for morph-api-client — mirrors poc/ts-vue against the same + Keycloak + mock-api backend. Demonstrates MorphClient init, OAuth login, + token status, host HTTP calls, and the HTTP trace log. +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + morph_core: + path: ../../packages/dart/morph_core + morph_oauth2: + path: ../../packages/dart/morph_oauth2 + morph_logger: + path: ../../packages/dart/morph_logger + morph_storage: + path: ../../packages/dart/morph_storage + morph_core_storage: + path: ../../../morph-data-store/adapters/dart-morph-core-storage + morph_data_store: + path: ../../../morph-data-store/core/dart-morph-data-store + http: ^1.2.0 + url_launcher: ^6.3.0 + app_links: ^6.4.0 + shared_preferences: ^2.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + uses-material-design: true + assets: + - assets/poc-config.json + - assets/poc-simulation.json diff --git a/poc/flutter-poc/run_web.sh b/poc/flutter-poc/run_web.sh new file mode 100755 index 0000000..115a03c --- /dev/null +++ b/poc/flutter-poc/run_web.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# run_web.sh — start all PoC backends and the Flutter web app on Chrome +# +# Usage: ./run_web.sh [--no-keycloak] [--no-mock-api] [--debug] +# +# Ports: +# 8080 — Keycloak (Docker) +# 3000 — Mock API (Node) +# 4200 — Flutter web app (Chrome) +# +# By default runs in --profile mode (single bundled JS, loads in ~2s). +# Pass --debug for hot-reload at the cost of a 30-60s load on each page +# reload (596 separate JS files), which can cause the OAuth code to expire. +# --------------------------------------------------------------------------- +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +POC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +FLUTTER_PORT=4200 + +# --- flags --- +START_KEYCLOAK=true +START_MOCK_API=true +FLUTTER_MODE="--profile" +for arg in "$@"; do + case "$arg" in + --no-keycloak) START_KEYCLOAK=false ;; + --no-mock-api) START_MOCK_API=false ;; + --debug) FLUTTER_MODE="" ;; + esac +done + +# --- cleanup on exit --- +MOCK_API_PID="" +cleanup() { + echo "" + echo "Shutting down..." + [ -n "$MOCK_API_PID" ] && kill "$MOCK_API_PID" 2>/dev/null || true + if $START_KEYCLOAK; then + docker compose -f "$POC_DIR/keycloak/docker-compose.yml" stop 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +# --- 1. Keycloak --- +if $START_KEYCLOAK; then + echo "▶ Starting Keycloak..." + if ! docker compose -f "$POC_DIR/keycloak/docker-compose.yml" up -d 2>&1; then + echo " ⚠ Docker not available — skipping Keycloak." + echo " Start OrbStack (or Docker Desktop) and re-run, or pass --no-keycloak." + START_KEYCLOAK=false + else + echo -n " Waiting for Keycloak to be ready" + until curl -sf http://localhost:8080/realms/morph > /dev/null 2>&1; do + echo -n "." + sleep 2 + done + echo " ready!" + fi +else + echo "⏭ Skipping Keycloak (--no-keycloak)" +fi + +# --- 2. Mock API --- +if $START_MOCK_API; then + MOCK_API_DIR="$POC_DIR/mock-api" + if [ ! -d "$MOCK_API_DIR/node_modules" ]; then + echo "▶ Installing mock-api dependencies..." + npm install --prefix "$MOCK_API_DIR" --silent + fi + + echo "▶ Starting Mock API on http://localhost:3000..." + node "$MOCK_API_DIR/server.js" & + MOCK_API_PID=$! + sleep 1 + echo " Mock API running (PID: $MOCK_API_PID)" +else + echo "⏭ Skipping Mock API (--no-mock-api)" +fi + +# --- 3. Flutter web --- +echo "" +echo "▶ Starting Flutter web app on http://localhost:$FLUTTER_PORT ..." +echo " OAuth callback: http://localhost:$FLUTTER_PORT/" + +cd "$SCRIPT_DIR" +if [ -n "$FLUTTER_MODE" ]; then + echo " Mode: profile (fast load, no hot-reload — use --debug for hot-reload)" +else + echo " Mode: debug (hot-reload enabled, but page reloads are slow ~30-60s)" +fi +echo "" + +# shellcheck disable=SC2086 +flutter run \ + $FLUTTER_MODE \ + --device-id chrome \ + --web-port "$FLUTTER_PORT" \ + --web-browser-flag "--disable-web-security" \ + --dart-define DEVICE_CLIENT_SECRET=morph-device-secret \ + --dart-define LOGIN_CLIENT_SECRET=morph-login-secret \ + --dart-define SESSION_CLIENT_SECRET=morph-session-secret diff --git a/poc/flutter-poc/test/poc_simulation_test.dart b/poc/flutter-poc/test/poc_simulation_test.dart new file mode 100644 index 0000000..fb2b2a7 --- /dev/null +++ b/poc/flutter-poc/test/poc_simulation_test.dart @@ -0,0 +1,296 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:morph_flutter_poc/poc_simulation.dart'; + +void main() { + group('PocSimStepResult.isError', () { + test('int status >= 400 is error', () { + expect( + const PocSimStepResult(label: 'x', status: 200).isError, + false, + ); + expect( + const PocSimStepResult(label: 'x', status: 399).isError, + false, + ); + expect( + const PocSimStepResult(label: 'x', status: 400).isError, + true, + ); + }); + + test('string sentinel statuses', () { + expect( + const PocSimStepResult(label: 'x', status: 'OK').isError, + false, + ); + expect( + const PocSimStepResult(label: 'x', status: 'ERR').isError, + true, + ); + expect( + const PocSimStepResult(label: 'x', status: 'NET').isError, + true, + ); + expect( + const PocSimStepResult(label: 'x', status: 'AUTH').isError, + true, + ); + }); + }); + + group('parsePocSimulationJson', () { + test('fills mock URL from fallback when baseUrl omitted', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'steps': [ + {'type': 'fetch', 'label': 'ping', 'path': '/health'}, + ], + }), + 'http://fallback/', + ); + expect(cfg.mockApiBaseUrl, 'http://fallback/'); + expect(cfg.steps, hasLength(1)); + expect(cfg.steps.first, isA()); + expect((cfg.steps.first as PocSimFetchStep).path, '/health'); + }); + + test('uses mockApi.baseUrl when present', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'mockApi': {'baseUrl': 'http://mock/'}, + 'steps': [], + }), + 'http://ignored/', + ); + expect(cfg.mockApiBaseUrl, 'http://mock/'); + }); + + test('parses fetch, host, logout and skips unknown types', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'steps': [ + {'type': 'fetch', 'label': 'f', 'path': '/a', 'expectStatus': 201}, + { + 'type': 'host', + 'label': 'h', + 'hostKey': 'main-api', + 'method': 'post', + 'path': '/p', + 'auth': 'morph-auth/device', + 'headers': {'X': '1'}, + 'skipInAutoSim': true, + }, + {'type': 'logout_provider', 'label': 'l', 'providerKey': 'morph-auth'}, + {'type': 'unknown_future_type', 'label': 'skip'}, + ], + }), + 'http://m/', + ); + expect(cfg.steps, hasLength(3)); + + final host = cfg.steps[1] as PocSimHostStep; + expect(host.method, 'POST'); + expect(host.headers, {'X': '1'}); + expect(host.skipInAutoSim, true); + }); + + test('probe_404 conditional block merges steps', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'steps': [ + {'type': 'fetch', 'label': 'a', 'path': '/a'}, + ], + 'conditionalBlocks': [ + { + 'id': 'probe_404', + 'steps': [ + {'type': 'fetch', 'label': 'probe', 'path': '/missing'}, + ], + }, + ], + }), + 'http://m/', + ); + expect(cfg.steps, hasLength(2)); + expect(cfg.steps.last.label, 'probe'); + }); + + test('sessionDeadCheck defaults', () { + final empty = parsePocSimulationJson('{}', 'http://m/'); + expect(empty.sessionDeadAuthIds, isEmpty); + expect(empty.sessionDeadMessage, 'Session expired.'); + + final full = parsePocSimulationJson( + jsonEncode({ + 'sessionDeadCheck': { + 'authIds': ['morph-auth/1fa'], + 'message': 'custom', + }, + }), + 'http://m/', + ); + expect(full.sessionDeadAuthIds, ['morph-auth/1fa']); + expect(full.sessionDeadMessage, 'custom'); + }); + }); + + group('isPocSessionDeadStop', () { + late PocSimHostStep sessionStep; + + setUp(() { + sessionStep = const PocSimHostStep( + label: 'x', + hostKey: 'main-api', + method: 'GET', + path: '/p', + auth: 'morph-auth/2fa', + ); + }); + + test('requires AUTH status', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult(label: 'x', status: 'ERR'), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + false, + ); + }); + + test('requires auth context in sessionDeadAuthIds', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'invalid_grant foo', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/device'], + ), + false, + ); + }); + + test('requires invalid_grant OR Token is not active in detail', () { + expect( + isPocSessionDeadStop( + result: + const PocSimStepResult(label: 'x', status: 'AUTH', detail: 'other'), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + false, + ); + + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'something invalid_grant', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + true, + ); + + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'Token is not active (session)', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + true, + ); + }); + + test('does not treat Token is not active as session-dead for non-listed auth ' + '(operator-precedence regression)', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'Token is not active', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/1fa'], // 2fa step not listed + ), + false, + ); + }); + + test('logout step never matches session host auth', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'invalid_grant', + ), + step: const PocSimLogoutStep(label: 'out', providerKey: 'morph-auth'), + sessionDeadAuthIds: const ['morph-auth'], + ), + false, + ); + }); + }); + + group('fetch step (mocked HTTP)', () { + test('parses JSON body on 200', () async { + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.toString(), 'http://m/ping'); + return http.Response(jsonEncode({'ok': true}), 200); + }); + final step = const PocSimFetchStep(label: 'p', path: '/ping'); + final r = + await runPocSimFetchForTesting(step, 'http://m', client); + expect(r.status, 200); + expect(r.isError, false); + expect(r.body, {'ok': true}); + client.close(); + }); + + test('unexpected explicit expectStatus sets detail', () async { + final client = MockClient( + (_) async => http.Response('{}', 500), + ); + final step = + const PocSimFetchStep(label: 'e', path: '/', expectStatus: 200); + final r = + await runPocSimFetchForTesting(step, 'http://m', client); + expect(r.status, 500); + expect(r.isError, true); + expect(r.detail, contains('Unexpected status')); + client.close(); + }); + }); + + group('loadPocSimulation asset', () { + test('parses committed poc-simulation.json', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + final cfg = await loadPocSimulation('http://should-not-use-if-json-has-base'); + expect(cfg.steps, isNotEmpty); + expect( + cfg.mockApiBaseUrl, + startsWith('http://'), + ); + expect( + cfg.sessionDeadAuthIds, + containsAll(['morph-auth/1fa', 'morph-auth/2fa']), + ); + }); + }); +} diff --git a/poc/flutter-poc/test/widget_test.dart b/poc/flutter-poc/test/widget_test.dart new file mode 100644 index 0000000..db27406 --- /dev/null +++ b/poc/flutter-poc/test/widget_test.dart @@ -0,0 +1,8 @@ +// Placeholder retained for template discovery; real coverage is in poc_simulation_test.dart. +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('smoke: test harness runs', () { + expect(true, isTrue); + }); +} diff --git a/poc/flutter-poc/web/favicon.png b/poc/flutter-poc/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/poc/flutter-poc/web/favicon.png differ diff --git a/poc/flutter-poc/web/icons/Icon-192.png b/poc/flutter-poc/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-192.png differ diff --git a/poc/flutter-poc/web/icons/Icon-512.png b/poc/flutter-poc/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-512.png differ diff --git a/poc/flutter-poc/web/icons/Icon-maskable-192.png b/poc/flutter-poc/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-maskable-192.png differ diff --git a/poc/flutter-poc/web/icons/Icon-maskable-512.png b/poc/flutter-poc/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-maskable-512.png differ diff --git a/poc/flutter-poc/web/index.html b/poc/flutter-poc/web/index.html new file mode 100644 index 0000000..de27731 --- /dev/null +++ b/poc/flutter-poc/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + morph_flutter_poc + + + + + + + diff --git a/poc/flutter-poc/web/manifest.json b/poc/flutter-poc/web/manifest.json new file mode 100644 index 0000000..2df1494 --- /dev/null +++ b/poc/flutter-poc/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "morph_flutter_poc", + "short_name": "morph_flutter_poc", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/poc/keycloak/morph-realm.json b/poc/keycloak/morph-realm.json index a50aa9c..31c93e3 100644 --- a/poc/keycloak/morph-realm.json +++ b/poc/keycloak/morph-realm.json @@ -10,8 +10,14 @@ "refreshTokenMaxReuse": 0, "roles": { "realm": [ - { "name": "user", "description": "Standard user" }, - { "name": "admin", "description": "Admin user" } + { + "name": "user", + "description": "Standard user" + }, + { + "name": "admin", + "description": "Admin user" + } ] }, "users": [ @@ -29,7 +35,9 @@ "temporary": false } ], - "realmRoles": ["user"] + "realmRoles": [ + "user" + ] }, { "username": "admin", @@ -45,7 +53,10 @@ "temporary": false } ], - "realmRoles": ["user", "admin"] + "realmRoles": [ + "user", + "admin" + ] } ], "clients": [ @@ -63,13 +74,16 @@ "protocol": "openid-connect", "fullScopeAllowed": true, "attributes": { - "access.token.lifespan": "15" - } + "access.token.lifespan": "30" + }, + "webOrigins": [ + "http://localhost:4200" + ] }, { "clientId": "morph-login", "name": "Morph Login Client (2FA)", - "description": "User login via authorization_code. 30s access, 60s client session idle (refresh window). Token exchange enabled for 2FA→1FA flow.", + "description": "User login via authorization_code. 30s access, 60s client session idle (refresh window). Token exchange enabled for 2FA\u21921FA flow.", "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "morph-login-secret", @@ -89,15 +103,18 @@ "http://localhost:5173/auth/callback", "http://127.0.0.1:5173/auth/callback", "http://localhost:5173/*", - "http://127.0.0.1:5173/*" + "http://127.0.0.1:5173/*", + "http://localhost:4200/", + "http://localhost:4200/*" ], "webOrigins": [ "http://localhost:3000", "http://localhost:5173", - "http://127.0.0.1:5173" + "http://127.0.0.1:5173", + "http://localhost:4200" ], "attributes": { - "access.token.lifespan": "30", + "access.token.lifespan": "60", "client.session.idle.timeout": "60", "oauth2.token.exchange.grant.enabled": "true" }, @@ -120,7 +137,7 @@ { "clientId": "morph-session", "name": "Morph Session Client (1FA)", - "description": "Session token via 2FA→1FA token exchange. 20s access, 60s client session idle. Token exchange enabled.", + "description": "Session token via 2FA\u21921FA token exchange. 20s access, 60s client session idle. Token exchange enabled.", "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "morph-session-secret", @@ -131,10 +148,13 @@ "protocol": "openid-connect", "fullScopeAllowed": true, "attributes": { - "access.token.lifespan": "20", + "access.token.lifespan": "40", "client.session.idle.timeout": "60", "oauth2.token.exchange.grant.enabled": "true" - } + }, + "webOrigins": [ + "http://localhost:4200" + ] }, { "clientId": "morph-api", @@ -146,5 +166,8 @@ "protocol": "openid-connect", "fullScopeAllowed": true } - ] -} + ], + "accessCodeLifespan": 300, + "accessCodeLifespanLogin": 300, + "accessCodeLifespanUserAction": 300 +} \ No newline at end of file diff --git a/poc/ts-vue/package-lock.json b/poc/ts-vue/package-lock.json index 11f0f00..3c5c020 100644 --- a/poc/ts-vue/package-lock.json +++ b/poc/ts-vue/package-lock.json @@ -8,7 +8,10 @@ "name": "ts-vue", "version": "0.0.0", "dependencies": { - "morph-api-client": "file:../../core", + "@morph/browser-storage": "file:../../packages/ts/browser-storage", + "@morph/core": "file:../../packages/ts/core", + "@morph/logger": "file:../../packages/ts/logger", + "@morph/oauth2": "file:../../packages/ts/oauth2", "vue": "^3.5.30", "vue-router": "^4.5.0" }, @@ -24,6 +27,101 @@ "../../core": { "name": "morph-api-client", "version": "0.1.0", + "extraneous": true, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/browser-storage": { + "name": "@morph/browser-storage", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/core": { + "name": "@morph/core", + "version": "0.1.0", + "extraneous": true, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/logger": { + "name": "@morph/logger", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/oauth2": { + "name": "@morph/oauth2", + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/ts/browser-storage": { + "name": "@morph/browser-storage", + "version": "0.1.0", + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/ts/core": { + "name": "@morph/core", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/ts/logger": { + "name": "@morph/logger", + "version": "0.1.0", + "dependencies": { + "@morph/core": "^0.1.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/ts/oauth2": { + "name": "@morph/oauth2", + "version": "0.1.0", + "dependencies": { + "@morph/core": "^0.1.0" + }, "devDependencies": { "typescript": "^5.4.5", "vite": "^5.2.11", @@ -116,6 +214,22 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@morph/browser-storage": { + "resolved": "../../packages/ts/browser-storage", + "link": true + }, + "node_modules/@morph/core": { + "resolved": "../../packages/ts/core", + "link": true + }, + "node_modules/@morph/logger": { + "resolved": "../../packages/ts/logger", + "link": true + }, + "node_modules/@morph/oauth2": { + "resolved": "../../packages/ts/oauth2", + "link": true + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -422,7 +536,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -958,10 +1071,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/morph-api-client": { - "resolved": "../../core", - "link": true - }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -1006,7 +1115,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1123,7 +1231,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1145,7 +1252,6 @@ "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", @@ -1230,7 +1336,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", diff --git a/poc/ts-vue/package.json b/poc/ts-vue/package.json index 523dca0..2168bf3 100644 --- a/poc/ts-vue/package.json +++ b/poc/ts-vue/package.json @@ -9,7 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "morph-api-client": "file:../../core", + "@morph/core": "file:../../packages/ts/core", + "@morph/oauth2": "file:../../packages/ts/oauth2", + "@morph/browser-storage": "file:../../packages/ts/browser-storage", + "@morph/logger": "file:../../packages/ts/logger", "vue": "^3.5.30", "vue-router": "^4.5.0" }, diff --git a/poc/ts-vue/src/deviceIdentity.ts b/poc/ts-vue/src/deviceIdentity.ts new file mode 100644 index 0000000..b751197 --- /dev/null +++ b/poc/ts-vue/src/deviceIdentity.ts @@ -0,0 +1,45 @@ +/** + * Web browser simulation of mobile device/installation identity. + * + * Mobile model: + * - deviceId → stable per physical device (localStorage) + * - installationId → per app install / browser session (sessionStorage) + * + * Pin values for CI / demos via VITE_DEVICE_ID and VITE_INSTALLATION_ID. + */ +export class WebSimIdentity { + static readonly DEVICE_KEY = 'morph-poc:device-id'; + static readonly INSTALL_KEY = 'morph-poc:installation-id'; + + static getDeviceId(): string { + const fromEnv = (import.meta.env.VITE_DEVICE_ID as string | undefined)?.trim(); + if (fromEnv) return fromEnv; + if (typeof localStorage === 'undefined') return 'poc-device-anon'; + try { + let id = localStorage.getItem(WebSimIdentity.DEVICE_KEY); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(WebSimIdentity.DEVICE_KEY, id); + } + return id; + } catch { + return `poc-device-${crypto.randomUUID()}`; + } + } + + static getInstallationId(): string { + const fromEnv = (import.meta.env.VITE_INSTALLATION_ID as string | undefined)?.trim(); + if (fromEnv) return fromEnv; + if (typeof sessionStorage === 'undefined') return 'poc-install-anon'; + try { + let id = sessionStorage.getItem(WebSimIdentity.INSTALL_KEY); + if (!id) { + id = crypto.randomUUID(); + sessionStorage.setItem(WebSimIdentity.INSTALL_KEY, id); + } + return id; + } catch { + return `poc-install-${crypto.randomUUID()}`; + } + } +} diff --git a/poc/ts-vue/src/hostHttpTraceStore.ts b/poc/ts-vue/src/hostHttpTraceStore.ts index 35c262d..5f02b03 100644 --- a/poc/ts-vue/src/hostHttpTraceStore.ts +++ b/poc/ts-vue/src/hostHttpTraceStore.ts @@ -1,5 +1,5 @@ import { shallowRef } from 'vue'; -import type { MorphHttpTraceEvent } from 'morph-api-client'; +import type { MorphHttpTraceEvent } from '@morph/core'; export type HostHttpTraceRow = MorphHttpTraceEvent & { id: string }; diff --git a/poc/ts-vue/src/morph.ts b/poc/ts-vue/src/morph.ts index 3740d96..bedc919 100644 --- a/poc/ts-vue/src/morph.ts +++ b/poc/ts-vue/src/morph.ts @@ -1,12 +1,15 @@ import { MorphClient, - createBrowserSessionStorage, normalizeLoopbackOrigin, type MorphConfig, type MorphOptions, -} from 'morph-api-client'; +} from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { browserStoragePlugin } from '@morph/browser-storage'; +import { loggerPlugin } from '@morph/logger'; import morphConfigJson from '../../../docs/poc/poc-config.json'; import { pushHostHttpTrace } from './hostHttpTraceStore'; +import { WebSimIdentity } from './deviceIdentity'; /** * Deep-clone `docs/poc/poc-config.json` and apply PoC-wide runtime flags only (no per-provider key hacks — proxies live in config + `variables`). @@ -53,47 +56,13 @@ export function getSimulationConsoleVerbose(): boolean { const morphConfig = buildMorphConfig(); -const storage = createBrowserSessionStorage('morph-poc:tk:'); +const STORAGE_PREFIX = 'morph-poc:tk:'; /** * Mobile model: device token uses stable deviceId + installationId (GUID created on first install). * Web PoC simulation: browser profile ≈ device (localStorage), browser tab session ≈ install (sessionStorage — new tab/session ⇒ new installationId). * Set VITE_DEVICE_ID / VITE_INSTALLATION_ID to pin values (CI, demos). */ -const WEB_SIM_DEVICE_KEY = 'morph-poc:device-id'; -const WEB_SIM_INSTALL_KEY = 'morph-poc:installation-id'; - -function readOrCreateWebSimDeviceId(): string { - const fromEnv = import.meta.env.VITE_DEVICE_ID?.trim(); - if (fromEnv) return fromEnv; - if (typeof localStorage === 'undefined') return 'poc-device-anon'; - try { - let id = localStorage.getItem(WEB_SIM_DEVICE_KEY); - if (!id) { - id = crypto.randomUUID(); - localStorage.setItem(WEB_SIM_DEVICE_KEY, id); - } - return id; - } catch { - return `poc-device-${crypto.randomUUID()}`; - } -} - -function readOrCreateWebSimInstallationId(): string { - const fromEnv = import.meta.env.VITE_INSTALLATION_ID?.trim(); - if (fromEnv) return fromEnv; - if (typeof sessionStorage === 'undefined') return 'poc-install-anon'; - try { - let id = sessionStorage.getItem(WEB_SIM_INSTALL_KEY); - if (!id) { - id = crypto.randomUUID(); - sessionStorage.setItem(WEB_SIM_INSTALL_KEY, id); - } - return id; - } catch { - return `poc-install-${crypto.randomUUID()}`; - } -} function keycloakRealmOidcPath(host: string): string { return `${host.replace(/\/$/, '')}/realms/morph/protocol/openid-connect`; @@ -105,8 +74,8 @@ function variables(): Record { const useDynamicRedirect = import.meta.env.DEV; const oauthBase = useDynamicRedirect ? normalizeLoopbackOrigin(origin) : origin.replace(/\/$/, ''); return { - deviceId: readOrCreateWebSimDeviceId(), - installationId: readOrCreateWebSimInstallationId(), + deviceId: WebSimIdentity.getDeviceId(), + installationId: WebSimIdentity.getInstallationId(), deviceClientSecret: v.VITE_DEVICE_CLIENT_SECRET ?? '', loginClientSecret: v.VITE_LOGIN_CLIENT_SECRET ?? '', sessionClientSecret: v.VITE_SESSION_CLIENT_SECRET ?? '', @@ -185,32 +154,27 @@ export function warnPocOAuthRedirectIfNonLoopback(): void { ); } +const logger = loggerPlugin({ + level: getSimulationConsoleVerbose() ? 'debug' : 'info', + prefix: '[morph] ', +}); + const options: MorphOptions = { - storage, + plugins: [ + logger, + oauth2Plugin({ + logger, + storage: browserStoragePlugin({ prefix: STORAGE_PREFIX, logger }), + variables: morphVariables, + autoAcquireNonInteractive: true, + callbacks: { + onTokenChange(authId, tokens) { + console.debug('[morph] onTokenChange', authId, !!tokens); + }, + }, + }), + ], variables: morphVariables, - autoAcquireNonInteractive: true, - callbacks: { - onAuthRequired(authId, metadata) { - console.warn('[morph] onAuthRequired', authId, metadata); - }, - onLogout(authId, reason) { - console.info('[morph] onLogout', authId, reason); - }, - onTokenChange(authId, tokens) { - console.debug('[morph] onTokenChange', authId, !!tokens); - }, - }, - onLog(level, message, err, ctx) { - const line = `[morph:${level}] ${message}`; - const rest: [unknown, unknown] = [err ?? '', ctx ?? '']; - if (getSimulationConsoleVerbose()) { - if (level === 'error' || level === 'warn') console[level](line, ...rest); - else if (level === 'info') console.info(line, ...rest); - else console.log(line, ...rest); - return; - } - console[level === 'debug' ? 'debug' : level](line, ...rest); - }, onHttpTrace: (e) => pushHostHttpTrace(e), }; diff --git a/poc/ts-vue/src/pocSimulationRunner.ts b/poc/ts-vue/src/pocSimulationRunner.ts index 2ac68d2..d48cad9 100644 --- a/poc/ts-vue/src/pocSimulationRunner.ts +++ b/poc/ts-vue/src/pocSimulationRunner.ts @@ -1,4 +1,4 @@ -import { AuthError, MorphHttpError, type MorphClient } from 'morph-api-client'; +import { AuthError, MorphHttpError, type MorphClient } from '@morph/core'; import type { PocSimulationConfig, PocSimFetchStep, diff --git a/poc/ts-vue/src/pocSimulationWhen.ts b/poc/ts-vue/src/pocSimulationWhen.ts index 7b939df..c1cbee9 100644 --- a/poc/ts-vue/src/pocSimulationWhen.ts +++ b/poc/ts-vue/src/pocSimulationWhen.ts @@ -1,4 +1,4 @@ -import type { MorphClient } from 'morph-api-client'; +import type { MorphClient } from '@morph/core'; import type { PocSimCondition } from './pocSimulation'; export type PocSimConditionEvalContext = { diff --git a/poc/ts-vue/src/views/HomeView.vue b/poc/ts-vue/src/views/HomeView.vue index adb47d0..eeefc8c 100644 --- a/poc/ts-vue/src/views/HomeView.vue +++ b/poc/ts-vue/src/views/HomeView.vue @@ -1,6 +1,6 @@