From a80bdd745a20983853d76cdaffacedfa8ca69028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aras=20=C3=96rs?= <83857485+arasors@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:12:14 +0300 Subject: [PATCH] feat(flutter): add typed Sentroy SDK, auth widgets and MCP support --- .github/workflows/flutter-sdk.yml | 60 + README.md | 6 +- flutter/.gitignore | 8 + flutter/.pubignore | 10 + flutter/AGENTS.md | 10 + flutter/CHANGELOG.md | 6 + flutter/LICENSE | 21 + flutter/PARITY.md | 16 + flutter/README.md | 126 +++ flutter/analysis_options.yaml | 6 + flutter/api-parity.json | 846 +++++++++++++++ flutter/example/analysis_options.yaml | 4 + flutter/example/lib/main.dart | 170 +++ flutter/example/pubspec.yaml | 13 + flutter/example/web/index.html | 2 + flutter/lib/sentroy_client_sdk.dart | 19 + flutter/lib/sentroy_flutter.dart | 5 + flutter/lib/src/auth/admin.dart | 229 ++++ flutter/lib/src/auth/client.dart | 457 ++++++++ flutter/lib/src/auth/storage.dart | 52 + flutter/lib/src/auth/types.dart | 268 +++++ flutter/lib/src/client.dart | 94 ++ flutter/lib/src/errors.dart | 33 + flutter/lib/src/flutter_auth.dart | 92 ++ flutter/lib/src/http.dart | 265 +++++ flutter/lib/src/json.dart | 31 + flutter/lib/src/mcp/mcp.dart | 875 +++++++++++++++ flutter/lib/src/resources/mail.dart | 346 ++++++ flutter/lib/src/resources/storage.dart | 99 ++ flutter/lib/src/resources/whatsapp.dart | 87 ++ flutter/lib/src/thumbnails.dart | 41 + flutter/lib/src/types/mail.dart | 1327 +++++++++++++++++++++++ flutter/lib/src/types/storage.dart | 493 +++++++++ flutter/lib/src/types/whatsapp.dart | 365 +++++++ flutter/lib/src/vault.dart | 320 ++++++ flutter/pubspec.yaml | 21 + flutter/test/auth_test.dart | 366 +++++++ flutter/test/flutter_auth_test.dart | 109 ++ flutter/test/http_test.dart | 204 ++++ flutter/test/mail_test.dart | 339 ++++++ flutter/test/mcp_test.dart | 451 ++++++++ flutter/test/storage_test.dart | 104 ++ flutter/test/thumbnails_test.dart | 56 + flutter/test/vault_test.dart | 164 +++ flutter/test/whatsapp_test.dart | 40 + scripts/check_flutter_parity.py | 51 + 46 files changed, 8705 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/flutter-sdk.yml create mode 100644 flutter/.gitignore create mode 100644 flutter/.pubignore create mode 100644 flutter/AGENTS.md create mode 100644 flutter/CHANGELOG.md create mode 100644 flutter/LICENSE create mode 100644 flutter/PARITY.md create mode 100644 flutter/README.md create mode 100644 flutter/analysis_options.yaml create mode 100644 flutter/api-parity.json create mode 100644 flutter/example/analysis_options.yaml create mode 100644 flutter/example/lib/main.dart create mode 100644 flutter/example/pubspec.yaml create mode 100644 flutter/example/web/index.html create mode 100644 flutter/lib/sentroy_client_sdk.dart create mode 100644 flutter/lib/sentroy_flutter.dart create mode 100644 flutter/lib/src/auth/admin.dart create mode 100644 flutter/lib/src/auth/client.dart create mode 100644 flutter/lib/src/auth/storage.dart create mode 100644 flutter/lib/src/auth/types.dart create mode 100644 flutter/lib/src/client.dart create mode 100644 flutter/lib/src/errors.dart create mode 100644 flutter/lib/src/flutter_auth.dart create mode 100644 flutter/lib/src/http.dart create mode 100644 flutter/lib/src/json.dart create mode 100644 flutter/lib/src/mcp/mcp.dart create mode 100644 flutter/lib/src/resources/mail.dart create mode 100644 flutter/lib/src/resources/storage.dart create mode 100644 flutter/lib/src/resources/whatsapp.dart create mode 100644 flutter/lib/src/thumbnails.dart create mode 100644 flutter/lib/src/types/mail.dart create mode 100644 flutter/lib/src/types/storage.dart create mode 100644 flutter/lib/src/types/whatsapp.dart create mode 100644 flutter/lib/src/vault.dart create mode 100644 flutter/pubspec.yaml create mode 100644 flutter/test/auth_test.dart create mode 100644 flutter/test/flutter_auth_test.dart create mode 100644 flutter/test/http_test.dart create mode 100644 flutter/test/mail_test.dart create mode 100644 flutter/test/mcp_test.dart create mode 100644 flutter/test/storage_test.dart create mode 100644 flutter/test/thumbnails_test.dart create mode 100644 flutter/test/vault_test.dart create mode 100644 flutter/test/whatsapp_test.dart create mode 100644 scripts/check_flutter_parity.py diff --git a/.github/workflows/flutter-sdk.yml b/.github/workflows/flutter-sdk.yml new file mode 100644 index 0000000..d1d6b2f --- /dev/null +++ b/.github/workflows/flutter-sdk.yml @@ -0,0 +1,60 @@ +name: Flutter SDK +on: + pull_request: + paths: ['flutter/**', 'scripts/check_flutter_parity.py', 'scripts/check_rust_parity.py', 'typescript/src/**', '.github/workflows/flutter-sdk.yml'] + push: + branches: [main] + paths: ['flutter/**', 'scripts/check_flutter_parity.py', 'scripts/check_rust_parity.py', 'typescript/src/**', '.github/workflows/flutter-sdk.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: flutter-sdk-${{ github.ref }} + cancel-in-progress: true +jobs: + validate: + runs-on: ubuntu-22.04 + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - flutter: '3.32.0' + revision: be698c48a6750c8cb8e61c740ca9991bb947aba2 + - flutter: '3.47.9' + revision: c197ca5199e9ee5661e865596c9c07294dfab51c + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - name: Install Flutter SDK + run: | + git clone --depth 1 --branch '${{ matrix.flutter }}' https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter" + test "$(git -C "$RUNNER_TEMP/flutter" rev-parse HEAD)" = '${{ matrix.revision }}' + echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/flutter/bin/flutter" config --no-analytics + "$RUNNER_TEMP/flutter/bin/flutter" --version + - name: Resolve dependencies + working-directory: flutter + run: | + flutter pub get + cd example + flutter pub get + - name: Check API presence + run: python3 scripts/check_flutter_parity.py + - name: Analyze + working-directory: flutter + run: flutter analyze --no-pub + - name: Check formatting + if: matrix.flutter == '3.47.9' + working-directory: flutter + run: dart format --output=none --set-exit-if-changed lib test example/lib + - name: Test services and widgets + working-directory: flutter + run: flutter test --no-pub + - name: Validate publish archive + if: matrix.flutter == '3.47.9' + working-directory: flutter + run: flutter pub publish --dry-run + - name: Build Web example + if: matrix.flutter == '3.47.9' + working-directory: flutter/example + run: flutter build web --release --no-pub diff --git a/README.md b/README.md index 899dd21..41695bd 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ | Python | [`sentroy-client-sdk`](https://pypi.org/project/sentroy-client-sdk/) | `pip install sentroy-client-sdk` | | PHP | [`sentroy-co/client-sdk`](https://packagist.org/packages/sentroy-co/client-sdk) | `composer require sentroy-co/client-sdk` | | Rust | [`sentroy-client-sdk`](rust/) | [Git or path dependency](rust/README.md) — crates.io release pending | +| Flutter / Dart | [`sentroy_client_sdk`](flutter/) | [Git or path dependency](flutter/README.md) — pub.dev release pending | | cURL | — | [Examples](curl/) | -All SDKs target the Mail and Storage APIs against the hosted platform (`https://sentroy.com`) or your own Sentroy deployment. The Rust SDK also covers WhatsApp, Identity, Env Vault and MCP; see each SDK's reference for its supported surface. +All SDKs target the Mail and Storage APIs against the hosted platform (`https://sentroy.com`) or your own Sentroy deployment. The Rust and Flutter SDKs also cover WhatsApp, Identity, Env Vault and MCP; see each SDK's reference for its supported surface. ## Authentication @@ -45,6 +46,7 @@ Each SDK ships a single comprehensive `AGENTS.md` reference — feed the raw URL ``` https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/typescript/AGENTS.md https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/rust/AGENTS.md +https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/flutter/AGENTS.md https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/go/README.md https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/python/README.md https://raw.githubusercontent.com/Sentroy-Co/client-sdk/refs/heads/main/php/README.md @@ -61,7 +63,7 @@ https://docs.sentroy.com/skill.md # Anthropic Skill format https://docs.sentroy.com/agents.md # universal markdown ``` -> TypeScript and Rust have split `README.md` (lean, npm-facing) from `AGENTS.md` (full reference). The Go, Python, PHP and cURL READMEs remain comprehensive and double as agent-facing docs. +> TypeScript, Rust and Flutter have split `README.md` (lean, npm-facing) from `AGENTS.md` (full reference). The Go, Python, PHP and cURL READMEs remain comprehensive and double as agent-facing docs. ## License diff --git a/flutter/.gitignore b/flutter/.gitignore new file mode 100644 index 0000000..1df8e4c --- /dev/null +++ b/flutter/.gitignore @@ -0,0 +1,8 @@ +.dart_tool/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock +example/.dart_tool/ +example/build/ +example/pubspec.lock diff --git a/flutter/.pubignore b/flutter/.pubignore new file mode 100644 index 0000000..1da6a99 --- /dev/null +++ b/flutter/.pubignore @@ -0,0 +1,10 @@ +.dart_tool/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock +example/.dart_tool/ +example/build/ +example/pubspec.lock +.gitignore +.pubignore diff --git a/flutter/AGENTS.md b/flutter/AGENTS.md new file mode 100644 index 0000000..628dc4e --- /dev/null +++ b/flutter/AGENTS.md @@ -0,0 +1,10 @@ +# Flutter SDK maintenance + +Read README.md and PARITY.md before changing APIs. The package targets Flutter 3.32 / Dart 3.8 and newer. Keep library imports portable; platform imports in tests are allowed. + +- Preserve all mappings in api-parity.json. Run `python3 ../scripts/check_flutter_parity.py`; it checks presence, while contract tests check behavior. +- Keep company, Identity, Vault and MCP credentials separate. Preserve request UUIDs on uncertain BCC outcomes and never add automatic replay or redirects to writes/private transfers. +- Use the shared abortable HTTP transport for service calls. Enforce total response deadlines and byte limits; a Future timeout alone does not cancel an HTTP request. +- Do not persist rotating refresh tokens after dispatch or allow delayed responses to overwrite newer sessions. Keep pluggable storage failures explicit. +- Preserve bound private references, immutable origin, metadata/hash/receipt checks, streaming limits and close cancellation in the MCP adapter. Its upstream client is community-maintained mcp_dart. +- Validate with analysis, formatting, tests, package dry run, the parity guard and the example Web build. Check both minimum and current Flutter in CI. Never publish credentials or include build/cache output in the package. diff --git a/flutter/CHANGELOG.md b/flutter/CHANGELOG.md new file mode 100644 index 0000000..04d93ea --- /dev/null +++ b/flutter/CHANGELOG.md @@ -0,0 +1,6 @@ +## 0.1.0 + +- Add typed Mail, Storage and WhatsApp APIs with all 70 TypeScript resource methods. +- Add Identity sessions, MFA, passkeys, server-side JWT verification and pluggable storage. +- Add Flutter auth controller/scope, Env Vault and MCP private-file helpers. +- Add contract and widget tests, a runnable Web example and API presence checks. diff --git a/flutter/LICENSE b/flutter/LICENSE new file mode 100644 index 0000000..402d971 --- /dev/null +++ b/flutter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sentroy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/flutter/PARITY.md b/flutter/PARITY.md new file mode 100644 index 0000000..987c53f --- /dev/null +++ b/flutter/PARITY.md @@ -0,0 +1,16 @@ +# API coverage + +The comparison source is TypeScript SDK 2.18.3, commit `326f131dec57c8e8a4dd6b2f8eb18020a772e861`. [api-parity.json](api-parity.json) maps the root asynchronous resource inventory to Dart classes and methods. + +| Surface | Methods | Dart scope | +| --- | ---: | --- | +| Mail | 45 | Domains, Mailboxes, Templates, Inbox, Send, audience contacts/lists/members, suppressions, webhooks/deliveries, logs | +| Storage | 12 | Buckets, media upload/download and metadata, quota/usage | +| WhatsApp | 13 | Numbers, templates, audiences, send and logs | +| **Total** | **70** | Checked by the API presence guard | + +Additional native surfaces include Identity and admin, signed JWT verification, memory/custom/serialized session storage, a Flutter controller/scope, thumbnail helpers, Env Vault, MCP connections and all five private file helpers. Dart methods retain the reference camelCase names; factory access such as `members(id)` and `deliveries(id)` is also available. + +The presence guard detects unmapped current TypeScript methods and missing Dart methods. It does not prove wire or security equivalence. Tests separately cover serialization, routes, error receipts, session races, signatures, caching, file bounds and widgets. + +Native boundaries are described in [README.md](README.md): the app supplies navigation, secure storage and WebAuthn integration. MCP uses the existing 2025 POST JSON/SSE service flow; optional standalone GET notification streams, resumption and 2026 discovery are outside this adapter. Contact import error row/reason diagnostics are intentionally not returned. React UI and the TypeScript CLI have no direct Dart replacement. diff --git a/flutter/README.md b/flutter/README.md new file mode 100644 index 0000000..d6530a8 --- /dev/null +++ b/flutter/README.md @@ -0,0 +1,126 @@ +# Sentroy Flutter SDK + +A native Flutter client for Sentroy Mail, Storage, WhatsApp, Identity, Env Vault and MCP. Requires Flutter 3.32 / Dart 3.8 or newer. Version 0.1.0 is prepared in this repository; a pub.dev release has not been published. + +## Install + +Pin a reviewed commit when installing from Git: + +```yaml +dependencies: + sentroy_client_sdk: + git: + url: https://github.com/Sentroy-Co/client-sdk.git + ref: + path: flutter +``` + +For a checkout, use `sentroy_client_sdk: {path: ../client-sdk/flutter}`. Import `package:sentroy_client_sdk/sentroy_client_sdk.dart` for service APIs or `sentroy_flutter.dart` for Flutter state integration as well. + +## Company APIs + +```dart +final sdk = Sentroy( + companySlug: 'my-company', + accessToken: token, // Obtain at runtime from your authorized application flow. +); +try { + final domains = await sdk.domains.list(); + final buckets = await sdk.buckets.list(); + final messages = await sdk.inbox.list( + const InboxListParams(unreadOnly: true, page: 1, limit: 25), + ); +} finally { + sdk.close(); +} +``` + +The root client exposes `domains`, `mailboxes`, `templates`, `inbox`, `send`, `audience`, `suppressions`, `webhooks`, `logs`, `buckets`, `media`, `storage`, and `whatsapp`. [PARITY.md](PARITY.md) lists the 70 mapped TypeScript resource methods. Requests and responses have Dart models; localized values accept a string or `Map`, and address unions accept a string or `List`. `PatchValue(null)` explicitly clears a nullable update field, while an omitted wrapper leaves the field unchanged. + +The company gateway routes are `/api/{mail,storage,whatsapp}/companies/{slug}`. Set `baseUrl`, `timeout`, or inject an `http.Client` for a custom deployment or platform integration. The default deadline is 30 seconds and covers the whole response. Injected clients remain caller-owned. Closing the SDK blocks new resource calls. Browser cookie authentication requires an appropriately configured injected client; the SDK does not automatically acquire a Sentroy browser session. + +No service request automatically follows redirects or retries. Normal JSON responses have a 16 MiB cap. Media downloads return bytes and accept an optional `maxBytes` limit; multipart upload preserves filename, MIME type and processing flags. + +### BCC and error recovery + +BCC sends and drafts use dedicated versioned compose endpoints and a bounded 4 KiB receipt. A `ComposeRequestError` carries `requestId`, `statusCode`, `body`, and `cause`. Save the UUID on uncertain outcomes and reconcile the existing request before retrying with that same UUID. A new UUID represents a new send. + +`SentroyException` preserves available status and response body details. Its string representation omits the body. Treat bodies, auth descriptions and raw protocol errors as potentially sensitive when handling diagnostics. + +## Identity and Flutter state + +```dart +final auth = SentroyAuth(projectSlug: 'my-project'); +final controller = SentroyAuthController(auth); +await controller.initialize(); + +// Place in your widget tree: +SentroyAuthScope(controller: controller, child: const MyApp()); + +// Inside a descendant build method: +final state = SentroyAuthScope.of(context); +final user = state.user; +``` + +The scope rebuilds consumers when session, loading or error state changes. The controller exposes signup, signin, MFA verification, refresh, signout and restore. MFA login returns a challenge instead of a signed-in session; call `verifyMfa` with the user-entered code. Dispose the controller and then close the auth client when its owner is disposed. + +The auth client also exposes password reset, email verification and changes, account deletion, sessions, activity, magic links, invitations, social authorization URLs and passkey begin/complete operations. The application handles deep links, validates callback context, clears callback fragments from navigation history, and invokes a platform WebAuthn authenticator. `consumeRedirectFragment` accepts the supplied fragment and verifies the user via `/me`; it does not implement a browser or invent PKCE endpoints. + +Refresh is explicit and coalesced. The old rotating token is removed from durable storage before dispatch; an uncertain response requires signing in again. Delayed operations cannot replace a newer or cleared session. Choose your app's refresh scheduling and handle the controller's busy state during rotation. + +### Persist a session securely + +Storage defaults to memory. Supply an `AuthSessionStorage`, or bridge your chosen secure-storage plugin through callbacks bound to an origin/project-specific key: + +```dart +final sessions = SerializedAuthSessionStorage( + read: () => secureStorage.read(key: sessionKey), + write: (value) => secureStorage.write(key: sessionKey, value: value), + clear: () => secureStorage.delete(key: sessionKey), +); +final auth = SentroyAuth(projectSlug: projectSlug, storage: sessions); +``` + +Here `secureStorage` belongs to your app, for example a configured `flutter_secure_storage` instance. The SDK adds no implicit persistent store. Company `stk_` tokens, Identity project keys, Vault `stk_env_` keys and MCP credentials have separate purposes. Keep project admin and private Vault keys on trusted servers. `SentroyAuthAdmin` verifies RS256 JWT signatures, issuer, audience, expiration and unique JWKS key identity; its configured-origin cache can be invalidated. The public server API has no admin user-list endpoint, matching the reference SDK. + +## Vault + +`VaultClient(VaultConfig(...))` supports cached reads, explicit resolver fallback, public filtering, invalidation and refresh. The default TTL is five minutes and timeout five seconds. `VaultConfig.fromMap` reads a caller-supplied environment map, so there is no unconditional `dart:io` dependency. Clones share cache, transport and lifetime. Verify webhook HMAC over raw bytes with `verifyVaultWebhook`; the default replay window is five minutes and rejects future timestamps. `handleWebhook` invalidates lazily; call `refresh()` for immediate fetching. + +## MCP + +```dart +final connection = await connectSentroyMcp( + app: 'mail', companySlug: 'my-company', token: mcpToken, +); +try { + final tools = await connection.client.listTools(); +} finally { + await connection.close(); +} +``` + +An async `McpTokenProvider` may be supplied instead of a static `mcp_`/`mcp_at_` token. It is resolved before requests; the SDK does not acquire consent or tokens. Core, Mail, Storage, Linear and Chaby resource/metadata URLs are supported. Tool writes remain subject to server permissions and approval requirements. + +The community-maintained `mcp_dart` client/codec is connected through a bounded HTTP adapter. It uses MCP 2025-03-26 initialization and POST JSON/SSE exchanges, with no 2026 discovery, standalone GET notification stream, resumption, session reinitialization or automatic replay. Protocol JSON and SSE events have a 1 MiB bound; closing cancels active transport/private requests. Public MCP request/result types are re-exported for calling the client. + +All five private helpers are provided: `downloadFile`, `downloadMailAttachment`, `downloadMailContactsExport`, `uploadMailContactsImport`, and `uploadStorageFile`. References are bound to the immutable resource origin, expected path/query and grant metadata. Hashes, sizes, filenames, MIME types, expiry, revisions and receipts are checked. Limits are 64 MiB for storage downloads, 8 MiB for decoded mail attachments, 1 MiB for contact files and 10 MiB for storage uploads. Default deadlines are 60 seconds for downloads and 15 seconds for uploads, configurable from 1 millisecond to 120 seconds. A lost acknowledgment, server error or invalid upload receipt yields `mcp_upload_outcome_unknown`; inspect the existing operation before another upload. Contact import 4xx errors retain a sanitized status without the optional server row/reason diagnostics. + +## Example and checks + +Run `cd example && flutter run -d chrome`. It demonstrates the auth scope and a company Mail API request using a token entered at runtime. The package has no unconditional platform imports and the example is built for Web in CI. Android and iOS device testing remains application-specific. + +From this directory: + +```sh +flutter pub get +(cd example && flutter pub get) +flutter analyze +flutter test +dart format --output=none --set-exit-if-changed lib test example/lib +python3 ../scripts/check_flutter_parity.py +flutter pub publish --dry-run +(cd example && flutter build web --release) +``` + +The inventory check validates method presence. Contract, lifecycle, cryptographic and widget tests provide separate behavioral coverage. React components and the Node CLI remain TypeScript integrations. diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml new file mode 100644 index 0000000..399d53f --- /dev/null +++ b/flutter/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml +analyzer: + errors: + invalid_use_of_visible_for_testing_member: error + exclude: + - build/** diff --git a/flutter/api-parity.json b/flutter/api-parity.json new file mode 100644 index 0000000..1f2d614 --- /dev/null +++ b/flutter/api-parity.json @@ -0,0 +1,846 @@ +{ + "reference_commit": "326f131dec57c8e8a4dd6b2f8eb18020a772e861", + "description": "Root TypeScript resource async methods mapped to Dart methods. Checks API presence only.", + "methods": [ + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceListMembers", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceListMembers", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceListMembers", + "method": "add" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceListMembers", + "method": "add" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceListMembers", + "method": "remove" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceListMembers", + "method": "remove" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceLists", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceLists", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "create" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceLists", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceLists", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceLists", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceContacts", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "search" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceContacts", + "method": "search" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceContacts", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "create" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceContacts", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "update" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceContacts", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/audience.ts", + "class": "AudienceContacts", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "AudienceContacts", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "Buckets", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "Buckets", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "create" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "Buckets", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "update" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "Buckets", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/buckets.ts", + "class": "Buckets", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "Buckets", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/domains.ts", + "class": "Domains", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Domains", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/domains.ts", + "class": "Domains", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Domains", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "saveDraft" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "saveDraft" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "listFolders" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "listFolders" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "getThread" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "getThread" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "markAsRead" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "markAsRead" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "markAsUnread" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "markAsUnread" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "move" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "move" + } + }, + { + "typescript": { + "file": "typescript/src/resources/inbox.ts", + "class": "Inbox", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Inbox", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/logs.ts", + "class": "Logs", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Logs", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/logs.ts", + "class": "Logs", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Logs", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/mailboxes.ts", + "class": "Mailboxes", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Mailboxes", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "MediaResource", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "MediaResource", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "upload" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "MediaResource", + "method": "upload" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "MediaResource", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/media.ts", + "class": "MediaResource", + "method": "download" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "MediaResource", + "method": "download" + } + }, + { + "typescript": { + "file": "typescript/src/resources/send.ts", + "class": "Send", + "method": "email" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Send", + "method": "email" + } + }, + { + "typescript": { + "file": "typescript/src/resources/storage.ts", + "class": "Storage", + "method": "quota" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "Storage", + "method": "quota" + } + }, + { + "typescript": { + "file": "typescript/src/resources/storage.ts", + "class": "Storage", + "method": "usage" + }, + "dart": { + "file": "flutter/lib/src/resources/storage.dart", + "class": "Storage", + "method": "usage" + } + }, + { + "typescript": { + "file": "typescript/src/resources/suppressions.ts", + "class": "Suppressions", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Suppressions", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/suppressions.ts", + "class": "Suppressions", + "method": "add" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Suppressions", + "method": "add" + } + }, + { + "typescript": { + "file": "typescript/src/resources/suppressions.ts", + "class": "Suppressions", + "method": "remove" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Suppressions", + "method": "remove" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Templates", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Templates", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "create" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Templates", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "update" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Templates", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/templates.ts", + "class": "Templates", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Templates", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "WebhookDeliveries", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "WebhookDeliveries", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "WebhookDeliveries", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "WebhookDeliveries", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "WebhookDeliveries", + "method": "replay" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "WebhookDeliveries", + "method": "replay" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Webhooks", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Webhooks", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "create" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Webhooks", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "update" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Webhooks", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Webhooks", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/webhooks.ts", + "class": "Webhooks", + "method": "test" + }, + "dart": { + "file": "flutter/lib/src/resources/mail.dart", + "class": "Webhooks", + "method": "test" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppNumbers", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppNumbers", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppTemplates", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppTemplates", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "create" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppTemplates", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "update" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppTemplates", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppTemplates", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppTemplates", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppAudiences", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "get" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppAudiences", + "method": "get" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "create" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppAudiences", + "method": "create" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "update" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppAudiences", + "method": "update" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppAudiences", + "method": "delete" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppAudiences", + "method": "delete" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsAppLogs", + "method": "list" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsAppLogs", + "method": "list" + } + }, + { + "typescript": { + "file": "typescript/src/resources/whatsapp.ts", + "class": "WhatsApp", + "method": "send" + }, + "dart": { + "file": "flutter/lib/src/resources/whatsapp.dart", + "class": "WhatsApp", + "method": "send" + } + } + ] +} diff --git a/flutter/example/analysis_options.yaml b/flutter/example/analysis_options.yaml new file mode 100644 index 0000000..fa91d52 --- /dev/null +++ b/flutter/example/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + exclude: + - web/** +include: ../analysis_options.yaml diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart new file mode 100644 index 0000000..cb53022 --- /dev/null +++ b/flutter/example/lib/main.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:sentroy_client_sdk/sentroy_flutter.dart'; + +void main() => runApp(const MaterialApp(home: SentroyExample())); + +class SentroyExample extends StatefulWidget { + const SentroyExample({super.key}); + @override + State createState() => _SentroyExampleState(); +} + +class _SentroyExampleState extends State { + final _email = TextEditingController(); + final _password = TextEditingController(); + final _slug = TextEditingController(); + final _token = TextEditingController(); + final _auth = SentroyAuth( + projectSlug: const String.fromEnvironment( + 'SENTROY_AUTH_PROJECT', + defaultValue: 'demo', + ), + ); + late final _controller = SentroyAuthController(_auth); + String? _message; + bool _loading = false; + @override + void initState() { + super.initState(); + unawaited(_controller.initialize()); + } + + @override + void dispose() { + _controller.dispose(); + unawaited(_auth.close()); + _email.dispose(); + _password.dispose(); + _slug.dispose(); + _token.dispose(); + super.dispose(); + } + + Future _login() async { + try { + final result = await _controller.signIn( + SignInRequest(email: _email.text, password: _password.text), + ); + if (mounted) { + setState( + () => _message = result.mfa == null + ? 'Signed in.' + : 'MFA required: call verifyMfa with the challenge and user code.', + ); + } + } on SentroyException catch (e) { + if (mounted) setState(() => _message = e.message); + } + } + + Future _domains() async { + setState(() => _loading = true); + Sentroy? sdk; + try { + sdk = Sentroy(companySlug: _slug.text, accessToken: _token.text); + final domains = await sdk.domains.list(); + if (mounted) { + setState(() => _message = '${domains.length} mail domains available.'); + } + } catch (_) { + if (mounted) { + setState( + () => _message = + 'Could not load domains. Check your company and access permissions.', + ); + } + } finally { + sdk?.close(); + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) => SentroyAuthScope( + controller: _controller, + child: Builder( + builder: (context) { + final auth = SentroyAuthScope.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Sentroy SDK')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Identity', + style: Theme.of(context).textTheme.headlineSmall, + ), + if (auth.isSignedIn) ...[ + Text(auth.user!.email), + TextButton( + onPressed: auth.isBusy ? null : auth.signOut, + child: const Text('Sign out'), + ), + ] else ...[ + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: 'Email'), + ), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + ), + ), + FilledButton( + onPressed: auth.isBusy ? null : _login, + child: Text(auth.isBusy ? 'Signing in…' : 'Sign in'), + ), + ], + const SizedBox(height: 32), + Text( + 'Company Mail API', + style: Theme.of(context).textTheme.headlineSmall, + ), + const Text( + 'Enter a company access token at runtime to try a read operation.', + ), + TextField( + controller: _slug, + decoration: const InputDecoration( + labelText: 'Company slug', + ), + ), + TextField( + controller: _token, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Access token', + ), + ), + FilledButton( + onPressed: _loading ? null : _domains, + child: Text( + _loading ? 'Loading…' : 'List mail domains', + ), + ), + if (_message != null) + Padding( + padding: const EdgeInsets.only(top: 24), + child: Text(_message!), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ); +} diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml new file mode 100644 index 0000000..c725316 --- /dev/null +++ b/flutter/example/pubspec.yaml @@ -0,0 +1,13 @@ +name: sentroy_sdk_example +description: Flutter example for the Sentroy SDK. +publish_to: none +version: 1.0.0 +environment: + sdk: '>=3.8.0 <4.0.0' +dependencies: + flutter: + sdk: flutter + sentroy_client_sdk: + path: ../ +dev_dependencies: + flutter_lints: ^6.0.0 diff --git a/flutter/example/web/index.html b/flutter/example/web/index.html new file mode 100644 index 0000000..71109e0 --- /dev/null +++ b/flutter/example/web/index.html @@ -0,0 +1,2 @@ + +Sentroy SDK diff --git a/flutter/lib/sentroy_client_sdk.dart b/flutter/lib/sentroy_client_sdk.dart new file mode 100644 index 0000000..b729417 --- /dev/null +++ b/flutter/lib/sentroy_client_sdk.dart @@ -0,0 +1,19 @@ +/// Sentroy's native Flutter service SDK. +library; + +export 'src/client.dart'; +export 'src/errors.dart'; +export 'src/json.dart' show JsonMap, PatchValue; +export 'src/resources/mail.dart'; +export 'src/resources/storage.dart'; +export 'src/resources/whatsapp.dart'; +export 'src/types/mail.dart'; +export 'src/types/storage.dart'; +export 'src/types/whatsapp.dart'; +export 'src/thumbnails.dart'; +export 'src/auth/client.dart'; +export 'src/auth/admin.dart'; +export 'src/auth/types.dart'; +export 'src/auth/storage.dart'; +export 'src/vault.dart'; +export 'src/mcp/mcp.dart'; diff --git a/flutter/lib/sentroy_flutter.dart b/flutter/lib/sentroy_flutter.dart new file mode 100644 index 0000000..a417e29 --- /dev/null +++ b/flutter/lib/sentroy_flutter.dart @@ -0,0 +1,5 @@ +/// Flutter state and widget integration for the Sentroy service SDK. +library; + +export 'sentroy_client_sdk.dart'; +export 'src/flutter_auth.dart'; diff --git a/flutter/lib/src/auth/admin.dart b/flutter/lib/src/auth/admin.dart new file mode 100644 index 0000000..ee614f7 --- /dev/null +++ b/flutter/lib/src/auth/admin.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:jose/jose.dart'; +import '../errors.dart'; +import '../json.dart'; +import 'client.dart'; +import 'types.dart'; + +/// Server-side Identity operations. Keep the API key on a trusted server. +class SentroyAuthAdmin { + SentroyAuthAdmin({ + Uri? baseUrl, + required this.projectSlug, + required this.apiKey, + http.Client? httpClient, + Duration timeout = const Duration(seconds: 30), + this.jwksCacheTtl = const Duration(hours: 1), + }) : baseUrl = baseUrl ?? Uri.parse('https://auth.sentroy.com'), + _auth = SentroyAuth( + baseUrl: baseUrl, + projectSlug: projectSlug, + apiKey: apiKey, + httpClient: httpClient, + timeout: timeout, + ) { + if (apiKey.length < 12 || apiKey.codeUnits.any((x) => x > 127)) { + throw ArgumentError('API key must contain at least 12 ASCII characters'); + } + if (jwksCacheTtl.isNegative) { + throw ArgumentError('JWKS cache TTL must not be negative'); + } + users = AuthAdminUsers(_auth); + } + final Uri baseUrl; + final String projectSlug, apiKey; + final Duration jwksCacheTtl; + final SentroyAuth _auth; + late final AuthAdminUsers users; + List? _keys; + DateTime? _expires; + Future>? _pendingJwks; + int _cacheGeneration = 0; + void invalidateJwksCache() { + _keys = null; + _expires = null; + _cacheGeneration++; + _pendingJwks = null; + } + + Future close() => _auth.close(); + Future> _jwks() { + if (_keys != null && + _expires != null && + DateTime.now().isBefore(_expires!)) { + return Future.value(_keys!); + } + if (_pendingJwks != null) return _pendingJwks!; + final generation = _cacheGeneration; + late final Future> pending; + pending = + (() async { + final j = jsonObject(await _auth.request('GET', '/jwks.json')); + final keys = jsonList(j['keys'], (v) => v); + if (_cacheGeneration == generation) { + _keys = keys; + _expires = DateTime.now().add(jwksCacheTtl); + } + return keys; + })().whenComplete(() { + if (identical(_pendingJwks, pending)) _pendingJwks = null; + }); + return _pendingJwks = pending; + } + + Future verifyIdToken(String token) async { + try { + final parts = token.split('.'); + if (parts.length != 3) throw const FormatException('Malformed JWT'); + JsonMap decode(String s) => jsonObject( + jsonDecode(utf8.decode(base64Url.decode(base64Url.normalize(s)))), + ); + final header = decode(parts[0]), claims = decode(parts[1]); + final kid = header['kid']; + if (header['alg'] != 'RS256' || kid is! String || kid.isEmpty) { + throw const FormatException('Unsupported JWT header'); + } + final exp = claims['exp']; + if (exp is! num || exp <= DateTime.now().millisecondsSinceEpoch / 1000) { + throw const FormatException('Token expired'); + } + final issuer = + '${baseUrl.toString().replaceFirst(RegExp(r'/+$'), '')}/p/${pathSegment(projectSlug)}'; + if (claims['iss'] != issuer) { + throw const FormatException('Issuer mismatch'); + } + if (claims['aud'] != apiKey.substring(0, 12)) { + throw const FormatException('Audience mismatch'); + } + final matches = (await _jwks()).where((k) => k['kid'] == kid).toList(); + if (matches.length != 1) { + throw const FormatException('Key ID must identify one key'); + } + final key = matches.single; + if (key['kty'] != 'RSA' || + (key['alg'] != null && key['alg'] != 'RS256') || + (key['use'] != null && key['use'] != 'sig') || + (key['key_ops'] != null && + (key['key_ops'] is! List || + !(key['key_ops'] as List).contains('verify')))) { + throw const FormatException('Invalid signing key'); + } + final store = JsonWebKeyStore()..addKey(JsonWebKey.fromJson(key)); + if (!await JsonWebSignature.fromCompactSerialization( + token, + ).verify(store)) { + throw const FormatException('Invalid signature'); + } + return claims; + } on SentroyException { + rethrow; + } catch (_) { + throw const SentroyAuthException( + 'invalid_token', + 'Token verification failed', + ); + } + } +} + +class AuthAdminUsers { + AuthAdminUsers(this._auth); + Never list({int? limit, int? skip, bool? emailVerified}) => + throw UnsupportedError( + 'User listing requires the session-authenticated dashboard API', + ); + final SentroyAuth _auth; + Future create(SignUpRequest input) async => + SignupResponse.fromJson( + jsonObject( + await _auth.request('POST', '/signup', body: input.toJson()), + ), + ); + Future signIn(SignInRequest input) async { + final j = jsonObject( + await _auth.request('POST', '/login', body: input.toJson()), + ); + return j['mfaRequired'] == true + ? LoginOutcome.mfa(MfaChallengeResponse.fromJson(j)) + : LoginOutcome.tokens(LoginResponse.fromJson(j)); + } + + Future verifyMfa(MfaVerifyRequest input) async => + LoginResponse.fromJson( + jsonObject( + await _auth.request( + 'POST', + '/login/mfa/verify', + body: input.toJson(), + ), + ), + ); + Future refresh(String token) async => + AuthTokensResponse.fromJson( + jsonObject( + await _auth.request( + 'POST', + '/refresh', + body: {'refreshToken': token}, + ), + ), + ); + Future signOut(String token) async { + await _auth.request('POST', '/logout', body: {'refreshToken': token}); + } + + Future verifyEmail(String token) async => + SentroyAuthUser.fromJson( + jsonObject( + jsonObject( + await _auth.request( + 'POST', + '/verify-email', + body: {'token': token}, + ), + )['user'], + ), + ); + Future requestPasswordReset(String email) => + _auth.sendPasswordReset(email); + Future confirmPasswordReset({ + required String token, + required String newPassword, + }) => _auth.confirmPasswordReset(token: token, newPassword: newPassword); + Future sendMagicLink({required String email, String? redirectUri}) => + _auth.sendMagicLink(email: email, redirectUri: redirectUri); + Future consumeMagicLink(String token) async => + LoginResponse.fromJson( + jsonObject( + await _auth.request( + 'POST', + '/magic-link/consume', + body: {'token': token}, + ), + ), + ); + Future acceptInvitation({ + required String token, + required String password, + String? displayName, + }) async => LoginResponse.fromJson( + jsonObject( + await _auth.request( + 'POST', + '/invitation/accept', + body: { + 'token': token, + 'password': password, + 'displayName': ?displayName, + }, + ), + ), + ); + Future getUser(String token) async => + SentroyAuthUser.fromJson( + jsonObject(await _auth.request('GET', '/me', bearer: token)), + ); + Future getUserinfo(String token) async => + jsonObject(await _auth.request('GET', '/userinfo', bearer: token)); +} diff --git a/flutter/lib/src/auth/client.dart b/flutter/lib/src/auth/client.dart new file mode 100644 index 0000000..334d456 --- /dev/null +++ b/flutter/lib/src/auth/client.dart @@ -0,0 +1,457 @@ +import 'dart:async'; +import 'package:http/http.dart' as http; +import '../errors.dart'; +import '../json.dart'; +import '../http.dart'; +import 'storage.dart'; +import 'types.dart'; + +class SentroyAuth { + SentroyAuth({ + Uri? baseUrl, + required this.projectSlug, + this.apiKey, + http.Client? httpClient, + this.timeout = const Duration(seconds: 30), + AuthSessionStorage? storage, + }) : baseUrl = baseUrl ?? Uri.parse('https://auth.sentroy.com'), + _http = SentroyHttp( + baseUrl: baseUrl ?? Uri.parse('https://auth.sentroy.com'), + accessToken: apiKey, + client: httpClient, + timeout: timeout, + ), + _storage = storage ?? MemoryAuthSessionStorage() { + pathSegment(projectSlug); + } + final Uri baseUrl; + final String projectSlug; + final String? apiKey; + final Duration timeout; + final SentroyHttp _http; + final AuthSessionStorage _storage; + final _changes = StreamController.broadcast(); + AuthSession? _session; + Future? _refreshing; + int _generation = 0; + Future _storageWrites = Future.value(); + AuthSession? get currentSession => _session; + Stream get changes => _changes.stream; + Uri _uri(String path, [Map? query]) { + final root = baseUrl.toString().replaceFirst(RegExp(r'/+$'), ''); + final u = Uri.parse('$root/api/v1/auth/${pathSegment(projectSlug)}$path'); + return query == null || query.isEmpty + ? u + : u.replace(queryParameters: query); + } + + Future request( + String method, + String path, { + Object? body, + String? bearer, + }) async { + final transport = bearer == null ? _http : _http.withBearer(bearer); + final route = '/api/v1/auth/${pathSegment(projectSlug)}$path'; + try { + switch (method) { + case 'GET': + return await transport.get(route); + case 'POST': + return await transport.post(route, body); + case 'DELETE': + return await transport.delete(route, body: body); + default: + throw ArgumentError('Unsupported auth method'); + } + } on SentroyException catch (e) { + if (e.statusCode == null) rethrow; + final j = e.body is Map ? e.body as Map : const {}; + throw SentroyAuthException( + j['error'] is String ? j['error'] as String : 'http_error', + j['error_description'] is String + ? j['error_description'] as String + : 'HTTP ${e.statusCode}', + statusCode: e.statusCode, + ); + } + } + + String _token() => + _session?.accessToken ?? + (throw const SentroyAuthException('not_signed_in', 'Not signed in')); + Future setSession(AuthSession? session) { + final generation = ++_generation; + final write = _storageWrites.then((_) async { + if (session == null) { + await _storage.clear(); + } else { + await _storage.write(session); + } + if (generation == _generation) { + _session = session; + if (!_changes.isClosed) _changes.add(session); + } + }); + _storageWrites = write.catchError((Object _) {}); + return write; + } + + Future restore() async { + final generation = _generation; + await _storageWrites; + final s = await _storage.read(); + if (generation == _generation) { + _session = s; + if (!_changes.isClosed) _changes.add(s); + } + return _session; + } + + Future close() async { + await _changes.close(); + _http.close(); + } + + Future signUp(SignUpRequest input) async { + final r = SignupResponse.fromJson( + jsonObject(await request('POST', '/signup', body: input.toJson())), + ); + if (r.accessToken != null && r.refreshToken != null) { + await setSession( + AuthSession( + accessToken: r.accessToken!, + refreshToken: r.refreshToken!, + user: r.user, + ), + ); + } + return r; + } + + Future signIn(SignInRequest input) async { + final j = jsonObject(await request('POST', '/login', body: input.toJson())); + if (j['mfaRequired'] == true) { + return LoginOutcome.mfa(MfaChallengeResponse.fromJson(j)); + } + final r = LoginResponse.fromJson(j); + await setSession(r.session); + return LoginOutcome.tokens(r); + } + + Future _login(String path, JsonMap body) async { + final r = LoginResponse.fromJson( + jsonObject(await request('POST', path, body: body)), + ); + await setSession(r.session); + return r; + } + + Future verifyMfa(MfaVerifyRequest input) => + _login('/login/mfa/verify', input.toJson()); + Future signOut() async { + final s = _session; + await setSession(null); + try { + if (s != null) { + await request( + 'POST', + '/logout', + body: {'refreshToken': s.refreshToken}, + ); + } + } on SentroyException { + // Local sign-out has already completed. + } + } + + Future refreshNow() => _refreshing ??= _refresh().whenComplete(() { + _refreshing = null; + }); + Future _refresh() async { + final s = _session; + if (s == null) { + throw const SentroyAuthException('not_signed_in', 'Not signed in'); + } + // Remove the rotating token before dispatch. A timeout may mean the server + // consumed it, so neither this process nor a restarted one may replay it. + final generation = _generation + 1; + await setSession(null); + if (_generation != generation) return; + final r = AuthTokensResponse.fromJson( + jsonObject( + await request( + 'POST', + '/refresh', + body: {'refreshToken': s.refreshToken}, + ), + ), + ); + if (_generation == generation) { + await setSession( + AuthSession( + accessToken: r.accessToken, + refreshToken: r.refreshToken, + user: s.user, + ), + ); + } + } + + Future sendPasswordReset(String email) async { + await request('POST', '/password-reset/request', body: {'email': email}); + } + + Future confirmPasswordReset({ + required String token, + required String newPassword, + }) async => SentroyAuthUser.fromJson( + jsonObject( + jsonObject( + await request( + 'POST', + '/password-reset/confirm', + body: {'token': token, 'newPassword': newPassword}, + ), + )['user'], + ), + ); + Future verifyEmail(String token) async { + final generation = _generation; + final u = SentroyAuthUser.fromJson( + jsonObject( + jsonObject( + await request('POST', '/verify-email', body: {'token': token}), + )['user'], + ), + ); + final s = _session; + if (generation == _generation && s != null && s.user.id == u.id) { + await setSession( + AuthSession( + accessToken: s.accessToken, + refreshToken: s.refreshToken, + user: u, + ), + ); + } + return u; + } + + Future sendMagicLink({ + required String email, + String? redirectUri, + }) async { + await request( + 'POST', + '/magic-link/request', + body: {'email': email, 'redirectUri': ?redirectUri}, + ); + } + + Future consumeMagicLink(String token) => + _login('/magic-link/consume', {'token': token}); + Future acceptInvitation({ + required String token, + required String password, + String? displayName, + }) => _login('/invitation/accept', { + 'token': token, + 'password': password, + 'displayName': ?displayName, + }); + Uri socialAuthorizeUrl( + SocialProvider provider, { + String? redirectUri, + bool? rememberMe, + }) => _uri('/social/${provider.name}/authorize', { + 'redirectUri': ?redirectUri, + if (rememberMe == true) 'rememberMe': '1', + }); + Future consumeRedirectFragment(String fragment) async { + final hash = fragment.startsWith('#') ? fragment.substring(1) : fragment; + final p = Uri(query: hash).queryParametersAll; + if ((p['access_token']?.length ?? 0) != 1 || + (p['refresh_token']?.length ?? 0) != 1) { + return null; + } + final a = p['access_token']!.single, r = p['refresh_token']!.single; + if (a.isEmpty || r.isEmpty) return null; + final generation = _generation; + final u = SentroyAuthUser.fromJson( + jsonObject(await request('GET', '/me', bearer: a)), + ); + if (generation == _generation) { + await setSession(AuthSession(accessToken: a, refreshToken: r, user: u)); + } + return u; + } + + Future getCurrentUser() async { + final s = _session; + if (s == null) return null; + final generation = _generation; + final u = SentroyAuthUser.fromJson( + jsonObject(await request('GET', '/me', bearer: s.accessToken)), + ); + if (generation == _generation) { + await setSession( + AuthSession( + accessToken: s.accessToken, + refreshToken: s.refreshToken, + user: u, + ), + ); + } + return u; + } + + Future> listSessions() async => jsonList( + await request('GET', '/me/sessions', bearer: _token()), + SessionSummary.fromJson, + ); + Future revokeSession(String id) async { + await request( + 'DELETE', + '/me/sessions/${pathSegment(id)}', + bearer: _token(), + ); + } + + Future changePassword({ + required String currentPassword, + required String newPassword, + }) async { + final generation = _generation; + await request( + 'POST', + '/me/password', + bearer: _token(), + body: {'currentPassword': currentPassword, 'newPassword': newPassword}, + ); + if (generation == _generation) await setSession(null); + } + + Future requestEmailChange({ + required String newEmail, + required String currentPassword, + }) async { + await request( + 'POST', + '/me/email/change-request', + bearer: _token(), + body: {'newEmail': newEmail, 'currentPassword': currentPassword}, + ); + } + + Future confirmEmailChange(String token) async { + final generation = _generation; + final u = SentroyAuthUser.fromJson( + jsonObject( + await request( + 'POST', + '/me/email/change-confirm', + body: {'token': token}, + ), + ), + ); + if (generation == _generation) await setSession(null); + return u; + } + + Future requestAccountDeletion(String currentPassword) async { + await request( + 'POST', + '/me/account/delete-request', + bearer: _token(), + body: {'currentPassword': currentPassword}, + ); + } + + Future confirmAccountDeletion(String token) async { + final generation = _generation; + await request('POST', '/me/account/delete-confirm', body: {'token': token}); + if (generation == _generation) await setSession(null); + } + + Future> getActivity() async => jsonList( + await request('GET', '/me/activity', bearer: _token()), + ActivityEntry.fromJson, + ); + Future getMfaStatus() async => MfaStatus.fromJson( + jsonObject(await request('GET', '/me/mfa', bearer: _token())), + ); + Future enrollTotp() async => MfaEnrollResponse.fromJson( + jsonObject(await request('POST', '/me/mfa/totp/enroll', bearer: _token())), + ); + Future verifyTotpEnrollment(String code) async => + MfaVerifyEnrollmentResponse.fromJson( + jsonObject( + await request( + 'POST', + '/me/mfa/totp/verify-enrollment', + bearer: _token(), + body: {'code': code}, + ), + ), + ); + Future disableTotp(String currentPassword) async { + await request( + 'POST', + '/me/mfa/totp/disable', + bearer: _token(), + body: {'currentPassword': currentPassword}, + ); + } + + Future> listPasskeys() async => jsonList( + await request('GET', '/me/passkey', bearer: _token()), + PasskeySummary.fromJson, + ); + Future deletePasskey(String id) async { + await request('DELETE', '/me/passkey/${pathSegment(id)}', bearer: _token()); + } + + Future beginPasskeyRegistration() async => + PasskeyChallenge.fromJson( + jsonObject( + await request('POST', '/me/passkey/register/begin', bearer: _token()), + ), + ); + Future completePasskeyRegistration({ + required String challengeToken, + required JsonMap response, + String? deviceName, + }) async { + await request( + 'POST', + '/me/passkey/register/complete', + bearer: _token(), + body: { + 'challengeToken': challengeToken, + 'response': response, + 'deviceName': ?deviceName, + }, + ); + } + + Future beginPasskeyAuthentication({String? email}) async => + PasskeyChallenge.fromJson( + jsonObject( + await request( + 'POST', + '/passkey/authenticate/begin', + body: {'email': ?email}, + ), + ), + ); + Future completePasskeyAuthentication({ + required String challengeToken, + required JsonMap response, + bool? rememberMe, + }) => _login('/passkey/authenticate/complete', { + 'challengeToken': challengeToken, + 'response': response, + 'rememberMe': ?rememberMe, + }); +} diff --git a/flutter/lib/src/auth/storage.dart b/flutter/lib/src/auth/storage.dart new file mode 100644 index 0000000..ca8b055 --- /dev/null +++ b/flutter/lib/src/auth/storage.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import '../json.dart'; +import 'types.dart'; + +abstract class AuthSessionStorage { + Future read(); + Future write(AuthSession session); + Future clear(); +} + +class MemoryAuthSessionStorage implements AuthSessionStorage { + AuthSession? _session; + @override + Future read() async => _session; + @override + Future write(AuthSession session) async { + _session = session; + } + + @override + Future clear() async { + _session = null; + } +} + +/// Bridges platform secure-storage APIs without adding a platform plugin to +/// every SDK consumer. Supply callbacks bound to a project-specific storage key. +class SerializedAuthSessionStorage implements AuthSessionStorage { + const SerializedAuthSessionStorage({ + required Future Function() read, + required Future Function(String value) write, + required Future Function() clear, + }) : _read = read, + _write = write, + _clear = clear; + final Future Function() _read; + final Future Function(String value) _write; + final Future Function() _clear; + @override + Future read() async { + final value = await _read(); + return value == null + ? null + : AuthSession.fromJson(jsonObject(jsonDecode(value))); + } + + @override + Future write(AuthSession session) => + _write(jsonEncode(session.toJson())); + @override + Future clear() => _clear(); +} diff --git a/flutter/lib/src/auth/types.dart b/flutter/lib/src/auth/types.dart new file mode 100644 index 0000000..29376d7 --- /dev/null +++ b/flutter/lib/src/auth/types.dart @@ -0,0 +1,268 @@ +import '../json.dart'; + +class SentroyAuthUser { + SentroyAuthUser({ + required this.id, + required this.authProjectId, + required this.email, + required this.emailVerified, + this.displayName, + this.image, + this.metadata = const {}, + this.lastLoginAt, + required this.createdAt, + required this.updatedAt, + }); + final String id, authProjectId, email, createdAt, updatedAt; + final bool emailVerified; + final String? displayName, image, lastLoginAt; + final JsonMap metadata; + factory SentroyAuthUser.fromJson(JsonMap j) => SentroyAuthUser( + id: j['id'] as String, + authProjectId: j['authProjectId'] as String, + email: j['email'] as String, + emailVerified: j['emailVerified'] as bool, + displayName: j['displayName'] as String?, + image: j['image'] as String?, + metadata: j['metadata'] == null ? {} : jsonObject(j['metadata']), + lastLoginAt: j['lastLoginAt'] as String?, + createdAt: j['createdAt'] as String, + updatedAt: j['updatedAt'] as String, + ); + JsonMap toJson() => { + 'id': id, + 'authProjectId': authProjectId, + 'email': email, + 'emailVerified': emailVerified, + 'displayName': displayName, + 'image': image, + 'metadata': metadata, + 'lastLoginAt': lastLoginAt, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; +} + +class AuthSession { + const AuthSession({ + required this.accessToken, + required this.refreshToken, + required this.user, + }); + final String accessToken, refreshToken; + final SentroyAuthUser user; + factory AuthSession.fromJson(JsonMap j) => AuthSession( + accessToken: j['accessToken'] as String, + refreshToken: j['refreshToken'] as String, + user: SentroyAuthUser.fromJson(jsonObject(j['user'])), + ); + JsonMap toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'user': user.toJson(), + }; +} + +class SignInRequest { + const SignInRequest({ + required this.email, + required this.password, + this.rememberMe, + }); + final String email, password; + final bool? rememberMe; + JsonMap toJson() => { + 'email': email, + 'password': password, + if (rememberMe != null) 'rememberMe': rememberMe, + }; +} + +class SignUpRequest { + const SignUpRequest({ + required this.email, + required this.password, + this.displayName, + this.metadata, + }); + final String email, password; + final String? displayName; + final JsonMap? metadata; + JsonMap toJson() => { + 'email': email, + 'password': password, + if (displayName != null) 'displayName': displayName, + if (metadata != null) 'metadata': metadata, + }; +} + +class MfaVerifyRequest { + const MfaVerifyRequest({ + required this.mfaToken, + this.code, + this.recoveryCode, + }); + final String mfaToken; + final String? code, recoveryCode; + JsonMap toJson() => { + 'mfaToken': mfaToken, + if (code != null) 'code': code, + if (recoveryCode != null) 'recoveryCode': recoveryCode, + }; +} + +class AuthTokensResponse { + const AuthTokensResponse({ + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + required this.tokenType, + }); + final String accessToken, refreshToken, tokenType; + final int expiresIn; + factory AuthTokensResponse.fromJson(JsonMap j) => AuthTokensResponse( + accessToken: j['accessToken'] as String, + refreshToken: j['refreshToken'] as String, + expiresIn: j['expiresIn'] as int, + tokenType: j['tokenType'] as String, + ); +} + +class LoginResponse extends AuthTokensResponse { + const LoginResponse({ + required this.user, + required super.accessToken, + required super.refreshToken, + required super.expiresIn, + required super.tokenType, + }); + final SentroyAuthUser user; + factory LoginResponse.fromJson(JsonMap j) => LoginResponse( + user: SentroyAuthUser.fromJson(jsonObject(j['user'])), + accessToken: j['accessToken'] as String, + refreshToken: j['refreshToken'] as String, + expiresIn: j['expiresIn'] as int, + tokenType: j['tokenType'] as String, + ); + AuthSession get session => AuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + user: user, + ); +} + +class SignupResponse { + const SignupResponse({ + required this.user, + this.accessToken, + this.refreshToken, + this.expiresIn, + this.tokenType, + this.emailVerificationRequired, + }); + final SentroyAuthUser user; + final String? accessToken, refreshToken, tokenType; + final int? expiresIn; + final bool? emailVerificationRequired; + factory SignupResponse.fromJson(JsonMap j) => SignupResponse( + user: SentroyAuthUser.fromJson(jsonObject(j['user'])), + accessToken: j['accessToken'] as String?, + refreshToken: j['refreshToken'] as String?, + expiresIn: j['expiresIn'] as int?, + tokenType: j['tokenType'] as String?, + emailVerificationRequired: j['emailVerificationRequired'] as bool?, + ); +} + +class MfaChallengeResponse { + const MfaChallengeResponse({ + required this.mfaToken, + required this.factorType, + }); + final String mfaToken, factorType; + factory MfaChallengeResponse.fromJson(JsonMap j) => MfaChallengeResponse( + mfaToken: j['mfaToken'] as String, + factorType: j['factorType'] as String, + ); +} + +class LoginOutcome { + const LoginOutcome.tokens(this.tokens) : mfa = null; + const LoginOutcome.mfa(this.mfa) : tokens = null; + final LoginResponse? tokens; + final MfaChallengeResponse? mfa; + String get kind => mfa == null ? 'tokens' : 'mfa'; +} + +class SessionSummary { + SessionSummary.fromJson(JsonMap j) + : id = j['id'] as String, + refreshTokenPrefix = j['refreshTokenPrefix'] as String, + userAgent = j['userAgent'] as String?, + ip = j['ip'] as String?, + expiresAt = j['expiresAt'] as String, + createdAt = j['createdAt'] as String; + final String id, refreshTokenPrefix, expiresAt, createdAt; + final String? userAgent, ip; +} + +class ActivityEntry { + ActivityEntry.fromJson(JsonMap j) + : id = j['id'] as String, + action = j['action'] as String, + ipAddress = j['ipAddress'] as String?, + createdAt = j['createdAt'] as String, + details = j['details'] == null ? null : jsonObject(j['details']); + final String id, action, createdAt; + final String? ipAddress; + final JsonMap? details; +} + +class MfaStatus { + MfaStatus.fromJson(JsonMap j) + : enrolled = j['enrolled'] as bool, + factorType = j['factorType'] as String?, + verifiedAt = j['verifiedAt'] as String?, + recoveryCodesRemaining = j['recoveryCodesRemaining'] as int?; + final bool enrolled; + final String? factorType, verifiedAt; + final int? recoveryCodesRemaining; +} + +class MfaEnrollResponse { + MfaEnrollResponse.fromJson(JsonMap j) + : secret = j['secret'] as String, + otpauthUri = j['otpauthUri'] as String; + final String secret, otpauthUri; +} + +class MfaVerifyEnrollmentResponse { + MfaVerifyEnrollmentResponse.fromJson(JsonMap j) + : enrolled = j['enrolled'] as bool, + recoveryCodes = List.from(j['recoveryCodes'] as List); + final bool enrolled; + final List recoveryCodes; +} + +class PasskeySummary { + PasskeySummary.fromJson(JsonMap j) + : id = j['id'] as String, + credentialIdPrefix = j['credentialIdPrefix'] as String, + deviceName = j['deviceName'] as String?, + transports = List.from(j['transports'] as List), + lastUsedAt = j['lastUsedAt'] as String?, + createdAt = j['createdAt'] as String; + final String id, credentialIdPrefix, createdAt; + final String? deviceName, lastUsedAt; + final List transports; +} + +class PasskeyChallenge { + PasskeyChallenge.fromJson(JsonMap j) + : options = jsonObject(j['options']), + challengeToken = j['challengeToken'] as String; + final JsonMap options; + final String challengeToken; +} + +enum SocialProvider { google, github, facebook, microsoft, twitter, apple } diff --git a/flutter/lib/src/client.dart b/flutter/lib/src/client.dart new file mode 100644 index 0000000..406b6e9 --- /dev/null +++ b/flutter/lib/src/client.dart @@ -0,0 +1,94 @@ +import 'package:http/http.dart' as http; +import 'http.dart'; +import 'json.dart'; +import 'resources/mail.dart'; +import 'resources/storage.dart'; +import 'resources/whatsapp.dart'; + +/// One company-scoped SDK. Closes a client it creates, while leaving a supplied +/// platform client under application ownership. +class Sentroy { + factory Sentroy({ + required String companySlug, + String? accessToken, + Uri? baseUrl, + http.Client? httpClient, + Duration timeout = const Duration(seconds: 30), + }) { + final base = baseUrl ?? Uri.parse('https://sentroy.com'); + if (!['http', 'https'].contains(base.scheme) || + !base.hasAuthority || + base.userInfo.isNotEmpty || + base.hasQuery || + base.hasFragment) { + throw ArgumentError('Invalid Sentroy base URL'); + } + final slug = pathSegment(companySlug); + final root = base.toString().replaceFirst(RegExp(r'/+$'), ''); + final client = httpClient ?? http.Client(); + try { + SentroyHttp gateway(String app) => SentroyHttp( + baseUrl: Uri.parse('$root/api/$app/companies/$slug'), + accessToken: accessToken, + client: client, + timeout: timeout, + ); + return Sentroy._( + gateway('mail'), + gateway('storage'), + gateway('whatsapp'), + client, + httpClient == null, + ); + } catch (_) { + if (httpClient == null) client.close(); + rethrow; + } + } + Sentroy._( + SentroyHttp mail, + SentroyHttp storageHttp, + SentroyHttp whatsappHttp, + this._client, + this._ownsClient, + ) : domains = Domains(mail), + mailboxes = Mailboxes(mail), + templates = Templates(mail), + inbox = Inbox(mail), + send = Send(mail), + audience = Audience(mail), + suppressions = Suppressions(mail), + webhooks = Webhooks(mail), + logs = Logs(mail), + buckets = Buckets(storageHttp), + media = MediaResource(storageHttp), + storage = Storage(storageHttp), + whatsapp = WhatsApp(whatsappHttp), + _transports = [mail, storageHttp, whatsappHttp]; + + final Domains domains; + final Mailboxes mailboxes; + final Templates templates; + final Inbox inbox; + final Send send; + final Audience audience; + final Suppressions suppressions; + final Webhooks webhooks; + final Logs logs; + final Buckets buckets; + final MediaResource media; + final Storage storage; + final WhatsApp whatsapp; + final http.Client _client; + final bool _ownsClient; + final List _transports; + bool _closed = false; + void close() { + if (_closed) return; + _closed = true; + for (final transport in _transports) { + transport.close(); + } + if (_ownsClient) _client.close(); + } +} diff --git a/flutter/lib/src/errors.dart b/flutter/lib/src/errors.dart new file mode 100644 index 0000000..0b1ad3d --- /dev/null +++ b/flutter/lib/src/errors.dart @@ -0,0 +1,33 @@ +/// API or transport failure. Bodies may contain private data; toString omits them. +class SentroyException implements Exception { + const SentroyException( + this.message, { + this.statusCode, + this.body, + this.cause, + }); + final String message; + final int? statusCode; + final dynamic body; + final Object? cause; + @override + String toString() => + 'SentroyException${statusCode == null ? '' : ' ($statusCode)'}: $message'; +} + +class ComposeRequestError extends SentroyException { + ComposeRequestError(this.requestId, Object cause) + : super( + 'Compose outcome requires recovery using request $requestId', + statusCode: cause is SentroyException ? cause.statusCode : null, + body: cause is SentroyException ? cause.body : null, + cause: cause, + ); + final String requestId; +} + +class SentroyAuthException extends SentroyException { + const SentroyAuthException(this.code, super.description, {super.statusCode}); + final String code; + String get description => message; +} diff --git a/flutter/lib/src/flutter_auth.dart b/flutter/lib/src/flutter_auth.dart new file mode 100644 index 0000000..e545d68 --- /dev/null +++ b/flutter/lib/src/flutter_auth.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'auth/client.dart'; +import 'auth/types.dart'; + +/// A notifier for session restoration and interactive authentication. The +/// application owns the supplied auth client and its secure storage adapter. +class SentroyAuthController extends ChangeNotifier { + SentroyAuthController(this.auth) : _session = auth.currentSession { + _subscription = auth.changes.listen((session) { + if (_disposed) return; + _session = session; + notifyListeners(); + }); + } + final SentroyAuth auth; + late final StreamSubscription _subscription; + AuthSession? _session; + bool _busy = false; + bool _initialized = false; + bool _disposed = false; + Object? _error; + AuthSession? get session => _session; + SentroyAuthUser? get user => _session?.user; + bool get isSignedIn => _session != null; + bool get isBusy => _busy; + bool get isInitialized => _initialized; + Object? get error => _error; + + Future _run(Future Function() action) async { + if (_disposed) throw StateError('Auth controller is disposed'); + if (_busy) throw StateError('An authentication action is already running'); + _busy = true; + _error = null; + notifyListeners(); + try { + final result = await action(); + _session = auth.currentSession; + return result; + } catch (error) { + _error = error; + rethrow; + } finally { + _busy = false; + if (!_disposed) notifyListeners(); + } + } + + Future initialize() => _run(() async { + await auth.restore(); + _initialized = true; + }); + Future signIn(SignInRequest request) => + _run(() => auth.signIn(request)); + Future signUp(SignUpRequest request) => + _run(() => auth.signUp(request)); + Future verifyMfa(MfaVerifyRequest request) => + _run(() => auth.verifyMfa(request)); + Future refresh() => _run(auth.refreshNow); + Future signOut() => _run(auth.signOut); + void clearError() { + if (_disposed || _error == null) return; + _error = null; + notifyListeners(); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + unawaited(_subscription.cancel()); + super.dispose(); + } +} + +/// Makes a controller available to descendant widgets and rebuilds consumers +/// when session, loading or error state changes. +class SentroyAuthScope extends InheritedNotifier { + const SentroyAuthScope({ + super.key, + required SentroyAuthController controller, + required super.child, + }) : super(notifier: controller); + static SentroyAuthController of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + if (scope?.notifier == null) { + throw FlutterError('SentroyAuthScope was not found in this context'); + } + return scope!.notifier!; + } +} diff --git a/flutter/lib/src/http.dart b/flutter/lib/src/http.dart new file mode 100644 index 0000000..c01d2f6 --- /dev/null +++ b/flutter/lib/src/http.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'errors.dart'; + +/// Shared HTTP transport. A supplied client remains caller-owned. +class SentroyHttp { + SentroyHttp({ + required Uri baseUrl, + String? accessToken, + http.Client? client, + Duration timeout = const Duration(seconds: 30), + }) : this._( + baseUrl, + accessToken, + client ?? http.Client(), + client == null, + timeout, + ); + + SentroyHttp._( + Uri baseUrl, + this._accessToken, + this._client, + this._ownsClient, + this.timeout, + ) : _baseUrl = baseUrl.toString().replaceFirst(RegExp(r'/+$'), '') { + if (!['http', 'https'].contains(baseUrl.scheme) || + !baseUrl.hasAuthority || + baseUrl.host.isEmpty || + baseUrl.userInfo.isNotEmpty || + baseUrl.hasQuery || + baseUrl.hasFragment || + timeout <= Duration.zero) { + throw ArgumentError( + 'Base URL must be HTTP(S) without credentials, query or fragment; timeout must be positive', + ); + } + if (_accessToken != null && + (_accessToken.isEmpty || + RegExp(r'[^\x21-\x7e]').hasMatch(_accessToken))) { + throw ArgumentError('Invalid bearer token'); + } + } + + static const int jsonLimit = 16 * 1024 * 1024; + final String _baseUrl; + final String? _accessToken; + final http.Client _client; + final bool _ownsClient; + final Duration timeout; + bool _closed = false; + + SentroyHttp withBearer(String? token) { + if (_closed) throw const SentroyException('HTTP client is closed'); + return SentroyHttp._(Uri.parse(_baseUrl), token, _client, false, timeout); + } + + Uri _uri(String path, Map? query) { + if (!path.startsWith('/') || + path.startsWith('//') || + path.contains('?') || + path.contains('#') || + path.runes.any((c) => c < 32 || c == 127)) { + throw ArgumentError('Invalid relative API path'); + } + final values = {}; + String scalar(Object? item) { + if (item is String || item is num || item is bool) return '$item'; + throw ArgumentError('Query fields must contain scalar values'); + } + + for (final entry in (query ?? {}).entries) { + final value = entry.value; + if (value == null) continue; + values[entry.key] = value is List + ? value.map(scalar).join(',') + : scalar(value); + } + final uri = Uri.parse('$_baseUrl$path'); + return values.isEmpty ? uri : uri.replace(queryParameters: values); + } + + void _headers(http.BaseRequest request) { + if (_closed) throw const SentroyException('HTTP client is closed'); + request.followRedirects = false; + request.maxRedirects = 0; + request.headers['Accept'] = 'application/json'; + if (_accessToken != null) { + request.headers['Authorization'] = 'Bearer $_accessToken'; + } + } + + Future _bounded(http.StreamedResponse response, int? cap) async { + if (cap != null && + response.contentLength != null && + response.contentLength! > cap) { + await response.stream.listen(null).cancel(); + throw const SentroyException('Response exceeds byte limit'); + } + final builder = BytesBuilder(); + await for (final chunk in response.stream) { + if (cap != null && chunk.length > cap - builder.length) { + throw const SentroyException('Response exceeds byte limit'); + } + builder.add(chunk); + } + return builder.takeBytes(); + } + + Future _execute( + http.BaseRequest request, + Completer abort, + Future Function(http.StreamedResponse) decode, + ) async { + _headers(request); + try { + return await (() async => decode(await _client.send(request)))().timeout( + timeout, + onTimeout: () { + if (!abort.isCompleted) abort.complete(); + throw const SentroyException('HTTP request timed out'); + }, + ); + } on SentroyException { + rethrow; + } on FormatException catch (error) { + throw SentroyException('Invalid JSON response', cause: error); + } catch (error) { + throw SentroyException('HTTP request failed', cause: error); + } + } + + Future _decode( + http.StreamedResponse response, { + bool once = false, + }) async { + if (once && + ((response.statusCode >= 300 && response.statusCode < 400) || + !(response.headers['content-type']?.toLowerCase().startsWith( + 'application/json', + ) ?? + false))) { + await response.stream.listen(null).cancel(); + throw const SentroyException('Invalid compose receipt'); + } + final bytes = await _bounded(response, once ? 4096 : jsonLimit); + dynamic payload; + try { + payload = bytes.isEmpty ? null : jsonDecode(utf8.decode(bytes)); + } on FormatException { + if (response.statusCode >= 200 && response.statusCode < 300) rethrow; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw SentroyException( + 'API request failed', + statusCode: response.statusCode, + body: payload, + ); + } + return payload is Map && payload.containsKey('data') + ? payload['data'] + : payload; + } + + Future _json( + String method, + String path, { + Map? query, + Object? body, + bool once = false, + }) { + final abort = Completer(); + final request = http.AbortableRequest( + method, + _uri(path, query), + abortTrigger: abort.future, + ); + if (body != null) { + request.headers['Content-Type'] = 'application/json'; + request.body = jsonEncode(body); + } + return _execute( + request, + abort, + (response) => _decode(response, once: once), + ); + } + + Future get(String path, {Map? query}) => + _json('GET', path, query: query); + Future post(String path, [Object? body]) => + _json('POST', path, body: body); + Future patch(String path, Object? body) => + _json('PATCH', path, body: body); + Future delete( + String path, { + Map? query, + Object? body, + }) => _json('DELETE', path, query: query, body: body); + Future postOnce(String path, Object body) { + if (!['/send-bcc', '/inbox/drafts-bcc-v1'].contains(path)) { + throw ArgumentError('Invalid compose path'); + } + return _json('POST', path, body: body, once: true); + } + + Future getBytes( + String path, { + Map? query, + int? maxBytes, + }) { + if (maxBytes != null && maxBytes < 0) { + throw ArgumentError('Invalid byte limit'); + } + final abort = Completer(); + final request = http.AbortableRequest( + 'GET', + _uri(path, query), + abortTrigger: abort.future, + ); + return _execute(request, abort, (response) async { + if (response.statusCode < 200 || response.statusCode >= 300) { + await _decode(response); + } + return _bounded(response, maxBytes); + }); + } + + Future multipart( + String path, { + required List bytes, + required String filename, + String? mimeType, + Map fields = const {}, + }) { + final abort = Completer(); + final request = http.AbortableMultipartRequest( + 'POST', + _uri(path, null), + abortTrigger: abort.future, + ); + request.fields.addAll(fields); + request.files.add( + http.MultipartFile.fromBytes( + 'file', + bytes, + filename: filename, + contentType: mimeType == null ? null : MediaType.parse(mimeType), + ), + ); + return _execute(request, abort, _decode); + } + + void close() { + if (_closed) return; + _closed = true; + if (_ownsClient) _client.close(); + } + + @override + String toString() => 'SentroyHttp(authenticated: ${_accessToken != null})'; +} diff --git a/flutter/lib/src/json.dart b/flutter/lib/src/json.dart new file mode 100644 index 0000000..28bde1a --- /dev/null +++ b/flutter/lib/src/json.dart @@ -0,0 +1,31 @@ +/// JSON object returned by the Sentroy API. +typedef JsonMap = Map; + +/// An explicit nullable patch. A null wrapper omits the field; this wrapper +/// with a null value clears it. +class PatchValue { + const PatchValue(this.value); + final T? value; +} + +JsonMap jsonObject(dynamic value) { + if (value is Map) return value; + if (value is Map) return Map.from(value); + throw const FormatException('Expected a JSON object'); +} + +List jsonList(dynamic value, T Function(JsonMap) decode) { + if (value is! List) throw const FormatException('Expected a JSON array'); + return value.map((item) => decode(jsonObject(item))).toList(growable: false); +} + +/// Encode one route segment without permitting URL dot-segment normalization. +String pathSegment(String value) { + if (value.isEmpty || + value == '.' || + value == '..' || + value.runes.any((c) => c < 32 || c == 127)) { + throw ArgumentError('Invalid path segment'); + } + return Uri.encodeComponent(value); +} diff --git a/flutter/lib/src/mcp/mcp.dart b/flutter/lib/src/mcp/mcp.dart new file mode 100644 index 0000000..f516779 --- /dev/null +++ b/flutter/lib/src/mcp/mcp.dart @@ -0,0 +1,875 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:mcp_dart/mcp_dart.dart' as mcp; +import '../errors.dart'; + +export 'package:mcp_dart/mcp_dart.dart' + show McpClient, Implementation, CallToolResult, CallToolRequest; + +class SentroyMcpError extends SentroyException { + const SentroyMcpError(this.code, [int? status]) + : super(code, statusCode: status); + final String code; +} + +typedef McpTokenProvider = FutureOr Function(); +const _origins = { + 'core': 'https://sentroy.com', + 'mail': 'https://mail.sentroy.com', + 'storage': 'https://storage.sentroy.com', + 'linear': 'https://linear.sentroy.com', + 'chaby': 'https://chaby.sentroy.com', +}; +final _slug = RegExp(r'^[a-z0-9][a-z0-9_-]{0,99}$'); +final _id = RegExp(r'^[a-f0-9]{24}$'); +final _hex = RegExp(r'^[a-f0-9]{64}$'); +final _uuid = RegExp( + r'^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$', +); +const _contactCap = 1024 * 1024, + _storageCap = 10 * 1024 * 1024, + _downloadCap = 64 * 1024 * 1024; + +String sentroyMcpResource({ + required String app, + required String companySlug, + String? origin, +}) { + if (!_origins.containsKey(app) || !_slug.hasMatch(companySlug)) { + throw const SentroyMcpError('invalid_mcp_resource'); + } + final u = Uri.tryParse(origin ?? _origins[app]!); + if (u == null || + !u.hasAuthority || + u.host.isEmpty || + u.userInfo.isNotEmpty || + u.hasQuery || + u.hasFragment || + (u.path.isNotEmpty && u.path != '/') || + !(u.scheme == 'https' || + (u.scheme == 'http' && + ['localhost', '127.0.0.1', '::1'].contains(u.host)))) { + throw const SentroyMcpError('invalid_mcp_origin'); + } + return '${u.origin}/mcp/${Uri.encodeComponent(companySlug)}'; +} + +String sentroyMcpResourceMetadata({ + required String app, + required String companySlug, + String? origin, +}) { + final u = Uri.parse( + sentroyMcpResource(app: app, companySlug: companySlug, origin: origin), + ); + return '${u.origin}/.well-known/oauth-protected-resource${u.path}'; +} + +Future _token(Object source) async { + Object? value; + try { + value = source is String ? source : await (source as McpTokenProvider)(); + } catch (_) { + throw const SentroyMcpError('mcp_token_unavailable'); + } + if (value is! String || + !RegExp(r'^mcp_(?:at_)?[A-Za-z0-9_-]{43}$').hasMatch(value)) { + throw const SentroyMcpError('invalid_mcp_token'); + } + return value; +} + +const _ext = >{ + 'application/pdf': ['pdf'], + 'image/jpeg': ['jpg', 'jpeg'], + 'image/png': ['png'], + 'image/webp': ['webp'], + 'text/plain': ['txt', 'log'], + 'text/csv': ['csv'], + 'audio/mpeg': ['mp3'], + 'audio/wav': ['wav'], + 'audio/ogg': ['ogg', 'oga'], + 'audio/mp4': ['m4a', 'mp4'], + 'audio/webm': ['webm'], + 'video/mp4': ['mp4'], + 'video/webm': ['webm'], + 'video/quicktime': ['mov'], +}; +bool _name(dynamic name, dynamic mime) => + name is String && + mime is String && + name.isNotEmpty && + name.length <= 200 && + name.trim() == name && + utf8.decode(utf8.encode(name)) == name && + !RegExp(r'[/\\\x00-\x1f\x7f]').hasMatch(name) && + name.lastIndexOf('.') > 0 && + (_ext[mime]?.contains( + name.substring(name.lastIndexOf('.') + 1).toLowerCase(), + ) ?? + false); +Uint8List _copy(List body, int cap, String code) { + if (body.isEmpty || body.length > cap || body.any((v) => v < 0 || v > 255)) { + throw SentroyMcpError(code); + } + return Uint8List.fromList(body); +} + +String _hash(List b) => sha256.convert(b).toString(); +Map sentroyMcpContactImportFileInfo( + List body, + String format, +) { + if (!['csv', 'jsonl'].contains(format)) { + throw const SentroyMcpError('invalid_mcp_contact_file'); + } + final b = _copy(body, _contactCap, 'invalid_mcp_contact_file'); + return {'format': format, 'bytes': b.length, 'fileHash': _hash(b)}; +} + +Map sentroyMcpStorageUploadFileInfo( + List body, { + required String name, + required String mimeType, +}) { + if (!_name(name, mimeType)) { + throw const SentroyMcpError('invalid_mcp_storage_upload'); + } + final b = _copy(body, _storageCap, 'invalid_mcp_storage_upload'); + return { + 'name': name, + 'mimeType': mimeType, + 'bytes': b.length, + 'sha256': _hash(b), + }; +} + +bool _future(dynamic s) { + if (s is! String || + !RegExp(r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$').hasMatch(s)) { + return false; + } + final d = DateTime.tryParse(s); + return d != null && + d.toUtc().toIso8601String() == s && + d.isAfter(DateTime.now().toUtc()); +} + +bool _timestamp(dynamic s) { + if (s is! String || + !RegExp(r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$').hasMatch(s)) { + return false; + } + final d = DateTime.tryParse(s); + return d != null && d.toUtc().toIso8601String() == s; +} + +bool _re(RegExp r, dynamic s) => s is String && r.hasMatch(s); +Map _snapshot(Map raw) => + Map.from(jsonDecode(jsonEncode(raw)) as Map); +Uri _url(dynamic raw, Uri resource, String code) { + final u = raw is String && raw.length <= 5000 ? Uri.tryParse(raw) : null; + if (u == null || + !u.hasAuthority || + u.origin != resource.origin || + u.userInfo.isNotEmpty || + u.hasFragment) { + throw SentroyMcpError(code); + } + return u; +} + +bool _query(Uri u, Set names) => + u.queryParametersAll.length == names.length && + u.queryParametersAll.keys.toSet().containsAll(names) && + u.queryParametersAll.values.every((v) => v.length == 1); + +class SentroyMcpConnection { + SentroyMcpConnection._( + this.app, + this.resource, + this._source, + this._http, + this.client, + ); + final String app; + final String resource; + final Object _source; + final http.Client _http; + final mcp.McpClient client; + Uri get _resource => Uri.parse(resource); + bool _closed = false; + final Set> _transfers = {}; + Future close() async { + if (_closed) return; + _closed = true; + for (final abort in _transfers.toList()) { + if (!abort.isCompleted) abort.complete(); + } + await client.close(); + } + + Future _transfer( + String method, + Uri url, { + List? body, + required int cap, + int? expected, + int timeoutMs = 60000, + String prefix = 'mcp_download', + String? expiresAt, + bool requireJson = false, + }) async { + if (timeoutMs < 1 || timeoutMs > 120000) { + throw const SentroyMcpError('invalid_mcp_timeout'); + } + if (_closed) throw const SentroyMcpError('mcp_connection_closed'); + final abort = Completer(); + _transfers.add(abort); + var sent = false; + Timer? timer; + StreamSubscription>? sub; + try { + timer = Timer(Duration(milliseconds: timeoutMs), () { + if (!abort.isCompleted) abort.complete(); + sub?.cancel(); + }); + final bearer = await Future.any([ + _token(_source), + abort.future.then( + (_) => throw SentroyMcpError('${prefix}_aborted'), + ), + ]); + if (expiresAt != null && !_future(expiresAt)) { + throw SentroyMcpError( + url.path.contains('/contacts/') + ? 'invalid_mcp_contact_file_reference' + : 'invalid_mcp_storage_upload_reference', + ); + } + final req = http.AbortableRequest(method, url, abortTrigger: abort.future) + ..followRedirects = false + ..headers['Authorization'] = 'Bearer $bearer'; + if (body != null) { + req.headers['Content-Type'] = 'application/octet-stream'; + req.bodyBytes = body; + } + sent = true; + final response = await Future.any([ + _http.send(req), + abort.future.then( + (_) => throw SentroyMcpError('${prefix}_aborted'), + ), + ]); + if (response.statusCode != 200) { + await response.stream.listen(null).cancel(); + throw SentroyMcpError( + method == 'GET' + ? 'mcp_download_failed' + : (response.statusCode >= 500 || response.statusCode < 400 + ? 'mcp_upload_outcome_unknown' + : 'mcp_upload_failed'), + response.statusCode, + ); + } + if (requireJson && + response.headers['content-type']?.split(';').first.trim() != + 'application/json') { + await response.stream.listen(null).cancel(); + throw const SentroyMcpError('mcp_upload_outcome_unknown'); + } + final encoding = response.headers['content-encoding']?.toLowerCase(), + length = response.headers['content-length']; + if ((encoding == null || encoding == 'identity') && + length != null && + (!RegExp(r'^\d+$').hasMatch(length) || + (int.tryParse(length) ?? cap + 1) > cap || + (expected != null && int.tryParse(length) != expected))) { + await response.stream.listen(null).cancel(); + throw SentroyMcpError('${prefix}_size_mismatch'); + } + final result = BytesBuilder(); + final done = Completer(); + var count = 0; + sub = response.stream.listen( + (chunk) { + count += chunk.length; + if (count > cap || (expected != null && count > expected)) { + sub?.cancel(); + if (!done.isCompleted) { + done.completeError(SentroyMcpError('${prefix}_size_mismatch')); + } + } else { + result.add(chunk); + } + }, + onError: (Object _) { + if (!done.isCompleted) { + done.completeError(SentroyMcpError('${prefix}_failed')); + } + }, + onDone: () { + if (!done.isCompleted) { + if (expected != null && count != expected) { + done.completeError(SentroyMcpError('${prefix}_size_mismatch')); + } else { + done.complete(result.takeBytes()); + } + } + }, + ); + return await Future.any([ + done.future, + abort.future.then( + (_) => throw SentroyMcpError('${prefix}_aborted'), + ), + ]); + } on SentroyMcpError catch (error) { + if (method == 'PUT' && + sent && + error.code != 'mcp_upload_failed' && + error.code != 'mcp_upload_outcome_unknown') { + throw const SentroyMcpError('mcp_upload_outcome_unknown'); + } + rethrow; + } catch (_) { + throw SentroyMcpError( + method == 'PUT' && sent + ? 'mcp_upload_outcome_unknown' + : '${prefix}_failed', + ); + } finally { + _transfers.remove(abort); + timer?.cancel(); + if (!abort.isCompleted) abort.complete(); + await sub?.cancel(); + } + } + + Future downloadFile( + Map raw, { + int timeoutMs = 60000, + }) async { + if (app != 'storage') { + throw const SentroyMcpError('mcp_download_not_supported'); + } + final r = _snapshot(raw), f = r['file']; + if (r['requiresAuthorization'] != true || + r['method'] != 'GET' || + f is! Map || + !_re(_id, f['id']) || + f['size'] is! int || + f['size'] < 0 || + f['size'] > _downloadCap || + r['downloadUrl'] is! String || + (r['downloadUrl'] as String).length > 4000) { + throw const SentroyMcpError('invalid_mcp_download'); + } + final u = _url(r['downloadUrl'], _resource, 'invalid_mcp_download'), + prefix = '${_resource.path}/files/'; + final p = u.path.startsWith(prefix) + ? u.path.substring(prefix.length).split('/') + : []; + if (p.length != 2 || + !_slug.hasMatch(p[0]) || + p[1] != f['id'] || + !_query(u, {'connection', 'version'}) || + !_re(_id, u.queryParameters['connection']) || + !_timestamp(u.queryParameters['version'])) { + throw const SentroyMcpError('invalid_mcp_download'); + } + return _transfer( + 'GET', + u, + cap: _downloadCap, + expected: f['size'] as int, + timeoutMs: timeoutMs, + ); + } + + Future downloadMailAttachment( + Map raw, { + int timeoutMs = 60000, + }) async { + if (app != 'mail') { + throw const SentroyMcpError('mcp_download_not_supported'); + } + final r = _snapshot(raw), a = r['attachment']; + if (r['requiresAuthorization'] != true || + r['method'] != 'GET' || + r['maxBytes'] is! int || + r['maxBytes'] < 1 || + r['maxBytes'] > 8 * 1024 * 1024 || + a is! Map || + a['encodedSize'] is! int || + a['encodedSize'] < 0 || + a['encodedSize'] > 12 * 1024 * 1024 || + a['partId'] is! String || + (a['partId'] as String).length > 80 || + !RegExp(r'^\d+(?:\.\d+)*$').hasMatch(a['partId'])) { + throw const SentroyMcpError('invalid_mcp_download'); + } + final u = _url(r['downloadUrl'], _resource, 'invalid_mcp_download'), + prefix = '${_resource.path}/attachments/'; + final encoded = u.path.startsWith(prefix) + ? u.path.substring(prefix.length) + : ''; + if (!RegExp(r'^[A-Za-z0-9_-]{1,2500}$').hasMatch(encoded) || + !_query(u, {'connection', 'version'}) || + !_re(_id, u.queryParameters['connection']) || + !_re(_hex, u.queryParameters['version'])) { + throw const SentroyMcpError('invalid_mcp_download'); + } + try { + final d = + jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(encoded))), + ) + as Map; + final ref = d['reference'] as Map; + bool clean(dynamic x, int n) => + x is String && + x.isNotEmpty && + x.length <= n && + !RegExp(r'[\r\n\x00]').hasMatch(x); + if (d.length != 2 || + d['partId'] != a['partId'] || + ref.length != 5 || + !clean(ref['companyId'], 100) || + !clean(ref['folder'], 512) || + (ref['folder'] as String).startsWith('__') || + ref['mailbox'] is! String || + (ref['mailbox'] as String).length > 320 || + !RegExp(r'^[^\s@]+@[^\s@]+$').hasMatch(ref['mailbox']) || + !_re(RegExp(r'^[1-9]\d{0,19}$'), ref['uidValidity']) || + ref['uid'] is! int || + ref['uid'] < 1 || + ref['uid'] > 4294967295) { + throw const FormatException(); + } + } catch (_) { + throw const SentroyMcpError('invalid_mcp_download'); + } + return _transfer('GET', u, cap: r['maxBytes'] as int, timeoutMs: timeoutMs); + } + + Uri _contact(Map r, bool upload) { + if (app != 'mail') { + throw const SentroyMcpError('mcp_contact_files_not_supported'); + } + final id = r[upload ? 'uploadId' : 'exportId'], hash = r['fileHash']; + if (r['requiresOriginalBearer'] != true || + r['bytes'] is! int || + r['bytes'] < (upload ? 1 : 0) || + r['bytes'] > _contactCap || + !_future(r['expiresAt']) || + !_re(_uuid, id) || + !_re(_hex, hash) || + (!upload && !['csv', 'jsonl'].contains(r['format']))) { + throw const SentroyMcpError('invalid_mcp_contact_file_reference'); + } + final raw = r[upload ? 'uploadUrl' : 'downloadUrl']; + if (raw is! String || raw.length > 4000) { + throw const SentroyMcpError('invalid_mcp_contact_file_reference'); + } + final u = _url(raw, _resource, 'invalid_mcp_contact_file_reference'); + if (u.path != + '${_resource.path}/contacts/${upload ? 'imports' : 'exports'}/$id' || + !_query(u, upload ? {'connection'} : {'connection', 'hash'}) || + !_re(_id, u.queryParameters['connection']) || + (!upload && u.queryParameters['hash'] != hash)) { + throw const SentroyMcpError('invalid_mcp_contact_file_reference'); + } + return u; + } + + Future downloadMailContactsExport( + Map raw, { + int timeoutMs = 60000, + }) async { + final r = _snapshot(raw), u = _contact(r, false); + final b = await _transfer( + 'GET', + u, + cap: _contactCap, + expected: r['bytes'] as int, + timeoutMs: timeoutMs, + prefix: 'mcp_contact_file', + ); + if (_hash(b) != r['fileHash']) { + throw const SentroyMcpError('mcp_contact_file_hash_mismatch'); + } + return b; + } + + Future> uploadMailContactsImport( + Map raw, + List body, { + int timeoutMs = 15000, + }) async { + final r = _snapshot(raw), + u = _contact(r, true), + b = _copy(body, _contactCap, 'invalid_mcp_contact_file'); + if (b.length != r['bytes']) { + throw const SentroyMcpError('mcp_contact_file_size_mismatch'); + } + if (_hash(b) != r['fileHash']) { + throw const SentroyMcpError('mcp_contact_file_hash_mismatch'); + } + if (!_future(r['expiresAt'])) { + throw const SentroyMcpError('invalid_mcp_contact_file_reference'); + } + final bytes = await _transfer( + 'PUT', + u, + body: b, + cap: 4096, + timeoutMs: timeoutMs, + prefix: 'mcp_upload', + expiresAt: r['expiresAt'] as String, + ); + dynamic result; + try { + result = jsonDecode(utf8.decode(bytes)); + } catch (_) { + throw const SentroyMcpError('mcp_upload_outcome_unknown'); + } + if (result is! Map || + result['uploadId'] != r['uploadId'] || + result['state'] != 'ready' || + result['fileHash'] != r['fileHash'] || + result['expiresAt'] != r['expiresAt'] || + !_future(result['expiresAt']) || + result['count'] is! int || + result['count'] < 1 || + result['count'] > 100) { + throw const SentroyMcpError('mcp_upload_outcome_unknown'); + } + return { + 'uploadId': r['uploadId'], + 'state': 'ready', + 'fileHash': r['fileHash'], + 'expiresAt': r['expiresAt'], + 'count': result['count'], + }; + } + + bool _storageMetadata(Map r) { + final b = r['bucket']; + return _re(_uuid, r['uploadId']) && + _name(r['name'], r['mimeType']) && + r['bytes'] is int && + r['bytes'] >= 1 && + r['bytes'] <= _storageCap && + _re(_hex, r['sha256']) && + _re(_hex, r['revision']) && + b is Map && + _re(_id, b['id']) && + _re(_slug, b['slug']) && + _re(_hex, b['revision']) && + ['waiting', 'ready'].contains(r['state']) && + _future(r['expiresAt']) && + r['published'] == false && + r['malwareScanned'] == false && + r['requiresHumanApproval'] == true; + } + + Future> uploadStorageFile( + Map raw, + List body, { + int timeoutMs = 15000, + }) async { + if (app != 'storage') { + throw const SentroyMcpError('mcp_storage_upload_not_supported'); + } + final r = _snapshot(raw); + if (!_storageMetadata(r) || + r['requiresOriginalBearer'] != true || + r['method'] != 'PUT' || + r['contentType'] != 'application/octet-stream' || + r['uploadUrl'] is! String || + (r['uploadUrl'] as String).length > 4000) { + throw const SentroyMcpError('invalid_mcp_storage_upload_reference'); + } + final u = _url( + r['uploadUrl'], + _resource, + 'invalid_mcp_storage_upload_reference', + ); + if (u.path != '${_resource.path}/uploads/${r['uploadId']}' || + !_query(u, {'connection'}) || + !_re(_id, u.queryParameters['connection'])) { + throw const SentroyMcpError('invalid_mcp_storage_upload_reference'); + } + final b = _copy(body, _storageCap, 'invalid_mcp_storage_upload'); + if (b.length != r['bytes']) { + throw const SentroyMcpError('mcp_storage_upload_size_mismatch'); + } + if (_hash(b) != r['sha256']) { + throw const SentroyMcpError('mcp_storage_upload_hash_mismatch'); + } + if (!_future(r['expiresAt'])) { + throw const SentroyMcpError('invalid_mcp_storage_upload_reference'); + } + final bytes = await _transfer( + 'PUT', + u, + body: b, + cap: 4096, + timeoutMs: timeoutMs, + prefix: 'mcp_upload', + expiresAt: r['expiresAt'] as String, + requireJson: true, + ); + dynamic v; + try { + v = jsonDecode(utf8.decode(bytes)); + } catch (_) { + throw const SentroyMcpError('mcp_upload_outcome_unknown'); + } + if (v is! Map || + !_storageMetadata(v) || + v['state'] != 'ready' || + [ + 'uploadId', + 'name', + 'mimeType', + 'bytes', + 'sha256', + 'expiresAt', + ].any((k) => v[k] != r[k]) || + [ + 'id', + 'slug', + 'revision', + ].any((k) => v['bucket'][k] != r['bucket'][k])) { + throw const SentroyMcpError('mcp_upload_outcome_unknown'); + } + return { + 'uploadId': r['uploadId'], + 'name': r['name'], + 'mimeType': r['mimeType'], + 'bytes': r['bytes'], + 'sha256': r['sha256'], + 'bucket': r['bucket'], + 'state': 'ready', + 'revision': v['revision'], + 'expiresAt': r['expiresAt'], + 'published': false, + 'malwareScanned': false, + 'requiresHumanApproval': true, + }; + } +} + +/// Connect using MCP 2025-03-26 initialization and POST JSON/SSE responses. +/// Server-initiated GET streams, resumption, and MCP 2026 discovery are not supported. +Future connectSentroyMcp({ + required String app, + required String companySlug, + required Object token, + String? origin, + http.Client? httpClient, + Map? clientInfo, + Duration connectTimeout = const Duration(seconds: 30), +}) async { + final resource = sentroyMcpResource( + app: app, + companySlug: companySlug, + origin: origin, + ); + if (connectTimeout <= Duration.zero || + connectTimeout > const Duration(minutes: 2)) { + throw const SentroyMcpError('invalid_mcp_timeout'); + } + try { + await _token(token).timeout(connectTimeout); + } on TimeoutException { + throw const SentroyMcpError('mcp_connection_failed'); + } + final httpClientValue = httpClient ?? http.Client(); + final transport = _SafeTransport( + Uri.parse(resource), + token, + httpClientValue, + ownsClient: httpClient == null, + ); + final client = mcp.McpClient( + mcp.Implementation( + name: clientInfo?['name'] ?? 'sentroy-client-sdk', + version: clientInfo?['version'] ?? '0.1.0', + ), + options: const mcp.McpClientOptions( + protocolVersion: '2025-03-26', + useServerDiscover: false, + ), + ); + try { + await client.connect(transport).timeout(connectTimeout); + } catch (_) { + await transport.close(); + throw const SentroyMcpError('mcp_connection_failed'); + } + return SentroyMcpConnection._(app, resource, token, httpClientValue, client); +} + +class _SafeTransport extends mcp.Transport + implements mcp.ProtocolVersionAwareTransport { + _SafeTransport( + this.url, + this.source, + this.httpClient, { + required this.ownsClient, + }); + final Uri url; + final Object source; + final http.Client httpClient; + final bool ownsClient; + String? _session; + bool _closed = false; + final Set> _aborts = {}; + @override + String? protocolVersion; + @override + String? get sessionId => _session; + @override + Future start() async {} + @override + Future send(mcp.JsonRpcMessage message, {int? relatedRequestId}) async { + if (_closed) throw const SentroyMcpError('mcp_network_error'); + final abort = Completer(); + _aborts.add(abort); + try { + final bearer = await Future.any([ + _token(source), + abort.future.then( + (_) => throw const SentroyMcpError('mcp_network_error'), + ), + ]); + if (_closed) throw const SentroyMcpError('mcp_network_error'); + final req = http.AbortableRequest('POST', url, abortTrigger: abort.future) + ..followRedirects = false + ..headers.addAll({ + 'Authorization': 'Bearer $bearer', + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Mcp-Session-Id': ?_session, + 'Mcp-Protocol-Version': ?protocolVersion, + }) + ..body = jsonEncode(message.toJson()); + final res = await httpClient.send(req); + if (res.statusCode >= 300 && res.statusCode < 400) { + await res.stream.listen(null).cancel(); + throw SentroyMcpError('mcp_redirect_refused', res.statusCode); + } + if (res.statusCode != 200 && res.statusCode != 202) { + await res.stream.listen(null).cancel(); + throw SentroyMcpError('mcp_network_error', res.statusCode); + } + final session = res.headers['mcp-session-id']; + if (session != null && + (!RegExp(r'^[\x21-\x7e]{1,256}$').hasMatch(session) || + (_session != null && _session != session))) { + await res.stream.listen(null).cancel(); + throw const SentroyMcpError('mcp_network_error'); + } + _session ??= session; + if (res.statusCode == 202) { + await res.stream.listen(null).cancel(); + return; + } + final contentType = res.headers['content-type']?.split(';').first.trim(); + if (contentType == 'application/json') { + final bytes = BytesBuilder(); + await for (final chunk in res.stream) { + if (bytes.length + chunk.length > 1024 * 1024) { + throw const SentroyMcpError('mcp_network_error'); + } + bytes.add(chunk); + } + onmessage?.call( + mcp.JsonRpcMessage.fromJson( + jsonDecode(utf8.decode(bytes.takeBytes())) as Map, + ), + ); + } else if (contentType == 'text/event-stream') { + final data = []; + final line = BytesBuilder(); + var size = 0; + var event = ''; + var afterCr = false; + void dispatch() { + if (data.isNotEmpty && (event.isEmpty || event == 'message')) { + onmessage?.call( + mcp.JsonRpcMessage.fromJson( + jsonDecode(data.join('\n')) as Map, + ), + ); + } + data.clear(); + size = 0; + event = ''; + } + + void finishLine() { + final text = utf8.decode(line.takeBytes()); + if (text.isEmpty) { + dispatch(); + } else if (!text.startsWith(':')) { + final colon = text.indexOf(':'); + final field = colon < 0 ? text : text.substring(0, colon); + var value = colon < 0 ? '' : text.substring(colon + 1); + if (value.startsWith(' ')) value = value.substring(1); + if (field == 'data') data.add(value); + if (field == 'event') event = value; + } + } + + await for (final chunk in res.stream) { + for (final byte in chunk) { + if (afterCr) { + afterCr = false; + if (byte == 10) continue; + } + size++; + if (size > 1024 * 1024) { + throw const SentroyMcpError('mcp_network_error'); + } + if (byte == 10 || byte == 13) { + finishLine(); + afterCr = byte == 13; + } else { + line.addByte(byte); + } + } + } + if (line.length > 0) finishLine(); + dispatch(); + } else { + await res.stream.listen(null).cancel(); + throw const SentroyMcpError('mcp_network_error'); + } + } on SentroyMcpError { + rethrow; + } catch (_) { + throw const SentroyMcpError('mcp_network_error'); + } finally { + _aborts.remove(abort); + if (!abort.isCompleted) abort.complete(); + } + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + for (final abort in _aborts.toList()) { + if (!abort.isCompleted) abort.complete(); + } + if (ownsClient) httpClient.close(); + onclose?.call(); + } +} diff --git a/flutter/lib/src/resources/mail.dart b/flutter/lib/src/resources/mail.dart new file mode 100644 index 0000000..4b2704a --- /dev/null +++ b/flutter/lib/src/resources/mail.dart @@ -0,0 +1,346 @@ +import 'dart:math'; + +import '../errors.dart'; +import '../http.dart'; +import '../json.dart'; +import '../types/mail.dart'; + +String _path(String base, String id) => '$base/${pathSegment(id)}'; +JsonMap _query(JsonMap? value) => value ?? {}; +bool _hasBcc(Object? bcc) => + bcc is String ? bcc.trim().isNotEmpty : bcc is List && bcc.isNotEmpty; +String _requestId(String? value) { + if (value != null && value.isNotEmpty) { + if (!RegExp( + r'^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$', + caseSensitive: false, + ).hasMatch(value)) { + throw ArgumentError('requestId must be a UUID'); + } + return value.toLowerCase(); + } + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + final s = bytes.map((v) => v.toRadixString(16).padLeft(2, '0')).join(); + return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}-${s.substring(16, 20)}-${s.substring(20)}'; +} + +class Domains { + Domains(this._http); + final SentroyHttp _http; + Future> list() async => + jsonList(await _http.get('/domains'), Domain.fromJson); + Future get(String id) async => + Domain.fromJson(jsonObject(await _http.get(_path('/domains', id)))); +} + +class Mailboxes { + Mailboxes(this._http); + final SentroyHttp _http; + Future> list() async => + jsonList(await _http.get('/mailboxes'), MailboxUser.fromJson); +} + +class Templates { + Templates(this._http); + final SentroyHttp _http; + Future> list([TemplateListParams? params]) async => jsonList( + await _http.get('/templates', query: _query(params?.toJson())), + Template.fromJson, + ); + Future