Calling another application
One CosmoOps application calling another's API for a workspace, with a short-lived token Connect issues - and verifying such a call on the receiving side.
An application can call another application's API for a workspace, and
optionally for one of its members, without anyone pasting a key. Connect
issues a five-minute token bound to the provider (aud), the workspace and
the scopes the workspace consented to; the call itself goes straight to the
provider, which checks the token locally.
Two things are configured in Console, not in code: the provider's
exposedApi (its audience, scopes and events) and the consumer's consumes
list (the ceiling on what it may ask for). A workspace admin connects the
pair once; first-party pairs are connected automatically.
Calling (the consumer)
Your API key needs the applications:call scope.
import { ConnectClient } from "@cosmoops/connect-sdk/server";
const connect = new ConnectClient({ apiKey: process.env.CONNECT_API_KEY! });
// Background work - a sync, a cron, a webhook: an app-only token.
const messaging = connect.applications.fetch("https://messaging.cosmoops.com", {
workspaceId,
scope: ["messaging:chat.read"],
});
const res = await messaging("/api/conversations?contact=c_1");For a call a member started, pass their session token instead of the
workspace. Connect runs its entry gate on both applications, so a member
without a seat on the provider cannot reach it through you, and only scopes
the provider marked delegable may be asked for.
const messaging = connect.applications.fetch("https://messaging.cosmoops.com", {
subjectToken: await getConnectSessionToken(),
});applications.fetch refuses any URL outside the provider's origin - the
token is bound to that audience. Tokens are cached per (provider, workspace or
member, scope set) until a minute before they expire, one request at a time,
so a burst asks Connect once. A refusal is never cached. To hold the token
yourself, applications.token({ resource, workspaceId, scope }) and
applications.tokenForMember({ resource, subjectToken, scope }) return it
directly.
A refusal arrives as a ConnectError whose code is an RFC 6749 error -
access_denied (no connection, or no subscription to both applications),
invalid_scope, invalid_target (no application exposes that audience) and
so on. isConnectTokenRefusal(error) tells those apart from a network fault.
Being called (the provider)
import { ConnectTokenError, verifyConnectToken } from "@cosmoops/connect-sdk/next";
export async function GET(request: Request) {
try {
const caller = await verifyConnectToken(request, {
audience: "https://messaging.cosmoops.com",
scopes: ["messaging:chat.read"],
});
// caller.workspaceId, caller.applicationId, caller.memberId (null when app-only)
return Response.json(await conversationsFor(caller.workspaceId));
} catch (error) {
if (error instanceof ConnectTokenError) return error.toResponse();
throw error;
}
}Verification is local: the only network read is Connect's
/.well-known/jwks.json, cached for ten minutes and refetched early - at most
every 30 seconds - when a token names a key it has not seen. It accepts ES256
only, checks typ, the signature, iss, aud exactly, exp/iat with 30
seconds of leeway and every required scope. toResponse() answers 401 or 403
with the matching WWW-Authenticate header, or 503 when Connect's keys could
not be read. ./next's verifyConnectToken follows CONNECT_APP_URL;
./server's takes connectUrl.
Connect decides whether the caller may reach your API for this workspace with
these scopes. What memberId may do inside it is still yours to decide.
Events
A provider publishes an event declared in its exposedApi.events (API key
scope events:publish):
await connect.events.publish({
workspaceId,
type: "messaging.chat.lead_captured",
data: { contactId },
idempotencyKey: `lead-${chatId}`,
});Connect delivers it to every connected application holding the event's
requiredScope, as an application.event webhook, signed and retried like
every other. Receive it with an "application.event" handler on
createConnectWebhookHandler, or on a dedicated route:
import { verifyApplicationEvent } from "@cosmoops/connect-sdk/server";
const event = verifyApplicationEvent(await request.text(), request.headers.get("connect-signature"), secret);
// event.id (deduplicate on it), event.type, event.source, event.workspaceId, event.data