From ed92e546f238819f4255adc3677e1fc90413b003 Mon Sep 17 00:00:00 2001 From: Mehmet TOSUN Date: Tue, 24 Mar 2026 16:59:00 +0300 Subject: [PATCH 01/30] Added make file --- Makefile | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..50112e0 --- /dev/null +++ b/Makefile @@ -0,0 +1,92 @@ +.PHONY: help install build dev mock-api \ + 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 (core + vue + mock-api) + cd core && npm install + cd poc/ts-vue && npm install + cd poc/mock-api && npm install + +build: ## Build the core SDK (run after core/ changes) + cd core && npm run build + +# --------------------------------------------------------------------------- +# 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 core/node_modules core/dist + rm -rf poc/ts-vue/node_modules poc/ts-vue/dist + rm -rf poc/mock-api/node_modules From 018df027fd2eb964bf10b461f804e52a83f4a5f1 Mon Sep 17 00:00:00 2001 From: Mehmet TOSUN Date: Tue, 7 Apr 2026 12:12:03 +0300 Subject: [PATCH 02/30] Added document, guide to run and troubleshoot --- README.md | 92 +++++++------ core/package-lock.json | 2 - docs/README.md | 28 ++++ docs/architecture.md | 19 ++- docs/overview.md | 258 +++++++++++++++++++++++++++++++++++ docs/poc-guide.md | 228 +++++++++++++++++++++++++++++++ docs/troubleshooting.md | 226 ++++++++++++++++++++++++++++++ poc/ts-vue/package-lock.json | 5 - 8 files changed, 801 insertions(+), 57 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/overview.md create mode 100644 docs/poc-guide.md create mode 100644 docs/troubleshooting.md diff --git a/README.md b/README.md index f5c1d5f..d4011f4 100644 --- a/README.md +++ b/README.md @@ -2,63 +2,75 @@ 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 the core SDK +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 core, Vue PoC, and mock API | +| `make build` | Build the core SDK (run after `core/` changes) | +| `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 | +| [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 | ## Layout | Path | Role | |------|------| | `core/` | `morph-api-client` TypeScript SDK package | -| `core/src/runtime.ts` | MorphRuntime — thin coordinator | +| `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 | diff --git a/core/package-lock.json b/core/package-lock.json index 3839cee..aebbdcf 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1751,7 +1751,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1796,7 +1795,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8ef9908 --- /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 | +| [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 and NetworkDelegate interfaces | +| [Architecture](architecture.md) | Internal design: module structure, HTTP pipeline, dependency graph | + +## PoC-Specific + +| 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/architecture.md b/docs/architecture.md index 61cbc91..a907f3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,8 +105,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 +118,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 +138,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] @@ -214,9 +213,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/overview.md b/docs/overview.md new file mode 100644 index 0000000..3ff8723 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,258 @@ +# 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 Vue app makes API calls through the SDK: `morph.host('main-api').get('/accounts')` +2. The SDK resolves the appropriate token for the request (refresh if expired, exchange if needed) +3. The SDK attaches the `Authorization: Bearer ` header and sends the request to the Mock API +4. The Mock API validates the JWT against Keycloak's JWKS endpoint +5. 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/ +├── core/ # SDK npm package (morph-api-client) +│ └── src/ +│ ├── client/ # MorphClient, HostClient, AuthHandle +│ ├── config/ # Config validation, variable interpolation +│ ├── tokens/ # TokenLifecycle, TokenVault +│ ├── http/ # HostPipeline (requests + 401 recovery) +│ ├── oauth/ # Token HTTP, authorization URL builder +│ ├── util/ # JWT, expiry, URL helpers +│ ├── storage/ # Browser storage factories +│ ├── runtime.ts # MorphRuntime coordinator +│ ├── types.ts # Public type definitions +│ └── errors.ts # Error classes +├── 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/poc-guide.md b/docs/poc-guide.md new file mode 100644 index 0000000..a59c435 --- /dev/null +++ b/docs/poc-guide.md @@ -0,0 +1,228 @@ +# 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 the core SDK +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 +``` + +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! | + +--- + +## 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/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/poc/ts-vue/package-lock.json b/poc/ts-vue/package-lock.json index 11f0f00..5b38aba 100644 --- a/poc/ts-vue/package-lock.json +++ b/poc/ts-vue/package-lock.json @@ -422,7 +422,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1006,7 +1005,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1123,7 +1121,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1145,7 +1142,6 @@ "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", @@ -1230,7 +1226,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", From 42fd4df7732eac4aa8bdfa52907df3c683848058 Mon Sep 17 00:00:00 2001 From: Mehmet TOSUN Date: Tue, 7 Apr 2026 13:06:41 +0300 Subject: [PATCH 03/30] Added plugin support and some performance improvements --- Makefile | 22 +- README.md | 24 +- docs/api-reference.md | 14 +- docs/architecture.md | 42 ++- docs/getting-started.md | 7 +- docs/overview.md | 30 +- docs/platform-adapters.md | 2 +- docs/poc-guide.md | 2 +- core/package-lock.json => package-lock.json | 350 +++++++++++------- package.json | 12 +- packages/browser-storage/package.json | 32 ++ .../browser-storage/src}/browserStorage.ts | 2 +- packages/browser-storage/src/index.ts | 1 + .../browser-storage}/tsconfig.json | 0 packages/browser-storage/vite.config.ts | 26 ++ {core => packages/core}/package.json | 4 +- .../core}/src/client/AuthHandle.ts | 0 .../core}/src/client/HostClient.ts | 0 .../core}/src/client/MorphClient.ts | 0 .../core}/src/config/interpolate.ts | 0 .../core}/src/config/validate.ts | 4 +- {core => packages/core}/src/errors.ts | 0 .../core}/src/http/hostPipeline.ts | 4 +- {core => packages/core}/src/index.ts | 10 +- {core => packages/core}/src/runtime.ts | 12 +- {core => packages/core}/src/types.ts | 23 ++ {core => packages/core}/src/util/duration.ts | 0 .../core}/src/util/exchangeSources.ts | 0 {core => packages/core}/src/util/expiry.ts | 0 {core => packages/core}/src/util/httpTrace.ts | 0 {core => packages/core}/src/util/jwt.ts | 0 .../core}/src/util/normalizeOrigin.ts | 0 .../core}/src/util/oauthAuthorize.ts | 0 .../core}/src/util/oauthReturn.ts | 0 .../core}/src/util/oauthState.ts | 0 {core => packages/core}/src/util/url.ts | 0 packages/core/tsconfig.json | 19 + {core => packages/core}/vite.config.ts | 2 +- packages/oauth2/package.json | 32 ++ packages/oauth2/src/index.ts | 22 ++ .../oauth2}/src/oauth/tokenHttp.ts | 6 +- .../oauth2}/src/tokens/tokenLifecycle.ts | 14 +- .../oauth2}/src/tokens/tokenVault.ts | 6 +- packages/oauth2/src/util/duration.ts | 24 ++ packages/oauth2/src/util/exchangeSources.ts | 17 + packages/oauth2/src/util/expiry.ts | 38 ++ packages/oauth2/src/util/interpolate.ts | 28 ++ packages/oauth2/src/util/listAuthIds.ts | 8 + packages/oauth2/src/util/normalizeOrigin.ts | 15 + packages/oauth2/src/util/oauthAuthorize.ts | 29 ++ packages/oauth2/src/util/oauthReturn.ts | 26 ++ packages/oauth2/src/util/oauthState.ts | 22 ++ packages/oauth2/src/util/url.ts | 8 + packages/oauth2/tsconfig.json | 19 + packages/oauth2/vite.config.ts | 26 ++ poc/ts-vue/package-lock.json | 54 ++- poc/ts-vue/package.json | 4 +- poc/ts-vue/src/hostHttpTraceStore.ts | 2 +- poc/ts-vue/src/morph.ts | 6 +- poc/ts-vue/src/pocSimulationRunner.ts | 2 +- poc/ts-vue/src/pocSimulationWhen.ts | 2 +- poc/ts-vue/src/views/HomeView.vue | 2 +- poc/ts-vue/src/views/OAuthCallbackView.vue | 2 +- 63 files changed, 826 insertions(+), 232 deletions(-) rename core/package-lock.json => package-lock.json (85%) create mode 100644 packages/browser-storage/package.json rename {core/src/storage => packages/browser-storage/src}/browserStorage.ts (96%) create mode 100644 packages/browser-storage/src/index.ts rename {core => packages/browser-storage}/tsconfig.json (100%) create mode 100644 packages/browser-storage/vite.config.ts rename {core => packages/core}/package.json (82%) rename {core => packages/core}/src/client/AuthHandle.ts (100%) rename {core => packages/core}/src/client/HostClient.ts (100%) rename {core => packages/core}/src/client/MorphClient.ts (100%) rename {core => packages/core}/src/config/interpolate.ts (100%) rename {core => packages/core}/src/config/validate.ts (96%) rename {core => packages/core}/src/errors.ts (100%) rename {core => packages/core}/src/http/hostPipeline.ts (98%) rename {core => packages/core}/src/index.ts (87%) rename {core => packages/core}/src/runtime.ts (96%) rename {core => packages/core}/src/types.ts (87%) rename {core => packages/core}/src/util/duration.ts (100%) rename {core => packages/core}/src/util/exchangeSources.ts (100%) rename {core => packages/core}/src/util/expiry.ts (100%) rename {core => packages/core}/src/util/httpTrace.ts (100%) rename {core => packages/core}/src/util/jwt.ts (100%) rename {core => packages/core}/src/util/normalizeOrigin.ts (100%) rename {core => packages/core}/src/util/oauthAuthorize.ts (100%) rename {core => packages/core}/src/util/oauthReturn.ts (100%) rename {core => packages/core}/src/util/oauthState.ts (100%) rename {core => packages/core}/src/util/url.ts (100%) create mode 100644 packages/core/tsconfig.json rename {core => packages/core}/vite.config.ts (93%) create mode 100644 packages/oauth2/package.json create mode 100644 packages/oauth2/src/index.ts rename {core => packages/oauth2}/src/oauth/tokenHttp.ts (95%) rename {core => packages/oauth2}/src/tokens/tokenLifecycle.ts (98%) rename {core => packages/oauth2}/src/tokens/tokenVault.ts (97%) create mode 100644 packages/oauth2/src/util/duration.ts create mode 100644 packages/oauth2/src/util/exchangeSources.ts create mode 100644 packages/oauth2/src/util/expiry.ts create mode 100644 packages/oauth2/src/util/interpolate.ts create mode 100644 packages/oauth2/src/util/listAuthIds.ts create mode 100644 packages/oauth2/src/util/normalizeOrigin.ts create mode 100644 packages/oauth2/src/util/oauthAuthorize.ts create mode 100644 packages/oauth2/src/util/oauthReturn.ts create mode 100644 packages/oauth2/src/util/oauthState.ts create mode 100644 packages/oauth2/src/util/url.ts create mode 100644 packages/oauth2/tsconfig.json create mode 100644 packages/oauth2/vite.config.ts diff --git a/Makefile b/Makefile index 50112e0..477f6c1 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,17 @@ help: ## Show available targets # Dependencies & build # --------------------------------------------------------------------------- -install: ## Install all npm dependencies (core + vue + mock-api) - cd core && npm install - cd poc/ts-vue && npm install - cd poc/mock-api && npm install - -build: ## Build the core SDK (run after core/ changes) - cd core && npm run build +install: ## Install all npm dependencies (packages + vue + mock-api) + cd packages/core && npm install + cd packages/oauth2 && npm install + cd packages/browser-storage && npm install + cd poc/ts-vue && npm install + cd poc/mock-api && npm install + +build: ## Build all SDK packages (core → oauth2 → browser-storage) + cd packages/core && npm run build + cd packages/oauth2 && npm run build + cd packages/browser-storage && npm run build # --------------------------------------------------------------------------- # Dev servers @@ -87,6 +91,8 @@ stop: down ## Alias for 'down' # --------------------------------------------------------------------------- clean: ## Remove node_modules and dist artefacts - rm -rf core/node_modules core/dist + rm -rf packages/core/node_modules packages/core/dist + rm -rf packages/oauth2/node_modules packages/oauth2/dist + rm -rf packages/browser-storage/node_modules packages/browser-storage/dist rm -rf poc/ts-vue/node_modules poc/ts-vue/dist rm -rf poc/mock-api/node_modules diff --git a/README.md b/README.md index d4011f4..5c49d76 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ Opens **http://127.0.0.1:5173/** (Vue PoC). Run `make help` to see all targets. | Target | Description | |--------|-------------| -| `make install` | Install npm deps for core, Vue PoC, and mock API | -| `make build` | Build the core SDK (run after `core/` changes) | +| `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) | @@ -67,14 +67,12 @@ See **[docs/README.md](docs/README.md)** for the full documentation index. ## 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/core/` | `@morph/core` | Types, config, HTTP pipeline, MorphClient, AuthHandle | +| `packages/oauth2/` | `@morph/oauth2` | TokenLifecycle, TokenVault, OAuth helpers | +| `packages/browser-storage/` | `@morph/browser-storage` | sessionStorage / localStorage adapters | +| `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/api-reference.md b/docs/api-reference.md index 2059633..94f71a5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -17,7 +17,7 @@ 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 config from './morph-config.json'; const tokenStore = new Map(); @@ -1030,7 +1030,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 +1103,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); @@ -1124,7 +1124,7 @@ try { Ready-made `StorageProvider` implementations for web apps. ```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:'); @@ -1138,7 +1138,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 +1150,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 +1159,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 a907f3f..e48f874 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,24 +151,32 @@ 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/ +│ ├── 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 # createOAuth2Plugin, oauth2Plugin factory +│ └── browser-storage/ # @morph/browser-storage — browser storage adapters +│ └── src/ +│ ├── browserStorage.ts # createBrowserSessionStorage, createBrowserLocalStorage +│ └── index.ts ├── 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 diff --git a/docs/getting-started.md b/docs/getting-started.md index 4c103af..2cb50d8 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 ``` --- @@ -85,10 +85,13 @@ See [Configuration Reference](configuration.md) for a full field reference. ## Initialization ```typescript -import { MorphClient, createBrowserSessionStorage } from 'morph-api-client'; +import { MorphClient } from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { createBrowserSessionStorage } from '@morph/browser-storage'; import config from './morph-config.json'; const morph = MorphClient.init(config, { + auth: oauth2Plugin, storage: createBrowserSessionStorage('myapp:tk:'), callbacks: { diff --git a/docs/overview.md b/docs/overview.md index 3ff8723..90b56cd 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -215,18 +215,24 @@ Copy `poc/ts-vue/.env.example` to `poc/ts-vue/.env` to get started. The three cl ``` morph-api-client/ -├── core/ # SDK npm package (morph-api-client) -│ └── src/ -│ ├── client/ # MorphClient, HostClient, AuthHandle -│ ├── config/ # Config validation, variable interpolation -│ ├── tokens/ # TokenLifecycle, TokenVault -│ ├── http/ # HostPipeline (requests + 401 recovery) -│ ├── oauth/ # Token HTTP, authorization URL builder -│ ├── util/ # JWT, expiry, URL helpers -│ ├── storage/ # Browser storage factories -│ ├── runtime.ts # MorphRuntime coordinator -│ ├── types.ts # Public type definitions -│ └── errors.ts # Error classes +├── 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 ├── poc/ │ ├── ts-vue/ # Vue 3 PoC app │ │ ├── src/views/ # HomeView, OAuthCallbackView diff --git a/docs/platform-adapters.md b/docs/platform-adapters.md index bbeb7a4..f0903c4 100644 --- a/docs/platform-adapters.md +++ b/docs/platform-adapters.md @@ -72,7 +72,7 @@ The storage provider stores and returns this string as-is. Deserialization is ha For web applications, the SDK ships two ready-made `StorageProvider` implementations: ```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:'); diff --git a/docs/poc-guide.md b/docs/poc-guide.md index a59c435..3a003a4 100644 --- a/docs/poc-guide.md +++ b/docs/poc-guide.md @@ -8,7 +8,7 @@ Step-by-step walkthrough of the Vue PoC application. This guide assumes you have ```bash make install # install all npm dependencies -make build # build the core SDK +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 ``` diff --git a/core/package-lock.json b/package-lock.json similarity index 85% rename from core/package-lock.json rename to package-lock.json index aebbdcf..c11ba87 100644 --- a/core/package-lock.json +++ b/package-lock.json @@ -1,12 +1,18 @@ { - "name": "morph-api-client", - "version": "0.1.0", + "name": "morph-api-client-repo", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "morph-api-client-repo", + "workspaces": [ + "packages/*" + ] + }, + "core": { "name": "morph-api-client", "version": "0.1.0", + "extraneous": true, "devDependencies": { "typescript": "^5.4.5", "vite": "^5.2.11", @@ -546,6 +552,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@morph/browser-storage": { + "resolved": "packages/browser-storage", + "link": true + }, + "node_modules/@morph/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@morph/oauth2": { + "resolved": "packages/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 +588,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -584,9 +602,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -598,9 +616,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -612,9 +630,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -626,9 +644,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -640,9 +658,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -654,13 +672,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -668,13 +689,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -682,13 +706,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -696,13 +723,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -710,13 +740,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -724,13 +757,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -738,13 +774,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -752,13 +791,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -766,13 +808,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -780,13 +825,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -794,13 +842,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -808,13 +859,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -822,13 +876,16 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -836,9 +893,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -850,9 +907,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -864,9 +921,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -878,9 +935,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -892,9 +949,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -906,9 +963,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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -1031,28 +1088,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.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", "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.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/language-core": { @@ -1082,9 +1139,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.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -1108,9 +1165,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.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "dev": true, "license": "MIT" }, @@ -1149,9 +1206,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.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1533,9 +1590,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": { @@ -1606,9 +1663,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.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { @@ -1622,31 +1679,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.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -1780,9 +1837,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": { @@ -1933,6 +1990,39 @@ "optionalDependencies": { "commander": "^9.4.1" } + }, + "packages/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/core": { + "name": "@morph/core", + "version": "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", + "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..1c75dcb 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,17 @@ { "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/*" + ], "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/core", + "build:oauth2": "npm run build --prefix packages/oauth2", + "build:storage": "npm run build --prefix packages/browser-storage", + "build:packages": "npm run build:core && npm run build:oauth2 && npm run build:storage", "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/browser-storage/package.json b/packages/browser-storage/package.json new file mode 100644 index 0000000..b2772fd --- /dev/null +++ b/packages/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/browser-storage/src/browserStorage.ts similarity index 96% rename from core/src/storage/browserStorage.ts rename to packages/browser-storage/src/browserStorage.ts index cc66000..25c63ba 100644 --- a/core/src/storage/browserStorage.ts +++ b/packages/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/browser-storage/src/index.ts b/packages/browser-storage/src/index.ts new file mode 100644 index 0000000..f560786 --- /dev/null +++ b/packages/browser-storage/src/index.ts @@ -0,0 +1 @@ +export { createBrowserSessionStorage, createBrowserLocalStorage } from './browserStorage.js'; diff --git a/core/tsconfig.json b/packages/browser-storage/tsconfig.json similarity index 100% rename from core/tsconfig.json rename to packages/browser-storage/tsconfig.json diff --git a/packages/browser-storage/vite.config.ts b/packages/browser-storage/vite.config.ts new file mode 100644 index 0000000..97a5248 --- /dev/null +++ b/packages/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/core/package.json similarity index 82% rename from core/package.json rename to packages/core/package.json index 16a9c5e..c4f237d 100644 --- a/core/package.json +++ b/packages/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/core/src/client/AuthHandle.ts similarity index 100% rename from core/src/client/AuthHandle.ts rename to packages/core/src/client/AuthHandle.ts diff --git a/core/src/client/HostClient.ts b/packages/core/src/client/HostClient.ts similarity index 100% rename from core/src/client/HostClient.ts rename to packages/core/src/client/HostClient.ts diff --git a/core/src/client/MorphClient.ts b/packages/core/src/client/MorphClient.ts similarity index 100% rename from core/src/client/MorphClient.ts rename to packages/core/src/client/MorphClient.ts diff --git a/core/src/config/interpolate.ts b/packages/core/src/config/interpolate.ts similarity index 100% rename from core/src/config/interpolate.ts rename to packages/core/src/config/interpolate.ts diff --git a/core/src/config/validate.ts b/packages/core/src/config/validate.ts similarity index 96% rename from core/src/config/validate.ts rename to packages/core/src/config/validate.ts index 9818cb8..a76e686 100644 --- a/core/src/config/validate.ts +++ b/packages/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/core/src/errors.ts similarity index 100% rename from core/src/errors.ts rename to packages/core/src/errors.ts diff --git a/core/src/http/hostPipeline.ts b/packages/core/src/http/hostPipeline.ts similarity index 98% rename from core/src/http/hostPipeline.ts rename to packages/core/src/http/hostPipeline.ts index b800474..c330f28 100644 --- a/core/src/http/hostPipeline.ts +++ b/packages/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/core/src/index.ts similarity index 87% rename from core/src/index.ts rename to packages/core/src/index.ts index ed99ba1..7db0b19 100644 --- a/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,11 +15,18 @@ export type { MorphContextMeta, TokenExchangeGrant, StorageProvider, + StorageConfig, NetworkDelegate, NetworkConfig, + NetworkPolicy, HostConfig, ProviderConfig, AuthContextConfig, + CtxRef, + AuthPlugin, + AuthPluginFactory, + DelegateMetadata, + InteractionMode, LogoutReason, MorphHttpTraceEvent, OAuthReturnResult, @@ -38,7 +45,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 +53,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/core/src/runtime.ts similarity index 96% rename from core/src/runtime.ts rename to packages/core/src/runtime.ts index 7daace9..1498469 100644 --- a/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,4 +1,6 @@ import type { + AuthPlugin, + AuthPluginFactory, HostConfig, MorphConfig, MorphContextMeta, @@ -16,11 +18,15 @@ 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 resolveAuthPlugin(auth: AuthPlugin | AuthPluginFactory, resolved: ResolvedMorphConfig, options: MorphOptions, variables: Record): AuthPlugin { + if (typeof auth === 'function') return auth(resolved, options, variables); + return auth; +} + export class MorphRuntime { - readonly tokens: TokenLifecycle; + readonly tokens: AuthPlugin; readonly http: HostPipeline; private disposed = false; @@ -29,7 +35,7 @@ export class MorphRuntime { readonly options: MorphOptions, private readonly variables: Record, ) { - this.tokens = new TokenLifecycle(resolved, options, variables, options.onLog); + this.tokens = resolveAuthPlugin(options.auth, resolved, options, variables); this.http = new HostPipeline(resolved, options, variables, this.tokens); } diff --git a/core/src/types.ts b/packages/core/src/types.ts similarity index 87% rename from core/src/types.ts rename to packages/core/src/types.ts index 81a12cf..b8f918a 100644 --- a/core/src/types.ts +++ b/packages/core/src/types.ts @@ -246,7 +246,30 @@ 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 MorphOptions { + auth: AuthPlugin | AuthPluginFactory; storage: StorageProvider; variables?: Record; callbacks: MorphCallbacks; diff --git a/core/src/util/duration.ts b/packages/core/src/util/duration.ts similarity index 100% rename from core/src/util/duration.ts rename to packages/core/src/util/duration.ts diff --git a/core/src/util/exchangeSources.ts b/packages/core/src/util/exchangeSources.ts similarity index 100% rename from core/src/util/exchangeSources.ts rename to packages/core/src/util/exchangeSources.ts diff --git a/core/src/util/expiry.ts b/packages/core/src/util/expiry.ts similarity index 100% rename from core/src/util/expiry.ts rename to packages/core/src/util/expiry.ts diff --git a/core/src/util/httpTrace.ts b/packages/core/src/util/httpTrace.ts similarity index 100% rename from core/src/util/httpTrace.ts rename to packages/core/src/util/httpTrace.ts diff --git a/core/src/util/jwt.ts b/packages/core/src/util/jwt.ts similarity index 100% rename from core/src/util/jwt.ts rename to packages/core/src/util/jwt.ts diff --git a/core/src/util/normalizeOrigin.ts b/packages/core/src/util/normalizeOrigin.ts similarity index 100% rename from core/src/util/normalizeOrigin.ts rename to packages/core/src/util/normalizeOrigin.ts diff --git a/core/src/util/oauthAuthorize.ts b/packages/core/src/util/oauthAuthorize.ts similarity index 100% rename from core/src/util/oauthAuthorize.ts rename to packages/core/src/util/oauthAuthorize.ts diff --git a/core/src/util/oauthReturn.ts b/packages/core/src/util/oauthReturn.ts similarity index 100% rename from core/src/util/oauthReturn.ts rename to packages/core/src/util/oauthReturn.ts diff --git a/core/src/util/oauthState.ts b/packages/core/src/util/oauthState.ts similarity index 100% rename from core/src/util/oauthState.ts rename to packages/core/src/util/oauthState.ts diff --git a/core/src/util/url.ts b/packages/core/src/util/url.ts similarity index 100% rename from core/src/util/url.ts rename to packages/core/src/util/url.ts diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..ebeffba --- /dev/null +++ b/packages/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/core/vite.config.ts similarity index 93% rename from core/vite.config.ts rename to packages/core/vite.config.ts index 6edc78a..aaf75eb 100644 --- a/core/vite.config.ts +++ b/packages/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/oauth2/package.json b/packages/oauth2/package.json new file mode 100644 index 0000000..47415dc --- /dev/null +++ b/packages/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/oauth2/src/index.ts b/packages/oauth2/src/index.ts new file mode 100644 index 0000000..4bf5fa5 --- /dev/null +++ b/packages/oauth2/src/index.ts @@ -0,0 +1,22 @@ +import type { + AuthPlugin, + AuthPluginFactory, + MorphOptions, +} from '@morph/core'; +import { TokenLifecycle } from './tokens/tokenLifecycle.js'; + +export function createOAuth2Plugin(resolved: Parameters[0], options: MorphOptions, variables?: Record): AuthPlugin { + return new TokenLifecycle(resolved, options, variables ?? options.variables ?? {}, options.onLog); +} + +export const oauth2Plugin: AuthPluginFactory = (resolved, options, variables) => { + return new TokenLifecycle(resolved, options, variables, options.onLog); +}; + +export { TokenLifecycle } from './tokens/tokenLifecycle.js'; +export { TokenVault } from './tokens/tokenVault.js'; + +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/oauth2/src/oauth/tokenHttp.ts similarity index 95% rename from core/src/oauth/tokenHttp.ts rename to packages/oauth2/src/oauth/tokenHttp.ts index 79839d5..c24c219 100644 --- a/core/src/oauth/tokenHttp.ts +++ b/packages/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, MorphOptions, NetworkPolicy, ProviderConfig } from '@morph/core'; +import { TokenEndpointError } from '@morph/core'; +import { interpolateRecord, interpolateString } from '../util/interpolate.js'; export interface OAuthTokenResponse { access_token: string; diff --git a/core/src/tokens/tokenLifecycle.ts b/packages/oauth2/src/tokens/tokenLifecycle.ts similarity index 98% rename from core/src/tokens/tokenLifecycle.ts rename to packages/oauth2/src/tokens/tokenLifecycle.ts index aadfa71..0bfc2b4 100644 --- a/core/src/tokens/tokenLifecycle.ts +++ b/packages/oauth2/src/tokens/tokenLifecycle.ts @@ -1,14 +1,15 @@ import type { AuthContextConfig, + AuthPlugin, + CtxRef, LogoutReason, MorphOptions, ProviderConfig, 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 { TokenVault } from './tokenVault.js'; import { buildClientAuthFields, @@ -19,13 +20,14 @@ 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>(); diff --git a/core/src/tokens/tokenVault.ts b/packages/oauth2/src/tokens/tokenVault.ts similarity index 97% rename from core/src/tokens/tokenVault.ts rename to packages/oauth2/src/tokens/tokenVault.ts index f9663df..029876f 100644 --- a/core/src/tokens/tokenVault.ts +++ b/packages/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/oauth2/src/util/duration.ts b/packages/oauth2/src/util/duration.ts new file mode 100644 index 0000000..820beb8 --- /dev/null +++ b/packages/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/oauth2/src/util/exchangeSources.ts b/packages/oauth2/src/util/exchangeSources.ts new file mode 100644 index 0000000..d27151f --- /dev/null +++ b/packages/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/oauth2/src/util/expiry.ts b/packages/oauth2/src/util/expiry.ts new file mode 100644 index 0000000..42dca3d --- /dev/null +++ b/packages/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/oauth2/src/util/interpolate.ts b/packages/oauth2/src/util/interpolate.ts new file mode 100644 index 0000000..7581535 --- /dev/null +++ b/packages/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/oauth2/src/util/listAuthIds.ts b/packages/oauth2/src/util/listAuthIds.ts new file mode 100644 index 0000000..a85d09e --- /dev/null +++ b/packages/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/oauth2/src/util/normalizeOrigin.ts b/packages/oauth2/src/util/normalizeOrigin.ts new file mode 100644 index 0000000..8913dfa --- /dev/null +++ b/packages/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/oauth2/src/util/oauthAuthorize.ts b/packages/oauth2/src/util/oauthAuthorize.ts new file mode 100644 index 0000000..be5d2c0 --- /dev/null +++ b/packages/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/oauth2/src/util/oauthReturn.ts b/packages/oauth2/src/util/oauthReturn.ts new file mode 100644 index 0000000..59ad9b5 --- /dev/null +++ b/packages/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/oauth2/src/util/oauthState.ts b/packages/oauth2/src/util/oauthState.ts new file mode 100644 index 0000000..74e7266 --- /dev/null +++ b/packages/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/oauth2/src/util/url.ts b/packages/oauth2/src/util/url.ts new file mode 100644 index 0000000..f42b283 --- /dev/null +++ b/packages/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/oauth2/tsconfig.json b/packages/oauth2/tsconfig.json new file mode 100644 index 0000000..ebeffba --- /dev/null +++ b/packages/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/oauth2/vite.config.ts b/packages/oauth2/vite.config.ts new file mode 100644 index 0000000..03ae824 --- /dev/null +++ b/packages/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/ts-vue/package-lock.json b/poc/ts-vue/package-lock.json index 5b38aba..c9af8f8 100644 --- a/poc/ts-vue/package-lock.json +++ b/poc/ts-vue/package-lock.json @@ -8,7 +8,9 @@ "name": "ts-vue", "version": "0.0.0", "dependencies": { - "morph-api-client": "file:../../core", + "@morph/browser-storage": "file:../../packages/browser-storage", + "@morph/core": "file:../../packages/core", + "@morph/oauth2": "file:../../packages/oauth2", "vue": "^3.5.30", "vue-router": "^4.5.0" }, @@ -24,6 +26,40 @@ "../../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", + "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", + "devDependencies": { + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-dts": "^3.9.1" + } + }, + "../../packages/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 +152,18 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@morph/browser-storage": { + "resolved": "../../packages/browser-storage", + "link": true + }, + "node_modules/@morph/core": { + "resolved": "../../packages/core", + "link": true + }, + "node_modules/@morph/oauth2": { + "resolved": "../../packages/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", @@ -957,10 +1005,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", diff --git a/poc/ts-vue/package.json b/poc/ts-vue/package.json index 523dca0..f4f5094 100644 --- a/poc/ts-vue/package.json +++ b/poc/ts-vue/package.json @@ -9,7 +9,9 @@ "preview": "vite preview" }, "dependencies": { - "morph-api-client": "file:../../core", + "@morph/core": "file:../../packages/core", + "@morph/oauth2": "file:../../packages/oauth2", + "@morph/browser-storage": "file:../../packages/browser-storage", "vue": "^3.5.30", "vue-router": "^4.5.0" }, 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..58c1e78 100644 --- a/poc/ts-vue/src/morph.ts +++ b/poc/ts-vue/src/morph.ts @@ -1,10 +1,11 @@ import { MorphClient, - createBrowserSessionStorage, normalizeLoopbackOrigin, type MorphConfig, type MorphOptions, -} from 'morph-api-client'; +} from '@morph/core'; +import { oauth2Plugin } from '@morph/oauth2'; +import { createBrowserSessionStorage } from '@morph/browser-storage'; import morphConfigJson from '../../../docs/poc/poc-config.json'; import { pushHostHttpTrace } from './hostHttpTraceStore'; @@ -186,6 +187,7 @@ export function warnPocOAuthRedirectIfNonLoopback(): void { } const options: MorphOptions = { + auth: oauth2Plugin, storage, variables: morphVariables, autoAcquireNonInteractive: true, 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..acf6976 100644 --- a/poc/ts-vue/src/views/HomeView.vue +++ b/poc/ts-vue/src/views/HomeView.vue @@ -1,6 +1,6 @@ + + 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 From d2c01d82e6e915c030c4d390786d04f93816033f Mon Sep 17 00:00:00 2001 From: Cemal YILMAZ <206660090+cemal-yilmaz-bt@users.noreply.github.com> Date: Mon, 4 May 2026 17:25:51 +0300 Subject: [PATCH 24/30] poc(flutter): web stability, simulation tests & docs (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(poc): Flutter POC feature parity with TS Vue POC Closes #27 - Add assets/poc-simulation.json (copy of docs/poc/poc-simulation.json) - Add lib/poc_simulation.dart: PocSimStep sealed classes + runPocSimStep executor for fetch / host / logout_provider steps - Add lib/widgets/mock_api_sheet.dart: bottom sheet with all 9 simulation steps as buttons + integrated HTTP trace log - Add lib/widgets/provider_config_sheet.dart: shows getProviderMeta() as formatted JSON - Add lib/widgets/simulation_panel.dart: sequential auto-loop runner with per-step result rows, 404-probe toggle, and session-dead guard - Refactor home_screen.dart: status grouped by provider, per-row dynamic actions driven by grantHint, Config button, token exchange dropdown - Add getMockApiBase() to morph_init.dart (Android 10.0.2.2 aware) - Add http as direct dependency; fix morph_realm.json localhost:4200 URIs - flutter analyze: no issues Co-authored-by: Cursor * fix(poc): complete web OAuth callback before runApp to fix token visibility Co-authored-by: Cursor * fix(poc): navigate current tab for web OAuth login instead of opening new tab Co-authored-by: Cursor * fix(web-poc): switch to profile mode and extend OAuth code lifetime Debug mode loads 596 separate JS files (30-60s) causing the Keycloak authorization code (60s default) to expire before completeOAuthCallback can exchange it, resulting in a blank screen / no-token state. - run_web.sh: default to --profile (single bundled JS, ~2s load); --debug flag available when hot-reload is needed - morph-realm.json: accessCodeLifespan 60s→300s so even slower loads won't race the code expiry window Co-authored-by: Cursor * chore(web-poc): add missing web/ platform scaffold Co-authored-by: Cursor * fix(web-poc): add CORS webOrigins for device+session clients, add SDK log panel Co-authored-by: Cursor * fix(web-poc): route SDK logs to browser console, remove UI log panel Co-authored-by: Cursor * debug(web-poc): add exhaustive tracing to catch profile-mode silent crashes Co-authored-by: Cursor * fix(web-poc): use in-memory storage on web to avoid ContextStore identity deadlock ContextStore.setData with Boundary.user silently drops writes when _activeUser is not set. On web the OAuth redirect reloads the page so the user identity is never established before the token write, causing all session-scoped tokens to be silently discarded. Use memoryStorageMorphPlugin on web: completeOAuthCallback() runs in main() before runApp(), so the token is in the same JS heap the UI then reads from — no persistence needed for the current page load. Co-authored-by: Cursor * fix(web-poc): render UI before processing OAuth callback to unblock blank screen The blank screen was caused by completeOAuthCallback hanging or crashing silently before runApp() was called. Render the UI first, then process the OAuth callback in initState via addPostFrameCallback. HomeScreen exposes a public refreshStatus() that the parent app calls after the callback completes. Co-authored-by: Cursor * chore(poc): address PR #28 review (precedence, timeouts, UI dupes) - simulation_panel: fix &&/|| precedence in session-dead guard - simulation_panel: drop duplicate Simulation title; use Spacer in toolbar - mock_api_sheet: message color from result.isError; remove redundant ternary - poc_simulation: 10s timeout on http.get; document PoC string-matching tradeoff Co-authored-by: Cursor * test(poc): add unit tests for poc_simulation + injectable fetch client - Extract parsePocSimulationJson for JSON parsing without rootBundle - Add isPocSessionDeadStop (used by SimulationPanel) with regression tests - Refactor _runFetch to optional http.Client override + runPocSimFetchForTesting - Add 15 tests: models, parser, session guard, mocked fetch, asset load - Replace empty widget_test placeholder with minimal smoke test Co-authored-by: Cursor * docs: sync Dart/Flutter PoC parity, web/runtime notes, and test coverage - dart-parity: Flutter PoC parity (#27/#28), web vs native storage, Keycloak webOrigins, run_web.sh profile default, unit tests, CI scope for flutter-poc - README: dart-parity description reflects current status - poc-guide: new Flutter PoC section (run paths, OAuth, CORS, simulation, tests) - poc/simulation: Flutter executor + isPocSessionDeadStop + test pointer Co-authored-by: Cursor --------- Co-authored-by: u0b002 Co-authored-by: Cursor --- docs/README.md | 2 +- docs/dart-parity.md | 12 +- docs/poc-guide.md | 18 ++ docs/poc/simulation.md | 10 + poc/flutter-poc/lib/poc_simulation.dart | 46 ++- .../lib/widgets/simulation_panel.dart | 18 +- poc/flutter-poc/test/poc_simulation_test.dart | 296 ++++++++++++++++++ poc/flutter-poc/test/widget_test.dart | 11 +- 8 files changed, 391 insertions(+), 22 deletions(-) create mode 100644 poc/flutter-poc/test/poc_simulation_test.dart diff --git a/docs/README.md b/docs/README.md index 3981ff6..2648bc1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ | [Token Lifecycle](token-lifecycle.md) | Token resolution algorithm, refresh, exchange, recovery, session monitoring | | [Platform Adapters](platform-adapters.md) | StorageProvider (via plugins) and NetworkDelegate interfaces | | [Architecture](architecture.md) | Internal design: module structure, HTTP pipeline, dependency graph | -| [Dart parity](dart-parity.md) | Dart/Flutter SDK roadmap, `packages/dart/morph_core` scaffold ([issue #1](https://github.com/burgan-tech/morph-api-client/issues/1)) | +| [Dart parity](dart-parity.md) | Dart/Flutter SDK status vs TypeScript ([issue #1](https://github.com/burgan-tech/morph-api-client/issues/1), [issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)), Flutter PoC parity, backlog | | Document | Description | |----------|-------------| diff --git a/docs/dart-parity.md b/docs/dart-parity.md index 1e49368..8d91d23 100644 --- a/docs/dart-parity.md +++ b/docs/dart-parity.md @@ -35,12 +35,16 @@ Base branch for work is **`f/plugin`** unless release policy changes. | Memory storage | **Done:** `morph_storage` module + plugin for tests/tools. | | OAuth return / redirect | **Done:** `oauthRedirectBase` on `MorphOptions`; `completeOAuthReturn` with conditional `dart:html` + optional `Uri? currentUri`. | | Typed `MorphConfig` DTOs | **Backlog:** hand-written or codegen from JSON boundary. | -| Persistent / browser storage | **Done:** `morph_core_storage` adapter in `morph-data-store` bridges `StorageProvider` → `IContextStore` (Keychain/KeyStore); `poc/flutter-poc` switched from in-memory to persistent storage. | -| Sample app | **Done:** `poc/flutter-poc` Flutter app — token status, OAuth login, mock-API call, HTTP trace log (closes #24). | +| Persistent storage (adapter) | **Done:** `morph_core_storage` in **morph-data-store** bridges `morph_core.StorageProvider` → `IContextStore` (Device / platform secure storage). Suitable for **mobile/desktop** PoC and production apps that set **ContextStore identity** before user-scoped writes. | +| Flutter PoC (`poc/flutter-poc`) | **Done:** Feature parity with **`poc/ts-vue`** (closes [#27](https://github.com/burgan-tech/morph-api-client/issues/27), PR [#28](https://github.com/burgan-tech/morph-api-client/pull/28)): provider-grouped status, dynamic actions, mock-API sheet + HTTP trace, provider config sheet, JSON-driven simulation (`assets/poc-simulation.json` copy of `docs/poc/poc-simulation.json`). **Web:** uses **in-memory** `StorageProvider` — full-page OAuth reload cannot satisfy ContextStore **user** boundary before the first token write; **non-web** uses **ContextStore** when init succeeds. **`run_web.sh`** defaults to **`--profile`** (fast single bundle; debug reload is slow and can race Keycloak code expiry). Keycloak **`webOrigins`** must include `http://localhost:4200` for **morph-device** and **morph-session** as well as morph-login (browser `POST /token`). **Unit tests:** `poc/flutter-poc/test/poc_simulation_test.dart` (+ `flutter test`); `parsePocSimulationJson`, `isPocSessionDeadStop`, mocked fetch steps. | +| Sample app (minimal) | **Done (earlier milestone):** baseline Flutter app — token status, OAuth, mock call, trace log ([#24](https://github.com/burgan-tech/morph-api-client/issues/24)). | ## CI -**.github/workflows/dart.yml** runs `dart analyze --fatal-infos` and `dart test` for `morph_core`, `morph_oauth2`, and `morph_logger`. **`morph_storage`** is intentionally omitted from the matrix while it stays test-only transitively; add when it has standalone coverage. +**.github/workflows/dart.yml** runs `dart analyze --fatal-infos` and `dart test` for **`morph_core`**, **`morph_oauth2`**, and **`morph_logger`**. + +- **`morph_storage`** is intentionally omitted from the matrix while it stays test-only transitively; tracked in backlog [#21](https://github.com/burgan-tech/morph-api-client/issues/21). +- **`poc/flutter-poc`** is **not** in CI yet: `pubspec` uses **`path:`** dependencies on **morph-data-store** checked out beside this repo locally. Adding CI would require a second checkout or publishing those packages. ## Design intent (aligned with TS) @@ -54,4 +58,4 @@ Per [architecture.md](architecture.md): Acceptance criteria and epic tracking: **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)**. -When starting work on a backlog row (persistent / browser storage, typed `MorphConfig`, `poc/` sample, adding `morph_storage` to CI), open a **dedicated GitHub issue** and drive it with the [morph-api-client-issue-pr-merge](../.cursor/skills/morph-api-client-issue-pr-merge/SKILL.md) workflow so each slice stays reviewable. +When starting work on a backlog row (typed `MorphConfig`, **`morph_storage` in CI**, façade polish, OAuth hardening, `package:web` migration), use the open issues **[#18](https://github.com/burgan-tech/morph-api-client/issues/18)**–**[#22](https://github.com/burgan-tech/morph-api-client/issues/22)** and epic **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)**. Drive each slice with the [morph-api-client-issue-pr-merge](../.cursor/skills/morph-api-client-issue-pr-merge/SKILL.md) workflow so it stays reviewable. diff --git a/docs/poc-guide.md b/docs/poc-guide.md index b17e849..40b5cef 100644 --- a/docs/poc-guide.md +++ b/docs/poc-guide.md @@ -32,6 +32,24 @@ Test users in Keycloak: --- +## Flutter PoC (`poc/flutter-poc`) + +The Flutter sample mirrors **`poc/ts-vue`** against the **same** Keycloak (8080) and Mock API (3000). Issue [#27](https://github.com/burgan-tech/morph-api-client/issues/27) / PR [#28](https://github.com/burgan-tech/morph-api-client/pull/28): grouped token status, dynamic actions from `grantHint`, mock-API bottom sheet + HTTP trace, provider metadata sheet, and **`docs/poc/poc-simulation.json`** bundled as **`assets/poc-simulation.json`**. + +| Topic | Flutter notes | +|--------|----------------| +| Run (Chrome + backends) | From `poc/flutter-poc`: `./run_web.sh` starts Keycloak, mock-api, and Flutter web on **localhost:4200** (defaults to **`--profile`**). Flags: `--no-keycloak`, `--no-mock-api`, `--debug`. From repo root Keycloak compose path is **`poc/keycloak`** (not `flutter-poc/poc/keycloak`). | +| OAuth on web | Redirect **`http://localhost:4200/`** is registered in **`poc/keycloak/morph-realm.json`**. Use **`webOnlyWindowName: '_self'`** so login stays in one tab. | +| CORS | Keycloak clients **`morph-login`**, **`morph-device`**, **`morph-session`** need **`webOrigins`** including **`http://localhost:4200`** so browser token `POST` succeeds (not only the login client). | +| Storage | **Web:** in-memory tokens for the PoC (session-scoped Keycloak tokens vs ContextStore **user** identity — see [dart-parity.md](dart-parity.md)). **iOS/Android/desktop:** **ContextStore** via **`morph_core_storage`** when initialization succeeds. | +| Simulation UI | **Run simulation** runs the auto-step list on demand (Vue uses a timed tick by default). Session-dead stopping uses the same JSON `sessionDeadCheck` idea; see [poc/simulation.md](poc/simulation.md). | +| Tests | `cd poc/flutter-poc && flutter test` — parser, session-dead helper, mocked HTTP fetch, real asset load. | +| Deeper detail | [dart-parity.md](dart-parity.md); app-specific **`poc/flutter-poc/README.md`** (Android emulator host, secrets, web troubleshooting). | + +Then continue with Vue flow below, or use Flutter for the same conceptual steps (acquire device token, Keycloak login, exchange, mock API, simulation). + +--- + ## The Home Page Open **http://127.0.0.1:5173/** in your browser. The Home page has four main sections: 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/poc/flutter-poc/lib/poc_simulation.dart b/poc/flutter-poc/lib/poc_simulation.dart index 372f40d..a7badc3 100644 --- a/poc/flutter-poc/lib/poc_simulation.dart +++ b/poc/flutter-poc/lib/poc_simulation.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; import 'package:morph_core/morph_core.dart'; // --------------------------------------------------------------------------- @@ -73,6 +74,23 @@ final class PocSimStepResult { } } +/// When an auto-simulation step returns [PocSimStepResult.status] `AUTH`, decide +/// whether to stop the loop (stale Keycloak session). Kept pure for unit tests. +bool isPocSessionDeadStop({ + required PocSimStepResult result, + required PocSimStep step, + required List sessionDeadAuthIds, +}) { + if (result.status != 'AUTH') return false; + final isSessionDead = sessionDeadAuthIds.any( + (id) => step is PocSimHostStep && step.auth == id, + ); + final detail = result.detail ?? ''; + return isSessionDead && + (detail.contains('invalid_grant') || + detail.contains('Token is not active')); +} + // --------------------------------------------------------------------------- // Config loader // --------------------------------------------------------------------------- @@ -93,10 +111,16 @@ class PocSimulationConfig { 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?) ?? mockApiBase; + final baseUrl = (mockApi['baseUrl'] as String?) ?? mockApiFallback; final sessionDeadCheck = (json['sessionDeadCheck'] as Map?) ?? {}; @@ -185,10 +209,15 @@ Future runPocSimStep( } Future _runFetch( - PocSimFetchStep step, String mockApiBase) async { + PocSimFetchStep step, + String mockApiBase, [ + http.Client? clientOverride, +]) async { final url = '$mockApiBase${step.path}'; + final client = clientOverride ?? http.Client(); + final ownsClient = clientOverride == null; try { - final res = await http + final res = await client .get(Uri.parse(url)) .timeout(const Duration(seconds: 10)); final expectedStatus = step.expectStatus; @@ -210,9 +239,20 @@ Future _runFetch( } catch (e) { return PocSimStepResult( label: step.label, status: 'NET', detail: e.toString()); + } finally { + if (ownsClient) client.close(); } } +/// Calls [_runFetch] with an injected HTTP client for unit tests. +@visibleForTesting +Future runPocSimFetchForTesting( + PocSimFetchStep step, + String mockApiBase, + http.Client httpClient, +) => + _runFetch(step, mockApiBase, httpClient); + Future _runHost( MorphClient morph, PocSimHostStep step) async { try { diff --git a/poc/flutter-poc/lib/widgets/simulation_panel.dart b/poc/flutter-poc/lib/widgets/simulation_panel.dart index fc33e0a..8efe9d5 100644 --- a/poc/flutter-poc/lib/widgets/simulation_panel.dart +++ b/poc/flutter-poc/lib/widgets/simulation_panel.dart @@ -53,17 +53,13 @@ class _SimulationPanelState extends State { setState(() => _results.add(result)); // Session dead check - if (result.status == 'AUTH') { - final detail = result.detail ?? ''; - final isSessionDead = widget.cfg.sessionDeadAuthIds.any( - (id) => step is PocSimHostStep && step.auth == id, - ); - if (isSessionDead && - (detail.contains('invalid_grant') || - detail.contains('Token is not active'))) { - setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage); - break; - } + if (isPocSessionDeadStop( + result: result, + step: step, + sessionDeadAuthIds: widget.cfg.sessionDeadAuthIds, + )) { + setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage); + break; } } 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 index afe544c..db27406 100644 --- a/poc/flutter-poc/test/widget_test.dart +++ b/poc/flutter-poc/test/widget_test.dart @@ -1,3 +1,8 @@ -// Placeholder — integration tests requiring a running Keycloak + mock-api -// backend are not wired in this PoC. Add unit tests for individual widgets here. -void main() {} +// 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); + }); +} From e25714905da875d58c06e8d4f0adcef3bf52e547 Mon Sep 17 00:00:00 2001 From: Cemal YILMAZ <206660090+cemal-yilmaz-bt@users.noreply.github.com> Date: Mon, 4 May 2026 22:37:06 +0300 Subject: [PATCH 25/30] chore: nest TypeScript SDK under packages/ts (closes #31) (#32) * chore: nest TypeScript SDK under packages/ts/ - Move core, oauth2, logger, browser-storage to packages/ts/ for parity with packages/dart/ - Point npm workspaces and Makefile/package scripts at packages/ts/* - Update poc/ts-vue file: deps, README, dart-parity, architecture tree, Dart parity comments Closes #31 Co-authored-by: Cursor * docs: align architecture project tree (Gemini review) - Align branch comments and restore browserStorage.ts description - Regenerate package-lock.json after clean npm install (remove extraneous entries) Co-authored-by: Cursor --------- Co-authored-by: u0b002 Co-authored-by: Cursor --- Makefile | 24 +- README.md | 8 +- docs/architecture.md | 52 +-- docs/dart-parity.md | 4 +- docs/github-issue-1-body-issue-only.md | 4 +- docs/github-issue-1-body.md | 4 +- docs/github-issue-dart-full-parity-body.md | 2 +- package-lock.json | 350 ++++++++---------- package.json | 10 +- packages/dart/morph_core/README.md | 2 +- .../lib/src/client/auth_handle.dart | 2 +- .../lib/src/client/host_client.dart | 2 +- .../lib/src/config/exchange_sources.dart | 2 +- .../lib/src/config/interpolate_config.dart | 2 +- .../lib/src/config/list_auth_ids.dart | 2 +- .../lib/src/config/validate_config.dart | 2 +- .../lib/src/errors/morph_errors.dart | 2 +- .../lib/src/http/host_pipeline.dart | 2 +- .../lib/src/runtime/morph_runtime.dart | 2 +- .../lib/src/runtime/plugin_install.dart | 2 +- .../morph_core/lib/src/util/duration_ms.dart | 2 +- .../dart/morph_core/lib/src/util/expiry.dart | 2 +- .../morph_core/lib/src/util/jwt_utils.dart | 2 +- .../lib/src/util/oauth_authorize.dart | 2 +- .../morph_core/lib/src/util/oauth_state.dart | 2 +- .../lib/src/util/resolve_endpoint.dart | 2 +- .../dart/morph_logger/lib/create_logger.dart | 6 +- .../dart/morph_logger/lib/logger_plugin.dart | 2 +- .../morph_oauth2/lib/src/oauth2_plugin.dart | 4 +- .../morph_oauth2/lib/src/oauth_callbacks.dart | 2 +- .../morph_oauth2/lib/src/token_lifecycle.dart | 2 +- .../morph_oauth2/lib/src/token_vault.dart | 2 +- .../{ => ts}/browser-storage/package.json | 0 .../browser-storage/src/browserStorage.ts | 0 .../{ => ts}/browser-storage/src/index.ts | 0 .../{ => ts}/browser-storage/tsconfig.json | 0 .../{ => ts}/browser-storage/vite.config.ts | 0 packages/{ => ts}/core/package.json | 0 .../{ => ts}/core/src/client/AuthHandle.ts | 0 .../{ => ts}/core/src/client/HostClient.ts | 0 .../{ => ts}/core/src/client/MorphClient.ts | 0 .../{ => ts}/core/src/config/interpolate.ts | 0 packages/{ => ts}/core/src/config/validate.ts | 0 packages/{ => ts}/core/src/errors.ts | 0 .../{ => ts}/core/src/http/hostPipeline.ts | 0 packages/{ => ts}/core/src/index.ts | 0 packages/{ => ts}/core/src/runtime.ts | 0 packages/{ => ts}/core/src/types.ts | 0 packages/{ => ts}/core/src/util/duration.ts | 0 .../{ => ts}/core/src/util/exchangeSources.ts | 0 packages/{ => ts}/core/src/util/expiry.ts | 0 packages/{ => ts}/core/src/util/httpTrace.ts | 0 packages/{ => ts}/core/src/util/jwt.ts | 0 .../{ => ts}/core/src/util/normalizeOrigin.ts | 0 .../{ => ts}/core/src/util/oauthAuthorize.ts | 0 .../{ => ts}/core/src/util/oauthReturn.ts | 0 packages/{ => ts}/core/src/util/oauthState.ts | 0 packages/{ => ts}/core/src/util/url.ts | 0 packages/{ => ts}/core/tsconfig.json | 0 packages/{ => ts}/core/vite.config.ts | 0 packages/{ => ts}/logger/package.json | 0 packages/{ => ts}/logger/src/index.ts | 0 packages/{ => ts}/logger/tsconfig.json | 0 packages/{ => ts}/logger/vite.config.ts | 0 packages/{ => ts}/oauth2/package.json | 0 packages/{ => ts}/oauth2/src/index.ts | 0 .../{ => ts}/oauth2/src/oauth/tokenHttp.ts | 0 .../oauth2/src/tokens/tokenLifecycle.ts | 0 .../{ => ts}/oauth2/src/tokens/tokenVault.ts | 0 packages/{ => ts}/oauth2/src/util/duration.ts | 0 .../oauth2/src/util/exchangeSources.ts | 0 packages/{ => ts}/oauth2/src/util/expiry.ts | 0 .../{ => ts}/oauth2/src/util/interpolate.ts | 0 .../{ => ts}/oauth2/src/util/listAuthIds.ts | 0 .../oauth2/src/util/normalizeOrigin.ts | 0 .../oauth2/src/util/oauthAuthorize.ts | 0 .../{ => ts}/oauth2/src/util/oauthReturn.ts | 0 .../{ => ts}/oauth2/src/util/oauthState.ts | 0 packages/{ => ts}/oauth2/src/util/url.ts | 0 packages/{ => ts}/oauth2/tsconfig.json | 0 packages/{ => ts}/oauth2/vite.config.ts | 0 poc/ts-vue/package.json | 8 +- 82 files changed, 243 insertions(+), 275 deletions(-) rename packages/{ => ts}/browser-storage/package.json (100%) rename packages/{ => ts}/browser-storage/src/browserStorage.ts (100%) rename packages/{ => ts}/browser-storage/src/index.ts (100%) rename packages/{ => ts}/browser-storage/tsconfig.json (100%) rename packages/{ => ts}/browser-storage/vite.config.ts (100%) rename packages/{ => ts}/core/package.json (100%) rename packages/{ => ts}/core/src/client/AuthHandle.ts (100%) rename packages/{ => ts}/core/src/client/HostClient.ts (100%) rename packages/{ => ts}/core/src/client/MorphClient.ts (100%) rename packages/{ => ts}/core/src/config/interpolate.ts (100%) rename packages/{ => ts}/core/src/config/validate.ts (100%) rename packages/{ => ts}/core/src/errors.ts (100%) rename packages/{ => ts}/core/src/http/hostPipeline.ts (100%) rename packages/{ => ts}/core/src/index.ts (100%) rename packages/{ => ts}/core/src/runtime.ts (100%) rename packages/{ => ts}/core/src/types.ts (100%) rename packages/{ => ts}/core/src/util/duration.ts (100%) rename packages/{ => ts}/core/src/util/exchangeSources.ts (100%) rename packages/{ => ts}/core/src/util/expiry.ts (100%) rename packages/{ => ts}/core/src/util/httpTrace.ts (100%) rename packages/{ => ts}/core/src/util/jwt.ts (100%) rename packages/{ => ts}/core/src/util/normalizeOrigin.ts (100%) rename packages/{ => ts}/core/src/util/oauthAuthorize.ts (100%) rename packages/{ => ts}/core/src/util/oauthReturn.ts (100%) rename packages/{ => ts}/core/src/util/oauthState.ts (100%) rename packages/{ => ts}/core/src/util/url.ts (100%) rename packages/{ => ts}/core/tsconfig.json (100%) rename packages/{ => ts}/core/vite.config.ts (100%) rename packages/{ => ts}/logger/package.json (100%) rename packages/{ => ts}/logger/src/index.ts (100%) rename packages/{ => ts}/logger/tsconfig.json (100%) rename packages/{ => ts}/logger/vite.config.ts (100%) rename packages/{ => ts}/oauth2/package.json (100%) rename packages/{ => ts}/oauth2/src/index.ts (100%) rename packages/{ => ts}/oauth2/src/oauth/tokenHttp.ts (100%) rename packages/{ => ts}/oauth2/src/tokens/tokenLifecycle.ts (100%) rename packages/{ => ts}/oauth2/src/tokens/tokenVault.ts (100%) rename packages/{ => ts}/oauth2/src/util/duration.ts (100%) rename packages/{ => ts}/oauth2/src/util/exchangeSources.ts (100%) rename packages/{ => ts}/oauth2/src/util/expiry.ts (100%) rename packages/{ => ts}/oauth2/src/util/interpolate.ts (100%) rename packages/{ => ts}/oauth2/src/util/listAuthIds.ts (100%) rename packages/{ => ts}/oauth2/src/util/normalizeOrigin.ts (100%) rename packages/{ => ts}/oauth2/src/util/oauthAuthorize.ts (100%) rename packages/{ => ts}/oauth2/src/util/oauthReturn.ts (100%) rename packages/{ => ts}/oauth2/src/util/oauthState.ts (100%) rename packages/{ => ts}/oauth2/src/util/url.ts (100%) rename packages/{ => ts}/oauth2/tsconfig.json (100%) rename packages/{ => ts}/oauth2/vite.config.ts (100%) diff --git a/Makefile b/Makefile index 861c954..df140de 100644 --- a/Makefile +++ b/Makefile @@ -13,18 +13,18 @@ help: ## Show available targets # --------------------------------------------------------------------------- install: ## Install all npm dependencies (packages + vue + mock-api) - cd packages/core && npm install - cd packages/oauth2 && npm install - cd packages/browser-storage && npm install - cd packages/logger && npm install + 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/core && npm run build - cd packages/oauth2 && npm run build - cd packages/browser-storage && npm run build - cd packages/logger && npm run build + 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 @@ -105,10 +105,10 @@ stop: down ## Alias for 'down' # --------------------------------------------------------------------------- clean: ## Remove node_modules and dist artefacts - rm -rf packages/core/node_modules packages/core/dist - rm -rf packages/oauth2/node_modules packages/oauth2/dist - rm -rf packages/browser-storage/node_modules packages/browser-storage/dist - rm -rf packages/logger/node_modules packages/logger/dist + 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 6c6e4a8..4547228 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,10 @@ See **[docs/README.md](docs/README.md)** for the full documentation index. | Path | Package | Role | |------|---------|------| -| `packages/core/` | `@morph/core` | Types, config, HTTP pipeline, MorphClient, AuthHandle | -| `packages/oauth2/` | `@morph/oauth2` | TokenLifecycle, TokenVault, OAuth helpers | -| `packages/browser-storage/` | `@morph/browser-storage` | sessionStorage / localStorage adapters | -| `packages/logger/` | `@morph/logger` | Structured logging and HTTP trace plugin | +| `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 | diff --git a/docs/architecture.md b/docs/architecture.md index 8cb1f08..e178f08 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,29 +156,35 @@ The `allowedAuth` list enables **request-time validation**: if a request through ``` morph-api-client/ ├── packages/ -│ ├── 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 +│ ├── 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 diff --git a/docs/dart-parity.md b/docs/dart-parity.md index 8d91d23..a372681 100644 --- a/docs/dart-parity.md +++ b/docs/dart-parity.md @@ -1,7 +1,7 @@ # Dart / Flutter SDK parity -The TypeScript implementation is the reference (`packages/core`, `packages/oauth2`, -`packages/browser-storage`, `packages/logger`). **Dart/Flutter parity** is tracked on +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). diff --git a/docs/github-issue-1-body-issue-only.md b/docs/github-issue-1-body-issue-only.md index 2187e19..e2de29f 100644 --- a/docs/github-issue-1-body-issue-only.md +++ b/docs/github-issue-1-body-issue-only.md @@ -20,8 +20,8 @@ Local run: `make dart-all` or `cd packages/dart/morph_core && dart pub get && da ## Later phases (sub-issues or checklist) -- [ ] Strongly typed `MorphConfig` / `MorphOptions` aligned with `packages/core/src/types.ts` -- [ ] Runtime + topological plugin install (parity with `packages/core/src/runtime.ts`) +- [ ] 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 diff --git a/docs/github-issue-1-body.md b/docs/github-issue-1-body.md index 4006f66..4fdf59c 100644 --- a/docs/github-issue-1-body.md +++ b/docs/github-issue-1-body.md @@ -23,8 +23,8 @@ Local run: `make dart-all` or `cd packages/dart/morph_core && dart pub get && da ## Later phases (sub-issues or checklist) -- [ ] Strongly typed `MorphConfig` / `MorphOptions` aligned with `packages/core/src/types.ts` -- [ ] Runtime + topological plugin install (parity with `packages/core/src/runtime.ts`) +- [ ] 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 diff --git a/docs/github-issue-dart-full-parity-body.md b/docs/github-issue-dart-full-parity-body.md index 453ad19..1c642c2 100644 --- a/docs/github-issue-dart-full-parity-body.md +++ b/docs/github-issue-dart-full-parity-body.md @@ -1,6 +1,6 @@ ## Goal -Deliver a **Dart/Flutter Morph API Client** with **functional parity** to the existing TypeScript implementation (`packages/core`, `packages/oauth2`, `packages/browser-storage`, `packages/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). +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`**). diff --git a/package-lock.json b/package-lock.json index 506ff17..d4d3374 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,19 +6,9 @@ "": { "name": "morph-api-client-repo", "workspaces": [ - "packages/*" + "packages/ts/*" ] }, - "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" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -40,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": { @@ -553,19 +543,19 @@ } }, "node_modules/@morph/browser-storage": { - "resolved": "packages/browser-storage", + "resolved": "packages/ts/browser-storage", "link": true }, "node_modules/@morph/core": { - "resolved": "packages/core", + "resolved": "packages/ts/core", "link": true }, "node_modules/@morph/logger": { - "resolved": "packages/logger", + "resolved": "packages/ts/logger", "link": true }, "node_modules/@morph/oauth2": { - "resolved": "packages/oauth2", + "resolved": "packages/ts/oauth2", "link": true }, "node_modules/@rollup/pluginutils": { @@ -592,9 +582,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "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" ], @@ -606,9 +596,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "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" ], @@ -620,9 +610,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "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" ], @@ -634,9 +624,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "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" ], @@ -648,9 +638,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "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" ], @@ -662,9 +652,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "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" ], @@ -676,16 +666,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "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" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -693,16 +680,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "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" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -710,16 +694,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "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" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -727,16 +708,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "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" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -744,16 +722,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "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" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -761,16 +736,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "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" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -778,16 +750,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "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" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -795,16 +764,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "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" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -812,16 +778,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "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" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -829,16 +792,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "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" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -846,16 +806,13 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "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" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -863,16 +820,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "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" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -880,16 +834,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "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" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -897,9 +848,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "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" ], @@ -911,9 +862,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "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" ], @@ -925,9 +876,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "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" ], @@ -939,9 +890,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "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" ], @@ -953,9 +904,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "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" ], @@ -967,9 +918,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "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" ], @@ -1092,28 +1043,28 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", - "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "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.2", - "@vue/shared": "3.5.32", + "@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.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", - "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "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.32", - "@vue/shared": "3.5.32" + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" } }, "node_modules/@vue/language-core": { @@ -1143,9 +1094,9 @@ } }, "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "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": { @@ -1169,9 +1120,9 @@ } }, "node_modules/@vue/shared": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", - "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", "dev": true, "license": "MIT" }, @@ -1210,9 +1161,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "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": { @@ -1283,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", @@ -1401,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": { @@ -1554,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": [ { @@ -1607,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": [ { @@ -1646,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" @@ -1667,9 +1629,9 @@ } }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "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": { @@ -1683,31 +1645,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@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" } }, @@ -1995,7 +1957,7 @@ "commander": "^9.4.1" } }, - "packages/browser-storage": { + "packages/ts/browser-storage": { "name": "@morph/browser-storage", "version": "0.1.0", "dependencies": { @@ -2007,7 +1969,7 @@ "vite-plugin-dts": "^3.9.1" } }, - "packages/core": { + "packages/ts/core": { "name": "@morph/core", "version": "0.1.0", "devDependencies": { @@ -2016,7 +1978,7 @@ "vite-plugin-dts": "^3.9.1" } }, - "packages/logger": { + "packages/ts/logger": { "name": "@morph/logger", "version": "0.1.0", "dependencies": { @@ -2028,7 +1990,7 @@ "vite-plugin-dts": "^3.9.1" } }, - "packages/oauth2": { + "packages/ts/oauth2": { "name": "@morph/oauth2", "version": "0.1.0", "dependencies": { diff --git a/package.json b/package.json index 6a793bd..96add7e 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,14 @@ "private": true, "description": "Morph API Client — TS SDK packages + PoC apps", "workspaces": [ - "packages/*" + "packages/ts/*" ], "scripts": { "dev": "npm run dev --prefix poc/ts-vue -- --host 127.0.0.1", - "build:core": "npm run build --prefix packages/core", - "build:oauth2": "npm run build --prefix packages/oauth2", - "build:storage": "npm run build --prefix packages/browser-storage", - "build:logger": "npm run build --prefix packages/logger", + "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:packages && npm run build:vue" diff --git a/packages/dart/morph_core/README.md b/packages/dart/morph_core/README.md index 7860b54..7a83d0c 100644 --- a/packages/dart/morph_core/README.md +++ b/packages/dart/morph_core/README.md @@ -6,7 +6,7 @@ Dart port of **`@morph/core`** (Morph API Client). See ## Implemented (vs TypeScript) - **`validateAndIndexConfig`** — same validation rules and indexes as - `packages/core/src/config/validate.ts` (throws **`ConfigValidationError`** with + `packages/ts/core/src/config/validate.ts` (throws **`ConfigValidationError`** with aggregated messages). - **`normalizeExchangeSources`**, **`listAuthIdsForProvider`** — parity with TS helpers. - **`MorphClient.init`** — runs config validation, then throws **`UnimplementedError`** diff --git a/packages/dart/morph_core/lib/src/client/auth_handle.dart b/packages/dart/morph_core/lib/src/client/auth_handle.dart index 676b83b..198f1d8 100644 --- a/packages/dart/morph_core/lib/src/client/auth_handle.dart +++ b/packages/dart/morph_core/lib/src/client/auth_handle.dart @@ -3,7 +3,7 @@ import '../runtime/morph_runtime.dart'; import '../types/morph_surface.dart'; import '../util/jwt_utils.dart'; -/// Parity: [`AuthHandle`](packages/core/src/client/AuthHandle.ts). +/// Parity: [`AuthHandle`](packages/ts/core/src/client/AuthHandle.ts). final class AuthHandle { AuthHandle(this._rt, this.authId); diff --git a/packages/dart/morph_core/lib/src/client/host_client.dart b/packages/dart/morph_core/lib/src/client/host_client.dart index c824adc..e0ea1ca 100644 --- a/packages/dart/morph_core/lib/src/client/host_client.dart +++ b/packages/dart/morph_core/lib/src/client/host_client.dart @@ -2,7 +2,7 @@ import '../runtime/morph_runtime.dart'; import '../types/morph_surface.dart'; import '../types/morph_types.dart'; -/// Parity: [`HostClient`](packages/core/src/client/HostClient.ts). +/// Parity: [`HostClient`](packages/ts/core/src/client/HostClient.ts). final class HostClient { HostClient(this._rt, this.host); diff --git a/packages/dart/morph_core/lib/src/config/exchange_sources.dart b/packages/dart/morph_core/lib/src/config/exchange_sources.dart index 3f5d3a3..090e273 100644 --- a/packages/dart/morph_core/lib/src/config/exchange_sources.dart +++ b/packages/dart/morph_core/lib/src/config/exchange_sources.dart @@ -1,6 +1,6 @@ import 'package:morph_core/src/types/morph_types.dart'; -/// Parity with TS [normalizeExchangeSources](packages/core/src/util/exchangeSources.ts). +/// Parity with TS [normalizeExchangeSources](packages/ts/core/src/util/exchangeSources.ts). List normalizeExchangeSourcesFromTokenBlock(TokenBlock token) { final ex = token.exchangeSource; if (ex == null) return []; diff --git a/packages/dart/morph_core/lib/src/config/interpolate_config.dart b/packages/dart/morph_core/lib/src/config/interpolate_config.dart index 2542c63..86a8838 100644 --- a/packages/dart/morph_core/lib/src/config/interpolate_config.dart +++ b/packages/dart/morph_core/lib/src/config/interpolate_config.dart @@ -1,6 +1,6 @@ final _varPattern = RegExp(r'\$([a-zA-Z_][a-zA-Z0-9_]*)'); -/// Replaces `$variable` tokens (parity: [packages/core/src/config/interpolate.ts]). +/// Replaces `$variable` tokens (parity: [packages/ts/core/src/config/interpolate.ts]). String interpolateString( String template, Map variables, [ 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 index 5e577c4..4606527 100644 --- a/packages/dart/morph_core/lib/src/config/list_auth_ids.dart +++ b/packages/dart/morph_core/lib/src/config/list_auth_ids.dart @@ -1,6 +1,6 @@ import 'package:morph_core/src/config/resolved_morph_config.dart'; -/// Parity with TS [listAuthIdsForProvider](packages/core/src/config/validate.ts). +/// 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); diff --git a/packages/dart/morph_core/lib/src/config/validate_config.dart b/packages/dart/morph_core/lib/src/config/validate_config.dart index 86695e5..0bdfdd2 100644 --- a/packages/dart/morph_core/lib/src/config/validate_config.dart +++ b/packages/dart/morph_core/lib/src/config/validate_config.dart @@ -8,7 +8,7 @@ import 'package:morph_core/src/types/morph_types.dart'; /// 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/core/src/config/validate.ts). +/// [@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) { diff --git a/packages/dart/morph_core/lib/src/errors/morph_errors.dart b/packages/dart/morph_core/lib/src/errors/morph_errors.dart index a57b0e4..e63dfc1 100644 --- a/packages/dart/morph_core/lib/src/errors/morph_errors.dart +++ b/packages/dart/morph_core/lib/src/errors/morph_errors.dart @@ -1,4 +1,4 @@ -// Core errors mirroring `@morph/core` packages/core/src/errors.ts. +// Core errors mirroring `@morph/core` packages/ts/core/src/errors.ts. final class ConfigValidationError implements Exception { ConfigValidationError(this.errors) : assert(errors.isNotEmpty); diff --git a/packages/dart/morph_core/lib/src/http/host_pipeline.dart b/packages/dart/morph_core/lib/src/http/host_pipeline.dart index 6a27275..c04dcb3 100644 --- a/packages/dart/morph_core/lib/src/http/host_pipeline.dart +++ b/packages/dart/morph_core/lib/src/http/host_pipeline.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:morph_core/morph_core.dart'; -/// HTTP host pipeline parity with `packages/core/src/http/hostPipeline.ts`. +/// HTTP host pipeline parity with `packages/ts/core/src/http/hostPipeline.ts`. final class HostPipeline { HostPipeline({ required ResolvedMorphConfig resolved, diff --git a/packages/dart/morph_core/lib/src/runtime/morph_runtime.dart b/packages/dart/morph_core/lib/src/runtime/morph_runtime.dart index 8310274..46651bb 100644 --- a/packages/dart/morph_core/lib/src/runtime/morph_runtime.dart +++ b/packages/dart/morph_core/lib/src/runtime/morph_runtime.dart @@ -30,7 +30,7 @@ final class ParsedAuthProvider extends ParsedAuthRef { final String providerKey; } -/// Parity: [`MorphRuntime`](/packages/core/src/runtime.ts). +/// Parity: [`MorphRuntime`](/packages/ts/core/src/runtime.ts). final class MorphRuntime { MorphRuntime( this.resolved, diff --git a/packages/dart/morph_core/lib/src/runtime/plugin_install.dart b/packages/dart/morph_core/lib/src/runtime/plugin_install.dart index b7293cd..d7faa33 100644 --- a/packages/dart/morph_core/lib/src/runtime/plugin_install.dart +++ b/packages/dart/morph_core/lib/src/runtime/plugin_install.dart @@ -2,7 +2,7 @@ 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/core/src/runtime.ts`). +/// [MorphPlugin.provides] from other plugins (parity `packages/ts/core/src/runtime.ts`). List topoSortPlugins(List plugins) { if (plugins.isEmpty) return []; diff --git a/packages/dart/morph_core/lib/src/util/duration_ms.dart b/packages/dart/morph_core/lib/src/util/duration_ms.dart index a8f9934..df3fcbb 100644 --- a/packages/dart/morph_core/lib/src/util/duration_ms.dart +++ b/packages/dart/morph_core/lib/src/util/duration_ms.dart @@ -7,7 +7,7 @@ const Map _unitMs = { }; /// Parses strings like `"200ms"`, `"10s"`, `"30d"` into milliseconds. -/// Parity: [packages/core/src/util/duration.ts](packages/core/src/util/duration.ts). +/// 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; diff --git a/packages/dart/morph_core/lib/src/util/expiry.dart b/packages/dart/morph_core/lib/src/util/expiry.dart index 070799f..7401620 100644 --- a/packages/dart/morph_core/lib/src/util/expiry.dart +++ b/packages/dart/morph_core/lib/src/util/expiry.dart @@ -1,7 +1,7 @@ import 'package:morph_core/src/util/duration_ms.dart'; import 'package:morph_core/src/util/jwt_utils.dart'; -/// Parity [packages/oauth2/src/util/expiry.ts]. +/// 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; diff --git a/packages/dart/morph_core/lib/src/util/jwt_utils.dart b/packages/dart/morph_core/lib/src/util/jwt_utils.dart index 16a6524..127d2b2 100644 --- a/packages/dart/morph_core/lib/src/util/jwt_utils.dart +++ b/packages/dart/morph_core/lib/src/util/jwt_utils.dart @@ -9,7 +9,7 @@ String _base64UrlDecode(String s) { return utf8.decode(base64Decode(b64)); } -/// Parity: [packages/core/src/util/jwt.ts](packages/core/src/util/jwt.ts). +/// 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'); diff --git a/packages/dart/morph_core/lib/src/util/oauth_authorize.dart b/packages/dart/morph_core/lib/src/util/oauth_authorize.dart index 0ff4c26..9260b97 100644 --- a/packages/dart/morph_core/lib/src/util/oauth_authorize.dart +++ b/packages/dart/morph_core/lib/src/util/oauth_authorize.dart @@ -1,7 +1,7 @@ import 'package:morph_core/src/util/resolve_endpoint.dart'; /// Builds an OAuth 2.0 authorization redirect URL. -/// Parity: [packages/core/src/util/oauthAuthorize.ts](packages/core/src/util/oauthAuthorize.ts). +/// Parity: [packages/ts/core/src/util/oauthAuthorize.ts](packages/ts/core/src/util/oauthAuthorize.ts). String buildOAuth2AuthorizationUrl({ required String baseUrl, required String authorizationPath, diff --git a/packages/dart/morph_core/lib/src/util/oauth_state.dart b/packages/dart/morph_core/lib/src/util/oauth_state.dart index fddefda..f3747db 100644 --- a/packages/dart/morph_core/lib/src/util/oauth_state.dart +++ b/packages/dart/morph_core/lib/src/util/oauth_state.dart @@ -4,7 +4,7 @@ import 'dart:math'; const _prefix = 'morph1.'; /// Encodes [authId] into OAuth state (URL-safe base64 payload). -/// Parity: [packages/core/src/util/oauthState.ts](packages/core/src/util/oauthState.ts). +/// 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}); diff --git a/packages/dart/morph_core/lib/src/util/resolve_endpoint.dart b/packages/dart/morph_core/lib/src/util/resolve_endpoint.dart index 8854c95..e7cad9a 100644 --- a/packages/dart/morph_core/lib/src/util/resolve_endpoint.dart +++ b/packages/dart/morph_core/lib/src/util/resolve_endpoint.dart @@ -1,4 +1,4 @@ -/// Combines base URL and path/absolute URL (parity: [packages/core/src/util/url.ts]). +/// 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; diff --git a/packages/dart/morph_logger/lib/create_logger.dart b/packages/dart/morph_logger/lib/create_logger.dart index 8c06232..1253b8f 100644 --- a/packages/dart/morph_logger/lib/create_logger.dart +++ b/packages/dart/morph_logger/lib/create_logger.dart @@ -2,7 +2,7 @@ import 'dart:developer' as dev; import 'package:morph_core/morph_core.dart'; -/// Same level names as TS [`LogLevel`](/packages/logger/src/index.ts). +/// Same level names as TS [`LogLevel`](/packages/ts/logger/src/index.ts). typedef LogLevel = String; const Map _levelOrder = { @@ -12,7 +12,7 @@ const Map _levelOrder = { 'error': 3, }; -/// Parity [`LoggerPluginOptions`](/packages/logger/src/index.ts). +/// Parity [`LoggerPluginOptions`](/packages/ts/logger/src/index.ts). final class LoggerPluginOptions { const LoggerPluginOptions({ this.level = 'info', @@ -59,7 +59,7 @@ MorphLogFn _defaultLog(String prefix, LogLevel minLevel) { }; } -/// Returns a [`MorphLogFn`] (TS [`createLogger`](/packages/logger/src/index.ts)). +/// 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 index f70ec5d..69c8edf 100644 --- a/packages/dart/morph_logger/lib/logger_plugin.dart +++ b/packages/dart/morph_logger/lib/logger_plugin.dart @@ -2,7 +2,7 @@ import 'package:morph_core/morph_core.dart'; import 'create_logger.dart'; -/// Parity [`loggerPlugin`](/packages/logger/src/index.ts). +/// Parity [`loggerPlugin`](/packages/ts/logger/src/index.ts). MorphPlugin loggerPlugin([LoggerPluginOptions? opts]) => _LoggerMorphPlugin(opts ?? const LoggerPluginOptions()); diff --git a/packages/dart/morph_oauth2/lib/src/oauth2_plugin.dart b/packages/dart/morph_oauth2/lib/src/oauth2_plugin.dart index 491775c..ee7b4c4 100644 --- a/packages/dart/morph_oauth2/lib/src/oauth2_plugin.dart +++ b/packages/dart/morph_oauth2/lib/src/oauth2_plugin.dart @@ -3,7 +3,7 @@ import 'package:morph_core/morph_core.dart'; import 'oauth_callbacks.dart'; import 'token_lifecycle.dart'; -/// Options for [oauth2Plugin] ([`OAuth2PluginOptions`](../packages/oauth2/src/index.ts) parity). +/// Options for [oauth2Plugin]. TS parity: [`OAuth2PluginOptions`](/packages/ts/oauth2/src/index.ts). final class OAuth2PluginOptions { const OAuth2PluginOptions({ this.storage, @@ -38,7 +38,7 @@ MorphLogFn? _resolveLogFn(Object? logger, MorphPluginContext ctx) { return ctx.options.onLog; } -/// OAuth2 auth plugin parity with TS [`oauth2Plugin`](../packages/oauth2/src/index.ts). +/// OAuth2 auth plugin parity with TS [`oauth2Plugin`](/packages/ts/oauth2/src/index.ts). MorphPlugin oauth2Plugin([OAuth2PluginOptions? opts]) => _OAuth2MorphPlugin(opts ?? const OAuth2PluginOptions()); diff --git a/packages/dart/morph_oauth2/lib/src/oauth_callbacks.dart b/packages/dart/morph_oauth2/lib/src/oauth_callbacks.dart index 4eb15be..dd52adf 100644 --- a/packages/dart/morph_oauth2/lib/src/oauth_callbacks.dart +++ b/packages/dart/morph_oauth2/lib/src/oauth_callbacks.dart @@ -13,7 +13,7 @@ final class MorphOAuthCallbacksPartial { final void Function(String authId, TokenSet? tokens)? onTokenChange; } -/// Callbacks matching TS [`MorphCallbacks`](../packages/core/src/types.ts). +/// Callbacks matching TS [`MorphCallbacks`](/packages/ts/core/src/types.ts). final class MorphOAuthCallbacks { MorphOAuthCallbacks({ required this.onAuthRequired, diff --git a/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart b/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart index 30fe39c..c6ac278 100644 --- a/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart +++ b/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart @@ -27,7 +27,7 @@ const _grantTokenExchange = 'urn:ietf:params:oauth:grant-type:token-exchange'; const _accessTokenSubjectType = 'urn:ietf:params:oauth:token-type:access_token'; -/// Implements [`AuthPlugin`] — parity `packages/oauth2/src/tokens/tokenLifecycle.ts`. +/// Implements [`AuthPlugin`] — parity `packages/ts/oauth2/src/tokens/tokenLifecycle.ts`. final class TokenLifecycle implements AuthPlugin { TokenLifecycle( this.resolved, diff --git a/packages/dart/morph_oauth2/lib/src/token_vault.dart b/packages/dart/morph_oauth2/lib/src/token_vault.dart index ddbd1e4..1ed7ea8 100644 --- a/packages/dart/morph_oauth2/lib/src/token_vault.dart +++ b/packages/dart/morph_oauth2/lib/src/token_vault.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:morph_core/morph_core.dart'; -/// Path manifest JSON stored beside token blobs (`packages/oauth2/src/tokens/tokenVault.ts`). +/// 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) => diff --git a/packages/browser-storage/package.json b/packages/ts/browser-storage/package.json similarity index 100% rename from packages/browser-storage/package.json rename to packages/ts/browser-storage/package.json diff --git a/packages/browser-storage/src/browserStorage.ts b/packages/ts/browser-storage/src/browserStorage.ts similarity index 100% rename from packages/browser-storage/src/browserStorage.ts rename to packages/ts/browser-storage/src/browserStorage.ts diff --git a/packages/browser-storage/src/index.ts b/packages/ts/browser-storage/src/index.ts similarity index 100% rename from packages/browser-storage/src/index.ts rename to packages/ts/browser-storage/src/index.ts diff --git a/packages/browser-storage/tsconfig.json b/packages/ts/browser-storage/tsconfig.json similarity index 100% rename from packages/browser-storage/tsconfig.json rename to packages/ts/browser-storage/tsconfig.json diff --git a/packages/browser-storage/vite.config.ts b/packages/ts/browser-storage/vite.config.ts similarity index 100% rename from packages/browser-storage/vite.config.ts rename to packages/ts/browser-storage/vite.config.ts diff --git a/packages/core/package.json b/packages/ts/core/package.json similarity index 100% rename from packages/core/package.json rename to packages/ts/core/package.json diff --git a/packages/core/src/client/AuthHandle.ts b/packages/ts/core/src/client/AuthHandle.ts similarity index 100% rename from packages/core/src/client/AuthHandle.ts rename to packages/ts/core/src/client/AuthHandle.ts diff --git a/packages/core/src/client/HostClient.ts b/packages/ts/core/src/client/HostClient.ts similarity index 100% rename from packages/core/src/client/HostClient.ts rename to packages/ts/core/src/client/HostClient.ts diff --git a/packages/core/src/client/MorphClient.ts b/packages/ts/core/src/client/MorphClient.ts similarity index 100% rename from packages/core/src/client/MorphClient.ts rename to packages/ts/core/src/client/MorphClient.ts diff --git a/packages/core/src/config/interpolate.ts b/packages/ts/core/src/config/interpolate.ts similarity index 100% rename from packages/core/src/config/interpolate.ts rename to packages/ts/core/src/config/interpolate.ts diff --git a/packages/core/src/config/validate.ts b/packages/ts/core/src/config/validate.ts similarity index 100% rename from packages/core/src/config/validate.ts rename to packages/ts/core/src/config/validate.ts diff --git a/packages/core/src/errors.ts b/packages/ts/core/src/errors.ts similarity index 100% rename from packages/core/src/errors.ts rename to packages/ts/core/src/errors.ts diff --git a/packages/core/src/http/hostPipeline.ts b/packages/ts/core/src/http/hostPipeline.ts similarity index 100% rename from packages/core/src/http/hostPipeline.ts rename to packages/ts/core/src/http/hostPipeline.ts diff --git a/packages/core/src/index.ts b/packages/ts/core/src/index.ts similarity index 100% rename from packages/core/src/index.ts rename to packages/ts/core/src/index.ts diff --git a/packages/core/src/runtime.ts b/packages/ts/core/src/runtime.ts similarity index 100% rename from packages/core/src/runtime.ts rename to packages/ts/core/src/runtime.ts diff --git a/packages/core/src/types.ts b/packages/ts/core/src/types.ts similarity index 100% rename from packages/core/src/types.ts rename to packages/ts/core/src/types.ts diff --git a/packages/core/src/util/duration.ts b/packages/ts/core/src/util/duration.ts similarity index 100% rename from packages/core/src/util/duration.ts rename to packages/ts/core/src/util/duration.ts diff --git a/packages/core/src/util/exchangeSources.ts b/packages/ts/core/src/util/exchangeSources.ts similarity index 100% rename from packages/core/src/util/exchangeSources.ts rename to packages/ts/core/src/util/exchangeSources.ts diff --git a/packages/core/src/util/expiry.ts b/packages/ts/core/src/util/expiry.ts similarity index 100% rename from packages/core/src/util/expiry.ts rename to packages/ts/core/src/util/expiry.ts diff --git a/packages/core/src/util/httpTrace.ts b/packages/ts/core/src/util/httpTrace.ts similarity index 100% rename from packages/core/src/util/httpTrace.ts rename to packages/ts/core/src/util/httpTrace.ts diff --git a/packages/core/src/util/jwt.ts b/packages/ts/core/src/util/jwt.ts similarity index 100% rename from packages/core/src/util/jwt.ts rename to packages/ts/core/src/util/jwt.ts diff --git a/packages/core/src/util/normalizeOrigin.ts b/packages/ts/core/src/util/normalizeOrigin.ts similarity index 100% rename from packages/core/src/util/normalizeOrigin.ts rename to packages/ts/core/src/util/normalizeOrigin.ts diff --git a/packages/core/src/util/oauthAuthorize.ts b/packages/ts/core/src/util/oauthAuthorize.ts similarity index 100% rename from packages/core/src/util/oauthAuthorize.ts rename to packages/ts/core/src/util/oauthAuthorize.ts diff --git a/packages/core/src/util/oauthReturn.ts b/packages/ts/core/src/util/oauthReturn.ts similarity index 100% rename from packages/core/src/util/oauthReturn.ts rename to packages/ts/core/src/util/oauthReturn.ts diff --git a/packages/core/src/util/oauthState.ts b/packages/ts/core/src/util/oauthState.ts similarity index 100% rename from packages/core/src/util/oauthState.ts rename to packages/ts/core/src/util/oauthState.ts diff --git a/packages/core/src/util/url.ts b/packages/ts/core/src/util/url.ts similarity index 100% rename from packages/core/src/util/url.ts rename to packages/ts/core/src/util/url.ts diff --git a/packages/core/tsconfig.json b/packages/ts/core/tsconfig.json similarity index 100% rename from packages/core/tsconfig.json rename to packages/ts/core/tsconfig.json diff --git a/packages/core/vite.config.ts b/packages/ts/core/vite.config.ts similarity index 100% rename from packages/core/vite.config.ts rename to packages/ts/core/vite.config.ts diff --git a/packages/logger/package.json b/packages/ts/logger/package.json similarity index 100% rename from packages/logger/package.json rename to packages/ts/logger/package.json diff --git a/packages/logger/src/index.ts b/packages/ts/logger/src/index.ts similarity index 100% rename from packages/logger/src/index.ts rename to packages/ts/logger/src/index.ts diff --git a/packages/logger/tsconfig.json b/packages/ts/logger/tsconfig.json similarity index 100% rename from packages/logger/tsconfig.json rename to packages/ts/logger/tsconfig.json diff --git a/packages/logger/vite.config.ts b/packages/ts/logger/vite.config.ts similarity index 100% rename from packages/logger/vite.config.ts rename to packages/ts/logger/vite.config.ts diff --git a/packages/oauth2/package.json b/packages/ts/oauth2/package.json similarity index 100% rename from packages/oauth2/package.json rename to packages/ts/oauth2/package.json diff --git a/packages/oauth2/src/index.ts b/packages/ts/oauth2/src/index.ts similarity index 100% rename from packages/oauth2/src/index.ts rename to packages/ts/oauth2/src/index.ts diff --git a/packages/oauth2/src/oauth/tokenHttp.ts b/packages/ts/oauth2/src/oauth/tokenHttp.ts similarity index 100% rename from packages/oauth2/src/oauth/tokenHttp.ts rename to packages/ts/oauth2/src/oauth/tokenHttp.ts diff --git a/packages/oauth2/src/tokens/tokenLifecycle.ts b/packages/ts/oauth2/src/tokens/tokenLifecycle.ts similarity index 100% rename from packages/oauth2/src/tokens/tokenLifecycle.ts rename to packages/ts/oauth2/src/tokens/tokenLifecycle.ts diff --git a/packages/oauth2/src/tokens/tokenVault.ts b/packages/ts/oauth2/src/tokens/tokenVault.ts similarity index 100% rename from packages/oauth2/src/tokens/tokenVault.ts rename to packages/ts/oauth2/src/tokens/tokenVault.ts diff --git a/packages/oauth2/src/util/duration.ts b/packages/ts/oauth2/src/util/duration.ts similarity index 100% rename from packages/oauth2/src/util/duration.ts rename to packages/ts/oauth2/src/util/duration.ts diff --git a/packages/oauth2/src/util/exchangeSources.ts b/packages/ts/oauth2/src/util/exchangeSources.ts similarity index 100% rename from packages/oauth2/src/util/exchangeSources.ts rename to packages/ts/oauth2/src/util/exchangeSources.ts diff --git a/packages/oauth2/src/util/expiry.ts b/packages/ts/oauth2/src/util/expiry.ts similarity index 100% rename from packages/oauth2/src/util/expiry.ts rename to packages/ts/oauth2/src/util/expiry.ts diff --git a/packages/oauth2/src/util/interpolate.ts b/packages/ts/oauth2/src/util/interpolate.ts similarity index 100% rename from packages/oauth2/src/util/interpolate.ts rename to packages/ts/oauth2/src/util/interpolate.ts diff --git a/packages/oauth2/src/util/listAuthIds.ts b/packages/ts/oauth2/src/util/listAuthIds.ts similarity index 100% rename from packages/oauth2/src/util/listAuthIds.ts rename to packages/ts/oauth2/src/util/listAuthIds.ts diff --git a/packages/oauth2/src/util/normalizeOrigin.ts b/packages/ts/oauth2/src/util/normalizeOrigin.ts similarity index 100% rename from packages/oauth2/src/util/normalizeOrigin.ts rename to packages/ts/oauth2/src/util/normalizeOrigin.ts diff --git a/packages/oauth2/src/util/oauthAuthorize.ts b/packages/ts/oauth2/src/util/oauthAuthorize.ts similarity index 100% rename from packages/oauth2/src/util/oauthAuthorize.ts rename to packages/ts/oauth2/src/util/oauthAuthorize.ts diff --git a/packages/oauth2/src/util/oauthReturn.ts b/packages/ts/oauth2/src/util/oauthReturn.ts similarity index 100% rename from packages/oauth2/src/util/oauthReturn.ts rename to packages/ts/oauth2/src/util/oauthReturn.ts diff --git a/packages/oauth2/src/util/oauthState.ts b/packages/ts/oauth2/src/util/oauthState.ts similarity index 100% rename from packages/oauth2/src/util/oauthState.ts rename to packages/ts/oauth2/src/util/oauthState.ts diff --git a/packages/oauth2/src/util/url.ts b/packages/ts/oauth2/src/util/url.ts similarity index 100% rename from packages/oauth2/src/util/url.ts rename to packages/ts/oauth2/src/util/url.ts diff --git a/packages/oauth2/tsconfig.json b/packages/ts/oauth2/tsconfig.json similarity index 100% rename from packages/oauth2/tsconfig.json rename to packages/ts/oauth2/tsconfig.json diff --git a/packages/oauth2/vite.config.ts b/packages/ts/oauth2/vite.config.ts similarity index 100% rename from packages/oauth2/vite.config.ts rename to packages/ts/oauth2/vite.config.ts diff --git a/poc/ts-vue/package.json b/poc/ts-vue/package.json index 3cb5105..2168bf3 100644 --- a/poc/ts-vue/package.json +++ b/poc/ts-vue/package.json @@ -9,10 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "@morph/core": "file:../../packages/core", - "@morph/oauth2": "file:../../packages/oauth2", - "@morph/browser-storage": "file:../../packages/browser-storage", - "@morph/logger": "file:../../packages/logger", + "@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" }, From 8c9d9cf68ae038e7370136846a1d4d1ff94b52f4 Mon Sep 17 00:00:00 2001 From: Cemal YILMAZ <206660090+cemal-yilmaz-bt@users.noreply.github.com> Date: Mon, 4 May 2026 22:46:27 +0300 Subject: [PATCH 26/30] docs(dart): align morph_core and morph_storage package metadata (#33) Closes #33 --- packages/dart/morph_core/README.md | 11 +++++++---- packages/dart/morph_core/pubspec.yaml | 4 ++-- packages/dart/morph_storage/pubspec.yaml | 5 ++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/dart/morph_core/README.md b/packages/dart/morph_core/README.md index 7a83d0c..b590859 100644 --- a/packages/dart/morph_core/README.md +++ b/packages/dart/morph_core/README.md @@ -9,11 +9,14 @@ Dart port of **`@morph/core`** (Morph API Client). See `packages/ts/core/src/config/validate.ts` (throws **`ConfigValidationError`** with aggregated messages). - **`normalizeExchangeSources`**, **`listAuthIdsForProvider`** — parity with TS helpers. -- **`MorphClient.init`** — runs config validation, then throws **`UnimplementedError`** - until runtime / OAuth / HTTP / plugins are ported ([#3](https://github.com/burgan-tech/morph-api-client/issues/3)). +- **`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`) are -planned; config is validated as decoded JSON maps for now. +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 diff --git a/packages/dart/morph_core/pubspec.yaml b/packages/dart/morph_core/pubspec.yaml index 8d07b8c..f4a6c83 100644 --- a/packages/dart/morph_core/pubspec.yaml +++ b/packages/dart/morph_core/pubspec.yaml @@ -1,7 +1,7 @@ name: morph_core description: > - Dart port of @morph/core (Morph API Client). Scaffold: mirrors TypeScript init - shape; OAuth, HTTP pipeline, and plugins unimplemented until parity milestones. + 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 diff --git a/packages/dart/morph_storage/pubspec.yaml b/packages/dart/morph_storage/pubspec.yaml index 871b298..1b44500 100644 --- a/packages/dart/morph_storage/pubspec.yaml +++ b/packages/dart/morph_storage/pubspec.yaml @@ -1,5 +1,8 @@ name: morph_storage -description: Storage adapters for Morph (Dart parity with @morph/browser-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 From 0723761ab1c5d1e5c2babadb9ef77e2d90a8ebb7 Mon Sep 17 00:00:00 2001 From: Cemal YILMAZ <206660090+cemal-yilmaz-bt@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:59:48 +0300 Subject: [PATCH 27/30] =?UTF-8?q?refactor(poc):=20address=20Gemini=20revie?= =?UTF-8?q?w=20=E2=80=94=20simplify=20isPocSessionDeadStop=20+=20reuse=20h?= =?UTF-8?q?ttp.Client=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isPocSessionDeadStop: replace any() closure with type check + contains() - SimulationPanel: shared http.Client for TCP keep-alive across sim steps - runPocSimStep: optional http.Client? param threaded to _runFetch --- poc/flutter-poc/lib/poc_simulation.dart | 12 ++++++------ poc/flutter-poc/lib/widgets/simulation_panel.dart | 10 +++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/poc/flutter-poc/lib/poc_simulation.dart b/poc/flutter-poc/lib/poc_simulation.dart index a7badc3..e3a9d48 100644 --- a/poc/flutter-poc/lib/poc_simulation.dart +++ b/poc/flutter-poc/lib/poc_simulation.dart @@ -82,9 +82,8 @@ bool isPocSessionDeadStop({ required List sessionDeadAuthIds, }) { if (result.status != 'AUTH') return false; - final isSessionDead = sessionDeadAuthIds.any( - (id) => step is PocSimHostStep && step.auth == id, - ); + final isSessionDead = + step is PocSimHostStep && sessionDeadAuthIds.contains(step.auth); final detail = result.detail ?? ''; return isSessionDead && (detail.contains('invalid_grant') || @@ -199,10 +198,11 @@ PocSimStep? _parseStep(Map m) { Future runPocSimStep( MorphClient morph, PocSimulationConfig cfg, - PocSimStep step, -) async { + PocSimStep step, [ + http.Client? httpClient, +]) async { return switch (step) { - PocSimFetchStep s => _runFetch(s, cfg.mockApiBaseUrl), + PocSimFetchStep s => _runFetch(s, cfg.mockApiBaseUrl, httpClient), PocSimHostStep s => _runHost(morph, s), PocSimLogoutStep s => _runLogout(morph, s), }; diff --git a/poc/flutter-poc/lib/widgets/simulation_panel.dart b/poc/flutter-poc/lib/widgets/simulation_panel.dart index 8efe9d5..448d277 100644 --- a/poc/flutter-poc/lib/widgets/simulation_panel.dart +++ b/poc/flutter-poc/lib/widgets/simulation_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:morph_core/morph_core.dart'; import '../poc_simulation.dart'; @@ -24,6 +25,13 @@ class _SimulationPanelState extends State { 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) => @@ -47,7 +55,7 @@ class _SimulationPanelState extends State { for (final step in _autoSteps) { if (!mounted) break; - final result = await runPocSimStep(widget.morph, widget.cfg, step); + final result = await runPocSimStep(widget.morph, widget.cfg, step, _httpClient); if (!mounted) break; setState(() => _results.add(result)); From 0bbf7517512b112590b30e4c6b2a4561208d34ff Mon Sep 17 00:00:00 2001 From: Cemal YILMAZ <206660090+cemal-yilmaz-bt@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:45:12 +0300 Subject: [PATCH 28/30] fix(dart): address 5 Gemini review findings on PR #37 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **High #1 — host_pipeline.dart: three body/header bugs** - List body was calling toString() → binary corruption; now uses bodyBytes directly - GET/HEAD with no body no longer sets bodyBytes = utf8.encode('') → no spurious Content-Length: 0 - Request-time headers (may contain $ in JWS signatures) are no longer passed through interpolateString; only host config headers are interpolated from variables **High #2 — token_lifecycle.dart: resolveAccessToken lock contention** - Add _tokenCache (Map) populated on every _persist - resolveAccessToken checks cache first; returns immediately if token is valid (skips lock acquisition and storage read on the hot path) - Cache is invalidated on clear/logout via _persist(set: null) **Medium #1 — validate_config.dart: no exchangeSource cycle detection** - Add DFS cycle detection after the unknown-ref check - Circular A→B→A chains now raise a ConfigValidationError at init time instead of deadlocking at runtime via nested _withLock calls **Medium #2 — oauth_authorize.dart: blindly appending '?'** - Use '&' instead of '?' when the resolved endpoint already contains query params **Medium #3 — token_http.dart: expires_in string cast** - Parse expires_in robustly: num.toInt() or int.tryParse(String) so identity providers returning "3600" no longer throw TypeError --- .../lib/src/config/validate_config.dart | 23 +++++++++++++++++++ .../lib/src/http/host_pipeline.dart | 21 ++++++++++++----- .../lib/src/util/oauth_authorize.dart | 2 +- .../dart/morph_oauth2/lib/src/token_http.dart | 11 ++++++++- .../morph_oauth2/lib/src/token_lifecycle.dart | 18 +++++++++++++-- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/packages/dart/morph_core/lib/src/config/validate_config.dart b/packages/dart/morph_core/lib/src/config/validate_config.dart index 0bdfdd2..87240f0 100644 --- a/packages/dart/morph_core/lib/src/config/validate_config.dart +++ b/packages/dart/morph_core/lib/src/config/validate_config.dart @@ -98,6 +98,29 @@ ResolvedMorphConfig validateAndIndexConfig(dynamic raw) { } } + // 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) { diff --git a/packages/dart/morph_core/lib/src/http/host_pipeline.dart b/packages/dart/morph_core/lib/src/http/host_pipeline.dart index c04dcb3..4de6814 100644 --- a/packages/dart/morph_core/lib/src/http/host_pipeline.dart +++ b/packages/dart/morph_core/lib/src/http/host_pipeline.dart @@ -113,10 +113,14 @@ final class HostPipeline { final timeoutMs = parseDurationMs(timeout, 30000); String? serialized; + List? bodyBytes; if (body == null) { serialized = null; - } else if (body is String || body is List) { - serialized = body.toString(); + } 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); } @@ -129,21 +133,26 @@ final class HostPipeline { 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, - ...?_interpolateHeaders(headers), + ...?headers, headerCfg.name: '${headerCfg.scheme} $token', }; Future once(Map hdrs) async { final req = http.Request(method, uri); req.headers.addAll(hdrs); - final b = serialized; - req.bodyBytes = utf8.encode(b ?? ''); - if (serialized != null) { + 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); } diff --git a/packages/dart/morph_core/lib/src/util/oauth_authorize.dart b/packages/dart/morph_core/lib/src/util/oauth_authorize.dart index 9260b97..a18cbf3 100644 --- a/packages/dart/morph_core/lib/src/util/oauth_authorize.dart +++ b/packages/dart/morph_core/lib/src/util/oauth_authorize.dart @@ -22,7 +22,7 @@ String buildOAuth2AuthorizationUrl({ if (scopes != null && scopes.isNotEmpty) { q['scope'] = scopes.join(' '); } - final buf = StringBuffer('$u?'); + final buf = StringBuffer(u)..write(u.contains('?') ? '&' : '?'); var first = true; void add(String k, String v) { if (!first) buf.write('&'); diff --git a/packages/dart/morph_oauth2/lib/src/token_http.dart b/packages/dart/morph_oauth2/lib/src/token_http.dart index 6505cef..e6519f6 100644 --- a/packages/dart/morph_oauth2/lib/src/token_http.dart +++ b/packages/dart/morph_oauth2/lib/src/token_http.dart @@ -20,10 +20,19 @@ final class OAuthTokenResponse { 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: (m['expires_in'] as num?)?.toInt(), + expiresIn: expiresIn, tokenType: m['token_type'] as String?, ); } diff --git a/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart b/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart index c6ac278..55010df 100644 --- a/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart +++ b/packages/dart/morph_oauth2/lib/src/token_lifecycle.dart @@ -47,10 +47,16 @@ final class TokenLifecycle implements AuthPlugin { 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]) { @@ -91,8 +97,10 @@ final class TokenLifecycle implements AuthPlugin { 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); } @@ -336,8 +344,14 @@ final class TokenLifecycle implements AuthPlugin { }); @override - Future resolveAccessToken(String aid, CtxRef ref, String mode) => - _withLock(aid, () => _resolveAccessInner(aid, ref, mode)); + 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); From 66716032ff48b08daef44163942509a1653a86f4 Mon Sep 17 00:00:00 2001 From: Cemal YILMAZ <206660090+cemal-yilmaz-bt@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:49:41 +0300 Subject: [PATCH 29/30] fix(dart): resolve dart analyze --fatal-infos failures - validate_config: rename _dfsSeen/_hasCycle local vars (no leading underscore for locals) - host_pipeline: remove now-unused _interpolateHeaders method (headers no longer interpolated at request time per Gemini fix) --- .../morph_core/lib/src/config/validate_config.dart | 12 ++++++------ .../dart/morph_core/lib/src/http/host_pipeline.dart | 2 -- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/dart/morph_core/lib/src/config/validate_config.dart b/packages/dart/morph_core/lib/src/config/validate_config.dart index 87240f0..4fa443a 100644 --- a/packages/dart/morph_core/lib/src/config/validate_config.dart +++ b/packages/dart/morph_core/lib/src/config/validate_config.dart @@ -100,22 +100,22 @@ ResolvedMorphConfig validateAndIndexConfig(dynamic raw) { // 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) { + final dfsSeen = {}; + bool hasCycle(String node, Set inStack) { if (inStack.contains(node)) return true; - if (_dfsSeen.contains(node)) return false; - _dfsSeen.add(node); + 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; + if (hasCycle(src, {...inStack})) return true; } } return false; } for (final authId in contextByAuthId.keys) { - if (_hasCycle(authId, {})) { + if (hasCycle(authId, {})) { errors.add('Circular token.exchangeSource dependency detected involving "$authId"'); break; } diff --git a/packages/dart/morph_core/lib/src/http/host_pipeline.dart b/packages/dart/morph_core/lib/src/http/host_pipeline.dart index 4de6814..27ee5d1 100644 --- a/packages/dart/morph_core/lib/src/http/host_pipeline.dart +++ b/packages/dart/morph_core/lib/src/http/host_pipeline.dart @@ -226,6 +226,4 @@ final class HostPipeline { ); } - Map? _interpolateHeaders(Map? h) => - interpolateRecord(h, _variables); } From fdbce85dc3eb19144393cb52183835ec36e1c21d Mon Sep 17 00:00:00 2001 From: ikarakayali Date: Mon, 6 Jul 2026 13:43:22 +0300 Subject: [PATCH 30/30] improvements --- docs/api-reference.md | 60 +++++++++++++++-- docs/getting-started.md | 28 +++++++- docs/poc/test-scenarios.md | 2 +- docs/token-lifecycle.md | 1 + packages/ts/core/src/client/AuthHandle.ts | 5 +- packages/ts/core/src/client/MorphClient.ts | 14 +++- packages/ts/core/src/runtime.ts | 13 ++-- packages/ts/core/src/types.ts | 43 ++++++++++++ packages/ts/oauth2/src/index.ts | 53 +++++++++++++-- packages/ts/oauth2/src/oauth/tokenHttp.ts | 18 +++-- .../oauth2/src/tokens/oauth2TokenOptions.ts | 10 +++ .../ts/oauth2/src/tokens/tokenLifecycle.ts | 39 ++++++----- poc/ts-vue/package-lock.json | 65 ++++++++++++++++--- poc/ts-vue/src/deviceIdentity.ts | 45 +++++++++++++ poc/ts-vue/src/morph.ts | 39 +---------- poc/ts-vue/src/views/HomeView.vue | 2 +- poc/ts-vue/vite.config.ts | 4 +- 17 files changed, 353 insertions(+), 88 deletions(-) create mode 100644 packages/ts/oauth2/src/tokens/oauth2TokenOptions.ts create mode 100644 poc/ts-vue/src/deviceIdentity.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index e6cf85b..1be59d2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -54,7 +54,39 @@ const morph = MorphClient.init(config, { }), ], - // --- Core-level options (shared, not plugin-specific) --- + callbacks: { + onAuthRequired: (authId, metadata) => { + // authId format: 'provider/context', e.g. 'burgan-auth/device' + if (metadata.interaction === 'non-interactive') { + morph.auth(authId).acquire(); + + } else if (metadata.interaction === 'interactive') { + router.navigate('/login', { authId, workflow: metadata.workflow }); + // ... later, after redirect returns a code: + morph.auth(authId).submitCode(code); + await morph.auth('burgan-auth/1fa').exchangeToken('burgan-auth/2fa'); + + } else if (metadata.interaction === 'redirect') { + window.location.href = `/auth/redirect?authId=${authId}`; + // ... later, after redirect returns a code: + // morph.auth(authId).submitCode(code, { codeVerifier }); + } + }, + + onLogout: (authId, reason) => { + console.log(`[${authId}] logged out: ${reason}`); + 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); + }, + }, + + // --- Functional delegates (SDK needs the result) --- networkDelegate: { async getNetworkConfig(hostname) { @@ -342,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= @@ -429,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. --- @@ -716,6 +748,22 @@ oauth2Plugin({ 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. + const [provider, context] = authId.split('/'); + + switch (metadata.interaction) { + case 'non-interactive': + morph.auth(authId).acquire(); + break; + case 'interactive': + router.navigate('/login', { authId }); + break; + case 'redirect': + window.location.href = `/auth/redirect?authId=${authId}`; + break; + } }, }) ``` @@ -775,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) @@ -804,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...', diff --git a/docs/getting-started.md b/docs/getting-started.md index 9d6eac3..90dde31 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -110,6 +110,32 @@ const morph = MorphClient.init(config, { autoAcquireNonInteractive: true, }), ], + storage: createBrowserSessionStorage('myapp:tk:'), + + callbacks: { + onAuthRequired: (authId, metadata) => { + if (metadata.interaction === 'non-interactive') { + morph.auth(authId).acquire(); + } else if (metadata.interaction === 'interactive') { + router.navigate('/login', { authId }); + } + }, + onLogout: (authId, reason) => { + console.log(`Logged out of ${authId}: ${reason}`); + if (authId === 'my-auth/user') { + router.navigate('/login'); + } + }, + }, + + variables: { + deviceClientId: 'device-client-abc', + deviceClientSecret: 'device-secret-xyz', + userClientId: 'user-client-def', + userClientSecret: 'user-secret-uvw', + oauthRedirectUri: `${window.location.origin}/oauth/callback`, + deviceId: getDeviceId(), + }, }); ``` @@ -180,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: 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 a188f5c..2707c12 100644 --- a/docs/token-lifecycle.md +++ b/docs/token-lifecycle.md @@ -59,6 +59,7 @@ All token endpoint calls (authorization_code, refresh_token, client_credentials, ### autoAcquireNonInteractive 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 diff --git a/packages/ts/core/src/client/AuthHandle.ts b/packages/ts/core/src/client/AuthHandle.ts index 7a0c7f5..f825f02 100644 --- a/packages/ts/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/packages/ts/core/src/client/MorphClient.ts b/packages/ts/core/src/client/MorphClient.ts index def656e..7d13695 100644 --- a/packages/ts/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/packages/ts/core/src/runtime.ts b/packages/ts/core/src/runtime.ts index 0be4ddf..7155404 100644 --- a/packages/ts/core/src/runtime.ts +++ b/packages/ts/core/src/runtime.ts @@ -262,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, }; } @@ -294,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 @@ -304,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/packages/ts/core/src/types.ts b/packages/ts/core/src/types.ts index e0b62f1..f5d09ba 100644 --- a/packages/ts/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 { @@ -299,6 +324,24 @@ export interface MorphOptions { context?: Record, ) => void; 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; + /** + * 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; diff --git a/packages/ts/oauth2/src/index.ts b/packages/ts/oauth2/src/index.ts index 0c6b168..2b70a68 100644 --- a/packages/ts/oauth2/src/index.ts +++ b/packages/ts/oauth2/src/index.ts @@ -1,19 +1,39 @@ 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/tokenLifecycle.js'; +import type { OAuth2TokenOptions } from './tokens/oauth2TokenOptions.js'; -export type { OAuth2TokenOptions } from './tokens/tokenLifecycle.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; @@ -22,6 +42,12 @@ export interface OAuth2PluginOptions { 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 { @@ -67,11 +93,12 @@ export function oauth2Plugin(opts?: OAuth2PluginOptions): MorphPlugin { log?.('info', `onLogout: ${authId} (${reason})`, undefined, { authId, reason }); }; - const variables = { ...ctx.variables, ...opts?.variables }; + 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, @@ -81,7 +108,9 @@ export function oauth2Plugin(opts?: OAuth2PluginOptions): MorphPlugin { autoAcquireNonInteractive: opts?.autoAcquireNonInteractive, onLog: log, }; - const auth = new TokenLifecycle(ctx.resolved, tokenOpts, variables, tokenOpts.onLog, storage); + 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); }, }; @@ -90,6 +119,22 @@ export function oauth2Plugin(opts?: OAuth2PluginOptions): MorphPlugin { 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'; diff --git a/packages/ts/oauth2/src/oauth/tokenHttp.ts b/packages/ts/oauth2/src/oauth/tokenHttp.ts index 68aa485..22a4aa0 100644 --- a/packages/ts/oauth2/src/oauth/tokenHttp.ts +++ b/packages/ts/oauth2/src/oauth/tokenHttp.ts @@ -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, + 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); 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/packages/ts/oauth2/src/tokens/tokenLifecycle.ts b/packages/ts/oauth2/src/tokens/tokenLifecycle.ts index 8e4c12e..cdbc904 100644 --- a/packages/ts/oauth2/src/tokens/tokenLifecycle.ts +++ b/packages/ts/oauth2/src/tokens/tokenLifecycle.ts @@ -3,7 +3,6 @@ import type { AuthPlugin, CtxRef, LogoutReason, - MorphCallbacks, ProviderConfig, StorageProvider, TokenExchangeGrant, @@ -11,15 +10,9 @@ import type { } from '@morph/core'; import type { ResolvedMorphConfig } from '@morph/core'; import { AuthError, UnknownContextError } from '@morph/core'; +import type { OAuth2TokenOptions } from './oauth2TokenOptions.js'; -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; -} +export type { OAuth2TokenOptions } from './oauth2TokenOptions.js'; import { TokenVault } from './tokenVault.js'; import { buildClientAuthFields, @@ -68,10 +61,6 @@ export class TokenLifecycle implements AuthPlugin { 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) { @@ -120,7 +109,7 @@ export class TokenLifecycle implements AuthPlugin { 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); } @@ -130,8 +119,21 @@ export class TokenLifecycle implements AuthPlugin { { 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 }); } @@ -174,7 +176,8 @@ export class TokenLifecycle implements AuthPlugin { 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 }); }); @@ -216,7 +219,7 @@ export class TokenLifecycle implements AuthPlugin { 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 }); } diff --git a/poc/ts-vue/package-lock.json b/poc/ts-vue/package-lock.json index 0fc65bb..3c5c020 100644 --- a/poc/ts-vue/package-lock.json +++ b/poc/ts-vue/package-lock.json @@ -8,10 +8,10 @@ "name": "ts-vue", "version": "0.0.0", "dependencies": { - "@morph/browser-storage": "file:../../packages/browser-storage", - "@morph/core": "file:../../packages/core", - "@morph/logger": "file:../../packages/logger", - "@morph/oauth2": "file:../../packages/oauth2", + "@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" }, @@ -37,6 +37,7 @@ "../../packages/browser-storage": { "name": "@morph/browser-storage", "version": "0.1.0", + "extraneous": true, "dependencies": { "@morph/core": "^0.1.0" }, @@ -49,6 +50,7 @@ "../../packages/core": { "name": "@morph/core", "version": "0.1.0", + "extraneous": true, "devDependencies": { "typescript": "^5.4.5", "vite": "^5.2.11", @@ -58,6 +60,7 @@ "../../packages/logger": { "name": "@morph/logger", "version": "0.1.0", + "extraneous": true, "dependencies": { "@morph/core": "^0.1.0" }, @@ -68,6 +71,52 @@ } }, "../../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": { @@ -166,19 +215,19 @@ "license": "MIT" }, "node_modules/@morph/browser-storage": { - "resolved": "../../packages/browser-storage", + "resolved": "../../packages/ts/browser-storage", "link": true }, "node_modules/@morph/core": { - "resolved": "../../packages/core", + "resolved": "../../packages/ts/core", "link": true }, "node_modules/@morph/logger": { - "resolved": "../../packages/logger", + "resolved": "../../packages/ts/logger", "link": true }, "node_modules/@morph/oauth2": { - "resolved": "../../packages/oauth2", + "resolved": "../../packages/ts/oauth2", "link": true }, "node_modules/@napi-rs/wasm-runtime": { 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/morph.ts b/poc/ts-vue/src/morph.ts index 0f6ba80..bedc919 100644 --- a/poc/ts-vue/src/morph.ts +++ b/poc/ts-vue/src/morph.ts @@ -9,6 +9,7 @@ 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`). @@ -62,40 +63,6 @@ const STORAGE_PREFIX = 'morph-poc:tk:'; * 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`; @@ -107,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 ?? '', diff --git a/poc/ts-vue/src/views/HomeView.vue b/poc/ts-vue/src/views/HomeView.vue index acf6976..eeefc8c 100644 --- a/poc/ts-vue/src/views/HomeView.vue +++ b/poc/ts-vue/src/views/HomeView.vue @@ -187,7 +187,7 @@ async function runAcquire(authId: string) { busy.value = true; message.value = ''; try { - await morph.auth(authId).acquireWithClientCredentials(); + await morph.auth(authId).acquire(); message.value = `Token acquired (${authId}).`; await refreshStatus(); } catch (e) { diff --git a/poc/ts-vue/vite.config.ts b/poc/ts-vue/vite.config.ts index 096f634..39c8141 100644 --- a/poc/ts-vue/vite.config.ts +++ b/poc/ts-vue/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => { Object.entries(viteEnv).map(([k, v]) => [`import.meta.env.${k}`, JSON.stringify(v)]), ) - const keycloakOrigin = viteEnv.VITE_KEYCLOAK_ORIGIN ?? 'http://localhost:8080' + const keycloakOrigin = viteEnv.VITE_KEYCLOAK_ORIGIN ?? 'http://localhost:8080' const repoRoot = path.resolve(__dirname, '../..') @@ -36,7 +36,9 @@ export default defineConfig(({ mode }) => { server: { // HMR must use the same hostname as the page (e.g. localhost vs 127.0.0.1) or the WS fails and // the injected client throws in a tight loop: Cannot read properties of undefined (reading 'send'). + host: 'localhost', strictPort: true, + port: 5173, hmr: { host: 'localhost' }, fs: { allow: [__dirname, path.resolve(__dirname, '..'), repoRoot],