@serve.zone/api
@serve.zone/api is the TypeScript client for Cloudly, the serve.zone control plane. It wraps the shared @serve.zone/interfaces contracts with a CloudlyApiClient that can authenticate, open a TypedSocket connection, receive server-pushed events, and call Cloudly management APIs from services, automation, and CLI-style tools.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm add @serve.zone/api
What It Provides
The package exports a browser-safe management client and an explicit Node-only runtime client:
import { CloudlyApiClient } from '@serve.zone/api';
import { CloudlyApiClient as CloudlyRuntimeApiClient } from '@serve.zone/api/runtime';
CloudlyApiClient provides:
- A TypedSocket client connected to Cloudly.
- A local
TypedRouterfor Cloudly-to-client callbacks. - Identity helpers for token-based machine clients and username/password admin login.
- Domain-focused API groups for clusters, services, images, registries, versioned secrets, private networks, platform bindings, backups, settings, tasks, domains, DNS, and deployments.
- An RxJS subject for pushed cluster config updates.
The Node-only @serve.zone/api/runtime entry adds the cluster-only
secretRuntime group for recipient enrollment, registration expectations,
sealed secret and Corestore credential material exchange, and deployment
reporting. The browser-safe root does not resolve or expose the Interfaces
runtime module.
Quick Start
import { CloudlyApiClient } from '@serve.zone/api';
const cloudly = new CloudlyApiClient({
registerAs: 'cli',
cloudlyUrl: 'https://cloudly.example.com:443',
});
await cloudly.start();
await cloudly.loginWithUsernameAndPassword(
process.env.CLOUDLY_USERNAME!,
process.env.CLOUDLY_PASSWORD!
);
const services = await cloudly.services.getServices();
for (const service of services) {
console.log(service.id, service.data.name, service.data.imageVersion);
}
cloudlyUrl defaults to process.env.CLOUDLY_URL in Node.js and then
https://cloudly.layer.io:443 when not supplied. Browser clients use the
default URL unless they provide cloudlyUrl explicitly.
Authentication
Use loginWithUsernameAndPassword() for human/admin-style sessions. It talks to Cloudly's HTTP TypedRequest endpoint, registers the active socket through registerCloudlyClientSession, and then stores the returned identity. Login before start() retains the credential and registers it when the socket opens.
const identity = await cloudly.loginWithUsernameAndPassword('admin@example.com', 'password');
console.log(identity.role);
Use getIdentityByToken() for machine clients that already have a Cloudly token or jump code. This path uses the active TypedSocket connection, so call start() first.
const identity = await cloudly.getIdentityByToken(process.env.CLOUDLY_TOKEN!, {
tagConnection: true,
statefullIdentity: true,
});
Most API groups use cloudly.identity automatically. Pass identities explicitly only for methods that expose an identity argument.
Use await cloudly.setIdentity(existingIdentity) to adopt an IIdentity
returned by Cloudly's OIDC exchange or a stored login. The client snapshots
the supplied value, serializes it with other authentication operations, and
registers the active socket before replacing its API identity. Before
start(), adoption retains the credential for initial connection restoration.
Assigning the identity property directly does not register a socket or update
its reconnect credential.
Use await cloudly.clearIdentity() to log out. It immediately clears
identity and the retained reconnect credential, invalidates pending logins
and identity adoptions, and then joins socket shutdown. A later start()
opens anonymously. Ordinary stop() retains the current identity and session
credential for a later restart. identity has type IIdentity | undefined;
requireIdentity() returns the current identity or throws when logged out.
tagConnection: true authenticates the exact socket through the registration
RPC; the option no longer writes a client-owned identity tag. Cloudly verifies
the JWT and assigns its own identity metadata. Only { identity: { jwt }, protocol } is sent, where protocol is this client's protocol offer (see
Protocol handshake). Registration and authentication requests bypass
TypedRequest hooks and disable automatic retries. A rejected or malformed
registration closes the socket and rejects authentication without replacing
the existing identity.
statefullIdentity controls the identity used by API methods independently of
socket registration. With tagConnection: true, statefullIdentity: false, the
client preserves cloudly.identity and retains the separate socket credential
for reconnect. With tagConnection: false, token authentication does not
change the socket credential. Concurrent authentication calls commit in
invocation order. stop() aborts outstanding authentication; late responses
cannot change identity or reconnect credentials.
Reconnect re-registers the retained session before TypedSocket publishes
connected. A session opened by getIdentityByToken(token, { tagConnection: true })
is re-established from its token on every reconnect: the client exchanges the
token for a fresh identity and registers that, so an outage longer than the
identity's lifetime does not end the client, and identity follows the renewed
session while it still is that session's identity. A session adopted with
setIdentity() or a login has no token to renew it; once its identity has
expired it is not offered, and the socket restores anonymously for a new login.
When Cloudly refuses the session while a socket restores it (for example a
revoked token), TypedSocket denies that restoration: start() rejects with the
denial, and a reconnect ends the client. Either way the API client holds no
socket, keeps the refusal as a CloudlySessionRefusedError on
lastSessionRefusal (Cloudly's answer is its cause, and it is the cause of
the transport's denial), and drops the refused session; the next start()
opens anonymously so a new credential can authenticate it.
Only Cloudly's verdict refuses a session. When Cloudly could not answer a
restoration — its handler failed and TypedRequest answered exactly Internal server error with no error data, or it asked for a retry — the session is kept and the attempt is
deferred with TypedSocket's TypedSocketRestoreDeferral: the socket closes
that connection, waits, and offers the session again on a new one without
spending a reconnect. The waits follow the socket's own backoff, from
initialBackoffMs (one second by default) doubling up to maxBackoffMs (one
minute by default), and start over once a restoration succeeds. Meanwhile
connectionStatus is reconnecting; when the first restoration of start()
is deferred, start() resolves with the socket reconnecting rather than
connected. With autoReconnect: false TypedSocket cannot defer, so start()
rejects instead, still keeping the session.
An optional typedSocketClientOptions.restoreConnection callback
runs after this registration and receives TypedSocket 9's bounded request factory.
Use that callback's createTypedRequest and abortSignal for additional
connection setup; the public socket is not available during initial startup.
An authentication or callback failure prevents connection readiness.
Organization Private Networks
After adopting a Cloudly identity returned by its canonical IDP OIDC login,
use privateNetworks.listOwnedOrganizations() for current organization picker
entries. Cloudly limits them to the login's original organization grant and
current owner membership. Picker entries confer no authority: each protected
network or service operation checks current ownership on the server.
const { organizations } = await cloudly.privateNetworks.listOwnedOrganizations();
const organization = organizations[0];
if (organization) {
const page = { organizationId: organization.id, cursor: null, limit: 50 };
const { networks, nextCursor } = await cloudly.privateNetworks.listNetworks(page);
const { services } = await cloudly.privateNetworks.listServiceCandidates(page);
console.log(networks, nextCursor, services);
}
The facet also provides getNetwork({ organizationId, networkId }),
mutateNetwork(mutation), getServiceMembership({ organizationId, serviceId }),
and setServiceMembership(mutation). Service candidates contain only IDs and
names. Lists use explicit cursors and limits. These calls send only the active
JWT credential and use the client's existing socket or HTTP transport with
automatic retries disabled.
Mutation types and structural snapshot validators come from
@serve.zone/interfaces. The client captures validated mutation bodies before
dispatch. Create a mutation ID once per user intent and retain the exact body
and ID after an ambiguous failure. An explicit retry resends that intent;
changing a body requires a new intent after refreshing current state. Updates
and retirement require the observed network revision. Membership
expectedRevision: null means no document has ever existed; an existing empty
membership uses its current revision. A sole attachment must be the default
network; multiple attachments require an explicit default selection or null.
Acceptance records desired state. It does not prove packet enforcement or DNS readiness. A successful replay can return historical state, so refresh current network or membership data after acceptance instead of treating the receipt as the latest revision.
Service network policy
Each service has at most one network policy document holding two halves under
one revision: its published ports, which an organization owner writes, and
its network egress, which only a Cloudly administrator reads or writes because
platform endpoints are platform authority.
const scope = { organizationId: 'organization-primary', serviceId: 'service-api' };
const { networkEgress } = await cloudly.privateNetworks.getServiceNetworkEgress(scope);
const { networkEgress: accepted, replayed } =
await cloudly.privateNetworks.setServiceNetworkEgress({
...scope,
mutationId: 'grant-corestore-egress',
// The document's one revision; null only when the service has no document yet.
expectedRevision: networkEgress?.revision ?? null,
publicEgress: false,
platformEndpointIds: ['corestore-database-node-a'],
});
setServicePublishedPorts carries the document's egress forward and
setServiceNetworkEgress carries its published ports forward; either write
advances the shared revision, so a stale revision from the other half is
refused as a conflict. The first accepted write of either creates the document,
and from then on both reads return it: a document with
publicEgress: false and platformEndpointIds: [] is an explicit deny-all
policy, while a null read means the service has no document at all.
platformEndpointIds must be sorted and unique; the client refuses any other
shape before dispatch and never reorders it. Retries follow the mutation rules
above, and acceptance is neither an admitted workload nor applied packet state.
API Groups
The client exposes focused groups instead of one large method list:
| Group | Purpose |
|---|---|
cluster |
Create, list, fetch, and update Cloudly clusters, and export a cluster relay's bearer with its audit trail (administrators only). |
node |
List, fetch, and delete cluster nodes with their deletion impact, create node jump commands, and rename a node or reset it to its default hostname. |
services |
Create, list, fetch, update, delete, and inspect service registry targets. |
deployments |
List, create, update, restart, scale, and delete deployment records. |
image |
Create image records, list images, and push or pull image versions. |
externalRegistry |
Manage external container registries and verify registry access. |
secrets |
Manage versioned service, SecretSet, platform-provider, and system secrets. |
secretRuntime |
Operate cluster-scoped sealed secret delivery, Corestore control-credential retrieval, and credential publication through the Node-only @serve.zone/api/runtime entry. |
privateNetworks |
List owned organizations and service candidates, read and mutate organization private networks, read and set service membership and published ports, and read or set service network egress (administrators only). |
platform |
Read capabilities, provider configs, desired state, and service bindings, and set Cloudly's Corestore control token with its state and audit trail (administrators only). |
backup |
Create, list, fetch, restore, and clean up isolated backup rehearsals. |
settings |
Read and update non-secret runtime settings, test provider connectivity, and bootstrap Cloudly's dcrouter gateway credential. |
tasks |
List tasks, inspect executions, trigger jobs, and cancel executions. |
domains and dns |
Manage domain inventory, verification, DNS entries, and zones. |
Example service workflow:
organizationId and serviceData are both required. The former flat
service-data argument is removed without a compatibility fallback.
const service = await cloudly.services.createService({
organizationId: 'organization-primary',
serviceData: {
name: 'api',
description: 'Public API service',
imageId: 'image-api',
imageVersion: '1.0.0',
environment: {
NODE_ENV: 'production',
},
serviceCategory: 'workload',
deploymentStrategy: 'limited-replicas',
scaleFactor: 2,
balancingStrategy: 'round-robin',
ports: {
web: 3000,
},
domains: [
{
name: 'api',
protocol: 'https',
},
],
deploymentIds: [],
},
});
const registryTarget = await service.getRegistryTarget('latest');
Example platform-binding workflow:
const capabilities = await cloudly.platform.getPlatformCapabilities();
const emailBindings = await cloudly.platform.getPlatformBindings({
capability: 'email',
});
console.log(capabilities.capabilities, emailBindings.bindings);
Secret Management
Version 12 keeps the version 10 removal of the SecretGroup and SecretBundle
APIs and exposes their legacy-free replacement as cloudly.secrets.
Service.getSecretBundleAsFlatObject() remains removed; plaintext flattening
has no replacement API. The client retains
the administrative secret RPC surface introduced with Interfaces 24:
getSecretIngressRecipient()listSecrets()andgetSecretMetadata()createSecret(),rotateSecret(), andchangeSecretLifecycle()getSecretVersionPurgePreflight()andpurgeSecretVersion()listSecretSets(),createSecretSet(),updateSecretSet(), andchangeSecretSetLifecycle()getSecretSetConsumerRollout()setServiceSecretSetAttachments()previewServiceSecretResolution()
Each method returns the complete Interfaces response, including the applicable
secret, target, SecretSet, or attachment revision fences. Requests always use
the authenticated client's JWT credential without forwarding identity claims.
Purge preflight accepts optional cursor and limit fields and validates the
exact request and response schemas. Irreversible purge requires
mutationId, secretId, secretVersionId, expectedSecretRevision,
expectedSecretVersionRevision, and expectedTargetSecretsRevision fences.
Use provided-bytes when the API client should seal local bytes to Cloudly's
current active ingress recipient:
const valueBytes = new TextEncoder().encode(process.env.DATABASE_PASSWORD!);
try {
const result = await cloudly.secrets.createSecret({
mutationId: crypto.randomUUID(),
target: {
kind: 'service',
serviceId: 'service-api',
},
key: 'DATABASE_PASSWORD',
environment: 'production',
name: 'Database password',
delivery: {
type: 'launcher-environment',
variableName: 'DATABASE_PASSWORD',
uid: 1000,
gid: 1000,
mode: 0o400,
},
valueInput: {
mode: 'provided-bytes',
bytes: valueBytes,
},
expectedTargetSecretsRevision: 0,
});
console.log(result.secret.id, result.targetSecretsRevision);
} finally {
valueBytes.fill(0);
}
The client copies caller-provided bytes. Create validates them against the selected delivery; rotate validates the byte type and 500 KiB limit before any request. Both operations obtain a fresh active ingress recipient, seal with SmartCrypto using the exact Interfaces request context, and wipe their owned byte, key, and context copies. The client does not alter the caller's array, cache recipients, retry failed mutations, or log value material. The caller remains responsible for wiping its own byte array. Direct ingress-recipient lookups reject responses with extra fields, invalid recipient metadata, or any recipient that is not active; there is no compatibility fallback.
Use { mode: 'generated', encoding, bytes } to request bounded server-side
generation. A strict existing Interfaces { mode: 'sealed', envelope } input
is also accepted when the caller already owns sealing; Cloudly validates its
recipient and request context. Generated and externally sealed inputs do not
perform an ingress-recipient lookup.
These methods require a Cloudly release that implements the Interfaces 24 secret RPCs. Control-plane releases without those handlers reject the calls.
Cluster Secret Runtime
Coreflow-style machine clients import the Node-only runtime client and use
cloudly.secretRuntime after token authentication:
import { CloudlyApiClient } from '@serve.zone/api/runtime';
The group exposes the Interfaces 27 runtime request surface:
getSecretRecipientEnrollmentState()beginSecretRecipientEnrollment()andcompleteSecretRecipientEnrollment()getCoreflowSecretRuntimeRegistrationExpectation()getResolvedSecretMaterial()getCorestoreControlCredentialMaterial()publishCorestoreCredentialMaterial()reportSecretDeploymentState()
Every request uses only the authenticated client's JWT credential. The client
reconstructs the exact wire DTO and drops extra caller fields. Cluster scope is
never accepted from method options. Runtime requests use maxRetries: 0; the
client does not automatically retry enrollment or deployment-report mutations.
Corestore publication likewise requires the caller to reuse its exact
mutationId, grant, ingress recipient fence, and sealed envelope for an
explicit retry; the client never chooses organization, cluster, or provider
authority.
These methods do not establish a runtime session. Cloudly must authorize the
caller's registered runtime separately from its JWT-authenticated API socket.
Registration-expectation responses must be either an exact recognized
unavailable response or an exact available response containing fresh,
validated target, WorkloadInit approval, and active-recipient authority.
Malformed, extra-field, stale, and legacy authority is rejected.
The caller remains responsible for generating and protecting the recipient private key, opening sealed material only after Interfaces verification, and computing the canonical deployment report digest.
Gateway Credential Bootstrap
Cloudly enrolls itself at dcrouter as one least-privilege gateway client. The
only way that credential enters Cloudly is a dcrouter admin token an
administrator hands in once, for the first enrollment and whenever the
enrollment state reads admin-bootstrap-required. The settings group carries
the Interfaces 32.29 requests:
bootstrapExternalGatewayCredential({ target, bootstrapToken })getExternalGatewayEnrollmentState()
const tokenBytes = new TextEncoder().encode(adminTokenFromStdin);
try {
const { state } = await cloudly.settings.bootstrapExternalGatewayCredential({
target: {
gatewayUrl: 'https://gateway.example.com',
gatewayClientId: 'cloudly.example.com',
},
bootstrapToken: tokenBytes,
});
console.log(state.status);
} finally {
tokenBytes.fill(0);
}
const { state } = await cloudly.settings.getExternalGatewayEnrollmentState();
target names the dcrouterGatewayUrl (HTTPS only) and
dcrouterGatewayClientId the administrator read from Cloudly's settings. The
client refuses a token that is not 1 to 1024 bytes of visible ASCII before any
request, fetches a fresh active ingress recipient, seals a copy of the token
under data.createGatewayBootstrapEnvelopeContext(target) with the same sealing
the secret methods use, and validates the finished request with
validateBootstrapExternalGatewayCredentialRequest before sending it. The
envelope opens only for a request naming the same target, and Cloudly refuses a
target that differs from its settings (gateway-target-mismatch). The client
wipes its token and context copies, never logs or keeps the token, and never
retries the call; the caller wipes its own array and revokes the admin token
at dcrouter afterwards. Both methods accept only an exact { state } answer
that passes data.validateExternalGatewayEnrollmentState; a refusal is one of
data.externalGatewayBootstrapRefusals and leaves the held credential
unchanged.
These methods require a Cloudly release that serves the Interfaces 32.29 gateway bootstrap requests. This package has no command line; a CLI built on it reads the token from standard input, never from arguments, and passes the bytes here.
Cluster Relay Bearer Export
A cluster's relay reads its bearer from a file an operator hands it. The
cluster group fetches that bearer from Cloudly over the API, so nothing has to
reach into Cloudly's process. It carries the Interfaces 32.41 requests:
exportClusterRelayAuthorization({ clusterId, expectedGeneration })getClusterRelayAuthorizationExports(clusterId)
// `generation` is what `getClusterRelayCredential` answered for the cluster.
const exported = await cloudly.cluster.exportClusterRelayAuthorization({
clusterId,
expectedGeneration: generation,
});
// Write exported.authorization where the relay reads it, mode 0600, and nowhere else.
const { receipts } = await cloudly.cluster.getClusterRelayAuthorizationExports(clusterId);
expectedGeneration is exact, never a minimum: Cloudly answers the bearer the
credential delivered at that generation and refuses with one of
data.clusterRelayAuthorizationExportRefusals otherwise, for example
relay-credential-generation-mismatch after a rotation committed. A new bearer
is a rotation (mutateClusterRelayCredential) followed by an export at the new
generation. Both methods need a freshly signed-in administrator and send only
its JWT credential.
The client checks the request with
data.snapshotClusterRelayAuthorizationExportRequest before anything is sent,
fires once and never retries (each answered export is a separate handover and a
separate audit receipt), and joins the answer to the request with
data.bindClusterRelayAuthorizationExport before returning it: the sealed
credential is the requested cluster's at the requested generation, the bearer
names that credential and the receipt names that exact credential. An answer
that fails the join is refused with cluster relay authorization export response is invalid, which never quotes the answer. The client never logs or
keeps the bearer; the caller owns it.
getClusterRelayAuthorizationExports answers the value-free audit receipts
(administrator, credential reference: id, generation and digest, and time; never the bearer or a hash of it), oldest
first. It accepts only an exact { receipts } whose every receipt passes
data.snapshotClusterRelayAuthorizationExportReceipt, names the requested
cluster and keeps written order.
Cluster Node Hostnames
Cloudly owns the static hostname each cluster node should carry
(IClusterNode.data.hostnameIntent), with the default scheme
cloudly-worker-<name>; Spark applies it on the host. The node group carries
the Interfaces 32.52 requests:
setClusterNodeHostname({ nodeId, expectedGeneration, hostname })resetClusterNodeHostname({ nodeId, expectedGeneration, name? })createNodeJumpCommand({ clusterId, role?, nodeType?, ttlMs?, nodeName? })
const node = await cloudly.node.getNodeById(nodeId);
const expectedGeneration = node.data.hostnameIntent?.generation ?? 0;
// Rename: the node keeps its name, only the hostname changes.
const { node: renamed } = await cloudly.node.setClusterNodeHostname({
nodeId,
expectedGeneration,
hostname: 'edge-alpha',
});
// Back to the default, optionally under a new name: cloudly-worker-beta.
await cloudly.node.resetClusterNodeHostname({
nodeId,
expectedGeneration: renamed.data.hostnameIntent?.generation ?? 0,
name: 'beta',
});
// A new host that enrolls as cloudly-worker-beta.
const { command } = await cloudly.node.createNodeJumpCommand({ clusterId, nodeName: 'beta' });
expectedGeneration is the generation of the node's current intent, 0 when
it has none; it alone serializes changes, and an intent the node has not yet
converged to may be replaced. Both methods answer { node } as it now stands,
so no second read is needed. A reset without name reuses the name of the
node's current intent. Both are fired once and never retried. Cloudly refuses
with a TypedResponseError whose errorText is one of
data.clusterNodeHostnameRefusals (node-missing, hostname-invalid,
name-invalid, hostname-in-use, hostname-unchanged,
node-hostname-referenced, node-runtime-unsupported, conflict), and the
client passes it through unchanged; a refused request changes nothing. Read the
node again after conflict.
Corestore Control Token
Every cluster relay calls its nodes' Corestore control API with one control
token, which Cloudly stores for both Corestore provider configs. The platform
group sets it over the API, so nothing has to reach into Cloudly's process. It
carries the Interfaces 32.42 requests:
setCorestoreControlToken({ token, expected })getCorestoreControlTokenState()
const { state } = await cloudly.platform.getCorestoreControlTokenState();
const { state: stored, receipt } = await cloudly.platform.setCorestoreControlToken({
token: tokenFromStdin,
expected: state, // { state: 'absent' } or { state: 'present', revision }
});
expected is the state the administrator last read, never a minimum: Cloudly
stores the token only while it is still exactly there, and refuses with one of
data.corestoreControlTokenSetRefusals otherwise (token-present,
token-absent and token-revision-mismatch when another set committed in
between, token-invalid, provider-unavailable). Both methods need a freshly
signed-in administrator and send only its JWT credential.
The client checks the request with data.snapshotCorestoreControlTokenSetRequest
before anything is sent (a token outside isCorestoreControlToken is refused
with corestore control token set request is invalid, which never quotes it),
fires the set once through the secret request path described under
Transport Notes, and joins the answer to the request with
data.bindCorestoreControlTokenSet: the state is present at revision 1 after an
absent expectation and at the expected revision plus one otherwise, and the
receipt is an admin-request receipt for exactly that revision. An answer that
fails the join is refused with corestore control token set response is invalid. The answer and the receipt never carry the token.
getCorestoreControlTokenState answers the state and its value-free receipts
(id, revision, via, actorId, setAt), oldest first, and accepts only an
answer that passes data.snapshotCorestoreControlTokenStateRead: receipts are
contiguous and end at the state's revision, and a token Cloudly stored before
receipts existed is revision 1 without one. A receipt via: 'container-cli'
names operator:secrets-cli, Cloudly's in-container
secrets set-corestore-control-token command, which advances the same revision.
Isolated Restore Lifecycle
Isolated restores rehearse a backup into a server-generated, non-runnable scratch namespace. Cloudly derives the source service and cluster from the authoritative backup and generates the restore, scratch namespace, staging archive, and resource mapping identifiers. Callers cannot select a runnable target service or supply those generated identifiers.
A restore names its target by targetNodeId, the cluster node every control call is routed to. The node name the signed restore grant authorizes is derived from that node by Cloudly and refused as caller input; getIsolatedRestores filters by either.
Use a stable, unique idempotency key for one restore intent. Retrying the same create request with that key returns the original restore instead of provisioning another scratch namespace.
const { restore } = await cloudly.backup.createIsolatedRestore({
sourceBackupId: 'backup-source',
targetNodeId: 'node-rehearsal',
purpose: 'quarterly recovery rehearsal',
idempotencyKey: 'quarterly-rehearsal-2026-q3',
ttlMs: 24 * 60 * 60 * 1000,
});
const page = await cloudly.backup.getIsolatedRestores({
sourceBackupId: restore.sourceBackupId,
status: 'ready',
limit: 25,
});
const current = await cloudly.backup.getIsolatedRestoreById(restore.id);
await cloudly.backup.cleanupIsolatedRestore({
restoreId: current.restore.id,
reason: 'rehearsal completed',
});
if (page.nextCursor) {
const nextPage = await cloudly.backup.getIsolatedRestores({
cursor: page.nextCursor,
limit: 25,
});
console.log(nextPage.restores.length);
}
These methods require an authenticated client identity with a JWT. The client sends only the JWT credential; Cloudly must verify it and derive actor, role, tenant, and ownership authority server-side. Cleanup is asynchronous and idempotent, so inspect the returned restore status or fetch it again until it reaches cleaned.
Server-Pushed Events
Cloudly can call back into connected clients through the client's local TypedRouter. One callback stream is exposed as an RxJS subject:
cloudly.configUpdateSubject.subscribe((configUpdate) => {
console.log('received cluster config update', configUpdate.configData.id);
});
This is why long-running clients such as Coreflow register over TypedSocket instead of using one-off HTTP requests only.
CoreMail Control Client
CoreMailControlClient is the control-plane side of a CoreMail instance. It is
a separate export from CloudlyApiClient because it speaks CoreMail's own
TypedSocket protocol directly, not Cloudly's.
CoreMail has two session kinds. The workload session is what a hosted app
opens to submit and receive mail, and it lives in
@serve.zone/platformclient. The control session is what the control plane
opens to tell one CoreMail replica which bindings, gateway and limits it should
be running, and to read back reconciliation status and per-service mail
statistics. Cloudly owns that session for a cluster, Onebox for a single host.
import { CoreMailControlClient } from '@serve.zone/api';
const coreMail = new CoreMailControlClient({
endpointUrl: 'https://coremail.example.com/socket',
credentialId: process.env.COREMAIL_CONTROL_CREDENTIAL_ID!,
credentialVersion: Number(process.env.COREMAIL_CONTROL_CREDENTIAL_VERSION),
credentialSecret: process.env.COREMAIL_CONTROL_CREDENTIAL_SECRET!,
});
await coreMail.start();
const status = await coreMail.applyDesiredState(desiredState, {
expectedAppliedConfigEpoch: lastAppliedConfigEpoch,
});
await coreMail.stop();
Notes on the contract:
endpointUrlmust be one canonicalhttps://host/socketURL. Plaintexthttp:andws:are accepted only for a loopback host, for in-process tests and a co-located Onebox.- A rejected credential arrives as a dropped socket, not a typed error:
CoreMail closes the peer before the authentication failure propagates. Handle
a disconnect, not an
AUTHENTICATION_FAILEDenvelope. applyDesiredStatecomputes the canonical bytes and digest with the@serve.zone/interfaceshelpers, so the desired state you pass is normalized and hashed exactly the way CoreMail re-derives it.configEpochmust strictly exceedexpectedAppliedConfigEpoch; a stale expectation loses to whichever controller wrote last, instead of both overwriting each other.- Credential verifiers are
argon2idhashes produced by whoever mints the credential. This package deliberately carries no argon2 or other native dependency, and never sees a plaintext binding secret. - Nothing in this client logs a credential, a bearer token or a grant.
Per-service mail statistics
getServiceMailStatistics({fromDayUtc, toDayUtc, serviceIds?, cursor?, limit})
returns one page of ICoreMailServiceMailStatistics from
@serve.zone/interfaces — {tenantId, serviceId, bindingId, dayUtc, outbound, inbound, updatedAt}, where outbound counts submittedApi, submittedSmtp,
delivered, deferred, failed and deadLettered, and inbound counts
received, acknowledgedProcessed and acknowledgedDiscarded. Days are UTC
calendar days, YYYY-MM-DD, and both bounds are inclusive.
Every entry is run through the published
normalizeCoreMailServiceMailStatistics, so a page that drops, renames or adds
a field fails loudly instead of arriving as a silently zeroed counter. On top of
that, the client rejects a page larger than the requested limit, an entry
outside the requested day range, and an entry for a service that was not asked
for. Omitting serviceIds means every service; an empty array is refused,
because it would widen the query rather than narrow it.
Pagination is CoreMail's: follow nextCursor until it is absent.
The transfer-grant caveat
Desired state is pushed in three steps: prepare over the socket, an HTTP
PUT of the canonical JSON bytes to the grant's path against the authenticated
origin, then apply over the socket. The HTTP hop exists only because CoreMail
currently issues HTTP transfer grants; serve.zone/AGENTS.md records that a
TypedSocket-native byte transfer is planned to replace them.
That hop is therefore isolated in a single protected method,
uploadDesiredState(grant, bytes). It is the only place in this client that
touches fetch, and its contract is just "make the grant's bytes reach
CoreMail, or throw". When the native transfer lands, that one method is
replaced and nothing else changes.
The upload is byte-exact by necessity: CoreMail rejects the PUT unless
Content-Length equals the grant's lengthBytes and the body hashes to the
grant's sha256, and it re-canonicalizes the received bytes and refuses
anything that is not identical to what the digest covers.
Idempotency
A prepare that reports replayed: true means CoreMail already had this exact
(configEpoch, digest) staged from an interrupted attempt. It does not
mean the state is applied: a staged snapshot still needs its bytes, and a fresh
single-use grant is issued on every preparation, so the client uploads and
applies either way.
When a sequence fails, the client asks CoreMail what it actually has before
retrying. If the epoch and digest are already applied, that status is returned
as success — which is what makes an apply that committed but never answered
resolve correctly instead of retrying into a fence conflict.
Connection Status
TypedSocket 9 ends a client for good in more ways than a refusal: its
reconnects run out (maxRetries, at most 100), Cloudly refuses its handshake
(a Cloudly on another TypedSocket major), or its connection is lost with
reconnection turned off. Its disconnected status is transient on the way to
the next reconnect, so an end can only be read from its diagnostics. The client
does that once for its consumers: connectionStatus says where the connection
stands, and connectionStatusSubject publishes every change, the current
status first.
import type { TCloudlyConnectionStatus } from '@serve.zone/api';
cloudly.connectionStatusSubject.subscribe((status: TCloudlyConnectionStatus) => {
if (status.state === 'ended') {
// The client already released the socket; start() opens the next one.
console.warn(`Cloudly connection ended: ${status.end.reason}`);
}
});
| State | Meaning |
|---|---|
stopped |
Never started, stopped by its owner (stop(), or the abortSignal passed to start() aborting), or start() failed (and rejected). |
connecting |
start() is opening the socket. |
connected |
The socket is connected and its session restored. |
reconnecting |
The socket is connecting again after a lost connection or a deferred restoration; requests fail until it is connected. |
ended |
The socket ended without its owner stopping it; end.reason says why. It stays until the next start() or stop(). |
end.reason |
Cause | What a later socket meets |
|---|---|---|
unreachable |
The connection was lost and not restored: the reconnects ran out (attempts), or reconnection was off (attempts: 0). |
An outage; start again with your own backoff. |
handshakeRefused |
Cloudly refused the TypedSocket handshake, typically a TypedSocket 8 Cloudly; detail is the close. |
The same refusal until one side is upgraded. |
protocolRefused |
Cloudly refused this build's session offer; refusal is also on lastProtocolRefusal. The session is kept. |
The same refusal until one side is upgraded. |
sessionRefused |
Cloudly refused the offered session; refusal is also on lastSessionRefusal. The session is dropped. |
An anonymous socket; authenticate with a new credential. |
restoreDenied |
TypedSocket's own restoration bounds, or the caller's restoreConnection refused. |
Depends on the cause on error. |
tagDenied |
Cloudly denied a client tag while a reconnect reconciled it; code is the denial. |
The tag is dropped; a later socket can connect. |
authenticationFailed |
A session registration on the live socket failed, so the client closed the socket rather than leave it ambiguously authenticated; error is what the caller was rejected with. |
A socket restoring the previous session. |
On every end the client has already released the socket: typedsocketClient
is undefined and start() opens a new one. Whether and when to start again is
the owner's policy — a long-running service reopens after an outage, while a
CLI reports it. The client never reopens a socket on its own.
Protocol handshake
The protocol version is the installed @serve.zone/interfaces release: one release is exactly
one contract, and there is no protocol number, version suffix or schemaVersion beside it.
Every session registration carries cloudlyClientProtocolOffer — this build's offer for the one
session kind the client opens — and Cloudly answers with its own. Cloudly reads the offer before
it validates the body or the JWT, so a major mismatch is named instead of arriving as a credential
or contract rejection, and the client negotiates the answered offer in turn: a controller that
accepted this client but answers an offer this build cannot serve is refused rather than used.
Cloudly negotiates the offer and the client negotiates the answer in that order on every registration; only who receives a refusal differs:
- When a caller authenticates —
setIdentity(),loginWithUsernameAndPassword()orgetIdentityByToken({ tagConnection: true })— the registration runs on the current socket and either refusal rejects that call withCloudlyProtocolIncompatibleError, after closing the socket it could not authenticate;connectionStatusends withprotocolRefused. - When a connection restores the retained session — the socket
start()opens, and every reconnect after it — the same registration runs with no caller on it. The transport denies that restoration terminally and carries this client'sCloudlyProtocolIncompatibleErroras the denial'scause: astart()that met the refusal rejects with that denial, and a reconnect that met it publishes onerestoreDenieddiagnostic on the socket'sdiagnosticsSubjectbefore settling ondisconnected. The client ends its own socket lifecycle in step:typedsocketClientis released, the refusal is retained onlastProtocolRefusal, the session credential is kept, and a reconnect's refusal endsconnectionStatuswithprotocolRefused.
import { CloudlyApiClient, CloudlyProtocolIncompatibleError, cloudlyClientProtocolOffer } from '@serve.zone/api';
console.log(cloudlyClientProtocolOffer); // { interfacesVersion: '32.x.y', minimumPeerVersion: '32.0.0' }
try {
await cloudly.loginWithUsernameAndPassword('admin@example.com', 'password');
} catch (error) {
if (error instanceof CloudlyProtocolIncompatibleError) {
// error.message is the shared refusal line; error.refusal names the reason
// ('major-mismatch', 'peer-below-minimum', 'self-below-peer-minimum') and both offers.
console.error(error.refusal.reason, error.refusal.refusing, error.refusal.refused);
}
}
A refusal stands until an operator upgrades one of the two sides, so this client never retries it:
the registration disables retries, and a denied restoration is terminal — the transport never
reconnects from it. Offering again is the caller's decision. After a refused restoration the
client holds no socket, the reason is on lastProtocolRefusal, and the published denial names the
same refusal as its cause; start() opens a new socket lifetime, clears that refusal and
re-offers the retained credential, so a caller that has upgraded a side recovers with start() —
or with a fresh authentication, which registers the same way.
minimumPeerVersion is 32.0.0 and is raised only by the release that starts depending on a
later @serve.zone/interfaces minor, which is named in the changelog.
Transport Notes
The client uses TypedRequest 9.1.0 and TypedSocket 9 and requires Cloudly to
run TypedSocket 9, to implement registerCloudlyClientSession and to speak the
same @serve.zone/interfaces major (see Protocol handshake).
TypedSocket's handshake admits only peers of its own package major, so a
Cloudly still on TypedSocket 8 and this client part at the handshake, before
any request runs:
start() rejects with TypedSocket's TypedSocketHandshakeError, and a
reconnect that meets one ends connectionStatus with handshakeRefused. An
application that shares this client's router graph, or composes it with its
own TypedRouter, must resolve the same @api.global/typedrequest 9.1.0.
Some management methods include HTTP TypedRequest fallback through
/typedrequest; socket registration always uses the physical WebSocket.
Every request whose body or answer carries a secret bypasses TypedRequest
traffic hooks. A hook is a process-wide observer (TypedRouter.setGlobalHooks,
TypedRouter.addGlobalHooks); one registered with a payload projector, as a
traffic logger or devtools panel is, would otherwise receive the payload as it
was sent. CloudlyApiClient fires these requests through one secret request
path, once and without retries, over the socket when it is started and over
/typedrequest otherwise:
| Request | Secret |
|---|---|
platform.setCorestoreControlToken |
the Corestore control token (body) |
settings.bootstrapExternalGatewayCredential |
the sealed dcrouter admin token (body) |
cluster.exportClusterRelayAuthorization |
the relay bearer (answer) |
getCertificateForDomain |
the certificate's private key (answer) |
externalRegistry.createRegistry, updateRegistry, getRegistryById, getRegistries, verifyRegistry, and a registry record's update() |
the registry password, token or API key in the write-only data.password (body; a Cloudly before Interfaces 32.60 also answered it) |
node.createNodeJumpCommand |
the single-use jump code (answer) |
secretRuntime.completeSecretRecipientEnrollment |
the opened enrollment challenge (body) |
Sign-in (loginWithUsernameAndPassword), token exchange (getIdentityByToken)
and session registration set the same bypass on the requests they build
themselves. CoreMailControlClient sets it on coreMailAuthenticateControl
(the control credential secret) and coreMailPrepareDesiredStateUpload (whose
grant carries the upload bearer token).
Requests that carry only ciphertext sealed to a recipient stay observable:
secrets.createSecret and rotateSecret, and the runtime's resolved secret
material, Corestore control credential material and credential publication.
So do value-free reads beside the requests above. The caller's own session JWT,
which every authenticated request carries in identity, is not hidden: a hook
registered with a payload projector sees it on every observable request.
Call start() before using socket-backed API groups. Concurrent starts share
one socket and one router owner. typedsocketClient is undefined before start
and after stop; requireTypedSocket() returns the active client or throws a
clear lifecycle error. stop() joins startup and socket cleanup so a later
start() can reuse the router and restore the retained session.
Image upload and download use directional, connection-bound VirtualStreams.
Uploads wait for both the server response and the receiver's acceptance before
refreshing image metadata. image.pushImageVersion(version, readable, options)
accepts acceptanceTimeoutMs (ten minutes by default, integer milliseconds from
1 through 3,600,000) and abortSignal. The fixed acceptance wait starts after
FIN transmission; Cloudly independently limits its own processing time. Transfer
progress and parent request deadlines remain unchanged. Caller cancellation
aborts and joins the source, request, stream receipt, and final metadata refresh,
including cancellation after readable EOF. image.update({ abortSignal }) also
supports a cancellable standalone refresh. Downloads expose a backpressured readable stream,
accept the remote transfer only after complete consumption, and reject it if
the caller cancels reading.
Development
pnpm install
pnpm run build
pnpm test
The package is authored as ESM TypeScript and built strictly with tsrepo build tsfolders.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in license.md.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.