Saltar al contenido principal

Third-party SDK

Integration guide

So that your SaaS product knows what each of your customers has bought through Mitnix Commerce, verifies it without intermediaries and stops granting it when Commerce revokes it.

For the team of a third party that sells its SaaS product through the Mitnix Commerce platform and wants that product to know, without intermediaries, what each of its customers has bought (capabilities, limits, quotas and validity) and to stop granting it when Commerce revokes it. Spanish original (the Spanish text is the reference if they differ).

Everything described here is implemented in the five @mitnix-ai-solutions/platform-* 1.0.2 packages and runs end to end in the tercero-saas example, which Mitnix compiles and tests against the packages as they are published.

1. How it fits together

 your SaaS product ──OIDC token from your platform──▶ Commerce (/installation/v1/*)
      │                                                  │
      │  1 trust/bundle        trust bundle ◀────────────┤  (your vendor's licensing authority)
      │  2 tenant-grants       encrypted Grants (JWE) ◀──┤  (one per customer, signed by your authority)
      │  3 trust/operations    content key ◀─────────────┤  (open-grant: Trust opens it; the private key never leaves the HSM)
      │  4 tenant-license-status  revocations ◀──────────┘
      ▼
 verify against the anchor ▶ grant capabilities, limits and quotas per customer (tenant)
  • Your product holds no secret. It authenticates with the OIDC token that your runtime platform issues for your workload; Commerce verifies it against what you registered.
  • You verify, offline. Commerce delivers the Grant, but what makes it trustworthy is its signature (ES256, mitnix.signed-artifact/v3) checked against an anchor that you pin. A license from Mitnix or from another third party is rejected.
  • Commerce works out who you are from the token (vendor, product and environment). The SDK never sends your vendorId.

2. Installation

The five packages are on npmjs.com and install without any token:

npm install @mitnix-ai-solutions/platform-common@1.0.2 @mitnix-ai-solutions/platform-artifacts@1.0.2 \
  @mitnix-ai-solutions/platform-trust@1.0.2 @mitnix-ai-solutions/platform-licensing@1.0.2 \
  @mitnix-ai-solutions/platform-commerce-client@1.0.2

Node 22 or later (platform fetch and WebCrypto; the SDK uses no Node or DOM types). The SDK license is proprietary and free, valid only for use with Commerce (LICENSE). The provenance of what is published can be verified without credentials with the manifest signed by Mitnix (verification). Versions and their compatibility with /installation/v1 are on mitnix.net/sdk.

3. Register your product in the Commerce console

Done by your vendor's administrator in your console (https://<your host>/consola/#/canal, "Identidad de tus productos"). For each product and environment, register:

Field What it is
Product The one in your catalog, shaped <vendor>:<product> (after the colon: lowercase letters, digits and inner hyphens, up to 32).
Environment development, staging or production. It must be the environment in which the Commerce installation sells (the console shows it and proposes it).
Issuer The exact iss of your platform's token.
Audience The exact aud: the one you choose is the one your product will request when it obtains the token. Use the Commerce URL.
Subject The exact sub of your workload's token.

What Commerce does when you register:

  1. Discovers the issuer (<issuer>/.well-known/openid-configuration): the document's issuer must be exactly the registered one, and it pins the jwks_uri. It never discovers again afterwards.
  2. Requires an https:// issuer with a public name (no literal IP, port, credentials or localhost) that resolves only to public addresses.
  3. An identity (issuer and subject) belongs to one product and environment across the whole platform.
  4. Requests the custody identity tercero.<vendor>.<product> for the environment from Trust. A Mitnix security officer approves it. With "Comprobar custodia", once approved and active, Commerce registers your SaaS installation tercero.<vendor>.<product>.<environment> (for example tercero.prueba.excerptia.staging): the recipient of your Grants and the installationId your product uses.

Changing the identity of an already registered product and environment (another issuer, audience or subject) replaces it without losing the custody: it does not need approving again.

4. Your workload token

Your runtime platform issues an OIDC token (JWT) for your service. Commerce accepts it only if:

  • it is signed with RS256 or ES256 (P-256) using a key from the pinned jwks_uri, and carries a kid;
  • iss, sub and aud are exactly the registered ones;
  • it carries exp and iat, lives 24 hours or less (exp − iat), has not expired and was not issued (nor starts, if it has nbf) in the future, with 30 s of clock tolerance.

How to obtain it on each platform is explained by the platform's own documentation; what Commerce demands is the above. Verified in Commerce today: Cloud Run (how Mitnix's own applications identify themselves): GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=<audience> with the Metadata-Flavor: Google header; it lasts one hour. On Kubernetes (projected service-account token with the requested audience), AWS, Azure or a CI with OIDC the same rules apply: your issuer must be public and discoverable by Commerce from the Internet. A private issuer (a cluster without a public discovery document) cannot be registered.

The SDK does not store the token or cache it: it calls your provider on every request (tokenProvider({ forceRefresh })) and puts the token only in Authorization: Bearer. The token never appears in a URL, a body, an error or a log. After a 401 it asks for a new one (forceRefresh: true) and retries once.

const commerce = createCommerceChannelClient({
  commerceUrl: "https://<the Commerce URL Mitnix gives you>",
  tokenProvider: async ({ forceRefresh }) => requestTokenFromThePlatform({ audience, bypassCache: forceRefresh }),
});

For development and tests without a runtime platform you can use an OIDC issuer of your own with client_credentials (what the example's staging test does); the example ships it as one more token source (MITNIX_TOKEN_URL).

5. The trust anchor

To verify licenses without asking anyone, your product pins an anchor (public keys only, nothing secret):

const anchor: VendorTrustAnchor = {
  vendorId: "acme",                                   // your vendor identifier
  environment: "production",                          // the `environment` of the bundle Trust signs (see note)
  trustUpdatePublicKey: { kty: "EC", crv: "P-256", x: "…", y: "…" },  // public key of Mitnix's trust-update authority
};
  • The trust bundle (mitnix.trust.vendor-bundle/v1) is signed by Mitnix's trust-update authority and contains the third-party root and the licensing authorities of your vendor with their public keys, plus their revocations. If someone served you a bundle signed with another key, or for another vendor or environment, applyVendorTrustBundle rejects it (VENDOR_BUNDLE_SIGNATURE_INVALID, …_SCOPE_MISMATCH, …_ENVIRONMENT_MISMATCH).
  • Note on environment: it is the environment Trust signs with, not your application's environment. Trust only supports production today and uses it in staging too, so the staging bundle carries environment: "production". Check it in the bundle you receive (payload.environment).
  • Where to get it: in your Commerce console, «Identidad de tus productos» (https://<your host>/consola/#/canal), Ancla de confianza section. Any member of your team can see it, readers included. It shows your vendorId, the environment you must set (the one Trust actually writes into your bundle, with a note when it differs from the sales environment) and the trust-update public key as a JWK with its SHA-256 thumbprint (RFC 7638). «Copiar ancla» and «Descargar JSON» give you the VendorTrustAnchor object as is; «Copiar clave (JWK)» gives just the key, which is what the example expects in MITNIX_TRUST_UPDATE_JWK or MITNIX_TRUST_UPDATE_JWK_FILE. Commerce takes it from Trust's trust distribution and only shows it if it verifies the signature of the bundle it serves you. If you have no bundle yet (Trust has not approved your licensing authority), the section says so.
  • Check the thumbprint before pinning the anchor: it is the base64url SHA-256 of the JSON {"crv":"P-256","kty":"EC","x":"…","y":"…"} (keys in that order, no whitespace), and it must match the one shown in the console.
  • Rotation: when Mitnix rotates the trust-update key, the console shows more than one. «En uso» (in use) is the one that signs the bundle Commerce serves you now; «Vigente» (active) or «Siguiente» (next) are the upcoming ones. The SDK anchor holds a single key (trustUpdatePublicKey) and the Grant verifier re-checks the stored bundle with it. So switch like this: get the new key ready and, when the console marks it «En uso», replace the one in the anchor and apply the new bundle right away (applyVendorTrustBundle). Until you change the anchor, the new bundle is rejected with VENDOR_BUNDLE_SIGNATURE_INVALID and your product keeps the last one it applied.

6. The flow in code

The example implements it in src/licencias.ts (ServicioDeLicencias class). The steps:

// 1. Trust bundle: applied exactly as Commerce serves it (base64url). If it fails, the last valid one is kept.
const delivered = await commerce.trustBundle();             // { vendorId, sequence, bundleId, bundle }
const applied = await applyVendorTrustBundle({ bundle: delivered.bundle, anchor, store: bundleStore });

// 2. Encrypted Grants: one per customer (tenant).
const { grants } = await commerce.tenantGrants();           // [{ grantId, encryptedDocument }]

// 3. Open the JWE. open-grant goes through Commerce to Trust: the SDK extracts the encrypted key, sends it and decrypts locally.
const opened = await openTenantGrantJwe(grant.encryptedDocument, commerce.openGrant, { expectedInstallationId: installationId });

// 4. Verify and apply: your authority's signature, recipient, validity, per-tenant sequence…
const runtime = new TenantGrantRuntime(tenantStore, createAnchoredTenantGrantVerifier({ anchor, bundleStore }));
const r = await runtime.apply(opened.value, { installationId, installationKind: "saas", now: new Date().toISOString(), expectedAuthority: { vendorId: anchor.vendorId } });

// 5. Revocations: what Commerce has revoked after signing.
const status = await commerce.tenantLicenseStatus();        // { tenants: [{ commercialAccountReference, grantId, revokedAddons, revokedAt?, confirmedEntitlements }] }

What each step guarantees:

  • openTenantGrantJwe validates the JWE (RSA-OAEP-256 + A256GCM, 384-byte encrypted_key), rejects before spending a Key Vault operation if the recipient is not your installation, and wipes the content key bytes after use. The result is the signed document unverified: decrypting does not prove who signed it.
  • TenantGrantRuntime.apply validates in the normative order (TENANT_GRANT_VALIDATION_ORDER). With expectedAuthority: { vendorId } only tercero-<vendorId>-licencias and its -g<n> generations are valid: a license from Mitnix or another third party gives LG-CHAIN-INVALID. The anchored verifier only accepts an authority the latest bundle declares active, not revoked, valid when it signed and at verification time.
  • An authority revoked in a new bundle makes its Grants return LG-AUTHORITY-REVOKED.

What your product provides: the stores

The SDK persists nothing (and creates no timers). Your product implements:

  • VendorTrustBundleStore: the last applied bundle and a monotonic high-water mark (the highest applied sequence) somewhere a restore of an old bundle copy will not drag it back. This is the anti-rollback: an old bundle is still correctly signed, so only the sequence stops it.
  • TenantGrantStateStore: the protected per-tenant state (current Grant, highest accepted sequence, superseded Grants) with conditional atomic replacement.

The example uses the SDK's ephemeral stores (createEphemeral…), which are not for production: they neither persist nor resist a rollback.

7. Capabilities, limits and quotas

With the applied Grant (AppliedTenantGrant, from store.load(tenantRef)):

You need Function Result
Can it use the capability now? requireTenantCapability(grant, capabilityId, now) / tenantHasCapability CAPABILITY_NOT_GRANTED without a valid Grant, without the capability or with its entitlement expired
Contract validity tenantGrantTermStatus(grant, now) not-yet-valid, active or expired (from entry into force to term.validUntil, inclusive)
Validity of an add-on tenantEntitlementStatus / tenantEntitlementValidUntil Each entitlement may have its own validUntil; it never exceeds the contract end
Grace after expiry tenantGrantGrace(grant, now, policies) You declare the policies (TenantGracePolicy); the SDK ships none
Stock limit (e.g. users) admitStockOperation(grant, limitId, howMany, now) STOCK_LIMIT_REACHED if you already reached the maximum
Periodic quotas and prepaid lots computeQuotaBalance, planReservation, closeReservation, replayConsumptionLedger Reserve on accept, charge on completion, release on failure; calendar-month-utc period first, then lots by nearest expiry

The consumption ledger (ConsumptionLedgerEntry) is yours: the balance is rebuilt from the Grant and the ledger; an inconsistent ledger gives LEDGER_INCONSISTENT and produces no balance.

Combine the Grant with Commerce's status. The signed Grant says what was bought and until when; it does not say it was later refunded. Commerce revokes it (refund, lost dispute, non-payment) and announces it in tenant-license-status: revokedAddons (all confirmed ones if the Grant is revoked) and revokedAt. Also, confirmedEntitlements is what Commerce confirms as acquired: a signed capability that is not listed there is not granted. The example (describir in licencias.ts) does this combination and fails closed: without Commerce's status, it grants nothing.

This is an SDK recommendation, not a server requirement (Commerce sends no Retry-After today).

Query Cost Recommendation
tenantLicenseStatus() Commerce database only; no signing or Trust At most every 5 minutes; it is what detects a refund
trustBundle() Commerce database only; never queries Trust or Azure At startup and hourly; it brings a revocation of your authority
tenantGrants() Commerce database only At startup and when tenantLicenseStatus shows a grantId different from the applied one (renewal or upgrade)
openGrant (open-grant) One Trust Key Vault operation per Grant Once per grantId: do not open one already applied, nor repeat one definitively rejected while the bundle has not changed (the example does it this way)
confirmPurge Idempotent Only when Commerce asks for it (see "Terminations")

The SDK retries nothing except the 401, does not wait and does not poll: frequency and waiting are your product's decision, and it should only repeat what is idempotent (queries and confirmPurge).

9. Channel errors

The client throws CommerceChannelError with code, retryable, retryAfterSeconds?, status? and commerceCode?. The message is a fixed SDK text: the server's own message never reaches your product.

What you see SDK code What it means and what to do
401 COMMERCE_AUTHENTICATION_FAILED Commerce does not authenticate you, even after requesting another token. Check issuer, audience and subject in #/canal, your clock, and that the token lives 24 h or less. Repeating does not fix it
503 INSTALLATION_IDENTITY_UNVERIFIABLE COMMERCE_UNAVAILABLE (retryable) Your issuer's JWKS did not answer Commerce. It is not your product's fault: retry later
404 NOT_FOUND on trust/bundle COMMERCE_REQUEST_REJECTED Commerce does not recognise the token as the identity of any third-party product (the other routes answer 401 in that case). Register the exact issuer, audience and subject in #/canal, for the installation's product and environment
404 INSTALLATION_TRUST_BUNDLE_NOT_AVAILABLE COMMERCE_TRUST_BUNDLE_NOT_AVAILABLE There is no trust bundle for your vendor yet (the licensing authority is not approved yet). Wait and tell Mitnix; not your product's fault
403 INSTALLATION_CUSTODY_SCOPE_FORBIDDEN COMMERCE_REQUEST_REJECTED Custody only operates with your identity's application and environment. Check the registered product and environment
429, other 5xx, network or timeout COMMERCE_UNAVAILABLE (retryable) Retry later; if Retry-After is present, retryAfterSeconds carries it (capped at 3600 s)
400, 422 and other 4xx COMMERCE_REQUEST_REJECTED Integration failure; do not repeat
3xx COMMERCE_REDIRECT_REFUSED Commerce does not redirect the channel; the SDK refuses any redirect so the token never leaves the origin
2xx that violates the contract COMMERCE_RESPONSE_INVALID You never receive half-parsed data
Your token provider fails COMMERCE_TOKEN_UNAVAILABLE Retryable if your provider's failure is

A failure while opening a Grant arrives as TENANT_GRANT_KEY_OPEN_FAILED with details.causeCode, retryable and retryAfterSeconds (only when they have a safe shape). Verification rejections (LG-CHAIN-INVALID, LG-SIGNATURE-INVALID, LG-AUTHORITY-REVOKED, LG-RECIPIENT-MISMATCH, LG-NOT-YET-VALID, LG-SUPERSEDED…) are final for that document. The example maps them to an action in src/errores.ts. The full list of codes is in the types the packages export: CommerceChannelErrorCode in platform-commerce-client, and TenantGrantValidationCode and TenantGrantJweErrorCode in platform-licensing/tenant-grants.

10. Terminations and refunds

  • Refund or lost dispute: Commerce revokes the license; you will see it in tenantLicenseStatus() (revokedAddons, revokedAt). The signed Grant remains valid until its term.validUntil, so your product must query the status and stop granting.
  • End of contract: tenantDispositions() tells you, per tenant, whether to block and retain the data (block-and-retain) or delete it (purge), with dataRetainedUntil. After a purge, confirmPurge({ commercialAccountReference, grantId, scope? }) confirms to Commerce that you did it.

11. Testing your integration

  1. Unit tests, no network: the example tests the whole flow against a Commerce simulated at HTTP level (commerceSimulado in src/pruebas/mundo.ts) with real keys (signed bundle, Grant signed by your authority and by another third party's, JWE encrypted with RSA-3072) and the SDK without doubles. You can copy that double to test your product.
  2. End to end, against the staging Commerce with a test vendor: register your test product's identity, buy with a test card and run the example with your token (see its README).

12. What the SDK does not do

It does not store the token, does not persist the bundle or the Grants, opens no connections of its own beyond fetch, creates no timers or polling, retries nothing except a 401, and never sends your vendorId or any secret. All of that belongs to your product, on purpose: the SDK needs no permission in your environment.

Working example

A minimal SaaS product (Node 22, strict TypeScript) that goes through the whole flow of this guide with the packages published on npmjs. Its tests run offline against a simulated Commerce with real keys.

tar -xzf mitnix-ejemplo-tercero-saas.tar.gz
cd mitnix-ejemplo-tercero-saas
npm install
npm test
Download the example

SHA-256: eccf2461e0eb9e0e36eb277c9eb6b9a39f46822cce076172f1d558ad0c0b1e54