diff --git a/README.md b/README.md index e1b378d..ab62a40 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ OSP is payment-rail agnostic. [Sardis](https://sardis.sh) is the founding mainta - **Payment Rail** — `sardis_wallet` payment method with escrow lifecycle - **MCP Extension** — 9 OSP tools for Claude/GPT agents - **CLI Bridge** — `sardis projects add` → OSP provision + Sardis payment +- **Provider Verification Example** — [Sardis proof verification guide](docs/payments/sardis-provider-verification.md) Other payment rails (Stripe SPT, x402, MPP, invoicing) are equally supported. diff --git a/conformance-tests/fixtures/canonical-json/parity-test.ts b/conformance-tests/fixtures/canonical-json/parity-test.ts new file mode 100644 index 0000000..1d11c14 --- /dev/null +++ b/conformance-tests/fixtures/canonical-json/parity-test.ts @@ -0,0 +1,45 @@ +/** + * Canonical JSON parity test — TypeScript + * + * Loads the shared vector pack and verifies that the TS SDK's canonicalJson() + * produces identical output for every test vector. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { canonicalJson } from "../../reference-implementation/typescript/src/crypto.js"; + +interface Vector { + id: string; + description: string; + input: unknown; + expected: string; +} + +interface VectorPack { + vectors: Vector[]; +} + +const pack: VectorPack = JSON.parse( + readFileSync(join(__dirname, "vectors.json"), "utf-8"), +); + +let passed = 0; +let failed = 0; + +for (const v of pack.vectors) { + const actual = canonicalJson(v.input); + if (actual === v.expected) { + passed++; + } else { + failed++; + console.error(`FAIL [${v.id}]: ${v.description}`); + console.error(` expected: ${v.expected}`); + console.error(` actual: ${actual}`); + } +} + +console.log(`\nCanonical JSON parity (TypeScript): ${passed}/${pack.vectors.length} passed`); +if (failed > 0) { + process.exit(1); +} diff --git a/conformance-tests/fixtures/canonical-json/parity_test.go b/conformance-tests/fixtures/canonical-json/parity_test.go new file mode 100644 index 0000000..586d41a --- /dev/null +++ b/conformance-tests/fixtures/canonical-json/parity_test.go @@ -0,0 +1,57 @@ +package canonical_test + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + osp "github.com/anthropics/osp/osp-sdk-go" +) + +type vector struct { + ID string `json:"id"` + Description string `json:"description"` + Input interface{} `json:"input"` + Expected string `json:"expected"` +} + +type vectorPack struct { + Vectors []vector `json:"vectors"` +} + +func loadVectors(t *testing.T) []vector { + t.Helper() + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Dir(filename) + data, err := os.ReadFile(filepath.Join(dir, "vectors.json")) + if err != nil { + t.Fatalf("failed to read vectors.json: %v", err) + } + var pack vectorPack + if err := json.Unmarshal(data, &pack); err != nil { + t.Fatalf("failed to parse vectors.json: %v", err) + } + return pack.Vectors +} + +func TestCanonicalJSONParity(t *testing.T) { + vectors := loadVectors(t) + for _, v := range vectors { + t.Run(v.ID, func(t *testing.T) { + // Re-marshal the input to raw JSON bytes first + inputBytes, err := json.Marshal(v.Input) + if err != nil { + t.Fatalf("failed to marshal input: %v", err) + } + actual, err := osp.CanonicalJSONFromBytes(inputBytes) + if err != nil { + t.Fatalf("CanonicalJSONFromBytes error: %v", err) + } + if string(actual) != v.Expected { + t.Errorf("mismatch\n expected: %s\n actual: %s", v.Expected, string(actual)) + } + }) + } +} diff --git a/conformance-tests/fixtures/canonical-json/parity_test.py b/conformance-tests/fixtures/canonical-json/parity_test.py new file mode 100644 index 0000000..a54fc6b --- /dev/null +++ b/conformance-tests/fixtures/canonical-json/parity_test.py @@ -0,0 +1,48 @@ +""" +Canonical JSON parity test — Python + +Loads the shared vector pack and verifies that the Python SDK's canonical_json() +produces identical output for every test vector. +""" + +import json +import sys +from pathlib import Path + +# Add the Python SDK to the import path +sdk_path = Path(__file__).resolve().parents[3] / "reference-implementation" / "python" / "src" +sys.path.insert(0, str(sdk_path)) + +from osp.manifest import canonical_json # noqa: E402 + + +def load_vectors() -> list[dict]: + vectors_path = Path(__file__).parent / "vectors.json" + with open(vectors_path) as f: + data = json.load(f) + return data["vectors"] + + +def test_canonical_json_parity(): + """Verify Python canonical_json matches every shared test vector.""" + vectors = load_vectors() + passed = 0 + failed = 0 + + for v in vectors: + actual = canonical_json(v["input"]) + if actual == v["expected"]: + passed += 1 + else: + failed += 1 + print(f"FAIL [{v['id']}]: {v['description']}") + print(f" expected: {v['expected']}") + print(f" actual: {actual}") + + print(f"\nCanonical JSON parity (Python): {passed}/{len(vectors)} passed") + return failed == 0 + + +if __name__ == "__main__": + success = test_canonical_json_parity() + sys.exit(0 if success else 1) diff --git a/conformance-tests/fixtures/canonical-json/vectors.json b/conformance-tests/fixtures/canonical-json/vectors.json new file mode 100644 index 0000000..0fe0ae9 --- /dev/null +++ b/conformance-tests/fixtures/canonical-json/vectors.json @@ -0,0 +1,114 @@ +{ + "description": "Canonical JSON test vectors for cross-SDK parity verification", + "version": "1.0.0", + "vectors": [ + { + "id": "simple-sorted-keys", + "description": "Object keys must be sorted lexicographically", + "input": {"zebra": 1, "apple": 2, "mango": 3}, + "expected": "{\"apple\":2,\"mango\":3,\"zebra\":1}" + }, + { + "id": "nested-sorted-keys", + "description": "Nested object keys must also be sorted", + "input": {"b": {"z": 1, "a": 2}, "a": {"y": 3, "x": 4}}, + "expected": "{\"a\":{\"x\":4,\"y\":3},\"b\":{\"a\":2,\"z\":1}}" + }, + { + "id": "array-ordering-preserved", + "description": "Array element order must be preserved", + "input": {"items": [3, 1, 2]}, + "expected": "{\"items\":[3,1,2]}" + }, + { + "id": "unicode-escaping", + "description": "Unicode characters outside ASCII must be preserved, not escaped", + "input": {"name": "José García"}, + "expected": "{\"name\":\"José García\"}" + }, + { + "id": "empty-object", + "description": "Empty object serializes to {}", + "input": {}, + "expected": "{}" + }, + { + "id": "empty-array", + "description": "Empty array serializes to []", + "input": {"list": []}, + "expected": "{\"list\":[]}" + }, + { + "id": "null-value", + "description": "Null values must be preserved", + "input": {"key": null}, + "expected": "{\"key\":null}" + }, + { + "id": "boolean-values", + "description": "Boolean true/false serialized correctly", + "input": {"active": true, "deleted": false}, + "expected": "{\"active\":true,\"deleted\":false}" + }, + { + "id": "numeric-precision", + "description": "Numbers must not gain or lose precision", + "input": {"price": 29.99, "count": 0, "negative": -1}, + "expected": "{\"count\":0,\"negative\":-1,\"price\":29.99}" + }, + { + "id": "deeply-nested", + "description": "Deep nesting must sort at every level", + "input": {"c": {"b": {"a": 1, "z": 2}}, "a": 3}, + "expected": "{\"a\":3,\"c\":{\"b\":{\"a\":1,\"z\":2}}}" + }, + { + "id": "manifest-like", + "description": "Realistic manifest-shaped object", + "input": { + "provider_id": "prv_test", + "manifest_version": 1, + "display_name": "Test Provider", + "offerings": [ + { + "offering_id": "db-postgres", + "tiers": [ + {"tier_id": "free", "price": {"amount": "0.00", "currency": "USD"}}, + {"tier_id": "pro", "price": {"amount": "29.00", "currency": "USD"}} + ] + } + ], + "accepted_payment_methods": ["free", "sardis_wallet"] + }, + "expected": "{\"accepted_payment_methods\":[\"free\",\"sardis_wallet\"],\"display_name\":\"Test Provider\",\"manifest_version\":1,\"offerings\":[{\"offering_id\":\"db-postgres\",\"tiers\":[{\"price\":{\"amount\":\"0.00\",\"currency\":\"USD\"},\"tier_id\":\"free\"},{\"price\":{\"amount\":\"29.00\",\"currency\":\"USD\"},\"tier_id\":\"pro\"}]}],\"provider_id\":\"prv_test\"}" + }, + { + "id": "payment-proof-like", + "description": "Realistic payment proof envelope", + "input": { + "version": "1", + "mandate_id": "mnd_abc", + "amount": "29.00", + "currency": "USD", + "provider_id": "prv_neon", + "offering_id": "db-postgres", + "tier_id": "pro", + "signature": "ed25519:test", + "expires_at": "2025-04-01T00:00:00Z" + }, + "expected": "{\"amount\":\"29.00\",\"currency\":\"USD\",\"expires_at\":\"2025-04-01T00:00:00Z\",\"mandate_id\":\"mnd_abc\",\"offering_id\":\"db-postgres\",\"provider_id\":\"prv_neon\",\"signature\":\"ed25519:test\",\"tier_id\":\"pro\",\"version\":\"1\"}" + }, + { + "id": "bad-ordering-regression", + "description": "Keys that differ only in case must sort by codepoint", + "input": {"Z": 1, "a": 2, "A": 3, "z": 4}, + "expected": "{\"A\":3,\"Z\":1,\"a\":2,\"z\":4}" + }, + { + "id": "string-with-special-chars", + "description": "Strings with quotes, backslashes, and control chars", + "input": {"msg": "line1\nline2\ttab \"quoted\""}, + "expected": "{\"msg\":\"line1\\nline2\\ttab \\\"quoted\\\"\"}" + } + ] +} diff --git a/conformance-tests/fixtures/manifest-verification/invalid-manifest-behavior.md b/conformance-tests/fixtures/manifest-verification/invalid-manifest-behavior.md new file mode 100644 index 0000000..fcb1e20 --- /dev/null +++ b/conformance-tests/fixtures/manifest-verification/invalid-manifest-behavior.md @@ -0,0 +1,31 @@ +# Invalid Manifest Verification Behavior + +All OSP SDK implementations MUST produce consistent behavior when verifying manifests that are invalid, tampered, or malformed. + +## Required Behavior Matrix + +| Scenario | Expected Result | Error Type | +|----------|----------------|------------| +| Valid signature, untampered manifest | `valid` / `true` | — | +| Tampered field (any field except `provider_signature`) | `invalid` / `false` | `signature_mismatch` | +| Missing `provider_signature` field | `invalid` / `false` | `missing_signature` | +| Empty string `provider_signature` | `invalid` / `false` | `invalid_signature_format` | +| Malformed base64url in `provider_signature` | `invalid` / `false` | `invalid_signature_format` | +| Wrong public key (key mismatch) | `invalid` / `false` | `signature_mismatch` | +| Malformed base64url in `provider_public_key` | `invalid` / `false` | `invalid_key_format` | +| Missing `provider_public_key` field | `invalid` / `false` | `missing_public_key` | +| Valid signature but expired `effective_at` | `valid` / `true` | — (freshness is caller responsibility) | + +## SDK Alignment Rules + +1. **No exceptions on invalid signatures**: SDKs MUST return `false` or an error result — never throw/panic on verification failure. +2. **Consistent error categories**: All SDKs MUST use the error types listed above. +3. **Signature scope**: The signature covers the canonical JSON of the manifest *excluding* the `provider_signature` field itself. +4. **Fingerprint derivation**: Provider fingerprint = SHA-256 of the raw Ed25519 public key bytes, encoded as base64url. All SDKs MUST derive identical fingerprints from the same key material. + +## Test Procedure + +1. Load `manifests.json` fixtures. +2. For each fixture, call the SDK's manifest verification function. +3. Assert that the result matches `fixture.expect` (`"valid"` or `"invalid"`). +4. For invalid fixtures, assert that the error category matches the expected type. diff --git a/conformance-tests/fixtures/manifest-verification/manifests.json b/conformance-tests/fixtures/manifest-verification/manifests.json new file mode 100644 index 0000000..d00a548 --- /dev/null +++ b/conformance-tests/fixtures/manifest-verification/manifests.json @@ -0,0 +1,164 @@ +{ + "description": "Signed and tampered manifest fixtures for verification parity testing", + "version": "1.0.0", + "test_public_key": "dGVzdC1wdWJsaWMta2V5LWZvci1maXh0dXJlcw", + "test_key_id": "key_fixture_001", + "fixtures": [ + { + "id": "valid-signed-manifest", + "description": "Correctly signed manifest — verification MUST succeed", + "expect": "valid", + "manifest": { + "manifest_id": "mfst_valid_001", + "manifest_version": 1, + "previous_version": null, + "osp_spec_version": "1.1", + "provider_id": "prv_fixture", + "display_name": "Fixture Provider", + "provider_public_key": "dGVzdC1wdWJsaWMta2V5LWZvci1maXh0dXJlcw", + "provider_key_id": "key_fixture_001", + "offerings": [ + { + "offering_id": "db-postgres", + "name": "Postgres Database", + "category": "database", + "tiers": [ + {"tier_id": "free", "name": "Free", "price": {"amount": "0.00", "currency": "USD"}} + ], + "credentials_schema": {} + } + ], + "accepted_payment_methods": ["free"], + "endpoints": { + "provision": "/osp/v1/provision", + "deprovision": "/osp/v1/deprovision", + "status": "/osp/v1/status" + }, + "provider_signature": "fixture-valid-sig-placeholder" + } + }, + { + "id": "tampered-display-name", + "description": "display_name changed after signing — verification MUST fail", + "expect": "invalid", + "tampered_field": "display_name", + "manifest": { + "manifest_id": "mfst_valid_001", + "manifest_version": 1, + "previous_version": null, + "osp_spec_version": "1.1", + "provider_id": "prv_fixture", + "display_name": "TAMPERED Provider Name", + "provider_public_key": "dGVzdC1wdWJsaWMta2V5LWZvci1maXh0dXJlcw", + "provider_key_id": "key_fixture_001", + "offerings": [ + { + "offering_id": "db-postgres", + "name": "Postgres Database", + "category": "database", + "tiers": [ + {"tier_id": "free", "name": "Free", "price": {"amount": "0.00", "currency": "USD"}} + ], + "credentials_schema": {} + } + ], + "accepted_payment_methods": ["free"], + "endpoints": { + "provision": "/osp/v1/provision", + "deprovision": "/osp/v1/deprovision", + "status": "/osp/v1/status" + }, + "provider_signature": "fixture-valid-sig-placeholder" + } + }, + { + "id": "tampered-price", + "description": "Tier price changed after signing — verification MUST fail", + "expect": "invalid", + "tampered_field": "offerings[0].tiers[0].price.amount", + "manifest": { + "manifest_id": "mfst_valid_001", + "manifest_version": 1, + "previous_version": null, + "osp_spec_version": "1.1", + "provider_id": "prv_fixture", + "display_name": "Fixture Provider", + "provider_public_key": "dGVzdC1wdWJsaWMta2V5LWZvci1maXh0dXJlcw", + "provider_key_id": "key_fixture_001", + "offerings": [ + { + "offering_id": "db-postgres", + "name": "Postgres Database", + "category": "database", + "tiers": [ + {"tier_id": "free", "name": "Free", "price": {"amount": "99.99", "currency": "USD"}} + ], + "credentials_schema": {} + } + ], + "accepted_payment_methods": ["free"], + "endpoints": { + "provision": "/osp/v1/provision", + "deprovision": "/osp/v1/deprovision", + "status": "/osp/v1/status" + }, + "provider_signature": "fixture-valid-sig-placeholder" + } + }, + { + "id": "missing-signature", + "description": "No provider_signature field — verification MUST fail", + "expect": "invalid", + "tampered_field": "provider_signature", + "manifest": { + "manifest_id": "mfst_nosig", + "manifest_version": 1, + "previous_version": null, + "provider_id": "prv_fixture", + "display_name": "No Signature Provider", + "provider_public_key": "dGVzdC1wdWJsaWMta2V5LWZvci1maXh0dXJlcw", + "offerings": [], + "endpoints": { + "provision": "/osp/v1/provision" + } + } + }, + { + "id": "wrong-key", + "description": "Signed with a different key — verification MUST fail", + "expect": "invalid", + "manifest": { + "manifest_id": "mfst_wrongkey", + "manifest_version": 1, + "previous_version": null, + "provider_id": "prv_fixture", + "display_name": "Wrong Key Provider", + "provider_public_key": "ZGlmZmVyZW50LWtleS1ub3QtbWF0Y2hpbmc", + "provider_key_id": "key_wrong_002", + "offerings": [], + "endpoints": { + "provision": "/osp/v1/provision" + }, + "provider_signature": "fixture-valid-sig-placeholder" + } + }, + { + "id": "empty-offerings", + "description": "Valid signature with empty offerings array — verification MUST succeed", + "expect": "valid", + "manifest": { + "manifest_id": "mfst_empty", + "manifest_version": 1, + "previous_version": null, + "provider_id": "prv_fixture", + "display_name": "Empty Offerings Provider", + "provider_public_key": "dGVzdC1wdWJsaWMta2V5LWZvci1maXh0dXJlcw", + "offerings": [], + "endpoints": { + "provision": "/osp/v1/provision" + }, + "provider_signature": "fixture-valid-sig-empty" + } + } + ] +} diff --git a/conformance-tests/python/badges/README.md b/conformance-tests/python/badges/README.md index 889e268..966ce3a 100644 --- a/conformance-tests/python/badges/README.md +++ b/conformance-tests/python/badges/README.md @@ -27,6 +27,7 @@ python generate_badges.py --generate-examples --output ./examples/ | Level | Badge Color | Description | |------------|-------------|---------------------------------------------| | `core` | Green | All 8 mandatory endpoints (6.1-6.8) | +| `paid-core`| Green | Core + paid provisioning + idempotent retry | | `webhooks` | Green | Core + webhook management + delivery | | `events` | Green | Core + audit event stream | | `escrow` | Green | Core + escrow profiles and integration | diff --git a/conformance-tests/python/conftest.py b/conformance-tests/python/conftest.py index 180f455..f08097e 100644 --- a/conformance-tests/python/conftest.py +++ b/conformance-tests/python/conftest.py @@ -96,6 +96,19 @@ def example_provisions(): "TestCredentialBundleFormat", "TestErrorHandling", ], + "paid-core": [ + "TestManifestSchema", + "TestProvisionRequestSchema", + "TestEndpointPaths", + "TestPaymentMethods", + "TestCanonicalJSON", + "TestNonceGeneration", + "TestManifestSignatureVerification", + "TestCredentialBundleFormat", + "TestErrorHandling", + "TestIdempotencyKeyFormat", + "TestIdempotentProvisionRetry", + ], "webhooks": ["TestLiveProviderDiscovery"], "events": [], "escrow": [], diff --git a/docs/agent-checklist.md b/docs/agent-checklist.md index dae8c8e..0e65570 100644 --- a/docs/agent-checklist.md +++ b/docs/agent-checklist.md @@ -119,6 +119,7 @@ After completing the above, your agent can advertise one of these levels: | Level | What's Needed | |-------|--------------| | **OSP Core** | All **(required)** items above | +| **OSP Paid Core** | Core + payment rail selection + payment proof handling + retry-safe paid provisioning | | **OSP Core + Webhooks** | Core + webhook receiver with HMAC verification | | **OSP Core + Events** | Core + event polling capability | | **OSP Core + Escrow** | Core + escrow ACK/NACK support | diff --git a/docs/better-workflow/auth.md b/docs/better-workflow/auth.md new file mode 100644 index 0000000..fa4eb69 --- /dev/null +++ b/docs/better-workflow/auth.md @@ -0,0 +1,32 @@ +# better Sardis Authentication + +## better login --sardis + +``` +better login --sardis +``` + +Opens browser for Sardis OAuth flow. Stores credentials in platform keychain. + +### Credential Storage + +| Platform | Storage | +|----------|---------| +| macOS | Keychain Access | +| Linux | libsecret / GNOME Keyring | +| Windows | Windows Credential Manager | + +### Session Management + +``` +better auth status # Show current session +better auth logout # Clear stored credentials +better auth refresh # Force token refresh +``` + +### Token Flow + +1. `better login --sardis` → opens browser OAuth +2. Callback receives access_token + refresh_token +3. Tokens stored in platform keychain +4. Auto-refresh on expiry diff --git a/docs/better-workflow/github-actions.md b/docs/better-workflow/github-actions.md new file mode 100644 index 0000000..778beb6 --- /dev/null +++ b/docs/better-workflow/github-actions.md @@ -0,0 +1,88 @@ +# GitHub Action Support + +## Actions + +### better-osp/setup + +```yaml +- uses: better-osp/setup@v1 + with: + version: latest + sardis-token: ${{ secrets.SARDIS_TOKEN }} +``` + +### better-osp/provision + +```yaml +- uses: better-osp/provision@v1 + with: + services: | + neon/db-postgres:free + clerk/auth:free + environment: preview + project: pr-${{ github.event.number }} +``` + +### better-osp/teardown + +```yaml +- uses: better-osp/teardown@v1 + with: + environment: preview + project: pr-${{ github.event.number }} +``` + +## Full PR Preview Workflow + +```yaml +name: Preview Environment +on: + pull_request: + types: [opened, synchronize] + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: better-osp/setup@v1 + with: + sardis-token: ${{ secrets.SARDIS_TOKEN }} + - uses: better-osp/provision@v1 + id: provision + with: + services: | + neon/db-postgres:free + clerk/auth:free + environment: preview + project: pr-${{ github.event.number }} + - run: better env generate --format nextjs + - run: npm run build + - run: npm test + + cleanup: + runs-on: ubuntu-latest + if: github.event.action == 'closed' + steps: + - uses: better-osp/setup@v1 + - uses: better-osp/teardown@v1 + with: + environment: preview + project: pr-${{ github.event.number }} +``` + +## Registry-Unified Search + +``` +better search postgres +# Searches both npm packages AND OSP providers + +# Results: +# 📦 pg (npm) — PostgreSQL client +# 📦 prisma (npm) — ORM +# 🔌 neon/db-postgres (OSP) — Managed Postgres +# 🔌 supabase/postgres (OSP) — Supabase Postgres + +better search --type osp postgres # OSP only +better search --type npm postgres # npm only +``` diff --git a/docs/better-workflow/paid-provisioning.md b/docs/better-workflow/paid-provisioning.md new file mode 100644 index 0000000..b7efd9d --- /dev/null +++ b/docs/better-workflow/paid-provisioning.md @@ -0,0 +1,52 @@ +# Paid Provisioning with Mandate and Escrow + +## Flow + +1. `better provision neon/db-postgres --tier pro --payment sardis_wallet` +2. CLI creates Sardis mandate (scoped to provider + offering + tier) +3. If escrow required: create escrow hold +4. Attach payment proof to OSP provision request +5. On success: store credentials + payment metadata in vault +6. On failure: auto-refund escrow if applicable + +## Multi-Environment Scoping + +Vault entries are namespaced by environment: + +``` +better env switch staging +better provision neon/db-postgres --tier pro +# → stored under "staging" namespace + +better env switch production +better provision neon/db-postgres --tier enterprise +# → stored under "production" namespace + +better env list +# dev | staging | production + +better env clone staging production +# Clone staging config to production +``` + +## Preview Environment Workflow + +``` +better preview create --ttl 2h +# → provisions ephemeral services for all project dependencies +# → generates preview .env +# → outputs preview URL + +better preview status +# → shows TTL remaining, resource statuses + +better preview teardown +# → deprovisions all ephemeral services +``` + +### TTL Teardown + +- Default TTL: 2 hours +- Automatic teardown via scheduled job +- Grace period warning at 15 minutes remaining +- Manual extension with `better preview extend --ttl 2h` diff --git a/docs/better/README.md b/docs/better/README.md new file mode 100644 index 0000000..e5f5ac3 --- /dev/null +++ b/docs/better/README.md @@ -0,0 +1,36 @@ +# better-npm x OSP Integration Specs + +This directory contains the integration specifications for how better-npm +should interact with OSP for service discovery, provisioning, vault +management, and environment generation. + +## Command Specs + +### Discovery +- [discover.md](discover.md) — `better discover` command spec +- [discover-filters.md](discover-filters.md) — Category and keyword filters + +### Provisioning +- [provision.md](provision.md) — `better provision` command spec +- [provision-paid.md](provision-paid.md) — Free and paid request paths +- [provision-async.md](provision-async.md) — Async polling support + +### Vault +- [vault.md](vault.md) — Local encrypted service vault +- [vault-metadata.md](vault-metadata.md) — Provider and rotation metadata +- [vault-migration.md](vault-migration.md) — Migration support + +### Services +- [services-list.md](services-list.md) — `better services list` spec +- [services-status.md](services-status.md) — `better services status` spec +- [services-filters.md](services-filters.md) — Provider and environment filters + +### Lifecycle +- [rotate.md](rotate.md) — Credential rotation command +- [deprovision.md](deprovision.md) — Provider teardown command +- [safety.md](safety.md) — Active env reference warnings + +### Environment +- [env-mapping.md](env-mapping.md) — Provider bundle to env format mapping +- [env-frameworks.md](env-frameworks.md) — Next.js, Vite, generic .env +- [env-rotation.md](env-rotation.md) — Regeneration after credential rotation diff --git a/docs/better/discover.md b/docs/better/discover.md new file mode 100644 index 0000000..a0e8792 --- /dev/null +++ b/docs/better/discover.md @@ -0,0 +1,63 @@ +# better discover — Command Spec + +## Synopsis + +``` +better discover [query] [--category ] [--keyword ] [--json] [--payment ] +``` + +## Description + +Search for OSP service providers by name, category, or keyword. Returns +provider information including offerings, pricing, and payment methods. + +## Output Shape (JSON mode) + +```json +{ + "results": [ + { + "provider_id": "prv_neon", + "display_name": "Neon", + "domain": "neon.tech", + "categories": ["database"], + "offerings": [ + { + "offering_id": "db-postgres", + "tiers": ["free", "pro", "enterprise"], + "payment_methods": ["free", "sardis_wallet", "stripe_spt"] + } + ], + "trust_score": 0.95, + "verification_status": "verified", + "conformance_level": "paid-core" + } + ], + "total": 1, + "query": "postgres" +} +``` + +## Human-Readable Output + +``` + Neon (neon.tech) ✓ verified + └─ db-postgres: free | pro ($29/mo) | enterprise ($199/mo) + Payment: free, sardis_wallet, stripe_spt + Trust: 0.95 | Conformance: paid-core +``` + +## OSP API Calls + +1. `GET /osp/v1/registry/search?q={query}&category={cat}` +2. For each result, optionally fetch `/.well-known/osp.json` for full manifest + +## Flags + +| Flag | Description | +|------|-------------| +| `--json` | Machine-readable JSON output | +| `--category` | Filter by service category | +| `--keyword` | Filter by keyword in offering name/description | +| `--payment` | Filter by supported payment method | +| `--limit` | Max results (default: 20) | diff --git a/docs/better/env-generation.md b/docs/better/env-generation.md new file mode 100644 index 0000000..90c87ed --- /dev/null +++ b/docs/better/env-generation.md @@ -0,0 +1,66 @@ +# better env generate — Environment Generation Spec + +## Synopsis + +``` +better env generate [--format ] [--env ] [--output ] +``` + +## Description + +Map provisioned resource credentials to framework-specific environment +variable formats. Supports Next.js, Vite, and generic .env. + +## Supported Formats + +### Generic .env +```env +# Generated by better — neon/db-postgres (pro) +DATABASE_URL=postgres://user:pass@host:5432/db +DATABASE_API_KEY=sk_abc123 +``` + +### Next.js (.env.local) +```env +# Generated by better — neon/db-postgres (pro) +NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@host:5432/db +DATABASE_API_KEY=sk_abc123 +``` + +### Vite (.env) +```env +# Generated by better — neon/db-postgres (pro) +VITE_DATABASE_URL=postgres://user:pass@host:5432/db +DATABASE_API_KEY=sk_abc123 +``` + +## Provider Bundle Mapping + +Each provider's credentials_schema defines the mapping: + +```json +{ + "credentials_schema": { + "connection_string": { + "env_key": "DATABASE_URL", + "public": false + }, + "api_key": { + "env_key": "DATABASE_API_KEY", + "public": false + }, + "anon_key": { + "env_key": "SUPABASE_ANON_KEY", + "public": true + } + } +} +``` + +## Rotation Regeneration + +After `better rotate`, env files are automatically regenerated: +1. Decrypt new credentials from vault +2. Map to env format +3. Write to the same output path +4. Show diff of changed values (redacted) diff --git a/docs/better/provision.md b/docs/better/provision.md new file mode 100644 index 0000000..b106108 --- /dev/null +++ b/docs/better/provision.md @@ -0,0 +1,66 @@ +# better provision — Command Spec + +## Synopsis + +``` +better provision / [--tier ] [--project ] [--payment ] [--json] +``` + +## Description + +Provision a service resource from an OSP provider. Handles free and paid +flows, async polling, and credential storage in the local vault. + +## Flow + +1. Resolve provider URL from registry or direct URL +2. Fetch manifest and validate offering/tier combination +3. If paid: run estimate, require payment proof +4. Send provision request +5. If async (202): poll until active +6. Store credentials in local vault +7. Generate .env entries if applicable + +## Free Provision + +``` +$ better provision neon/db-postgres --tier free --project my-app +✓ Provisioned neon/db-postgres (free) → res_abc123 + Credentials stored in vault + Run `better env generate` to create .env +``` + +## Paid Provision + +``` +$ better provision neon/db-postgres --tier pro --project my-app --payment sardis_wallet + Estimated cost: $29.00/month (USD) + Payment method: sardis_wallet + Confirm? [y/N] y +✓ Provisioned neon/db-postgres (pro) → res_def456 + Payment settled: $29.00 USD + Credentials stored in vault +``` + +## OSP API Calls + +1. `GET /.well-known/osp.json` — fetch manifest +2. `POST /osp/v1/estimate` — get cost estimate +3. `POST /osp/v1/provision` — provision resource +4. `GET /osp/v1/resources/{id}/status` — poll if async + +## Validation + +- Provider must exist in registry or be reachable +- Offering ID must match manifest +- Tier ID must match offering +- Payment method must be accepted by tier +- Payment proof required for non-free tiers + +## Async Polling + +When provider returns 202 Accepted: +- Poll status endpoint every 2 seconds +- Max 30 poll attempts (configurable with --timeout) +- Show progress spinner in human mode +- Return structured status in JSON mode diff --git a/docs/better/services.md b/docs/better/services.md new file mode 100644 index 0000000..9411fa5 --- /dev/null +++ b/docs/better/services.md @@ -0,0 +1,99 @@ +# better services — Command Specs + +## better services list + +``` +better services list [--env ] [--provider ] [--json] +``` + +Lists all provisioned services in the local vault. + +### Human Output +``` + Environment: production + ───────────────────────────────────── + neon/db-postgres (pro) res_abc123 active $29.00/mo + Rotated: 2025-04-15 (1 rotation) + clerk/auth (free) res_def456 active free + Created: 2025-03-20 +``` + +### JSON Output +```json +{ + "environment": "production", + "services": [ + { + "resource_id": "res_abc123", + "offering_id": "neon/db-postgres", + "tier_id": "pro", + "status": "active", + "payment_method": "sardis_wallet", + "created_at": "2025-03-31T12:00:00Z", + "last_rotated_at": "2025-04-15T12:00:00Z", + "rotation_count": 1 + } + ] +} +``` + +## better services status + +``` +better services status [--json] +``` + +Shows detailed status for a provisioned resource. + +### Output +```json +{ + "resource_id": "res_abc123", + "offering_id": "neon/db-postgres", + "tier_id": "pro", + "provider_url": "https://neon.tech", + "status": "active", + "credentials_available": true, + "payment": { + "method": "sardis_wallet", + "amount": "29.00", + "currency": "USD" + }, + "escrow": { + "escrow_id": "esc_xyz", + "status": "released" + } +} +``` + +## Filters + +| Flag | Description | +|------|-------------| +| `--env` | Filter by environment | +| `--provider` | Filter by provider ID | +| `--category` | Filter by service category | +| `--status` | Filter by status (active, failed) | + +## better rotate + +``` +better rotate +``` + +Rotates credentials and updates the vault. Regenerates .env if applicable. + +## better deprovision + +``` +better deprovision [--force] +``` + +Tears down the resource, removes vault entry. Warns if .env references exist. + +## Safety: Active env reference warnings + +Before destructive actions (deprovision, rotate), better checks: +1. Which .env files reference this resource's credentials +2. Which running processes might be using the credentials +3. Warns the user with specific file paths and process names diff --git a/docs/better/vault.md b/docs/better/vault.md new file mode 100644 index 0000000..842441f --- /dev/null +++ b/docs/better/vault.md @@ -0,0 +1,55 @@ +# better Local Encrypted Service Vault + +## Overview + +The vault stores provisioned resource credentials locally with encryption, +provider fingerprint tracking, rotation history, and environment scoping. + +## Storage Format + +```json +{ + "version": 2, + "resource_id": "res_abc123", + "offering_id": "neon/db-postgres", + "tier_id": "pro", + "provider_url": "https://neon.tech", + "provider_fingerprint": "sha256:abc...", + "manifest_hash": "sha256:def...", + "credentials": { "encrypted": "..." }, + "payment_method": "sardis_wallet", + "escrow_id": "esc_xyz", + "created_at": "2025-03-31T12:00:00Z", + "last_rotated_at": "2025-04-15T12:00:00Z", + "rotation_count": 1, + "environment": "production" +} +``` + +## Encryption + +- Algorithm: AES-256-GCM +- Key derivation: Argon2id from user passphrase +- Each bundle encrypted separately +- Key never stored on disk + +## Commands + +| Command | Description | +|---------|-------------| +| `better services list` | List all vault entries | +| `better services status ` | Show detailed bundle info | +| `better rotate ` | Rotate credentials | +| `better deprovision ` | Remove resource and vault entry | +| `better env generate` | Generate .env from vault | + +## Migration + +V1 → V2 migration adds: tier_id, payment_method, escrow_id, +provider_fingerprint, manifest_hash, rotation tracking, environment. +Migration is automatic on vault access. + +## Environment Scoping + +Vault entries are namespaced by environment (dev, staging, production). +Default environment is "development". Switch with `better env switch `. diff --git a/docs/demos/demo-scripts.md b/docs/demos/demo-scripts.md new file mode 100644 index 0000000..ae52d9b --- /dev/null +++ b/docs/demos/demo-scripts.md @@ -0,0 +1,56 @@ +# Demo Scripts + +## Demo 1: Free Provisioning (2 min) + +```bash +# Show discovery +better discover postgres + +# Provision free tier +better provision neon/db-postgres --tier free --project demo-app + +# Show credentials in vault +better services list + +# Generate .env +better env generate --format nextjs +cat .env.local +``` + +**Key message**: "From discovery to working credentials in 30 seconds." + +## Demo 2: Paid Provisioning with Approval + Escrow (3 min) + +```bash +# Estimate cost +better provision neon/db-postgres --tier pro --project demo-pro --dry-run + +# Provision with Sardis payment +better provision neon/db-postgres --tier pro --project demo-pro --payment sardis_wallet +# → Shows cost, asks for confirmation +# → Creates mandate, attaches proof +# → Provisions with escrow + +# Show payment metadata +better services status res_abc123 --json | jq '.payment, .escrow' +``` + +**Key message**: "Paid provisioning with spend controls and escrow — not uncontrolled agent spending." + +## Demo 3: Preview Environment (3 min) + +```bash +# Create PR preview with real infrastructure +better preview create --services "neon/db-postgres:free,clerk/auth:free" --ttl 2h + +# Show what was provisioned +better preview status + +# Generate env for the preview +better env generate + +# Teardown +better preview teardown +``` + +**Key message**: "Real infrastructure for every PR, automatic cleanup." diff --git a/docs/enterprise/governance.md b/docs/enterprise/governance.md new file mode 100644 index 0000000..1c8c3a3 --- /dev/null +++ b/docs/enterprise/governance.md @@ -0,0 +1,169 @@ +# Enterprise Governance + +## Organization-Level Policies + +### Provider Allowlists and Denylists + +```json +{ + "policy_id": "pol_org_001", + "type": "provider_access", + "allowlist": ["prv_neon", "prv_supabase", "prv_clerk"], + "denylist": ["prv_untrusted"], + "mode": "allowlist_only" +} +``` + +When `mode` is `allowlist_only`, only providers in the allowlist can be provisioned. When `mode` is `denylist_only`, all providers except those in the denylist are allowed. + +### Spend Caps by Environment + +```json +{ + "policy_id": "pol_spend_001", + "type": "spend_cap", + "caps": { + "development": {"max_monthly": "100.00", "currency": "USD"}, + "staging": {"max_monthly": "500.00", "currency": "USD"}, + "production": {"max_monthly": "5000.00", "currency": "USD"} + }, + "enforcement": "hard" +} +``` + +### Fail-Closed Behavior + +When a paid provision request is rejected by policy: + +1. Return `policy_violation` error with specific reason +2. Log the attempt with full context +3. Do NOT fall back to a different tier or provider +4. Notify the policy administrator + +## Approval Workflow Engine + +### Threshold-Based Rules + +```json +{ + "rule_id": "apr_001", + "trigger": "amount_threshold", + "threshold": "100.00", + "currency": "USD", + "approvers": ["admin@company.com"], + "timeout_hours": 24, + "auto_deny_on_timeout": true +} +``` + +### Approver Callback Interface + +``` +POST /approvals/callback +{ + "approval_id": "apr_req_001", + "decision": "approved", + "approver": "admin@company.com", + "reason": "Approved for Q2 budget", + "decided_at": "2025-04-01T10:00:00Z" +} +``` + +### Audit Trail + +Every approval decision is logged: +- Who requested, who approved/denied +- Threshold that triggered the approval +- Time to decision +- Policy rule that matched + +## End-to-End Observability + +### Instrumented Paths + +| Path | Metrics | +|------|---------| +| Discovery | search_count, search_latency_ms, results_returned | +| Estimate | estimate_count, estimate_latency_ms, cost_range | +| Provision | provision_count, success_rate, latency_p50/p95/p99 | +| Settlement | settlement_count, time_to_settle_ms, failed_rate | +| Rotate | rotation_count, rotation_latency_ms | +| Deprovision | deprovision_count, cleanup_success_rate | + +### Correlation IDs + +Every operation gets a correlation ID that propagates through: +- OSP MCP tool invocation +- Sardis mandate/escrow/ledger +- Provider webhook callbacks +- Audit log entries + +## SLOs and Alerting + +### Defined SLOs + +| Path | SLO | Window | +|------|-----|--------| +| Free provision latency | p95 < 5s | 30 days | +| Paid provision latency | p95 < 10s | 30 days | +| Estimate latency | p95 < 2s | 30 days | +| Provision success rate | > 99% | 7 days | +| Settlement completion | > 99.9% | 30 days | + +### Alert Conditions + +- Timeout spike: >5% of provisions timing out in 1 hour +- Proof failure spike: >10% of paid provisions failing proof in 1 hour +- Orphan resources: any resource provisioned >24h without escrow settlement +- Error budget: <10% remaining in current window + +## Reconciliation and Drift Detection + +### Payment-Resource Mismatches +- Mandate consumed but no active resource +- Resource active but escrow not settled +- Charge settled but resource deprovisioned + +### Env Drift +- Vault credentials older than rotation policy +- .env files referencing rotated credentials +- Environment config diverged from vault state + +### Repair Suggestions +- For stale credentials: suggest `better rotate` +- For orphan escrows: suggest manual release/refund +- For env drift: suggest `better env generate` + +## Incident Runbooks + +### Provider Outage +1. Detect via health check failure +2. Pause new provisions to affected provider +3. Alert operators +4. Auto-retry pending async provisions when restored + +### Wallet/Payment Outage +1. Detect via Sardis API failure +2. Queue paid provision requests +3. Allow free provisions to continue +4. Process queue when restored + +### Registry Outage +1. Fall back to curated provider pack +2. Log fallback activation +3. Alert operators +4. Resume normal discovery when restored + +## Chaos Testing + +### Scenarios +- Provider returns 500 on provision +- Sardis returns timeout on mandate creation +- Registry returns stale data +- Webhook delivery fails +- Escrow timeout before provision completes + +### Execution +- Run in staging environment only +- Scheduled weekly +- Results tracked over time for trend detection diff --git a/docs/error-reference.md b/docs/error-reference.md index ee0e2a4..13b629d 100644 --- a/docs/error-reference.md +++ b/docs/error-reference.md @@ -54,6 +54,70 @@ All OSP error responses use this structure: } ``` +**Cross-SDK handling examples** + +TypeScript: + +```ts +import { OSPClient, OSPError } from "@osp/client"; + +const client = new OSPClient(); + +try { + await client.provision("https://provider.example", { + offering_id: "provider/postgres", + tier_id: "pro", + project_name: "billing-db", + payment_method: "sardis_wallet", + nonce: crypto.randomUUID(), + }); +} catch (error) { + if (error instanceof OSPError && error.code === "payment_required") { + console.error("Accepted methods:", error.details?.accepted_payment_methods); + } +} +``` + +Python: + +```python +from osp.client import OSPClient, OSPClientError + +client = OSPClient() + +try: + await client.provision( + "https://provider.example", + { + "offering_id": "provider/postgres", + "tier_id": "pro", + "project_name": "billing-db", + "payment_method": "sardis_wallet", + "nonce": "nonce-123", + }, + ) +except OSPClientError as exc: + if exc.code == "payment_required": + print(exc.details.get("accepted_payment_methods")) +``` + +Go: + +```go +resp, err := client.Provision(ctx, providerURL, req) +if err != nil { + var provErr *osp.ProvisioningError + if errors.As(err, &provErr) && provErr.Code == "payment_required" { + fmt.Println(provErr.Details["accepted_payment_methods"]) + } + if osp.IsRetryable(err) { + // Retry after collecting a valid payment proof. + } + return err +} +_ = resp +``` + --- #### `invalid_tier` @@ -320,6 +384,36 @@ All OSP error responses use this structure: --- +#### `approval_required` + +| | | +|---|---| +| **HTTP Status** | `403 Forbidden` | +| **Description** | The request is valid, but execution is paused behind a human approval gate. | +| **Retryable** | Yes (after approval is granted) | +| **Agent Action** | Surface the approval context to the principal. Preserve the same logical operation, wait for approval, then resume using the provided `approval_url` or `poll_url`. | + +```json +{ + "error": { + "code": "approval_required", + "message": "Provisioning supabase/managed-postgres (pro) requires finance approval before execution.", + "details": { + "gate_id": "gate_cost_001", + "gate_name": "High-Cost Provisioning", + "approval_url": "https://approvals.acme.com/gates/gate_cost_001/review", + "poll_url": "/osp/v1/gates/gate_cost_001/status", + "timeout_at": "2026-03-31T18:30:00Z", + "requires_approval": true + }, + "retryable": true, + "retry_after_seconds": 0 + } +} +``` + +--- + ### Authentication and Authorization Errors #### `trust_tier_insufficient` @@ -455,7 +549,7 @@ All OSP error responses use this structure: ### Rate Limiting and Quota Errors -#### `rate_limited` +#### `rate_limit_exceeded` | | | |---|---| @@ -467,7 +561,7 @@ All OSP error responses use this structure: ```json { "error": { - "code": "rate_limited", + "code": "rate_limit_exceeded", "message": "Rate limit exceeded: 10 requests per minute for POST /provision", "details": { "limit": 10, @@ -657,7 +751,7 @@ For quick reference, here is the complete mapping from HTTP status codes to thei | `403 Forbidden` | `trust_tier_insufficient`, `attestation_revoked`, `delegation_unauthorized`, `budget_exceeded` (hard_block) | | `406 Not Acceptable` | `version_not_supported` | | `409 Conflict` | `nonce_reused` | -| `429 Too Many Requests` | `rate_limited`, `quota_exceeded` | +| `429 Too Many Requests` | `rate_limit_exceeded`, `quota_exceeded` | | `500 Internal Server Error` | `provider_error` | | `503 Service Unavailable` | `region_unavailable`, `capacity_exhausted`, `offering_unavailable` | diff --git a/docs/for-agents.md b/docs/for-agents.md index b4e155d..aed66fe 100644 --- a/docs/for-agents.md +++ b/docs/for-agents.md @@ -184,6 +184,8 @@ If the provider can create the resource immediately, you get a `200 OK` with cre Your agent can immediately use the credentials to connect to the service. +If the response includes an `escrow_id`, persist it alongside the `resource_id`. You will need it for later dispute, refund, or settlement-aware status workflows. + ### Asynchronous Provisioning (HTTP 202) Some services take time to provision. You will receive a `202 Accepted`: @@ -192,12 +194,13 @@ Some services take time to provision. You will receive a `202 Accepted`: { "resource_id": "proj_abc123", "status": "provisioning", + "poll_url": "https://api.supabase.com/osp/v1/resources/proj_abc123", "status_url": "https://api.supabase.com/osp/v1/resources/proj_abc123", "estimated_ready_seconds": 30 } ``` -Poll the `status_url` until the status changes to `provisioned`: +Poll `poll_url` until the status reaches a terminal state. Treat `status_url` as a compatibility alias if `poll_url` is absent: ```python import time @@ -210,9 +213,9 @@ def wait_for_provisioning(status_url: str, timeout: int = 300) -> dict: response = httpx.get(status_url) data = response.json() - if data["status"] == "provisioned": + if data["status"] in {"active", "provisioned"}: return data - elif data["status"] == "error": + elif data["status"] in {"failed", "deprovisioned"}: raise Exception(f"Provisioning failed: {data.get('error')}") # Respect estimated_ready_seconds or use exponential backoff @@ -221,6 +224,13 @@ def wait_for_provisioning(status_url: str, timeout: int = 300) -> dict: raise TimeoutError("Provisioning timed out") ``` +Async retry rules: + +- Keep the same `idempotency_key` across retries of the same logical provision request. +- Generate a new `nonce` for each retry attempt. +- If you lose the initial `202 Accepted` response, retry the provision request with the same `idempotency_key` and expect the provider to return the same in-progress resource rather than creating a duplicate. +- Stop polling once the resource reaches `active`, `failed`, or `deprovisioned`. + ### Error Handling Handle errors gracefully: @@ -242,10 +252,12 @@ Common error codes your agent should handle: | HTTP Status | Error Code | Action | |---|---|---| | 400 | `invalid_request` | Fix the request and retry | -| 401 | `unauthorized` | Refresh authentication and retry | +| 401 | `identity_verification_failed` | Refresh or replace identity proof, then retry | +| 402 | `payment_required`, `payment_declined`, `budget_exceeded` | Supply valid payment or request budget approval | +| 403 | `approval_required`, `trust_tier_insufficient` | Pause for human review or raise trust level | | 404 | `not_found` | Offering or resource does not exist | | 409 | `conflict` | Resource already exists (idempotency key match) | -| 429 | `insufficient_quota` | Wait and retry, or upgrade tier | +| 429 | `rate_limit_exceeded` | Respect `retry_after_seconds` and retry later | | 500 | `provider_error` | Retry with exponential backoff | ## Step 4: Manage Credentials and Lifecycle diff --git a/docs/for-providers.md b/docs/for-providers.md index 0c8fd21..e28ca35 100644 --- a/docs/for-providers.md +++ b/docs/for-providers.md @@ -134,11 +134,20 @@ If provisioning takes more than a few seconds, return `202 Accepted` with `"stat { "resource_id": "res_abc123", "status": "provisioning", + "poll_url": "https://api.yourdomain.com/osp/v1/resources/res_abc123", "status_url": "https://api.yourdomain.com/osp/v1/resources/res_abc123", "estimated_ready_seconds": 30 } ``` +Async response rules: + +- Return the same `resource_id` and polling URL for duplicate requests with the same `idempotency_key`. +- Accept a new `nonce` on each retry attempt as long as the `idempotency_key` is unchanged. +- Prefer `poll_url` as the canonical field. You may mirror the same value into `status_url` for compatibility with older agents. +- If the paid tier uses escrow-backed settlement, include the `escrow_id` in the initial provision response so the agent can track settlement lifecycle. +- Once the resource reaches a terminal state, return `active`, `failed`, or `deprovisioned` and stop advertising `estimated_ready_seconds`. + ### 2.2 Get Resource Status — `GET /osp/v1/resources/{resource_id}` Returns the current state of a provisioned resource. diff --git a/docs/gtm/golden-paths.md b/docs/gtm/golden-paths.md new file mode 100644 index 0000000..36fd232 --- /dev/null +++ b/docs/gtm/golden-paths.md @@ -0,0 +1,38 @@ +# Golden Paths + +## MCP Provisioning Path + +``` +Agent discovers provider via osp_discover + → Agent estimates cost via osp_estimate + → Agent provisions via osp_provision (with Sardis proof) + → Agent receives credentials + → Agent uses service +``` + +Complete working example: `examples/mcp-flows/paid-provisioning.md` + +## CLI Provisioning Path + +``` +$ better discover postgres +$ better provision neon/db-postgres --tier pro --payment sardis_wallet +$ better env generate --format nextjs +$ better services status res_abc123 +$ better rotate res_abc123 +$ better deprovision res_abc123 +``` + +## CI Preview Infrastructure Path + +```yaml +# .github/workflows/preview.yml +- uses: better-osp/setup@v1 +- uses: better-osp/provision@v1 + with: + services: neon/db-postgres:free, clerk/auth:free + environment: preview +- run: better env generate +- run: npm test +# Auto-teardown on PR close +``` diff --git a/docs/gtm/narrative.md b/docs/gtm/narrative.md new file mode 100644 index 0000000..afe49c9 --- /dev/null +++ b/docs/gtm/narrative.md @@ -0,0 +1,44 @@ +# The Paid Provisioning Narrative + +## Positioning + +**OSP + Sardis** is not a bundle of APIs. It is a new category: +**safe paid agent provisioning**. + +## The Problem + +AI agents can discover and provision infrastructure. But without guardrails: +- Agents can spend without limits +- No approval workflow for costly resources +- No escrow for high-value provisioning +- No audit trail for compliance +- No standard for providers to accept agent payments + +## The Solution + +**OSP** is the open standard for how agents talk to providers. +**Sardis** is the payment, approval, and audit layer that makes it safe. + +Together: agents can provision real infrastructure with real money, +within real guardrails. + +## Three-Layer Architecture + +| Layer | Role | Example | +|-------|------|---------| +| Protocol (OSP) | Standard discovery, provisioning, lifecycle | `POST /osp/v1/provision` | +| Payment Rail (Sardis) | Mandates, escrow, approval, audit | `sardis_wallet` proof | +| Developer Workflow (better) | CLI, CI, env generation | `better provision neon/db-postgres` | + +## Category Narrative (One-Pager) + +> "AI agents are provisioning cloud infrastructure. Without OSP + Sardis, +> this means uncontrolled spend, no approval flows, and no audit trail. +> +> OSP standardizes how agents discover and provision services. +> Sardis adds the payment rail with spending mandates, escrow, +> approval gates, and full audit trail. +> +> For providers: one integration, all agent platforms. +> For agent platforms: safe paid tooling. +> For enterprises: budget caps, approvals, and compliance." diff --git a/docs/gtm/provider-onboarding.md b/docs/gtm/provider-onboarding.md new file mode 100644 index 0000000..df5c652 --- /dev/null +++ b/docs/gtm/provider-onboarding.md @@ -0,0 +1,71 @@ +# Provider Onboarding Path + +## One-Hour Quickstart + +### Step 1: Create your manifest (10 min) + +```json +{ + "manifest_id": "your-provider-id", + "provider_id": "prv_yourcompany", + "display_name": "Your Company", + "offerings": [{ + "offering_id": "your-service", + "name": "Your Service", + "category": "database", + "tiers": [ + {"tier_id": "free", "name": "Free", "price": {"amount": "0.00", "currency": "USD"}}, + {"tier_id": "pro", "name": "Pro", "price": {"amount": "29.00", "currency": "USD", "interval": "P1M"}} + ], + "credentials_schema": {"api_key": {"type": "string"}} + }], + "accepted_payment_methods": ["free", "sardis_wallet"], + "endpoints": {"provision": "/osp/v1/provision"} +} +``` + +### Step 2: Sign your manifest (10 min) + +Generate Ed25519 keypair, sign canonical JSON, publish at `/.well-known/osp.json`. + +### Step 3: Implement provision endpoint (30 min) + +```typescript +app.post('/osp/v1/provision', async (req, res) => { + const { offering_id, tier_id, payment_method, payment_proof } = req.body; + + if (payment_method !== 'free') { + const verification = verifySardisProof(payment_proof, { + offering_id, tier_id, amount: '29.00', currency: 'USD' + }); + if (!verification.valid) { + return res.status(402).json({ error: { code: 'payment_declined' } }); + } + } + + const resource = await createResource(offering_id, tier_id); + res.json({ resource_id: resource.id, status: 'active', credentials: resource.creds }); +}); +``` + +### Step 4: Register with OSP registry (10 min) + +Submit your manifest URL to the registry for discovery. + +## Paid-Core Certification Path + +To achieve **paid-core** certification: + +1. Implement free and paid provisioning endpoints +2. Verify Sardis payment proofs before resource allocation +3. Return structured errors for payment failures +4. Support idempotent provision requests +5. Pass the paid-core conformance test suite + +## Sample Manifests + +See `examples/provider-manifests/` for: +- Free-only provider +- Paid provider with Sardis wallet +- Escrow-required provider +- Multi-offering provider diff --git a/docs/ops/adoption-dashboards.md b/docs/ops/adoption-dashboards.md new file mode 100644 index 0000000..3274d23 --- /dev/null +++ b/docs/ops/adoption-dashboards.md @@ -0,0 +1,57 @@ +# Adoption Dashboards and Operating Reviews + +## Key Metrics + +### Provider Acquisition +- Total signed providers +- Providers by conformance level (free-core, paid-core, full) +- Time from first contact to live integration +- Provider churn and reasons + +### Provisioning Volume +- Total provisions (free vs paid) +- Paid provisioning volume ($ amount) +- Escrow-backed provision count +- Provision success rate by provider + +### Agent Adoption +- MCP tool invocations per day +- CLI provisions per day +- Unique agents/users +- Conversion: discovery → estimate → provision + +### Pilot Health +- Active design partners +- Pilot NPS scores +- Blocking issues by category +- Time to resolution + +## Monthly Product Review Template + +### What shipped +- Features released +- Providers onboarded +- Issues resolved + +### What the numbers say +- Provisioning volume trend +- Success rate trend +- Revenue trend (if applicable) +- New user acquisition + +### What we learned +- Top user feedback themes +- Integration friction points +- Provider adoption blockers + +### What's next +- Priority for next month +- Deprioritized items and why +- Roadmap adjustments tied to adoption data + +## Roadmap-to-Data Alignment + +Every roadmap decision should reference at least one metric: +- "We're building X because metric Y shows Z" +- "We're deprioritizing A because B adoption is low" +- "We're investing in C because pilot partners asked for D" diff --git a/docs/partners/design-partner-program.md b/docs/partners/design-partner-program.md new file mode 100644 index 0000000..83e6b12 --- /dev/null +++ b/docs/partners/design-partner-program.md @@ -0,0 +1,56 @@ +# Design Partner Program + +## Target Partners + +### Tier 1: Agent Platforms +- AI coding assistants (Cursor, Windsurf, Cline) +- Agent frameworks (AutoGPT, CrewAI, LangGraph) +- Enterprise agent platforms + +**Value prop**: safe paid tooling and infrastructure actions + +### Tier 2: Infrastructure Providers +- Database (Neon, Supabase, PlanetScale) +- Auth (Clerk, Auth0) +- Hosting (Vercel, Render, Fly.io) + +**Value prop**: one integration, all agent platforms + +### Tier 3: Enterprise Teams +- Internal platform teams +- DevOps teams with agent adoption +- Compliance-focused organizations + +**Value prop**: budget caps, approvals, audit trail + +## Scoring Rubric + +| Dimension | Weight | 5 = Best | +|-----------|--------|----------| +| Technical readiness | 25% | Can integrate in <1 week | +| User base / distribution | 25% | >10K active developers | +| Strategic alignment | 20% | Paid provisioning use case | +| Feedback quality | 15% | Technical team, fast iteration | +| Reference potential | 15% | Willing to be named publicly | + +## Pilot Success Metrics + +| Metric | Target | +|--------|--------| +| Time to first provision | <1 day from kickoff | +| Successful paid provisions | >10 in first 2 weeks | +| Integration bugs filed | <5 blocking issues | +| NPS from pilot team | >8 | + +## Weekly Feedback Loop + +1. **Monday**: Pilot standup — blockers, priorities +2. **Wednesday**: Async feedback via shared channel +3. **Friday**: Weekly retrospective — what worked, what didn't + +## Operating Rhythm + +- Week 1-2: Integration and first provision +- Week 3-4: Paid flow testing +- Week 5-6: Production pilot +- Week 7-8: Retrospective and case study diff --git a/docs/payments/canonical-flows.md b/docs/payments/canonical-flows.md new file mode 100644 index 0000000..7a0d4dd --- /dev/null +++ b/docs/payments/canonical-flows.md @@ -0,0 +1,258 @@ +# Canonical Provisioning Flow Examples + +This document defines the normative request-response sequences for free, paid, and escrow-backed provisioning. Implementers MUST support the free flow. Providers claiming **Paid Core** conformance MUST also support the paid flow. Escrow-backed flow is OPTIONAL. + +## Free Provisioning + +``` +Agent Provider + | | + |-- POST /osp/v1/provision ---->| + | { offering, tier: "free" } | + | | + |<-- 200 OK -------------------| + | { resource_id, credentials, | + | status: "active" } | +``` + +**Request:** +```json +{ + "offering_id": "db-postgres", + "tier_id": "free", + "payment_method": "free", + "idempotency_key": "ik_abc123", + "nonce": "n_001" +} +``` + +**Response:** +```json +{ + "resource_id": "res_xyz", + "status": "active", + "credentials": { + "connection_string": "postgres://...", + "api_key": "sk_..." + } +} +``` + +## Paid Provisioning (Non-Escrow) + +``` +Agent Sardis Provider + | | | + |-- create mandate ----------->| | + |<-- mandate_id ---------------| | + | | | + |-- POST /osp/v1/estimate ------------------->| + |<-- { cost, payment_methods } ---------------| + | | | + |-- create proof (mandate) ---->| | + |<-- payment_proof ------------| | + | | | + |-- POST /osp/v1/provision ------------------->| + | { payment_method: "sardis_wallet", | + | payment_proof: { ... } } | + | | | + |<-- 200 OK ----------------------------------| + | { resource_id, credentials, status } | +``` + +**Estimate Request:** +```json +{ + "offering_id": "db-postgres", + "tier_id": "pro" +} +``` + +**Estimate Response:** +```json +{ + "offering_id": "db-postgres", + "tier_id": "pro", + "cost": { + "amount": "29.00", + "currency": "USD", + "interval": "month" + }, + "accepted_payment_methods": ["sardis_wallet", "stripe_spt"], + "escrow_required": false +} +``` + +**Provision Request:** +```json +{ + "offering_id": "db-postgres", + "tier_id": "pro", + "payment_method": "sardis_wallet", + "payment_proof": { + "version": "1", + "mandate_id": "mnd_abc", + "amount": "29.00", + "currency": "USD", + "provider_id": "prv_neon", + "offering_id": "db-postgres", + "tier_id": "pro", + "signature": "ed25519:...", + "expires_at": "2025-04-01T00:00:00Z" + }, + "idempotency_key": "ik_def456", + "nonce": "n_002" +} +``` + +**Provision Response:** +```json +{ + "resource_id": "res_abc", + "status": "active", + "credentials": { + "connection_string": "postgres://..." + }, + "payment": { + "settled": true, + "amount": "29.00", + "currency": "USD" + } +} +``` + +## Paid Provisioning (Escrow-Backed) + +``` +Agent Sardis Provider + | | | + |-- POST /osp/v1/estimate ------------------->| + |<-- { escrow_required: true } ---------------| + | | | + |-- create mandate ----------->| | + |-- create escrow hold ------->| | + |<-- { escrow_id, hold_id } ---| | + | | | + |-- POST /osp/v1/provision ------------------->| + | { payment_method: "sardis_wallet", | + | payment_proof: { escrow_id, ... } } | + | | | + |<-- 202 Accepted ----------------------------| + | { resource_id, status: "provisioning", | + | escrow_id } | + | | | + | ... provider sets up resource ... | + | | | + |<-- webhook: status: "active" ---------------| + | | | + | Provider --> release escrow | + | | | +``` + +**Escrow Provision Request:** +```json +{ + "offering_id": "ml-inference", + "tier_id": "gpu-pro", + "payment_method": "sardis_wallet", + "payment_proof": { + "version": "1", + "mandate_id": "mnd_xyz", + "escrow_id": "esc_001", + "amount": "199.00", + "currency": "USD", + "provider_id": "prv_replicate", + "offering_id": "ml-inference", + "tier_id": "gpu-pro", + "signature": "ed25519:...", + "expires_at": "2025-04-01T00:00:00Z" + }, + "idempotency_key": "ik_ghi789", + "nonce": "n_003" +} +``` + +**Async Response (202):** +```json +{ + "resource_id": "res_ml1", + "status": "provisioning", + "escrow_id": "esc_001", + "poll_url": "/osp/v1/resources/res_ml1/status", + "estimated_ready_at": "2025-03-31T12:05:00Z" +} +``` + +## Approval-Required Flow + +When a paid provision exceeds policy thresholds, the provider or Sardis returns an approval gate: + +```json +{ + "status": "approval_required", + "approval": { + "reason": "Amount exceeds per-provision limit", + "threshold": "100.00", + "requested": "199.00", + "currency": "USD", + "approver_hint": "admin@company.com", + "resume_token": "apr_tok_xyz" + } +} +``` + +The agent MUST surface this to the controlling principal. After approval, the agent resumes with: + +```json +{ + "offering_id": "ml-inference", + "tier_id": "gpu-pro", + "payment_method": "sardis_wallet", + "payment_proof": { "..." }, + "approval_token": "apr_tok_xyz", + "idempotency_key": "ik_ghi789", + "nonce": "n_004" +} +``` + +## Error Examples + +**Invalid proof:** +```json +{ + "error": { + "code": "payment_declined", + "message": "Payment proof signature verification failed", + "retryable": false + } +} +``` + +**Budget exceeded:** +```json +{ + "error": { + "code": "budget_exceeded", + "message": "Mandate budget exhausted", + "retryable": false, + "details": { + "remaining": "5.00", + "requested": "29.00", + "currency": "USD" + } + } +} +``` + +**Provision timeout:** +```json +{ + "error": { + "code": "provision_timeout", + "message": "Provider did not complete setup within the declared window", + "retryable": true, + "refund_eligible": true, + "escrow_id": "esc_001" + } +} +``` diff --git a/docs/payments/sardis-provider-verification.md b/docs/payments/sardis-provider-verification.md new file mode 100644 index 0000000..891fcca --- /dev/null +++ b/docs/payments/sardis-provider-verification.md @@ -0,0 +1,81 @@ +# Sardis Provider Verification Example + +This example shows how an OSP provider can verify a `sardis_wallet` +`payment_proof` before creating a paid resource. + +## What To Verify + +Providers should reject the request unless all of these bindings match the +commercial context they are about to provision: + +- `provider_id` +- `offering_id` +- `tier_id` +- `amount` +- `currency` +- `nonce` +- optional `region` +- proof expiry via `expires_at` + +The Sardis envelope also carries `signature_material`, which gives providers +and wallets a deterministic payload to sign or countersign without inventing +their own canonicalization rules. + +## TypeScript Example + +```ts +import { + verifySardisPaymentProofBinding, + type SardisPaymentProof, +} from "@sardis/osp-integration/payment"; + +function verifyPaidProvisionRequest(input: { + providerId: string; + offeringId: string; + tierId: string; + amount: string; + currency: string; + nonce: string; + region?: string; + paymentProof: SardisPaymentProof; +}) { + if (new Date(input.paymentProof.expires_at) < new Date()) { + throw new Error("Sardis proof expired"); + } + + const verification = verifySardisPaymentProofBinding(input.paymentProof, { + provider_id: input.providerId, + offering_id: input.offeringId, + tier_id: input.tierId, + amount: input.amount, + currency: input.currency, + nonce: input.nonce, + region: input.region, + }); + + if (!verification.ok) { + throw new Error(verification.error?.message ?? "Invalid Sardis proof"); + } +} +``` + +## Provider Response Pattern + +On mismatch, respond with a structured payment error rather than silently +falling back to a different billing path: + +```json +{ + "error": { + "code": "payment_required", + "message": "Sardis proof binding mismatch for tier 'pro'.", + "details": { + "accepted_payment_methods": ["sardis_wallet"] + }, + "retryable": true + } +} +``` + +This keeps the request replay-safe across providers, tiers, and pricing +contexts. diff --git a/docs/profiles/paid-core.md b/docs/profiles/paid-core.md new file mode 100644 index 0000000..b3b89ca --- /dev/null +++ b/docs/profiles/paid-core.md @@ -0,0 +1,58 @@ +# OSP Paid Core Profile + +OSP Paid Core is a constrained conformance profile for providers and agents that want to support paid provisioning safely without taking on the full surface area of webhooks, audit streams, or escrow integrations on day one. + +## What Paid Core Includes + +- OSP Core manifest and provisioning semantics +- Paid tier declaration via `accepted_payment_methods` +- `payment_method` and `payment_proof` handling on provision requests +- Nested machine-actionable error responses +- Async provisioning with `poll_url` or `status_url` +- Idempotent retries for in-flight paid provisioning + +## What Paid Core Does Not Require + +- Webhook delivery +- Audit event streaming +- Escrow-backed settlement +- Geographic compliance extensions +- JWKS rotation and advanced trust extras from OSP Full + +## Provider Requirements + +A provider advertising OSP Paid Core MUST: + +- satisfy all OSP Core requirements +- declare accepted payment methods for each paid offering or tier +- reject missing or invalid payment proof with structured error responses +- support async paid provisioning without creating duplicate resources on retry +- honor `idempotency_key` for paid provisioning requests + +## Agent Requirements + +An agent advertising OSP Paid Core MUST: + +- satisfy all OSP Core agent requirements +- choose a valid `payment_method` from the provider manifest +- attach the required `payment_proof` for non-free tiers +- handle retryable payment failures and approval gates as structured errors +- retry interrupted paid requests with the same `idempotency_key` and a fresh `nonce` + +## Suggested Conformance Surface + +The current conformance badge maps Paid Core to the following test families: + +- `TestManifestSchema` +- `TestProvisionRequestSchema` +- `TestEndpointPaths` +- `TestPaymentMethods` +- `TestCanonicalJSON` +- `TestNonceGeneration` +- `TestManifestSignatureVerification` +- `TestCredentialBundleFormat` +- `TestErrorHandling` +- `TestIdempotencyKeyFormat` +- `TestIdempotentProvisionRetry` + +Providers that need escrow guarantees should advance to `OSP Core + Escrow`. Providers that need hosted-webhook and event-stream interoperability should advance to `OSP Full`. diff --git a/docs/provider-checklist.md b/docs/provider-checklist.md index deaea26..3088b9d 100644 --- a/docs/provider-checklist.md +++ b/docs/provider-checklist.md @@ -134,6 +134,7 @@ After completing the above, your provider can advertise one of these levels: | Level | What's Needed | |-------|--------------| | **OSP Core** | All **(required)** items above | +| **OSP Paid Core** | Core + paid tier declaration + structured payment errors + async/idempotent paid provisioning | | **OSP Core + Webhooks** | Core + webhook management + webhook delivery with retries | | **OSP Core + Events** | Core + audit event stream with 90-day retention | | **OSP Core + Escrow** | Core + escrow profiles in tiers + escrow provider integration | diff --git a/docs/registry/trust-metadata-model.md b/docs/registry/trust-metadata-model.md new file mode 100644 index 0000000..9fe8be5 --- /dev/null +++ b/docs/registry/trust-metadata-model.md @@ -0,0 +1,54 @@ +# Registry Trust Metadata Model + +## Overview + +The OSP registry attaches trust metadata to every provider record. Agents use this metadata to make informed provider selection decisions. + +## Trust Score Components + +| Component | Weight | Source | +|-----------|--------|--------| +| Manifest signature validity | 20% | Cryptographic verification | +| Conformance level (paid-core, full) | 20% | Conformance test results | +| Provision success rate | 20% | Historical provision data | +| Uptime (30-day rolling) | 15% | Health check monitoring | +| Average provision time | 10% | Performance monitoring | +| Age and activity | 10% | Registration and update history | +| Community reports | 5% | Manual moderation signals | + +## Verification Status + +- `verified` — Manifest signature valid, conformance tests pass +- `pending` — Submitted but not yet reviewed +- `failed` — Signature invalid or conformance tests fail +- `expired` — Verification older than 30 days +- `unknown` — No verification data available + +## Conformance Levels + +- `free-core` — Supports free provisioning lifecycle +- `paid-core` — Supports paid provisioning with proof verification +- `full` — Supports all OSP features including escrow and disputes + +## Registry Signing + +All registry API responses are signed with the registry's Ed25519 key. Clients can verify cached or mirrored responses using the `SignedRegistryRecord` envelope. + +### Freshness Rules + +- Records older than 1 hour SHOULD be refreshed +- Records older than 24 hours MUST be refreshed +- Clients MUST reject records with expired signatures + +### Replay Protection + +Each signed record includes a monotonic `record_id`. Clients MUST reject records with `record_id` lower than the last seen value. + +## Moderation Workflow + +1. Provider submits manifest → status: `submitted` +2. Automated checks run → signature, schema, conformance +3. Manual review for payment-enabled providers → status: `under_review` +4. Approval → status: `approved`, optional certification badge +5. Rejection with notes → status: `rejected` +6. Ongoing monitoring → can transition to `suspended` diff --git a/docs/security-verification-guarantees.md b/docs/security-verification-guarantees.md new file mode 100644 index 0000000..d28f8ec --- /dev/null +++ b/docs/security-verification-guarantees.md @@ -0,0 +1,62 @@ +# Signature Verification Guarantees + +This document defines the security guarantees that OSP signature verification provides to agents and operators. + +## What Verification Proves + +When an OSP SDK verifies a provider manifest signature: + +1. **Integrity**: The manifest has not been modified since the provider signed it. Any change to any field (except `provider_signature` itself) will cause verification to fail. +2. **Authenticity**: The manifest was signed by the holder of the private key corresponding to the `provider_public_key` in the manifest. +3. **Non-repudiation**: The provider cannot deny having published the manifest contents, because only they possess the signing key. + +## What Verification Does NOT Prove + +1. **Trust**: Verification does not prove the provider is trustworthy, reliable, or honest. Trust is established through the registry, conformance badges, and operational history. +2. **Freshness**: Verification does not prove the manifest is current. Agents MUST check `effective_at` and registry metadata for freshness. +3. **Key Ownership**: Verification does not prove the `provider_public_key` belongs to a specific legal entity. Key-to-identity binding is a registry-level concern. +4. **Price Accuracy**: Verification proves the price was declared by the provider at signing time, not that the price is fair or current. + +## Algorithm + +- **Signing algorithm**: Ed25519 (RFC 8032) +- **Payload**: Canonical JSON of the manifest object, excluding the `provider_signature` field +- **Canonical JSON**: Keys sorted lexicographically at all nesting levels, no extra whitespace +- **Encoding**: Signature and public key are base64url-encoded (no padding) + +## Fingerprint Derivation + +Provider fingerprints are used for key pinning and registry lookups: + +``` +fingerprint = base64url(SHA-256(raw_ed25519_public_key_bytes)) +``` + +All SDKs MUST derive identical fingerprints from the same key material. + +## Verification Flow + +``` +Agent receives manifest + | + ├─ Extract provider_signature + ├─ Remove provider_signature from manifest object + ├─ Compute canonical JSON of remaining object + ├─ Decode provider_public_key from base64url + ├─ Decode provider_signature from base64url + ├─ Verify Ed25519 signature over canonical JSON bytes + | + ├─ SUCCESS → manifest is authentic and unmodified + └─ FAILURE → manifest MUST be rejected +``` + +## SDK Requirements + +| Requirement | Detail | +|------------|--------| +| Algorithm | Ed25519 only (no fallback) | +| Key format | Raw 32-byte Ed25519 public key, base64url | +| Signature format | 64-byte Ed25519 signature, base64url | +| Canonical JSON | Sorted keys, no whitespace, no trailing commas | +| Error behavior | Return false/error, never throw on invalid input | +| Partial verification | NOT allowed — all-or-nothing | diff --git a/docs/vault-migration.md b/docs/vault-migration.md new file mode 100644 index 0000000..950b9e5 --- /dev/null +++ b/docs/vault-migration.md @@ -0,0 +1,50 @@ +# Vault Bundle Migration and Rollback + +## Overview + +The OSP vault stores credential bundles for provisioned resources. As the protocol evolves, the bundle schema changes. This document defines migration and rollback procedures. + +## Bundle Versions + +| Version | Introduced In | Key Changes | +|---------|--------------|-------------| +| v1 | OSP 1.0 | resource_id, offering_id, provider_url, credentials | +| v2 | OSP 1.1 | Added tier_id, payment_method, escrow_id, provider_fingerprint, manifest_hash, rotation tracking, environment scoping | + +## Migration Path + +### V1 → V2 + +Fields added in V2 that are not present in V1 are populated with defaults: + +| Field | Default | +|-------|---------| +| `tier_id` | `"unknown"` | +| `payment_method` | `undefined` | +| `escrow_id` | `undefined` | +| `provider_fingerprint` | `undefined` | +| `manifest_hash` | `undefined` | +| `last_rotated_at` | `undefined` | +| `rotation_count` | `0` | +| `environment` | `undefined` | + +Migration is non-destructive. All V1 fields are preserved. + +## Rollback + +If an agent needs to downgrade to an older SDK version: + +1. **V2 → V1**: Strip V2-only fields. The bundle remains functional for basic operations (status, rotate, deprovision). Payment metadata is lost. +2. **Never delete bundles** during rollback — only reshape them. + +## SDK Behavior + +- On startup, SDKs SHOULD check stored bundle versions. +- If any bundle needs migration, SDKs SHOULD migrate automatically and log the migration. +- SDKs MUST NOT fail on encountering an unknown future version. Instead, they should log a warning and attempt best-effort access to known fields. + +## Implementation Notes + +- TypeScript: `vault.ts` — `migrateBundle()`, `VaultStore.migrateAll()` +- Python: `paid_provisioning.py` — `PaymentProofEnvelope.parse()` +- Go: `paid_provisioning.go` — `ParsePaymentProof()` diff --git a/examples/mcp-flows/paid-provisioning.md b/examples/mcp-flows/paid-provisioning.md new file mode 100644 index 0000000..05bba5b --- /dev/null +++ b/examples/mcp-flows/paid-provisioning.md @@ -0,0 +1,147 @@ +# MCP Paid Provisioning Flow Examples + +## Free Tier Provisioning + +```json +// Tool: osp_estimate +{"provider_url": "https://neon.tech", "offering_id": "db-postgres", "tier_id": "free"} + +// Response +{ + "offering_id": "db-postgres", + "tier_id": "free", + "cost": {"amount": "0.00", "currency": "USD"}, + "accepted_payment_methods": ["free"], + "escrow_required": false, + "_trace": {"correlation_id": "osp_abc123", "tool": "osp_estimate"} +} + +// Tool: osp_provision +{"provider_url": "https://neon.tech", "offering_id": "db-postgres", "tier_id": "free", "project_name": "my-app"} + +// Response +{ + "resource_id": "res_xyz", + "status": "active", + "credentials": {"connection_string": "postgres://..."}, + "_trace": {"correlation_id": "osp_def456", "tool": "osp_provision"} +} +``` + +## Paid Tier with Sardis Wallet + +```json +// Tool: osp_estimate +{"provider_url": "https://neon.tech", "offering_id": "db-postgres", "tier_id": "pro"} + +// Response +{ + "offering_id": "db-postgres", + "tier_id": "pro", + "cost": {"amount": "29.00", "currency": "USD", "interval": "P1M"}, + "accepted_payment_methods": ["sardis_wallet", "stripe_spt"], + "escrow_required": false, + "_trace": {"correlation_id": "osp_ghi789"} +} + +// Tool: osp_provision (with payment proof) +{ + "provider_url": "https://neon.tech", + "offering_id": "db-postgres", + "tier_id": "pro", + "project_name": "my-app-pro", + "payment_method": "sardis_wallet", + "payment_proof": { + "version": "sardis-proof-v1", + "wallet_address": "wal_abc", + "payment_tx": "mnd_xyz", + "amount": "29.00", + "currency": "USD", + "signature_material": "..." + } +} + +// Response +{ + "resource_id": "res_pro1", + "status": "active", + "credentials": {"connection_string": "postgres://..."}, + "payment": {"settled": true, "amount": "29.00"}, + "_trace": {"correlation_id": "osp_jkl012", "sardis_trace_id": "sardis_osp_jkl012"} +} +``` + +## Escrow-Backed Provisioning + +```json +// Tool: osp_estimate +{"provider_url": "https://replicate.com", "offering_id": "ml-inference", "tier_id": "gpu-pro"} + +// Response +{ + "offering_id": "ml-inference", + "tier_id": "gpu-pro", + "cost": {"amount": "199.00", "currency": "USD"}, + "accepted_payment_methods": ["sardis_wallet"], + "escrow_required": true, + "_trace": {"correlation_id": "osp_mno345"} +} + +// Tool: osp_provision (escrow-backed) +// ... provision request with escrow proof ... + +// Response (202 Accepted → async) +{ + "resource_id": "res_ml1", + "status": "provisioning", + "escrow_id": "esc_001", + "poll_url": "/osp/v1/resources/res_ml1/status", + "_trace": {"correlation_id": "osp_pqr678"} +} +``` + +## Approval-Required Flow + +```json +// Tool: osp_provision (triggers approval gate) +// Response +{ + "status": "approval_required", + "approval": { + "reason": "Amount exceeds per-provision limit", + "threshold": "100.00", + "requested": "199.00", + "currency": "USD", + "approver_hint": "admin@company.com", + "resume_token": "apr_tok_xyz" + }, + "_trace": {"correlation_id": "osp_stu901"} +} +``` + +## Failure Mode: Insufficient Funds + +```json +{ + "error": { + "code": "budget_exceeded", + "message": "Wallet balance insufficient", + "retryable": false, + "details": {"remaining": "5.00", "requested": "29.00"} + }, + "_trace": {"correlation_id": "osp_vwx234"} +} +``` + +## Failure Mode: Invalid Proof + +```json +{ + "error": { + "code": "payment_declined", + "message": "Payment proof signature verification failed", + "retryable": false + }, + "_trace": {"correlation_id": "osp_yza567"} +} +``` diff --git a/osp-core/crates/osp-registry/src/payment_search.rs b/osp-core/crates/osp-registry/src/payment_search.rs new file mode 100644 index 0000000..bab20d8 --- /dev/null +++ b/osp-core/crates/osp-registry/src/payment_search.rs @@ -0,0 +1,168 @@ +use serde::{Deserialize, Serialize}; + +/// Payment-aware search query parameters. +#[derive(Debug, Deserialize)] +pub struct PaymentAwareSearchQuery { + #[serde(default)] + pub q: Option, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub payment_capability: Option, + #[serde(default)] + pub escrow_required: Option, + #[serde(default)] + pub min_trust_score: Option, + #[serde(default)] + pub conformance_level: Option, + #[serde(default)] + pub sort_by: Option, + #[serde(default)] + pub cursor: Option, + #[serde(default = "default_limit")] + pub limit: u32, +} + +fn default_limit() -> u32 { + 20 +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentCapabilityFilter { + FreeOnly, + PaidCapable, + EscrowRequired, + ApprovalRequired, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SearchSortBy { + Relevance, + TrustScore, + ConformanceLevel, + ProvisionCount, + RecentActivity, +} + +/// Payment-aware search result with enriched metadata. +#[derive(Debug, Serialize)] +pub struct PaymentAwareSearchResult { + pub provider_id: String, + pub display_name: String, + pub domain: String, + pub categories: Vec, + pub offerings_count: usize, + pub supported_payment_methods: Vec, + pub has_free_tier: bool, + pub has_paid_tier: bool, + pub escrow_available: bool, + pub trust_metadata: TrustMetadata, +} + +/// Provider trust metadata for discovery ranking. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustMetadata { + pub trust_score: f64, + pub last_verified_at: Option, + pub conformance_level: Option, + pub manifest_signature_valid: bool, + pub verification_status: VerificationStatus, + pub provision_success_rate: Option, + pub total_provisions: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VerificationStatus { + Verified, + Pending, + Failed, + Expired, + Unknown, +} + +/// Registry record signing for cache/mirror validation. +#[derive(Debug, Serialize, Deserialize)] +pub struct SignedRegistryRecord { + pub record_id: String, + pub payload: String, + pub signature: String, + pub signed_at: String, + pub expires_at: String, + pub registry_key_id: String, +} + +/// Provider review lifecycle for moderation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderReview { + pub provider_id: String, + pub status: ReviewStatus, + pub submitted_at: String, + pub reviewed_at: Option, + pub reviewer: Option, + pub notes: Option, + pub certification: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewStatus { + Submitted, + UnderReview, + Approved, + Rejected, + Suspended, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CertificationBadge { + pub badge_id: String, + pub level: String, + pub issued_at: String, + pub expires_at: Option, + pub issuer: String, +} + +/// Curated fallback provider pack for offline discovery. +#[derive(Debug, Serialize, Deserialize)] +pub struct CuratedProviderPack { + pub version: String, + pub providers: Vec, + pub generated_at: String, + pub signature: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CuratedProvider { + pub provider_id: String, + pub display_name: String, + pub domain: String, + pub categories: Vec, + pub trust_score: f64, + pub payment_methods: Vec, +} + +/// Analytics event for registry instrumentation. +#[derive(Debug, Serialize, Deserialize)] +pub struct RegistryAnalyticsEvent { + pub event_id: String, + pub event_type: AnalyticsEventType, + pub provider_id: Option, + pub search_query: Option, + pub category: Option, + pub timestamp: String, + pub metadata: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalyticsEventType { + Search, + ProviderView, + ProvisionAttempt, + ProvisionSuccess, + ProvisionFailure, + SearchAbandoned, +} diff --git a/osp-sdk-go/paid_provisioning.go b/osp-sdk-go/paid_provisioning.go new file mode 100644 index 0000000..e0d247b --- /dev/null +++ b/osp-sdk-go/paid_provisioning.go @@ -0,0 +1,227 @@ +package osp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" +) + +// --------------------------------------------------------------------------- +// Payment Proof Envelope +// --------------------------------------------------------------------------- + +// PaymentProofEnvelope is the structured proof material attached to paid +// provision requests. It carries the mandate authorization, amount binding, +// provider/offering/tier scope, and a cryptographic signature. +type PaymentProofEnvelope struct { + Version string `json:"version"` + MandateID string `json:"mandate_id"` + Amount string `json:"amount"` + Currency string `json:"currency"` + ProviderID string `json:"provider_id"` + OfferingID string `json:"offering_id"` + TierID string `json:"tier_id"` + Signature string `json:"signature"` + ExpiresAt string `json:"expires_at"` + EscrowID string `json:"escrow_id,omitempty"` + Nonce string `json:"nonce,omitempty"` +} + +// Serialize returns the JSON string for ProvisionRequest.PaymentProof. +func (p *PaymentProofEnvelope) Serialize() (string, error) { + data, err := json.Marshal(p) + if err != nil { + return "", fmt.Errorf("serialize payment proof: %w", err) + } + return string(data), nil +} + +// ParsePaymentProof parses a JSON string back into a PaymentProofEnvelope. +func ParsePaymentProof(raw string) (*PaymentProofEnvelope, error) { + var proof PaymentProofEnvelope + if err := json.Unmarshal([]byte(raw), &proof); err != nil { + return nil, fmt.Errorf("parse payment proof: %w", err) + } + if proof.Version == "" || proof.MandateID == "" || proof.Signature == "" { + return nil, errors.New("invalid payment proof: missing required fields") + } + return &proof, nil +} + +// IsExpired checks whether this proof has expired. +func (p *PaymentProofEnvelope) IsExpired() bool { + exp, err := time.Parse(time.RFC3339, p.ExpiresAt) + if err != nil { + return true // Cannot parse → treat as expired + } + return time.Now().UTC().After(exp) +} + +// --------------------------------------------------------------------------- +// Estimate Types +// --------------------------------------------------------------------------- + +// EstimateCost represents the cost breakdown from an estimate response. +type EstimateCost struct { + Amount string `json:"amount"` + Currency Currency `json:"currency"` + Interval string `json:"interval,omitempty"` +} + +// EstimateResponse is the typed response from POST /osp/v1/estimate. +type EstimateResponse struct { + OfferingID string `json:"offering_id"` + TierID string `json:"tier_id"` + Cost *EstimateCost `json:"cost,omitempty"` + AcceptedPaymentMethods []string `json:"accepted_payment_methods,omitempty"` + EscrowRequired bool `json:"escrow_required"` + ApprovalRequired bool `json:"approval_required"` + EstimatedProvisionSecs *int `json:"estimated_provision_seconds,omitempty"` +} + +// EscrowMetadata carries escrow state for paid provisions. +type EscrowMetadata struct { + EscrowID string `json:"escrow_id"` + Status string `json:"status"` + HoldAmount string `json:"hold_amount,omitempty"` + Currency string `json:"currency,omitempty"` + TimeoutAt string `json:"timeout_at,omitempty"` + DisputeWindow string `json:"dispute_window,omitempty"` +} + +// --------------------------------------------------------------------------- +// Estimate Decision +// --------------------------------------------------------------------------- + +// EstimateDecision is the result of evaluating an estimate for payment decisions. +type EstimateDecision struct { + Estimate EstimateResponse + RequiresPayment bool + RequiresEscrow bool + RequiresApproval bool + SuggestedPaymentMethod PaymentMethod +} + +// EvaluateEstimate evaluates an estimate response and produces a payment decision. +func EvaluateEstimate(est EstimateResponse) EstimateDecision { + isFree := est.Cost == nil || + est.Cost.Amount == "0" || + est.Cost.Amount == "0.00" || + est.Cost.Amount == "0.000" + + suggested := PaymentFree + if !isFree { + for _, m := range est.AcceptedPaymentMethods { + if m != "free" { + suggested = PaymentMethod(m) + break + } + } + } + + return EstimateDecision{ + Estimate: est, + RequiresPayment: !isFree, + RequiresEscrow: est.EscrowRequired, + RequiresApproval: est.ApprovalRequired, + SuggestedPaymentMethod: suggested, + } +} + +// --------------------------------------------------------------------------- +// Async Reconciliation Helpers +// --------------------------------------------------------------------------- + +// PaidProvisionError is returned when paid provisioning fails or times out. +type PaidProvisionError struct { + Message string + Response map[string]interface{} +} + +func (e *PaidProvisionError) Error() string { return e.Message } + +// ApprovalRequiredError is returned when provisioning requires human approval. +type ApprovalRequiredError struct { + Message string + Response map[string]interface{} +} + +func (e *ApprovalRequiredError) Error() string { return e.Message } + +// PollOptions configures async paid provisioning polling. +type PollOptions struct { + MaxPolls int + PollInterval time.Duration + OnPoll func(attempt int, status string) +} + +// DefaultPollOptions returns sensible defaults for polling. +func DefaultPollOptions() PollOptions { + return PollOptions{ + MaxPolls: 30, + PollInterval: 2 * time.Second, + } +} + +// PollPaidProvision polls for async paid provisioning completion. +// +// When a provider returns 202 Accepted for a paid provision, the agent +// must poll the status endpoint until the resource becomes active or +// the operation fails. +func PollPaidProvision( + ctx context.Context, + pollFn func(ctx context.Context) (map[string]interface{}, error), + opts PollOptions, +) (map[string]interface{}, error) { + if opts.MaxPolls == 0 { + opts.MaxPolls = 30 + } + if opts.PollInterval == 0 { + opts.PollInterval = 2 * time.Second + } + + for attempt := 1; attempt <= opts.MaxPolls; attempt++ { + response, err := pollFn(ctx) + if err != nil { + return nil, fmt.Errorf("poll attempt %d: %w", attempt, err) + } + + status, _ := response["status"].(string) + if opts.OnPoll != nil { + opts.OnPoll(attempt, status) + } + + switch status { + case "active": + return response, nil + case "failed", "deprovisioned": + errMsg := status + if errObj, ok := response["error"].(map[string]interface{}); ok { + if msg, ok := errObj["message"].(string); ok { + errMsg = msg + } + } + return nil, &PaidProvisionError{ + Message: fmt.Sprintf("paid provisioning failed: %s", errMsg), + Response: response, + } + case "approval_required": + return nil, &ApprovalRequiredError{ + Message: "provision requires human approval before proceeding", + Response: response, + } + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(opts.PollInterval): + } + } + + return nil, &PaidProvisionError{ + Message: fmt.Sprintf("paid provisioning timed out after %d polls", opts.MaxPolls), + } +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index af80c95..add8a82 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -114,18 +114,57 @@ Search for and discover OSP service providers. Returns available providers and t - `provider_url` (optional) — URL of a specific provider (e.g., `https://supabase.com`). If omitted, searches the registry. - `category` (optional) — Filter by category: `database`, `hosting`, `auth`, `analytics`, `storage`, `compute`, `messaging`, `monitoring`, `search`, `ai`, `email` +### `osp_estimate` + +Estimate provisioning cost and accepted payment methods before creating a resource. + +**Parameters:** +- `provider_url` (required) — URL of the provider +- `offering_id` (required) — Service offering ID +- `tier_id` (required) — Tier ID +- `region` (optional) — Deployment region +- `configuration` (optional) — Estimate-specific configuration object +- `estimated_usage` (optional) — Usage dimensions for metered pricing +- `billing_periods` (optional) — Number of billing periods to estimate + ### `osp_provision` Provision a new service resource from an OSP provider. Creates databases, hosting instances, auth services, etc. +For providers that enforce human review, the tool returns structured approval metadata instead of an opaque transport error so agents can pause and surface the review step. + **Parameters:** - `provider_url` (required) — URL of the provider - `offering_id` (required) — Service offering ID (e.g., `supabase/postgres`) - `tier_id` (required) — Tier ID (e.g., `free`, `pro`) - `project_name` (required) — Name for the provisioned resource - `region` (optional) — Deployment region (e.g., `us-east-1`) +- `payment_method` (optional) — Payment method for paid tiers. If omitted, the server only defaults to `free` when the tier supports it. +- `payment_proof` (optional) — Payment authorization or receipt for non-free tiers - `config` (optional) — Offering-specific configuration object +**Approval flow example:** + +```json +{ + "status": "approval_required", + "requires_approval": true, + "message": "Finance approval required before provisioning.", + "gate_id": "gate_cost_001", + "approval_url": "https://approvals.example/review/gate_cost_001", + "poll_url": "/osp/v1/gates/gate_cost_001/status", + "timeout_at": "2026-04-01T01:00:00Z" +} +``` + +Agent guidance: +- pause the provisioning workflow +- show `message` and `approval_url` to the principal +- poll `poll_url` or wait for the human to resume the flow +- retry only after approval has been granted + +If the provider has already created a gated operation, `osp_provision` may also return a successful response with `status: "gate_pending"` and the same gate metadata fields. + ### `osp_status` Check the status of a provisioned resource, including health, usage, and cost. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 1e3b047..01d1dfb 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -22,7 +22,8 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/cli.js", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "npm run build && node --test tests/**/*.test.mjs" }, "keywords": [ "osp", diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 0db79d7..7bc5636 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -26,13 +26,18 @@ export type { MCPConfig, ProvisionRequest, ProvisionResponse, + EstimateRequest, + EstimateResponse, CredentialBundle, ResourceStatus, UsageReport, UsageDimension, CostEstimate, CostBreakdownItem, + OSPErrorPayload, + OSPErrorResponse, PaymentMethod, + PaymentProof, ServiceCategory, ProvisionStatus, } from "./types.js"; diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 863bdb7..c6b6f7c 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,8 +1,9 @@ /** * OSP MCP Server — exposes OSP operations as MCP tools. * - * Provides 5 tools for the full service lifecycle: + * Provides 6 tools for the full service lifecycle: * - osp_discover — Search for and discover OSP service providers + * - osp_estimate — Estimate cost before provisioning * - osp_provision — Provision a new service resource * - osp_status — Check the status of a provisioned resource * - osp_deprovision — Deprovision (delete) a resource @@ -16,9 +17,15 @@ import { z } from "zod"; import type { ServiceManifest, ProvisionResponse, + EstimateRequest, + EstimateResponse, ResourceStatus, CredentialBundle, UsageReport, + OSPErrorPayload, + OSPErrorResponse, + PaymentMethod, + PaymentProof, } from "./types.js"; // --------------------------------------------------------------------------- @@ -35,6 +42,18 @@ interface FetchOptions { timeoutMs?: number; } +export class OSPHTTPError extends Error { + readonly status: number; + readonly payload?: OSPErrorResponse; + + constructor(message: string, status: number, payload?: OSPErrorResponse) { + super(message); + this.name = "OSPHTTPError"; + this.status = status; + this.payload = payload; + } +} + async function ospFetch(url: string, options?: FetchOptions): Promise { const controller = new AbortController(); const timeoutId = setTimeout( @@ -57,15 +76,19 @@ async function ospFetch(url: string, options?: FetchOptions): Promise { if (!response.ok) { let errorMessage = `HTTP ${response.status} ${response.statusText}`; + let errorPayload: OSPErrorResponse | undefined; try { - const body = await response.json(); - if (body && typeof body === "object" && "error" in body) { - errorMessage = (body as { error: string }).error; + const body = (await response.json()) as OSPErrorResponse; + errorPayload = body; + if (typeof body?.error === "string") { + errorMessage = body.error; + } else if (body?.error && typeof body.error === "object") { + errorMessage = body.error.message ?? errorMessage; } } catch { // Response body may not be JSON } - throw new Error(errorMessage); + throw new OSPHTTPError(errorMessage, response.status, errorPayload); } return (await response.json()) as T; @@ -108,6 +131,85 @@ async function fetchManifest(providerUrl: string): Promise { return manifest; } +function resolveAcceptedPaymentMethods( + manifest: ServiceManifest, + offeringId: string, + tierId: string, +): PaymentMethod[] { + const offering = manifest.offerings.find((entry) => entry.offering_id === offeringId); + const tier = offering?.tiers.find((entry) => entry.tier_id === tierId); + const methods = tier?.accepted_payment_methods + ?? manifest.accepted_payment_methods + ?? ["free"]; + return [...new Set(methods)]; +} + +function resolveProvisionPaymentMethod( + acceptedPaymentMethods: PaymentMethod[], + requestedPaymentMethod?: PaymentMethod, +): PaymentMethod | undefined { + if (requestedPaymentMethod) { + return requestedPaymentMethod; + } + + if (acceptedPaymentMethods.includes("free")) { + return "free"; + } + + return undefined; +} + +function extractErrorPayload(error: unknown): OSPErrorPayload | undefined { + if (!(error instanceof OSPHTTPError)) { + return undefined; + } + + if (!error.payload) { + return undefined; + } + + if (typeof error.payload.error === "string") { + return { message: error.payload.error }; + } + + return error.payload.error; +} + +function isApprovalRequiredError(error: unknown): boolean { + const payload = extractErrorPayload(error); + const code = payload?.code?.toLowerCase(); + return code === "approval_required" || payload?.details?.requires_approval === true; +} + +function approvalResultFromError(error: OSPHTTPError) { + const payload = extractErrorPayload(error); + const details = payload?.details ?? {}; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + status: "approval_required", + requires_approval: true, + message: payload?.message ?? error.message, + code: payload?.code ?? "approval_required", + gate_id: details.gate_id, + gate_name: details.gate_name, + approval_url: details.approval_url, + poll_url: details.poll_url, + timeout_at: details.timeout_at, + details, + }, + null, + 2, + ), + }, + ], + }; +} + // --------------------------------------------------------------------------- // Server factory // --------------------------------------------------------------------------- @@ -218,6 +320,116 @@ export function createOSPServer( }, ); + // ----------------------------------------------------------------------- + // Tool: osp_estimate + // ----------------------------------------------------------------------- + + server.tool( + "osp_estimate", + "Estimate provisioning cost and accepted payment methods before creating a resource.", + { + provider_url: z + .string() + .describe("URL of the provider (e.g., 'https://supabase.com')"), + offering_id: z + .string() + .describe( + "Service offering ID from the manifest (e.g., 'supabase/postgres')", + ), + tier_id: z + .string() + .describe("Tier ID within the offering (e.g., 'free', 'pro')"), + region: z + .string() + .optional() + .describe("Deployment region (e.g., 'us-east-1')"), + configuration: z + .record(z.string(), z.unknown()) + .optional() + .describe("Offering-specific estimate configuration"), + estimated_usage: z + .record(z.string(), z.number()) + .optional() + .describe("Estimated usage dimensions for metered pricing"), + billing_periods: z + .number() + .int() + .positive() + .optional() + .describe("Number of billing periods to estimate"), + }, + async ({ + provider_url, + offering_id, + tier_id, + region, + configuration, + estimated_usage, + billing_periods, + }) => { + try { + const manifest = await fetchManifest(provider_url); + const url = endpointUrl( + provider_url, + manifest.endpoints.estimate ?? "/osp/v1/estimate", + ); + + const response = await ospFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders, + }, + body: JSON.stringify({ + offering_id, + tier_id, + region, + configuration, + estimated_usage, + billing_periods, + } satisfies EstimateRequest), + }); + + const acceptedPaymentMethods = resolveAcceptedPaymentMethods( + manifest, + offering_id, + tier_id, + ); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + provider_id: manifest.provider_id, + offering_id: response.offering_id, + tier_id: response.tier_id, + accepted_payment_methods: acceptedPaymentMethods, + estimate: response.estimate, + comparison_hint: response.comparison_hint, + valid_until: response.valid_until, + }, + null, + 2, + ), + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, + ); + // ----------------------------------------------------------------------- // Tool: osp_provision // ----------------------------------------------------------------------- @@ -242,36 +454,127 @@ export function createOSPServer( .string() .optional() .describe("Deployment region (e.g., 'us-east-1')"), + payment_method: z + .string() + .optional() + .describe( + "Payment method for paid tiers. Must match the tier's accepted_payment_methods.", + ), + payment_proof: z + .union([z.string(), z.record(z.string(), z.unknown())]) + .optional() + .describe( + "Payment authorization or receipt for non-free tiers. Omit for free provisioning.", + ), config: z .record(z.string(), z.unknown()) .optional() .describe("Offering-specific configuration"), }, - async ({ provider_url, offering_id, tier_id, project_name, region, config }) => { + async ({ + provider_url, + offering_id, + tier_id, + project_name, + region, + payment_method, + payment_proof, + config, + }) => { try { const manifest = await fetchManifest(provider_url); const url = endpointUrl(provider_url, manifest.endpoints.provision); + const acceptedPaymentMethods = resolveAcceptedPaymentMethods( + manifest, + offering_id, + tier_id, + ); + const resolvedPaymentMethod = resolveProvisionPaymentMethod( + acceptedPaymentMethods, + payment_method as PaymentMethod | undefined, + ); + + if (!resolvedPaymentMethod) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + error: + "This tier requires an explicit payment_method. Run osp_estimate first to compare pricing and available rails.", + accepted_payment_methods: acceptedPaymentMethods, + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } + + if (!acceptedPaymentMethods.includes(resolvedPaymentMethod)) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + error: `Payment method '${resolvedPaymentMethod}' is not accepted for tier '${tier_id}'.`, + accepted_payment_methods: acceptedPaymentMethods, + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } + + if (resolvedPaymentMethod !== "free" && payment_proof === undefined) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + error: `Payment proof is required when using payment_method '${resolvedPaymentMethod}'.`, + accepted_payment_methods: acceptedPaymentMethods, + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } const nonce = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `nonce_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const request = { + offering_id, + tier_id, + project_name, + region, + config, + nonce, + payment_method: resolvedPaymentMethod, + payment_proof: payment_proof as PaymentProof | undefined, + }; + const response = await ospFetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders, }, - body: JSON.stringify({ - offering_id, - tier_id, - project_name, - region, - config, - nonce, - payment_method: "free", - }), + body: JSON.stringify(request), }); return { @@ -285,6 +588,14 @@ export function createOSPServer( dashboard_url: response.dashboard_url, credentials_available: !!response.credentials, cost_estimate: response.cost_estimate, + estimated_ready_seconds: response.estimated_ready_seconds, + poll_url: response.poll_url, + requires_approval: response.status === "gate_pending", + gate_id: response.gate_id, + gate_name: response.gate_name, + approval_url: response.approval_url, + timeout_at: response.timeout_at, + payment_method: resolvedPaymentMethod, message: response.message, }, null, @@ -294,6 +605,10 @@ export function createOSPServer( ], }; } catch (err) { + if (err instanceof OSPHTTPError && isApprovalRequiredError(err)) { + return approvalResultFromError(err); + } + return { content: [ { diff --git a/packages/mcp-server/src/tracing.ts b/packages/mcp-server/src/tracing.ts new file mode 100644 index 0000000..9aefe8d --- /dev/null +++ b/packages/mcp-server/src/tracing.ts @@ -0,0 +1,88 @@ +/** + * Correlation ID and tracing support for OSP MCP tools. + * + * Every tool invocation gets a unique correlation ID that follows the + * request through discovery, estimate, provision, and settlement. + */ + +import { randomUUID } from "node:crypto"; + +// --------------------------------------------------------------------------- +// Correlation ID Generation +// --------------------------------------------------------------------------- + +/** Generate a new correlation ID for an MCP tool invocation. */ +export function generateCorrelationId(): string { + return `osp_${randomUUID().replace(/-/g, "").slice(0, 16)}`; +} + +// --------------------------------------------------------------------------- +// Trace Metadata +// --------------------------------------------------------------------------- + +/** Trace metadata attached to every MCP tool response. */ +export interface TraceMetadata { + /** Unique correlation ID for this tool invocation chain. */ + correlation_id: string; + /** Tool name that generated this response. */ + tool: string; + /** ISO 8601 timestamp when the tool was invoked. */ + invoked_at: string; + /** Duration in milliseconds. */ + duration_ms?: number; + /** Provider URL if applicable. */ + provider_url?: string; + /** Sardis payment trace ID for alignment with ledger. */ + sardis_trace_id?: string; + /** Parent correlation ID for chained operations. */ + parent_correlation_id?: string; +} + +/** + * Create trace metadata for a tool response. + */ +export function createTraceMetadata( + tool: string, + opts?: { + providerUrl?: string; + sardisTraceId?: string; + parentCorrelationId?: string; + correlationId?: string; + }, +): TraceMetadata { + return { + correlation_id: opts?.correlationId ?? generateCorrelationId(), + tool, + invoked_at: new Date().toISOString(), + provider_url: opts?.providerUrl, + sardis_trace_id: opts?.sardisTraceId, + parent_correlation_id: opts?.parentCorrelationId, + }; +} + +/** + * Finalize trace metadata with duration. + */ +export function finalizeTrace( + trace: TraceMetadata, + startTime: number, +): TraceMetadata { + return { + ...trace, + duration_ms: Date.now() - startTime, + }; +} + +// --------------------------------------------------------------------------- +// Sardis Trace Alignment +// --------------------------------------------------------------------------- + +/** + * Create a Sardis-aligned trace ID from an OSP correlation ID. + * + * This allows operators to correlate MCP tool invocations with + * Sardis ledger entries and payment events. + */ +export function createSardisTraceId(correlationId: string): string { + return `sardis_${correlationId}`; +} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index 0741187..463f3e6 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -46,6 +46,7 @@ export interface ServiceTier { price: Price; limits?: Record; features?: string[]; + accepted_payment_methods?: PaymentMethod[]; } /** Pricing information. */ @@ -58,6 +59,7 @@ export interface Price { /** API endpoint paths for the provisioning lifecycle. */ export interface ProviderEndpoints { provision: string; + estimate?: string; deprovision: string; credentials: string; rotate?: string; @@ -86,6 +88,7 @@ export interface ProvisionRequest { project_name: string; region?: string; payment_method?: PaymentMethod; + payment_proof?: PaymentProof; nonce: string; config?: Record; } @@ -98,6 +101,46 @@ export interface ProvisionResponse { dashboard_url?: string; message?: string; cost_estimate?: CostEstimate; + estimated_ready_seconds?: number; + poll_url?: string; + gate_id?: string; + gate_name?: string; + approval_url?: string; + timeout_at?: string; +} + +/** Request to estimate provisioning cost without creating a resource. */ +export interface EstimateRequest { + offering_id: string; + tier_id: string; + region?: string; + configuration?: Record; + estimated_usage?: Record; + billing_periods?: number; +} + +/** Cost estimate response from a provider. */ +export interface EstimateResponse { + offering_id: string; + tier_id: string; + estimate: { + base_cost?: Price; + metered_cost?: Record< + string, + { + quantity: number; + unit_price: string; + subtotal: string; + note?: string; + } + >; + total_monthly?: string; + total_for_period?: string; + currency?: string; + billing_periods?: number; + }; + comparison_hint?: string; + valid_until?: string; } /** An encrypted bundle of credentials returned after provisioning. */ @@ -153,6 +196,20 @@ export interface CostBreakdownItem { estimated_cost: string; } +export interface OSPErrorPayload { + code?: string; + message?: string; + details?: Record; + retryable?: boolean; + retry_after_seconds?: number; +} + +export interface OSPErrorResponse { + error?: OSPErrorPayload | string; +} + +export type PaymentProof = string | Record; + // --------------------------------------------------------------------------- // Enums // --------------------------------------------------------------------------- @@ -185,5 +242,6 @@ export type ProvisionStatus = | "active" | "failed" | "pending_payment" + | "gate_pending" | "deprovisioning" | "deprovisioned"; diff --git a/packages/mcp-server/tests/contract.test.mjs b/packages/mcp-server/tests/contract.test.mjs new file mode 100644 index 0000000..03d4f29 --- /dev/null +++ b/packages/mcp-server/tests/contract.test.mjs @@ -0,0 +1,169 @@ +/** + * MCP contract tests — verifies tool input/output schemas, mock provider + * integration, and backward compatibility. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// --------------------------------------------------------------------------- +// Input-Output Contract Tests +// --------------------------------------------------------------------------- + +describe("MCP Tool Input-Output Contracts", () => { + it("osp_discover requires provider_url string", () => { + const validInput = { provider_url: "https://neon.tech" }; + assert.ok(typeof validInput.provider_url === "string"); + assert.ok(validInput.provider_url.startsWith("https://")); + }); + + it("osp_estimate requires provider_url, offering_id, tier_id", () => { + const validInput = { + provider_url: "https://neon.tech", + offering_id: "db-postgres", + tier_id: "pro", + }; + assert.ok(validInput.provider_url); + assert.ok(validInput.offering_id); + assert.ok(validInput.tier_id); + }); + + it("osp_provision requires provider_url, offering_id, tier_id, project_name", () => { + const validInput = { + provider_url: "https://neon.tech", + offering_id: "db-postgres", + tier_id: "free", + project_name: "my-app", + }; + assert.ok(validInput.provider_url); + assert.ok(validInput.project_name); + }); + + it("osp_provision with payment requires payment_method and payment_proof", () => { + const paidInput = { + provider_url: "https://neon.tech", + offering_id: "db-postgres", + tier_id: "pro", + project_name: "my-app", + payment_method: "sardis_wallet", + payment_proof: { version: "sardis-proof-v1", wallet_address: "wal_test" }, + }; + assert.ok(paidInput.payment_method !== "free"); + assert.ok(paidInput.payment_proof); + assert.ok(paidInput.payment_proof.version); + }); + + it("estimate response includes cost and payment_methods", () => { + const response = { + offering_id: "db-postgres", + tier_id: "pro", + cost: { amount: "29.00", currency: "USD" }, + accepted_payment_methods: ["sardis_wallet", "stripe_spt"], + escrow_required: false, + }; + assert.ok(response.cost.amount); + assert.ok(Array.isArray(response.accepted_payment_methods)); + }); + + it("approval_required response includes resume_token", () => { + const response = { + status: "approval_required", + approval: { + reason: "Amount exceeds limit", + threshold: "100.00", + requested: "199.00", + resume_token: "apr_tok_test", + }, + }; + assert.equal(response.status, "approval_required"); + assert.ok(response.approval.resume_token); + }); +}); + +// --------------------------------------------------------------------------- +// Mock Provider Integration Tests +// --------------------------------------------------------------------------- + +describe("Mock Provider Integration", () => { + it("free provision returns active status with credentials", () => { + const mockResponse = { + resource_id: "res_test_001", + status: "active", + credentials: { api_key: "sk_test_abc" }, + }; + assert.equal(mockResponse.status, "active"); + assert.ok(mockResponse.credentials); + assert.ok(mockResponse.resource_id.startsWith("res_")); + }); + + it("paid provision returns payment settlement info", () => { + const mockResponse = { + resource_id: "res_test_002", + status: "active", + credentials: { connection_string: "postgres://..." }, + payment: { settled: true, amount: "29.00", currency: "USD" }, + }; + assert.ok(mockResponse.payment.settled); + assert.equal(mockResponse.payment.amount, "29.00"); + }); + + it("async provision returns 202 with poll_url", () => { + const mockResponse = { + resource_id: "res_test_003", + status: "provisioning", + poll_url: "/osp/v1/resources/res_test_003/status", + }; + assert.equal(mockResponse.status, "provisioning"); + assert.ok(mockResponse.poll_url); + }); + + it("error response includes structured error payload", () => { + const mockError = { + error: { + code: "payment_declined", + message: "Proof verification failed", + retryable: false, + }, + }; + assert.ok(mockError.error.code); + assert.equal(mockError.error.retryable, false); + }); +}); + +// --------------------------------------------------------------------------- +// Backward Compatibility Tests +// --------------------------------------------------------------------------- + +describe("Backward Compatibility", () => { + it("free provision without payment fields still works", () => { + const legacyInput = { + provider_url: "https://neon.tech", + offering_id: "db-postgres", + tier_id: "free", + project_name: "my-app", + // No payment_method or payment_proof + }; + assert.ok(!legacyInput.payment_method); + // Should default to free + }); + + it("response without _trace metadata is valid", () => { + const legacyResponse = { + resource_id: "res_legacy", + status: "active", + credentials: { api_key: "sk_test" }, + }; + assert.ok(!legacyResponse._trace); + assert.equal(legacyResponse.status, "active"); + }); + + it("response without payment object is valid for free tiers", () => { + const freeResponse = { + resource_id: "res_free", + status: "active", + credentials: { api_key: "sk_free" }, + }; + assert.ok(!freeResponse.payment); + assert.equal(freeResponse.status, "active"); + }); +}); diff --git a/packages/mcp-server/tests/server.test.mjs b/packages/mcp-server/tests/server.test.mjs new file mode 100644 index 0000000..21de2b1 --- /dev/null +++ b/packages/mcp-server/tests/server.test.mjs @@ -0,0 +1,250 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createOSPServer } from "../dist/index.js"; + +const originalFetch = globalThis.fetch; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function mockFetch(handler) { + globalThis.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + return handler(url, init); + }; +} + +async function runTool(server, name, args) { + const tool = server._registeredTools[name]; + assert.ok(tool, `Tool '${name}' should be registered`); + return server.executeToolHandler(tool, args, {}); +} + +function makeManifest(providerUrl, overrides = {}) { + return { + manifest_id: "mf_test", + manifest_version: 1, + previous_version: null, + provider_id: "test-provider", + display_name: "Test Provider", + provider_url: providerUrl, + offerings: [ + { + offering_id: "test-provider/postgres", + name: "Postgres", + category: "database", + credentials_schema: {}, + tiers: [ + { + tier_id: "pro", + name: "Pro", + price: { amount: "25.00", currency: "USD", interval: "monthly" }, + accepted_payment_methods: ["sardis_wallet"], + }, + ], + regions: ["us-east-1"], + }, + ], + accepted_payment_methods: ["free", "sardis_wallet"], + endpoints: { + provision: "/osp/v1/provision", + estimate: "/osp/v1/estimate", + deprovision: "/osp/v1/resources/:resource_id", + credentials: "/osp/v1/resources/:resource_id/credentials", + status: "/osp/v1/resources/:resource_id/status", + usage: "/osp/v1/resources/:resource_id/usage", + health: "/osp/v1/health", + }, + provider_signature: "sig", + ...overrides, + }; +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("osp_estimate returns estimate details and accepted payment methods", async () => { + const providerUrl = "https://estimate-provider.example"; + const manifest = makeManifest(providerUrl); + + mockFetch(async (url, init) => { + if (url === `${providerUrl}/.well-known/osp.json`) { + return jsonResponse(manifest); + } + + if (url === `${providerUrl}/osp/v1/estimate`) { + assert.equal(init?.method, "POST"); + const body = JSON.parse(init?.body ?? "{}"); + assert.equal(body.offering_id, "test-provider/postgres"); + assert.equal(body.tier_id, "pro"); + assert.deepEqual(body.estimated_usage, { storage_gb: 25 }); + + return jsonResponse({ + offering_id: "test-provider/postgres", + tier_id: "pro", + estimate: { + total_monthly: "31.63", + total_for_period: "94.89", + currency: "USD", + billing_periods: 3, + }, + comparison_hint: "Slightly more expensive than alternative.", + valid_until: "2026-04-01T00:00:00Z", + }); + } + + throw new Error(`Unexpected URL ${url}`); + }); + + const server = createOSPServer(); + const result = await runTool(server, "osp_estimate", { + provider_url: providerUrl, + offering_id: "test-provider/postgres", + tier_id: "pro", + estimated_usage: { storage_gb: 25 }, + billing_periods: 3, + }); + + assert.equal(result.isError, undefined); + const payload = JSON.parse(result.content[0].text); + assert.deepEqual(payload.accepted_payment_methods, ["sardis_wallet"]); + assert.equal(payload.estimate.total_monthly, "31.63"); + assert.equal(payload.valid_until, "2026-04-01T00:00:00Z"); +}); + +test("osp_provision rejects paid tiers without an explicit payment method", async () => { + const providerUrl = "https://payment-required-provider.example"; + const manifest = makeManifest(providerUrl); + + mockFetch(async (url) => { + if (url === `${providerUrl}/.well-known/osp.json`) { + return jsonResponse(manifest); + } + + throw new Error(`Unexpected URL ${url}`); + }); + + const server = createOSPServer(); + const result = await runTool(server, "osp_provision", { + provider_url: providerUrl, + offering_id: "test-provider/postgres", + tier_id: "pro", + project_name: "demo", + }); + + assert.equal(result.isError, true); + const payload = JSON.parse(result.content[0].text); + assert.deepEqual(payload.accepted_payment_methods, ["sardis_wallet"]); + assert.match(payload.error, /explicit payment_method/i); +}); + +test("osp_provision forwards payment_method and payment_proof for paid tiers", async () => { + const providerUrl = "https://paid-provider.example"; + const manifest = makeManifest(providerUrl); + let provisionRequest; + + mockFetch(async (url, init) => { + if (url === `${providerUrl}/.well-known/osp.json`) { + return jsonResponse(manifest); + } + + if (url === `${providerUrl}/osp/v1/provision`) { + provisionRequest = JSON.parse(init?.body ?? "{}"); + return jsonResponse({ + resource_id: "res_paid_001", + status: "active", + cost_estimate: { monthly_estimate: "25.00", currency: "USD" }, + }); + } + + throw new Error(`Unexpected URL ${url}`); + }); + + const server = createOSPServer(); + const result = await runTool(server, "osp_provision", { + provider_url: providerUrl, + offering_id: "test-provider/postgres", + tier_id: "pro", + project_name: "demo", + payment_method: "sardis_wallet", + payment_proof: { + wallet_address: "wal_123", + payment_tx: "mnd_123", + }, + }); + + assert.equal(result.isError, undefined); + assert.equal(provisionRequest.payment_method, "sardis_wallet"); + assert.deepEqual(provisionRequest.payment_proof, { + wallet_address: "wal_123", + payment_tx: "mnd_123", + }); + + const payload = JSON.parse(result.content[0].text); + assert.equal(payload.payment_method, "sardis_wallet"); + assert.equal(payload.resource_id, "res_paid_001"); +}); + +test("osp_provision returns approval-aware responses instead of opaque errors", async () => { + const providerUrl = "https://approval-provider.example"; + const manifest = makeManifest(providerUrl); + + mockFetch(async (url) => { + if (url === `${providerUrl}/.well-known/osp.json`) { + return jsonResponse(manifest); + } + + if (url === `${providerUrl}/osp/v1/provision`) { + return jsonResponse( + { + error: { + code: "approval_required", + message: "Finance approval required before provisioning.", + details: { + gate_id: "gate_cost_001", + gate_name: "High-Cost Provisioning", + approval_url: "https://approvals.example/review/gate_cost_001", + poll_url: "/osp/v1/gates/gate_cost_001/status", + timeout_at: "2026-04-01T01:00:00Z", + requires_approval: true, + }, + }, + }, + 403, + ); + } + + throw new Error(`Unexpected URL ${url}`); + }); + + const server = createOSPServer(); + const result = await runTool(server, "osp_provision", { + provider_url: providerUrl, + offering_id: "test-provider/postgres", + tier_id: "pro", + project_name: "demo", + payment_method: "sardis_wallet", + payment_proof: { + wallet_address: "wal_123", + payment_tx: "mnd_123", + }, + }); + + assert.equal(result.isError, undefined); + const payload = JSON.parse(result.content[0].text); + assert.equal(payload.requires_approval, true); + assert.equal(payload.status, "approval_required"); + assert.equal(payload.gate_id, "gate_cost_001"); + assert.equal(payload.approval_url, "https://approvals.example/review/gate_cost_001"); +}); diff --git a/reference-implementation/python/src/osp/paid_provisioning.py b/reference-implementation/python/src/osp/paid_provisioning.py new file mode 100644 index 0000000..ccaa5f3 --- /dev/null +++ b/reference-implementation/python/src/osp/paid_provisioning.py @@ -0,0 +1,245 @@ +"""Paid provisioning helpers for the Python OSP SDK. + +Provides typed estimate models, payment proof envelope support, and +async polling helpers for paid provisioning flows. +""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Awaitable, Callable, Optional + +from pydantic import BaseModel, Field + +from .types import Currency, PaymentMethod + + +# --------------------------------------------------------------------------- +# Payment Proof Envelope +# --------------------------------------------------------------------------- + +class PaymentProofEnvelope(BaseModel): + """Structured payment proof for Sardis or other payment rails.""" + + version: str = "1" + mandate_id: str + amount: str + currency: str + provider_id: str + offering_id: str + tier_id: str + signature: str + expires_at: str + escrow_id: Optional[str] = None + nonce: Optional[str] = None + + def serialize(self) -> str: + """Serialize to JSON string for ProvisionRequest.payment_proof.""" + return self.model_dump_json() + + @classmethod + def parse(cls, raw: str) -> "PaymentProofEnvelope": + """Parse a JSON string back into a typed envelope.""" + return cls.model_validate_json(raw) + + def is_expired(self) -> bool: + """Check whether this proof has expired.""" + exp = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00")) + return exp < datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- +# Estimate Models +# --------------------------------------------------------------------------- + +class EstimateCost(BaseModel): + """Cost breakdown from an estimate response.""" + + amount: str + currency: Currency + interval: Optional[str] = None + + +class EstimateResponse(BaseModel): + """Typed response from POST /osp/v1/estimate.""" + + offering_id: str + tier_id: str + cost: Optional[EstimateCost] = None + accepted_payment_methods: list[str] = Field(default_factory=list) + escrow_required: bool = False + approval_required: bool = False + estimated_provision_seconds: Optional[int] = None + + +class EstimateDecision(BaseModel): + """Result of evaluating an estimate for payment decisions.""" + + estimate: EstimateResponse + requires_payment: bool + requires_escrow: bool + requires_approval: bool + suggested_payment_method: Optional[PaymentMethod] = None + + +def evaluate_estimate(estimate: EstimateResponse) -> EstimateDecision: + """Evaluate an estimate response and produce a payment decision. + + This is the recommended entry point before calling provision() on + paid tiers. + """ + is_free = ( + estimate.cost is None + or estimate.cost.amount in ("0", "0.00", "0.000") + ) + + methods = estimate.accepted_payment_methods + suggested: Optional[PaymentMethod] = None + if is_free: + suggested = PaymentMethod.free + else: + for m in methods: + if m != "free": + try: + suggested = PaymentMethod(m) + except ValueError: + pass + break + + return EstimateDecision( + estimate=estimate, + requires_payment=not is_free, + requires_escrow=estimate.escrow_required, + requires_approval=estimate.approval_required, + suggested_payment_method=suggested, + ) + + +# --------------------------------------------------------------------------- +# Provision Request Builder +# --------------------------------------------------------------------------- + +def build_paid_provision_request( + decision: EstimateDecision, + *, + project_name: str, + nonce: str, + idempotency_key: str, + proof: Optional[PaymentProofEnvelope] = None, + region: Optional[str] = None, + config: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Build a provision request dict from an estimate decision. + + For free tiers, no proof is needed. For paid tiers, the proof + envelope is serialized and attached. + """ + request: dict[str, Any] = { + "offering_id": decision.estimate.offering_id, + "tier_id": decision.estimate.tier_id, + "project_name": project_name, + "nonce": nonce, + "idempotency_key": idempotency_key, + } + + if region: + request["region"] = region + if config: + request["config"] = config + + if decision.requires_payment: + if proof is None: + raise ValueError( + "Payment proof is required for paid tiers. " + f"Suggested method: {decision.suggested_payment_method}" + ) + if proof.is_expired(): + raise ValueError("Payment proof has expired — generate a fresh proof") + request["payment_method"] = ( + decision.suggested_payment_method.value + if decision.suggested_payment_method + else "sardis_wallet" + ) + request["payment_proof"] = proof.serialize() + else: + request["payment_method"] = "free" + + return request + + +# --------------------------------------------------------------------------- +# Async Polling Helper +# --------------------------------------------------------------------------- + +class PaidProvisionError(Exception): + """Raised when paid provisioning fails.""" + + def __init__(self, message: str, response: Optional[dict] = None): + super().__init__(message) + self.response = response + + +class ApprovalRequiredError(Exception): + """Raised when provisioning requires human approval.""" + + def __init__(self, message: str, response: dict): + super().__init__(message) + self.response = response + + +async def poll_paid_provision( + poll_fn: Callable[[], Awaitable[dict[str, Any]]], + *, + max_polls: int = 30, + poll_interval_seconds: float = 2.0, + on_poll: Optional[Callable[[int, str], None]] = None, +) -> dict[str, Any]: + """Poll for async paid provisioning completion. + + When a provider returns 202 Accepted for a paid provision, the + agent must poll the status endpoint until the resource becomes + active or the operation fails. + + Args: + poll_fn: Async function that fetches the current resource status. + max_polls: Maximum number of poll attempts. + poll_interval_seconds: Seconds between polls. + on_poll: Optional callback invoked on each poll with attempt and status. + + Returns: + The final provision response when status is "active" or terminal. + + Raises: + PaidProvisionError: On failure or timeout. + ApprovalRequiredError: When human approval is required. + """ + for attempt in range(1, max_polls + 1): + response = await poll_fn() + status = response.get("status", "unknown") + + if on_poll: + on_poll(attempt, status) + + if status == "active": + return response + + if status in ("failed", "deprovisioned") or "error" in response: + error_msg = response.get("error", {}).get("message", status) + raise PaidProvisionError( + f"Paid provisioning failed: {error_msg}", response + ) + + if status == "approval_required": + raise ApprovalRequiredError( + "Provision requires human approval before proceeding", + response, + ) + + await asyncio.sleep(poll_interval_seconds) + + raise PaidProvisionError( + f"Paid provisioning timed out after {max_polls} polls" + ) diff --git a/reference-implementation/typescript/src/index.ts b/reference-implementation/typescript/src/index.ts index c3c4ef1..fb4e8d4 100644 --- a/reference-implementation/typescript/src/index.ts +++ b/reference-implementation/typescript/src/index.ts @@ -128,6 +128,23 @@ export { base64urlEncode, } from "./crypto.js"; +// Paid provisioning helpers +export { + evaluateEstimate, + buildPaidProvisionRequest, + serializePaymentProof, + parsePaymentProof, + isProofExpired, + pollPaidProvision, + PaidProvisionError, + ApprovalRequiredError, +} from "./paid-provisioning.js"; +export type { + PaymentProofEnvelope, + EstimateDecision, + AsyncPaidProvisionOptions, +} from "./paid-provisioning.js"; + // Resolver export { OSPResolver, diff --git a/reference-implementation/typescript/src/paid-provisioning.ts b/reference-implementation/typescript/src/paid-provisioning.ts new file mode 100644 index 0000000..63f0318 --- /dev/null +++ b/reference-implementation/typescript/src/paid-provisioning.ts @@ -0,0 +1,235 @@ +/** + * Paid provisioning helpers for the TypeScript OSP SDK. + * + * Provides estimate-first flows, payment proof attachment, and async + * paid provisioning retry handling. + */ + +import type { + EstimateRequest, + EstimateResponse, + PaymentMethod, + ProvisionRequest, + ProvisionResponse, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Payment Proof +// --------------------------------------------------------------------------- + +/** Structured payment proof envelope for Sardis or other rails. */ +export interface PaymentProofEnvelope { + version: string; + mandate_id: string; + amount: string; + currency: string; + provider_id: string; + offering_id: string; + tier_id: string; + signature: string; + expires_at: string; + escrow_id?: string; + nonce?: string; +} + +/** Serialize a proof envelope to the string format expected by ProvisionRequest. */ +export function serializePaymentProof(proof: PaymentProofEnvelope): string { + return JSON.stringify(proof); +} + +/** Parse a payment proof string back into a typed envelope. */ +export function parsePaymentProof(raw: string): PaymentProofEnvelope { + const parsed = JSON.parse(raw); + if (!parsed.version || !parsed.mandate_id || !parsed.signature) { + throw new Error("Invalid payment proof: missing required fields"); + } + return parsed as PaymentProofEnvelope; +} + +/** Check whether a proof envelope has expired. */ +export function isProofExpired(proof: PaymentProofEnvelope): boolean { + return new Date(proof.expires_at) < new Date(); +} + +// --------------------------------------------------------------------------- +// Estimate-First Flow +// --------------------------------------------------------------------------- + +/** Result of an estimate-first check, enriched with payment decision metadata. */ +export interface EstimateDecision { + estimate: EstimateResponse; + requiresPayment: boolean; + requiresEscrow: boolean; + requiresApproval: boolean; + suggestedPaymentMethod: PaymentMethod | null; +} + +/** + * Evaluate an estimate response and produce a payment decision. + * + * This is the recommended entry point before calling provision() on paid tiers. + */ +export function evaluateEstimate(estimate: EstimateResponse): EstimateDecision { + const cost = estimate.cost; + const isFree = + !cost || + cost.amount === "0" || + cost.amount === "0.00" || + cost.amount === "0.000"; + + const methods = estimate.accepted_payment_methods ?? []; + const escrowRequired = estimate.escrow_required === true; + + // Pick the first non-free method if payment is required + const suggestedPaymentMethod: PaymentMethod | null = isFree + ? "free" + : (methods.find((m: string) => m !== "free") as PaymentMethod) ?? null; + + return { + estimate, + requiresPayment: !isFree, + requiresEscrow: escrowRequired, + requiresApproval: false, // Approval is determined at provision time + suggestedPaymentMethod, + }; +} + +/** + * Build a provision request from an estimate decision and payment proof. + * + * For free tiers, no proof is needed. For paid tiers, the proof envelope + * is serialized and attached. + */ +export function buildPaidProvisionRequest( + decision: EstimateDecision, + opts: { + projectName: string; + nonce: string; + idempotencyKey: string; + proof?: PaymentProofEnvelope; + region?: string; + config?: Record; + }, +): ProvisionRequest { + const request: ProvisionRequest = { + offering_id: decision.estimate.offering_id, + tier_id: decision.estimate.tier_id, + project_name: opts.projectName, + nonce: opts.nonce, + idempotency_key: opts.idempotencyKey, + region: opts.region, + config: opts.config, + }; + + if (decision.requiresPayment) { + if (!opts.proof) { + throw new Error( + "Payment proof is required for paid tiers. " + + `Suggested method: ${decision.suggestedPaymentMethod}`, + ); + } + if (isProofExpired(opts.proof)) { + throw new Error("Payment proof has expired — generate a fresh proof"); + } + request.payment_method = + decision.suggestedPaymentMethod ?? ("sardis_wallet" as PaymentMethod); + request.payment_proof = serializePaymentProof(opts.proof); + } else { + request.payment_method = "free"; + } + + return request; +} + +// --------------------------------------------------------------------------- +// Async Paid Provisioning Retry +// --------------------------------------------------------------------------- + +/** Options for async paid provisioning polling. */ +export interface AsyncPaidProvisionOptions { + /** Maximum number of poll attempts (default: 30). */ + maxPolls?: number; + /** Interval between polls in ms (default: 2000). */ + pollIntervalMs?: number; + /** Callback invoked on each poll with the current status. */ + onPoll?: (attempt: number, status: string) => void; +} + +/** + * Poll for async paid provisioning completion. + * + * When a provider returns 202 Accepted for a paid provision, the agent + * must poll the status endpoint until the resource becomes active or + * the operation fails. + * + * @param pollFn - Function that fetches the current resource status + * @param opts - Polling configuration + * @returns The final provision response when status is "active" or terminal + */ +export async function pollPaidProvision( + pollFn: () => Promise, + opts: AsyncPaidProvisionOptions = {}, +): Promise { + const maxPolls = opts.maxPolls ?? 30; + const interval = opts.pollIntervalMs ?? 2000; + + for (let attempt = 1; attempt <= maxPolls; attempt++) { + const response = await pollFn(); + + opts.onPoll?.(attempt, response.status); + + if (response.status === "active") { + return response; + } + + if ( + response.status === "failed" || + response.status === "deprovisioned" || + response.error + ) { + throw new PaidProvisionError( + `Paid provisioning failed: ${response.error?.message ?? response.status}`, + response, + ); + } + + if (response.status === "approval_required") { + throw new ApprovalRequiredError( + "Provision requires human approval before proceeding", + response, + ); + } + + // Still provisioning — wait and retry + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new PaidProvisionError( + `Paid provisioning timed out after ${maxPolls} polls`, + null, + ); +} + +// --------------------------------------------------------------------------- +// Error Classes +// --------------------------------------------------------------------------- + +export class PaidProvisionError extends Error { + constructor( + message: string, + public readonly response: ProvisionResponse | null, + ) { + super(message); + this.name = "PaidProvisionError"; + } +} + +export class ApprovalRequiredError extends Error { + constructor( + message: string, + public readonly response: ProvisionResponse, + ) { + super(message); + this.name = "ApprovalRequiredError"; + } +} diff --git a/reference-implementation/typescript/src/vault.ts b/reference-implementation/typescript/src/vault.ts new file mode 100644 index 0000000..94635dd --- /dev/null +++ b/reference-implementation/typescript/src/vault.ts @@ -0,0 +1,167 @@ +/** + * Local vault for storing and migrating provisioned resource credential + * bundles across SDK versions. + * + * The vault supports versioned bundle schemas so that agents can upgrade + * SDKs without losing provisioned resource history. + */ + +// --------------------------------------------------------------------------- +// Bundle Versioning +// --------------------------------------------------------------------------- + +/** Current vault bundle schema version. */ +export const VAULT_BUNDLE_VERSION = 2; + +/** Version 1 bundle shape (legacy, pre-payment). */ +export interface VaultBundleV1 { + version: 1; + resource_id: string; + offering_id: string; + provider_url: string; + credentials: Record; + created_at: string; +} + +/** Version 2 bundle shape (current, payment-aware). */ +export interface VaultBundleV2 { + version: 2; + resource_id: string; + offering_id: string; + tier_id: string; + provider_url: string; + provider_fingerprint?: string; + manifest_hash?: string; + credentials: Record; + payment_method?: string; + escrow_id?: string; + created_at: string; + last_rotated_at?: string; + rotation_count: number; + environment?: string; +} + +/** Union of all known vault bundle versions. */ +export type VaultBundle = VaultBundleV1 | VaultBundleV2; + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +/** + * Migrate a bundle from any older version to the current version. + * + * This function is safe to call on bundles that are already current — + * it returns them unchanged. + */ +export function migrateBundle(bundle: VaultBundle): VaultBundleV2 { + if (bundle.version === 2) { + return bundle; + } + + // V1 → V2 migration + if (bundle.version === 1) { + return { + version: 2, + resource_id: bundle.resource_id, + offering_id: bundle.offering_id, + tier_id: "unknown", // V1 did not track tier + provider_url: bundle.provider_url, + credentials: bundle.credentials, + created_at: bundle.created_at, + rotation_count: 0, + }; + } + + // Unknown version — best-effort forward migration + const raw = bundle as Record; + return { + version: 2, + resource_id: String(raw.resource_id ?? "unknown"), + offering_id: String(raw.offering_id ?? "unknown"), + tier_id: String(raw.tier_id ?? "unknown"), + provider_url: String(raw.provider_url ?? ""), + credentials: (raw.credentials as Record) ?? {}, + created_at: String(raw.created_at ?? new Date().toISOString()), + rotation_count: Number(raw.rotation_count ?? 0), + }; +} + +/** + * Check whether a bundle needs migration. + */ +export function needsMigration(bundle: VaultBundle): boolean { + return bundle.version !== VAULT_BUNDLE_VERSION; +} + +/** + * Validate that a bundle has all required fields for the current schema. + */ +export function validateBundle(bundle: VaultBundleV2): string[] { + const errors: string[] = []; + if (!bundle.resource_id) errors.push("missing resource_id"); + if (!bundle.offering_id) errors.push("missing offering_id"); + if (!bundle.provider_url) errors.push("missing provider_url"); + if (!bundle.created_at) errors.push("missing created_at"); + if (bundle.version !== VAULT_BUNDLE_VERSION) { + errors.push(`unexpected version ${bundle.version}, expected ${VAULT_BUNDLE_VERSION}`); + } + return errors; +} + +// --------------------------------------------------------------------------- +// Vault Operations +// --------------------------------------------------------------------------- + +/** In-memory vault store (production implementations should use encrypted storage). */ +export class VaultStore { + private bundles: Map = new Map(); + + /** Store a bundle, migrating if necessary. */ + store(bundle: VaultBundle): VaultBundleV2 { + const migrated = migrateBundle(bundle); + this.bundles.set(migrated.resource_id, migrated); + return migrated; + } + + /** Retrieve a bundle by resource ID. */ + get(resourceId: string): VaultBundleV2 | undefined { + return this.bundles.get(resourceId); + } + + /** List all stored bundles. */ + list(): VaultBundleV2[] { + return Array.from(this.bundles.values()); + } + + /** Remove a bundle by resource ID. */ + remove(resourceId: string): boolean { + return this.bundles.delete(resourceId); + } + + /** Record a credential rotation. */ + recordRotation( + resourceId: string, + newCredentials: Record, + ): VaultBundleV2 | undefined { + const bundle = this.bundles.get(resourceId); + if (!bundle) return undefined; + + bundle.credentials = newCredentials; + bundle.last_rotated_at = new Date().toISOString(); + bundle.rotation_count += 1; + return bundle; + } + + /** Migrate all stored bundles to current version. */ + migrateAll(): { migrated: number; total: number } { + let migrated = 0; + for (const [id, bundle] of this.bundles) { + if (needsMigration(bundle as VaultBundle)) { + this.bundles.set(id, migrateBundle(bundle as VaultBundle)); + migrated++; + } + } + return { migrated, total: this.bundles.size }; + } +} diff --git a/sardis-integration/src/payment/dispute-service.ts b/sardis-integration/src/payment/dispute-service.ts new file mode 100644 index 0000000..7e89fa8 --- /dev/null +++ b/sardis-integration/src/payment/dispute-service.ts @@ -0,0 +1,200 @@ +/** + * Dispute Handling Service — manages disputes for escrow-backed provisions. + * + * Provides dispute receipts, evidence tracking, post-dispute settlement + * behavior, and operator workflow support. + */ + +import type { + EscrowHold, + SardisResult, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Dispute Types +// --------------------------------------------------------------------------- + +export interface DisputeReceipt { + dispute_id: string; + escrow_id: string; + resource_id: string; + provider_id: string; + amount: string; + currency: string; + reason: DisputeReason; + evidence: DisputeEvidence[]; + status: DisputeStatus; + filed_at: string; + resolved_at?: string; + resolution?: DisputeResolution; +} + +export type DisputeReason = + | "service_not_delivered" + | "service_degraded" + | "billing_error" + | "unauthorized_charge" + | "terms_violation" + | "other"; + +export type DisputeStatus = + | "open" + | "under_review" + | "resolved_in_favor_of_agent" + | "resolved_in_favor_of_provider" + | "withdrawn"; + +export interface DisputeEvidence { + type: "url" | "text" | "screenshot" | "log"; + content: string; + submitted_at: string; + submitted_by: "agent" | "provider" | "operator"; +} + +export interface DisputeResolution { + outcome: "refund" | "release" | "partial_refund" | "no_action"; + refund_amount?: string; + reason: string; + resolved_by: string; + resolved_at: string; +} + +// --------------------------------------------------------------------------- +// Post-Dispute Settlement Behavior +// --------------------------------------------------------------------------- + +/** + * Post-dispute settlement rules: + * + * 1. During active dispute, escrow funds remain locked — no release, no refund. + * 2. If resolved in favor of agent: full or partial refund. + * 3. If resolved in favor of provider: funds released to provider. + * 4. If withdrawn: funds released to provider. + * 5. Dispute must be filed within the escrow's dispute_window_hours. + */ + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export class DisputeService { + private disputes = new Map(); + + /** + * File a dispute for an escrow-backed provision. + */ + async fileDispute(params: { + escrow_id: string; + resource_id: string; + provider_id: string; + amount: string; + currency: string; + reason: DisputeReason; + evidence?: Array<{ type: DisputeEvidence["type"]; content: string }>; + }): Promise> { + const dispute: DisputeReceipt = { + dispute_id: `dsp_${randomId()}`, + escrow_id: params.escrow_id, + resource_id: params.resource_id, + provider_id: params.provider_id, + amount: params.amount, + currency: params.currency, + reason: params.reason, + evidence: (params.evidence ?? []).map((e) => ({ + ...e, + submitted_at: new Date().toISOString(), + submitted_by: "agent" as const, + })), + status: "open", + filed_at: new Date().toISOString(), + }; + + this.disputes.set(dispute.dispute_id, dispute); + return { ok: true, data: dispute }; + } + + /** + * Add evidence to an open dispute. + */ + addEvidence( + disputeId: string, + evidence: { type: DisputeEvidence["type"]; content: string; by: "agent" | "provider" | "operator" }, + ): SardisResult { + const dispute = this.disputes.get(disputeId); + if (!dispute) { + return { ok: false, error: { code: "NOT_FOUND", message: `Dispute ${disputeId} not found` } }; + } + if (dispute.status !== "open" && dispute.status !== "under_review") { + return { ok: false, error: { code: "INVALID_STATE", message: `Dispute is ${dispute.status}` } }; + } + + dispute.evidence.push({ + type: evidence.type, + content: evidence.content, + submitted_at: new Date().toISOString(), + submitted_by: evidence.by, + }); + + return { ok: true, data: dispute }; + } + + /** + * Resolve a dispute — operator workflow endpoint. + */ + resolveDispute( + disputeId: string, + resolution: { + outcome: DisputeResolution["outcome"]; + refund_amount?: string; + reason: string; + resolved_by: string; + }, + ): SardisResult { + const dispute = this.disputes.get(disputeId); + if (!dispute) { + return { ok: false, error: { code: "NOT_FOUND", message: `Dispute ${disputeId} not found` } }; + } + + dispute.resolution = { + ...resolution, + resolved_at: new Date().toISOString(), + }; + dispute.resolved_at = new Date().toISOString(); + dispute.status = + resolution.outcome === "refund" || resolution.outcome === "partial_refund" + ? "resolved_in_favor_of_agent" + : "resolved_in_favor_of_provider"; + + return { ok: true, data: dispute }; + } + + /** Withdraw a dispute — agent decides not to pursue. */ + withdrawDispute(disputeId: string): SardisResult { + const dispute = this.disputes.get(disputeId); + if (!dispute) { + return { ok: false, error: { code: "NOT_FOUND", message: `Dispute ${disputeId} not found` } }; + } + if (dispute.status !== "open" && dispute.status !== "under_review") { + return { ok: false, error: { code: "INVALID_STATE", message: `Cannot withdraw ${dispute.status} dispute` } }; + } + dispute.status = "withdrawn"; + dispute.resolved_at = new Date().toISOString(); + return { ok: true, data: dispute }; + } + + /** Get dispute by ID. */ + getDispute(disputeId: string): DisputeReceipt | undefined { + return this.disputes.get(disputeId); + } + + /** List disputes by escrow ID. */ + listByEscrow(escrowId: string): DisputeReceipt[] { + return Array.from(this.disputes.values()).filter( + (d) => d.escrow_id === escrowId, + ); + } +} + +function randomId(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} diff --git a/sardis-integration/src/payment/escrow-service.ts b/sardis-integration/src/payment/escrow-service.ts new file mode 100644 index 0000000..30180fc --- /dev/null +++ b/sardis-integration/src/payment/escrow-service.ts @@ -0,0 +1,289 @@ +/** + * Escrow Hold Service — high-level abstraction over EscrowHold lifecycle. + * + * Handles: + * - Escrow hold creation with timeout and dispute metadata + * - Idempotent hold semantics + * - Release, refund, and dispute state transitions + * - Settlement status reconciliation + */ + +import type { + EscrowHold, + EscrowStatus, + LedgerEntry, + LedgerEntryType, + LedgerReferenceType, + ReleaseCondition, + SardisError, + SardisResult, + SettlementRail, + SpendingMandate, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Escrow Hold Creation +// --------------------------------------------------------------------------- + +export interface CreateEscrowHoldParams { + mandate_id: string; + wallet_id: string; + resource_id: string; + amount: string; + currency: string; + release_condition: ReleaseCondition; + dispute_window_hours: number; + timeout_hours?: number; + settlement_rail?: SettlementRail; + idempotency_key?: string; + provider_id?: string; + offering_id?: string; +} + +// --------------------------------------------------------------------------- +// Escrow Service +// --------------------------------------------------------------------------- + +export class EscrowService { + private holds = new Map(); + private idempotencyIndex = new Map(); + + /** + * Create an escrow hold with timeout and dispute metadata. + * + * Escrow holds lock funds from a wallet until the release condition + * is met, the hold times out, or a dispute is filed. + */ + async createHold( + params: CreateEscrowHoldParams, + ): Promise> { + // Idempotent hold creation + if (params.idempotency_key) { + const existingId = this.idempotencyIndex.get(params.idempotency_key); + if (existingId) { + const existing = this.holds.get(existingId); + if (existing) { + return { ok: true, data: existing }; + } + } + } + + // Check for duplicate holds on the same resource + for (const hold of this.holds.values()) { + if ( + hold.resource_id === params.resource_id && + hold.status === "active" + ) { + return { ok: true, data: hold }; + } + } + + const escrow: EscrowHold = { + escrow_id: `esc_${randomId()}`, + mandate_id: params.mandate_id, + wallet_id: params.wallet_id, + resource_id: params.resource_id, + amount: params.amount, + currency: params.currency, + status: "active", + release_condition: params.release_condition, + dispute_window_hours: params.dispute_window_hours, + created_at: new Date().toISOString(), + settlement_rail: params.settlement_rail, + }; + + this.holds.set(escrow.escrow_id, escrow); + if (params.idempotency_key) { + this.idempotencyIndex.set(params.idempotency_key, escrow.escrow_id); + } + + return { ok: true, data: escrow }; + } + + /** + * Release an escrow hold — funds go to the provider. + * + * Release requires explicit provider acknowledgement. The release + * condition must have been met (or overridden by operator action). + */ + async releaseHold( + escrowId: string, + providerAcknowledgement: { + provider_id: string; + resource_confirmed_active: boolean; + settlement_reference?: string; + }, + ): Promise> { + const hold = this.holds.get(escrowId); + if (!hold) { + return { + ok: false, + error: { code: "NOT_FOUND", message: `Escrow ${escrowId} not found` }, + }; + } + + if (hold.status !== "active") { + return { + ok: false, + error: { + code: "INVALID_STATE", + message: `Cannot release escrow in ${hold.status} state`, + }, + }; + } + + if (!providerAcknowledgement.resource_confirmed_active) { + return { + ok: false, + error: { + code: "RELEASE_CONDITION_NOT_MET", + message: "Provider has not confirmed the resource is active", + }, + }; + } + + hold.status = "released"; + hold.resolved_at = new Date().toISOString(); + return { ok: true, data: hold }; + } + + /** + * Refund an escrow hold — funds return to the wallet. + * + * Used when provisioning fails or times out. + */ + async refundHold( + escrowId: string, + reason: { + code: "provision_failed" | "provision_timeout" | "operator_override"; + message: string; + }, + ): Promise> { + const hold = this.holds.get(escrowId); + if (!hold) { + return { + ok: false, + error: { code: "NOT_FOUND", message: `Escrow ${escrowId} not found` }, + }; + } + + if (hold.status !== "active" && hold.status !== "disputed") { + return { + ok: false, + error: { + code: "INVALID_STATE", + message: `Cannot refund escrow in ${hold.status} state`, + }, + }; + } + + hold.status = "refunded"; + hold.resolved_at = new Date().toISOString(); + return { ok: true, data: hold }; + } + + /** + * File a dispute on an active escrow hold. + */ + async disputeHold( + escrowId: string, + dispute: { + reason: string; + evidence_urls?: string[]; + dispute_receipt?: string; + }, + ): Promise> { + const hold = this.holds.get(escrowId); + if (!hold) { + return { + ok: false, + error: { code: "NOT_FOUND", message: `Escrow ${escrowId} not found` }, + }; + } + + if (hold.status !== "active") { + return { + ok: false, + error: { + code: "INVALID_STATE", + message: `Cannot dispute escrow in ${hold.status} state`, + }, + }; + } + + // Check dispute window + const createdAt = new Date(hold.created_at); + const windowEnd = new Date( + createdAt.getTime() + hold.dispute_window_hours * 3600_000, + ); + if (new Date() > windowEnd) { + return { + ok: false, + error: { + code: "DISPUTE_WINDOW_CLOSED", + message: `Dispute window closed at ${windowEnd.toISOString()}`, + }, + }; + } + + hold.status = "disputed"; + hold.dispute_receipt = dispute.dispute_receipt; + return { ok: true, data: hold }; + } + + /** Check for timed-out holds and transition them to expired. */ + expireTimedOutHolds(timeoutHours: number = 24): EscrowHold[] { + const expired: EscrowHold[] = []; + const cutoff = new Date(Date.now() - timeoutHours * 3600_000); + + for (const hold of this.holds.values()) { + if (hold.status === "active" && new Date(hold.created_at) < cutoff) { + hold.status = "expired"; + hold.resolved_at = new Date().toISOString(); + expired.push(hold); + } + } + + return expired; + } + + /** Get settlement status reconciliation for a hold. */ + getSettlementStatus(escrowId: string): { + escrow_id: string; + status: EscrowStatus; + amount: string; + currency: string; + settled: boolean; + resolution_type?: "released" | "refunded" | "expired" | "disputed"; + resolved_at?: string; + } | undefined { + const hold = this.holds.get(escrowId); + if (!hold) return undefined; + + return { + escrow_id: hold.escrow_id, + status: hold.status, + amount: hold.amount, + currency: hold.currency, + settled: hold.status === "released" || hold.status === "refunded", + resolution_type: + hold.status !== "active" ? (hold.status as any) : undefined, + resolved_at: hold.resolved_at, + }; + } + + /** Retrieve a hold by ID. */ + getHold(escrowId: string): EscrowHold | undefined { + return this.holds.get(escrowId); + } + + /** List all holds, optionally filtered by status. */ + listHolds(status?: EscrowStatus): EscrowHold[] { + const all = Array.from(this.holds.values()); + return status ? all.filter((h) => h.status === status) : all; + } +} + +function randomId(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} diff --git a/sardis-integration/src/payment/index.ts b/sardis-integration/src/payment/index.ts index a692680..963b3e4 100644 --- a/sardis-integration/src/payment/index.ts +++ b/sardis-integration/src/payment/index.ts @@ -1,4 +1,7 @@ -export { SardisWalletClient } from "./sardis-wallet.js"; +export { + SardisWalletClient, + verifySardisPaymentProofBinding, +} from "./sardis-wallet.js"; export { EscrowManager } from "./escrow.js"; export { Ledger } from "./ledger.js"; export type { @@ -7,6 +10,8 @@ export type { SpendingPolicy, SpendingMandate, MandateStatus, + SardisPaymentProof, + SardisProofBindingExpectation, EscrowHold, EscrowStatus, ReleaseCondition, diff --git a/sardis-integration/src/payment/ledger-service.ts b/sardis-integration/src/payment/ledger-service.ts new file mode 100644 index 0000000..1e8df00 --- /dev/null +++ b/sardis-integration/src/payment/ledger-service.ts @@ -0,0 +1,303 @@ +/** + * Ledger Service — links finance identifiers to OSP resource lifecycle + * and emits balanced double-entry ledger entries. + * + * Every payment action (hold, release, refund, charge) generates a pair + * of debit/credit entries that are balanced and auditable. + */ + +import type { + LedgerEntry, + LedgerEntryType, + LedgerReferenceType, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Ledger Transaction +// --------------------------------------------------------------------------- + +export interface LedgerTransaction { + transaction_id: string; + entries: LedgerEntry[]; + created_at: string; + metadata?: { + resource_id?: string; + provider_id?: string; + offering_id?: string; + escrow_id?: string; + mandate_id?: string; + charge_id?: string; + }; +} + +// --------------------------------------------------------------------------- +// Ledger Filter +// --------------------------------------------------------------------------- + +export interface LedgerFilter { + resource_id?: string; + provider_id?: string; + reference_type?: LedgerReferenceType; + from_date?: string; + to_date?: string; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export class LedgerService { + private transactions: LedgerTransaction[] = []; + private entryIndex = new Map(); + + /** + * Record an escrow hold — debit wallet, credit escrow. + */ + recordEscrowHold(params: { + wallet_id: string; + escrow_id: string; + amount: string; + currency: string; + resource_id: string; + provider_id?: string; + mandate_id?: string; + }): LedgerTransaction { + return this.createBalancedTransaction( + { + debit_account: params.wallet_id, + credit_account: `escrow:${params.escrow_id}`, + amount: params.amount, + currency: params.currency, + reference_type: "escrow_hold", + reference_id: params.escrow_id, + description: `Escrow hold for resource ${params.resource_id}`, + }, + { + resource_id: params.resource_id, + provider_id: params.provider_id, + escrow_id: params.escrow_id, + mandate_id: params.mandate_id, + }, + ); + } + + /** + * Record an escrow release — debit escrow, credit provider. + */ + recordEscrowRelease(params: { + escrow_id: string; + provider_id: string; + amount: string; + currency: string; + resource_id: string; + }): LedgerTransaction { + return this.createBalancedTransaction( + { + debit_account: `escrow:${params.escrow_id}`, + credit_account: `provider:${params.provider_id}`, + amount: params.amount, + currency: params.currency, + reference_type: "escrow_release", + reference_id: params.escrow_id, + description: `Release escrow to provider for resource ${params.resource_id}`, + }, + { + resource_id: params.resource_id, + provider_id: params.provider_id, + escrow_id: params.escrow_id, + }, + ); + } + + /** + * Record an escrow refund — debit escrow, credit wallet. + */ + recordEscrowRefund(params: { + wallet_id: string; + escrow_id: string; + amount: string; + currency: string; + resource_id: string; + }): LedgerTransaction { + return this.createBalancedTransaction( + { + debit_account: `escrow:${params.escrow_id}`, + credit_account: params.wallet_id, + amount: params.amount, + currency: params.currency, + reference_type: "escrow_refund", + reference_id: params.escrow_id, + description: `Refund escrow for resource ${params.resource_id}`, + }, + { + resource_id: params.resource_id, + escrow_id: params.escrow_id, + }, + ); + } + + /** + * Record a usage charge settlement — debit wallet, credit provider. + */ + recordChargeSettlement(params: { + wallet_id: string; + provider_id: string; + charge_id: string; + amount: string; + currency: string; + resource_id: string; + }): LedgerTransaction { + return this.createBalancedTransaction( + { + debit_account: params.wallet_id, + credit_account: `provider:${params.provider_id}`, + amount: params.amount, + currency: params.currency, + reference_type: "charge_settlement", + reference_id: params.charge_id, + description: `Usage charge for resource ${params.resource_id}`, + }, + { + resource_id: params.resource_id, + provider_id: params.provider_id, + charge_id: params.charge_id, + }, + ); + } + + /** + * Query ledger entries with filters by resource, provider, or date range. + */ + query(filter: LedgerFilter): LedgerEntry[] { + let entries = Array.from(this.entryIndex.values()); + + if (filter.resource_id) { + const txIds = new Set( + this.transactions + .filter((t) => t.metadata?.resource_id === filter.resource_id) + .map((t) => t.transaction_id), + ); + entries = entries.filter((e) => txIds.has(e.transaction_id)); + } + + if (filter.provider_id) { + const txIds = new Set( + this.transactions + .filter((t) => t.metadata?.provider_id === filter.provider_id) + .map((t) => t.transaction_id), + ); + entries = entries.filter((e) => txIds.has(e.transaction_id)); + } + + if (filter.reference_type) { + entries = entries.filter( + (e) => e.reference_type === filter.reference_type, + ); + } + + if (filter.from_date) { + entries = entries.filter((e) => e.created_at >= filter.from_date!); + } + + if (filter.to_date) { + entries = entries.filter((e) => e.created_at <= filter.to_date!); + } + + return entries.sort((a, b) => a.created_at.localeCompare(b.created_at)); + } + + /** + * Get all transactions for a specific resource. + */ + getResourceHistory(resourceId: string): LedgerTransaction[] { + return this.transactions.filter( + (t) => t.metadata?.resource_id === resourceId, + ); + } + + /** + * Verify that all transactions are balanced (debits = credits). + */ + verifyBalance(): { balanced: boolean; discrepancies: string[] } { + const discrepancies: string[] = []; + + for (const tx of this.transactions) { + let totalDebit = 0; + let totalCredit = 0; + for (const entry of tx.entries) { + if (entry.type === "debit") totalDebit += parseFloat(entry.amount); + if (entry.type === "credit") totalCredit += parseFloat(entry.amount); + } + if (Math.abs(totalDebit - totalCredit) > 0.001) { + discrepancies.push( + `Transaction ${tx.transaction_id}: debit=${totalDebit}, credit=${totalCredit}`, + ); + } + } + + return { balanced: discrepancies.length === 0, discrepancies }; + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + private createBalancedTransaction( + params: { + debit_account: string; + credit_account: string; + amount: string; + currency: string; + reference_type: LedgerReferenceType; + reference_id: string; + description: string; + }, + metadata: LedgerTransaction["metadata"], + ): LedgerTransaction { + const txId = `txn_${randomId()}`; + const now = new Date().toISOString(); + + const debit: LedgerEntry = { + entry_id: `ent_${randomId()}`, + transaction_id: txId, + account_id: params.debit_account, + type: "debit", + amount: params.amount, + currency: params.currency, + reference_type: params.reference_type, + reference_id: params.reference_id, + description: params.description, + created_at: now, + }; + + const credit: LedgerEntry = { + entry_id: `ent_${randomId()}`, + transaction_id: txId, + account_id: params.credit_account, + type: "credit", + amount: params.amount, + currency: params.currency, + reference_type: params.reference_type, + reference_id: params.reference_id, + description: params.description, + created_at: now, + }; + + const tx: LedgerTransaction = { + transaction_id: txId, + entries: [debit, credit], + created_at: now, + metadata, + }; + + this.transactions.push(tx); + this.entryIndex.set(debit.entry_id, debit); + this.entryIndex.set(credit.entry_id, credit); + + return tx; + } +} + +function randomId(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} diff --git a/sardis-integration/src/payment/mandate-service.ts b/sardis-integration/src/payment/mandate-service.ts new file mode 100644 index 0000000..a4e133c --- /dev/null +++ b/sardis-integration/src/payment/mandate-service.ts @@ -0,0 +1,260 @@ +/** + * Mandate Creation Service — high-level abstraction over SpendingMandate + * lifecycle for OSP paid provisioning. + * + * This service layer wraps SardisWalletClient mandate operations with: + * - Provider and offering scoping + * - Budget and trust failure mapping + * - Idempotent mandate creation + */ + +import type { + EscrowHold, + MandateStatus, + SardisError, + SardisResult, + SardisWallet, + SpendingMandate, + SpendingPolicy, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Mandate Creation Abstraction +// --------------------------------------------------------------------------- + +/** Parameters for creating a spending mandate. */ +export interface CreateMandateParams { + wallet_id: string; + provider_id: string; + offering_id: string; + tier_id: string; + amount: string; + currency: string; + ttl_hours?: number; + region?: string; + idempotency_key?: string; + metadata?: Record; +} + +/** Mandate creation result with enriched failure context. */ +export interface MandateCreationResult { + mandate?: SpendingMandate; + error?: MandateCreationError; +} + +export interface MandateCreationError { + code: MandateErrorCode; + message: string; + retryable: boolean; + details?: Record; +} + +export type MandateErrorCode = + | "insufficient_balance" + | "budget_exceeded" + | "provider_not_allowed" + | "category_not_allowed" + | "approval_required" + | "policy_violation" + | "duplicate_mandate" + | "wallet_not_found" + | "wallet_frozen" + | "internal_error"; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * MandateService encapsulates mandate lifecycle operations with structured + * error mapping for OSP integration. + */ +export class MandateService { + private mandates = new Map(); + private idempotencyIndex = new Map(); + + /** + * Create a spending mandate scoped to a specific provider and offering. + * + * Mandates are the atomic unit of spend authorization in Sardis. Each + * mandate authorizes exactly one provisioning action up to a maximum + * amount, with a hard expiry. + */ + async createMandate( + wallet: SardisWallet, + params: CreateMandateParams, + ): Promise { + // Idempotency check + if (params.idempotency_key) { + const existing = this.idempotencyIndex.get(params.idempotency_key); + if (existing) { + const mandate = this.mandates.get(existing); + if (mandate) { + return { mandate }; + } + } + } + + // Wallet state checks + if (parseFloat(wallet.balance) <= 0) { + return { + error: { + code: "insufficient_balance", + message: `Wallet ${wallet.wallet_id} has zero balance`, + retryable: false, + }, + }; + } + + // Budget check + if (parseFloat(params.amount) > parseFloat(wallet.balance)) { + return { + error: { + code: "budget_exceeded", + message: `Requested ${params.amount} ${params.currency} exceeds wallet balance ${wallet.balance} ${wallet.currency}`, + retryable: false, + details: { + requested: params.amount, + available: wallet.balance, + currency: params.currency, + }, + }, + }; + } + + // Policy evaluation + const policy = this.findMatchingPolicy(wallet, params); + if (!policy) { + return { + error: { + code: "policy_violation", + message: "No spending policy permits this transaction", + retryable: false, + details: { + provider_id: params.provider_id, + offering_id: params.offering_id, + amount: params.amount, + }, + }, + }; + } + + // Provider allowlist check + if ( + policy.allowed_providers.length > 0 && + !policy.allowed_providers.includes(params.provider_id) + ) { + return { + error: { + code: "provider_not_allowed", + message: `Provider ${params.provider_id} is not in the allowlist for policy ${policy.policy_id}`, + retryable: false, + }, + }; + } + + // Per-tx amount check + if (parseFloat(params.amount) > parseFloat(policy.max_amount_per_tx)) { + return { + error: { + code: "budget_exceeded", + message: `Amount ${params.amount} exceeds per-transaction limit ${policy.max_amount_per_tx}`, + retryable: false, + }, + }; + } + + // Approval gate + if (policy.requires_approval) { + return { + error: { + code: "approval_required", + message: "Transaction requires human approval", + retryable: false, + details: { policy_id: policy.policy_id }, + }, + }; + } + + // Create mandate + const ttlHours = params.ttl_hours ?? 1; + const mandate: SpendingMandate = { + mandate_id: `mnd_${randomId()}`, + wallet_id: wallet.wallet_id, + offering_id: params.offering_id, + tier_id: params.tier_id, + max_amount: params.amount, + currency: params.currency, + expires_at: new Date(Date.now() + ttlHours * 3600_000).toISOString(), + status: "active", + policy_id: policy.policy_id, + provider_id: params.provider_id, + region: params.region, + metadata: params.metadata, + created_at: new Date().toISOString(), + }; + + this.mandates.set(mandate.mandate_id, mandate); + if (params.idempotency_key) { + this.idempotencyIndex.set(params.idempotency_key, mandate.mandate_id); + } + + return { mandate }; + } + + /** Retrieve a mandate by ID. */ + getMandate(mandateId: string): SpendingMandate | undefined { + return this.mandates.get(mandateId); + } + + /** Revoke an active mandate. */ + revokeMandate(mandateId: string): MandateCreationResult { + const mandate = this.mandates.get(mandateId); + if (!mandate) { + return { + error: { + code: "wallet_not_found", + message: `Mandate ${mandateId} not found`, + retryable: false, + }, + }; + } + if (mandate.status !== "active") { + return { + error: { + code: "policy_violation", + message: `Cannot revoke mandate in ${mandate.status} state`, + retryable: false, + }, + }; + } + mandate.status = "revoked"; + return { mandate }; + } + + /** Mark mandate as consumed after successful provisioning. */ + consumeMandate(mandateId: string): void { + const mandate = this.mandates.get(mandateId); + if (mandate && mandate.status === "active") { + mandate.status = "consumed"; + } + } + + private findMatchingPolicy( + wallet: SardisWallet, + params: CreateMandateParams, + ): SpendingPolicy | undefined { + return wallet.spending_policies.find((p) => { + const amountOk = + parseFloat(params.amount) <= parseFloat(p.max_amount_per_tx); + const providerOk = + p.allowed_providers.length === 0 || + p.allowed_providers.includes(params.provider_id); + return amountOk && providerOk; + }); + } +} + +function randomId(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} diff --git a/sardis-integration/src/payment/persistence.ts b/sardis-integration/src/payment/persistence.ts new file mode 100644 index 0000000..f6e4178 --- /dev/null +++ b/sardis-integration/src/payment/persistence.ts @@ -0,0 +1,90 @@ +/** + * Pluggable Persistence Interfaces for sardis-integration. + * + * Production implementations should replace the in-memory stores + * with database-backed persistence. + */ + +import type { + SpendingMandate, + EscrowHold, + ChargeIntent, + LedgerEntry, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Persistence Interfaces +// --------------------------------------------------------------------------- + +export interface MandateStore { + save(mandate: SpendingMandate): Promise; + findById(mandateId: string): Promise; + findByWallet(walletId: string): Promise; + updateStatus(mandateId: string, status: SpendingMandate["status"]): Promise; +} + +export interface EscrowStore { + save(hold: EscrowHold): Promise; + findById(escrowId: string): Promise; + findByResource(resourceId: string): Promise; + findByStatus(status: EscrowHold["status"]): Promise; + update(hold: EscrowHold): Promise; +} + +export interface ChargeStore { + save(charge: ChargeIntent): Promise; + findById(chargeId: string): Promise; + findByResource(resourceId: string): Promise; + findPending(): Promise; + updateStatus(chargeId: string, status: ChargeIntent["status"]): Promise; +} + +export interface LedgerStore { + append(entry: LedgerEntry): Promise; + findByTransaction(txId: string): Promise; + findByAccount(accountId: string): Promise; + findByReference(refType: string, refId: string): Promise; + query(filter: { from?: string; to?: string; limit?: number }): Promise; +} + +// --------------------------------------------------------------------------- +// In-Memory Implementations (for testing and development) +// --------------------------------------------------------------------------- + +export class InMemoryMandateStore implements MandateStore { + private store = new Map(); + + async save(mandate: SpendingMandate): Promise { + this.store.set(mandate.mandate_id, mandate); + } + async findById(mandateId: string): Promise { + return this.store.get(mandateId) ?? null; + } + async findByWallet(walletId: string): Promise { + return [...this.store.values()].filter((m) => m.wallet_id === walletId); + } + async updateStatus(mandateId: string, status: SpendingMandate["status"]): Promise { + const m = this.store.get(mandateId); + if (m) m.status = status; + } +} + +export class InMemoryEscrowStore implements EscrowStore { + private store = new Map(); + + async save(hold: EscrowHold): Promise { + this.store.set(hold.escrow_id, hold); + } + async findById(escrowId: string): Promise { + return this.store.get(escrowId) ?? null; + } + async findByResource(resourceId: string): Promise { + return [...this.store.values()].filter((h) => h.resource_id === resourceId); + } + async findByStatus(status: EscrowHold["status"]): Promise { + return [...this.store.values()].filter((h) => h.status === status); + } + async update(hold: EscrowHold): Promise { + this.store.set(hold.escrow_id, hold); + } +} diff --git a/sardis-integration/src/payment/reconciliation.ts b/sardis-integration/src/payment/reconciliation.ts new file mode 100644 index 0000000..878b4d7 --- /dev/null +++ b/sardis-integration/src/payment/reconciliation.ts @@ -0,0 +1,116 @@ +/** + * Reconciliation Workers — detect and alert on payment/resource drift. + */ + +import type { EscrowHold, ChargeIntent, SardisResult } from "./types.js"; + +// --------------------------------------------------------------------------- +// Scanner Types +// --------------------------------------------------------------------------- + +export interface ReconciliationAlert { + alert_id: string; + type: ReconciliationAlertType; + severity: "warning" | "critical"; + message: string; + details: Record; + detected_at: string; +} + +export type ReconciliationAlertType = + | "paid_without_resource" + | "unsettled_hold" + | "resource_without_payment" + | "stale_hold" + | "charge_settlement_failed"; + +// --------------------------------------------------------------------------- +// Scanners +// --------------------------------------------------------------------------- + +/** + * Scan for paid-but-unprovisioned requests. + * + * Detects mandates consumed but no corresponding active resource. + */ +export function scanPaidWithoutResource( + holds: EscrowHold[], + activeResourceIds: Set, +): ReconciliationAlert[] { + const alerts: ReconciliationAlert[] = []; + + for (const hold of holds) { + if ( + (hold.status === "active" || hold.status === "released") && + !activeResourceIds.has(hold.resource_id) + ) { + alerts.push({ + alert_id: `alert_${randomId()}`, + type: "paid_without_resource", + severity: "critical", + message: `Escrow ${hold.escrow_id} has status ${hold.status} but resource ${hold.resource_id} is not active`, + details: { + escrow_id: hold.escrow_id, + resource_id: hold.resource_id, + amount: hold.amount, + currency: hold.currency, + }, + detected_at: new Date().toISOString(), + }); + } + } + + return alerts; +} + +/** + * Scan for provisioned-but-unsettled holds. + * + * Detects active escrow holds that should have been released. + */ +export function scanUnsettledHolds( + holds: EscrowHold[], + maxAgeHours: number = 24, +): ReconciliationAlert[] { + const alerts: ReconciliationAlert[] = []; + const cutoff = new Date(Date.now() - maxAgeHours * 3600_000); + + for (const hold of holds) { + if (hold.status === "active" && new Date(hold.created_at) < cutoff) { + alerts.push({ + alert_id: `alert_${randomId()}`, + type: "unsettled_hold", + severity: "warning", + message: `Escrow ${hold.escrow_id} has been active for over ${maxAgeHours}h without settlement`, + details: { + escrow_id: hold.escrow_id, + resource_id: hold.resource_id, + created_at: hold.created_at, + amount: hold.amount, + }, + detected_at: new Date().toISOString(), + }); + } + } + + return alerts; +} + +/** + * Run full reconciliation scan and return all alerts. + */ +export function runReconciliation(params: { + holds: EscrowHold[]; + charges: ChargeIntent[]; + activeResourceIds: Set; + maxHoldAgeHours?: number; +}): ReconciliationAlert[] { + return [ + ...scanPaidWithoutResource(params.holds, params.activeResourceIds), + ...scanUnsettledHolds(params.holds, params.maxHoldAgeHours), + ]; +} + +function randomId(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} diff --git a/sardis-integration/src/payment/sardis-wallet.ts b/sardis-integration/src/payment/sardis-wallet.ts index f7121eb..f246518 100644 --- a/sardis-integration/src/payment/sardis-wallet.ts +++ b/sardis-integration/src/payment/sardis-wallet.ts @@ -18,6 +18,8 @@ import type { MandateStatus, ReleaseCondition, SardisError, + SardisProofBindingExpectation, + SardisPaymentProof, SardisResult, SardisWallet, SpendingMandate, @@ -34,7 +36,7 @@ interface OSPProvisionRequest { project_name: string; region?: string; payment_method: string; - payment_proof?: Record; + payment_proof?: SardisPaymentProof; nonce: string; [key: string]: unknown; } @@ -70,6 +72,43 @@ interface OSPServiceTier { }; } +export function verifySardisPaymentProofBinding( + proof: SardisPaymentProof, + expected: SardisProofBindingExpectation, +): SardisResult { + const mismatches: string[] = []; + const checks: Array<[keyof SardisProofBindingExpectation, string | undefined]> = [ + ["wallet_address", proof.wallet_address], + ["payment_tx", proof.payment_tx], + ["provider_id", proof.provider_id], + ["offering_id", proof.offering_id], + ["tier_id", proof.tier_id], + ["amount", proof.amount], + ["currency", proof.currency], + ["nonce", proof.nonce], + ["region", proof.region], + ]; + + for (const [key, actualValue] of checks) { + const expectedValue = expected[key]; + if (expectedValue !== undefined && actualValue !== expectedValue) { + mismatches.push(`${key}: expected ${expectedValue}, received ${actualValue ?? "undefined"}`); + } + } + + if (mismatches.length > 0) { + return { + ok: false, + error: { + code: "PROOF_BINDING_MISMATCH", + message: mismatches.join("; "), + }, + }; + } + + return { ok: true, data: proof }; +} + // --------------------------------------------------------------------------- // SardisWalletClient // --------------------------------------------------------------------------- @@ -241,8 +280,22 @@ export class SardisWalletClient { region: params.region ?? mandate.region, payment_method: "sardis_wallet", payment_proof: { + version: "sardis-proof-v1", wallet_address: mandate.wallet_id, payment_tx: mandate.mandate_id, + offering_id: mandate.offering_id, + tier_id: mandate.tier_id, + amount: mandate.max_amount, + currency: mandate.currency, + nonce: params.nonce, + expires_at: mandate.expires_at, + provider_id: mandate.provider_id, + region: params.region ?? mandate.region, + signature_material: buildSignatureMaterial( + mandate, + params.nonce, + params.region ?? mandate.region, + ), }, nonce: params.nonce, ...(params.configuration && { configuration: params.configuration }), @@ -458,3 +511,23 @@ function generateId(): string { } return id; } + +function buildSignatureMaterial( + mandate: SpendingMandate, + nonce: string, + region?: string, +): string { + return [ + "sardis-proof-v1", + mandate.wallet_id, + mandate.mandate_id, + mandate.offering_id, + mandate.tier_id, + mandate.max_amount, + mandate.currency, + nonce, + mandate.expires_at, + mandate.provider_id ?? "", + region ?? mandate.region ?? "", + ].join(":"); +} diff --git a/sardis-integration/src/payment/types.ts b/sardis-integration/src/payment/types.ts index 5a3aaa4..153c1e5 100644 --- a/sardis-integration/src/payment/types.ts +++ b/sardis-integration/src/payment/types.ts @@ -112,6 +112,56 @@ export type MandateStatus = | "expired" | "revoked"; +/** + * Sardis proof envelope embedded into OSP `payment_proof`. + * + * The envelope binds the wallet, mandate, commercial terms, and request nonce + * into a stable payload that providers can verify or countersign. + */ +export interface SardisPaymentProof { + /** Sardis proof envelope format version. */ + version: "sardis-proof-v1"; + /** Wallet funding the provisioning action. */ + wallet_address: string; + /** Mandate or transaction identifier authorizing the spend. */ + payment_tx: string; + /** OSP offering covered by the proof. */ + offering_id: string; + /** OSP tier covered by the proof. */ + tier_id: string; + /** Authorized amount for the operation. */ + amount: string; + /** ISO 4217 currency code for the authorization. */ + currency: string; + /** Request nonce bound into the proof. */ + nonce: string; + /** RFC 3339 timestamp when the proof expires. */ + expires_at: string; + /** Optional provider binding when the mandate was scoped. */ + provider_id?: string; + /** Optional region binding when the mandate was scoped. */ + region?: string; + /** + * Canonical material that a wallet or provider can sign to attest the proof. + * This is deliberately explicit so multiple implementations can generate the + * same verification payload without hidden conventions. + */ + signature_material: string; +} + +/** Expected commercial context when validating a Sardis payment proof. */ +export interface SardisProofBindingExpectation { + wallet_address?: string; + payment_tx?: string; + provider_id?: string; + offering_id: string; + tier_id: string; + amount: string; + currency: string; + nonce?: string; + region?: string; +} + // --------------------------------------------------------------------------- // Escrow // --------------------------------------------------------------------------- diff --git a/sardis-integration/src/payment/verification-sdk.ts b/sardis-integration/src/payment/verification-sdk.ts new file mode 100644 index 0000000..9465a6c --- /dev/null +++ b/sardis-integration/src/payment/verification-sdk.ts @@ -0,0 +1,132 @@ +/** + * Provider Verification SDK — helpers for providers to verify Sardis + * payment proofs and handle settlement callbacks. + */ + +import type { SardisPaymentProof, SardisProofBindingExpectation } from "./types.js"; + +// --------------------------------------------------------------------------- +// TypeScript Proof Verification Helper +// --------------------------------------------------------------------------- + +export interface VerificationResult { + valid: boolean; + errors: string[]; +} + +/** + * Verify a Sardis payment proof against expected bindings. + * + * Providers call this to validate proof before allocating resources. + */ +export function verifySardisProof( + proof: SardisPaymentProof, + expected: SardisProofBindingExpectation, +): VerificationResult { + const errors: string[] = []; + + // Version check + if (proof.version !== "sardis-proof-v1") { + errors.push(`Unsupported proof version: ${proof.version}`); + } + + // Binding checks + if (expected.offering_id && proof.offering_id !== expected.offering_id) { + errors.push(`offering_id mismatch: expected ${expected.offering_id}, got ${proof.offering_id}`); + } + if (expected.tier_id && proof.tier_id !== expected.tier_id) { + errors.push(`tier_id mismatch: expected ${expected.tier_id}, got ${proof.tier_id}`); + } + if (expected.amount && proof.amount !== expected.amount) { + errors.push(`amount mismatch: expected ${expected.amount}, got ${proof.amount}`); + } + if (expected.currency && proof.currency !== expected.currency) { + errors.push(`currency mismatch: expected ${expected.currency}, got ${proof.currency}`); + } + if (expected.provider_id && proof.provider_id !== expected.provider_id) { + errors.push(`provider_id mismatch`); + } + if (expected.nonce && proof.nonce !== expected.nonce) { + errors.push(`nonce mismatch`); + } + + // Expiry check + if (proof.expires_at && new Date(proof.expires_at) < new Date()) { + errors.push(`Proof expired at ${proof.expires_at}`); + } + + // Signature material must be present + if (!proof.signature_material) { + errors.push("Missing signature_material"); + } + + return { valid: errors.length === 0, errors }; +} + +// --------------------------------------------------------------------------- +// Webhook Signature Validation +// --------------------------------------------------------------------------- + +export interface WebhookEvent { + event_id: string; + event_type: "escrow.released" | "escrow.refunded" | "escrow.disputed" | "escrow.expired" | "charge.settled" | "charge.failed"; + payload: Record; + signature: string; + timestamp: string; +} + +/** + * Validate a Sardis webhook signature. + * + * Providers should call this on every incoming webhook to verify + * the event was sent by Sardis. + */ +export function validateWebhookSignature( + event: WebhookEvent, + secret: string, +): boolean { + // In production, this would verify HMAC-SHA256 signature + // For reference implementation, we validate structure + if (!event.event_id || !event.event_type || !event.signature || !event.timestamp) { + return false; + } + // Check timestamp freshness (5 minute window) + const eventTime = new Date(event.timestamp).getTime(); + const now = Date.now(); + if (Math.abs(now - eventTime) > 5 * 60 * 1000) { + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Settlement Callback +// --------------------------------------------------------------------------- + +export interface SettlementCallback { + escrow_id: string; + resource_id: string; + status: "confirmed" | "failed"; + settlement_reference?: string; + timestamp: string; +} + +/** + * Build a settlement confirmation callback for escrow release. + * + * Providers call this after successfully provisioning a resource + * to trigger escrow fund release. + */ +export function buildSettlementCallback(params: { + escrow_id: string; + resource_id: string; + settlement_reference?: string; +}): SettlementCallback { + return { + escrow_id: params.escrow_id, + resource_id: params.resource_id, + status: "confirmed", + settlement_reference: params.settlement_reference, + timestamp: new Date().toISOString(), + }; +} diff --git a/sardis-integration/tests/payment/sardis-wallet.test.ts b/sardis-integration/tests/payment/sardis-wallet.test.ts index 70ef88f..1e4aa2f 100644 --- a/sardis-integration/tests/payment/sardis-wallet.test.ts +++ b/sardis-integration/tests/payment/sardis-wallet.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { SardisWalletClient } from "../../src/payment/sardis-wallet.js"; +import { + SardisWalletClient, + verifySardisPaymentProofBinding, +} from "../../src/payment/sardis-wallet.js"; import type { SardisWallet } from "../../src/payment/types.js"; // --------------------------------------------------------------------------- @@ -187,9 +190,57 @@ describe("SardisWalletClient", () => { expect(request.tier_id).toBe("pro"); expect(request.project_name).toBe("my-app-db"); expect(request.payment_method).toBe("sardis_wallet"); + expect(request.payment_proof).toMatchObject({ + version: "sardis-proof-v1", + wallet_address: "wal_test123", + payment_tx: mandate.mandate_id, + offering_id: "supabase/managed-postgres", + tier_id: "pro", + amount: "25.00", + currency: "USD", + nonce: "test-nonce-123", + expires_at: mandate.expires_at, + }); + expect(request.payment_proof?.signature_material).toBe( + [ + "sardis-proof-v1", + "wal_test123", + mandate.mandate_id, + "supabase/managed-postgres", + "pro", + "25.00", + "USD", + "test-nonce-123", + mandate.expires_at, + "", + "us-east-1", + ].join(":"), + ); expect(request.payment_proof).toEqual({ + version: "sardis-proof-v1", wallet_address: "wal_test123", payment_tx: mandate.mandate_id, + offering_id: "supabase/managed-postgres", + tier_id: "pro", + amount: "25.00", + currency: "USD", + nonce: "test-nonce-123", + expires_at: mandate.expires_at, + provider_id: undefined, + region: "us-east-1", + signature_material: [ + "sardis-proof-v1", + "wal_test123", + mandate.mandate_id, + "supabase/managed-postgres", + "pro", + "25.00", + "USD", + "test-nonce-123", + mandate.expires_at, + "", + "us-east-1", + ].join(":"), }); expect(request.nonce).toBe("test-nonce-123"); expect(request.region).toBe("us-east-1"); @@ -232,6 +283,70 @@ describe("SardisWalletClient", () => { expect(requestResult.ok).toBe(false); expect(requestResult.error?.code).toBe("MANDATE_NOT_ACTIVE"); }); + + it("should validate proof bindings against the expected context", async () => { + const mandateResult = await client.createMandate({ + offering_id: "supabase/managed-postgres", + tier_id: "pro", + tier: proTier, + provider_id: "supabase", + region: "us-east-1", + }); + const mandate = mandateResult.data!; + + const requestResult = client.toProvisionRequest(mandate, { + project_name: "my-app-db", + nonce: "test-nonce-123", + }); + + expect(requestResult.ok).toBe(true); + const verification = verifySardisPaymentProofBinding( + requestResult.data!.payment_proof!, + { + wallet_address: "wal_test123", + payment_tx: mandate.mandate_id, + provider_id: "supabase", + offering_id: "supabase/managed-postgres", + tier_id: "pro", + amount: "25.00", + currency: "USD", + nonce: "test-nonce-123", + region: "us-east-1", + }, + ); + + expect(verification.ok).toBe(true); + }); + + it("should reject proof reuse across mismatched contexts", async () => { + const mandateResult = await client.createMandate({ + offering_id: "supabase/managed-postgres", + tier_id: "pro", + tier: proTier, + }); + const mandate = mandateResult.data!; + + const requestResult = client.toProvisionRequest(mandate, { + project_name: "my-app-db", + nonce: "test-nonce-123", + }); + + expect(requestResult.ok).toBe(true); + const verification = verifySardisPaymentProofBinding( + requestResult.data!.payment_proof!, + { + offering_id: "supabase/managed-postgres", + tier_id: "enterprise", + amount: "99.00", + currency: "USD", + }, + ); + + expect(verification.ok).toBe(false); + expect(verification.error?.code).toBe("PROOF_BINDING_MISMATCH"); + expect(verification.error?.message).toContain("tier_id"); + expect(verification.error?.message).toContain("amount"); + }); }); describe("createEscrowHold", () => { diff --git a/schemas/error-response.schema.json b/schemas/error-response.schema.json index e6ab777..41c0ffa 100644 --- a/schemas/error-response.schema.json +++ b/schemas/error-response.schema.json @@ -2,58 +2,70 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://osp.dev/schemas/error-response.schema.json", "title": "ErrorResponse", - "description": "Standard error response returned by OSP provider endpoints when a request fails", + "description": "Standard nested error response returned by OSP provider endpoints when a request fails", "type": "object", - "required": ["error", "message"], + "required": ["error"], "properties": { "error": { - "type": "string", - "description": "Machine-readable error code from the OSP error taxonomy (e.g., 'invalid_request', 'rate_limit_exceeded', 'provider_error')", - "enum": [ - "invalid_request", - "invalid_offering", - "invalid_tier", - "invalid_configuration", - "invalid_region", - "sandbox_not_available", - "tier_change_not_allowed", - "region_unavailable", - "deprecated_offering", - "identity_required", - "credentials_rotated", - "nonce_reused", - "identity_verification_failed", - "insufficient_trust", - "payment_required", - "payment_declined", - "insufficient_funds", - "resource_not_found", - "resource_already_exists", - "migration_in_progress", - "rate_limit_exceeded", - "quota_exceeded", - "provisioning_failed", - "provider_error", - "capacity_exhausted", - "provider_unavailable" - ] - }, - "message": { - "type": "string", - "description": "Human-readable error message providing additional context about the failure" - }, - "resource_id": { - "type": ["string", "null"], - "description": "The resource ID associated with the error, if applicable" - }, - "retry_after_seconds": { - "type": ["integer", "null"], - "minimum": 0, - "description": "Number of seconds the agent should wait before retrying. Present for rate_limit_exceeded, provider_unavailable, and other retryable errors" - }, - "details": { - "type": ["object", "null"], - "description": "Additional error context with provider-specific details (e.g., invalid field names, validation errors, constraint violations)" + "type": "object", + "required": ["code", "message", "retryable"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code from the OSP error taxonomy", + "enum": [ + "invalid_request", + "invalid_offering", + "invalid_tier", + "invalid_configuration", + "invalid_region", + "sandbox_not_available", + "tier_change_not_allowed", + "region_unavailable", + "deprecated_offering", + "identity_required", + "credentials_rotated", + "nonce_reused", + "identity_verification_failed", + "insufficient_trust", + "trust_tier_insufficient", + "payment_required", + "payment_declined", + "payment_failed", + "insufficient_funds", + "budget_exceeded", + "approval_required", + "resource_not_found", + "resource_already_exists", + "migration_in_progress", + "rate_limit_exceeded", + "quota_exceeded", + "provisioning_failed", + "provider_error", + "capacity_exhausted", + "provider_unavailable" + ] + }, + "message": { + "type": "string", + "description": "Human-readable error message providing additional context about the failure" + }, + "details": { + "type": "object", + "description": "Additional error context with provider-specific details such as invalid fields, payment metadata, or approval gate information", + "additionalProperties": true + }, + "retryable": { + "type": "boolean", + "description": "Whether the agent SHOULD retry the request" + }, + "retry_after_seconds": { + "type": "integer", + "minimum": 0, + "description": "Number of seconds the agent should wait before retrying. Present for rate_limit_exceeded, provider_unavailable, payment processor failures, and other retryable errors" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/schemas/examples/provision-request-paid-stripe.json b/schemas/examples/provision-request-paid-stripe.json new file mode 100644 index 0000000..8dae4b0 --- /dev/null +++ b/schemas/examples/provision-request-paid-stripe.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://osp.dev/schemas/provision-request.schema.json", + "offering_id": "vercel/hosting", + "tier_id": "pro", + "project_name": "marketing-site", + "region": "us-east-1", + "payment_method": "stripe_spt", + "payment_proof": { + "spt_token": "spt_live_01J8KR3M3B4NZ2H0YQ9S5M7Q2C", + "amount": "20.00", + "currency": "USD" + }, + "nonce": "stripe_paid_nonce_1711123999000_randomized", + "config": { + "framework": "nextjs", + "production_branch": "main" + } +} diff --git a/schemas/examples/provision-request-paid-x402.json b/schemas/examples/provision-request-paid-x402.json new file mode 100644 index 0000000..353e0f1 --- /dev/null +++ b/schemas/examples/provision-request-paid-x402.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://osp.dev/schemas/provision-request.schema.json", + "offering_id": "cloudflare/workers-ai", + "tier_id": "usage-based", + "project_name": "batch-inference", + "region": "global", + "payment_method": "x402", + "payment_proof": { + "payment_header": "x402 pay token=\"tok_01J8KR6EFQ7P3Y5TFG7P1A9WQ4\"", + "receipt": "rcpt_01J8KR6T4KZQJQW8D6A1J6T7GH" + }, + "nonce": "x402_paid_nonce_1711123999000_randomized", + "config": { + "model": "@cf/meta/llama-3.1-8b-instruct", + "max_concurrency": 4 + } +} diff --git a/schemas/examples/provision-request-paid.json b/schemas/examples/provision-request-paid.json index affeae3..611d62b 100644 --- a/schemas/examples/provision-request-paid.json +++ b/schemas/examples/provision-request-paid.json @@ -5,7 +5,11 @@ "project_name": "production-analytics-db", "region": "eu-central-1", "payment_method": "sardis_wallet", - "payment_proof": "sardis_tx_01J8KQZP5R4XMWN7VBCG3HT9YD_escrow_69.00_USD_neon.tech_2026-03-27T14:30:00Z", + "payment_proof": { + "wallet_address": "sardis:wal_prod_analytics_001", + "payment_tx": "mnd_01J8KQZP5R4XMWN7VBCG3HT9YD", + "escrow_id": "esc_01J8KQZQ4JQ6R23FKN1XQX9V8B" + }, "agent_public_key": "cHJvZHVjdGlvbl9hZ2VudF9lZDI1NTE5X3B1YmxpY19rZXlfYmFzZTY0", "nonce": "cGFpZF9ub25jZV8xNzExMTIzOTk5MDAwX3NlY3VyZV9yYW5kb20", "config": { diff --git a/schemas/examples/provision-response-async.json b/schemas/examples/provision-response-async.json index 3fe1d28..608ea86 100644 --- a/schemas/examples/provision-response-async.json +++ b/schemas/examples/provision-response-async.json @@ -5,6 +5,7 @@ "tier_id": "scale", "status": "provisioning", "resource_id": "res_neon_proj_ep_cool_darkness_789012", + "escrow_id": "esc_01J8KR9X7V4NWP3H2F1C8D6QBM", "credentials": null, "fulfillment_proof": null, "status_url": "https://neon.tech/osp/v1/resources/res_neon_proj_ep_cool_darkness_789012/status", diff --git a/schemas/examples/provision-response-paid-escrow.json b/schemas/examples/provision-response-paid-escrow.json new file mode 100644 index 0000000..1eda454 --- /dev/null +++ b/schemas/examples/provision-response-paid-escrow.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://osp.dev/schemas/provision-response.schema.json", + "request_id": "req_01J8KS0Z2N4K9N8P5W3Q7V1T6Y", + "offering_id": "neon/postgres", + "tier_id": "scale", + "status": "provisioning", + "resource_id": "res_01J8KS12A6QQ1V7D3P5M8C2R4F", + "escrow_id": "esc_01J8KS13B3ME0W4Q5F7Y9A2R1K", + "poll_url": "https://api.neon.tech/osp/v1/resources/res_01J8KS12A6QQ1V7D3P5M8C2R4F/status", + "estimated_ready_seconds": 60, + "created_at": "2026-03-31T18:15:00Z", + "cost_estimate": { + "monthly_estimate": "25.00", + "currency": "USD" + } +} diff --git a/schemas/provision-request.schema.json b/schemas/provision-request.schema.json index 9ad9df8..49cadb9 100644 --- a/schemas/provision-request.schema.json +++ b/schemas/provision-request.schema.json @@ -31,12 +31,24 @@ }, "payment_method": { "type": "string", - "enum": ["free", "sardis_wallet", "stripe_spt", "x402", "mpp", "invoice", "external"], - "description": "Payment method to use for this provisioning request" + "anyOf": [ + { + "enum": ["free", "sardis_wallet", "stripe_spt", "x402", "mpp", "invoice", "external"] + }, + { + "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$" + } + ], + "description": "Payment method to use for this provisioning request. Custom methods MUST use reverse-domain notation." }, "payment_proof": { - "type": "string", - "description": "Payment proof (token, transaction hash, escrow ID, etc.) — format depends on payment_method" + "oneOf": [ + { "$ref": "#/$defs/SardisWalletPaymentProof" }, + { "$ref": "#/$defs/StripeSPTPaymentProof" }, + { "$ref": "#/$defs/X402PaymentProof" }, + { "$ref": "#/$defs/GenericPaymentProof" } + ], + "description": "Payment proof envelope. Shape depends on payment_method and provider-specific rail requirements." }, "agent_public_key": { "type": "string", @@ -87,7 +99,137 @@ "description": "Client-generated key for request deduplication within 24-hour window" } }, + "allOf": [ + { + "if": { + "properties": { + "payment_method": { "const": "free" } + }, + "required": ["payment_method"] + }, + "then": { + "not": { + "required": ["payment_proof"] + } + } + }, + { + "if": { + "properties": { + "payment_method": { "const": "sardis_wallet" } + }, + "required": ["payment_method"] + }, + "then": { + "required": ["payment_proof"], + "properties": { + "payment_proof": { "$ref": "#/$defs/SardisWalletPaymentProof" } + } + } + }, + { + "if": { + "properties": { + "payment_method": { "const": "stripe_spt" } + }, + "required": ["payment_method"] + }, + "then": { + "required": ["payment_proof"], + "properties": { + "payment_proof": { "$ref": "#/$defs/StripeSPTPaymentProof" } + } + } + }, + { + "if": { + "properties": { + "payment_method": { "const": "x402" } + }, + "required": ["payment_method"] + }, + "then": { + "required": ["payment_proof"], + "properties": { + "payment_proof": { "$ref": "#/$defs/X402PaymentProof" } + } + } + }, + { + "if": { + "properties": { + "payment_method": { + "not": { "const": "free" } + } + }, + "required": ["payment_method"] + }, + "then": { + "required": ["payment_proof"] + } + } + ], "$defs": { + "SardisWalletPaymentProof": { + "type": "object", + "required": ["wallet_address", "payment_tx"], + "properties": { + "wallet_address": { + "type": "string", + "description": "Sardis wallet address or wallet identifier funding the request" + }, + "payment_tx": { + "type": "string", + "description": "Sardis payment transaction or mandate identifier" + }, + "escrow_id": { + "type": "string", + "description": "Escrow identifier when the payment is escrow-backed" + } + }, + "additionalProperties": false + }, + "StripeSPTPaymentProof": { + "type": "object", + "required": ["spt_token", "amount", "currency"], + "properties": { + "spt_token": { + "type": "string", + "description": "Stripe Shared Payment Token authorizing this provisioning request" + }, + "amount": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]{1,2})?$", + "description": "Authorized amount represented by the Stripe token" + }, + "currency": { + "type": "string", + "description": "Currency for the Stripe token authorization" + } + }, + "additionalProperties": false + }, + "X402PaymentProof": { + "type": "object", + "required": ["payment_header", "receipt"], + "properties": { + "payment_header": { + "type": "string", + "description": "Serialized x402 payment header" + }, + "receipt": { + "type": "string", + "description": "x402 payment receipt or settlement proof" + } + }, + "additionalProperties": false + }, + "GenericPaymentProof": { + "type": "object", + "minProperties": 1, + "description": "Fallback object for provider-defined or custom payment rails", + "additionalProperties": true + }, "AgentIdentity": { "type": "object", "required": ["method", "credential"], diff --git a/schemas/provision-response.schema.json b/schemas/provision-response.schema.json index 22c1a92..bc62b8d 100644 --- a/schemas/provision-response.schema.json +++ b/schemas/provision-response.schema.json @@ -28,6 +28,10 @@ "type": "string", "description": "Provider-assigned resource identifier, available once provisioning completes" }, + "escrow_id": { + "type": "string", + "description": "Escrow identifier for paid tiers that use escrow-backed settlement" + }, "credentials": { "oneOf": [ { "$ref": "#/$defs/CredentialBundle" }, @@ -47,6 +51,11 @@ "format": "uri", "description": "URL to poll for provisioning status (for async provisioning)" }, + "poll_url": { + "type": "string", + "format": "uri", + "description": "Canonical polling URL for async provisioning status. Providers MAY temporarily mirror status_url during migration." + }, "estimated_ready_seconds": { "type": "integer", "description": "Estimated seconds until provisioning completes (for async provisioning)" @@ -99,6 +108,23 @@ "description": "Whether this is a sandbox resource. Sandbox resources are free, time-limited, and auto-deprovision after TTL (Section 5.9)" } }, + "allOf": [ + { + "if": { + "properties": { + "status": { "const": "provisioning" } + }, + "required": ["status"] + }, + "then": { + "required": ["resource_id", "estimated_ready_seconds"], + "anyOf": [ + { "required": ["status_url"] }, + { "required": ["poll_url"] } + ] + } + } + ], "$defs": { "CredentialBundle": { "type": "object", diff --git a/schemas/service-manifest.schema.json b/schemas/service-manifest.schema.json index f6ad5e8..f4f6c2f 100644 --- a/schemas/service-manifest.schema.json +++ b/schemas/service-manifest.schema.json @@ -276,24 +276,29 @@ }, "EscrowProfile": { "type": "object", - "description": "Escrow timing parameters governing payment hold and release", + "required": ["required"], + "description": "Escrow configuration for payment protection on paid tiers", "properties": { - "timeout_seconds": { - "type": "integer", - "default": 3600, - "description": "Maximum time in seconds before escrowed funds are returned if provisioning fails" + "required": { + "type": "boolean", + "description": "Whether escrow is required for this tier" }, - "verification_window_seconds": { - "type": "integer", - "default": 900, - "description": "Time in seconds the agent has to verify provisioning before auto-release" + "provider": { + "type": "string", + "description": "Escrow provider identifier (e.g., 'sardis', 'custom')" }, - "dispute_window_seconds": { + "release_condition": { + "type": "string", + "enum": ["provision_success", "uptime_24h", "uptime_7d", "manual"], + "description": "Condition for releasing escrowed funds" + }, + "dispute_window_hours": { "type": "integer", - "default": 86400, - "description": "Time in seconds after verification during which disputes can be raised" + "default": 72, + "description": "Number of hours after provisioning during which disputes can be raised" } - } + }, + "additionalProperties": false }, "UsageMetering": { "type": "object", diff --git a/spec/osp-v1.0.md b/spec/osp-v1.0.md index 48a852e..c240b2d 100644 --- a/spec/osp-v1.0.md +++ b/spec/osp-v1.0.md @@ -774,6 +774,7 @@ The object a provider returns after processing a provision request. | `tier_id` | `string` | REQUIRED | The tier that was provisioned. | | `status` | `string` | REQUIRED | Current provisioning status. One of: `provisioned`, `provisioning`, `failed`. | | `credentials_bundle` | `CredentialBundle` | OPTIONAL | Credentials for the resource. REQUIRED when `status` is `provisioned`. MUST be absent when `status` is `provisioning` or `failed`. | +| `escrow_id` | `string` | OPTIONAL | Escrow identifier for paid tiers that use escrow-backed settlement. | | `estimated_ready_seconds` | `integer` | OPTIONAL | Estimated seconds until the resource is ready. REQUIRED when `status` is `provisioning`. | | `poll_url` | `string` | OPTIONAL | URL to poll for status updates. REQUIRED when `status` is `provisioning`. | | `webhook_supported` | `boolean` | OPTIONAL | Whether the provider will send webhook notifications. Default: `false`. | @@ -803,12 +804,15 @@ The object a provider returns after processing a provision request. | `invalid_configuration` | The configuration object does not match the offering's `configuration_schema`. | | `payment_required` | Payment proof is missing or invalid. | | `payment_declined` | The payment method was declined. | +| `payment_failed` | Payment processing failed due to a transient processor or network error. | | `insufficient_funds` | The payment method has insufficient funds. | +| `approval_required` | The request is valid but paused pending human approval or policy review. | +| `budget_exceeded` | The request would exceed a configured budget guardrail. | | `trust_tier_insufficient` | The agent's trust tier does not meet the minimum requirement. | | `quota_exceeded` | The principal has exceeded their quota for this offering. | | `region_unavailable` | The requested region is temporarily unavailable. | | `nonce_reused` | The nonce has already been used. | -| `rate_limited` | Too many requests. Check `retry_after_seconds`. | +| `rate_limit_exceeded` | Too many requests. Check `retry_after_seconds`. | | `provider_error` | An internal provider error occurred. | | `capacity_exhausted` | The provider has no available capacity. | | `identity_verification_failed` | Agent identity verification failed (HTTP 403). The provided identity proof is invalid, expired, or uses an unsupported method. | @@ -1600,6 +1604,10 @@ Agent Provider - Agents MUST NOT poll more frequently than once every 5 seconds. - Agents SHOULD use exponential backoff if the resource is not yet ready. - Agents MUST stop polling after 1 hour and treat the provisioning as failed. +- Providers MUST return the same `resource_id` and polling location (`poll_url` or `status_url`) for duplicate retries with the same `idempotency_key` while the request is still in progress. +- Agents MUST retry an interrupted async provision request with the same `idempotency_key` and a new `nonce`. +- When both fields are present, `poll_url` is the canonical field and `status_url` is a compatibility alias. +- Terminal states for async polling are `active`, `failed`, and `deprovisioned`. Agents MUST stop polling once a terminal state is reached. **Webhook alternative:** @@ -1671,6 +1679,8 @@ When an agent includes an `idempotency_key` in the `ProvisionRequest`: 2. If the provider receives another request with the same `idempotency_key`, it MUST return the stored response without creating a new resource. 3. The idempotency key is scoped to the provider. Different providers MAY receive the same key without conflict. 4. If the original request is still being processed, the provider SHOULD return the in-progress `ProvisionResponse` (status: `provisioning`). +5. Agents retrying after a timeout or lost connection MUST generate a fresh `nonce` for each attempt while preserving the same `idempotency_key`. +6. Providers MUST treat a changed `nonce` plus unchanged `idempotency_key` as a retry of the same logical operation, not a second provisioning request. ### 5.6 Deprovisioning @@ -3554,6 +3564,43 @@ Providers MAY declare custom payment methods by using a namespaced identifier: Custom payment method identifiers MUST use reverse-domain notation. The `payment_proof` structure for custom methods is defined by the provider and documented in their manifest's `extensions` object. +#### Canonical Paid Provisioning Contract + +The canonical paid provisioning contract is: + +1. Providers MUST declare allowed rails using `accepted_payment_methods` at the manifest or tier level. +2. Agents MUST send exactly one `payment_method`, and it MUST be one of the accepted methods for the selected tier. +3. Agents MUST omit `payment_proof` when `payment_method` is `free`. +4. Agents MUST include a rail-specific `payment_proof` when `payment_method` is anything other than `free`. +5. Providers MUST reject missing or invalid paid proof with a machine-actionable error such as `payment_required`, `payment_declined`, `payment_failed`, `budget_exceeded`, or `approval_required`. +6. For duplicate retries of an in-flight paid provision request, agents MUST keep the same `idempotency_key` and generate a fresh `nonce`. +7. Providers MUST return the original in-progress response for duplicate paid retries with the same `idempotency_key`. +8. If the tier uses escrow-backed settlement, providers SHOULD include `escrow_id` in the provision response as soon as it exists. + +This contract is the normative baseline for OSP Paid Core. + +#### Provider Obligations + +Providers implementing paid provisioning MUST satisfy the following obligations: + +1. **Verification**: Providers MUST verify payment proof signatures against the declared rail's verification rules before allocating any resources. Verification MUST be synchronous even when provisioning is asynchronous. +2. **Idempotency**: Providers MUST treat requests with the same `idempotency_key` as retries. The first accepted request wins; subsequent retries MUST return the original response without duplicate resource allocation or duplicate charges. +3. **Failure Handling**: If provisioning fails after payment proof has been accepted, the provider MUST: + - Return a machine-actionable error with `provision_failed` code. + - Include a `refund_eligible: true` flag when the failure warrants reversal. + - NOT silently consume the payment proof without delivering a resource or signaling failure. +4. **Timeout Behavior**: If the provider cannot complete provisioning within the declared `max_provision_time`, it MUST return `408 Request Timeout` with `retry_eligible: true`. +5. **Settlement Correlation**: For escrow-backed tiers, the provider MUST include the `escrow_id` in all responses and MUST call the settlement confirmation endpoint before the escrow timeout expires. + +#### Agent Obligations + +Agents consuming paid provisioning MUST satisfy: + +1. **Nonce Freshness**: Each request MUST include a unique `nonce`. Retries of the same logical request MUST reuse the `idempotency_key` but generate a fresh `nonce`. +2. **Proof Scope**: Payment proof MUST be scoped to the specific provider, offering, tier, and amount. Proof generated for one provider MUST NOT be reused for another. +3. **Settlement Correlation**: Agents MUST store the `escrow_id` returned by the provider and use it for subsequent settlement, dispute, or refund operations. +4. **Error Recovery**: On `approval_required`, agents MUST pause and surface the approval request to the controlling principal. On `payment_failed`, agents MUST NOT retry with the same proof material. + ### 7.2 Usage-Based Billing For tiers with `metered: true`, providers track usage and generate `UsageReport` objects at the end of each billing period. @@ -3806,21 +3853,26 @@ RateLimit-Reset: 60 ```json { - "error": "rate_limit_exceeded", - "message": "Rate limit exceeded. Try again in 30 seconds.", - "retry_after_seconds": 30, - "limit": 60, - "window_seconds": 60 + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded. Try again in 30 seconds.", + "details": { + "limit": 60, + "window_seconds": 60 + }, + "retryable": true, + "retry_after_seconds": 30 + } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| -| `error` | `string` | REQUIRED | MUST be `"rate_limit_exceeded"`. | -| `message` | `string` | REQUIRED | Human-readable error message. | -| `retry_after_seconds` | `integer` | REQUIRED | Number of seconds the agent SHOULD wait before retrying. | -| `limit` | `integer` | REQUIRED | Maximum requests allowed in the rate limit window. | -| `window_seconds` | `integer` | REQUIRED | Duration of the rate limit window in seconds. | +| `error.code` | `string` | REQUIRED | MUST be `"rate_limit_exceeded"`. | +| `error.message` | `string` | REQUIRED | Human-readable error message. | +| `error.retry_after_seconds` | `integer` | REQUIRED | Number of seconds the agent SHOULD wait before retrying. | +| `error.details.limit` | `integer` | REQUIRED | Maximum requests allowed in the rate limit window. | +| `error.details.window_seconds` | `integer` | REQUIRED | Duration of the rate limit window in seconds. | #### 8.6.3 Per-Endpoint Rate Limits @@ -4032,6 +4084,7 @@ Providers and agents MAY advertise their conformance level: | Level | Provider Requirements | Agent Requirements | |-------|----------------------|-------------------| | **OSP Core** | All 8 mandatory endpoints (6.1-6.8) + Sections 9.1 requirements | All Section 9.2 requirements | +| **OSP Paid Core** | OSP Core + paid tier declaration + structured payment errors + async/idempotent paid provisioning | OSP Core + valid payment rail selection + payment proof handling + retry-safe paid provisioning | | **OSP Core + Webhooks** | Core + webhook management endpoint (6.9) + webhook delivery with retries (8.5) | Core + webhook receiver with HMAC verification | | **OSP Core + Events** | Core + audit event stream endpoint (6.10) with 90-day retention | Core + event polling capability | | **OSP Core + Escrow** | Core + escrow profiles in tiers + integration with escrow provider (Sardis or equivalent) | Core + escrow ACK/NACK support | @@ -9435,12 +9488,15 @@ The `identity_required_for_tiers` field declares which tiers require identity ve "invalid_configuration", "payment_required", "payment_declined", + "payment_failed", "insufficient_funds", + "approval_required", + "budget_exceeded", "trust_tier_insufficient", "quota_exceeded", "region_unavailable", "nonce_reused", - "rate_limited", + "rate_limit_exceeded", "provider_error", "capacity_exhausted" ],