@iveri/contracts
v0.20.0
Published
Wire contracts shared by every Iveri backend and frontend. Zero runtime dependencies.
Downloads
4,007
Readme
@iveri/contracts
The wire contract every Iveri service and frontend compiles against: shared types and plain enums, and nothing else.
Release 0.20.0 adds the notification contract under @iveri/contracts/notification —
NotificationChannel, NotificationDeliveryStatus, NotificationTemplate, Notification with
its per-channel NotificationDelivery list, InAppNotification and the submit body — plus six
notification:* permissions. Two absences are decisions and each carries a spec: there is no
push channel, because a channel a caller can name and nothing can deliver reads as a successful
submit and reaches nobody; and no delivered status, because a relay accepting a message is not
evidence a person received it and nothing subscribes to a bounce. Status lives on the delivery
rather than the notification — one that emailed successfully and dead-lettered in the inbox has
two true answers and no single one.
Release 0.19.0 adds MessageVariableInput — a declaration as a client sends it, where the
optional halves may be absent. A separate type rather than MessageVariable with everything
optional, because omitting a field on the way in and receiving null on the way out are
different statements.
Release 0.18.0 adds the localization contract under @iveri/contracts/localization —
Locale, Namespace, Message with its declared MessageVariable list, Translation,
Release, the build-time TranslationBundle and the runtime render shapes — plus five
localization:* permissions. Two consumers exist from day one and they read it differently: a
frontend pulls a bundle at build time and compiles it in, a backend renders a message at runtime
for a locale that belongs to the recipient rather than the caller.
Release 0.17.0 adds realtime.unsubscribe. Subscribing is additive and capped per socket, so
without it a long-lived inbox that pages through conversations reaches the cap and then silently
stops receiving anything.
Release 0.16.0 adds the Unibox realtime contract under @iveri/contracts/unibox —
RealtimeConversationEvent, the publish body, the socket message shapes and the event-name
constants — plus the unibox:realtime:publish permission. It is shared by three repos at once
(unibox-api publishes, unibox-realtime fans out, unibox-web subscribes), which is exactly
the case a hand-copied interface drifts on.
pnpm add @iveri/contracts// Cross-service primitives.
import { CursorPage, ErrorCode, Maybe, Paginated, UUID, UserPermission } from '@iveri/contracts';
// One service's wire surface.
import type { CaptureSummary, Endpoint } from '@iveri/contracts/conduit';
import type { AuthSession, Principal } from '@iveri/contracts/identity';
import type { Conversation, Message } from '@iveri/contracts/unibox';
import type { Namespace, TranslationBundle } from '@iveri/contracts/localization';
import type { Notification, NotificationChannel } from '@iveri/contracts/notification';Seven entry points, and no others — never @iveri/contracts/dist/....
The per-service surfaces are not re-exported from the root, deliberately. Conduit owns a
Provider, a Connection and an Endpoint; Unibox owns a Channel and a Contact and would
have wanted two of those names for entirely different things. Keeping each service behind its own
entry point means that collision never has to be resolved by renaming a type in a shipped API —
and it stopped being hypothetical the moment unibox was added.
Zero runtime dependencies — the rule that defines this package
This package ends up inside browser bundles, so it declares no dependencies,
peerDependencies or optionalDependencies, and its compiled output imports nothing outside
itself. Nothing from @nestjs/*, typeorm, class-validator, or node:* may ever appear
here — not even as a type-only import.
That is why it is a separate package from @iveri/nest-sdk rather than a folder inside it.
It is enforced by scripts/verify-zero-runtime-deps.mjs, which runs as part of build: CI
fails on the PR that adds a dependency, not on a frontend build weeks later.
Anything needing a dependency belongs in @iveri/nest-sdk (backend) or @iveri/react-kit
(frontend).
What is here
type/
| Type | Use |
| ---------------------------------- | --------------------------------------------------------------------------------------------- |
| Nil, Maybe<T>, Nullable<T> | absent values. Nullable for anything crossing the wire — JSON.stringify drops undefined |
| UUID | template-literal shape hint; validation stays at the edge with @IsUUID() |
| Paginated<T> | offset paging, where the client needs a total or arbitrary page jumps |
| CursorPage<T> | cursor paging — the default for lists that grow while being read |
| IsoDateTime | a timestamp as it appears on the wire. JSON has no date type — this is a string |
| ApiErrorResponse, ApiErrorBody | the error envelope. Only errors are enveloped; a success is the bare payload |
enum/
ErrorCode — the stable, machine-readable error classification carried in every error
response. Clients branch on code; message is for humans and may change at any time.
UserPermission + ALL_USER_PERMISSIONS — one catalogue of permissions for the whole
platform, not one per service. iveri-identity-api stores members on role rows, resolves
them into access tokens and validates custom-role input against ALL_USER_PERMISSIONS; every
other service reads them back out of the token to authorize a request; the frontend's
<RequirePermission> branches on the same values. A service defining its own enum locally
would find its permissions unassignable, because identity would reject them as unknown.
Values are <resource>:<action>, or <service>:<resource>:<action> where a resource name
would otherwise collide — identity's members are unprefixed because they predate the shared
catalogue and are already embedded in issued tokens and stored role rows.
api/identity/ → @iveri/contracts/identity
iveri-identity-api's authentication surface: SignupBody, LoginBody, RefreshTokenBody,
AcceptInvitationBody, AuthTokens, ServiceToken, Principal, AuthSession, plus
TenantStatus, MembershipStatus and PrincipalType.
It also holds AccessTokenPayload — the claims inside the JWT itself, rather than a request
or response body. It belongs here for a stronger reason than the rest: identity is the only
service that issues a token and every other service verifies one, so both sides must agree
claim for claim. conduit-api kept a hand-copied duplicate until the pt claim had to be added
to both at once — the drift a copy invites, arriving exactly when it is most expensive.
Auth only. Identity also exposes tenant, user, membership, role and API-key routes; those have one consumer each and stay in the service until a second one appears.
api/conduit/ → @iveri/contracts/conduit
conduit-api's full admin surface.
Inspect mode: Endpoint, CaptureSummary/CaptureDetail, Provider with its
SignatureManifest/HandshakeManifest, Replay, SignatureDiagnostic.
Gateway mode: Destination, Route with its HeaderMatcher/BodyMatcher, Delivery and
DeliveryAttempt.
Plus the request bodies for each, and the seven enums they are built from — SignatureVerdict,
SignatureAlgorithm, SignatureEncoding, SignatureHeaderScheme, HandshakeKind,
MatchOperator, DeliveryStatus.
api/unibox/ → @iveri/contracts/unibox
unibox-api's surface: Channel and its ChannelIngestSecretResponse, Contact with its
ContactIdentity, Conversation/ConversationWithMessages, Message with MessageAttachment,
Team, CannedReply, plus the request bodies and six enums — ChannelPlatform,
ConversationStatus, MessageDirection, MessageAuthorType, MessageStatus,
MessageContentType.
api/unibox-ai/ → @iveri/contracts/unibox-ai
The authenticated agent-runtime boundary: bounded conversation context, the handoff-aware agent run request, its acceptance statuses and response, and safe tenant MCP server metadata and registration request bodies. Credential values are request-only and never appear in responses.
Two of those enums carry members nothing writes yet, and both are deliberate rather than
speculative. MessageAuthorType.AI exists before unibox-ai does, because it is what an agent
taking over reads to know what the customer was already told — adding it later would leave every
message written before then indistinguishable from a human's, retroactively. ChannelPlatform.ECHO
is a first-class member for the same reason Conduit's local-echo connector is: the whole pipeline
is provable without a Meta app, and a test double would put a branch in the normalizer registry
that only exists under Jest.
api/localization/ → @iveri/contracts/localization
iveri-localization-api's surface, and the clearest case yet for a shared entry point: the two
halves are consumed by different kinds of client and must not drift. TranslationBundle is what a
frontend build pulls and commits; RenderBody/RenderedMessage is what a backend posts when it
needs a string in the recipient's language. Locale, Namespace, Message with its
MessageVariable declarations, Translation, Release and ReleaseDiff carry the authoring
surface the translator panel is written against, with TranslationStatus and VariableType.
Locale is a row rather than an enum on purpose — see the type's own note. Nothing in the
platform branches on which language a string is in, so encoding the set in a type would make
adding one a release of this package and a redeploy of every consumer, for a value no code reads.
api/notification/ → @iveri/contracts/notification
iveri-notification-api's surface: NotificationTemplate and its upsert body, Notification
with its per-channel NotificationDelivery list, SubmitNotificationBody, InAppNotification,
and the NotificationChannel and NotificationDeliveryStatus enums.
Three shapes here are decisions rather than descriptions, and each is pinned by a spec:
- Status lives on the delivery, not the notification. One that emailed successfully and dead-lettered in the inbox has two true answers, and a rolled-up field would have to pick one — hiding a dead letter behind a success, or failing a notification that mostly worked.
- No
pushchannel, because there is no device registry, no per-platform credentials and no token rotation behind one. A channel a caller can name and nothing can deliver looks like a successful submit and reaches nobody. - No
deliveredstatus, because a relay accepting a message is not evidence a person received it and nothing subscribes to a bounce yet.SENTis the strongest honest statement.
A template holds message keys, never text — the wording lives in iveri-localization-api, so
there is one place with ICU validation, a review gate and Georgian, rather than two.
Why the wire shapes moved here
conduit-admin-web hand-transcribed all of it, carefully and with comments. It still drifted:
TenantStatus was written 'active' | 'suspended' | 'cancelled' where identity sends
'ACTIVE' | 'SUSPENDED' | 'PENDING_DELETION' — wrong casing, and a member that has never
existed — and bodySha256 was missing from two shapes. Nothing caught any of it, because a
hand-written mirror is checked by nobody.
Both sides now compile against these types: each service's response DTO implements the
interface it fulfils, so a field renamed in conduit-api fails tsc in conduit-api rather
than surfacing as undefined in a panel weeks later.
What is deliberately not here yet
event/ — the domain event envelope and topic map. No service publishes one yet; it lands with
Conduit's outbox.
Adding to the contract
- A new
ErrorCodeorUserPermissionmember is a minor version. Changing what an existing member means, or removing one, is major — clients branch on these, and a removed permission silently downgrades every live session whose token still carries it. - Version
0.9.0adds the first Commerce permissions:commerce:catalog:readandcommerce:catalog:manage. - Version
0.10.0adds the Unibox AI agent-run entry point andunibox:ai:runpermission. - Version
0.11.0adds safe MCP server metadata, registration request bodies, and theunibox:mcp:read/unibox:mcp:managepermissions. - Version
0.12.0adds the billing plan, subscription, usage and payment permissions. - Version
0.13.0adds the billing invoice read and manage permissions. - Version
0.14.0adds messaging message, OTP and credit permissions. - Version
0.15.0adds optional per-key FIFO ordering to outbound dispatches. - Version
0.18.0adds the localization entry point and thelocalization:read,localization:message:manage,localization:translate,localization:publishandlocalization:renderpermissions. - Widening a type is minor; narrowing it is major.
- Under
api/, the contract follows the service: add the field to the service's DTO and to the interface in the same release, and letimplementsprove they agree. - Breaking changes get a migration note in
CHANGELOG.md. Services upgrade deliberately.
Build output
Dual ESM + CJS (dist/esm, dist/cjs) behind an exports map, so NestJS requires it and
Vite tree-shakes it. sideEffects: false.
The subpath entry points also need a typesVersions block, because TypeScript only reads
exports under moduleResolution node16/nodenext/bundler and every Nest service in the
fleet compiles with "node". Without it @iveri/contracts/conduit resolves at runtime and is
untyped at compile time — in one repo but not another, which is a confusing thing to debug.
scripts/verify-entry-points.mjs runs in build and fails if either half is missing.
