diff --git a/.changeset/card-spending-limits.md b/.changeset/card-spending-limits.md new file mode 100644 index 000000000..33408ff22 --- /dev/null +++ b/.changeset/card-spending-limits.md @@ -0,0 +1,5 @@ +--- +"@sapiom/core": minor +--- + +Add card spending-limit management and trusted confirmed-refund recording. These methods require the corresponding backend API deployment and management permissions. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f637a212..c37710544 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: Test on: pull_request: types: [opened, synchronize, reopened, stacked] - branches: [main] + # Branch-based stacks need the same unprivileged checks as PRs to main. push: branches: [main] diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index d90f39feb..46741e87d 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,9 @@ # @sapiom/core +## Unreleased + +- Add trusted-platform card spending-limit read/write helpers over ordinary payment Rules, with versioned settings, shared payment usage and additional applicable limits. Card spending is booked at credential release; no refund-confirmation API is provided. Requires the corresponding backend deployment; package publication is pending. + ## 0.5.0 ### Minor Changes diff --git a/packages/core/README.md b/packages/core/README.md index dfcd70a34..cbc06a864 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,35 +19,50 @@ Core SDK for Sapiom API providing HTTP client adapters, transaction management, ## Installation -```bash -npm install @sapiom/core -# or -pnpm add @sapiom/core -# or -yarn add @sapiom/core +### Card spending limits + +Use a trusted platform credential with `org.read` / `org.write` to manage a card's common spending rules. Keep write permissions outside the purchasing agent. These methods require the corresponding backend deployment. + +```typescript +const current = await client.paymentCards.getSpendingLimits(cardId); +const saved = await client.paymentCards.setSpendingLimits(cardId, { + version: current.version, + purchaseLimit: { amount: "100.00", currency: "USD" }, + recurringLimit: { amount: "500.00", currency: "USD", period: "month" }, + customLimit: null, +}); +console.log(saved.usage.recurringLimit?.remaining); ``` +The helper owns one ordinary `payment_spending_limit` rule scoped to this card. Other Rules remain independent and appear in `additionalLimits`; transaction-specific conditions still require the full purchase context. A rule grouped by `paymentCardId` gives every card its own allowance. An ungrouped rule can share a budget across cards. + +Periods are UTC calendar weeks (Monday), months, years, or inclusive custom dates. A custom-date budget applies only within its dates. No rule means no spending-limit prerequisite. Other applicable Rules and card readiness checks still apply. + +Pending payments reserve capacity. Releasing the raw card credential books the full payment amount once; later merchant failures or cancellation do not restore it. M0 does not infer charges or refunds from merchant outcomes and exposes no refund-confirmation API. + +Unchanged saves preserve version and usage. Changing a cumulative budget starts a fresh allowance for that slot; other slots retain usage. Submit the latest version to avoid conflicting updates. Quantities are null when accounting is unavailable. `configurationActive` describes this helper's configuration, not permission to purchase. + ## Quick Start ### Direct API Client ```typescript -import { SapiomClient } from '@sapiom/core'; +import { SapiomClient } from "@sapiom/core"; const client = new SapiomClient({ apiKey: process.env.SAPIOM_API_KEY, - baseURL: 'https://api.sapiom.ai' // optional + baseURL: "https://api.sapiom.ai", // optional }); // Create a transaction const transaction = await client.transactions.create({ - service: 'api', - action: 'call', - resource: 'completion', + service: "api", + action: "call", + resource: "completion", qualifiers: { - model: 'gpt-4', - tokens: 1000 - } + model: "gpt-4", + tokens: 1000, + }, }); // Check transaction status @@ -63,17 +78,20 @@ npm install @sapiom/axios axios ``` ```typescript -import axios from 'axios'; -import { withSapiom } from '@sapiom/axios'; - -const client = withSapiom(axios.create({ - baseURL: 'https://api.example.com' -}), { - apiKey: process.env.SAPIOM_API_KEY -}); +import axios from "axios"; +import { withSapiom } from "@sapiom/axios"; + +const client = withSapiom( + axios.create({ + baseURL: "https://api.example.com", + }), + { + apiKey: process.env.SAPIOM_API_KEY, + }, +); // Automatically handles 402 payment flows -const response = await client.get('/premium-endpoint'); +const response = await client.get("/premium-endpoint"); ``` See [@sapiom/axios](../axios/README.md) for complete documentation. @@ -87,14 +105,14 @@ npm install @sapiom/fetch ``` ```typescript -import { createFetch } from '@sapiom/fetch'; +import { createFetch } from "@sapiom/fetch"; const safeFetch = createFetch({ - apiKey: process.env.SAPIOM_API_KEY + apiKey: process.env.SAPIOM_API_KEY, }); // Drop-in replacement for native fetch -const response = await safeFetch('https://api.example.com/data'); +const response = await safeFetch("https://api.example.com/data"); ``` See [@sapiom/fetch](../fetch/README.md) for complete documentation. @@ -108,15 +126,15 @@ npm install @sapiom/node-http ``` ```typescript -import { createClient } from '@sapiom/node-http'; +import { createClient } from "@sapiom/node-http"; const client = createClient({ - apiKey: process.env.SAPIOM_API_KEY + apiKey: process.env.SAPIOM_API_KEY, }); const response = await client.request({ - method: 'GET', - url: 'https://api.example.com/data' + method: "GET", + url: "https://api.example.com/data", }); ``` @@ -133,6 +151,7 @@ new SapiomClient(config: SapiomClientConfig) ``` **Config options:** + - `apiKey: string` - Required: Your Sapiom API key - `baseURL?: string` - Optional: API base URL (default: https://api.sapiom.ai) - `servicesDomain?: string` - Optional: Services domain for gateway clients (default: services.sapiom.ai) @@ -173,21 +192,21 @@ client.transactions.getPaymentDetails(transaction): PaymentDetails | null ```typescript // Store one secret under an external ref -await client.vault.set('agent-123', 'OPENAI_API_KEY', { - value: 'sk-...' +await client.vault.set("agent-123", "OPENAI_API_KEY", { + value: "sk-...", }); // Read one secret. Missing keys return null. -const openaiKey = await client.vault.get('agent-123', 'OPENAI_API_KEY'); +const openaiKey = await client.vault.get("agent-123", "OPENAI_API_KEY"); // Read a subset of secrets from the same ref -const secrets = await client.vault.getMany('agent-123', [ - 'OPENAI_API_KEY', - 'ANTHROPIC_API_KEY' +const secrets = await client.vault.getMany("agent-123", [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", ]); // Remove one key -await client.vault.deleteKey('agent-123', 'OPENAI_API_KEY'); +await client.vault.deleteKey("agent-123", "OPENAI_API_KEY"); ``` ## HTTP Integrations @@ -247,7 +266,11 @@ try { ### Custom HTTP Adapter ```typescript -import type { HttpClientAdapter, HttpRequest, HttpResponse } from '@sapiom/core'; +import type { + HttpClientAdapter, + HttpRequest, + HttpResponse, +} from "@sapiom/core"; class MyCustomAdapter implements HttpClientAdapter { async request(config: HttpRequest): Promise { @@ -259,11 +282,11 @@ class MyCustomAdapter implements HttpClientAdapter { ### Payment Error Detection ```typescript -import { isPaymentRequiredError, extractX402Response } from '@sapiom/core'; +import { isPaymentRequiredError, extractX402Response } from "@sapiom/core"; if (isPaymentRequiredError(error)) { const x402Response = extractX402Response(error); - console.log('Payment required:', x402Response); + console.log("Payment required:", x402Response); } ``` @@ -278,7 +301,7 @@ import type { HttpResponse, X402Response, X402PaymentRequirement, -} from '@sapiom/core'; +} from "@sapiom/core"; ``` ## License diff --git a/packages/core/examples/card-spending-limits.ts b/packages/core/examples/card-spending-limits.ts new file mode 100644 index 000000000..938d23f3c --- /dev/null +++ b/packages/core/examples/card-spending-limits.ts @@ -0,0 +1,32 @@ +import { SapiomClient } from "@sapiom/core"; + +/** Run from a trusted platform backend after the matching API and SDK have been released. */ +async function configureCard() { + const apiKey = process.env.SAPIOM_MANAGEMENT_API_KEY; + const cardId = process.env.SAPIOM_CARD_ID; + if (!apiKey || !cardId) + throw new Error("Set SAPIOM_MANAGEMENT_API_KEY and SAPIOM_CARD_ID"); + const client = new SapiomClient({ + apiKey, + baseURL: process.env.SAPIOM_API_URL, + backgroundRefresh: false, + }); + const current = await client.paymentCards.getSpendingLimits(cardId); + const saved = await client.paymentCards.setSpendingLimits(cardId, { + version: current.version, + purchaseLimit: { amount: "100.00", currency: "USD" }, + recurringLimit: { amount: "500.00", currency: "USD", period: "month" }, + customLimit: null, + }); + console.log( + JSON.stringify( + { cardId, version: saved.version, usage: saved.usage }, + null, + 2, + ), + ); +} +void configureCard().catch((error: Error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/packages/core/src/client/PaymentCardsAPI.test.ts b/packages/core/src/client/PaymentCardsAPI.test.ts new file mode 100644 index 000000000..a2ac2d009 --- /dev/null +++ b/packages/core/src/client/PaymentCardsAPI.test.ts @@ -0,0 +1,64 @@ +import { SapiomClient } from "./SapiomClient"; +import type { CardSpendingLimitsSettings } from "../types/payment-card"; + +describe("Card-limit management through the public client", () => { + const cardId = "11111111-1111-4111-8111-111111111111"; + let client: SapiomClient; + let fetchMock: jest.Mock; + const originalFetch = global.fetch; + beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock; + client = new SapiomClient({ + apiKey: "fixture-management-key", + baseURL: "https://api.test", + backgroundRefresh: false, + }); + }); + afterEach(() => { + global.fetch = originalFetch; + }); + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + const settings: CardSpendingLimitsSettings = { + version: 0, + purchaseLimit: { amount: "100.00", currency: "USD" }, + recurringLimit: { amount: "500.00", currency: "USD", period: "month" }, + customLimit: null, + }; + + it("uses versioned routes and preserves explicit slots and decimal amounts", async () => { + fetchMock.mockResolvedValueOnce(json({ ...settings, version: 1 })); + expect( + (await client.paymentCards.setSpendingLimits(cardId, settings)).version, + ).toBe(1); + const [url, request] = fetchMock.mock.calls[0]; + expect(String(url)).toBe( + `https://api.test/v1/payment-cards/${cardId}/spending-limits`, + ); + expect(request.method).toBe("PUT"); + expect(JSON.parse(request.body)).toEqual(settings); + expect(request.headers["x-api-key"]).toBe("fixture-management-key"); + fetchMock.mockResolvedValueOnce( + json({ + ...settings, + version: 1, + usage: { recurringLimit: { remaining: "450.00" } }, + }), + ); + expect( + (await client.paymentCards.getSpendingLimits(cardId)).usage.recurringLimit + ?.remaining, + ).toBe("450.00"); + }); + it("propagates a stale-version conflict without silently retrying a reset", async () => { + fetchMock.mockResolvedValue(json({ code: "RULE_VERSION_CONFLICT" }, 409)); + await expect( + client.paymentCards.setSpendingLimits(cardId, settings), + ).rejects.toThrow(/409.*RULE_VERSION_CONFLICT/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/client/PaymentCardsAPI.ts b/packages/core/src/client/PaymentCardsAPI.ts new file mode 100644 index 000000000..496457093 --- /dev/null +++ b/packages/core/src/client/PaymentCardsAPI.ts @@ -0,0 +1,30 @@ +import type { + CardSpendingLimitsSettings, + CardSpendingLimitsSummary, +} from "../types/payment-card.js"; +import type { HttpClient } from "./HttpClient.js"; + +/** Management methods for a trusted platform backend; purchasing keys cannot change limits. */ +export class PaymentCardsAPI { + constructor(private readonly httpClient: HttpClient) {} + + /** Read a card's versioned spending limits and current usage. Requires org.read. */ + async getSpendingLimits(cardId: string): Promise { + return this.httpClient.request({ + method: "GET", + url: `/payment-cards/${encodeURIComponent(cardId)}/spending-limits`, + }); + } + + /** All slots are explicit. Changed cumulative settings start fresh; unchanged saves preserve usage. */ + async setSpendingLimits( + cardId: string, + settings: CardSpendingLimitsSettings, + ): Promise { + return this.httpClient.request({ + method: "PUT", + url: `/payment-cards/${encodeURIComponent(cardId)}/spending-limits`, + body: settings, + }); + } +} diff --git a/packages/core/src/client/SapiomClient.ts b/packages/core/src/client/SapiomClient.ts index c4e2850fe..7bfa12098 100644 --- a/packages/core/src/client/SapiomClient.ts +++ b/packages/core/src/client/SapiomClient.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpRequestConfig } from "./HttpClient.js"; import type { RetryConfig } from "./HttpClient.js"; import { IdentityManager } from "./IdentityManager.js"; import { TransactionAPI } from "./TransactionAPI.js"; +import { PaymentCardsAPI } from "./PaymentCardsAPI.js"; import { VaultAPI } from "./VaultAPI.js"; const DEFAULT_SERVICES_DOMAIN = "services.sapiom.ai"; @@ -92,6 +93,7 @@ export class SapiomClient { public readonly apiKeys: ApiKeyAPI; public readonly identity: IdentityManager; public readonly transactions: TransactionAPI; + public readonly paymentCards: PaymentCardsAPI; public readonly vault: VaultAPI; constructor(config: SapiomClientConfig) { @@ -128,6 +130,7 @@ export class SapiomClient { backgroundRefresh: config.backgroundRefresh, }); this.transactions = new TransactionAPI(this.httpClient); + this.paymentCards = new PaymentCardsAPI(this.httpClient); this.vault = new VaultAPI(this.vaultHttpClient); } diff --git a/packages/core/src/client/TransactionAPI.ts b/packages/core/src/client/TransactionAPI.ts index c5685af01..de45d04a3 100644 --- a/packages/core/src/client/TransactionAPI.ts +++ b/packages/core/src/client/TransactionAPI.ts @@ -10,7 +10,6 @@ import { TransactionCostResponse, } from "../types/transaction.js"; import type { HttpClient } from "./HttpClient.js"; - export class TransactionAPI { constructor(private readonly httpClient: HttpClient) {} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 440bacb1b..d4dfa75d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,16 @@ export { IdentityManager } from "./client/IdentityManager.js"; export type { IdentityManagerOptions } from "./client/IdentityManager.js"; export { ApiKeyAPI } from "./client/ApiKeyAPI.js"; export { TransactionAPI } from "./client/TransactionAPI.js"; +export { PaymentCardsAPI } from "./client/PaymentCardsAPI.js"; +export type { + CardMoneyLimit, + CardRecurringLimit, + CardCustomLimit, + CardSpendingLimitsSettings, + CardAllowanceUsage, + CardSpendingLimitsSummary, + PaymentLimitSummary, +} from "./types/payment-card.js"; export { VaultAPI } from "./client/VaultAPI.js"; // Payment Error Detection diff --git a/packages/core/src/types/payment-card.ts b/packages/core/src/types/payment-card.ts new file mode 100644 index 000000000..b37e39f2b --- /dev/null +++ b/packages/core/src/types/payment-card.ts @@ -0,0 +1,58 @@ +/** Positive USD amounts are decimal strings with at most two fractional digits. */ +export interface CardMoneyLimit { + amount: string; + currency: "USD"; +} +export interface CardRecurringLimit extends CardMoneyLimit { + period: "week" | "month" | "year"; +} +export interface CardCustomLimit extends CardMoneyLimit { + /** UTC YYYY-MM-DD; both supplied dates are inclusive. */ + startDate: string; + endDate: string; +} +export interface CardSpendingLimitsSettings { + /** Use the version returned by getSpendingLimits; 0 means no configuration exists yet. */ + version: number; + purchaseLimit: CardMoneyLimit | null; + recurringLimit: CardRecurringLimit | null; + customLimit: CardCustomLimit | null; +} +export interface CardAllowanceUsage { + available: boolean; + state: "active" | "future" | "expired" | "inactive" | "unavailable"; + currency: "USD"; + startInclusive: string | null; + endExclusive: string | null; + resetAt: string | null; + /** Unavailable evidence produces null quantities, never an invented zero. */ + /** Irreversibly consumed payments; card payments become spent on credential release. */ + spent: string | null; + pendingCommitted: string | null; + used: string | null; + remaining: string | null; +} +export interface CardSpendingLimitsSummary extends CardSpendingLimitsSettings { + ruleId: string | null; + status: "active" | "paused" | "removed"; + evaluatedAt: string; + /** Indicates effective configuration; each proposed purchase is still checked against all limits. */ + configurationActive: boolean; + /** Other matching or conditional payment rules, edited through the Rules API. */ + additionalLimits: PaymentLimitSummary[]; + usage: { + purchaseLimit?: null; + recurringLimit?: CardAllowanceUsage; + customLimit?: CardAllowanceUsage; + }; +} +export interface PaymentLimitSummary { + ruleId: string; + constraintId: string; + name: string; + /** Conditional rules need the complete transaction context to establish applicability. */ + scope: "match" | "unavailable"; + limit: { kind: "money"; amount: string; currency: "USD" }; + groupBy: Array<"agent" | "trace" | "service" | "paymentCardId">; + usage: CardAllowanceUsage | null; +} diff --git a/scripts/pr-ci-security.test.mjs b/scripts/pr-ci-security.test.mjs index 1a62a7720..875a52a5b 100644 --- a/scripts/pr-ci-security.test.mjs +++ b/scripts/pr-ci-security.test.mjs @@ -32,11 +32,19 @@ test("pull request CI uses unprivileged fork-safe triggers", () => { /^ types: \[opened, synchronize, reopened, stacked\]$/m, `${relativePath}: run existing PR events and native stack changes`, ); - assert.match( - pullRequest, - /^ branches: \[main\]$/m, - `${relativePath}: target main directly or through a native stack`, - ); + if (relativePath === ".github/workflows/test.yml") { + assert.doesNotMatch( + pullRequest, + /^ branches(?:-ignore)?:/m, + `${relativePath}: test branch-based stacks regardless of their base`, + ); + } else { + assert.match( + pullRequest, + /^ branches: \[main\]$/m, + `${relativePath}: target main directly or through a native stack`, + ); + } assert.doesNotMatch(workflow, /pull_request_target:/, relativePath); assert.match( workflow,