Developers · Connect v1

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.

Everything you need to implement and test the Connect v1 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)

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

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 forwards to your app
checkModuleDefinitions, checkOpenApi The module definition 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 for a full route.

Verifying SSO

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

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

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.

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

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

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

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:

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

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

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.

.NET (C#)

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:

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 8.0+ (uses only the built-in hash extension):

<?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 3.10+ (standard library only):

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.