# @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/](https://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/](https://code.foss.global/) account to submit Pull Requests directly.

## Install

```bash
pnpm add @serve.zone/api
```

## What It Provides

The package exports a browser-safe management client and an explicit Node-only
runtime client:

```typescript
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 `TypedRouter` for 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

```typescript
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.

```typescript
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.

```typescript
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](#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 credential before TypedSocket publishes
`connected`. An optional `typedSocketClientOptions.restoreConnection` callback
runs after this registration and receives Socket8'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.

```typescript
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.

## API Groups

The client exposes focused groups instead of one large method list:

| Group | Purpose |
| --- | --- |
| `cluster` | Create, list, fetch, and update Cloudly clusters. |
| `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. |
| `platform` | Read capabilities, provider configs, desired state, and service bindings. |
| `backup` | Create, list, fetch, restore, and clean up isolated backup rehearsals. |
| `settings` | Read and update non-secret runtime settings and test provider connectivity. |
| `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.

```typescript
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:

```typescript
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()` and `getSecretMetadata()`
- `createSecret()`, `rotateSecret()`, and `changeSecretLifecycle()`
- `getSecretVersionPurgePreflight()` and `purgeSecretVersion()`
- `listSecretSets()`, `createSecretSet()`, `updateSecretSet()`, and
  `changeSecretSetLifecycle()`
- `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:

```typescript
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:

```typescript
import { CloudlyApiClient } from '@serve.zone/api/runtime';
```

The group exposes the Interfaces 27 runtime request surface:

- `getSecretRecipientEnrollmentState()`
- `beginSecretRecipientEnrollment()` and `completeSecretRecipientEnrollment()`
- `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.

## 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.

```typescript
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:

```typescript
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.

```typescript
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:

- `endpointUrl` must be one canonical `https://host/socket` URL. Plaintext
  `http:` and `ws:` 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_FAILED` envelope.
- `applyDesiredState` computes the canonical bytes and digest with the
  `@serve.zone/interfaces` helpers, so the desired state you pass is normalized
  and hashed exactly the way CoreMail re-derives it. `configEpoch` must strictly
  exceed `expectedAppliedConfigEpoch`; a stale expectation loses to whichever
  controller wrote last, instead of both overwriting each other.
- Credential *verifiers* are `argon2id` hashes 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.

## 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:

1. **When a caller authenticates** — `setIdentity()`, `loginWithUsernameAndPassword()` or
   `getIdentityByToken({ tagConnection: true })` — the registration runs on the current socket and
   either refusal rejects that call with `CloudlyProtocolIncompatibleError`, after closing the
   socket it could not authenticate.
2. **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's `CloudlyProtocolIncompatibleError` as the
   denial's `cause`: a `start()` that met the refusal rejects with that denial, and a reconnect
   that met it publishes one `restoreDenied` diagnostic on the socket's `diagnosticsSubject`
   before settling on `disconnected`. The client ends its own socket lifecycle in step:
   `typedsocketClient` is released, the refusal is retained on `lastProtocolRefusal`, and the
   session credential is kept.

```typescript
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 8 and TypedSocket 8.4 and requires Cloudly to
implement `registerCloudlyClientSession` and to speak the same
`@serve.zone/interfaces` major (see [Protocol handshake](#protocol-handshake)).
Some management methods include HTTP TypedRequest fallback through
`/typedrequest`; socket registration always uses the physical WebSocket.

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

```bash
pnpm install
pnpm run build
pnpm test
```

The package is authored as ESM TypeScript and built strictly with `tsbuild 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](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<br>
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.
