# SDK & tools

> The @cosuite/connect package for Node.js, the conformance test CLI, signature test vectors and verified signature functions for .NET, PHP and Python.

Source: https://cosuite.io/en/developers/sdk

Everything you need to implement and test the [Connect v1 contract](https://cosuite.io/en/developers/contract). Correctness does not depend on your language: the same signature test vectors and the same conformance test apply to every app.

## @cosuite/connect (Node.js) {#node}

The reference implementation of the contract for Node.js 18.17+, written in TypeScript. MIT license.

```bash
npm install https://cosuite.io/developers/sdk/cosuite-connect-1.8.0.tgz
```

The package installs as `@cosuite/connect`, so imports stay the same. It will also be published to the npm registry; until then install it from this address.

| Export | Purpose |
|---|---|
| `signRequest`, `verifyRequest`, `computeSignature` | HMAC request signature |
| `handleConnectRequest`, `matchAsistRoute`, `okResponse`, `errorResponse` | Signed endpoint shell: body limit, signature, request-id replay, error envelope |
| `ConnectError` | Throw it from a handler to return a contract error (`new ConnectError("tenant_not_found", "…")`) |
| `CoSuiteClient` | Your app → CoSuite client (`getEntitlements`, `sendEvent`) |
| `createSsoVerifier`, `isSafeRedirect` | SSO token verification (ES256 + JWKS) and safe redirect check |
| `schemas` | zod schemas and TypeScript types for every request and response |
| `verifyGatewayRequest`, `gatewayServer` | Verify requests the [customer API gateway](https://cosuite.io/en/developers/customer-api) forwards to your app |
| `checkModuleDefinitions`, `checkOpenApi` | The [module definition](https://cosuite.io/en/developers/module-definition#rules) checks the conformance test runs |
| `MemoryReplayStore` | Single-process store for request-id and SSO `jti` replay (implement `ReplayStore` on Redis or your database when you run several processes) |
| `createMockAsist`, `serveMockAsist` | In-memory reference app that implements the whole contract |
| `@cosuite/connect/conformance` | The conformance test as a library (`runConformance`) |

`handleConnectRequest` works with the web-standard `Request` and `Response`, so it runs as is in Next.js route handlers, Hono, Bun or Deno, and with a small bridge in Express or Fastify. See the [10-minute quick start](https://cosuite.io/en/developers#quickstart) for a full route.

### Verifying SSO {#node-sso}

```ts
import { createSsoVerifier, MemoryReplayStore } from "@cosuite/connect";

const verifySso = createSsoVerifier({
  issuer: "https://cosuite.io",
  audience: "dent", // your app key
  jwksUrl: "https://api.cosuite.io/connect/v1/jwks.json",
  replay: new MemoryReplayStore(),
});

// POST /api/cosuite/v1/sso, form field "token"
const claims = await verifySso(token); // throws ConnectError when invalid, expired or reused
// claims.sub, claims.tid, claims.email, claims.role, claims.redirect …
```

### Sending events {#node-events}

```ts
import { CoSuiteClient } from "@cosuite/connect";

const cosuite = new CoSuiteClient({
  baseUrl: "https://api.cosuite.io/connect/v1",
  keyId: process.env.COSUITE_KEY_ID!,
  secret: process.env.COSUITE_CONNECT_SECRET!,
});

await cosuite.sendEvent({
  id: "evt_01J9ZK3Q", // stable id from your outbox; also used as the request id
  type: "usage.threshold_reached",
  tenantId: "org_8f2c",
  occurredAt: new Date().toISOString(),
  data: { metric: "smsPerMonth", percent: 80 },
});
```

## Conformance test {#conformance}

The conformance test connects to your app's URL and exercises the contract end to end. It does not care which language your app is written in. CoSuite runs it before your app goes live and you should run it in your CI against staging: **if it is red, do not release.**

```bash
export COSUITE_CONNECT_SECRET=…   # the secret of the key you test with; never put it on the command line
npx --yes --package=https://cosuite.io/developers/sdk/cosuite-connect-1.8.0.tgz cosuite-connect conformance --url https://staging.dentcloud.example --key csk_dent_test
```

| Option | Meaning |
|---|---|
| `--url` | Your app's base URL (the contract lives under `/api/cosuite/v1`) |
| `--key` | The `keyId` to sign with |
| `--secret-env` | Name of the environment variable that holds the secret (default `COSUITE_CONNECT_SECRET`) |
| `--sso-key`, `--issuer` | A private JWK file and issuer for the SSO checks. Without them the SSO checks are skipped. |
| `--json` | Machine-readable output for CI |

It runs these checks in order:

1. manifest and health;
2. module definition (contract 1.7, only the fields you fill in), including downloading and checking each OpenAPI document;
3. signature attacks: unsigned, expired timestamp, unknown key, changed body, changed method;
4. opening a tenant with `test: true`, request-id replay (same body returns the first response, different body is `409`), idempotent re-open, unknown link code is `409 invalid_link_code`, unknown tenant `404`, invalid body `400`, `PATCH`;
5. entitlement versions: 2, then 1 (must not apply), the same 2 again (must not apply), then 3 with an unknown module and field (must be ignored), then read back;
6. adding, listing and removing members;
7. customers (if you declare `customers`): page shape, invalid limit and cursor;
8. usage and summary, including the 1-second summary budget and the 6-card maximum;
9. SSO (with `--sso-key`): valid token reaches the requested page, reused token, token for another app, broken signature;
10. deleting the tenant.

The exit code is `0` when everything passes and `1` otherwise. The human-readable output currently prints its check names in Turkish and ends with `YEŞİL` (green) or `KIRMIZI` (red); use `--json` if you parse the result.

### Local reference app {#mock}

Develop against an in-memory app that already implements the contract, or compare its behaviour with yours:

```bash
COSUITE_CONNECT_SECRET=… npx --yes --package=https://cosuite.io/developers/sdk/cosuite-connect-1.8.0.tgz cosuite-connect mock --asist dent --port 4010 --key csk_dent_dev [--single-tenant-users]
```

`--single-tenant-users` makes it behave like an app where an email can belong to only one account (`409 member_conflict`).

## Signature test vectors {#test-vectors}

Every implementation must produce exactly these signatures. All strings are UTF-8; the timestamp is used as written (as a decimal string).

| Case | Secret | Method | pathAndQuery | Timestamp | Body | Signature |
|---|---|---|---|---|---|---|
| GET, no body | `s3cr3t-key-0123456789abcdef0123456789` | `GET` | `/api/cosuite/v1/manifest` | `1761469200` | *(empty)* | `v1=0742611504ab28bc645cd09b102fe78ba0ec192698d3905fcc0dbb7519c1bce9` |
| PUT, JSON body (lower-case method) | `s3cr3t-key-0123456789abcdef0123456789` | `put` | `/api/cosuite/v1/tenants/org_8f2c/entitlements` | `1761469200` | `{"version":42,"status":"active"}` | `v1=ae6344f124a267db473e8c4e3da23413e35926426959bebfe447ec287570fe6f` |
| Query string and non-ASCII text | `başka-gizli-ə` | `POST` | `/app/api/cosuite/v1/tenants?x=1&y=%C3%A7` | `1761469201` | `{"name":"Yılmaz Otel — Bakı"}` | `v1=c14f7a789416cde9b3b1efe524695b267ee8b12d0d2a91fdce5926dc73bb5601` |
| Encoded id in path | `k` | `DELETE` | `/api/cosuite/v1/tenants/org%3Aa%2Fb/members/usr_1` | `0` | *(empty)* | `v1=e83120960821a9035bdd4077ccf127dc077e4c8058bbf4b9633ae46071308744` |

The canonical string of the first case is:

```text
GET|/api/cosuite/v1/manifest|1761469200|e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

The same vectors ship as `test-vectors/signature.json` in the `@cosuite/connect` package.

## Signature snippets {#signature-snippets}

There is no separate package for other languages: the signature is a single function. Each snippet below was run against all four test vectors above and produces them exactly. Remember the rules around it: read the **raw** body bytes, verify before parsing JSON, accept at most 256 KB, keep write-request ids for 10 minutes and return the [error envelope](https://cosuite.io/en/developers/contract#errors).

### .NET (C#) {#dotnet}

```csharp
using System.Security.Cryptography;
using System.Text;

public static class CoSuiteSignature
{
    public const int MaxSkewSeconds = 300;

    public static string Compute(string secret, string method, string pathAndQuery, string timestamp, byte[] body)
    {
        var bodyHash = Convert.ToHexStringLower(SHA256.HashData(body));
        var canonical = $"{method.ToUpperInvariant()}|{pathAndQuery}|{timestamp}|{bodyHash}";
        var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(canonical));
        return "v1=" + Convert.ToHexStringLower(mac);
    }

    /// <summary>secrets: the valid secrets for this keyId (two during key rotation).</summary>
    public static bool Verify(IReadOnlyList<string> secrets, string method, string pathAndQuery, string timestamp, byte[] body, string signature, long nowUnix)
    {
        if (!long.TryParse(timestamp, out var ts) || Math.Abs(nowUnix - ts) > MaxSkewSeconds) return false;
        if (!signature.StartsWith("v1=") || signature.Length != 67) return false;
        var given = Encoding.ASCII.GetBytes(signature);
        var match = false;
        foreach (var s in secrets)
            match |= CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Compute(s, method, pathAndQuery, timestamp, body)), given);
        return match;
    }
}
```

In ASP.NET Core, call `Request.EnableBuffering()` and read the raw body before model binding; `pathAndQuery` is `Request.PathBase + Request.Path + Request.QueryString`. Do not return ProblemDetails from contract endpoints; use the contract's error envelope. `Convert.ToHexStringLower` needs .NET 9; on older versions use `Convert.ToHexString(...).ToLowerInvariant()`.

SSO with `Microsoft.IdentityModel.JsonWebTokens`:

```csharp
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

var jwks = new JsonWebKeySet(await http.GetStringAsync("https://api.cosuite.io/connect/v1/jwks.json")); // cache for 1 hour
var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters
{
    ValidIssuer = "https://cosuite.io",
    ValidAudience = "dent",                 // your app key
    IssuerSigningKeys = jwks.GetSigningKeys(),
    ValidAlgorithms = new[] { SecurityAlgorithms.EcdsaSha256 },
    ClockSkew = TimeSpan.FromSeconds(30),
    RequireExpirationTime = true,
});
// Then: reject if exp - iat > 60, keep jti for 2 minutes (single use), allow only "/path" redirects.
```

### PHP {#php}

PHP 8.0+ (uses only the built-in `hash` extension):

```php
<?php
const COSUITE_MAX_SKEW_SECONDS = 300;

function cosuite_signature(string $secret, string $method, string $pathAndQuery, string $timestamp, string $body): string
{
    $canonical = strtoupper($method) . '|' . $pathAndQuery . '|' . $timestamp . '|' . hash('sha256', $body);
    return 'v1=' . hash_hmac('sha256', $canonical, $secret);
}

/** @param string[] $secrets the valid secrets for this keyId (two during key rotation) */
function cosuite_verify(array $secrets, string $method, string $pathAndQuery, string $timestamp, string $body, string $signature, ?int $now = null): bool
{
    $now ??= time();
    if (!ctype_digit($timestamp) || abs($now - (int) $timestamp) > COSUITE_MAX_SKEW_SECONDS) {
        return false; // 401 signature_expired
    }
    $match = false;
    foreach ($secrets as $secret) {
        $match = hash_equals(cosuite_signature($secret, $method, $pathAndQuery, $timestamp, $body), $signature) || $match;
    }
    return $match; // false -> 401 invalid_signature
}

// Usage: $body = file_get_contents('php://input'); $path = $_SERVER['REQUEST_URI'];
// cosuite_verify($secrets, $_SERVER['REQUEST_METHOD'], $path, $_SERVER['HTTP_X_COSUITE_TIMESTAMP'], $body, $_SERVER['HTTP_X_COSUITE_SIGNATURE']);
```

### Python {#python}

Python 3.10+ (standard library only):

```python
import hashlib
import hmac
import time

MAX_SKEW_SECONDS = 300


def cosuite_signature(secret: str, method: str, path_and_query: str, timestamp: str, body: bytes) -> str:
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = f"{method.upper()}|{path_and_query}|{timestamp}|{body_hash}"
    mac = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return "v1=" + mac


def cosuite_verify(secrets: list[str], method: str, path_and_query: str, timestamp: str,
                   body: bytes, signature: str, now: int | None = None) -> bool:
    now = int(time.time()) if now is None else now
    if not timestamp.isdigit() or abs(now - int(timestamp)) > MAX_SKEW_SECONDS:
        return False  # 401 signature_expired
    match = False
    for secret in secrets:  # two secrets are valid during key rotation
        expected = cosuite_signature(secret, method, path_and_query, timestamp, body)
        match |= hmac.compare_digest(expected, signature)
    return match  # False -> 401 invalid_signature
```

Pass the raw body bytes (`request.body` in Django, `request.get_data()` in Flask) and the raw request target exactly as the client sent it (for example `RAW_URI` or `REQUEST_URI` from your WSGI server). Framework helpers that rebuild the path may decode or re-encode characters such as `%3A` and break the signature.

For SSO in PHP or Python, use any maintained JWT library that supports ES256 and JWKS (for example `firebase/php-jwt` or `PyJWT` with `PyJWKClient`) and apply the [SSO checks](https://cosuite.io/en/developers/contract#sso).
